mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 17:47:43 +02:00
Compare commits
3 Commits
agent/lamb
...
agent/j/df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ba3a5ad6e | ||
|
|
2d44ad36ce | ||
|
|
38bcc91b64 |
@@ -88,8 +88,8 @@ type GitHubPullRequestResponse struct {
|
||||
}
|
||||
|
||||
type GitHubConnectResponse struct {
|
||||
URL string `json:"url"`
|
||||
Configured bool `json:"configured"`
|
||||
URL string `json:"url"`
|
||||
Configured bool `json:"configured"`
|
||||
}
|
||||
|
||||
func githubInstallationToResponse(i db.GithubInstallation) GitHubInstallationResponse {
|
||||
@@ -489,6 +489,19 @@ func (h *Handler) ListPullRequestsForIssue(w http.ResponseWriter, r *http.Reques
|
||||
// version numbers like "v1.2-3".
|
||||
var identifierRe = regexp.MustCompile(`(?i)\b([a-z][a-z0-9]{1,9})-(\d+)\b`)
|
||||
|
||||
// closingIdentifierRe extracts identifiers that appear immediately after a
|
||||
// GitHub-style closing keyword ("close[sd]?", "fix(e[sd])?", "resolve[sd]?"),
|
||||
// optionally separated by a colon and whitespace. Matching is intentionally
|
||||
// strict on adjacency — "Fix MUL-1" closes MUL-1, but "Fix login MUL-1"
|
||||
// does not. This mirrors GitHub's own closing-keyword grammar and is the
|
||||
// gate the webhook uses to decide whether to auto-advance an issue to
|
||||
// `done` after a PR merges. References like "Follow up in MUL-2" and bare
|
||||
// title prefixes like "MUL-1: ..." link the PR (via identifierRe) but
|
||||
// never auto-close.
|
||||
var closingIdentifierRe = regexp.MustCompile(
|
||||
`(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)[:\s]+([a-z][a-z0-9]{1,9})-(\d+)\b`,
|
||||
)
|
||||
|
||||
// HandleGitHubWebhook (POST /api/webhooks/github) is GitHub's destination for
|
||||
// every event from a connected installation. We verify HMAC signature, route
|
||||
// on X-GitHub-Event, and either upsert PR rows + auto-link to issues or
|
||||
@@ -635,7 +648,7 @@ type ghPullRequestPayload struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
} `json:"user"`
|
||||
} `json:"pull_request"`
|
||||
Changes *ghPRChanges `json:"changes"`
|
||||
Changes *ghPRChanges `json:"changes"`
|
||||
Repository struct {
|
||||
Name string `json:"name"`
|
||||
Owner struct {
|
||||
@@ -669,27 +682,27 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
|
||||
state := derivePRState(p.PullRequest.State, p.PullRequest.Draft, p.PullRequest.Merged)
|
||||
mergeable, clearMergeable := derivePRMergeableState(p.Action, p.PullRequest.MergeableState, baseRefChanged(p.Changes))
|
||||
pr, err := h.Queries.UpsertGitHubPullRequest(ctx, db.UpsertGitHubPullRequestParams{
|
||||
WorkspaceID: inst.WorkspaceID,
|
||||
InstallationID: inst.InstallationID,
|
||||
RepoOwner: p.Repository.Owner.Login,
|
||||
RepoName: p.Repository.Name,
|
||||
PrNumber: p.PullRequest.Number,
|
||||
Title: p.PullRequest.Title,
|
||||
State: state,
|
||||
HtmlUrl: p.PullRequest.HTMLURL,
|
||||
Branch: ptrToText(strPtrOrNil(p.PullRequest.Head.Ref)),
|
||||
AuthorLogin: ptrToText(strPtrOrNil(p.PullRequest.User.Login)),
|
||||
AuthorAvatarUrl: ptrToText(strPtrOrNil(p.PullRequest.User.AvatarURL)),
|
||||
MergedAt: parseGHTime(p.PullRequest.MergedAt),
|
||||
ClosedAt: parseGHTime(p.PullRequest.ClosedAt),
|
||||
PrCreatedAt: parseGHTimeRequired(p.PullRequest.CreatedAt),
|
||||
PrUpdatedAt: parseGHTimeRequired(p.PullRequest.UpdatedAt),
|
||||
HeadSha: p.PullRequest.Head.SHA,
|
||||
MergeableState: mergeable,
|
||||
ClearMergeableState: pgtype.Bool{Bool: clearMergeable, Valid: true},
|
||||
Additions: p.PullRequest.Additions,
|
||||
Deletions: p.PullRequest.Deletions,
|
||||
ChangedFiles: p.PullRequest.ChangedFiles,
|
||||
WorkspaceID: inst.WorkspaceID,
|
||||
InstallationID: inst.InstallationID,
|
||||
RepoOwner: p.Repository.Owner.Login,
|
||||
RepoName: p.Repository.Name,
|
||||
PrNumber: p.PullRequest.Number,
|
||||
Title: p.PullRequest.Title,
|
||||
State: state,
|
||||
HtmlUrl: p.PullRequest.HTMLURL,
|
||||
Branch: ptrToText(strPtrOrNil(p.PullRequest.Head.Ref)),
|
||||
AuthorLogin: ptrToText(strPtrOrNil(p.PullRequest.User.Login)),
|
||||
AuthorAvatarUrl: ptrToText(strPtrOrNil(p.PullRequest.User.AvatarURL)),
|
||||
MergedAt: parseGHTime(p.PullRequest.MergedAt),
|
||||
ClosedAt: parseGHTime(p.PullRequest.ClosedAt),
|
||||
PrCreatedAt: parseGHTimeRequired(p.PullRequest.CreatedAt),
|
||||
PrUpdatedAt: parseGHTimeRequired(p.PullRequest.UpdatedAt),
|
||||
HeadSha: p.PullRequest.Head.SHA,
|
||||
MergeableState: mergeable,
|
||||
ClearMergeableState: pgtype.Bool{Bool: clearMergeable, Valid: true},
|
||||
Additions: p.PullRequest.Additions,
|
||||
Deletions: p.PullRequest.Deletions,
|
||||
ChangedFiles: p.PullRequest.ChangedFiles,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("github: upsert pr failed", "err", err)
|
||||
@@ -701,7 +714,8 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
|
||||
|
||||
// Auto-link: scan title/body/branch for issue identifiers, look them
|
||||
// up in this workspace, attach the link rows. Idempotent (ON CONFLICT
|
||||
// DO NOTHING) so re-firing the webhook doesn't duplicate.
|
||||
// upserts the close_intent flag — see LinkIssueToPullRequest) so
|
||||
// re-firing the webhook doesn't duplicate.
|
||||
//
|
||||
// RFC MUL-2414 §4.8: the PR mirror upsert above always runs (so re-enabling
|
||||
// GitHub features restores history without backfill), but the link rows
|
||||
@@ -711,43 +725,80 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
|
||||
linkedIssueIDs := make([]string, 0)
|
||||
if h.workspaceAutoLinkPRsEnabled(ctx, inst.WorkspaceID) {
|
||||
idents := extractIdentifiers(p.PullRequest.Title, p.PullRequest.Body, p.PullRequest.Head.Ref)
|
||||
// closingIdents is the subset of identifiers that this PR explicitly
|
||||
// declared via a closing keyword ("Closes/Fixes/Resolves MUL-X").
|
||||
// Linking still happens for every mention (idents above), but the
|
||||
// link row's close_intent column — and therefore whether the
|
||||
// auto-advance gate eventually fires — is only set for keyword-
|
||||
// declared identifiers. Bare title prefixes and branch-name
|
||||
// references are link-only.
|
||||
closingIdents := map[string]struct{}{}
|
||||
for _, c := range extractClosingIdentifiers(p.PullRequest.Title, p.PullRequest.Body) {
|
||||
closingIdents[c] = struct{}{}
|
||||
}
|
||||
// close_intent should follow the PR title/body while the PR is still
|
||||
// editable before its terminal close event. Once GitHub has delivered
|
||||
// a terminal event, later edit/synchronize webhooks must not rewrite
|
||||
// the merge-time close decision.
|
||||
preserveCloseIntent := p.Action != "closed" && (state == "merged" || state == "closed")
|
||||
prefix := h.getIssuePrefix(ctx, inst.WorkspaceID)
|
||||
// reevalIssues collects each issue whose link row we just touched so
|
||||
// we can re-run the auto-advance gate against the persisted aggregate
|
||||
// after every link upsert in this event. Driving the gate off
|
||||
// persisted state (instead of "did *this* webhook declare closing
|
||||
// intent?") is what fixes the multi-PR sibling case: a PR with
|
||||
// `Closes MUL-1` merges first while a link-only sibling is still
|
||||
// open, then the sibling closes later — its webhook has no closing
|
||||
// keyword, but the earlier link row carries close_intent=true, so
|
||||
// MUL-1 still advances.
|
||||
reevalIssues := make([]db.Issue, 0, len(idents))
|
||||
for _, id := range idents {
|
||||
issue, ok := h.lookupIssueByIdentifier(ctx, inst.WorkspaceID, prefix, id)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, declared := closingIdents[id]
|
||||
closeIntent := declared && !preserveCloseIntent
|
||||
if err := h.Queries.LinkIssueToPullRequest(ctx, db.LinkIssueToPullRequestParams{
|
||||
IssueID: issue.ID,
|
||||
PullRequestID: pr.ID,
|
||||
LinkedByType: strToText("system"),
|
||||
LinkedByID: pgtype.UUID{},
|
||||
IssueID: issue.ID,
|
||||
PullRequestID: pr.ID,
|
||||
CloseIntent: closeIntent,
|
||||
PreserveCloseIntent: preserveCloseIntent,
|
||||
LinkedByType: strToText("system"),
|
||||
LinkedByID: pgtype.UUID{},
|
||||
}); err != nil {
|
||||
slog.Warn("github: link failed", "err", err)
|
||||
continue
|
||||
}
|
||||
linkedIssueIDs = append(linkedIssueIDs, uuidToString(issue.ID))
|
||||
reevalIssues = append(reevalIssues, issue)
|
||||
}
|
||||
|
||||
// A terminal PR event (`merged` or `closed`) may be the moment the
|
||||
// last in-flight sibling resolves, so we re-evaluate the issue on
|
||||
// both. We advance the issue to done when:
|
||||
// 1. the issue isn't already terminal (`done` / `cancelled`);
|
||||
// 2. no sibling PR is still `open` / `draft`;
|
||||
// 3. at least one linked PR (this one or a sibling) is `merged`.
|
||||
// Rule (3) prevents an "all closed-without-merge" sequence from
|
||||
// silently auto-closing the issue — if nothing was ever delivered,
|
||||
// the user should decide what to do manually.
|
||||
if (state == "merged" || state == "closed") && issue.Status != "done" && issue.Status != "cancelled" {
|
||||
counts, err := h.Queries.GetSiblingPullRequestStateCountsForIssue(ctx, db.GetSiblingPullRequestStateCountsForIssueParams{
|
||||
IssueID: issue.ID,
|
||||
ID: pr.ID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("github: count sibling pr states failed", "err", err, "issue_id", uuidToString(issue.ID))
|
||||
// A terminal PR event (`merged` or `closed`) may be the moment the
|
||||
// last in-flight sibling resolves. We re-evaluate every issue we
|
||||
// just linked once both the PR row and the link row are persisted,
|
||||
// so the aggregate query sees the freshest state. We advance the
|
||||
// issue to done when:
|
||||
// 1. the issue isn't already terminal (`done` / `cancelled`);
|
||||
// 2. no linked PR is still `open` / `draft`;
|
||||
// 3. at least one merged linked PR declared close_intent (a
|
||||
// "Closes/Fixes/Resolves" keyword on its link row).
|
||||
// Rule (3) is what prevents "Follow up in MUL-2" / "Unblocks MUL-3"
|
||||
// references from being treated the same as "Closes MUL-1", and
|
||||
// also prevents an "all closed-without-merge" sequence from
|
||||
// silently auto-closing the issue — if nothing carrying closing
|
||||
// intent was ever delivered, the user should decide manually.
|
||||
if state == "merged" || state == "closed" {
|
||||
for _, issue := range reevalIssues {
|
||||
if issue.Status == "done" || issue.Status == "cancelled" {
|
||||
continue
|
||||
}
|
||||
anyMerged := state == "merged" || counts.MergedCount > 0
|
||||
if counts.OpenCount == 0 && anyMerged {
|
||||
counts, err := h.Queries.GetIssuePullRequestCloseAggregate(ctx, issue.ID)
|
||||
if err != nil {
|
||||
slog.Warn("github: 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)
|
||||
}
|
||||
}
|
||||
@@ -757,7 +808,7 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
|
||||
// Broadcast PR change to the workspace so any open issue detail page
|
||||
// re-queries its PR list.
|
||||
h.publish(protocol.EventPullRequestUpdated, workspaceID, "system", "", map[string]any{
|
||||
"pull_request": resp,
|
||||
"pull_request": resp,
|
||||
"linked_issue_ids": linkedIssueIDs,
|
||||
})
|
||||
}
|
||||
@@ -990,6 +1041,29 @@ func extractIdentifiers(parts ...string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// extractClosingIdentifiers pulls every "PREFIX-NUMBER" identifier that
|
||||
// appears immediately after a GitHub-style closing keyword in the supplied
|
||||
// fields, deduplicating in input order. Identifiers in branch names are
|
||||
// intentionally excluded — callers should pass only title and body — because
|
||||
// branch names are not natural-language fields and treating "mul-1/fix-login"
|
||||
// as a close declaration would silently re-open the bug this gate is meant
|
||||
// to fix.
|
||||
func extractClosingIdentifiers(parts ...string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := []string{}
|
||||
for _, src := range parts {
|
||||
for _, m := range closingIdentifierRe.FindAllStringSubmatch(src, -1) {
|
||||
ident := strings.ToUpper(m[1]) + "-" + m[2]
|
||||
if _, dup := seen[ident]; dup {
|
||||
continue
|
||||
}
|
||||
seen[ident] = struct{}{}
|
||||
out = append(out, ident)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lookupIssueByIdentifier looks up an issue in the given workspace by its
|
||||
// "PREFIX-NUMBER" identifier. Returns the row + true if the prefix matches
|
||||
// workspaceAutoLinkPRsEnabled reports whether the workspace allows the
|
||||
|
||||
@@ -68,6 +68,72 @@ func TestExtractIdentifiers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClosingIdentifiers(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "single_closes",
|
||||
in: []string{"", "Closes MUL-1"},
|
||||
want: []string{"MUL-1"},
|
||||
},
|
||||
{
|
||||
name: "all_keyword_inflections",
|
||||
in: []string{
|
||||
"",
|
||||
"close MUL-1\nclosed MUL-2\ncloses MUL-3\nfix MUL-4\nfixes MUL-5\nfixed MUL-6\nresolve MUL-7\nresolves MUL-8\nresolved MUL-9",
|
||||
},
|
||||
want: []string{"MUL-1", "MUL-2", "MUL-3", "MUL-4", "MUL-5", "MUL-6", "MUL-7", "MUL-8", "MUL-9"},
|
||||
},
|
||||
{
|
||||
name: "case_insensitive_and_colon",
|
||||
in: []string{"CLOSES: MUL-1", "Fixes:MUL-2 resolves MUL-3"},
|
||||
want: []string{"MUL-1", "MUL-2", "MUL-3"},
|
||||
},
|
||||
{
|
||||
name: "bare_reference_does_not_close",
|
||||
// The bug-report repro: only ABC-1 carries closing intent.
|
||||
// ABC-2/ABC-3 are linked (extractIdentifiers) but must not
|
||||
// appear in the closing set.
|
||||
in: []string{"ABC-1: Lorem Ipsum", "Closes ABC-1. Follow up work planned in ABC-2. Unblocks ABC-3."},
|
||||
want: []string{"ABC-1"},
|
||||
},
|
||||
{
|
||||
name: "keyword_not_adjacent_does_not_close",
|
||||
// "Fix login MUL-1" — keyword present but the identifier is
|
||||
// not adjacent. Consistent with GitHub's closing-keyword
|
||||
// grammar; matches via extractIdentifiers for linking only.
|
||||
in: []string{"Fix login MUL-1", ""},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "dedupe_across_fields",
|
||||
in: []string{"Closes MUL-1", "fixes mul-1"},
|
||||
want: []string{"MUL-1"},
|
||||
},
|
||||
{
|
||||
name: "no_match_on_disclosed_or_foreclose",
|
||||
// Word-boundary guards against keyword fragments embedded
|
||||
// in larger words ("Disclosed MUL-1", "Foreclose MUL-1").
|
||||
in: []string{"Disclosed MUL-1 in foreclose MUL-2", ""},
|
||||
want: []string{},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := extractClosingIdentifiers(tc.in...)
|
||||
if len(got) == 0 && len(tc.want) == 0 {
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("extractClosingIdentifiers() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePRState(t *testing.T) {
|
||||
cases := []struct {
|
||||
state string
|
||||
@@ -205,7 +271,7 @@ func TestWebhook_MergedPR_AdvancesLinkedIssueToDone(t *testing.T) {
|
||||
"number": 1234,
|
||||
"html_url": "https://github.com/acme/widget/pull/1234",
|
||||
"title": "Fix login " + created.Identifier,
|
||||
"body": "",
|
||||
"body": "Closes " + created.Identifier,
|
||||
"state": "closed",
|
||||
"draft": false,
|
||||
"merged": true,
|
||||
@@ -676,6 +742,506 @@ func TestWebhook_AllClosedWithoutMerge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// fireBareWebhook is a focused helper for the closing-keyword gate tests
|
||||
// below: it fires a single merged-PR webhook with caller-controlled title,
|
||||
// body, and branch so each test can exercise a specific PR-grammar shape
|
||||
// (bare identifier, mixed closing/non-closing references, branch-only
|
||||
// reference) without re-typing the full webhook envelope each time.
|
||||
func fireBareWebhook(t *testing.T, secret string, installationID int64, prNumber int32, title, body, branch string) {
|
||||
t.Helper()
|
||||
payload := map[string]any{
|
||||
"action": "closed",
|
||||
"pull_request": map[string]any{
|
||||
"number": prNumber,
|
||||
"html_url": fmt.Sprintf("https://github.com/acme/widget/pull/%d", prNumber),
|
||||
"title": title,
|
||||
"body": body,
|
||||
"state": "closed",
|
||||
"draft": false,
|
||||
"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": branch},
|
||||
"user": map[string]any{"login": "octocat"},
|
||||
},
|
||||
"repository": map[string]any{"name": "widget", "owner": map[string]any{"login": "acme"}},
|
||||
"installation": map[string]any{"id": installationID},
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(raw)
|
||||
sig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewReader(raw))
|
||||
req.Header.Set("X-GitHub-Event", "pull_request")
|
||||
req.Header.Set("X-Hub-Signature-256", sig)
|
||||
testHandler.HandleGitHubWebhook(rec, req)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("webhook pr=%d: expected 202, got %d (%s)", prNumber, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_MergedPR_OnlyClosesIdentifiersWithClosingKeyword is the repro
|
||||
// from GitHub issue multica-ai/multica#3264: a PR that mentions three issues
|
||||
// must only auto-complete the one declared with a closing keyword. Follow-up
|
||||
// / unblocks references are linked but stay in their previous status.
|
||||
func TestWebhook_MergedPR_OnlyClosesIdentifiersWithClosingKeyword(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("handler test fixture not initialized (no DB?)")
|
||||
}
|
||||
ctx := context.Background()
|
||||
secret := "closing-keyword-secret"
|
||||
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
|
||||
|
||||
// Three issues to mention in the same PR body.
|
||||
createIssue := func(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 %q: %d %s", title, w.Code, w.Body.String())
|
||||
}
|
||||
var out IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&out)
|
||||
return out
|
||||
}
|
||||
closes := createIssue("primary work")
|
||||
followUp := createIssue("follow up work")
|
||||
unblocks := createIssue("unblocked work")
|
||||
|
||||
t.Cleanup(func() {
|
||||
for _, id := range []string{closes.ID, followUp.ID, unblocks.ID} {
|
||||
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, id)
|
||||
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, id)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, id)
|
||||
}
|
||||
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
|
||||
testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID)
|
||||
})
|
||||
|
||||
const installationID int64 = 30264001
|
||||
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
|
||||
WorkspaceID: parseUUID(testWorkspaceID),
|
||||
InstallationID: installationID,
|
||||
AccountLogin: "closing-keyword-acct",
|
||||
AccountType: "User",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateGitHubInstallation: %v", err)
|
||||
}
|
||||
|
||||
// PR title mirrors the reporter's repro shape — bare identifier prefix —
|
||||
// and body declares closing intent on `closes` only.
|
||||
title := closes.Identifier + ": Lorem Ipsum dolor sit amet"
|
||||
body := fmt.Sprintf(
|
||||
"Closes %s. Follow up work planned in %s. Unblocks %s.",
|
||||
closes.Identifier, followUp.Identifier, unblocks.Identifier,
|
||||
)
|
||||
fireBareWebhook(t, secret, installationID, 1, title, body, "fix/login")
|
||||
|
||||
// All three should be linked — auto-link layer is intentionally generous.
|
||||
for _, issue := range []IssueResponse{closes, followUp, unblocks} {
|
||||
linked, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(issue.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("ListPullRequestsByIssue(%s): %v", issue.Identifier, err)
|
||||
}
|
||||
if len(linked) != 1 {
|
||||
t.Errorf("expected %s to be linked to the PR, got %d link rows", issue.Identifier, len(linked))
|
||||
}
|
||||
}
|
||||
|
||||
// Only the closing-keyword identifier advances to done.
|
||||
wantStatus := map[string]string{
|
||||
closes.ID: "done",
|
||||
followUp.ID: "in_progress",
|
||||
unblocks.ID: "in_progress",
|
||||
}
|
||||
for _, issue := range []IssueResponse{closes, followUp, unblocks} {
|
||||
got, err := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue(%s): %v", issue.Identifier, err)
|
||||
}
|
||||
if got.Status != wantStatus[issue.ID] {
|
||||
t.Errorf("issue %s: status = %q, want %q", issue.Identifier, got.Status, wantStatus[issue.ID])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_MergedPR_TitlePrefixDoesNotClose locks in the design choice
|
||||
// that a bare "MUL-X: foo" title (no closing keyword) links but never
|
||||
// auto-completes. The user must write `Closes MUL-X` somewhere if they want
|
||||
// the merge to flip the status.
|
||||
func TestWebhook_MergedPR_TitlePrefixDoesNotClose(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("handler test fixture not initialized (no DB?)")
|
||||
}
|
||||
ctx := context.Background()
|
||||
secret := "title-prefix-secret"
|
||||
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "title-prefix repro",
|
||||
"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)
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
|
||||
testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
|
||||
})
|
||||
|
||||
const installationID int64 = 30264002
|
||||
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
|
||||
WorkspaceID: parseUUID(testWorkspaceID),
|
||||
InstallationID: installationID,
|
||||
AccountLogin: "title-prefix-acct",
|
||||
AccountType: "User",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateGitHubInstallation: %v", err)
|
||||
}
|
||||
|
||||
fireBareWebhook(t, secret, installationID, 2, created.Identifier+": fix something", "", "fix/login")
|
||||
|
||||
linked, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("ListPullRequestsByIssue: %v", err)
|
||||
}
|
||||
if len(linked) != 1 {
|
||||
t.Errorf("expected 1 linked PR even without a closing keyword, got %d", len(linked))
|
||||
}
|
||||
|
||||
got, err := testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue: %v", err)
|
||||
}
|
||||
if got.Status != "in_progress" {
|
||||
t.Errorf("expected issue to stay in_progress (title prefix alone is not closing intent), got %q", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_MergedPR_BranchNameDoesNotClose guards the conservative design
|
||||
// decision that identifiers extracted from the branch name link the PR but
|
||||
// never auto-complete the issue — branch names are not natural-language
|
||||
// fields and cannot carry a closing keyword.
|
||||
func TestWebhook_MergedPR_BranchNameDoesNotClose(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("handler test fixture not initialized (no DB?)")
|
||||
}
|
||||
ctx := context.Background()
|
||||
secret := "branch-name-secret"
|
||||
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "branch-name repro",
|
||||
"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)
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
|
||||
testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
|
||||
})
|
||||
|
||||
const installationID int64 = 30264003
|
||||
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
|
||||
WorkspaceID: parseUUID(testWorkspaceID),
|
||||
InstallationID: installationID,
|
||||
AccountLogin: "branch-name-acct",
|
||||
AccountType: "User",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateGitHubInstallation: %v", err)
|
||||
}
|
||||
|
||||
branch := strings.ToLower(created.Identifier) + "/fix-login"
|
||||
fireBareWebhook(t, secret, installationID, 3, "Fix login flow", "", branch)
|
||||
|
||||
linked, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("ListPullRequestsByIssue: %v", err)
|
||||
}
|
||||
if len(linked) != 1 {
|
||||
t.Errorf("expected branch-name reference to still link the PR, got %d link rows", len(linked))
|
||||
}
|
||||
|
||||
got, err := testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue: %v", err)
|
||||
}
|
||||
if got.Status != "in_progress" {
|
||||
t.Errorf("expected issue to stay in_progress (branch-name reference is not closing intent), got %q", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// firePRWebhook fires a webhook for a single PR with caller-controlled
|
||||
// title, body, branch, and lifecycle (open / merged / closed without
|
||||
// merge). Tests below need the open→merged sequence so close_intent on
|
||||
// one PR has to persist across multiple webhook events for a sibling PR.
|
||||
func firePRWebhook(t *testing.T, secret string, installationID int64, prNumber int32, title, body, branch, lifecycle string) {
|
||||
t.Helper()
|
||||
var action, state string
|
||||
var merged bool
|
||||
var mergedAt, closedAt any
|
||||
switch lifecycle {
|
||||
case "opened":
|
||||
action, state, merged = "opened", "open", false
|
||||
mergedAt, closedAt = nil, nil
|
||||
case "edited":
|
||||
action, state, merged = "edited", "open", false
|
||||
mergedAt, closedAt = nil, nil
|
||||
case "merged":
|
||||
action, state, merged = "closed", "closed", true
|
||||
mergedAt, closedAt = "2026-04-29T00:00:00Z", "2026-04-29T00:00:00Z"
|
||||
case "edited_merged":
|
||||
action, state, merged = "edited", "closed", true
|
||||
mergedAt, closedAt = "2026-04-29T00:00:00Z", "2026-04-29T00:00:00Z"
|
||||
case "closed":
|
||||
action, state, merged = "closed", "closed", false
|
||||
mergedAt, closedAt = nil, "2026-04-29T00:00:00Z"
|
||||
default:
|
||||
t.Fatalf("firePRWebhook: unknown lifecycle %q", lifecycle)
|
||||
}
|
||||
payload := map[string]any{
|
||||
"action": action,
|
||||
"pull_request": map[string]any{
|
||||
"number": prNumber,
|
||||
"html_url": fmt.Sprintf("https://github.com/acme/widget/pull/%d", prNumber),
|
||||
"title": title,
|
||||
"body": body,
|
||||
"state": state,
|
||||
"draft": false,
|
||||
"merged": merged,
|
||||
"merged_at": mergedAt,
|
||||
"closed_at": closedAt,
|
||||
"created_at": "2026-04-28T00:00:00Z",
|
||||
"updated_at": "2026-04-29T00:00:00Z",
|
||||
"head": map[string]any{"ref": branch},
|
||||
"user": map[string]any{"login": "octocat"},
|
||||
},
|
||||
"repository": map[string]any{"name": "widget", "owner": map[string]any{"login": "acme"}},
|
||||
"installation": map[string]any{"id": installationID},
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(raw)
|
||||
sig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewReader(raw))
|
||||
req.Header.Set("X-GitHub-Event", "pull_request")
|
||||
req.Header.Set("X-Hub-Signature-256", sig)
|
||||
testHandler.HandleGitHubWebhook(rec, req)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("webhook pr=%d (%s): expected 202, got %d (%s)", prNumber, lifecycle, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhook_CloseKeywordRemovedBeforeMergeDoesNotClose(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("handler test fixture not initialized (no DB?)")
|
||||
}
|
||||
ctx := context.Background()
|
||||
secret := "close-intent-removal-secret"
|
||||
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "close intent can be removed",
|
||||
"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)
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
|
||||
testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
|
||||
})
|
||||
|
||||
const installationID int64 = 30264005
|
||||
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
|
||||
WorkspaceID: parseUUID(testWorkspaceID),
|
||||
InstallationID: installationID,
|
||||
AccountLogin: "close-intent-removal-acct",
|
||||
AccountType: "User",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateGitHubInstallation: %v", err)
|
||||
}
|
||||
|
||||
firePRWebhook(t, secret, installationID, 1, "Implement removal path", "Closes "+created.Identifier, "feat/remove-close-intent", "opened")
|
||||
firePRWebhook(t, secret, installationID, 1, "Implement removal path", "Related "+created.Identifier, "feat/remove-close-intent", "edited")
|
||||
firePRWebhook(t, secret, installationID, 1, "Implement removal path", "Related "+created.Identifier, "feat/remove-close-intent", "merged")
|
||||
|
||||
got, err := testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue after merge: %v", err)
|
||||
}
|
||||
if got.Status != "in_progress" {
|
||||
t.Fatalf("after closing keyword was removed before merge: status = %q, want in_progress", got.Status)
|
||||
}
|
||||
counts, err := testHandler.Queries.GetIssuePullRequestCloseAggregate(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssuePullRequestCloseAggregate: %v", err)
|
||||
}
|
||||
if counts.MergedWithCloseIntentCount != 0 {
|
||||
t.Fatalf("merged_with_close_intent_count = %d, want 0", counts.MergedWithCloseIntentCount)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "post merge close keyword is link only",
|
||||
"status": "in_progress",
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue second: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var second IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&second)
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, second.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, second.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, second.ID)
|
||||
})
|
||||
|
||||
// Adding a closing keyword after the merge must not rewrite the
|
||||
// merge-time decision and retroactively close either an existing link
|
||||
// or a newly mentioned issue.
|
||||
firePRWebhook(t, secret, installationID, 1, "Implement removal path", "Closes "+created.Identifier+"\nCloses "+second.Identifier, "feat/remove-close-intent", "edited_merged")
|
||||
got, err = testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue after post-merge edit: %v", err)
|
||||
}
|
||||
if got.Status != "in_progress" {
|
||||
t.Errorf("after adding closing keyword post-merge: status = %q, want in_progress", got.Status)
|
||||
}
|
||||
got, err = testHandler.Queries.GetIssue(ctx, parseUUID(second.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue second after post-merge edit: %v", err)
|
||||
}
|
||||
if got.Status != "in_progress" {
|
||||
t.Errorf("second issue after post-merge closing keyword: status = %q, want in_progress", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_LinkOnlySiblingMergeAfterCloseKeywordPR is the regression
|
||||
// guard for the multi-PR sibling case Elon flagged on the first attempt
|
||||
// of this fix. Scenario:
|
||||
//
|
||||
// 1. PR A declares closing intent (`Closes MUL-X`) and is opened.
|
||||
// 2. PR B references the same issue (link-only — no closing keyword)
|
||||
// and is opened.
|
||||
// 3. PR A merges. The issue stays in_progress because PR B is open.
|
||||
// 4. PR B merges later. PR B's webhook has no closing keyword, so the
|
||||
// previous implementation skipped re-evaluating the issue and the
|
||||
// issue stayed stuck in_progress forever.
|
||||
//
|
||||
// The persisted close_intent column on issue_pull_request fixes this:
|
||||
// the aggregate sees PR A's merged+close_intent row regardless of which
|
||||
// webhook drives the re-evaluation, so PR B's merge advances the issue.
|
||||
func TestWebhook_LinkOnlySiblingMergeAfterCloseKeywordPR(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("handler test fixture not initialized (no DB?)")
|
||||
}
|
||||
ctx := context.Background()
|
||||
secret := "link-only-sibling-secret"
|
||||
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "needs two prs",
|
||||
"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)
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, created.ID)
|
||||
testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID)
|
||||
testPool.Exec(ctx, `DELETE FROM github_installation WHERE workspace_id = $1`, testWorkspaceID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
|
||||
})
|
||||
|
||||
const installationID int64 = 30264004
|
||||
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
|
||||
WorkspaceID: parseUUID(testWorkspaceID),
|
||||
InstallationID: installationID,
|
||||
AccountLogin: "link-only-sibling-acct",
|
||||
AccountType: "User",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateGitHubInstallation: %v", err)
|
||||
}
|
||||
|
||||
// 1) PR A opens with closing intent.
|
||||
firePRWebhook(t, secret, installationID, 1, "Implement primary path", "Closes "+created.Identifier, "feat/primary", "opened")
|
||||
// 2) PR B opens link-only — title prefix mention, no closing keyword.
|
||||
firePRWebhook(t, secret, installationID, 2, created.Identifier+": follow-up cleanup", "", "feat/cleanup", "opened")
|
||||
|
||||
// Sanity: issue is still in_progress (both PRs open).
|
||||
got, err := testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue after open: %v", err)
|
||||
}
|
||||
if got.Status != "in_progress" {
|
||||
t.Fatalf("after both PRs opened: status = %q, want in_progress", got.Status)
|
||||
}
|
||||
|
||||
// 3) PR A merges. PR B still open → issue stays in_progress.
|
||||
firePRWebhook(t, secret, installationID, 1, "Implement primary path", "Closes "+created.Identifier, "feat/primary", "merged")
|
||||
got, err = testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue after A merge: %v", err)
|
||||
}
|
||||
if got.Status != "in_progress" {
|
||||
t.Fatalf("after PR A merged with PR B still open: status = %q, want in_progress", got.Status)
|
||||
}
|
||||
|
||||
// 4) PR B merges (link-only, no closing keyword). The persisted
|
||||
// close_intent on PR A's link must still carry the advance.
|
||||
firePRWebhook(t, secret, installationID, 2, created.Identifier+": follow-up cleanup", "", "feat/cleanup", "merged")
|
||||
got, err = testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue after B merge: %v", err)
|
||||
}
|
||||
if got.Status != "done" {
|
||||
t.Errorf("after both PRs merged (A with close_intent, B link-only): status = %q, want done", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// ── CI / mergeable_state tests ─────────────────────────────────────────────
|
||||
|
||||
func TestDerivePRMergeableState(t *testing.T) {
|
||||
@@ -720,9 +1286,9 @@ func TestAggregateChecksConclusion(t *testing.T) {
|
||||
return *p
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
name string
|
||||
failed, passed, pending, total int64
|
||||
want string
|
||||
want string
|
||||
}{
|
||||
{"no_suites_nil", 0, 0, 0, 0, "<nil>"},
|
||||
{"any_failure_wins", 1, 5, 0, 6, "failed"},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE issue_pull_request
|
||||
DROP COLUMN close_intent;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Persist whether a PR ↔ issue link was created with explicit closing intent
|
||||
-- (a "Closes/Fixes/Resolves PREFIX-N" keyword in the PR title or body). The
|
||||
-- webhook auto-advance gate consults this column so a link-only sibling PR
|
||||
-- closing later still resolves an issue that an earlier closing-keyword PR
|
||||
-- had blocked from advancing.
|
||||
ALTER TABLE issue_pull_request
|
||||
ADD COLUMN close_intent BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -183,65 +183,74 @@ func (q *Queries) GetGitHubPullRequest(ctx context.Context, arg GetGitHubPullReq
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSiblingPullRequestStateCountsForIssue = `-- name: GetSiblingPullRequestStateCountsForIssue :one
|
||||
const getIssuePullRequestCloseAggregate = `-- name: GetIssuePullRequestCloseAggregate :one
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN pr.state IN ('open', 'draft') THEN 1 ELSE 0 END), 0)::bigint AS open_count,
|
||||
COALESCE(SUM(CASE WHEN pr.state = 'merged' THEN 1 ELSE 0 END), 0)::bigint AS merged_count
|
||||
COALESCE(SUM(CASE WHEN pr.state = 'merged' AND ipr.close_intent THEN 1 ELSE 0 END), 0)::bigint AS merged_with_close_intent_count
|
||||
FROM github_pull_request pr
|
||||
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
|
||||
WHERE ipr.issue_id = $1
|
||||
AND pr.id <> $2
|
||||
`
|
||||
|
||||
type GetSiblingPullRequestStateCountsForIssueParams struct {
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
type GetIssuePullRequestCloseAggregateRow struct {
|
||||
OpenCount int64 `json:"open_count"`
|
||||
MergedWithCloseIntentCount int64 `json:"merged_with_close_intent_count"`
|
||||
}
|
||||
|
||||
type GetSiblingPullRequestStateCountsForIssueRow struct {
|
||||
OpenCount int64 `json:"open_count"`
|
||||
MergedCount int64 `json:"merged_count"`
|
||||
}
|
||||
|
||||
// Returns, for the PRs linked to an issue excluding one PR by id (the PR
|
||||
// currently being processed by the webhook handler), how many are still in
|
||||
// flight (open or draft) and how many have already merged. The webhook
|
||||
// handler combines these with the current event's state to decide whether
|
||||
// to auto-advance the issue: the issue moves to done only when there is no
|
||||
// in-flight sibling AND at least one linked PR (current or sibling) merged.
|
||||
func (q *Queries) GetSiblingPullRequestStateCountsForIssue(ctx context.Context, arg GetSiblingPullRequestStateCountsForIssueParams) (GetSiblingPullRequestStateCountsForIssueRow, error) {
|
||||
row := q.db.QueryRow(ctx, getSiblingPullRequestStateCountsForIssue, arg.IssueID, arg.ID)
|
||||
var i GetSiblingPullRequestStateCountsForIssueRow
|
||||
err := row.Scan(&i.OpenCount, &i.MergedCount)
|
||||
// Aggregates the issue's linked PRs into the two counts that gate
|
||||
// auto-advance: how many are still in flight (`open` or `draft`) and how
|
||||
// many merged PRs declared explicit closing intent on the link row. The
|
||||
// webhook auto-advances the issue when open_count = 0 AND
|
||||
// merged_with_close_intent_count > 0. Both the PR state and the link row
|
||||
// (with close_intent) are persisted before this query runs, so the result
|
||||
// is event-agnostic — a link-only sibling closing after a closing-keyword
|
||||
// PR has already merged still resolves the issue.
|
||||
func (q *Queries) GetIssuePullRequestCloseAggregate(ctx context.Context, issueID pgtype.UUID) (GetIssuePullRequestCloseAggregateRow, error) {
|
||||
row := q.db.QueryRow(ctx, getIssuePullRequestCloseAggregate, issueID)
|
||||
var i GetIssuePullRequestCloseAggregateRow
|
||||
err := row.Scan(&i.OpenCount, &i.MergedWithCloseIntentCount)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const linkIssueToPullRequest = `-- name: LinkIssueToPullRequest :exec
|
||||
|
||||
INSERT INTO issue_pull_request (
|
||||
issue_id, pull_request_id, linked_by_type, linked_by_id
|
||||
issue_id, pull_request_id, linked_by_type, linked_by_id, close_intent
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
$1, $2, $4, $5, $3
|
||||
)
|
||||
ON CONFLICT (issue_id, pull_request_id) DO NOTHING
|
||||
ON CONFLICT (issue_id, pull_request_id) DO UPDATE SET
|
||||
close_intent = CASE
|
||||
WHEN $6 THEN issue_pull_request.close_intent
|
||||
ELSE EXCLUDED.close_intent
|
||||
END
|
||||
`
|
||||
|
||||
type LinkIssueToPullRequestParams struct {
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
PullRequestID pgtype.UUID `json:"pull_request_id"`
|
||||
LinkedByType pgtype.Text `json:"linked_by_type"`
|
||||
LinkedByID pgtype.UUID `json:"linked_by_id"`
|
||||
IssueID pgtype.UUID `json:"issue_id"`
|
||||
PullRequestID pgtype.UUID `json:"pull_request_id"`
|
||||
CloseIntent bool `json:"close_intent"`
|
||||
LinkedByType pgtype.Text `json:"linked_by_type"`
|
||||
LinkedByID pgtype.UUID `json:"linked_by_id"`
|
||||
PreserveCloseIntent bool `json:"preserve_close_intent"`
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Issue ↔ Pull Request link
|
||||
// =====================
|
||||
// close_intent reflects the PR's explicit close declaration at the moment
|
||||
// the webhook is allowed to update that intent. Open/edit/merge webhooks use
|
||||
// the current title/body parse result so authors can remove a closing keyword
|
||||
// before merge. Post-terminal edits can opt into preserving the stored value,
|
||||
// keeping the merge-time decision stable.
|
||||
func (q *Queries) LinkIssueToPullRequest(ctx context.Context, arg LinkIssueToPullRequestParams) error {
|
||||
_, err := q.db.Exec(ctx, linkIssueToPullRequest,
|
||||
arg.IssueID,
|
||||
arg.PullRequestID,
|
||||
arg.CloseIntent,
|
||||
arg.LinkedByType,
|
||||
arg.LinkedByID,
|
||||
arg.PreserveCloseIntent,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -380,6 +380,7 @@ type IssuePullRequest struct {
|
||||
LinkedByType pgtype.Text `json:"linked_by_type"`
|
||||
LinkedByID pgtype.UUID `json:"linked_by_id"`
|
||||
LinkedAt pgtype.Timestamptz `json:"linked_at"`
|
||||
CloseIntent bool `json:"close_intent"`
|
||||
}
|
||||
|
||||
type IssueReaction struct {
|
||||
|
||||
@@ -151,20 +151,21 @@ ORDER BY pr.pr_created_at DESC;
|
||||
SELECT issue_id FROM issue_pull_request
|
||||
WHERE pull_request_id = $1;
|
||||
|
||||
-- name: GetSiblingPullRequestStateCountsForIssue :one
|
||||
-- Returns, for the PRs linked to an issue excluding one PR by id (the PR
|
||||
-- currently being processed by the webhook handler), how many are still in
|
||||
-- flight (open or draft) and how many have already merged. The webhook
|
||||
-- handler combines these with the current event's state to decide whether
|
||||
-- to auto-advance the issue: the issue moves to done only when there is no
|
||||
-- in-flight sibling AND at least one linked PR (current or sibling) merged.
|
||||
-- name: GetIssuePullRequestCloseAggregate :one
|
||||
-- Aggregates the issue's linked PRs into the two counts that gate
|
||||
-- auto-advance: how many are still in flight (`open` or `draft`) and how
|
||||
-- many merged PRs declared explicit closing intent on the link row. The
|
||||
-- webhook auto-advances the issue when open_count = 0 AND
|
||||
-- merged_with_close_intent_count > 0. Both the PR state and the link row
|
||||
-- (with close_intent) are persisted before this query runs, so the result
|
||||
-- is event-agnostic — a link-only sibling closing after a closing-keyword
|
||||
-- PR has already merged still resolves the issue.
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN pr.state IN ('open', 'draft') THEN 1 ELSE 0 END), 0)::bigint AS open_count,
|
||||
COALESCE(SUM(CASE WHEN pr.state = 'merged' THEN 1 ELSE 0 END), 0)::bigint AS merged_count
|
||||
COALESCE(SUM(CASE WHEN pr.state = 'merged' AND ipr.close_intent THEN 1 ELSE 0 END), 0)::bigint AS merged_with_close_intent_count
|
||||
FROM github_pull_request pr
|
||||
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
|
||||
WHERE ipr.issue_id = $1
|
||||
AND pr.id <> $2;
|
||||
WHERE ipr.issue_id = $1;
|
||||
|
||||
-- =====================
|
||||
-- GitHub PR check suite
|
||||
@@ -195,12 +196,21 @@ WHERE EXCLUDED.updated_at >= github_pull_request_check_suite.updated_at;
|
||||
-- =====================
|
||||
|
||||
-- name: LinkIssueToPullRequest :exec
|
||||
-- close_intent reflects the PR's explicit close declaration at the moment
|
||||
-- the webhook is allowed to update that intent. Open/edit/merge webhooks use
|
||||
-- the current title/body parse result so authors can remove a closing keyword
|
||||
-- before merge. Post-terminal edits can opt into preserving the stored value,
|
||||
-- keeping the merge-time decision stable.
|
||||
INSERT INTO issue_pull_request (
|
||||
issue_id, pull_request_id, linked_by_type, linked_by_id
|
||||
issue_id, pull_request_id, linked_by_type, linked_by_id, close_intent
|
||||
) VALUES (
|
||||
$1, $2, sqlc.narg('linked_by_type'), sqlc.narg('linked_by_id')
|
||||
$1, $2, sqlc.narg('linked_by_type'), sqlc.narg('linked_by_id'), $3
|
||||
)
|
||||
ON CONFLICT (issue_id, pull_request_id) DO NOTHING;
|
||||
ON CONFLICT (issue_id, pull_request_id) DO UPDATE SET
|
||||
close_intent = CASE
|
||||
WHEN sqlc.arg('preserve_close_intent') THEN issue_pull_request.close_intent
|
||||
ELSE EXCLUDED.close_intent
|
||||
END;
|
||||
|
||||
-- name: UnlinkIssueFromPullRequest :exec
|
||||
DELETE FROM issue_pull_request
|
||||
|
||||
Reference in New Issue
Block a user