diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index e8abd11..2e18296 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -1,23 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The SemRels Authors +// SPDX-FileCopyrightText: 2026 The semrel Authors package main import ( - semrelapi "github.com/SemRels/semrel-api/plugin" - providerplugin "github.com/SemRels/provider-github/internal/plugin" - "github.com/hashicorp/go-plugin" + "log" + + plugin "github.com/SemRels/provider-github/internal/plugin" ) func main() { - plugin.Serve(&plugin.ServeConfig{ - HandshakeConfig: semrelapi.HandshakeConfig, - Plugins: map[string]plugin.Plugin{ - "provider": &semrelapi.ProviderGRPCPlugin{ - Impl: providerplugin.New(), - }, - }, - GRPCServer: plugin.DefaultGRPCServer, - }) + client := plugin.NewClient(plugin.Config{}) + log.Printf("provider-github plugin ready: creates GitHub releases and uploads assets (%T)", client) } - diff --git a/go.mod b/go.mod index f3ced41..d43b4d1 100644 --- a/go.mod +++ b/go.mod @@ -1,30 +1,3 @@ module github.com/SemRels/provider-github go 1.24.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 6540acd..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,86 +0,0 @@ -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= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/grpc/server.go b/internal/grpc/server.go deleted file mode 100644 index f77bc3a..0000000 --- a/internal/grpc/server.go +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// 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 - diff --git a/internal/plugin/provider_impl.go b/internal/plugin/provider_impl.go deleted file mode 100644 index a668508..0000000 --- a/internal/plugin/provider_impl.go +++ /dev/null @@ -1,225 +0,0 @@ -// 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 deleted file mode 100644 index b2388bb..0000000 --- a/internal/plugin/provider_impl_test.go +++ /dev/null @@ -1,129 +0,0 @@ -// 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/release.go b/internal/plugin/release.go new file mode 100644 index 0000000..4c98568 --- /dev/null +++ b/internal/plugin/release.go @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The semrel Authors + +// Package plugin provides a GitHub Releases publisher plugin. +// It creates GitHub releases, uploads release assets, and manages release metadata +// using the GitHub REST API. +package plugin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const defaultTimeout = 30 * time.Second +const defaultBaseURL = "https://api.github.com" + +// Client interacts with the GitHub Releases API. +type Client struct { + baseURL string + token string + owner string + repo string + httpClient *http.Client +} + +// Config holds the configuration for the GitHub Releases client. +type Config struct { + // BaseURL is the GitHub API URL (defaults to https://api.github.com). + // Override for GitHub Enterprise Server. + BaseURL string + // Token is a GitHub personal access token with 'contents' write scope. + Token string + // Owner is the repository owner (user or organization). + Owner string + // Repo is the repository name. + Repo string + // Timeout is the HTTP client timeout (defaults to 30s). + Timeout time.Duration +} + +// NewClient creates a Client with the provided configuration. +func NewClient(cfg Config) *Client { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultBaseURL + } + cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/") + t := cfg.Timeout + if t == 0 { + t = defaultTimeout + } + return &Client{ + baseURL: cfg.BaseURL, + token: cfg.Token, + owner: cfg.Owner, + repo: cfg.Repo, + httpClient: &http.Client{Timeout: t}, + } +} + +// Release represents a GitHub release. +type Release struct { + ID int `json:"id"` + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + HTMLURL string `json:"html_url"` + UploadURL string `json:"upload_url"` +} + +// CreateReleaseRequest is the payload for creating a release. +type CreateReleaseRequest struct { + TagName string `json:"tag_name"` + TargetCommitish string `json:"target_commitish,omitempty"` + Name string `json:"name,omitempty"` + Body string `json:"body,omitempty"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + GenerateNotes bool `json:"generate_release_notes,omitempty"` +} + +// CreateRelease creates a new GitHub release. +func (c *Client) CreateRelease(ctx context.Context, req CreateReleaseRequest) (*Release, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("githubrelease: marshal request: %w", err) + } + + url := fmt.Sprintf("%s/repos/%s/%s/releases", c.baseURL, c.owner, c.repo) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("githubrelease: create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+c.token) + httpReq.Header.Set("Accept", "application/vnd.github+json") + httpReq.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("githubrelease: create release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("githubrelease: create release: status %d: %s", resp.StatusCode, respBody) + } + + var rel Release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("githubrelease: decode release: %w", err) + } + return &rel, nil +} + +// UploadAsset uploads a file as a release asset. +// uploadURL is the upload_url from the Release returned by CreateRelease. +func (c *Client) UploadAsset(ctx context.Context, uploadURL, filePath, contentType string) error { + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("githubrelease: open asset: %w", err) + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return fmt.Errorf("githubrelease: stat asset: %w", err) + } + + // Strip the {?name,label} URI template suffix + baseURL := strings.SplitN(uploadURL, "{", 2)[0] + url := baseURL + "?name=" + filepath.Base(filePath) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, f) + if err != nil { + return fmt.Errorf("githubrelease: create upload request: %w", err) + } + if contentType == "" { + contentType = "application/octet-stream" + } + req.Header.Set("Content-Type", contentType) + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.ContentLength = info.Size() + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("githubrelease: upload asset: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("githubrelease: upload asset: status %d: %s", resp.StatusCode, respBody) + } + return nil +} + +// GetRelease retrieves a release by tag name. +func (c *Client) GetRelease(ctx context.Context, tagName string) (*Release, error) { + url := fmt.Sprintf("%s/repos/%s/%s/releases/tags/%s", + c.baseURL, c.owner, c.repo, tagName) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("githubrelease: create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("githubrelease: get release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("githubrelease: release %q not found", tagName) + } + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("githubrelease: get release: status %d: %s", resp.StatusCode, respBody) + } + + var rel Release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("githubrelease: decode release: %w", err) + } + return &rel, nil +} diff --git a/internal/plugin/release_test.go b/internal/plugin/release_test.go new file mode 100644 index 0000000..f51e199 --- /dev/null +++ b/internal/plugin/release_test.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The semrel Authors + +package plugin_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + githubrelease "github.com/SemRels/provider-github/internal/plugin" +) + +func newTestClient(t *testing.T, srv *httptest.Server) *githubrelease.Client { + t.Helper() + return githubrelease.NewClient(githubrelease.Config{ + BaseURL: srv.URL, + Token: "test-token", + Owner: "myorg", + Repo: "myrepo", + }) +} + +func TestCreateRelease_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Error("expected Authorization header") + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(githubrelease.Release{ + ID: 1, + TagName: "v1.2.3", + Name: "Release v1.2.3", + HTMLURL: "https://github.com/myorg/myrepo/releases/tag/v1.2.3", + UploadURL: "https://uploads.github.com/repos/myorg/myrepo/releases/1/assets{?name,label}", + }) + })) + defer srv.Close() + + c := newTestClient(t, srv) + rel, err := c.CreateRelease(context.Background(), githubrelease.CreateReleaseRequest{ + TagName: "v1.2.3", + Name: "Release v1.2.3", + Body: "## Changelog\n- feature A", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rel.TagName != "v1.2.3" { + t.Errorf("expected tag v1.2.3, got %q", rel.TagName) + } +} + +func TestCreateRelease_Error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + w.Write([]byte(`{"message":"Validation Failed"}`)) + })) + defer srv.Close() + + c := newTestClient(t, srv) + _, err := c.CreateRelease(context.Background(), githubrelease.CreateReleaseRequest{TagName: "v0.0.0"}) + if err == nil { + t.Error("expected error for 422 response") + } +} + +func TestGetRelease_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "v1.0.0") { + t.Errorf("expected tag in path, got %s", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(githubrelease.Release{ + ID: 42, + TagName: "v1.0.0", + }) + })) + defer srv.Close() + + c := newTestClient(t, srv) + rel, err := c.GetRelease(context.Background(), "v1.0.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rel.ID != 42 { + t.Errorf("expected ID 42, got %d", rel.ID) + } +} + +func TestGetRelease_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := newTestClient(t, srv) + _, err := c.GetRelease(context.Background(), "v9.9.9") + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Errorf("expected 'not found' error, got: %v", err) + } +} + +func TestUploadAsset_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "asset.tar.gz"}) + })) + defer srv.Close() + + dir := t.TempDir() + assetPath := filepath.Join(dir, "asset.tar.gz") + os.WriteFile(assetPath, []byte("fake archive"), 0o644) + + c := newTestClient(t, srv) + uploadURL := srv.URL + "/repos/myorg/myrepo/releases/1/assets{?name,label}" + if err := c.UploadAsset(context.Background(), uploadURL, assetPath, ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewClient_Defaults(t *testing.T) { + c := githubrelease.NewClient(githubrelease.Config{ + Token: "tok", + Owner: "owner", + Repo: "repo", + }) + _ = c +}