diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07a9317..e3d0601 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,16 +20,16 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version-file: go.mod + go-version: "1.24.x" cache: true - name: Download dependencies run: go mod download - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: - version: v1.64.8 + version: v2.1.6 - name: Run tests run: go test -v ./... diff --git a/.golangci.yml b/.golangci.yml index 35b77c2..89f75ad 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,17 +1,14 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: 2026 The plugin-template Authors +version: "2" + run: timeout: 5m linters: enable: - errcheck - - gosimple - govet - ineffassign - staticcheck - - unused - -issues: - exclude-use-default: false diff --git a/README.md b/README.md index 570e731..0c6666f 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,42 @@ # provider-github -GitHub release provider plugin for SemRel. +GitHub release provider plugin for [SemRel](https://github.com/SemRels/semrel). -Provides GitHub repository, release, and metadata integration for SemRel releases. +Creates GitHub Releases, fetches commit history, and uploads release assets +via the [GitHub REST API](https://docs.github.com/en/rest). -## Documentation +## What It Does -- SemRel docs (planned): -- Plugin template: -- Registry: +| RPC | Description | +|------------------|------------------------------------------------------| +| `GetLastRelease` | Fetches the latest published GitHub Release and tag SHA | +| `GetCommitsSince`| Returns commits between a SHA and HEAD on the release branch | +| `CreateRelease` | Creates a new GitHub Release (supports dry-run) | +| `UploadAsset` | Uploads a release artifact to an existing release | -## Repository Layout +## Configuration (`.semrel.yaml`) -~~~text -cmd/plugin/ Plugin entry point -internal/plugin/ Business logic scaffold -internal/grpc/ gRPC transport scaffold -proto/v1 Symlink to the SemRel protobuf contract -.github/workflows/ CI, release, and security automation -~~~ +```yaml +plugins: + - name: provider-github + type: provider + config: + github_token: ${GITHUB_TOKEN} # optional; falls back to GITHUB_TOKEN/GH_TOKEN env vars +``` + +## Required Permissions + +The `GITHUB_TOKEN` (or `GH_TOKEN`) must have: +- `contents: write` — to create tags and releases ## Development -~~~bash -go build ./cmd/plugin +```bash go test ./... -~~~ - -## Configuration Example +go build ./cmd/plugin +``` -~~~yaml -plugins: - - name: provider-github - type: provider - config: - api_url: https://api.github.com - owner: SemRels - repository: example-repo - token: ${GITHUB_TOKEN} -~~~ +## License -## Status +Apache-2.0 — see [LICENSE](LICENSE). -This repository is bootstrapped from SemRels/plugin-template and is ready for implementation. diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index 518c1f0..e8abd11 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -1,26 +1,23 @@ // SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors +// SPDX-FileCopyrightText: 2026 The SemRels Authors package main import ( - "context" - "log" - "os" - - grpcserver "github.com/SemRels/provider-github/internal/grpc" - semrelplugin "github.com/SemRels/provider-github/internal/plugin" + semrelapi "github.com/SemRels/semrel-api/plugin" + providerplugin "github.com/SemRels/provider-github/internal/plugin" + "github.com/hashicorp/go-plugin" ) func main() { - provider := semrelplugin.NewProvider("provider-github") - server := grpcserver.NewProviderServer(provider) - - if _, err := server.Health(context.Background()); err != nil { - log.Printf("plugin health check failed: %v", err) - os.Exit(1) - } - - log.Printf("%s plugin template is ready", provider.Name()) + plugin.Serve(&plugin.ServeConfig{ + HandshakeConfig: semrelapi.HandshakeConfig, + Plugins: map[string]plugin.Plugin{ + "provider": &semrelapi.ProviderGRPCPlugin{ + Impl: providerplugin.New(), + }, + }, + GRPCServer: plugin.DefaultGRPCServer, + }) } diff --git a/go.mod b/go.mod index 86bff2a..f3ced41 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,30 @@ module github.com/SemRels/provider-github -go 1.24 +go 1.24.0 -toolchain go1.24.0 - -require github.com/stretchr/testify v1.10.0 +require ( + github.com/SemRels/semrel-api v0.1.5 + github.com/google/go-github/v69 v69.2.0 + github.com/hashicorp/go-plugin v1.8.0 + github.com/stretchr/testify v1.10.0 +) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/oklog/run v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - diff --git a/go.sum b/go.sum index 713a0b4..6540acd 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,85 @@ +github.com/SemRels/semrel-api v0.1.5 h1:AryW7TXonDY+YmAtwFRPr9bZgfOb5p/c8pWYW6BoQ0Y= +github.com/SemRels/semrel-api v0.1.5/go.mod h1:mfmpNLpy5zzxbq+1OEs8cmy2Do6rLgQnC615K2RnHow= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE= +github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= +github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/grpc/server.go b/internal/grpc/server.go index a63b055..f77bc3a 100644 --- a/internal/grpc/server.go +++ b/internal/grpc/server.go @@ -1,33 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors +// SPDX-FileCopyrightText: 2026 The SemRels Authors +// Package grpc is kept for backwards compatibility; the plugin transport is +// now handled by hashicorp/go-plugin via semrel-api. This file is intentionally +// empty — transport logic lives in cmd/plugin/main.go. package grpc -import ( - "context" - - semrelplugin "github.com/SemRels/provider-github/internal/plugin" -) - -// HealthResponse is a lightweight stand-in until generated protobuf bindings are wired in. -type HealthResponse struct { - Name string -} - -// ProviderServer adapts a provider implementation for the future gRPC transport layer. -type ProviderServer struct { - provider semrelplugin.Provider -} - -func NewProviderServer(provider semrelplugin.Provider) *ProviderServer { - return &ProviderServer{provider: provider} -} - -func (s *ProviderServer) Health(ctx context.Context) (*HealthResponse, error) { - if err := s.provider.HealthCheck(ctx); err != nil { - return nil, err - } - - return &HealthResponse{Name: s.provider.Name()}, nil -} - diff --git a/internal/plugin/provider.go b/internal/plugin/provider.go deleted file mode 100644 index 7845fca..0000000 --- a/internal/plugin/provider.go +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors - -package plugin - -import "context" - -// Provider defines the minimal contract a SemRel provider plugin should implement. -type Provider interface { - Name() string - HealthCheck(context.Context) error -} - -// ProviderPlugin is a small default implementation that can be extended or replaced. -type ProviderPlugin struct { - name string -} - -func NewProvider(name string) *ProviderPlugin { - if name == "" { - name = "provider-github" - } - - return &ProviderPlugin{name: name} -} - -func (p *ProviderPlugin) Name() string { - return p.name -} - -func (p *ProviderPlugin) HealthCheck(context.Context) error { - return nil -} - diff --git a/internal/plugin/provider_impl.go b/internal/plugin/provider_impl.go new file mode 100644 index 0000000..a668508 --- /dev/null +++ b/internal/plugin/provider_impl.go @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The SemRels Authors + +// Package plugin implements the ProviderPlugin for GitHub using the GitHub REST API. +package plugin + +import ( + "context" + "fmt" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/go-github/v69/github" + semrelv1 "github.com/SemRels/semrel-api/api/gen/v1" +) + +// Provider implements semrelv1.ProviderPluginServer backed by the GitHub REST API. +type Provider struct { + semrelv1.UnimplementedProviderPluginServer + + // newClient creates a GitHub client; injectable for testing. + newClient func(token string) *github.Client +} + +// New returns a Provider that creates real GitHub API clients. +func New() *Provider { + return &Provider{newClient: defaultClient} +} + +// NewWithClient returns a Provider with an injected client factory (for tests). +func NewWithClient(factory func(string) *github.Client) *Provider { + return &Provider{newClient: factory} +} + +func defaultClient(token string) *github.Client { + return github.NewClient(nil).WithAuthToken(token) +} + +func tokenFromCtx(ctx *semrelv1.ReleaseContext) string { + if ctx != nil { + if t := ctx.GetConfig()["github_token"]; t != "" { + return t + } + } + if t := os.Getenv("GITHUB_TOKEN"); t != "" { + return t + } + return os.Getenv("GH_TOKEN") +} + +// GetLastRelease returns the latest published GitHub Release tag and its SHA. +func (p *Provider) GetLastRelease(ctx context.Context, req *semrelv1.GetLastReleaseRequest) (*semrelv1.GetLastReleaseResponse, error) { + rctx := req.GetCtx() + client := p.newClient(tokenFromCtx(rctx)) + + release, resp, err := client.Repositories.GetLatestRelease(ctx, rctx.GetRepoOwner(), rctx.GetRepoName()) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + // No releases yet — return empty version + return &semrelv1.GetLastReleaseResponse{}, nil + } + return nil, fmt.Errorf("GetLatestRelease: %w", err) + } + + tagName := release.GetTagName() + ver, err := parseVersion(tagName) + if err != nil { + return &semrelv1.GetLastReleaseResponse{}, nil + } + + // Resolve the tag to a commit SHA + ref, _, err := client.Git.GetRef(ctx, rctx.GetRepoOwner(), rctx.GetRepoName(), "tags/"+tagName) + tagSHA := "" + if err == nil { + tagSHA = ref.GetObject().GetSHA() + } + + return &semrelv1.GetLastReleaseResponse{ + Version: ver, + TagSha: tagSHA, + }, nil +} + +// GetCommitsSince returns all commits between sinceSHA and HEAD on ctx.Branch. +func (p *Provider) GetCommitsSince(ctx context.Context, req *semrelv1.GetCommitsSinceRequest) (*semrelv1.GetCommitsSinceResponse, error) { + rctx := req.GetCtx() + client := p.newClient(tokenFromCtx(rctx)) + + opts := &github.CommitsListOptions{ + SHA: rctx.GetBranch(), + ListOptions: github.ListOptions{PerPage: 250}, + } + // GitHub's ListCommits supports "since" as a timestamp only, not a SHA. + // sinceSHA filtering is handled below: we stop when we encounter the known SHA. + + var allCommits []*semrelv1.Commit + for { + commits, resp, err := client.Repositories.ListCommits(ctx, rctx.GetRepoOwner(), rctx.GetRepoName(), opts) + if err != nil { + return nil, fmt.Errorf("ListCommits: %w", err) + } + + for _, c := range commits { + if c.GetSHA() == req.GetSinceSha() { + goto done + } + allCommits = append(allCommits, ghCommitToProto(c)) + } + + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } +done: + + return &semrelv1.GetCommitsSinceResponse{Commits: allCommits}, nil +} + +// CreateRelease creates a GitHub Release with the provided changelog as body. +func (p *Provider) CreateRelease(ctx context.Context, req *semrelv1.CreateReleaseRequest) (*semrelv1.CreateReleaseResponse, error) { + rctx := req.GetCtx() + client := p.newClient(tokenFromCtx(rctx)) + + ver := rctx.GetNextVersion() + tagName := fmt.Sprintf("v%d.%d.%d", ver.GetMajor(), ver.GetMinor(), ver.GetPatch()) + if pre := ver.GetPreRelease(); pre != "" { + tagName += "-" + pre + } + + releaseReq := &github.RepositoryRelease{ + TagName: github.Ptr(tagName), + TargetCommitish: github.Ptr(rctx.GetBranch()), + Name: github.Ptr(tagName), + Body: github.Ptr(req.GetChangelog()), + Draft: github.Ptr(false), + Prerelease: github.Ptr(ver.GetPreRelease() != ""), + } + + if rctx.GetDryRun() { + return &semrelv1.CreateReleaseResponse{ + ReleaseUrl: fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s (dry-run)", + rctx.GetRepoOwner(), rctx.GetRepoName(), tagName), + ReleaseId: "dry-run", + }, nil + } + + release, _, err := client.Repositories.CreateRelease(ctx, rctx.GetRepoOwner(), rctx.GetRepoName(), releaseReq) + if err != nil { + return nil, fmt.Errorf("CreateRelease: %w", err) + } + + return &semrelv1.CreateReleaseResponse{ + ReleaseUrl: release.GetHTMLURL(), + ReleaseId: fmt.Sprintf("%d", release.GetID()), + }, nil +} + +// UploadAsset uploads a file to an existing GitHub Release. +func (p *Provider) UploadAsset(ctx context.Context, req *semrelv1.UploadAssetRequest) (*semrelv1.UploadAssetResponse, error) { + rctx := req.GetCtx() + client := p.newClient(tokenFromCtx(rctx)) + + releaseID := int64(0) + if _, err := fmt.Sscanf(req.GetReleaseId(), "%d", &releaseID); err != nil { + return nil, fmt.Errorf("invalid release_id %q: %w", req.GetReleaseId(), err) + } + + f, err := os.Open(req.GetAssetPath()) + if err != nil { + return nil, fmt.Errorf("open asset %q: %w", req.GetAssetPath(), err) + } + defer func() { _ = f.Close() }() + + contentType := req.GetContentType() + if contentType == "" { + contentType = mime.TypeByExtension(filepath.Ext(req.GetAssetName())) + } + if contentType == "" { + contentType = "application/octet-stream" + } + + opts := &github.UploadOptions{Name: req.GetAssetName(), MediaType: contentType} + asset, _, err := client.Repositories.UploadReleaseAsset(ctx, rctx.GetRepoOwner(), rctx.GetRepoName(), releaseID, opts, f) + if err != nil { + return nil, fmt.Errorf("UploadReleaseAsset: %w", err) + } + + return &semrelv1.UploadAssetResponse{AssetUrl: asset.GetBrowserDownloadURL()}, nil +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func parseVersion(tag string) (*semrelv1.SemanticVersion, error) { + tag = strings.TrimPrefix(tag, "v") + var major, minor, patch uint32 + if _, err := fmt.Sscanf(tag, "%d.%d.%d", &major, &minor, &patch); err != nil { + return nil, fmt.Errorf("parse tag %q: %w", tag, err) + } + return &semrelv1.SemanticVersion{Major: major, Minor: minor, Patch: patch}, nil +} + +func ghCommitToProto(c *github.RepositoryCommit) *semrelv1.Commit { + proto := &semrelv1.Commit{ + Sha: c.GetSHA(), + } + if c.Commit != nil { + proto.RawMessage = c.Commit.GetMessage() + if c.Commit.Author != nil { + proto.AuthorName = c.Commit.Author.GetName() + proto.AuthorEmail = c.Commit.Author.GetEmail() + if c.Commit.Author.Date != nil { + proto.Timestamp = c.Commit.Author.Date.GetTime().Unix() + } + } + } + _ = time.Now // suppress unused import if needed + return proto +} diff --git a/internal/plugin/provider_impl_test.go b/internal/plugin/provider_impl_test.go new file mode 100644 index 0000000..b2388bb --- /dev/null +++ b/internal/plugin/provider_impl_test.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The SemRels Authors + +package plugin + +import ( + "fmt" + "net/http" + "testing" + + "github.com/google/go-github/v69/github" + semrelv1 "github.com/SemRels/semrel-api/api/gen/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http/httptest" + "encoding/json" +) + +func newTestServer(t *testing.T, mux *http.ServeMux) (*httptest.Server, *github.Client) { + t.Helper() + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + client, err := github.NewClient(nil).WithEnterpriseURLs(ts.URL+"/", ts.URL+"/") + require.NoError(t, err) + return ts, client +} + +func testProvider(client *github.Client) *Provider { + return NewWithClient(func(_ string) *github.Client { return client }) +} + +func ctx() *semrelv1.ReleaseContext { + return &semrelv1.ReleaseContext{ + RepoOwner: "SemRels", + RepoName: "myrepo", + Branch: "main", + } +} + +func TestGetLastRelease_NoRelease(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/api/v3/repos/SemRels/myrepo/releases/latest", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) //nolint:errcheck + }) + + _, client := newTestServer(t, mux) + p := testProvider(client) + + resp, err := p.GetLastRelease(t.Context(), &semrelv1.GetLastReleaseRequest{Ctx: ctx()}) + require.NoError(t, err) + assert.Nil(t, resp.GetVersion()) +} + +func TestGetLastRelease_WithRelease(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/api/v3/repos/SemRels/myrepo/releases/latest", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "tag_name": "v1.2.3", + "html_url": "https://github.com/SemRels/myrepo/releases/tag/v1.2.3", + }) + }) + mux.HandleFunc("/api/v3/repos/SemRels/myrepo/git/ref/tags/v1.2.3", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "ref": "refs/tags/v1.2.3", + "object": map[string]string{"sha": "abc123", "type": "commit"}, + }) + }) + + _, client := newTestServer(t, mux) + p := testProvider(client) + + resp, err := p.GetLastRelease(t.Context(), &semrelv1.GetLastReleaseRequest{Ctx: ctx()}) + require.NoError(t, err) + require.NotNil(t, resp.GetVersion()) + assert.Equal(t, uint32(1), resp.GetVersion().GetMajor()) + assert.Equal(t, uint32(2), resp.GetVersion().GetMinor()) + assert.Equal(t, uint32(3), resp.GetVersion().GetPatch()) + assert.Equal(t, "abc123", resp.GetTagSha()) +} + +func TestCreateRelease_DryRun(t *testing.T) { + t.Parallel() + + p := NewWithClient(func(_ string) *github.Client { return github.NewClient(nil) }) + + rctx := ctx() + rctx.DryRun = true + rctx.NextVersion = &semrelv1.SemanticVersion{Major: 2, Minor: 0, Patch: 0} + + resp, err := p.CreateRelease(t.Context(), &semrelv1.CreateReleaseRequest{ + Ctx: rctx, + Changelog: "## Changes\n- feat: something", + }) + require.NoError(t, err) + assert.Contains(t, resp.GetReleaseUrl(), "dry-run") + assert.Equal(t, "dry-run", resp.GetReleaseId()) +} + +func TestParseVersion(t *testing.T) { + t.Parallel() + + cases := []struct { + tag string + major uint32 + minor uint32 + patch uint32 + }{ + {"v1.2.3", 1, 2, 3}, + {"1.0.0", 1, 0, 0}, + {"v0.10.99", 0, 10, 99}, + } + + for _, tc := range cases { + t.Run(tc.tag, func(t *testing.T) { + t.Parallel() + ver, err := parseVersion(tc.tag) + require.NoError(t, err) + assert.Equal(t, tc.major, ver.GetMajor()) + assert.Equal(t, tc.minor, ver.GetMinor()) + assert.Equal(t, tc.patch, ver.GetPatch()) + }) + } +} diff --git a/internal/plugin/provider_test.go b/internal/plugin/provider_test.go deleted file mode 100644 index 7f1bb94..0000000 --- a/internal/plugin/provider_test.go +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors - -package plugin - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestNewProviderDefaultsName(t *testing.T) { - t.Parallel() - - provider := NewProvider("") - - require.Equal(t, "provider-github", provider.Name()) - require.NoError(t, provider.HealthCheck(context.Background())) -} - -func TestNewProviderUsesProvidedName(t *testing.T) { - t.Parallel() - - provider := NewProvider("provider-example") - - require.Equal(t, "provider-example", provider.Name()) -} -