Files
multica/server/internal/handler/vcs_webhook.go
Bohan Jiang 116761e505 MUL-7072 fix(github): retire reference-only PR links (#8245)
A bare body mention of an issue key claimed nothing, yet auto-link wrote a link row for it and flagged it reference_only — filtered out of the PR list, the close aggregate and the review dedup head SHA. The row had no reader, and once the PR went terminal the preserve gate froze the flag, so adding `Closes MUL-1` to a merged PR's body could never surface the PR on its issue. With no unlink surface anywhere, the one repair available from GitHub was the one that could not work.

Write no row for a passing mention, delete the historical hidden rows (migration 458), and drop the column's reads and writes; the column itself goes in a follow-up release that re-runs the delete as a mop-up. The visible set of links is unchanged, except that a post-merge `Closes` now links the PR (without retroactively completing the issue). An open PR whose claim is downgraded in place loses its link, which is what hiding used to accomplish.

Docs, the four locales, and the agent-facing platform skill are updated in step.
2026-09-10 17:16:50 +08:00

336 lines
13 KiB
Go

package handler
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/integrations/vcs"
"github.com/multica-ai/multica/server/internal/issuestatus"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// ── Response mappers ────────────────────────────────────────────────────────
// vcsPullRequestToResponse maps a stored VCS PR onto the shared PR response
// shape for single-PR webhook broadcasts (no aggregated check counts; the
// frontend re-queries the issue's PR list for fresh counts).
func vcsPullRequestToResponse(p db.VcsPullRequest) GitHubPullRequestResponse {
return GitHubPullRequestResponse{
ID: uuidToString(p.ID),
Provider: p.Provider,
WorkspaceID: uuidToString(p.WorkspaceID),
RepoOwner: p.RepoOwner,
RepoName: p.RepoName,
Number: p.PrNumber,
Title: p.Title,
State: p.State,
HtmlURL: p.HtmlUrl,
Branch: textToPtr(p.Branch),
AuthorLogin: textToPtr(p.AuthorLogin),
AuthorAvatarURL: textToPtr(p.AuthorAvatarUrl),
MergedAt: timestampToPtr(p.MergedAt),
ClosedAt: timestampToPtr(p.ClosedAt),
PRCreatedAt: timestampToString(p.PrCreatedAt),
PRUpdatedAt: timestampToString(p.PrUpdatedAt),
MergeableState: nil,
ChecksConclusion: nil,
Additions: p.Additions,
Deletions: p.Deletions,
ChangedFiles: p.ChangedFiles,
}
}
// vcsPullRequestRowToResponse maps an issue's PR-list row, which carries the
// aggregated commit-status counts, onto the shared response shape.
func vcsPullRequestRowToResponse(p db.ListVCSPullRequestsByIssueRow) GitHubPullRequestResponse {
return GitHubPullRequestResponse{
ID: uuidToString(p.ID),
Provider: p.Provider,
WorkspaceID: uuidToString(p.WorkspaceID),
RepoOwner: p.RepoOwner,
RepoName: p.RepoName,
Number: p.PrNumber,
Title: p.Title,
State: p.State,
HtmlURL: p.HtmlUrl,
Branch: textToPtr(p.Branch),
AuthorLogin: textToPtr(p.AuthorLogin),
AuthorAvatarURL: textToPtr(p.AuthorAvatarUrl),
MergedAt: timestampToPtr(p.MergedAt),
ClosedAt: timestampToPtr(p.ClosedAt),
PRCreatedAt: timestampToString(p.PrCreatedAt),
PRUpdatedAt: timestampToString(p.PrUpdatedAt),
MergeableState: nil,
ChecksConclusion: aggregateChecksConclusion(p.ChecksFailed, p.ChecksPassed, p.ChecksPending, p.ChecksTotal),
ChecksTotal: p.ChecksTotal,
ChecksPassed: p.ChecksPassed,
ChecksFailed: p.ChecksFailed,
ChecksPending: p.ChecksPending,
ChecksRunning: p.ChecksPending,
FailedCheckNames: []string{},
Additions: p.Additions,
Deletions: p.Deletions,
ChangedFiles: p.ChangedFiles,
}
}
// ── Webhook ─────────────────────────────────────────────────────────────────
// HandleVCSWebhook (POST /api/webhooks/vcs/{connectionId}) authenticates and
// mirrors webhooks from any token-based Git provider. The connection id in the path
// selects the workspace, the provider, and the decryption secret; the provider
// adapter handles the provider-specific signature scheme, event header, and
// payload shape, returning normalized events to the shared mirror logic below.
func (h *Handler) HandleVCSWebhook(w http.ResponseWriter, r *http.Request) {
// Where the integration is off (the managed cloud) the endpoint behaves as
// if it does not exist — a bare 404 that reveals nothing about config, the
// same response a genuinely unknown connection id gets below.
if !h.isVCSAvailable() {
writeError(w, http.StatusNotFound, "unknown connection")
return
}
if !h.isVCSConfigured() {
writeError(w, http.StatusServiceUnavailable, "vcs webhooks not configured")
return
}
connUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "connectionId"), "connection id")
if !ok {
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20)) // 10 MiB cap
if err != nil {
writeError(w, http.StatusBadRequest, "read body failed")
return
}
conn, err := h.Queries.GetVCSConnectionByID(r.Context(), connUUID)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
slog.Warn("vcs: lookup connection failed", "err", err)
}
writeError(w, http.StatusNotFound, "unknown connection")
return
}
provider, ok := vcs.For(conn.Provider)
if !ok {
slog.Error("vcs: connection has unknown provider", "provider", conn.Provider)
writeError(w, http.StatusInternalServerError, "unknown provider")
return
}
secret, err := h.openVCSSecret(conn.WebhookSecretEncrypted)
if err != nil {
slog.Error("vcs: decrypt webhook secret failed", "err", err)
writeError(w, http.StatusInternalServerError, "secret error")
return
}
if !provider.VerifySignature(secret, r.Header, body) {
writeError(w, http.StatusUnauthorized, "invalid signature")
return
}
switch provider.EventKind(r.Header) {
case vcs.EventPullRequest:
if pr, err := provider.ParsePullRequest(body); err != nil {
slog.Warn("vcs: bad pull_request payload", "provider", conn.Provider, "err", err)
} else {
h.mirrorVCSPullRequest(r.Context(), conn, pr)
}
case vcs.EventCIStatus:
if st, err := provider.ParseCIStatus(body); err != nil {
slog.Warn("vcs: bad status payload", "provider", conn.Provider, "err", err)
} else {
h.mirrorVCSCIStatus(r.Context(), conn, st)
}
default:
// Acknowledge unmodelled events so the provider doesn't flag the hook.
}
w.WriteHeader(http.StatusAccepted)
}
func (h *Handler) mirrorVCSPullRequest(ctx context.Context, conn db.VcsConnection, ev vcs.PullRequestEvent) {
if ev.RepoOwner == "" || ev.RepoName == "" || ev.Number == 0 {
slog.Warn("vcs: pull_request missing repo identity", "provider", conn.Provider)
return
}
pr, err := h.Queries.UpsertVCSPullRequest(ctx, db.UpsertVCSPullRequestParams{
WorkspaceID: conn.WorkspaceID,
ConnectionID: conn.ID,
Provider: conn.Provider,
RepoOwner: ev.RepoOwner,
RepoName: ev.RepoName,
PrNumber: ev.Number,
Title: ev.Title,
State: ev.State,
HtmlUrl: ev.HTMLURL,
Branch: ptrToText(strPtrOrNil(ev.Branch)),
AuthorLogin: ptrToText(strPtrOrNil(ev.AuthorLogin)),
AuthorAvatarUrl: ptrToText(strPtrOrNil(ev.AuthorAvatarURL)),
MergedAt: parseGHTime(ev.MergedAt),
ClosedAt: parseGHTime(ev.ClosedAt),
PrCreatedAt: parseGHTimeRequired(ev.CreatedAt),
PrUpdatedAt: parseGHTimeRequired(ev.UpdatedAt),
Additions: ev.Additions,
Deletions: ev.Deletions,
ChangedFiles: ev.ChangedFiles,
HeadSha: ev.HeadSHA,
})
if err != nil {
slog.Warn("vcs: upsert pr failed", "err", err)
return
}
// Out-of-order guard for the link write. UpsertVCSPullRequest keeps the
// newer persisted row on a stale redelivery, so `pr` may reflect a newer
// event than this `ev`. Everything the link write decides below — whether
// the PR still claims the issue, close_intent, preserveCloseIntent — comes
// from `ev`, so acting on a stale event would undo what the newer one
// already recorded (e.g. a redelivered older "opened" event clearing the
// close intent on a merged PR's link, blocking auto-advance, or dropping a
// link that event had just written). If the persisted row is strictly newer
// than this event, the newer event already linked and published — stop
// here. (An event with no usable timestamp falls back to now(), which is
// never strictly after the stored value, so it proceeds.)
evUpdatedAt := parseGHTimeRequired(ev.UpdatedAt)
if pr.PrUpdatedAt.Valid && evUpdatedAt.Valid && pr.PrUpdatedAt.Time.After(evUpdatedAt.Time) {
return
}
workspaceID := uuidToString(conn.WorkspaceID)
resp := vcsPullRequestToResponse(pr)
// Auto-link to issues by identifiers in title/body/branch. Connecting a
// a provider is the opt-in, so there is no separate per-workspace flag. The
// issue-side machinery is shared with GitHub.
linkedIssueIDs := make([]string, 0)
idents := extractIdentifiers(ev.Title, ev.Body, ev.Branch)
closingIdents := map[string]struct{}{}
for _, c := range extractClosingIdentifiers(ev.Title, ev.Body) {
closingIdents[c] = struct{}{}
}
// claimedIdents are the identifiers this PR claims: a title prefix, a
// branch-name reference, or a body closing keyword. An identifier matched
// ONLY by a bare body mention is a drive-by reference — it claims nothing,
// so it gets no link row and drops one an earlier claim created. Mirrors the
// GitHub path (MUL-3739, MUL-7072); branch is deliberately excluded from the
// closing-keyword scan there and here.
claimedIdents := map[string]struct{}{}
for _, id := range extractIdentifiers(ev.Title, ev.Branch) {
claimedIdents[id] = struct{}{}
}
for c := range closingIdents {
claimedIdents[c] = struct{}{}
}
// Freeze close_intent once the terminal merge/close event has arrived.
preserveCloseIntent := !ev.Terminal() && (ev.State == "merged" || ev.State == "closed")
prefix := h.getIssuePrefix(ctx, conn.WorkspaceID)
reevalIssues := make([]db.Issue, 0, len(idents))
for _, id := range idents {
issue, ok := h.lookupIssueByIdentifier(ctx, conn.WorkspaceID, prefix, id)
if !ok {
continue
}
if _, claimed := claimedIdents[id]; !claimed {
// Passing mention: never links, and drops an earlier claim's link
// while the PR is still editable. Frozen once terminal, like
// close_intent.
if preserveCloseIntent {
continue
}
if err := h.Queries.UnlinkIssueFromVCSPullRequest(ctx, db.UnlinkIssueFromVCSPullRequestParams{
IssueID: issue.ID,
PullRequestID: pr.ID,
}); err != nil {
slog.Warn("vcs: unlink failed", "err", err)
continue
}
reevalIssues = append(reevalIssues, issue)
continue
}
_, declared := closingIdents[id]
closeIntent := declared && !preserveCloseIntent
if err := h.Queries.LinkIssueToVCSPullRequest(ctx, db.LinkIssueToVCSPullRequestParams{
IssueID: issue.ID,
PullRequestID: pr.ID,
CloseIntent: closeIntent,
PreserveCloseIntent: preserveCloseIntent,
LinkedByType: strToText("system"),
LinkedByID: pgtype.UUID{},
}); err != nil {
slog.Warn("vcs: link failed", "err", err)
continue
}
linkedIssueIDs = append(linkedIssueIDs, uuidToString(issue.ID))
reevalIssues = append(reevalIssues, issue)
}
if ev.State == "merged" || ev.State == "closed" {
// Keep the catalog local to this delivery and connection's workspace.
resolver := issuestatus.NewResolver(conn.WorkspaceID)
for _, issue := range reevalIssues {
// A custom terminal status counts as terminal here. (MUL-6243)
if s := resolver.Effective(ctx, h.issueStatusCatalog(), issue.Status); s == "done" || s == "cancelled" {
continue
}
counts, err := h.Queries.GetIssueCombinedPullRequestCloseAggregate(ctx, issue.ID)
if err != nil {
slog.Warn("vcs: count linked pr states failed", "err", err, "issue_id", uuidToString(issue.ID))
continue
}
if counts.OpenCount == 0 && counts.MergedWithCloseIntentCount > 0 {
h.advanceIssueToDone(ctx, issue, workspaceID)
}
}
}
h.publish(protocol.EventPullRequestUpdated, workspaceID, "system", "", map[string]any{
"pull_request": resp,
"linked_issue_ids": linkedIssueIDs,
})
}
func (h *Handler) mirrorVCSCIStatus(ctx context.Context, conn db.VcsConnection, ev vcs.CIStatusEvent) {
if ev.SHA == "" || ev.State == "" {
return
}
// Use the provider's own event timestamp so UpsertVCSCommitStatus's
// monotonic guard has something real to compare — writing time.Now() here
// made the guard always true, so an out-of-order redelivery could regress a
// status. Falls back to now() only when the payload carried no timestamp.
if err := h.Queries.UpsertVCSCommitStatus(ctx, db.UpsertVCSCommitStatusParams{
ConnectionID: conn.ID,
Sha: ev.SHA,
Context: ev.Context,
State: ev.State,
TargetUrl: ptrToText(strPtrOrNil(ev.TargetURL)),
Description: ptrToText(strPtrOrNil(ev.Description)),
UpdatedAt: parseGHTimeRequired(ev.UpdatedAt),
}); err != nil {
slog.Warn("vcs: upsert commit status failed", "err", err)
return
}
issueIDs, err := h.Queries.ListIssueIDsForVCSPRHead(ctx, db.ListIssueIDsForVCSPRHeadParams{
ConnectionID: conn.ID,
HeadSha: ev.SHA,
})
if err != nil {
slog.Warn("vcs: lookup issues for status failed", "err", err)
return
}
workspaceID := uuidToString(conn.WorkspaceID)
for _, issueID := range issueIDs {
h.publish(protocol.EventPullRequestUpdated, workspaceID, "system", "", map[string]any{
"issue_id": uuidToString(issueID),
})
}
}