mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* feat(issue-status): per-workspace custom issue statuses over 7 canonical categories (MUL-6243) Co-authored-by: multica-agent <[email protected]> * fix(issue-status): address review — normalize all status consumers, close archive race, gate rollout, split PK index Review round 1 (MUL-6243 / PR #7065): 1. Behavior normalization was incomplete. Adds issue_effective_status() as a SQL mirror of issuestatus.Effective so the six SQL consumers the Go resolver cannot reach (duplicate guard, open-issue list, sub-issue progress, project stats, completed-inbox archive, open_only) resolve categories too. Wires the resolver into the task-failure sweeper and the stage-barrier helpers. 2. Archive TOCTOU. Archive now runs its in-use census and the archive itself in one transaction under an exclusive catalog lock; writes that target a custom status take the shared side. Built-in writes stay lock-free — a built-in can never be archived. 3. Rollout safety. Creating a custom status is gated behind the default-off CustomIssueStatuses flag, so a value old pods cannot interpret cannot exist until the fleet is updated. Readers ship ungated and are safe. 4. Primary key index. Follows the repo convention (327-329): table without a PK, unique index built CONCURRENTLY in its own migration, attached via PRIMARY KEY USING INDEX, and registered for invalid-index cleanup. 5. Status normalization vs stored value. Write paths now persist the canonical key from Resolve, so 'HUMAN_REVIEW' no longer validates and then fails the column format constraint as a 500. Also fixes the two CLI tests failing in CI: they asserted the pre-MUL-6243 contract that an unknown-but-well-formed status is rejected locally. Co-authored-by: multica-agent <[email protected]> * fix(issue-status): close the archive race in both orderings; resolve categories in batch barrier and CLI progress Review round 2 (MUL-6243 / PR #7065): 1. The archive race was only half closed. Handlers resolved the status BEFORE opening a transaction, so an archive could commit in that window and the write would still land on an archived key. assertIssueStatusStillActive now takes the shared catalog lock and RE-RESOLVES inside the caller's transaction, which is what actually makes the status provably active at write time. Applied to create, update, batch, and both description-merge branches, which previously bypassed the guard entirely. A losing writer gets 409 rather than a silent partial update. 2. The batch stage-barrier prefilter still compared literals, so a batch that moved the last child onto a custom done status left childDoneCompleted empty and skipped the parent notification. It now resolves categories first. 3. multica issue children --output json counted only literal done/cancelled. ListChildIssues now emits status_category and the CLI reads it, falling back to status for older backends. TestArchiveCommitsInsideTheWriteRaceWindow interleaves the two operations so the writer validates against an active status and the archive commits before the write. Verified it fails without the re-resolve (issue stranded on the archived status) and passes with it. Co-authored-by: multica-agent <[email protected]> * refactor(issue-status): address review nits — honest race-test naming, one catalog read per list Review round 3 nits (MUL-6243 / PR #7065): 1. TestArchiveRaceBothOrderings claimed more than it proved. Its 'archive wins' cases archived first and only then sent the request, which the pre-flight check alone rejects. Split into TestWritesRejectAnArchivedStatus (the sequential case, named for what it is) and TestArchiveRefusesAfterACommitted Write, and extended TestArchiveCommitsInsideTheWriteRaceWindow — the only test that truly interleaves archive between pre-flight and write — from create alone to all five write endpoints. Re-verified by deleting the in-transaction re-resolve: 4 of 5 fail with issues stranded on the archived status (create is guarded in the service layer and checked separately). 2. ListChildIssues / ListChildrenByParents issued one category lookup per custom-status child. issuestatus.Resolver now amortizes that to a single catalog read per request, lazily on the first custom key, so built-in statuses still cost zero queries however long the list is. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]>
321 lines
12 KiB
Go
321 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"
|
|
"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 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 {
|
|
// A custom terminal status counts as terminal here. (MUL-6243)
|
|
if s := issuestatus.Effective(ctx, h.Queries, issue.WorkspaceID, 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),
|
|
})
|
|
}
|
|
}
|