Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 4 additions & 14 deletions cmd/plugin/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 0 additions & 8 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
10 changes: 0 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
32 changes: 0 additions & 32 deletions internal/grpc/server.go

This file was deleted.

33 changes: 0 additions & 33 deletions internal/plugin/provider.go

This file was deleted.

28 changes: 0 additions & 28 deletions internal/plugin/provider_test.go

This file was deleted.

195 changes: 195 additions & 0 deletions internal/plugin/releases.go
Original file line number Diff line number Diff line change
@@ -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()

Check failure on line 107 in internal/plugin/releases.go

View workflow job for this annotation

GitHub Actions / Build, test, lint, and scan

Error return value of `resp.Body.Close` is not checked (errcheck)

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()

Check failure on line 141 in internal/plugin/releases.go

View workflow job for this annotation

GitHub Actions / Build, test, lint, and scan

Error return value of `resp.Body.Close` is not checked (errcheck)

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()

Check failure on line 157 in internal/plugin/releases.go

View workflow job for this annotation

GitHub Actions / Build, test, lint, and scan

Error return value of `f.Close` is not checked (errcheck)

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()

Check failure on line 169 in internal/plugin/releases.go

View workflow job for this annotation

GitHub Actions / Build, test, lint, and scan

Error return value of `w.Close` is not checked (errcheck)

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()

Check failure on line 188 in internal/plugin/releases.go

View workflow job for this annotation

GitHub Actions / Build, test, lint, and scan

Error return value of `resp.Body.Close` is not checked (errcheck)

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
}
Loading
Loading