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
112 changes: 56 additions & 56 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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).
115 changes: 91 additions & 24 deletions backend/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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(`
Expand All @@ -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 == "" {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading