Compare commits

...

2 Commits

Author SHA1 Message Date
Lambda
28352764da fix(github): exclude reference_only links from the close aggregate
A reference_only link is hidden from the issue PR list, but
GetIssuePullRequestCloseAggregate still counted it toward open_count.
An open body-only mention ("Related to MUL-X") could therefore block
the issue from auto-advancing to `done` after a real closing PR merged,
while being invisible in the right-side PR list.

Filter `AND NOT reference_only` in the aggregate too (reference_only
rows never carry close_intent, so merged_with_close_intent_count is
unchanged). Add TestWebhook_HiddenBodyMentionDoesNotBlockAutoAdvance.

Addresses code review on PR #4611.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 17:05:25 +08:00
Lambda
5e9b76e683 fix(github): hide reference-only PR links from the issue PR list
A PR that merely mentions an issue key in passing in its description
(e.g. "Related to MUL-3739") was auto-linked and shown in that issue's
right-side PR list as if it were a working PR for the issue.

Add a reference_only flag to issue_pull_request. The webhook keeps
linking generously (so close_intent stays trackable across edits) but
flags a link as reference_only unless the key is a genuine target: a
title prefix, a branch reference, or a body closing keyword
(Closes/Fixes/Resolves). ListPullRequestsByIssue filters
reference_only rows, so passing body mentions are hidden from the CLI
and the UI PR list while real targets remain. reference_only follows
the same terminal preserve gate as close_intent; the auto-advance gate
is unchanged.

Closes MUL-3739

Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 16:22:57 +08:00
9 changed files with 302 additions and 24 deletions

View File

@@ -887,6 +887,21 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
for _, c := range extractClosingIdentifiers(p.PullRequest.Title, p.PullRequest.Body) {
closingIdents[c] = struct{}{}
}
// qualifyingIdents are the identifiers that genuinely tie this PR to an
// issue: a title prefix, a branch-name reference, or a body closing
// keyword. Any identifier that is linked but NOT in this set was matched
// only by a bare mention in the PR body ("Related MUL-1", "Follow up in
// MUL-1"). Those links are still recorded (auto-link stays generous so
// close_intent can be tracked across edits) but are flagged
// reference_only and hidden from the issue's PR list — a passing mention
// should not surface the PR as a working PR for that issue (MUL-3739).
qualifyingIdents := map[string]struct{}{}
for _, id := range extractIdentifiers(p.PullRequest.Title, p.PullRequest.Head.Ref) {
qualifyingIdents[id] = struct{}{}
}
for c := range closingIdents {
qualifyingIdents[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
@@ -910,10 +925,13 @@ func (h *Handler) handlePullRequestEvent(ctx context.Context, body []byte) {
}
_, declared := closingIdents[id]
closeIntent := declared && !preserveCloseIntent
_, qualifies := qualifyingIdents[id]
referenceOnly := !qualifies
if err := h.Queries.LinkIssueToPullRequest(ctx, db.LinkIssueToPullRequestParams{
IssueID: issue.ID,
PullRequestID: pr.ID,
CloseIntent: closeIntent,
ReferenceOnly: referenceOnly,
PreserveCloseIntent: preserveCloseIntent,
LinkedByType: strToText("system"),
LinkedByID: pgtype.UUID{},

View File

@@ -852,14 +852,36 @@ func TestWebhook_MergedPR_OnlyClosesIdentifiersWithClosingKeyword(t *testing.T)
)
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))
// The closing-keyword issue (also a bare title prefix) is a genuine target,
// so it shows in the PR list. The follow-up / unblocks issues are matched
// only by a bare body mention — auto-link still records the row (generous),
// but the link is reference_only and excluded from the issue's PR list
// (MUL-3739).
listed, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(closes.ID))
if err != nil {
t.Fatalf("ListPullRequestsByIssue(%s): %v", closes.Identifier, err)
}
if len(listed) != 1 {
t.Errorf("expected %s (closing keyword) to show in the PR list, got %d rows", closes.Identifier, len(listed))
}
for _, issue := range []IssueResponse{followUp, unblocks} {
listed, 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))
if len(listed) != 0 {
t.Errorf("expected %s (bare body mention) to be hidden from the PR list, got %d rows", issue.Identifier, len(listed))
}
// The link row still exists — flagged reference_only, not deleted — so
// close_intent stays trackable across later edits.
var refOnly bool
if err := testPool.QueryRow(ctx,
`SELECT reference_only FROM issue_pull_request WHERE issue_id = $1`, issue.ID,
).Scan(&refOnly); err != nil {
t.Fatalf("query reference_only(%s): %v", issue.Identifier, err)
}
if !refOnly {
t.Errorf("expected %s link to be reference_only, got false", issue.Identifier)
}
}
@@ -1249,6 +1271,157 @@ func TestWebhook_LinkOnlySiblingMergeAfterCloseKeywordPR(t *testing.T) {
}
}
// TestWebhook_BareBodyMentionHiddenFromPRList is the regression guard for
// MUL-3739: a PR that only mentions an issue identifier in its body (no closing
// keyword, no title prefix, no branch reference) must not appear in that
// issue's PR list. Editing the body to add/remove a closing keyword flips the
// PR's visibility, because reference_only follows the live title/body parse
// while the PR is still open.
func TestWebhook_BareBodyMentionHiddenFromPRList(t *testing.T) {
if testHandler == nil {
t.Skip("handler test fixture not initialized (no DB?)")
}
ctx := context.Background()
secret := "bare-mention-secret"
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "mentioned in passing",
"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 = 30264006
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
WorkspaceID: parseUUID(testWorkspaceID),
InstallationID: installationID,
AccountLogin: "bare-mention-acct",
AccountType: "User",
}); err != nil {
t.Fatalf("CreateGitHubInstallation: %v", err)
}
listLen := func() int {
t.Helper()
rows, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("ListPullRequestsByIssue: %v", err)
}
return len(rows)
}
// 1) Opened with only a bare body mention → hidden from the PR list.
firePRWebhook(t, secret, installationID, 1, "Unrelated cleanup", "Context for reviewers: see "+created.Identifier, "feat/cleanup", "opened")
if n := listLen(); n != 0 {
t.Errorf("bare body mention should be hidden from PR list, got %d rows", n)
}
// 2) Edited to declare closing intent → now a genuine target, shown.
firePRWebhook(t, secret, installationID, 1, "Unrelated cleanup", "Closes "+created.Identifier, "feat/cleanup", "edited")
if n := listLen(); n != 1 {
t.Errorf("after adding a closing keyword the PR should show, got %d rows", n)
}
// 3) Edited back to a bare mention → hidden again.
firePRWebhook(t, secret, installationID, 1, "Unrelated cleanup", "Reverting: just referencing "+created.Identifier, "feat/cleanup", "edited")
if n := listLen(); n != 0 {
t.Errorf("after removing the closing keyword the PR should be hidden again, got %d rows", n)
}
}
// TestWebhook_HiddenBodyMentionDoesNotBlockAutoAdvance guards the P1 the code
// review flagged on PR #4611: a reference_only link (a PR that only mentions the
// issue in its body) is hidden from the PR list, so it must not silently gate
// auto-advance either. Here PR B stays open with a bare body mention while PR A
// merges with a closing keyword — the issue must still reach `done`, because
// the invisible PR B is excluded from the close aggregate's open_count.
func TestWebhook_HiddenBodyMentionDoesNotBlockAutoAdvance(t *testing.T) {
if testHandler == nil {
t.Skip("handler test fixture not initialized (no DB?)")
}
ctx := context.Background()
secret := "hidden-mention-gate-secret"
t.Setenv("GITHUB_WEBHOOK_SECRET", secret)
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "closing PR plus invisible mention",
"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 = 30264007
if _, err := testHandler.Queries.CreateGitHubInstallation(ctx, db.CreateGitHubInstallationParams{
WorkspaceID: parseUUID(testWorkspaceID),
InstallationID: installationID,
AccountLogin: "hidden-mention-gate-acct",
AccountType: "User",
}); err != nil {
t.Fatalf("CreateGitHubInstallation: %v", err)
}
// PR B opens with only a bare body mention → reference_only, hidden, open.
firePRWebhook(t, secret, installationID, 1, "Unrelated cleanup", "Context: see "+created.Identifier, "feat/cleanup", "opened")
// PR A opens with a closing keyword → genuine closing PR.
firePRWebhook(t, secret, installationID, 2, "Primary work", "Closes "+created.Identifier, "feat/primary", "opened")
// Only PR A shows in the list; PR B is hidden.
listed, err := testHandler.Queries.ListPullRequestsByIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("ListPullRequestsByIssue: %v", err)
}
if len(listed) != 1 {
t.Fatalf("expected only the closing PR to show, got %d rows", len(listed))
}
// Sanity: issue is still in_progress (PR A 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)
}
// PR A merges. PR B is still open but reference_only, so it must NOT count
// toward open_count — the issue should advance to done.
firePRWebhook(t, secret, installationID, 2, "Primary work", "Closes "+created.Identifier, "feat/primary", "merged")
got, err = testHandler.Queries.GetIssue(ctx, parseUUID(created.ID))
if err != nil {
t.Fatalf("GetIssue after merge: %v", err)
}
if got.Status != "done" {
t.Errorf("closing PR merged while only a hidden body-only mention is open: status = %q, want done", got.Status)
}
}
// ── CI / mergeable_state tests ─────────────────────────────────────────────
func TestDerivePRMergeableState(t *testing.T) {

View File

@@ -23,11 +23,13 @@ same gate and they read different fields.
**Linking** scans the PR **title, body, OR branch** for a routable issue key
(`PREFIX-NUMBER`, e.g. `MUL-2759`). Each match writes an issue ↔ PR link row.
This is the link that `multica issue pull-requests` reads back.
This is the link that `multica issue pull-requests` reads back — but see the
reference-only rule below: a key that appears **only** as a bare mention in the
body is linked yet hidden from that list.
```text
MUL-2759: add built-in issue working skill # title prefix → links
agent/matt/mul-2759-working-on-issues # branch ref → links
MUL-2759: add built-in issue working skill # title prefix → links, shown
agent/matt/mul-2759-working-on-issues # branch ref → links, shown
```
**Close intent** is stricter and is a separate scan over **title or body only —
@@ -48,6 +50,20 @@ close the issue on merge. A closing keyword immediately adjacent to the issue ke
records close intent; on merge, that close intent can move the linked issue to
`done`.
**Reference-only links (hidden from the PR list).** A key that appears **only**
as a bare mention in the body — no closing keyword, and not in the title or
branch — still writes a link row, but the row is flagged `reference_only` and
**excluded from `multica issue pull-requests`** (and the issue's right-side PR
list in the UI). This keeps passing mentions like `Related MUL-2759` or
`Follow up in MUL-2759` from surfacing an unrelated PR as if it were working on
that issue. To make a PR show up for an issue, put the key in the title, the
branch, or after a closing keyword in the body — not as a loose body reference.
```text
Closes MUL-2759 in the body # links and shown
Related to MUL-2759 in the body (no title/branch) # links but reference_only → hidden
```
### Default for code-changing issue work
When an issue run changes code in a checked-out GitHub repo, the default handoff
@@ -98,7 +114,9 @@ So "is it merged?" is `state == "merged"` (or `merged_at != null`); "is it still
a draft?" is `state == "draft"`; CI status is `checks_conclusion`.
If the command returns no linked PRs after a PR was opened, the link scanner did
not observe a routable issue key in the PR title/body/branch.
not observe a routable issue key in the PR title/body/branch — or the only match
was a bare body mention, which links as `reference_only` and is hidden from this
list (see the reference-only rule above).
## Metadata: high-signal keys only

View File

@@ -72,6 +72,19 @@ Every `PREFIX-NUMBER` mention in **title, body, or branch** resolves to an issue
in the workspace and writes a link row (`LinkIssueToPullRequest`, ~`github.go:762`).
This is what `multica issue pull-requests` later reads back.
**Reference-only flag (MUL-3739).** The link row carries a `reference_only`
boolean (`migrations/127_issue_pull_request_reference_only.up.sql`). The handler
computes a `qualifyingIdents` set = identifiers in **title or branch** (any
`extractIdentifiers` match) **body closing keywords** (`closingIdents`). A
linked identifier NOT in that set was matched only by a bare body mention, so its
row is written with `reference_only = true`. Both `ListPullRequestsByIssue` and
`GetIssuePullRequestCloseAggregate` filter `AND NOT reference_only`, so
reference-only links are hidden from the CLI / UI PR list **and** excluded from
the auto-advance gate (an open body-only mention must not silently block the
issue from reaching `done` while invisible in the list). The row still exists for
edit-time close-intent tracking. `reference_only` follows the same
`preserve_close_intent` terminal gate as `close_intent`.
Drifted from the prior skill's `github.go:727` citation, which pointed at the old
call-site location for the link logic.
@@ -93,8 +106,10 @@ deliberately excluded (function doc, `github.go:1044-1050`): a branch like
Drifted from the prior skill's `github.go:736` citation.
Net: a bare title prefix (`MUL-2759: ...`) or a branch ref links only;
`Closes MUL-2759` links **and** records close intent.
Net: a bare title prefix (`MUL-2759: ...`) or a branch ref links only (shown in
the PR list); `Closes MUL-2759` links **and** records close intent; a bare body
mention with no title/branch ref and no closing keyword links as `reference_only`
and is hidden from the PR list.
## Status side effects (enqueue contracts)
@@ -146,6 +161,7 @@ grep -n 'pull-requests <id>' cmd/multica/cmd_issue.go
grep -n 'ListPullRequestsForIssue' cmd/server/router.go internal/handler/github.go
grep -n 'func issuePullRequestRowToResponse\|type GitHubPullRequestResponse struct\|func derivePRState\|func extractIdentifiers\|func extractClosingIdentifiers\|closingIdentifierRe' internal/handler/github.go
grep -n 'extractIdentifiers(\|extractClosingIdentifiers(\|derivePRState(' internal/handler/github.go
grep -n 'qualifyingIdents\|reference_only\|ReferenceOnly' internal/handler/github.go pkg/db/queries/github.sql
grep -n 'prevIssue.Status == "backlog"\|func (h \*Handler) shouldEnqueueAgentTask' internal/handler/issue.go
grep -n 'func notifyParentOfChildDone' internal/handler/issue_child_done.go
```

View File

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

View File

@@ -0,0 +1,12 @@
-- Persist whether a PR ↔ issue link is justified ONLY by a bare mention of the
-- issue identifier in the PR description (body), with no closing keyword and no
-- reference in the PR title or branch name. The auto-link layer stays generous
-- and still records the link row (so close_intent can be tracked and downgraded
-- across edits), but a reference-only link is hidden from the issue's PR list:
-- a passing "Related MUL-1" / "Follow up in MUL-1" mention should not surface
-- the PR as if it were a working PR for that issue.
--
-- Defaults to FALSE so pre-existing links keep showing until their PR's next
-- webhook re-evaluates the reference.
ALTER TABLE issue_pull_request
ADD COLUMN reference_only BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -259,7 +259,7 @@ SELECT
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
WHERE ipr.issue_id = $1 AND NOT ipr.reference_only
`
type GetIssuePullRequestCloseAggregateRow struct {
@@ -275,6 +275,13 @@ type GetIssuePullRequestCloseAggregateRow struct {
// (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.
//
// reference_only links (a PR that merely mentions the issue identifier in its
// body) are excluded: they are hidden from the issue PR list, so they must not
// silently gate auto-advance either. An open body-only mention would otherwise
// keep open_count > 0 and block the issue from advancing while being invisible
// in the UI. (reference_only rows never carry close_intent, so excluding them
// does not change merged_with_close_intent_count.)
func (q *Queries) GetIssuePullRequestCloseAggregate(ctx context.Context, issueID pgtype.UUID) (GetIssuePullRequestCloseAggregateRow, error) {
row := q.db.QueryRow(ctx, getIssuePullRequestCloseAggregate, issueID)
var i GetIssuePullRequestCloseAggregateRow
@@ -303,14 +310,18 @@ func (q *Queries) GetPendingGitHubInstallation(ctx context.Context, installation
const linkIssueToPullRequest = `-- name: LinkIssueToPullRequest :exec
INSERT INTO issue_pull_request (
issue_id, pull_request_id, linked_by_type, linked_by_id, close_intent
issue_id, pull_request_id, linked_by_type, linked_by_id, close_intent, reference_only
) VALUES (
$1, $2, $4, $5, $3
$1, $2, $4, $5, $3, $6
)
ON CONFLICT (issue_id, pull_request_id) DO UPDATE SET
close_intent = CASE
WHEN $6 THEN issue_pull_request.close_intent
WHEN $7 THEN issue_pull_request.close_intent
ELSE EXCLUDED.close_intent
END,
reference_only = CASE
WHEN $7 THEN issue_pull_request.reference_only
ELSE EXCLUDED.reference_only
END
`
@@ -320,6 +331,7 @@ type LinkIssueToPullRequestParams struct {
CloseIntent bool `json:"close_intent"`
LinkedByType pgtype.Text `json:"linked_by_type"`
LinkedByID pgtype.UUID `json:"linked_by_id"`
ReferenceOnly bool `json:"reference_only"`
PreserveCloseIntent bool `json:"preserve_close_intent"`
}
@@ -331,6 +343,11 @@ type LinkIssueToPullRequestParams struct {
// 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.
//
// reference_only marks a link justified ONLY by a bare body mention (no closing
// keyword, no title/branch reference). It follows the same preserve gate as
// close_intent so a post-terminal edit can't retroactively hide a PR that did
// the work. The issue's PR list filters these out (see ListPullRequestsByIssue).
func (q *Queries) LinkIssueToPullRequest(ctx context.Context, arg LinkIssueToPullRequestParams) error {
_, err := q.db.Exec(ctx, linkIssueToPullRequest,
arg.IssueID,
@@ -338,6 +355,7 @@ func (q *Queries) LinkIssueToPullRequest(ctx context.Context, arg LinkIssueToPul
arg.CloseIntent,
arg.LinkedByType,
arg.LinkedByID,
arg.ReferenceOnly,
arg.PreserveCloseIntent,
)
return err
@@ -413,7 +431,7 @@ WITH issue_prs AS (
SELECT pr.id, pr.head_sha
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
WHERE ipr.issue_id = $1
WHERE ipr.issue_id = $1 AND NOT ipr.reference_only
),
per_app_latest AS (
SELECT DISTINCT ON (cs.pr_id, cs.app_id)
@@ -452,7 +470,7 @@ SELECT
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
LEFT JOIN checks c ON c.pr_id = pr.id
WHERE ipr.issue_id = $1
WHERE ipr.issue_id = $1 AND NOT ipr.reference_only
ORDER BY pr.pr_created_at DESC
`
@@ -494,7 +512,9 @@ type ListPullRequestsByIssueRow struct {
// selected so a single app firing multiple suites on the same head doesn't
// get counted N times. Late-arriving suites for an OLD head are stored but
// excluded by the head_sha filter, so they can't override the new head's
// pending view.
// pending view. reference_only links (a PR that merely mentions the issue
// identifier in its body, with no closing keyword and no title/branch
// reference) are filtered out — they are not working PRs for this issue.
func (q *Queries) ListPullRequestsByIssue(ctx context.Context, issueID pgtype.UUID) ([]ListPullRequestsByIssueRow, error) {
rows, err := q.db.Query(ctx, listPullRequestsByIssue, issueID)
if err != nil {

View File

@@ -502,6 +502,7 @@ type IssuePullRequest struct {
LinkedByID pgtype.UUID `json:"linked_by_id"`
LinkedAt pgtype.Timestamptz `json:"linked_at"`
CloseIntent bool `json:"close_intent"`
ReferenceOnly bool `json:"reference_only"`
}
type IssueReaction struct {

View File

@@ -120,12 +120,14 @@ WHERE workspace_id = $1 AND repo_owner = $2 AND repo_name = $3 AND pr_number = $
-- selected so a single app firing multiple suites on the same head doesn't
-- get counted N times. Late-arriving suites for an OLD head are stored but
-- excluded by the head_sha filter, so they can't override the new head's
-- pending view.
-- pending view. reference_only links (a PR that merely mentions the issue
-- identifier in its body, with no closing keyword and no title/branch
-- reference) are filtered out — they are not working PRs for this issue.
WITH issue_prs AS (
SELECT pr.id, pr.head_sha
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
WHERE ipr.issue_id = sqlc.arg('issue_id')
WHERE ipr.issue_id = sqlc.arg('issue_id') AND NOT ipr.reference_only
),
per_app_latest AS (
SELECT DISTINCT ON (cs.pr_id, cs.app_id)
@@ -164,7 +166,7 @@ SELECT
FROM github_pull_request pr
JOIN issue_pull_request ipr ON ipr.pull_request_id = pr.id
LEFT JOIN checks c ON c.pr_id = pr.id
WHERE ipr.issue_id = sqlc.arg('issue_id')
WHERE ipr.issue_id = sqlc.arg('issue_id') AND NOT ipr.reference_only
ORDER BY pr.pr_created_at DESC;
-- name: ListIssueIDsForPullRequest :many
@@ -180,12 +182,19 @@ WHERE pull_request_id = $1;
-- (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.
--
-- reference_only links (a PR that merely mentions the issue identifier in its
-- body) are excluded: they are hidden from the issue PR list, so they must not
-- silently gate auto-advance either. An open body-only mention would otherwise
-- keep open_count > 0 and block the issue from advancing while being invisible
-- in the UI. (reference_only rows never carry close_intent, so excluding them
-- does not change merged_with_close_intent_count.)
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' 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;
WHERE ipr.issue_id = $1 AND NOT ipr.reference_only;
-- =====================
-- GitHub PR check suite
@@ -262,15 +271,24 @@ RETURNING suite_id, head_sha, app_id, conclusion, status, suite_updated_at;
-- 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.
--
-- reference_only marks a link justified ONLY by a bare body mention (no closing
-- keyword, no title/branch reference). It follows the same preserve gate as
-- close_intent so a post-terminal edit can't retroactively hide a PR that did
-- the work. The issue's PR list filters these out (see ListPullRequestsByIssue).
INSERT INTO issue_pull_request (
issue_id, pull_request_id, linked_by_type, linked_by_id, close_intent
issue_id, pull_request_id, linked_by_type, linked_by_id, close_intent, reference_only
) VALUES (
$1, $2, sqlc.narg('linked_by_type'), sqlc.narg('linked_by_id'), $3
$1, $2, sqlc.narg('linked_by_type'), sqlc.narg('linked_by_id'), $3, sqlc.arg('reference_only')
)
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,
reference_only = CASE
WHEN sqlc.arg('preserve_close_intent') THEN issue_pull_request.reference_only
ELSE EXCLUDED.reference_only
END;
-- name: UnlinkIssueFromPullRequest :exec