mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 01:19:26 +02:00
Compare commits
2 Commits
v0.3.31
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28352764da | ||
|
|
5e9b76e683 |
@@ -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{},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE issue_pull_request
|
||||
DROP COLUMN reference_only;
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user