Compare commits

...

3 Commits

Author SHA1 Message Date
Eve
7ba3a5ad6e Fix GitHub close intent updates
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 16:42:08 +08:00
Jiang Bohan
2d44ad36ce fix(server): persist close_intent on issue↔PR link rows (MUL-2680)
The first take of MUL-2680 gated auto-advance on `closingIdents[id]` from
the current webhook event. That broke the multi-PR sibling case: a PR
declaring `Closes MUL-X` could merge first while a link-only sibling
stayed open, leaving the issue in_progress; when the sibling closed
later, its webhook carried no closing keyword and the handler skipped
re-evaluation, so the issue stayed stuck forever.

Move close intent from per-event state to per-link state:

- New `close_intent` column on `issue_pull_request` (migration 109),
  set monotonically — `LinkIssueToPullRequest` ORs the existing flag with
  the incoming one so a subsequent webhook re-fire without the keyword
  cannot clear it.
- New `GetIssuePullRequestCloseAggregate` query returns open-count and
  merged-with-close-intent-count for an issue. The auto-advance gate
  now reads from this persisted aggregate, which is event-agnostic: any
  terminal linked-PR event re-evaluates and the verdict only depends on
  accumulated DB state.
- Webhook handler links all mentioned identifiers first (writing
  close_intent for the ones declared with a keyword), then iterates the
  affected issues in a separate pass to re-evaluate. The 'only fires for
  keyword-declared identifiers in this event' gate is gone — replaced by
  `merged_with_close_intent_count > 0` against the link rows.

Regression test `TestWebhook_LinkOnlySiblingMergeAfterCloseKeywordPR`
walks the full open→merge→open→merge sequence Elon described and asserts
the issue advances on the link-only sibling's merge.

MUL-2680

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 16:24:21 +08:00
Jiang Bohan
38bcc91b64 fix(server): gate GitHub auto-close on closing keywords (MUL-2680)
Closes multica-ai/multica#3264. The PR webhook previously treated any
mention of an issue identifier in a PR title/body/branch as a close
intent, so a body of "Closes MUL-1. Follow up in MUL-2. Unblocks MUL-3."
would advance all three issues to done on merge. The auto-link layer
stays generous (mentions still link the PR), but advancing to done now
requires an explicit "Closes/Fixes/Resolves MUL-X" keyword adjacent to
the identifier in the title or body — bare title prefixes (`MUL-1: ...`)
and branch-name references no longer auto-complete.

MUL-2680

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 16:04:23 +08:00
7 changed files with 761 additions and 92 deletions

View File

@@ -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

View File

@@ -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"},

View File

@@ -0,0 +1,2 @@
ALTER TABLE issue_pull_request
DROP COLUMN close_intent;

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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