Files
multica/server/internal/handler/vcs_webhook.go
Bohan Jiang ecce589867 MUL-5265: GitHub API-snapshot PR cards — CI status + mergeability (#5889)
* feat(github): API-snapshot PR cards — CI status + mergeability (MUL-5265)

Fetch each linked PR's CI checks and mergeability from the GitHub GraphQL
API as the single source of truth (Plan C). Webhooks, page visits and a
bounded TTL sweep are refresh triggers only; nothing is inferred from
webhook payloads anymore.

Backend (server/internal/integrations/ghsnapshot):
- installation-token cache + GraphQL client (private key / tokens never logged)
- one paginated pullRequest query -> normalized per-check snapshot
- outbound queue: (installation,repo,PR) dedup + single in-flight per PR,
  bounded worker pool, Retry-After / rate-limit backoff, jitter
- head-SHA-guarded atomic batch replace (a slow response for an old head
  can never overwrite a newer head's snapshot)
- bounded chase window (30s->5m, stops on terminal/closed) + page-visit +
  TTL refresh; clean degradation when no App private key is configured

Removes the old suite-level webhook aggregation display path (query +
handlers + tests). check_suite / check_run / status are now pure triggers.

Frontend: PR card shows two independent tri-state elements (CI status +
mergeability). "Ready to merge" only when merge state is clean; no-checks
and unknown-mergeable never assert a positive verdict; progress strip
removed; four locales; stale marker.

Docs: github-integration + environment-variables (four languages) — now
required App private key, read-only Checks/Commit-statuses permissions,
new event subscriptions, capability boundaries and troubleshooting.

Co-authored-by: multica-agent <github@multica.ai>

* fix(github): address PR snapshot review blockers

Co-authored-by: multica-agent <github@multica.ai>

* fix(github): bound snapshot refresh scheduling

Co-authored-by: multica-agent <github@multica.ai>

* fix(github): concurrent check-run index migration + singleflight token mint

Address Elon's third-round review on the MUL-5265 PR snapshot pipeline.

Must-fix — migration built a non-concurrent index. The
github_pull_request_check_run table declared PRIMARY KEY (pr_id, ordinal)
inside CREATE TABLE, which builds a unique index synchronously and violates
the repo rule that every migration-created index (including on a new table)
use CREATE UNIQUE INDEX CONCURRENTLY in its own single-statement file. Split:
222 now creates the table without a primary key; new 223 adds the
(pr_id, ordinal) unique index CONCURRENTLY. The atomic delete-all/insert
write path already guarantees ordinal uniqueness, so a plain unique index is
sufficient; the index also serves the pr_id-prefix list aggregation and the
workspace/PR cleanup deletes.

Nit — token mint now singleflights per installation. installationToken
released the lock before minting, so the N workers of one installation could
mint N tokens on a cold cache or a simultaneous renew. Concurrent callers for
the same installation are now collapsed via singleflight into one HTTP mint;
added a -race concurrent-mint test asserting a single mint under 16 callers.

Verified: fresh DB migrates through 223 (table has no PK, concurrent unique
index present); ghsnapshot suite + new test pass under -race; migration lint
and handler github/workspace-delete tests pass; sqlc produced no diff;
go build / vet / gofmt / git diff --check clean.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 18:30:20 +08:00

319 lines
12 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"
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 metadata. UpsertVCSPullRequest keeps the
// newer persisted row on a stale redelivery, so `pr` may reflect a newer
// event than this `ev`. Everything the link write derives below —
// close_intent, reference_only, preserveCloseIntent — comes from `ev`, so
// rewriting the link from a stale event would corrupt what the newer event
// already set (e.g. a redelivered older "opened" event flipping a merged
// PR's link back to reference_only, blocking auto-advance). 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{}{}
}
// qualifyingIdents genuinely tie this PR to an issue: a title prefix, a
// branch-name reference, or a body closing keyword. An identifier matched
// ONLY by a bare body mention is reference_only — it links (so the PR shows
// in history) but is hidden from the issue PR list and excluded from the
// close aggregate, so a drive-by "Related MUL-1" neither looks like a
// working PR nor blocks a genuine Closes sibling from advancing the issue.
// Mirrors the GitHub path (MUL-3739); branch is deliberately excluded from
// the closing-keyword scan there and here.
qualifyingIdents := map[string]struct{}{}
for _, id := range extractIdentifiers(ev.Title, ev.Branch) {
qualifyingIdents[id] = struct{}{}
}
for c := range closingIdents {
qualifyingIdents[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
}
_, declared := closingIdents[id]
closeIntent := declared && !preserveCloseIntent
_, qualifies := qualifyingIdents[id]
referenceOnly := !qualifies
if err := h.Queries.LinkIssueToVCSPullRequest(ctx, db.LinkIssueToVCSPullRequestParams{
IssueID: issue.ID,
PullRequestID: pr.ID,
CloseIntent: closeIntent,
ReferenceOnly: referenceOnly,
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" {
for _, issue := range reevalIssues {
if issue.Status == "done" || issue.Status == "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),
})
}
}