diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index cddf3fb..127fdc2 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -1,25 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors +// SPDX-FileCopyrightText: 2026 The semrel Authors package main import ( - "context" "log" - "os" - grpcserver "github.com/SemRels/provider-gitlab/internal/grpc" - semrelplugin "github.com/SemRels/provider-gitlab/internal/plugin" + plugin "github.com/SemRels/provider-gitlab/internal/plugin" ) func main() { - provider := semrelplugin.NewProvider("provider-gitlab") - 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()) + client := plugin.NewClient(plugin.Config{}) + log.Printf("provider-gitlab plugin ready: creates GitLab releases (%T)", client) } diff --git a/go.mod b/go.mod index a4e7e6e..25decd7 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,3 @@ module github.com/SemRels/provider-gitlab go 1.24 toolchain go1.24.0 - -require github.com/stretchr/testify v1.10.0 - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/go.sum b/go.sum index 713a0b4..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +0,0 @@ -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/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/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -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 490a979..0000000 --- a/internal/grpc/server.go +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors - -package grpc - -import ( - "context" - - semrelplugin "github.com/SemRels/provider-gitlab/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 a31625e..0000000 --- a/internal/plugin/provider.go +++ /dev/null @@ -1,33 +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-gitlab" - } - - 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_test.go b/internal/plugin/provider_test.go deleted file mode 100644 index c0105fc..0000000 --- a/internal/plugin/provider_test.go +++ /dev/null @@ -1,28 +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-gitlab", 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()) -} diff --git a/internal/plugin/releases.go b/internal/plugin/releases.go new file mode 100644 index 0000000..876c165 --- /dev/null +++ b/internal/plugin/releases.go @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The semrel Authors + +// Package plugin provides a GitLab Releases publisher plugin. +// It creates releases, uploads release assets (generic packages), and attaches +// links to the GitLab Releases API. +package plugin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +const defaultTimeout = 30 * time.Second +const defaultBaseURL = "https://gitlab.com" + +// Client interacts with the GitLab Releases API. +type Client struct { + baseURL string + token string + projectID string + httpClient *http.Client +} + +// Config holds the configuration for the GitLab client. +type Config struct { + // BaseURL is the GitLab instance URL (defaults to https://gitlab.com). + BaseURL string + // Token is a GitLab personal access token or project access token. + Token string + // ProjectID is the numeric project ID or URL-encoded project path + // (e.g., "42" or "mygroup%2Fmyproject"). + ProjectID 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, + projectID: url.PathEscape(cfg.ProjectID), + httpClient: &http.Client{Timeout: t}, + } +} + +// Release represents a GitLab release. +type Release struct { + Name string `json:"name"` + TagName string `json:"tag_name"` + Description string `json:"description"` + ReleasedAt time.Time `json:"released_at,omitempty"` +} + +// CreateReleaseRequest is the payload for creating a release. +type CreateReleaseRequest struct { + Name string `json:"name"` + TagName string `json:"tag_name"` + Description string `json:"description"` +} + +// ReleaseLink represents a link attachment for a release. +type ReleaseLink struct { + Name string `json:"name"` + URL string `json:"url"` + LinkType string `json:"link_type,omitempty"` // "runbook", "package", "image", "other" +} + +// CreateRelease creates a new GitLab release for the given tag. +func (c *Client) CreateRelease(ctx context.Context, req CreateReleaseRequest) (*Release, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("gitlab: marshal create release: %w", err) + } + + apiURL := fmt.Sprintf("%s/api/v4/projects/%s/releases", c.baseURL, c.projectID) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("gitlab: create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("PRIVATE-TOKEN", c.token) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("gitlab: 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("gitlab: 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("gitlab: decode release: %w", err) + } + return &rel, nil +} + +// AddReleaseLink attaches a link to an existing release. +func (c *Client) AddReleaseLink(ctx context.Context, tagName string, link ReleaseLink) error { + body, err := json.Marshal(link) + if err != nil { + return fmt.Errorf("gitlab: marshal link: %w", err) + } + + apiURL := fmt.Sprintf("%s/api/v4/projects/%s/releases/%s/assets/links", + c.baseURL, c.projectID, url.PathEscape(tagName)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("gitlab: create link request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("PRIVATE-TOKEN", c.token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("gitlab: add release link: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("gitlab: add link: status %d: %s", resp.StatusCode, respBody) + } + return nil +} + +// UploadPackageFile uploads a file to the GitLab Generic Packages registry and +// returns the download URL suitable for use as a release asset link. +func (c *Client) UploadPackageFile(ctx context.Context, packageName, version, filePath string) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("gitlab: open file: %w", err) + } + defer f.Close() + + fileName := filepath.Base(filePath) + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + fw, err := w.CreateFormFile("file", fileName) + if err != nil { + return "", fmt.Errorf("gitlab: create form file: %w", err) + } + if _, err := io.Copy(fw, f); err != nil { + return "", fmt.Errorf("gitlab: copy file: %w", err) + } + w.Close() + + apiURL := fmt.Sprintf("%s/api/v4/projects/%s/packages/generic/%s/%s/%s", + c.baseURL, c.projectID, + url.PathEscape(packageName), + url.PathEscape(version), + url.PathEscape(fileName)) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, apiURL, &buf) + if err != nil { + return "", fmt.Errorf("gitlab: create upload request: %w", err) + } + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("PRIVATE-TOKEN", c.token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("gitlab: upload package file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return "", fmt.Errorf("gitlab: upload: status %d: %s", resp.StatusCode, respBody) + } + return apiURL, nil +} diff --git a/internal/plugin/releases_test.go b/internal/plugin/releases_test.go new file mode 100644 index 0000000..c729472 --- /dev/null +++ b/internal/plugin/releases_test.go @@ -0,0 +1,147 @@ +// 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" + + gitlab "github.com/SemRels/provider-gitlab/internal/plugin" +) + +func newTestClient(t *testing.T, srv *httptest.Server) *gitlab.Client { + t.Helper() + return gitlab.NewClient(gitlab.Config{ + BaseURL: srv.URL, + Token: "test-token", + ProjectID: "42", + }) +} + +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("PRIVATE-TOKEN") != "test-token" { + t.Error("expected PRIVATE-TOKEN header") + } + rel := map[string]string{ + "name": "Release v1.2.3", + "tag_name": "v1.2.3", + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(rel) + })) + defer srv.Close() + + c := newTestClient(t, srv) + rel, err := c.CreateRelease(context.Background(), gitlab.CreateReleaseRequest{ + Name: "Release v1.2.3", + TagName: "v1.2.3", + Description: "## Changelog\n- feature A", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rel.TagName != "v1.2.3" { + t.Errorf("expected tag_name 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":"tag not found"}`)) + })) + defer srv.Close() + + c := newTestClient(t, srv) + _, err := c.CreateRelease(context.Background(), gitlab.CreateReleaseRequest{ + Name: "v9.9.9", + TagName: "v9.9.9", + }) + if err == nil { + t.Fatal("expected error for 422 response") + } +} + +func TestAddReleaseLink_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/links") { + t.Errorf("expected /links path, got %s", r.URL.Path) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"name": "myapp"}) + })) + defer srv.Close() + + c := newTestClient(t, srv) + err := c.AddReleaseLink(context.Background(), "v1.2.3", gitlab.ReleaseLink{ + Name: "myapp-linux-amd64.tar.gz", + URL: "https://example.com/myapp-v1.2.3-linux-amd64.tar.gz", + LinkType: "package", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAddReleaseLink_Error(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.AddReleaseLink(context.Background(), "v9.9.9", gitlab.ReleaseLink{ + Name: "asset", + URL: "https://example.com/asset", + }) + if err == nil { + t.Error("expected error for 404 response") + } +} + +func TestUploadPackageFile_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if !strings.Contains(r.URL.Path, "myapp") { + t.Errorf("expected package name in path, got %s", r.URL.Path) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"message": "201 Created"}) + })) + defer srv.Close() + + // Create a temp file to upload + dir := t.TempDir() + filePath := filepath.Join(dir, "myapp-v1.0.0-linux-amd64.tar.gz") + os.WriteFile(filePath, []byte("fake archive content"), 0o644) + + c := newTestClient(t, srv) + downloadURL, err := c.UploadPackageFile(context.Background(), "myapp", "v1.0.0", filePath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if downloadURL == "" { + t.Error("expected non-empty download URL") + } +} + +func TestNewClient_Defaults(t *testing.T) { + c := gitlab.NewClient(gitlab.Config{ + Token: "tok", + ProjectID: "mygroup/myproject", + }) + _ = c +}