diff --git a/README.md b/README.md index e202688..91c7abd 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,115 @@ # com.paca.gitlab -Plugin first-party Paca che collega **progetti GitLab**, **merge request** e **branch** a progetti e task Paca. +First-party Paca plugin connecting **GitLab projects**, **merge requests**, and **branches** to Paca projects and tasks. -Port del plugin GitHub (`com.paca.github`) verso GitLab. Il **codice e la CI restano su GitHub**; il prodotto parla con GitLab.com (e, nel codice, con Self-Managed via URL opzionale). +Port of the GitHub plugin (`com.paca.github`) to GitLab. The **source code and CI remain on GitHub**; the product communicates with GitLab.com (and, in the codebase, with Self-Managed instances via an optional URL). -Documentazione GitLab di riferimento: [docs.gitlab.com](https://docs.gitlab.com/) · [REST API](https://docs.gitlab.com/ee/api/rest/) · [Project webhooks](https://docs.gitlab.com/user/project/integrations/webhooks/) · [Access token scopes](https://docs.gitlab.com/security/tokens/access_token_scopes/) · [MCP server ufficiale](https://docs.gitlab.com/user/model_context_protocol/mcp_server/) +Reference GitLab documentation: [docs.gitlab.com](https://docs.gitlab.com/) · [REST API](https://docs.gitlab.com/ee/api/rest/) · [Project webhooks](https://docs.gitlab.com/user/project/integrations/webhooks/) · [Access token scopes](https://docs.gitlab.com/security/tokens/access_token_scopes/) · [Official MCP server](https://docs.gitlab.com/user/model_context_protocol/mcp_server/) --- -## Cosa fa +## Features -- Salva un access token GitLab (cifrato) per progetto Paca -- Elenca e collega progetti GitLab accessibili dal token -- Crea webhook sul progetto collegato (push, merge request, pipeline) -- Su ogni task Paca: crea/linka branch e merge request, commenti, review, stato CI (pipelines) -- Espone trigger/condizioni/azioni di automazione (`gitlab.mr_*`, `gitlab.branch_linked`, …) -- Bundle MCP + skill `paca-gitlab-workflow` centrati sul *link al task Paca* (non sostituiscono il [MCP ufficiale GitLab](https://docs.gitlab.com/user/model_context_protocol/mcp_server/)) +- Stores an encrypted GitLab access token per Paca project +- Lists and links GitLab projects accessible by the token +- Creates webhooks on the linked project (push, merge request, pipeline) +- On each Paca task: creates/links branches and merge requests, comments, reviews, and CI pipeline status +- Exposes automation triggers, conditions, and actions (`gitlab.mr_*`, `gitlab.branch_linked`, …) +- Bundles MCP tools + the `paca-gitlab-workflow` skill centered around *linking to Paca tasks* (does not replace the [official GitLab MCP](https://docs.gitlab.com/user/model_context_protocol/mcp_server/)) --- -## Differenze rispetto al plugin GitHub +## Differences Compared to the GitHub Plugin | | GitHub (`com.paca.github`) | GitLab (`com.paca.gitlab`) | |--|---------------------------|----------------------------| -| Oggetto | Pull request | Merge request (`iid`) | -| Auth | PAT | Personal **o** Project **o** Group Access Token | -| Host API | `api.github.com` fisso | `instance_url` (default `https://gitlab.com`) | -| Webhook | `X-Hub-Signature-256` | `signing_token` HMAC (GitLab **19+**, Standard Webhooks) | -| CI | Checks / commit status | Pipelines MR | +| Object | Pull request | Merge request (`iid`) | +| Auth | PAT | Personal **or** Project **or** Group Access Token | +| API Host | Fixed `api.github.com` | `instance_url` (defaults to `https://gitlab.com`) | +| Webhook | `X-Hub-Signature-256` | HMAC `signing_token` (GitLab **19+**, Standard Webhooks) | +| CI | Checks / commit status | MR Pipelines | | Review | PR reviews | Note + Approvals API (`APPROVE` / `REQUEST_CHANGES` / `COMMENT`) | -| UI | inglese | i18n **en** + **it** | +| UI | English | i18n **en** + **it** | --- ## Setup -### Config host Paca +### Paca Host Configuration -| Chiave | Obbligatoria | Descrizione | -|--------|--------------|-------------| -| `ENCRYPTION_KEY` | sì | 64 caratteri hex (chiave AES-256-GCM per token e signing token webhook) | -| `PUBLIC_URL` | sì per i webhook | URL pubblico del server Paca (callback webhook) | +| Key | Required | Description | +|-----|----------|-------------| +| `ENCRYPTION_KEY` | Yes | 64 hex characters (AES-256-GCM key for tokens and webhook signing tokens) | +| `PUBLIC_URL` | Yes (for webhooks) | Public URL of the Paca server (webhook callbacks) | -Senza `PUBLIC_URL` il link di un progetto GitLab fallisce: la creazione webhook richiede un URL raggiungibile. +Without `PUBLIC_URL`, linking a GitLab project will fail because webhook creation requires a reachable callback URL. -Outbound in `plugin.json`: `gitlab.com`. Per un’istanza Self-Managed andrebbe aggiunto l’hostname in `allowedOutboundDomains` prima del build (test Self-Managed non in scope per ora). +Outbound in `plugin.json`: `gitlab.com`. For a Self-Managed instance, the hostname should be added to `allowedOutboundDomains` before building (Self-Managed testing is currently out of scope). -### Token GitLab +### GitLab Tokens -Supportati (obbligatori in prodotto): +Supported token types: 1. **Personal Access Token** 2. **Project Access Token** 3. **Group Access Token** -Scope consigliato: [`api`](https://docs.gitlab.com/security/tokens/access_token_scopes/). -Per creare project webhook serve ruolo **Maintainer** o **Owner** sul progetto GitLab ([webhooks](https://docs.gitlab.com/user/project/integrations/webhooks/)). +Recommended scope: [`api`](https://docs.gitlab.com/security/tokens/access_token_scopes/). +Creating project webhooks requires the **Maintainer** or **Owner** role on the GitLab project ([webhooks](https://docs.gitlab.com/user/project/integrations/webhooks/)). In Project Settings → **GitLab**: -- **Instance URL** (opzionale): default `https://gitlab.com` +- **Instance URL** (optional): defaults to `https://gitlab.com` - **Token type**: personal / project / group -- **Access token**: valore del token +- **Access token**: token value -Project/Group token vedono solo il proprio scope: la lista “accessible projects” può essere corta o un solo progetto — è normale. +Project and Group tokens are scoped strictly to their respective project or group: the list of "accessible projects" may be short or show only a single project — this is expected behavior. -### Webhook (GitLab 19+) +### Webhooks (GitLab 19+) -Alla link di un progetto il plugin crea un hook con: +When linking a project, the plugin creates a webhook with: - `push_events`, `merge_requests_events`, `pipeline_events` -- **`signing_token`** (`whsec_…`) — path primario di verifica (niente secret token legacy) +- **`signing_token`** (`whsec_…`) — primary verification path (no legacy secret token) -Header attesi in ingresso: `X-Gitlab-Event`, `webhook-id`, `webhook-timestamp`, `webhook-signature`. +Expected inbound headers: `X-Gitlab-Event`, `webhook-id`, `webhook-timestamp`, `webhook-signature`. --- -## Architettura +## Architecture ```text ├── backend/ Go → WASM (plugin-sdk-go), schema plugin_data_com_paca_gitlab ├── frontend/ React Module Federation + i18n (en/it) -├── mcp/ Tool MCP Paca-centrici +├── mcp/ Paca-centric MCP tools └── skills/ paca-gitlab-workflow ``` -Plugin ID: `com.paca.gitlab` · permesso custom: `gitlab.manage` +Plugin ID: `com.paca.gitlab` · Custom permission: `gitlab.manage` -### Route principali +### Main Routes -Prefisso host: `/api/v1/plugins/com.paca.gitlab/projects/:projectId/…` +Host prefix: `/api/v1/plugins/com.paca.gitlab/projects/:projectId/…` -| Metodo | Path | Note | -|--------|------|------| -| GET/POST/DELETE | `/integration`, `/integration/token` | stato + set/remove token (`instance_url`, `token_kind`) | -| GET | `/integration/accessible-repos` | progetti visibili al token | -| GET/POST/DELETE | `/repositories`… | link/unlink + webhook | -| * | `/tasks/:taskId/pull-requests`… | MR (path legacy “pull-requests”, semantica MR) | -| * | `/tasks/:taskId/branches`… | branch | -| POST | `/webhook` | pubblico (firma signing_token) | +| Method | Path | Notes | +|--------|------|-------| +| GET/POST/DELETE | `/integration`, `/integration/token` | Status + set/remove token (`instance_url`, `token_kind`) | +| GET | `/integration/accessible-repos` | Projects visible to the token | +| GET/POST/DELETE | `/repositories`… | Link/unlink + webhooks | +| * | `/tasks/:taskId/pull-requests`… | MRs (legacy "pull-requests" path, MR semantics) | +| * | `/tasks/:taskId/branches`… | Branches | +| POST | `/webhook` | Public endpoint (signing_token signature verification) | --- ## i18n -Frontend: cataloghi `en` e `it` in `frontend/src/i18n/`. -Locale da prop host `locale` oppure `navigator.language` (fallback `en`). +Frontend: `en` and `it` catalogs in `frontend/src/i18n/`. +Locale is determined from host prop `locale` or `navigator.language` (fallback to `en`). --- -## Sviluppo +## Development ### Backend @@ -128,12 +128,12 @@ cd mcp && bun install && bun run typecheck && bun run build ### Release -Workflow GitHub Actions: `.github/workflows/release.yml` su tag `v*` → artifact `gitlab-*.tar.gz`. +GitHub Actions workflow: `.github/workflows/release.yml` on `v*` tags → artifact `gitlab-*.tar.gz`. --- -## Note +## Notes -- **Self-Managed:** campo `instance_url` presente; smoke/test su istanze private **deferred** finché non c’è un’istanza da usare. Validazione primaria prevista su GitLab.com. -- Coesistenza GitHub+GitLab sullo stesso progetto Paca: **non** in scope. -- Minimo GitLab: **19+** (firma webhook `signing_token`). +- **Self-Managed:** `instance_url` field is present; smoke testing on private instances is **deferred** until a dedicated test instance is available. Primary validation targets GitLab.com. +- Coexistence of GitHub and GitLab on the same Paca project: **not** in scope. +- Minimum GitLab version: **19+** (`signing_token` webhook signature support). diff --git a/backend/webhook.go b/backend/webhook.go index 5686775..348f852 100644 --- a/backend/webhook.go +++ b/backend/webhook.go @@ -13,8 +13,15 @@ import ( plugin "github.com/Paca-AI/plugin-sdk-go" ) -// branchTaskRefRe matches a task-ID prefix in a branch name (e.g. "PROJ-42"). -var branchTaskRefRe = regexp.MustCompile(`(?i)\b([A-Z][A-Z0-9]{1,19})-(\d{1,6})\b`) +// Branch task-ref patterns (path-segment aware, matches UI CreateBranch names): +// feat/PROJ-42-slug / PROJ-42 +// feat/PROJ/42-slug / PROJ/42 +// feat/42-slug / feat/42 (number-only when project has no task_id_prefix) +var ( + branchTaskRefHyphenRe = regexp.MustCompile(`(?i)(?:^|/)([A-Z][A-Z0-9]{1,19})-(\d{1,6})(?:-|/|$)`) + branchTaskRefSlashRe = regexp.MustCompile(`(?i)(?:^|/)([A-Z][A-Z0-9]{1,19})/(\d{1,6})(?:-|/|$)`) + branchTaskRefNumberOnlyRe = regexp.MustCompile(`(?i)(?:^|/)(\d{1,6})(?:-|$)`) +) // ─── POST /webhook ──────────────────────────────────────────────────────────── @@ -219,37 +226,28 @@ func (p *gitlabPlugin) handlePushEvent(repoID, projectID string, payload []byte) Ref string `json:"ref"` Before string `json:"before"` After string `json:"after"` - Created bool `json:"created"` // not always present on GitLab + Created bool `json:"created"` // GitHub-style; GitLab usually omits this } if err := json.Unmarshal(payload, &event); err != nil { return nil } branchName := strings.TrimPrefix(event.Ref, "refs/heads/") if branchName == event.Ref { - return nil + return nil // not a branch ref (e.g. tag) } - // GitLab signals new branch with before = 40 zeros (or omitted created). - isCreated := event.Created || event.Before == "" || event.Before == strings.Repeat("0", 40) - isDeleted := event.After == strings.Repeat("0", 40) - if !isCreated || isDeleted { + if !isNewBranchPush(event.Created, event.Before, event.After) { return nil } - prefix, taskNumber, ok := extractBranchTaskRef(branchName) + refPrefix, taskNumber, ok := extractBranchTaskRef(branchName) if !ok { return nil } - taskResult, tErr := p.db.Query(` - SELECT t.id FROM tasks t - JOIN projects pr ON pr.id = t.project_id - WHERE UPPER(pr.task_id_prefix) = UPPER($1) AND t.task_number = $2 - LIMIT 1 - `, prefix, taskNumber) - if tErr != nil || len(taskResult.Rows) == 0 { + taskID, found := p.resolveTaskForBranchRef(projectID, refPrefix, taskNumber) + if !found { return nil } - taskID := newRowScanner(taskResult.Columns, taskResult.Rows[0]).str("id") now := time.Now().UTC().Format(time.RFC3339Nano) _, _ = p.db.Exec(` @@ -264,9 +262,49 @@ func (p *gitlabPlugin) handlePushEvent(repoID, projectID string, payload []byte) "repo_id": repoID, "branch_name": branchName, }) + p.log.Info("gitlab: branch auto-linked to task, task_id=" + taskID + ", branch=" + branchName) return nil } +// isNewBranchPush detects a newly created branch on push. +// GitLab sends before = 40 zero hex chars; GitHub may set created=true. +func isNewBranchPush(created bool, before, after string) bool { + zeroSHA := strings.Repeat("0", 40) + isDeleted := after == zeroSHA + isCreated := created || before == "" || before == zeroSHA + return isCreated && !isDeleted +} + +// resolveTaskForBranchRef finds a task in projectID matching the branch ref. +// Prefixed refs (PROJ-1 / PROJ/1) require the project's task_id_prefix to match. +// Number-only refs (feat/1-slug) are allowed only when the project has an empty prefix. +func (p *gitlabPlugin) resolveTaskForBranchRef(projectID, refPrefix string, taskNumber int64) (taskID string, ok bool) { + prefixResult, err := p.db.Query(`SELECT task_id_prefix FROM projects WHERE id = $1`, projectID) + if err != nil || len(prefixResult.Rows) == 0 { + return "", false + } + projectPrefix := strings.TrimSpace(newRowScanner(prefixResult.Columns, prefixResult.Rows[0]).str("task_id_prefix")) + + if refPrefix != "" { + if projectPrefix == "" || !strings.EqualFold(refPrefix, projectPrefix) { + return "", false + } + } else if projectPrefix != "" { + // Number-only branch, but project uses a prefix — ignore (matches UI). + return "", false + } + + taskResult, tErr := p.db.Query(` + SELECT id FROM tasks + WHERE project_id = $1 AND task_number = $2 AND deleted_at IS NULL + LIMIT 1 + `, projectID, taskNumber) + if tErr != nil || len(taskResult.Rows) == 0 { + return "", false + } + return newRowScanner(taskResult.Columns, taskResult.Rows[0]).str("id"), true +} + // verifyGitLabSigningToken validates Standard Webhooks HMAC (GitLab 19+ signing_token). func verifyGitLabSigningToken(payload []byte, signingToken, msgID, timestamp, signatureHeader string) bool { if signingToken == "" || msgID == "" || timestamp == "" || signatureHeader == "" { @@ -308,16 +346,45 @@ func extractGitLabProjectPath(payload []byte) string { return v.Repository.PathWithNamespace } +// conventionalBranchTypes are gitflow-style path segments, not Paca task prefixes. +// Needed so "feat/1-slug" is number-only, not prefix=FEAT via the slash form. +var conventionalBranchTypes = map[string]struct{}{ + "feat": {}, "feature": {}, "fix": {}, "bugfix": {}, "chore": {}, + "docs": {}, "doc": {}, "refactor": {}, "style": {}, "test": {}, + "tests": {}, "ci": {}, "build": {}, "perf": {}, "revert": {}, + "hotfix": {}, "release": {}, "wip": {}, +} + +func isConventionalBranchType(s string) bool { + _, ok := conventionalBranchTypes[strings.ToLower(s)] + return ok +} + +// extractBranchTaskRef parses a Paca task reference from a git branch name. +// Returns prefix="" for number-only branches (used when project has no task_id_prefix). func extractBranchTaskRef(branchName string) (prefix string, taskNumber int64, ok bool) { - m := branchTaskRefRe.FindStringSubmatch(branchName) - if m == nil { - return "", 0, false + if m := branchTaskRefHyphenRe.FindStringSubmatch(branchName); m != nil { + n, err := strconv.ParseInt(m[2], 10, 64) + if err == nil && n > 0 { + return strings.ToUpper(m[1]), n, true + } + } + for _, m := range branchTaskRefSlashRe.FindAllStringSubmatch(branchName, -1) { + if isConventionalBranchType(m[1]) { + continue + } + n, err := strconv.ParseInt(m[2], 10, 64) + if err == nil && n > 0 { + return strings.ToUpper(m[1]), n, true + } } - n, err := strconv.ParseInt(m[2], 10, 64) - if err != nil || n <= 0 { - return "", 0, false + if m := branchTaskRefNumberOnlyRe.FindStringSubmatch(branchName); m != nil { + n, err := strconv.ParseInt(m[1], 10, 64) + if err == nil && n > 0 { + return "", n, true + } } - return strings.ToUpper(m[1]), n, true + return "", 0, false } func normalizeMRState(state string, merged bool) string { diff --git a/backend/webhook_branch_test.go b/backend/webhook_branch_test.go new file mode 100644 index 0000000..1d2d2e1 --- /dev/null +++ b/backend/webhook_branch_test.go @@ -0,0 +1,213 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/Paca-AI/plugin-sdk-go/plugintest" +) + +func TestExtractBranchTaskRef(t *testing.T) { + cases := []struct { + branch string + prefix string + number int64 + ok bool + }{ + // Hyphen form (UI default with prefix) + {"feat/SKUI-1-prova", "SKUI", 1, true}, + {"feat/skui-1-prova", "SKUI", 1, true}, + {"SKUI-42", "SKUI", 42, true}, + {"chore/PROJ-99-add-thing", "PROJ", 99, true}, + {"feat/SKUI-1", "SKUI", 1, true}, + {"fix/ABC1-2-hotfix", "ABC1", 2, true}, + + // Slash form + {"feat/SKUI/1-prova", "SKUI", 1, true}, + {"SKUI/7", "SKUI", 7, true}, + {"chore/PROJ/12-docs", "PROJ", 12, true}, + + // Number-only (UI when project has no task_id_prefix) + {"feat/1-prova", "", 1, true}, + {"feat/42", "", 42, true}, + {"1-slug", "", 1, true}, + {"fix/3-hotfix", "", 3, true}, + + // Non-matches + {"main", "", 0, false}, + {"feat/add-login", "", 0, false}, + {"feat/SKUI-", "", 0, false}, + {"feat/-1-x", "", 0, false}, + {"feature/9-x", "", 9, true}, // conventional type + number-only + } + for _, tc := range cases { + prefix, n, ok := extractBranchTaskRef(tc.branch) + if ok != tc.ok || prefix != tc.prefix || n != tc.number { + t.Errorf("extractBranchTaskRef(%q) = (%q, %d, %v); want (%q, %d, %v)", + tc.branch, prefix, n, ok, tc.prefix, tc.number, tc.ok) + } + } +} + +func TestIsNewBranchPush(t *testing.T) { + zeros := strings.Repeat("0", 40) + sha := "abc123def456789012345678901234567890abcd" + + if !isNewBranchPush(false, zeros, sha) { + t.Error("GitLab new branch (before=zeros) should be created") + } + if !isNewBranchPush(true, sha, sha) { + t.Error("created=true should be created") + } + if !isNewBranchPush(false, "", sha) { + t.Error("empty before should be treated as created") + } + if isNewBranchPush(false, sha, sha) { + t.Error("normal push on existing branch should not be created") + } + if isNewBranchPush(false, zeros, zeros) { + t.Error("branch delete (after=zeros) should not be created") + } + if isNewBranchPush(true, zeros, zeros) { + t.Error("delete must win even if created=true") + } +} + +func TestHandlePushEvent_AutoLinksPrefixedBranch(t *testing.T) { + tc := setupPlugin(t) + seedProjectAndTask(t, tc, "SKUI", int64(1)) + seedEncryptedRepo(t, tc, "repo-1", testProjectID, "gorlix/documentazione-vault", "push-secret") + + payload := pushPayload("feat/SKUI-1-auto-link-smoke", true) + res := tc.Call("POST", "/webhook", signedPushWebhookRequest(testProjectID, payload, "push-secret")) + if res.StatusCode != 204 { + t.Fatalf("expected 204, got %d: %s", res.StatusCode, res.BodyString()) + } + if !tc.Log.HasMessage("branch auto-linked to task") { + t.Fatalf("expected auto-link log; entries: %+v", tc.Log.Entries()) + } + rows := tc.DB.AllRows("gitlab_task_branches") + if len(rows) != 1 { + t.Fatalf("expected 1 linked branch, got %d", len(rows)) + } +} + +func TestHandlePushEvent_AutoLinksSlashPrefixedBranch(t *testing.T) { + tc := setupPlugin(t) + seedProjectAndTask(t, tc, "SKUI", int64(1)) + seedEncryptedRepo(t, tc, "repo-1", testProjectID, "gorlix/documentazione-vault", "push-secret") + + payload := pushPayload("feat/SKUI/1-slash-form", true) + res := tc.Call("POST", "/webhook", signedPushWebhookRequest(testProjectID, payload, "push-secret")) + if res.StatusCode != 204 { + t.Fatalf("expected 204, got %d", res.StatusCode) + } + if !tc.Log.HasMessage("branch auto-linked to task") { + t.Fatalf("expected auto-link for slash form; entries: %+v", tc.Log.Entries()) + } +} + +func TestHandlePushEvent_AutoLinksNumberOnlyWhenNoPrefix(t *testing.T) { + tc := setupPlugin(t) + seedProjectAndTask(t, tc, "", int64(7)) + seedEncryptedRepo(t, tc, "repo-1", testProjectID, "gorlix/documentazione-vault", "push-secret") + + payload := pushPayload("feat/7-no-prefix", true) + res := tc.Call("POST", "/webhook", signedPushWebhookRequest(testProjectID, payload, "push-secret")) + if res.StatusCode != 204 { + t.Fatalf("expected 204, got %d", res.StatusCode) + } + if !tc.Log.HasMessage("branch auto-linked to task") { + t.Fatalf("expected number-only auto-link; entries: %+v", tc.Log.Entries()) + } +} + +func TestHandlePushEvent_IgnoresNumberOnlyWhenProjectHasPrefix(t *testing.T) { + tc := setupPlugin(t) + seedProjectAndTask(t, tc, "SKUI", int64(1)) + seedEncryptedRepo(t, tc, "repo-1", testProjectID, "gorlix/documentazione-vault", "push-secret") + + payload := pushPayload("feat/1-should-not-link", true) + res := tc.Call("POST", "/webhook", signedPushWebhookRequest(testProjectID, payload, "push-secret")) + if res.StatusCode != 204 { + t.Fatalf("expected 204, got %d", res.StatusCode) + } + if tc.Log.HasMessage("branch auto-linked to task") { + t.Fatal("number-only branch must not auto-link when project has a prefix") + } + if len(tc.DB.AllRows("gitlab_task_branches")) != 0 { + t.Fatal("expected no branch links") + } +} + +func TestHandlePushEvent_IgnoresExistingBranchPush(t *testing.T) { + tc := setupPlugin(t) + seedProjectAndTask(t, tc, "SKUI", int64(1)) + seedEncryptedRepo(t, tc, "repo-1", testProjectID, "gorlix/documentazione-vault", "push-secret") + + payload := pushPayload("feat/SKUI-1-existing", false) + res := tc.Call("POST", "/webhook", signedPushWebhookRequest(testProjectID, payload, "push-secret")) + if res.StatusCode != 204 { + t.Fatalf("expected 204, got %d", res.StatusCode) + } + if tc.Log.HasMessage("branch auto-linked to task") { + t.Fatal("push to existing branch must not auto-link") + } +} + +func TestHandlePushEvent_MismatchedPrefixIgnored(t *testing.T) { + tc := setupPlugin(t) + seedProjectAndTask(t, tc, "SKUI", int64(1)) + seedEncryptedRepo(t, tc, "repo-1", testProjectID, "gorlix/documentazione-vault", "push-secret") + + payload := pushPayload("feat/OTHER-1-nope", true) + res := tc.Call("POST", "/webhook", signedPushWebhookRequest(testProjectID, payload, "push-secret")) + if res.StatusCode != 204 { + t.Fatalf("expected 204, got %d", res.StatusCode) + } + if tc.Log.HasMessage("branch auto-linked to task") { + t.Fatal("mismatched prefix must not auto-link") + } +} + +func seedProjectAndTask(t *testing.T, tc *plugintest.Context, taskIDPrefix string, taskNumber int64) { + t.Helper() + tc.DB.SeedRows("projects", + []string{"id", "task_id_prefix"}, + [][]any{{testProjectID, taskIDPrefix}}) + // Replace tasks seed from setupPlugin with columns needed for lookup. + tc.DB.SeedRows("tasks", + []string{"id", "project_id", "task_number", "deleted_at"}, + [][]any{{testTaskID, testProjectID, taskNumber, nil}}) +} + +func pushPayload(branch string, isNew bool) []byte { + before := "abc123def456789012345678901234567890abcd" + if isNew { + before = strings.Repeat("0", 40) + } + body := fmt.Sprintf(`{ + "object_kind":"push", + "ref":"refs/heads/%s", + "before":"%s", + "after":"def456abc789012345678901234567890abcdef0", + "project":{"path_with_namespace":"gorlix/documentazione-vault"} + }`, branch, before) + return []byte(body) +} + +func signedPushWebhookRequest(projectID string, payload []byte, signingToken string) plugintest.Request { + req := signedWebhookRequest(projectID, payload, signingToken) + req.Headers["X-Gitlab-Event"] = "Push Hook" + return req +} + +// Ensure payload is valid JSON (catches typos in test helpers). +func TestPushPayloadJSON(t *testing.T) { + var v map[string]any + if err := json.Unmarshal(pushPayload("feat/SKUI-1-x", true), &v); err != nil { + t.Fatal(err) + } +}