Skip to content

Commit 39608de

Browse files
intel352claude
andcommitted
feat: initial implementation of workflow-plugin-gitlab
GitLab integration external plugin providing: - git.webhook module: receives GitLab webhooks (push, tag_push, merge_request, pipeline), validates X-Gitlab-Token, normalizes events to common GitEvent schema, publishes to configurable topic - step.gitlab_trigger_pipeline: trigger GitLab CI pipeline via REST API v4 - step.gitlab_pipeline_status: check pipeline status - step.gitlab_create_merge_request: create merge request - step.gitlab_mr_comment: comment on merge request - Mock client for testing without a real GitLab instance - GoReleaser v2 config for cross-platform releases Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
0 parents  commit 39608de

File tree

13 files changed

+2202
-0
lines changed

13 files changed

+2202
-0
lines changed

.github/workflows/release.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
goreleaser:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
with:
17+
fetch-depth: 0
18+
19+
- uses: actions/setup-go@v5
20+
with:
21+
go-version-file: go.mod
22+
23+
- name: Run GoReleaser
24+
uses: goreleaser/goreleaser-action@v6
25+
with:
26+
distribution: goreleaser
27+
version: "~> v2"
28+
args: release --clean
29+
env:
30+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31+
GOPRIVATE: github.com/GoCodeAlone/*
32+
GONOSUMCHECK: github.com/GoCodeAlone/*

.goreleaser.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
version: 2
2+
3+
project_name: workflow-plugin-gitlab
4+
5+
before:
6+
hooks:
7+
- go mod tidy
8+
9+
builds:
10+
- id: workflow-plugin-gitlab
11+
main: ./cmd/workflow-plugin-gitlab
12+
binary: workflow-plugin-gitlab
13+
env:
14+
- CGO_ENABLED=0
15+
- GOPRIVATE=github.com/GoCodeAlone/*
16+
goos:
17+
- linux
18+
- darwin
19+
- windows
20+
goarch:
21+
- amd64
22+
- arm64
23+
24+
archives:
25+
- id: workflow-plugin-gitlab
26+
builds:
27+
- workflow-plugin-gitlab
28+
name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}"
29+
files:
30+
- plugin.json
31+
32+
checksum:
33+
name_template: "checksums.txt"
34+
35+
release:
36+
github:
37+
owner: GoCodeAlone
38+
name: workflow-plugin-gitlab

Makefile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.PHONY: build test install clean
2+
3+
BINARY_NAME = workflow-plugin-gitlab
4+
INSTALL_DIR ?= data/plugins/$(BINARY_NAME)
5+
6+
build:
7+
GOPRIVATE=github.com/GoCodeAlone/* go build -o bin/$(BINARY_NAME) ./cmd/$(BINARY_NAME)
8+
9+
test:
10+
GOPRIVATE=github.com/GoCodeAlone/* go test ./... -v -race
11+
12+
install: build
13+
mkdir -p $(DESTDIR)/$(INSTALL_DIR)
14+
cp bin/$(BINARY_NAME) $(DESTDIR)/$(INSTALL_DIR)/
15+
cp plugin.json $(DESTDIR)/$(INSTALL_DIR)/
16+
17+
clean:
18+
rm -rf bin/

cmd/workflow-plugin-gitlab/main.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Command workflow-plugin-gitlab is a workflow engine external plugin that
2+
// provides GitLab integration: webhook handling and GitLab CI pipeline management.
3+
// It runs as a subprocess and communicates with the host workflow engine via
4+
// the go-plugin protocol.
5+
package main
6+
7+
import (
8+
"github.com/GoCodeAlone/workflow-plugin-gitlab/internal"
9+
sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk"
10+
)
11+
12+
func main() {
13+
sdk.Serve(internal.NewGitLabPlugin())
14+
}

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module github.com/GoCodeAlone/workflow-plugin-gitlab
2+
3+
go 1.26
4+
5+
require github.com/GoCodeAlone/workflow v0.2.2

go.sum

Lines changed: 545 additions & 0 deletions
Large diffs are not rendered by default.

internal/client.go

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package internal
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"strings"
11+
"time"
12+
)
13+
14+
// GitLabClient is the interface for interacting with the GitLab REST API v4.
15+
// It is defined as an interface so tests can inject a mock.
16+
type GitLabClient interface {
17+
TriggerPipeline(ctx context.Context, projectID, ref string, variables map[string]string, token string) (*Pipeline, error)
18+
GetPipeline(ctx context.Context, projectID string, pipelineID int, token string) (*Pipeline, error)
19+
CreateMergeRequest(ctx context.Context, projectID string, opts MergeRequestOptions, token string) (*MergeRequest, error)
20+
CommentOnMR(ctx context.Context, projectID string, mrIID int, body, token string) error
21+
}
22+
23+
// Pipeline represents a GitLab CI pipeline.
24+
type Pipeline struct {
25+
ID int `json:"id"`
26+
Status string `json:"status"`
27+
Ref string `json:"ref"`
28+
SHA string `json:"sha"`
29+
WebURL string `json:"web_url"`
30+
CreatedAt string `json:"created_at"`
31+
}
32+
33+
// MergeRequest represents a GitLab merge request.
34+
type MergeRequest struct {
35+
ID int `json:"id"`
36+
IID int `json:"iid"`
37+
Title string `json:"title"`
38+
State string `json:"state"`
39+
SourceBranch string `json:"source_branch"`
40+
TargetBranch string `json:"target_branch"`
41+
WebURL string `json:"web_url"`
42+
}
43+
44+
// MergeRequestOptions holds parameters for creating a merge request.
45+
type MergeRequestOptions struct {
46+
SourceBranch string
47+
TargetBranch string
48+
Title string
49+
Description string
50+
}
51+
52+
// httpGitLabClient implements GitLabClient using net/http.
53+
type httpGitLabClient struct {
54+
baseURL string
55+
httpClient *http.Client
56+
}
57+
58+
// newHTTPGitLabClient returns a production GitLab API client.
59+
func newHTTPGitLabClient(baseURL string) GitLabClient {
60+
if baseURL == "" {
61+
baseURL = "https://gitlab.com"
62+
}
63+
return &httpGitLabClient{
64+
baseURL: strings.TrimRight(baseURL, "/"),
65+
httpClient: &http.Client{
66+
Timeout: 30 * time.Second,
67+
},
68+
}
69+
}
70+
71+
// doRequest performs an authenticated request to the GitLab API v4.
72+
func (c *httpGitLabClient) doRequest(ctx context.Context, method, path string, body any, token string) ([]byte, int, error) {
73+
var bodyReader io.Reader
74+
if body != nil {
75+
data, err := json.Marshal(body)
76+
if err != nil {
77+
return nil, 0, fmt.Errorf("marshal request body: %w", err)
78+
}
79+
bodyReader = bytes.NewReader(data)
80+
}
81+
82+
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
83+
if err != nil {
84+
return nil, 0, fmt.Errorf("create request: %w", err)
85+
}
86+
87+
req.Header.Set("PRIVATE-TOKEN", token)
88+
if body != nil {
89+
req.Header.Set("Content-Type", "application/json")
90+
}
91+
92+
resp, err := c.httpClient.Do(req)
93+
if err != nil {
94+
return nil, 0, fmt.Errorf("execute request: %w", err)
95+
}
96+
defer resp.Body.Close()
97+
98+
respBody, err := io.ReadAll(resp.Body)
99+
if err != nil {
100+
return nil, resp.StatusCode, fmt.Errorf("read response body: %w", err)
101+
}
102+
103+
return respBody, resp.StatusCode, nil
104+
}
105+
106+
// encodeProjectID URL-encodes a project path (e.g. "group/project" → "group%2Fproject").
107+
func encodeProjectID(id string) string {
108+
return strings.ReplaceAll(id, "/", "%2F")
109+
}
110+
111+
// TriggerPipeline triggers a GitLab CI pipeline on the given ref.
112+
func (c *httpGitLabClient) TriggerPipeline(ctx context.Context, projectID, ref string, variables map[string]string, token string) (*Pipeline, error) {
113+
body := map[string]any{"ref": ref}
114+
if len(variables) > 0 {
115+
vars := make([]map[string]string, 0, len(variables))
116+
for k, v := range variables {
117+
vars = append(vars, map[string]string{"key": k, "value": v})
118+
}
119+
body["variables"] = vars
120+
}
121+
122+
path := fmt.Sprintf("/api/v4/projects/%s/pipeline", encodeProjectID(projectID))
123+
respBody, status, err := c.doRequest(ctx, http.MethodPost, path, body, token)
124+
if err != nil {
125+
return nil, fmt.Errorf("trigger pipeline: %w", err)
126+
}
127+
if status != http.StatusCreated {
128+
return nil, fmt.Errorf("trigger pipeline: unexpected status %d: %s", status, string(respBody))
129+
}
130+
131+
var pipeline Pipeline
132+
if err := json.Unmarshal(respBody, &pipeline); err != nil {
133+
return nil, fmt.Errorf("parse pipeline response: %w", err)
134+
}
135+
return &pipeline, nil
136+
}
137+
138+
// GetPipeline retrieves the status of a GitLab CI pipeline.
139+
func (c *httpGitLabClient) GetPipeline(ctx context.Context, projectID string, pipelineID int, token string) (*Pipeline, error) {
140+
path := fmt.Sprintf("/api/v4/projects/%s/pipelines/%d", encodeProjectID(projectID), pipelineID)
141+
respBody, status, err := c.doRequest(ctx, http.MethodGet, path, nil, token)
142+
if err != nil {
143+
return nil, fmt.Errorf("get pipeline: %w", err)
144+
}
145+
if status != http.StatusOK {
146+
return nil, fmt.Errorf("get pipeline: unexpected status %d", status)
147+
}
148+
149+
var pipeline Pipeline
150+
if err := json.Unmarshal(respBody, &pipeline); err != nil {
151+
return nil, fmt.Errorf("parse pipeline response: %w", err)
152+
}
153+
return &pipeline, nil
154+
}
155+
156+
// CreateMergeRequest creates a GitLab merge request.
157+
func (c *httpGitLabClient) CreateMergeRequest(ctx context.Context, projectID string, opts MergeRequestOptions, token string) (*MergeRequest, error) {
158+
body := map[string]any{
159+
"source_branch": opts.SourceBranch,
160+
"target_branch": opts.TargetBranch,
161+
"title": opts.Title,
162+
}
163+
if opts.Description != "" {
164+
body["description"] = opts.Description
165+
}
166+
167+
path := fmt.Sprintf("/api/v4/projects/%s/merge_requests", encodeProjectID(projectID))
168+
respBody, status, err := c.doRequest(ctx, http.MethodPost, path, body, token)
169+
if err != nil {
170+
return nil, fmt.Errorf("create MR: %w", err)
171+
}
172+
if status != http.StatusCreated {
173+
return nil, fmt.Errorf("create MR: unexpected status %d: %s", status, string(respBody))
174+
}
175+
176+
var mr MergeRequest
177+
if err := json.Unmarshal(respBody, &mr); err != nil {
178+
return nil, fmt.Errorf("parse MR response: %w", err)
179+
}
180+
return &mr, nil
181+
}
182+
183+
// CommentOnMR posts a note on a GitLab merge request.
184+
func (c *httpGitLabClient) CommentOnMR(ctx context.Context, projectID string, mrIID int, body, token string) error {
185+
path := fmt.Sprintf("/api/v4/projects/%s/merge_requests/%d/notes", encodeProjectID(projectID), mrIID)
186+
_, status, err := c.doRequest(ctx, http.MethodPost, path, map[string]any{"body": body}, token)
187+
if err != nil {
188+
return fmt.Errorf("comment on MR: %w", err)
189+
}
190+
if status != http.StatusCreated {
191+
return fmt.Errorf("comment on MR: unexpected status %d", status)
192+
}
193+
return nil
194+
}
195+
196+
// mockGitLabClient returns canned responses for testing without a real GitLab instance.
197+
type mockGitLabClient struct{}
198+
199+
func (m *mockGitLabClient) TriggerPipeline(_ context.Context, projectID, ref string, _ map[string]string, _ string) (*Pipeline, error) {
200+
return &Pipeline{
201+
ID: 42,
202+
Status: "created",
203+
Ref: ref,
204+
SHA: "abc123def456",
205+
WebURL: "https://gitlab.example.com/" + projectID + "/-/pipelines/42",
206+
CreatedAt: time.Now().UTC().Format(time.RFC3339),
207+
}, nil
208+
}
209+
210+
func (m *mockGitLabClient) GetPipeline(_ context.Context, projectID string, pipelineID int, _ string) (*Pipeline, error) {
211+
return &Pipeline{
212+
ID: pipelineID,
213+
Status: "success",
214+
Ref: "main",
215+
SHA: "abc123def456",
216+
WebURL: fmt.Sprintf("https://gitlab.example.com/%s/-/pipelines/%d", projectID, pipelineID),
217+
}, nil
218+
}
219+
220+
func (m *mockGitLabClient) CreateMergeRequest(_ context.Context, projectID string, opts MergeRequestOptions, _ string) (*MergeRequest, error) {
221+
return &MergeRequest{
222+
ID: 100,
223+
IID: 1,
224+
Title: opts.Title,
225+
State: "opened",
226+
SourceBranch: opts.SourceBranch,
227+
TargetBranch: opts.TargetBranch,
228+
WebURL: "https://gitlab.example.com/" + projectID + "/-/merge_requests/1",
229+
}, nil
230+
}
231+
232+
func (m *mockGitLabClient) CommentOnMR(_ context.Context, _ string, _ int, _, _ string) error {
233+
return nil
234+
}

0 commit comments

Comments
 (0)