The problem
When someone opens a pull request on GitHub with Closes #123 in the description, GitHub establishes a real relationship between the two. Facility never reads it, so it cannot tell that a PR and an issue are the same unit of work — which is the whole premise of a story.
The only place we come close is the nightly outcomes reconciliation: services/api/src/watchtower/outcomes.ts:167 takes earliestIssue(evidence) to backfill outcomes.issue_number, using a GraphQL query in services/api/src/watchtower/github.ts:111-161. That query already asks for closingIssuesReferences. But it runs on a bare GITHUB_TOKEN client (:52-54) rather than the GitHub App, only for merged PRs, and only once a night. apps/web/lib/story.ts:127-128 says so explicitly:
// PRs join primarily through the runs that produced them (runs.gh.pr); the
// outcome.issue_number backfill is nightly, too late for live timelines.
So a story's PRs are, in practice, only the PRs Facility itself opened.
What I propose
Read closingIssuesReferences on the App-authenticated client, as part of the PR snapshot (see the mirroring issue), and store the result on the PR row as closing_issues integer[] with a GIN index. That answers both questions cheaply: "every PR for issue #914" is closing_issues @> ARRAY[914], and "is this PR an orphan?" is a column-local cardinality(...) = 0 with no join.
Use GitHub's resolution, not a regex over the body. Closes #123 is not the only thing that creates the link — GitHub also accepts manual links from the PR's Development panel, the Fixes/Resolves families, and full issue URLs. A regex is wrong in both directions. closingIssuesReferences is GitHub's own answer, and it is exactly the linkage GitHub will act on when the PR merges.
Two details worth getting right:
- Filter to same-repo references.
closingIssuesReferences can return cross-repo nodes (Closes owner/other#5); those must be dropped rather than written into a per-repo integer array. Cross-repo linking is out of scope.
- A PR may close several issues. Under the product rule that is correct and intentional: the PR belongs to both stories. Whatever builds the story union must fan a PR out into every matching issue, and the board must tolerate the same PR number appearing on two rows.
And close the loop from our side: openRunPullRequest (services/api/src/sandbox/orchestrator.ts:791) knows gh.issueNumber at :854, but opens the PR with whatever body the agent wrote (:859) — the closing keyword is not guaranteed. Prepending Closes #N when no closing reference to that issue is already present makes GitHub create the authoritative link, which then comes back through closingIssuesReferences. Facility provokes the link and reads it, rather than inventing its own.
One guard is needed there: gh.issueNumber is not always an issue number. processGithubAgentEvent sets it to a PR number (services/api/src/github/processor.ts:548). Today that is harmless because openRunPullRequest is reached only under isBuilderMode (:286), but that is true by construction rather than by check — an agent named builder configured with a pull_request trigger would write Closes #<PR-number> into a PR body, permanently, on GitHub. Checking the number against the issue mirror before injecting is cheap.
What I considered instead
- A regex over the PR body. Misses manual links and URL forms; matches keywords inside code fences that GitHub itself ignores. It would also have to be re-run on every body edit.
- A link table instead of an array. The read side is a wash. The write side is not:
closingIssuesReferences arrives as a complete per-PR fact inside one atomic snapshot, so an array is refreshed wholesale in the same UPSERT, while a link table needs a second write path that diffs a set we already hold in full, for a cardinality that is realistically 0–3. And array → link table is a pure derivation later (unnest), so the cheap choice does not lock anything in.
- Reusing
outcomes.issue_number. Nightly, merged-only, and a measurement record rather than a mirror.
Part of #65. Findings are against 0f646ac.
The problem
When someone opens a pull request on GitHub with
Closes #123in the description, GitHub establishes a real relationship between the two. Facility never reads it, so it cannot tell that a PR and an issue are the same unit of work — which is the whole premise of a story.The only place we come close is the nightly outcomes reconciliation:
services/api/src/watchtower/outcomes.ts:167takesearliestIssue(evidence)to backfilloutcomes.issue_number, using a GraphQL query inservices/api/src/watchtower/github.ts:111-161. That query already asks forclosingIssuesReferences. But it runs on a bareGITHUB_TOKENclient (:52-54) rather than the GitHub App, only for merged PRs, and only once a night.apps/web/lib/story.ts:127-128says so explicitly:So a story's PRs are, in practice, only the PRs Facility itself opened.
What I propose
Read
closingIssuesReferenceson the App-authenticated client, as part of the PR snapshot (see the mirroring issue), and store the result on the PR row asclosing_issues integer[]with a GIN index. That answers both questions cheaply: "every PR for issue #914" isclosing_issues @> ARRAY[914], and "is this PR an orphan?" is a column-localcardinality(...) = 0with no join.Use GitHub's resolution, not a regex over the body.
Closes #123is not the only thing that creates the link — GitHub also accepts manual links from the PR's Development panel, theFixes/Resolvesfamilies, and full issue URLs. A regex is wrong in both directions.closingIssuesReferencesis GitHub's own answer, and it is exactly the linkage GitHub will act on when the PR merges.Two details worth getting right:
closingIssuesReferencescan return cross-repo nodes (Closes owner/other#5); those must be dropped rather than written into a per-repo integer array. Cross-repo linking is out of scope.And close the loop from our side:
openRunPullRequest(services/api/src/sandbox/orchestrator.ts:791) knowsgh.issueNumberat:854, but opens the PR with whatever body the agent wrote (:859) — the closing keyword is not guaranteed. PrependingCloses #Nwhen no closing reference to that issue is already present makes GitHub create the authoritative link, which then comes back throughclosingIssuesReferences. Facility provokes the link and reads it, rather than inventing its own.One guard is needed there:
gh.issueNumberis not always an issue number.processGithubAgentEventsets it to a PR number (services/api/src/github/processor.ts:548). Today that is harmless becauseopenRunPullRequestis reached only underisBuilderMode(:286), but that is true by construction rather than by check — an agent namedbuilderconfigured with apull_requesttrigger would writeCloses #<PR-number>into a PR body, permanently, on GitHub. Checking the number against the issue mirror before injecting is cheap.What I considered instead
closingIssuesReferencesarrives as a complete per-PR fact inside one atomic snapshot, so an array is refreshed wholesale in the same UPSERT, while a link table needs a second write path that diffs a set we already hold in full, for a cardinality that is realistically 0–3. And array → link table is a pure derivation later (unnest), so the cheap choice does not lock anything in.outcomes.issue_number. Nightly, merged-only, and a measurement record rather than a mirror.Part of #65. Findings are against
0f646ac.