mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 09:26:50 +08:00
* feat(pr): move issues to a workspace-chosen status when their PRs merge (MUL-7726) The merge automation completed an issue only when a PR said "Closes MUL-1", so the workspace switch mostly did nothing: most PRs are linked by title or branch and never completed anything. The workspace now picks what a merge does, in settings.pr_merge_status: "none", or the key of a started or done status (custom ones included, Blocked excluded). Absent means Done. When every linked PR is merged, the issue moves to that status. A closing keyword only links. - Decision: terminal, triage, "none", the per-issue switch and a new at_target state (the issue already has the target) come before the PR states, so the issue page only speaks when a merge would move the issue. The response adds target_status. - MoveIssueFromPullRequests replaces CompleteIssueFromPullRequests and writes the target status. The target is resolved through the delivery's shared catalog read (Resolver.WritableCategory). An archived or unknown choice, or a failed read, fails closed to no write. - Close intent is no longer synced or read. The column stays. - Migration 551 pins "none" on existing workspaces whose history shows a merge never moved every issue: auto-complete switched off, or a linked PR whose merges were not all keyword merges. Workspaces with no links, or with only keyword merges, keep the Done default. Replays are harmless. - CLI: `multica issue pr-automation <id> on|off` lets an agent keep one issue where it is. The multica-platform skill describes the new rule. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(views): choose the status merged PRs move an issue to on the GitHub page (MUL-7726) - Settings → Integrations → GitHub → Features: "After PRs merge, move the issue to" picks Don't change, or a started or done status, custom ones included and Blocked excluded. It replaces the read-only auto-complete row. The self-hosted Git page shows the same setting. The statuses page drops its switch, and a badge on the target status now links to the setting. - Issue sidebar: the line under the PR list names the target status ("Moves to In Review when #19 merges"). It says nothing when the workspace leaves status alone, when the issue already has the target, or on an older backend's no_close_intent. The per-issue switch reads "Keep status when PRs merge" and is hidden when it would change nothing. The menu opens the GitHub setting. The link hint only shows while auto-link is on. - core: derivePRMergeStatus replaces derivePRAutoCompleteEnabled; the auto_complete schema adds target_status and the at_target state. - Copy in 5 languages. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * docs: describe the workspace-chosen status for merged PRs (MUL-7726) GitHub integration, self-hosted Git, issues and environment variable pages (4-5 languages) now say that the workspace picks what a merge does: Done by default, another started or done status, or no change. They also say that a closing keyword only links, and how to keep one issue's status. The troubleshooting entries explain what a missing line under the PR list means. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * refactor(pr): keep the merge automation out of agents' instructions (MUL-7726) Agents don't act on what a merge does to an issue, so they don't need a command for it or a rule to learn. Drop `multica issue pr-automation`, and cut the multica-platform skill down to how PRs get linked: no merge rule, no auto_complete field guide, no "the server moves it to done" note. People still keep one issue's status from its Pull requests menu, and the docs now point there. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(pr): keep the retired auto-complete switch working for old desktop clients (MUL-7726) Review of #8862: an admin on a desktop client from before this change can still flip "Complete issues when their PRs merge". The client saved pr_auto_complete_enabled = false, and the save looked successful, but the server only read pr_merge_status, so merges kept moving issues to Done. - Settings writes: a flip of the retired switch becomes a choice (off → none, on → Done). An old client that echoes a chosen target without flipping the switch leaves the target alone. Every write mirrors the switch from the effective choice (false for none, absent otherwise), so old clients show the right state. - Reads: without pr_merge_status, the retired switch decides (off → none, else Done), on the server and in derivePRMergeStatus. This also covers settings written by a pod that predates the change during rollout. - Migration 551 sets the switch off on the workspaces it pins to none. Co-Authored-By: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: multica-agent <[email protected]>
612 lines
24 KiB
Go
612 lines
24 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/util/secretbox"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
func withVCSBox(t *testing.T) *secretbox.Box {
|
|
t.Helper()
|
|
box, err := secretbox.New(bytes.Repeat([]byte("k"), 32))
|
|
if err != nil {
|
|
t.Fatalf("secretbox.New: %v", err)
|
|
}
|
|
prev := testHandler.VCSSecretBox
|
|
testHandler.VCSSecretBox = box
|
|
// The feature also requires the deployment-level switch; the box alone is
|
|
// no longer sufficient. Enable it for the "configured" path tests.
|
|
prevEnabled := testHandler.cfg.VCSIntegrationEnabled
|
|
testHandler.cfg.VCSIntegrationEnabled = true
|
|
t.Cleanup(func() {
|
|
testHandler.VCSSecretBox = prev
|
|
testHandler.cfg.VCSIntegrationEnabled = prevEnabled
|
|
})
|
|
return box
|
|
}
|
|
|
|
const vcsTestSecret = "vcs-webhook-secret"
|
|
|
|
func seedVCSConnection(t *testing.T, ctx context.Context, box *secretbox.Box, provider, instanceURL string) string {
|
|
t.Helper()
|
|
sealed, err := box.Seal([]byte(vcsTestSecret))
|
|
if err != nil {
|
|
t.Fatalf("seal: %v", err)
|
|
}
|
|
tokenSealed, _ := box.Seal([]byte("tok"))
|
|
conn, err := testHandler.Queries.UpsertVCSConnection(ctx, db.UpsertVCSConnectionParams{
|
|
WorkspaceID: parseUUID(testWorkspaceID),
|
|
Provider: provider,
|
|
InstanceUrl: instanceURL,
|
|
AccountLogin: "acme",
|
|
AccessTokenEncrypted: base64.StdEncoding.EncodeToString(tokenSealed),
|
|
WebhookSecretEncrypted: base64.StdEncoding.EncodeToString(sealed),
|
|
ConnectedByID: pgtype.UUID{},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertVCSConnection: %v", err)
|
|
}
|
|
return uuidToString(conn.ID)
|
|
}
|
|
|
|
func cleanupVCS(ctx context.Context, issueID string) {
|
|
testPool.Exec(ctx, `DELETE FROM issue_vcs_pull_request WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(ctx, `DELETE FROM vcs_commit_status cs USING vcs_connection c WHERE cs.connection_id = c.id AND c.workspace_id = $1`, testWorkspaceID)
|
|
testPool.Exec(ctx, `DELETE FROM vcs_pull_request WHERE workspace_id = $1`, testWorkspaceID)
|
|
testPool.Exec(ctx, `DELETE FROM vcs_connection WHERE workspace_id = $1`, testWorkspaceID)
|
|
if issueID != "" {
|
|
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issueID)
|
|
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
|
}
|
|
}
|
|
|
|
func newVCSIssue(t *testing.T, title string) IssueResponse {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title, "status": "in_progress",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue: %d %s", w.Code, w.Body.String())
|
|
}
|
|
var created IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&created)
|
|
return created
|
|
}
|
|
|
|
func vcsWebhookReq(connID string, headers map[string]string, raw []byte) *http.Request {
|
|
req := httptest.NewRequest("POST", "/api/webhooks/vcs/"+connID, bytes.NewReader(raw))
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("connectionId", connID)
|
|
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
}
|
|
|
|
func giteaSig(raw []byte) string {
|
|
mac := hmac.New(sha256.New, []byte(vcsTestSecret))
|
|
mac.Write(raw)
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func TestVCSWebhook_ForgejoMirrorsAndCloses(t *testing.T) {
|
|
ctx := context.Background()
|
|
box := withVCSBox(t)
|
|
connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test")
|
|
issue := newVCSIssue(t, "Forgejo PR auto-merge")
|
|
t.Cleanup(func() { cleanupVCS(ctx, issue.ID) })
|
|
|
|
raw, _ := json.Marshal(map[string]any{
|
|
"action": "closed",
|
|
"pull_request": map[string]any{
|
|
"number": 7, "html_url": "https://forgejo.test/acme/widget/pulls/7",
|
|
"title": "Fix login " + issue.Identifier, "body": "Closes " + issue.Identifier,
|
|
"state": "closed", "merged": true,
|
|
"merged_at": "2026-04-29T00:00:00Z", "closed_at": "2026-04-29T00:00:00Z",
|
|
"created_at": "2026-04-28T00:00:00Z", "updated_at": "2026-04-29T00:00:00Z",
|
|
"head": map[string]any{"ref": "fix/login", "sha": "abc"},
|
|
"user": map[string]any{"username": "octo"},
|
|
},
|
|
"repository": map[string]any{"name": "widget", "owner": map[string]any{"username": "acme"}},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{
|
|
"X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw),
|
|
}, raw))
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("expected 202, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
|
|
rows, err := testHandler.Queries.ListVCSPullRequestsByIssue(ctx, parseUUID(issue.ID))
|
|
if err != nil {
|
|
t.Fatalf("ListVCSPullRequestsByIssue: %v", err)
|
|
}
|
|
if len(rows) != 1 || rows[0].State != "merged" || rows[0].Provider != "forgejo" {
|
|
t.Fatalf("unexpected rows: %+v", rows)
|
|
}
|
|
updated, _ := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID))
|
|
if updated.Status != "done" {
|
|
t.Errorf("expected issue done, got %q", updated.Status)
|
|
}
|
|
}
|
|
|
|
// A bare body mention ("Related MUL-X", no closing keyword, not in title or
|
|
// branch) claims nothing, so it must not link at all: it neither shows as a
|
|
// working PR nor blocks a genuine Closes sibling from completing the issue.
|
|
// Mirrors the GitHub claim rule (MUL-3739, MUL-7072).
|
|
func TestVCSWebhook_BareBodyMentionIsNotLinked(t *testing.T) {
|
|
ctx := context.Background()
|
|
box := withVCSBox(t)
|
|
connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test")
|
|
issue := newVCSIssue(t, "Reference-only mention")
|
|
t.Cleanup(func() { cleanupVCS(ctx, issue.ID) })
|
|
|
|
// PR #7: OPEN, mentions the issue only in the body with no closing keyword,
|
|
// generic title/branch → no claim, no link.
|
|
refRaw, _ := json.Marshal(map[string]any{
|
|
"action": "opened",
|
|
"pull_request": map[string]any{
|
|
"number": 7, "html_url": "https://forgejo.test/acme/widget/pulls/7",
|
|
"title": "Update docs", "body": "Related " + issue.Identifier,
|
|
"state": "open", "merged": false,
|
|
"created_at": "2026-04-28T00:00:00Z", "updated_at": "2026-04-28T00:00:00Z",
|
|
"head": map[string]any{"ref": "docs", "sha": "ref7"},
|
|
"user": map[string]any{"username": "octo"},
|
|
},
|
|
"repository": map[string]any{"name": "widget", "owner": map[string]any{"username": "acme"}},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{
|
|
"X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(refRaw),
|
|
}, refRaw))
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("ref PR: expected 202, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
|
|
// No link row at all, so nothing to exclude from the PR list.
|
|
var links int
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT count(*) FROM issue_vcs_pull_request WHERE issue_id = $1`,
|
|
issue.ID).Scan(&links); err != nil {
|
|
t.Fatalf("count links: %v", err)
|
|
}
|
|
if links != 0 {
|
|
t.Fatalf("body-only mention should not link, got %d rows", links)
|
|
}
|
|
if rows, err := testHandler.Queries.ListVCSPullRequestsByIssue(ctx, parseUUID(issue.ID)); err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
} else if len(rows) != 0 {
|
|
t.Fatalf("unlinked PR must not appear in the list, got %d rows", len(rows))
|
|
}
|
|
|
|
// PR #8: MERGED with a title reference + Closes keyword → a real claim with
|
|
// close intent. The still-open, unlinked PR #7 must NOT block completion.
|
|
closeRaw, _ := json.Marshal(map[string]any{
|
|
"action": "closed",
|
|
"pull_request": map[string]any{
|
|
"number": 8, "html_url": "https://forgejo.test/acme/widget/pulls/8",
|
|
"title": "Fix " + issue.Identifier, "body": "Closes " + issue.Identifier,
|
|
"state": "closed", "merged": true,
|
|
"merged_at": "2026-04-29T00:00:00Z", "closed_at": "2026-04-29T00:00:00Z",
|
|
"created_at": "2026-04-28T00:00:00Z", "updated_at": "2026-04-29T00:00:00Z",
|
|
"head": map[string]any{"ref": "fix", "sha": "cls8"},
|
|
"user": map[string]any{"username": "octo"},
|
|
},
|
|
"repository": map[string]any{"name": "widget", "owner": map[string]any{"username": "acme"}},
|
|
})
|
|
w = httptest.NewRecorder()
|
|
testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{
|
|
"X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(closeRaw),
|
|
}, closeRaw))
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("close PR: expected 202, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
|
|
updated, _ := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID))
|
|
if updated.Status != "done" {
|
|
t.Errorf("issue should advance despite the open unlinked PR, got %q", updated.Status)
|
|
}
|
|
}
|
|
|
|
// The merge automation must span providers: an issue with an OPEN GitHub PR
|
|
// and a MERGED VCS merge request waits for the GitHub PR, and moves once it
|
|
// merges too.
|
|
func TestAutoCompleteSpansProviders(t *testing.T) {
|
|
ctx := context.Background()
|
|
box := withVCSBox(t)
|
|
connID := seedVCSConnection(t, ctx, box, "gitlab", "https://gitlab.test")
|
|
issue := newVCSIssue(t, "Cross-provider auto-complete")
|
|
now := pgtype.Timestamptz{Time: time.Now().UTC(), Valid: true}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, issue.ID)
|
|
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
|
|
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issue.ID)
|
|
cleanupVCS(ctx, issue.ID)
|
|
})
|
|
|
|
upsertGH := func(state string) db.GithubPullRequest {
|
|
t.Helper()
|
|
pr, err := testHandler.Queries.UpsertGitHubPullRequest(ctx, db.UpsertGitHubPullRequestParams{
|
|
WorkspaceID: parseUUID(testWorkspaceID), InstallationID: 987654,
|
|
RepoOwner: "acme", RepoName: "gh", PrNumber: 3,
|
|
Title: "WIP " + issue.Identifier, State: state,
|
|
HtmlUrl: "https://github.com/acme/gh/pull/3",
|
|
PrCreatedAt: now, PrUpdatedAt: now, HeadSha: "ghsha",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertGitHubPullRequest: %v", err)
|
|
}
|
|
return pr
|
|
}
|
|
ghPR := upsertGH("open")
|
|
if _, err := testHandler.Queries.LinkIssueToPullRequest(ctx, db.LinkIssueToPullRequestParams{
|
|
IssueID: parseUUID(issue.ID), PullRequestID: ghPR.ID,
|
|
}); err != nil {
|
|
t.Fatalf("LinkIssueToPullRequest: %v", err)
|
|
}
|
|
|
|
vcsPR, err := testHandler.Queries.UpsertVCSPullRequest(ctx, db.UpsertVCSPullRequestParams{
|
|
WorkspaceID: parseUUID(testWorkspaceID), ConnectionID: parseUUID(connID),
|
|
Provider: "gitlab", RepoOwner: "acme", RepoName: "gl", PrNumber: 4,
|
|
Title: "Fix " + issue.Identifier, State: "merged",
|
|
HtmlUrl: "https://gitlab.test/acme/gl/-/merge_requests/4",
|
|
PrCreatedAt: now, PrUpdatedAt: now, HeadSha: "glsha",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertVCSPullRequest: %v", err)
|
|
}
|
|
if _, err := testHandler.Queries.LinkIssueToVCSPullRequest(ctx, db.LinkIssueToVCSPullRequestParams{
|
|
IssueID: parseUUID(issue.ID), PullRequestID: vcsPR.ID,
|
|
}); err != nil {
|
|
t.Fatalf("LinkIssueToVCSPullRequest: %v", err)
|
|
}
|
|
|
|
testHandler.maybeAutoCompleteIssue(ctx, parseUUID(testWorkspaceID), parseUUID(issue.ID), nil)
|
|
if got, _ := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID)); got.Status == "done" {
|
|
t.Fatal("the open GitHub PR must hold the issue open")
|
|
}
|
|
|
|
upsertGH("merged")
|
|
testHandler.maybeAutoCompleteIssue(ctx, parseUUID(testWorkspaceID), parseUUID(issue.ID), nil)
|
|
if got, _ := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID)); got.Status != "done" {
|
|
t.Errorf("status = %q, want done once both providers' PRs merged", got.Status)
|
|
}
|
|
}
|
|
|
|
// DeleteIssue's VCS-link cleanup must honour the same workspace guard as the
|
|
// issue delete: passing a foreign issue_id with a mismatched workspace_id must
|
|
// be a complete no-op, not silently drop the victim tenant's link rows (#1661).
|
|
func TestDeleteIssue_VCSLinkCleanupIsWorkspaceScoped(t *testing.T) {
|
|
ctx := context.Background()
|
|
box := withVCSBox(t)
|
|
connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test")
|
|
issue := newVCSIssue(t, "Tenant-scoped delete")
|
|
t.Cleanup(func() { cleanupVCS(ctx, issue.ID) })
|
|
|
|
raw, _ := json.Marshal(map[string]any{
|
|
"action": "opened",
|
|
"pull_request": map[string]any{
|
|
"number": 7, "html_url": "https://forgejo.test/acme/widget/pulls/7",
|
|
"title": "Fix " + issue.Identifier, "state": "open", "merged": false,
|
|
"created_at": "2026-05-01T00:00:00Z", "updated_at": "2026-05-01T00:00:00Z",
|
|
"head": map[string]any{"ref": "fix", "sha": "abc"},
|
|
"user": map[string]any{"username": "octo"},
|
|
},
|
|
"repository": map[string]any{"name": "widget", "owner": map[string]any{"username": "acme"}},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{
|
|
"X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw),
|
|
}, raw))
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("seed PR: %d %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
linkCount := func() int {
|
|
var n int
|
|
testPool.QueryRow(ctx, `SELECT count(*) FROM issue_vcs_pull_request WHERE issue_id = $1`, issue.ID).Scan(&n)
|
|
return n
|
|
}
|
|
if linkCount() != 1 {
|
|
t.Fatalf("expected 1 link after seed, got %d", linkCount())
|
|
}
|
|
|
|
// Mismatched workspace_id → complete no-op: issue and link both survive.
|
|
wrongWS := parseUUID("11111111-1111-1111-1111-111111111111")
|
|
if err := testHandler.Queries.DeleteIssue(ctx, db.DeleteIssueParams{ID: parseUUID(issue.ID), WorkspaceID: wrongWS}); err != nil {
|
|
t.Fatalf("DeleteIssue(wrong ws): %v", err)
|
|
}
|
|
if _, err := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID)); err != nil {
|
|
t.Errorf("issue must survive a mismatched-workspace delete: %v", err)
|
|
}
|
|
if linkCount() != 1 {
|
|
t.Errorf("link rows must survive a mismatched-workspace delete, got %d", linkCount())
|
|
}
|
|
|
|
// Correct workspace_id → issue and its links both removed.
|
|
if err := testHandler.Queries.DeleteIssue(ctx, db.DeleteIssueParams{ID: parseUUID(issue.ID), WorkspaceID: parseUUID(testWorkspaceID)}); err != nil {
|
|
t.Fatalf("DeleteIssue(correct ws): %v", err)
|
|
}
|
|
if linkCount() != 0 {
|
|
t.Errorf("link rows should be gone after correct delete, got %d", linkCount())
|
|
}
|
|
}
|
|
|
|
// A PR linked while open stays linked when its merge event no longer names the
|
|
// issue, and that merge moves the issue: the keyword only links (MUL-7726).
|
|
func TestVCSWebhook_MergeMovesIssueLinkedEarlier(t *testing.T) {
|
|
ctx := context.Background()
|
|
box := withVCSBox(t)
|
|
connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test")
|
|
issue := newVCSIssue(t, "Keyword removed before merge")
|
|
t.Cleanup(func() { cleanupVCS(ctx, issue.ID) })
|
|
|
|
fire := func(action, state string, merged bool, body, updatedAt string) {
|
|
raw, _ := json.Marshal(map[string]any{
|
|
"action": action,
|
|
"pull_request": map[string]any{
|
|
"number": 9, "html_url": "https://forgejo.test/acme/widget/pulls/9",
|
|
"title": "Session refactor", "body": body, "state": state, "merged": merged,
|
|
"created_at": "2026-05-01T00:00:00Z", "updated_at": updatedAt,
|
|
"merged_at": "2026-05-02T00:00:00Z",
|
|
"head": map[string]any{"ref": "refactor", "sha": "def"},
|
|
"user": map[string]any{"username": "octo"},
|
|
},
|
|
"repository": map[string]any{"name": "widget", "owner": map[string]any{"username": "acme"}},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{
|
|
"X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw),
|
|
}, raw))
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("%s event: %d %s", action, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
fire("opened", "open", false, "Closes "+issue.Identifier, "2026-05-01T00:00:00Z")
|
|
fire("closed", "closed", true, "Related to "+issue.Identifier, "2026-05-02T00:00:00Z")
|
|
|
|
updated, _ := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID))
|
|
if updated.Status != "done" {
|
|
t.Errorf("status = %q, want done", updated.Status)
|
|
}
|
|
var links int
|
|
testPool.QueryRow(ctx, `SELECT count(*) FROM issue_vcs_pull_request WHERE issue_id = $1`, issue.ID).Scan(&links)
|
|
if links != 1 {
|
|
t.Errorf("the merged PR must stay linked, got %d links", links)
|
|
}
|
|
}
|
|
|
|
// A redelivered older event must not rewrite the link a newer event already
|
|
// set. The PR-upsert monotonic guard protects the PR row; this covers the link.
|
|
func TestVCSWebhook_StaleEventDoesNotRewriteLink(t *testing.T) {
|
|
ctx := context.Background()
|
|
box := withVCSBox(t)
|
|
connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test")
|
|
issue := newVCSIssue(t, "Out-of-order link guard")
|
|
t.Cleanup(func() { cleanupVCS(ctx, issue.ID) })
|
|
|
|
fire := func(action, state string, merged bool, title, body, updatedAt string) {
|
|
raw, _ := json.Marshal(map[string]any{
|
|
"action": action,
|
|
"pull_request": map[string]any{
|
|
"number": 7, "html_url": "https://forgejo.test/acme/widget/pulls/7",
|
|
"title": title, "body": body, "state": state, "merged": merged,
|
|
"created_at": "2026-05-01T00:00:00Z", "updated_at": updatedAt,
|
|
"merged_at": "2026-05-02T00:00:00Z",
|
|
"head": map[string]any{"ref": "wip", "sha": "abc"},
|
|
"user": map[string]any{"username": "octo"},
|
|
},
|
|
"repository": map[string]any{"name": "widget", "owner": map[string]any{"username": "acme"}},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{
|
|
"X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw),
|
|
}, raw))
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("%s event: %d %s", action, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// Newer terminal event: merged with a real claim (Closes).
|
|
fire("closed", "closed", true, "Fix "+issue.Identifier, "Closes "+issue.Identifier, "2026-05-02T00:00:00Z")
|
|
// Older redelivered "opened" event: bare body mention, generic title/branch.
|
|
// Without the guard this would drop the link: an open PR that no longer
|
|
// claims the issue is unlinked.
|
|
fire("opened", "open", false, "WIP", "touches "+issue.Identifier, "2026-05-01T00:00:00Z")
|
|
|
|
var links int
|
|
testPool.QueryRow(ctx, `SELECT count(*) FROM issue_vcs_pull_request WHERE issue_id = $1`, issue.ID).Scan(&links)
|
|
if links != 1 {
|
|
t.Errorf("stale event rewrote the link: %d links, want 1", links)
|
|
}
|
|
// The PR row also stayed at the newer merged state.
|
|
rows, _ := testHandler.Queries.ListVCSPullRequestsByIssue(ctx, parseUUID(issue.ID))
|
|
if len(rows) != 1 || rows[0].State != "merged" {
|
|
t.Errorf("PR row regressed: %+v", rows)
|
|
}
|
|
}
|
|
|
|
func TestVCSWebhook_GitlabMergeRequest(t *testing.T) {
|
|
ctx := context.Background()
|
|
box := withVCSBox(t)
|
|
connID := seedVCSConnection(t, ctx, box, "gitlab", "https://gitlab.test")
|
|
issue := newVCSIssue(t, "GitLab MR test")
|
|
t.Cleanup(func() { cleanupVCS(ctx, issue.ID) })
|
|
|
|
raw, _ := json.Marshal(map[string]any{
|
|
"object_kind": "merge_request",
|
|
"user": map[string]any{"username": "alice"},
|
|
"project": map[string]any{"path_with_namespace": "acme/widget"},
|
|
"object_attributes": map[string]any{
|
|
"iid": 42, "title": "Add " + issue.Identifier, "description": "Closes " + issue.Identifier,
|
|
"state": "merged", "action": "merge", "source_branch": "feat",
|
|
"url": "https://gitlab.test/acme/widget/-/merge_requests/42",
|
|
"last_commit": map[string]any{"id": "deadbeef"},
|
|
},
|
|
})
|
|
// GitLab authenticates by plaintext X-Gitlab-Token, not HMAC.
|
|
w := httptest.NewRecorder()
|
|
testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{
|
|
"X-Gitlab-Event": "Merge Request Hook", "X-Gitlab-Token": vcsTestSecret,
|
|
}, raw))
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("expected 202, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
|
|
rows, err := testHandler.Queries.ListVCSPullRequestsByIssue(ctx, parseUUID(issue.ID))
|
|
if err != nil {
|
|
t.Fatalf("ListVCSPullRequestsByIssue: %v", err)
|
|
}
|
|
if len(rows) != 1 || rows[0].Provider != "gitlab" || rows[0].RepoOwner != "acme" || rows[0].PrNumber != 42 {
|
|
t.Fatalf("unexpected rows: %+v", rows)
|
|
}
|
|
if rows[0].State != "merged" {
|
|
t.Errorf("expected merged, got %q", rows[0].State)
|
|
}
|
|
updated, _ := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID))
|
|
if updated.Status != "done" {
|
|
t.Errorf("expected issue done, got %q", updated.Status)
|
|
}
|
|
}
|
|
|
|
func TestVCSWebhook_CommitStatusMirrors(t *testing.T) {
|
|
ctx := context.Background()
|
|
box := withVCSBox(t)
|
|
connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test")
|
|
issue := newVCSIssue(t, "Forgejo CI status")
|
|
t.Cleanup(func() { cleanupVCS(ctx, issue.ID) })
|
|
|
|
prRaw, _ := json.Marshal(map[string]any{
|
|
"action": "opened",
|
|
"pull_request": map[string]any{
|
|
"number": 11, "html_url": "https://forgejo.test/acme/widget/pulls/11",
|
|
"title": issue.Identifier + " feature", "state": "open",
|
|
"created_at": "2026-04-28T00:00:00Z", "updated_at": "2026-04-28T00:00:00Z",
|
|
"head": map[string]any{"ref": "feat", "sha": "deadbeef"},
|
|
"user": map[string]any{"username": "octo"},
|
|
},
|
|
"repository": map[string]any{"name": "widget", "owner": map[string]any{"username": "acme"}},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{
|
|
"X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(prRaw),
|
|
}, prRaw))
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("pr: expected 202, got %d", w.Code)
|
|
}
|
|
|
|
stRaw, _ := json.Marshal(map[string]any{"sha": "deadbeef", "context": "ci/woodpecker", "state": "success"})
|
|
w = httptest.NewRecorder()
|
|
testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{
|
|
"X-Gitea-Event": "status", "X-Gitea-Signature": giteaSig(stRaw),
|
|
}, stRaw))
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("status: expected 202, got %d", w.Code)
|
|
}
|
|
|
|
rows, _ := testHandler.Queries.ListVCSPullRequestsByIssue(ctx, parseUUID(issue.ID))
|
|
if len(rows) != 1 || rows[0].ChecksTotal != 1 || rows[0].ChecksPassed != 1 {
|
|
t.Fatalf("expected 1 passed check, got %+v", rows)
|
|
}
|
|
}
|
|
|
|
func TestVCSWebhook_BadSignature(t *testing.T) {
|
|
ctx := context.Background()
|
|
box := withVCSBox(t)
|
|
connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test")
|
|
t.Cleanup(func() { cleanupVCS(ctx, "") })
|
|
|
|
raw := []byte(`{"action":"opened","pull_request":{"number":1}}`)
|
|
w := httptest.NewRecorder()
|
|
testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{
|
|
"X-Gitea-Event": "pull_request", "X-Gitea-Signature": "00",
|
|
}, raw))
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestVCSWebhook_UnknownConnection(t *testing.T) {
|
|
withVCSBox(t)
|
|
req := vcsWebhookReq("00000000-0000-0000-0000-000000000000", map[string]string{
|
|
"X-Gitea-Event": "pull_request", "X-Gitea-Signature": "00",
|
|
}, []byte(`{}`))
|
|
w := httptest.NewRecorder()
|
|
testHandler.HandleVCSWebhook(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestVCSWebhook_DisabledDeploymentReturns404 verifies the deployment-level
|
|
// switch is enforced server-side: with the integration off (the managed-cloud
|
|
// posture), even a valid, correctly-signed delivery to a real connection is
|
|
// rejected with a bare 404, so the feature is never processed and reveals
|
|
// nothing about config. The availability gate short-circuits before signature
|
|
// verification.
|
|
func TestVCSWebhook_DisabledDeploymentReturns404(t *testing.T) {
|
|
ctx := context.Background()
|
|
box := withVCSBox(t) // sets the box AND enables the switch
|
|
connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test")
|
|
t.Cleanup(func() { cleanupVCS(ctx, "") })
|
|
|
|
// Now flip the deployment switch off (withVCSBox's cleanup restores it).
|
|
testHandler.cfg.VCSIntegrationEnabled = false
|
|
|
|
raw := []byte(`{"action":"opened","pull_request":{"number":1}}`)
|
|
w := httptest.NewRecorder()
|
|
testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{
|
|
"X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw),
|
|
}, raw))
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404 when integration disabled, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestVCSWebhook_UnconfiguredDeploymentReturns404(t *testing.T) {
|
|
h := &Handler{cfg: Config{VCSIntegrationEnabled: true}}
|
|
w := httptest.NewRecorder()
|
|
h.HandleVCSWebhook(w, vcsWebhookReq("00000000-0000-0000-0000-000000000000", nil, []byte(`{}`)))
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404 when integration is unconfigured, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestVCSWebhook_MalformedTolerated(t *testing.T) {
|
|
ctx := context.Background()
|
|
box := withVCSBox(t)
|
|
connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test")
|
|
t.Cleanup(func() { cleanupVCS(ctx, "") })
|
|
|
|
raw := []byte(`{"action":"opened","pull_request":"not-an-object"}`)
|
|
w := httptest.NewRecorder()
|
|
testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{
|
|
"X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw),
|
|
}, raw))
|
|
if w.Code != http.StatusAccepted {
|
|
t.Fatalf("expected 202, got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
var count int
|
|
testPool.QueryRow(ctx, `SELECT count(*) FROM vcs_pull_request WHERE workspace_id = $1`, testWorkspaceID).Scan(&count)
|
|
if count != 0 {
|
|
t.Errorf("expected no PR rows, got %d", count)
|
|
}
|
|
}
|