Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
id: BACK-189
title: 'enhance(backlog-triage): emit comment and closing-PR relationship signals'
status: Done
labels:
- documentation
- enhancement
priority: medium
milestone:
created_date: '2026-06-06'
---
## Description
## Context

#73 added the snapshot-v2 collector fields needed for richer relationship analysis: per-issue `closing_prs` is present by default and `comments` is available through `--with-comments`. The collector is ready, but `triage-relate.js` still emits only body-based mentions/blocks/depends-on and title duplicate candidates.

## Desired change

Teach `triage-relate.js` to use snapshot-v2 fields conservatively.

Suggested signals:
- comment-based mentions when `comments` is present
- a closing-PR relationship signal when `closing_prs` contains merged PR metadata

## Acceptance Criteria

- [x] Comment mention scanning runs only when issue `comments` arrays are present.
- [x] Comment-derived edges carry evidence that identifies comment source separately from issue body evidence.
- [x] Closing-PR edges use `closing_prs` and do not imply an automatic close recommendation by themselves.
- [x] Missing optional fields degrade cleanly without warnings or undefined-field behavior.
- [x] Relationship docs and tests cover both enabled and absent-field paths.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
id: BACK-73
title: 'enhance(backlog-triage): snapshot v2 schema for closing PRs, comments, and closed-issue scan'
status: To Do
status: Done
labels:
- documentation
- enhancement
Expand Down
6 changes: 4 additions & 2 deletions backlog/sprints/2026-05-backlog-triage-snapshot-v2.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
milestone: backlog-triage snapshot v2
status: active
status: completed
started: 2026-05-31
due: TBD
objectives: [O4]
Expand All @@ -15,7 +15,7 @@ Shape and implement #73 so backlog-triage can see closing PR links, optional com
## Plan
Start with a short technical split before implementation. The issue is larger than a doc polish item because it touches collection schema and downstream scanners.

- [ ] #73 enhance(backlog-triage): snapshot v2 schema for closing PRs, comments, and closed-issue scan
- [x] #73 enhance(backlog-triage): snapshot v2 schema for closing PRs, comments, and closed-issue scan → PR #191 (merged)

## Running Context
- Current `triage-relate` and `triage-stale` explicitly defer PR-merged and duplicate-of-closed signals until snapshot v2 fields exist.
Expand All @@ -25,3 +25,5 @@ Start with a short technical split before implementation. The issue is larger th

## Progress
- 2026-05-31: Open issue set synced locally; #73 mirror created under `backlog/tasks/`. Previous spec-grill dogfood sprint completed after PR #175 landed.
- 2026-06-06: #73 completed via PR #191. Collector v2 now emits explicit `schema_version: 2`; downstream analyzer work was split into #189 (relationships) and #190 (stale/obsolete signals).
- 2026-06-06: Sprint closed. 1/1 tasks completed.
26 changes: 26 additions & 0 deletions backlog/sprints/2026-06-backlog-triage-relationships.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
milestone: backlog-triage relationships
status: completed
started: 2026-06-06
due: TBD
objectives: [O4]
component: "triage-grooming"
---

# backlog-triage relationships

## Goal
Use snapshot v2 relationship fields in `triage-relate` without turning advisory relationship evidence into automatic close recommendations.

## Plan
- [x] #189 enhance(backlog-triage): emit comment and closing-PR relationship signals → PR #192

## Running Context
- #73 shipped collector-side v2 fields: `closing_prs` by default, `comments` behind `--with-comments`.
- Keep #189 non-mutating. Relationship edges may influence review priority, but they must not create stale/close actions.
- Optional fields must degrade silently when absent so old snapshots remain readable.

## Progress
- 2026-06-06: Started #189 after closing the completed #73 snapshot-v2 sprint locally.
- 2026-06-06: #189 implemented in PR #192. `triage-relate` now emits `comment-mentions` and advisory `merged-pr-link` edges from snapshot v2 fields.
- 2026-06-06: Sprint closed. 1/1 tasks completed.
2 changes: 2 additions & 0 deletions backlog/sprints/_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@
- Resolve `progress-sync` metric semantics in `#49` before doing structural refactors in `#50`
- `progress-sync` and the bash helpers both parse sprint/task markdown, so contract drift needs explicit coverage
- `sync-pull.js --update` refreshes task frontmatter and, for machine-managed issues whose **incoming GitHub body** starts with the `<!-- dev-backlog:progress-issue month=` marker, also refreshes the markdown body; every other task mirror keeps its existing body so local AC checkbox state is preserved
- Backlog triage snapshot enrichments stay explicit and bounded: `--with-comments` and `--with-closed-issues` are opt-in, while downstream scanners must gracefully gate on optional fields instead of assuming they exist.
- `triage-relate` relationship edges are advisory context. Even a `merged-pr-link` edge must not become a close recommendation unless `triage-stale` implements a separate conservative obsolete signal.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
id: BACK-190
title: 'enhance(backlog-triage): add stale signals for merged PRs and closed duplicates'
status: To Do
labels:
- documentation
- enhancement
priority: medium
milestone:
created_date: '2026-06-06'
---
## Description
## Context

#73 added the collector-side data needed for stronger obsolete-candidate detection: per-issue `closing_prs` and optional top-level `closed_issues`. `triage-stale.js` still uses only inactivity and explicit labels, so the richer snapshot fields are not yet used for stale decisions.

## Desired change

Add conservative stale/obsolete candidates based on snapshot-v2 fields.

Candidate signals:
- open issue has a merged closing PR in `closing_prs`
- open issue appears to duplicate a recently closed issue from `closed_issues`

## Acceptance Criteria

- [ ] Merged closing-PR candidates require `closing_prs` evidence and include PR number/url/mergedAt in candidate evidence.
- [ ] Duplicate-of-closed candidates run only when top-level `closed_issues` is present.
- [ ] Duplicate-of-closed matching is conservative and tested against false positives.
- [ ] Missing optional fields degrade cleanly without warnings or undefined-field behavior.
- [ ] Stale docs and tests cover enabled and absent-field paths.

72 changes: 62 additions & 10 deletions skills/backlog-triage/references/relationships.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# Relationships

**Purpose.** `triage-relate.js` reads a previously collected issue snapshot and emits read-only relationship edges for four snapshot-resident signals:
**Purpose.** `triage-relate.js` reads a previously collected issue snapshot and emits read-only relationship edges for snapshot-resident signals:

- `mentions` from plain `#123` references in issue bodies
- `comment-mentions` from plain `#123` references in optional issue comments
- `blocks` from explicit blocking / closing phrases in issue bodies
- `depends-on` from explicit dependency phrases in issue bodies
- `merged-pr-link` from per-issue merged closing PR metadata
- `duplicate-candidate` from title-token Jaccard overlap

Every emitted edge carries evidence taken directly from the snapshot so downstream report rendering can show why the relationship was inferred without re-fetching from GitHub.
Expand All @@ -23,6 +25,23 @@ Every emitted edge carries evidence taken directly from the snapshot so downstre
- `match`: matched issue reference, for example `#123`
- `snippet`: normalized sentence/line fragment containing the match

### `comment-mentions`

- Source: `issue.comments[].body`
- Gate: runs only when `comments` is present as an array; missing or malformed optional fields emit no edges
- Match rule: same issue-reference parser as body `mentions`
- Filters:
- ignore self-references
- ignore references to issues absent from `snapshot.issues`
- ignore fenced-code and URL-fragment noise
- Confidence: `0.65`
- Evidence:
- `source`: `"comment"`
- `author`: comment author when present
- `createdAt`: comment timestamp when present
- `match`: matched issue reference
- `snippet`: normalized sentence/line fragment containing the match

### `blocks`

- Source: `issue.body`
Expand All @@ -46,6 +65,20 @@ Every emitted edge carries evidence taken directly from the snapshot so downstre
- `phrase`: normalized matched phrase, for example `depends on #123`
- `snippet`: normalized sentence/line fragment containing the phrase

### `merged-pr-link`

- Source: `issue.closing_prs`
- Gate: runs only when `closing_prs` is present as an array
- Match rule: emit only entries with `state: "MERGED"` and a non-empty `mergedAt`
- Confidence: `1`
- Action semantics: advisory relationship evidence only; this does not imply an automatic close recommendation
- Evidence:
- `source`: `"closing_prs"`
- `pr.number`: closing PR number
- `pr.state`: closing PR state
- `pr.mergedAt`: merge timestamp
- `pr.url`: closing PR URL when present

### `duplicate-candidate`

- Source: `issue.title`
Expand Down Expand Up @@ -80,7 +113,7 @@ All edges share the outer shape:
{
"from": 100,
"to": 101,
"kind": "mentions|blocks|depends-on|duplicate-candidate",
"kind": "mentions|comment-mentions|blocks|depends-on|merged-pr-link|duplicate-candidate",
"confidence": 0.75,
"evidence": {}
}
Expand All @@ -97,6 +130,18 @@ Evidence payloads vary by kind:
}
```

- `comment-mentions`

```json
{
"source": "comment",
"author": "octocat",
"createdAt": "2026-04-18T01:00:00.000Z",
"match": "#101",
"snippet": "Follow-up lives in #101."
}
```

- `blocks` / `depends-on`

```json
Expand All @@ -106,6 +151,20 @@ Evidence payloads vary by kind:
}
```

- `merged-pr-link`

```json
{
"source": "closing_prs",
"pr": {
"number": 88,
"state": "MERGED",
"mergedAt": "2026-04-18T01:15:00.000Z",
"url": "https://github.com/owner/name/pull/88"
}
}
```

- `duplicate-candidate`

```json
Expand All @@ -121,11 +180,4 @@ Evidence payloads vary by kind:

## Deferred follow-ups

The snapshot collector now has the raw fields for these signals, but `triage-relate.js` does not emit them yet:

- `comments`-based mention scan
- Requires: `--with-comments` snapshot enrichment and a clear edge shape that distinguishes issue-body evidence from comment evidence.
- `merged-pr-link` edge kind
- Requires: interpreting per-issue `closing_prs` without turning every linked PR into a close recommendation.

Tracked in #189.
`triage-relate.js` is intentionally still read-only. Turning `merged-pr-link` into a stale / obsolete close candidate belongs to `triage-stale.js` and is tracked separately in #190.
88 changes: 87 additions & 1 deletion skills/backlog-triage/scripts/triage-relate.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,17 @@ function makeEdge({ from, to, kind, confidence, evidence }) {
return { from, to, kind, confidence, evidence };
}

function edgeIdentity(edge) {
if (edge.kind === "merged-pr-link") {
return `${edge.from}:${edge.to}:${edge.kind}:${edge.evidence?.pr?.number || ""}`;
}
return `${edge.from}:${edge.to}:${edge.kind}`;
}

function dedupeEdges(edges) {
const seen = new Set();
return edges.filter((edge) => {
const key = `${edge.from}:${edge.to}:${edge.kind}`;
const key = edgeIdentity(edge);
if (seen.has(key)) return false;
seen.add(key);
return true;
Expand Down Expand Up @@ -197,6 +204,41 @@ function scanMentions(snapshot) {
return dedupeEdges(edges);
}

function scanCommentMentions(snapshot) {
const edges = [];
const openNumbers = snapshotIssueNumbers(snapshot);

for (const issue of snapshot.issues) {
const comments = Array.isArray(issue.comments) ? issue.comments : [];

for (const comment of comments) {
const body = typeof comment?.body === "string" ? comment.body : "";
for (const ref of extractIssueRefs(body)) {
if (issue.number === ref.number) continue;
if (!openNumbers.has(ref.number)) continue;

edges.push(
makeEdge({
from: issue.number,
to: ref.number,
kind: "comment-mentions",
confidence: 0.65,
evidence: {
source: "comment",
author: typeof comment?.author === "string" ? comment.author : null,
createdAt: typeof comment?.createdAt === "string" ? comment.createdAt : null,
match: ref.match,
snippet: ref.snippet,
},
})
);
}
}
}

return dedupeEdges(edges);
}

function scanPhraseEdges(snapshot, patterns, kind, confidence) {
const edges = [];
const openNumbers = snapshotIssueNumbers(snapshot);
Expand Down Expand Up @@ -249,6 +291,39 @@ function scanDependsOn(snapshot) {
);
}

function scanMergedPrLinks(snapshot) {
const edges = [];

for (const issue of snapshot.issues) {
const closingPrs = Array.isArray(issue.closing_prs) ? issue.closing_prs : [];

for (const pr of closingPrs) {
const state = typeof pr?.state === "string" ? pr.state.toUpperCase() : "";
if (state !== "MERGED" || typeof pr?.mergedAt !== "string" || !pr.mergedAt) continue;

edges.push(
makeEdge({
from: issue.number,
to: issue.number,
kind: "merged-pr-link",
confidence: 1,
evidence: {
source: "closing_prs",
pr: {
number: Number.isInteger(pr?.number) ? pr.number : null,
state: pr.state,
mergedAt: pr.mergedAt,
url: typeof pr?.url === "string" ? pr.url : null,
},
},
})
);
}
}

return dedupeEdges(edges);
}

function tokenizeTitle(title) {
return new Set(
String(title || "")
Expand Down Expand Up @@ -369,13 +444,22 @@ function resolveBacklogDir(snapshot) {
function analyzeSnapshot(snapshot, { config = readTriageConfig(resolveBacklogDir(snapshot)) } = {}) {
return sortEdges([
...scanMentions(snapshot),
...scanCommentMentions(snapshot),
...scanBlocks(snapshot),
...scanDependsOn(snapshot),
...scanMergedPrLinks(snapshot),
...findDuplicateCandidates(snapshot, config),
]);
}

function formatEdge(edge) {
if (edge.kind === "merged-pr-link") {
const pr = edge.evidence?.pr || {};
const prLabel = pr.number ? `PR #${pr.number}` : "merged PR";
const mergedAt = pr.mergedAt ? ` merged at ${pr.mergedAt}` : "";
return `#${edge.from} ${edge.kind} ${prLabel}${mergedAt}`;
}

if (edge.kind === "duplicate-candidate") {
return `#${edge.from} ${edge.kind} #${edge.to} (${edge.confidence.toFixed(2)}) ${edge.evidence.titles.from} <> ${edge.evidence.titles.to}`;
}
Expand Down Expand Up @@ -422,8 +506,10 @@ module.exports = {
maskFencedCodeBlocks,
extractIssueRefs,
scanMentions,
scanCommentMentions,
scanBlocks,
scanDependsOn,
scanMergedPrLinks,
findDuplicateCandidates,
readSnapshotFile,
analyzeSnapshot,
Expand Down
Loading
Loading