Skip to content

chore(memory): promote strong-fit data-sync drafts for review#129

Open
Hareet wants to merge 10 commits into
mainfrom
memory/promote-data-sync
Open

chore(memory): promote strong-fit data-sync drafts for review#129
Hareet wants to merge 10 commits into
mainfrom
memory/promote-data-sync

Conversation

@Hareet

@Hareet Hareet commented Jun 24, 2026

Copy link
Copy Markdown
Member

Promotes 14 strong-fit data-sync drafts from agent-memory/_pending/ into agent-memory/domains/data-sync/issues/ for squad content review.

Categories: bug (6), improvement (4), feature (4)
Themes: replication keys & primary-contact depth, purging/pre-purge counts, Nouveau/offline view design docs, service-worker UI extensions.

All 14 carry domainFit: strong + a ## Domain Rationale section. 38 weak-fit drafts deferred to Stream Cdata-sync was the largest catch-all in this run (73% weak), so this PR is the distilled minority; the over-broad-domain question is for later review.

@sugat009 sugat009 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes, though to be clear the fix is upstream and not something to patch by hand here. This seeder faithfully promotes what the distillation pipeline generated, so nothing is wrong with the promotion itself. The problem is in the drafts the pipeline produced, and I verified it against the live medic/cht-core API for every draft in this PR.

The defect: issueNumber / issueUrl name the merge PR, not the resolved issue

One precise note up front, because the intuitive version of this is not what is happening: issueNumber and issueUrl always agree with each other (the URL is literally /issues/<issueNumber>). The bug is that in 6 of 14 drafts here, that shared number is a pull request, so /issues/N silently redirects to /pull/N. The real resolved issue survives only in the PR-title slug. For example:

  • 8773-fix6299-trigger-sync... stores issueNumber: 8773 / issueUrl: .../issues/8773, but 8773 is the merge PR ("fix(#6299): trigger sync if something is left to sync"). The real issue is #6299 (closed): "Sync status sometimes says all reports synced when there are changes yet to sync".
  • 10776-perf10749-move-tasksbycontact... stores issueNumber: 10776 / issueUrl: .../issues/10776, but 10776 is the merge PR ("perf(10749): move tasks_by_contact view to offline-only design document"). The real issue is #10749 (closed): "Move tasks_by_contact to offline clients only".

This is the same scraper behaviour flagged on #121, and it is pipeline-wide: across the four clean seeders, 60 of 107 drafts are affected.

Duplicates in this PR. Because each draft is keyed by its PR, some resolved issues appear more than once:

  • Issue #10792 is promoted 3 times (10793, 10798, 10799), one draft per merge PR for the single fix.

Why request-changes rather than merge-and-fix-later

This corpus is the agent's memory of resolved issues (consumed by the Context Analysis Agent, see #135). A reference that resolves to a PR instead of the issue is simply wrong data, and it specifically defeats #135's planned de-duplication by issue id, since the "id" ends up being the PR id. Regenerating the drafts after the upstream fix will rewrite most of these files anyway, so merging now would churn the corpus twice.

Suggested fix (at the source, not file by file)

  1. Fix the distiller/scraper to take the resolved issue from the type(#N): PR title (the filename slug already extracts it correctly), and keep the PR number in source_pr where it belongs.
  2. Regenerate and re-promote this domain's drafts.
  3. Add de-duplication by real issue id (also a #135 acceptance item).

Open to discussing the approach. Once the drafts carry the real issue references, this should be a quick re-review.

@Hareet

Hareet commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

Good catch!

I noted that the pipeline is PR-centric but the schema is issue-based. Thanks for the dive and verification

Root cause

The scraper only harvests linked issues from Fixes/Closes/Resolves #N patterns in the PR body. Per its own header docs, it ignores:

  • the fix(#N): / feat(#N): PR title convention, and
  • GitHub's sidebar-linked issues (the GraphQL closingIssuesReferences field).

When that body search returned nothing, the distiller silently aliased the PR number as the issue number:

// src/scripts/distiller.ts:267
const issueNumber = pr.linkedIssues[0]?.number ?? pr.prNumber; // ← the bug

So id, issueNumber, and issueUrl all received the merge-PR number, while source_pr (correctly) retained the PR. The PR-vs-issue distinction was lost in three of the schema's identity fields.

Detection rule: a draft is affected when frontmatter issueNumber equals the number in source_pr (i.e. the fallback fired).


What data we retained

two retained signals are sufficient to re-link deterministically:

Signal Where Reliability
source_pr (PR number) frontmatter of every draft authoritative, already correct
Resolved-issue token filename slug, e.g. 8773-fix6299-… derived from the original PR title, independent of the buggy linkedIssues path

The frontmatter title field is an LLM-paraphrased description and usually does not contain the issue number — the filename is the better retained artifact.


Blast radius (all 261 promoted drafts)

Category Count Recoverable how
Already correct (issueNumber ≠ PR) 125
Broken, auto-fixable offline from filename token 130 pure local metadata rewrite
Broken, issueNumber == PR with no clean filename token 6 re-fetch by source_pr, or manual

136 of 261 are affected; 130 (96%) are fixable from retained data with zero network/LLM. This matches the reviewer's observed ratio (~60 of 107).

Per-domain (affected count = token-fixable shown; tokenless 6 are spread across domains, e.g. featna/app-skeleton slugs):

Domain files broken (token-fixable)
messaging 17 12
infrastructure 49 20
forms-and-reports 47 25
tasks-and-targets 35 14
authentication 39 18
contacts 44 31
interoperability 6 1
configuration 10 4
data-sync 14 5

Options

Option 1 — Offline re-link from filename token

Script parses <pr>-<type><issue>- per file and rewrites id/issueNumber/issueUrl to the real issue (keeping source_pr).

  • Pros: zero cost, instant, fully deterministic from retained data, reviewable as a pure metadata diff.
  • Cons: relies on the filename convention; the 6 tokenless cases (featna, bare slug, etc.) must be flagged for manual/lookup handling. Filename token is a secondary source, not GitHub-authoritative.

Option 2 — Deterministic re-fetch (no LLM)

Keyed by source_pr, call gh pr view <pr> --json title,closingIssuesReferences,body to get the authoritative resolved issue (including sidebar links the scraper missed), then apply the same metadata-only rewrite.

  • Pros: authoritative, handles every case incl. sidebar links, deterministic, cheap (~107 gh calls), no re-distillation. Effectively "apply the upstream fix as a post-process over existing drafts."
  • Cons: needs gh auth + network.

Option 3 — Fix distiller/scraper + re-run full pipeline (reviewer's literal ask)

Fix the source, then re-scrape and re-distill the affected PRs.

  • Pros: corrects the source for all future runs; regenerates everything consistently.
  • Cons: most expensive — re-runs LLM distillation, regenerates body content that is already reviewed-as-faithful, risks content drift, and disturbs the already-open PRs. Overkill, since only 3 metadata fields are wrong.

Recommendation — hybrid (no pipeline re-run)

The body content is independent of this bug, so re-running distillation (Option 3) is unnecessary and risky.

  1. Fix the source for future runs: add title-(#N) parsing + GraphQL closingIssuesReferences to the scraper, and stop the distiller silently aliasing PR→issue — set issueNumber from the authoritative link, and when there genuinely is none, leave it null/flagged rather than defaulting to the PR number (keep source_pr separate).
  2. Re-link existing drafts with a one-off deterministic script — Option 2 as the authoritative pass, using the Option 1 filename token as a built-in cross-check so every rewrite is verifiable. Metadata-only diff, no body changes.
  3. Validate + ship: npm run validate-schema--force-with-lease the affected PR branches → request re-review. This unblocks dedup-by-issue-ID (Context analysis agent doesn't load memory-pipeline drafts (wrong path + schema) #135).

I'm going to run 1 on this domain and verify a few of them. re-running the pipeline isn't bad, but uses my session limit in an hour and ends my weekly limit early

Base automatically changed from seeding-claude-cli-v2 to main June 26, 2026 19:26
@Hareet Hareet force-pushed the memory/promote-data-sync branch from 2976cbb to 7a9b3b0 Compare June 28, 2026 01:30
Hareet and others added 2 commits June 27, 2026 20:08
…ref filter, Sonar fixes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…SonarCloud S6594)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Hareet

Hareet commented Jun 28, 2026

Copy link
Copy Markdown
Member Author

This is ready for another look, @sugat009

What changed

Forward fix: the scraper now resolves linked issues from GitHub's closingIssuesReferences (sidebar) → the PR title scope type(#N): → the body Fixes/Closes/Resolves, and the distiller no longer falls back to the PR number — a PR that closes no tracked issue is flagged for human triage instead. The pipeline and the one-off relink share a single issue-linkage module, so they can't drift apart.

Existing drafts: relinked deterministically rather than re-distilled — the body content was already faithful, so the one-off tool rewrites only the three identity fields (id/issueNumber/issueUrl), keyed by source_pr against gh closingIssuesReferences, with the filename token as a cross-check. The diff is metadata-only (verified: exactly 3 lines per file, no body changes).

This PR (data-sync): 5 drafts relinked to their real issues (e.g. 8773 → #6299), 0 flagged. The 10793/10798/10799 drafts are a genuine three-PRs-to-one-issue cluster (all close #10792) and are left intact — source_pr is now the stable provenance key, so these become the worklist for dedup-by-issue-ID (#135) rather than mis-links.

The remaining domains follow in their own PRs using the same tool (after this one is approved); their edge cases (suspect mismatches, PRs that close no issue) are flagged for manual confirmation rather than guessed.

Re-requesting review — thanks again for catching this. 🙏

@Hareet Hareet requested a review from sugat009 June 28, 2026 02:17
@Hareet Hareet self-assigned this Jun 28, 2026
@Hareet Hareet moved this from Todo to In Review in CHT Multi-Agent System (cht-agent) Jun 28, 2026

@sugat009 sugat009 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at a0ae253b against the live cht-core API and the code. Strong fix: the distiller no longer aliases the PR number, the shared resolver and relink tool are clean, and 13 of 14 drafts are correctly relinked. One systemic gap I think is worth closing before merge (inline), the rest are notes and suggestions. All of it is open to discussion, please push back wherever I've read it wrong.

for (const ref of closingRefs) push(ref.number, 'closing-ref');

const titleIssue = parseTitleIssue(prTitle);
if (titleIssue !== null) push(titleIssue, 'title');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): a title/body-derived number is trusted as an issue without confirming it isn't a PR

parseTitleIssue (:24) and this push(titleIssue, 'title') accept any positive integer; nothing checks that N is an issue rather than a pull request. So when a PR title is fix(#N): with N itself a PR (a follow-up PR) and closingIssuesReferences is empty, a PR number flows into issueNumber/issueUrl/id. Since this resolver is shared, the forward scraper reproduces it too (see the note on fetchLinkedIssues).

One way to close it: after resolving a title/body candidate, verify it's a real issue (gh api repos/<repo>/issues/N -> a pull_request field means PR, 404 means missing); if it's a PR, follow one hop to that PR's closingIssuesReferences, else flag. Keeping this file pure and putting the gh check in the callers that already shell out would preserve the scraper/relink symmetry. Open to other approaches, what's your read?

Comment thread src/scripts/scraper.ts

return Array.from(seen)
.map((issueNumber): LinkedIssue | null => {
function fetchLinkedIssues(refs: IssueRef[], repo: string): LinkedIssue[] {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this is the path that lets the above reach the corpus

gh issue view <N> succeeds on a PR number (GitHub serves PRs through the issues endpoint), so a PR-number ref isn't dropped by the catch below. Just flagging where an is-it-an-issue check would naturally live.

Comment thread src/scripts/relink-issues.ts Outdated
function classify(ctx: FileCtx, online: boolean, exec: ExecFn): Classification {
const src = ctx.src;
if (!src) return unchanged(ctx.file, ctx.issueNumber); // old-convention file, not the alias bug
if (ctx.issueNumber === src.prNumber) return classifyAffected(ctx, src, online, exec);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): the relink's "affected" test is narrower than the bug

classify treats a draft as affected only when issueNumber === src.prNumber. The 10399 draft has issueNumber 10182 != source_pr 10399, so it falls to classifySuspect, which passes it because issueNumber matches the filename token (:272) -> neither relinked nor flagged. Broadening this to "issueNumber doesn't resolve to a real issue" (the same gh check as above) would catch both the original alias and this second-order case, and a re-run picks up the stragglers. Does that seem right to you?

category: bug
domain: data-sync
domainFit: strong
issueNumber: 10182

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): this issueNumber resolves to a PR, not an issue

10182 is a pull request (fix(#10183):); on GitHub PR 10399 closes nothing, and the real issue reads as 10183 ("Replication fails to filter out reports with needs_signoff set to false"), which matches this draft's summary. So this looks like the one draft the relink missed. After the resolver change it should re-stamp to 10183 (id/issueNumber/issueUrl). Does that match your read of it?

@@ -0,0 +1,83 @@
---
id: cht-core-10792

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note (non-blocking): id isn't unique here, and that's probably fine, just worth a decision

10793/10798/10799 all resolve to issue 10792, so all three carry id: cht-core-10792. Harmless today (nothing keys on id), but schema.json:62 and TEMPLATE.md:81 call id a "Unique identifier", which the corpus can't honor once multiple PRs fix one issue. Lightest fix is just softening that wording, plus making sure the #135 consumer dedups on issueNumber rather than id. A composite cht-core-<issue>-pr<pr> is the honest long-term shape if anything ever needs an id-keyed map, but that's a corpus-wide re-stamp and probably not worth it now. Curious which way you'd lean.

Comment thread src/scripts/relink-issues.ts Outdated
/** Single sidebar closing-ref is ground truth; use it even if the token disagrees. */
function resolveFromSidebar(authoritative: number, token: Token | null): Resolution {
const tokenMismatch = token ? token.issueNumber !== authoritative : undefined;
return relink(authoritative, 'gh', tokenMismatch);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): cover the tokenMismatch=true path

This branch (sidebar wins over a disagreeing filename token) is the safety-critical override and the only one that sets the TOKEN-MISMATCH audit flag, but the spec only asserts tokenMismatch === false. A case where the sidebar closing-ref and the filename token disagree would lock it in.

let fm = block[2];
const repoName = repo.split('/')[1]; // medic/cht-core -> cht-core
const edits: Array<[RegExp, string]> = [
[/^id: cht-(?:core|interoperability)-\d+$/m, `id: ${repoName}-${newIssue}`],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): not quite repo-agnostic despite the doc

The id/issueUrl match regexes hardcode owner medic and the two repo names, so a non-medic repo would throw rather than rewrite. Either generalize or tweak the "repo-agnostic" comment. (No CRLF / non-medic test exists either.)

Comment thread src/scripts/issue-linkage.ts Outdated
const raw: Array<{ number: number; url?: string }> = Array.isArray(meta.closingIssuesReferences)
? meta.closingIssuesReferences
: [];
return raw.filter(r => typeof r.url === 'string' && r.url.includes(`/${repo}/issues/`));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): unanchored substring match

url.includes('/<repo>/issues/') also passes for a non-host occurrence (e.g. https://github.com/attacker/medic/cht-core/issues/1). Not exploitable on trusted GitHub data, but anchoring to the host would be tidier.

Hareet and others added 2 commits June 30, 2026 21:27
… — addresses #129 review

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Hareet

Hareet commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Thanks @sugat009 — all seven points addressed. Rather than patch just the 10399 case, we enumerated the full "a number could be an issue OR a PR" space and handled it systematically. All checks green (611 tests, coverage, SonarCloud).

Root cause

GitHub serves PRs through the issues endpoint, so gh issue view <pr> succeeds and a PR number passed as an issue. The only reliable disambiguator is the pull_request key on gh api repos/<repo>/issues/N — that's now the basis of a small shared module, gh-classify.ts, consumed by both the scraper (forward pipeline) and the one-off relink so they can't drift. issue-linkage.ts stays pure; all gh checks live in the callers, as you suggested.

Blocking

  1. Title/body number not verified as an issueclassifyNumber confirms a title/body candidate via the pull_request key; if it's a PR, resolveRealIssue follows it one hop to its closingIssuesReferences, else flags. Wired into scraper.fetchLinkedIssues and the relink. Closing-refs are trusted (issues-only); only title/body are verified.
  2. "Affected" too narrowclassify broadened from issueNumber === source_pr to "issueNumber doesn't resolve to a real issue" (PR or missing), keyed off the draft's own source_pr repo. Precedence: source-PR closing-refs first, then follow the stored issueNumber-PR.
  3. 10399 → 10182 (a PR) → real issue 10183 — resolved end-to-end and asserted in a regression test; it lands in the data-sync re-run.

Non-blocking

  1. Non-unique id — schema id description softened to note it isn't globally unique (several PRs can close one issue); consumers dedup on issueNumber (Context analysis agent doesn't load memory-pipeline drafts (wrong path + schema) #135). The relink surfaces every collision cluster (collidesWith) as that dedup worklist.
  2. tokenMismatch coverage — added; the 10399 test asserts tokenMismatch: true (gh sidebar overrides a disagreeing filename token, recorded for audit).
  3. Repo agnosticism — corrected the doc to state it handles medic cht-core/cht-interoperability (matching the regex), and every probe now uses the draft's own source_pr repo, so cht-interoperability drafts resolve against their own repo, not a hardcoded default.
  4. URL filtersameRepoClosingRefs now anchors: startsWith('https://github.com/<repo>/issues/'), closing the github.com/attacker/medic/cht-core/issues/1 crafted-path bypass; body-URL refs pointing at another repo are dropped at the (pure) source too.

Extra cases the enumeration caught (beyond 10399)

  • Multi-issue PR (source or referenced) → flagged for manual choice, never guessed.
  • PR that closes no issue → flagged.
  • Transitive PR→PR chains / cycles → since closing-refs are issues-only, a PR-hop lands on an issue in one hop; we deliberately do not follow a hopped PR's title/body, so there's no recursion/cycle surface — deeper chains flag.
  • Cross-repo body URL (shared numbering) → dropped at the source; the pull_request check wouldn't have caught this (it's wrong-repo, not issue-vs-PR).
  • Transferred/converted issuerepository_url mismatch → treated as missing/flagged, not silently resolved to the redirect target.
  • Transient gh error (rate-limit/5xx) → fail-loud (ScraperError, cause preserved) in the scraper / flag in the relink — never silently drops a linkage, which would otherwise flip a filter-skip into a distill.
  • Idempotency — a relinked draft whose filename token is now stale stays unchanged on re-run (no re-flag loop).

Re-requesting review 🙏

@Hareet Hareet requested a review from sugat009 July 1, 2026 03:32

@sugat009 sugat009 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at a152324 against the live cht-core API, the code, and the tests. The code fix is excellent and fully closes the issue-vs-PR gap, including the nested 10399 case, with a direct unit test for it; the schema id wording, the tokenMismatch=true test gap, and the substring/doc nitpicks are all resolved. One data step remains: the relink wasn't re-applied, so the committed 10399 draft still carries a PR number (inline). Requesting changes just for that re-run.

* Returns issue:null with a reason for missing / no-issue / multi-issue.
* @throws {GhTransientError} on a transient gh failure.
*/
export function resolveRealIssue(repo: string, n: number, exec: ExecFn, cache?: ClassifyCache): ResolveResult {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: clean resolution of the gap. Classifying via the pull_request key, following a PR to its sole closingIssuesReferences (one authoritative hop), and surfacing GhTransientError so a throttled lookup can't demote a real issue, all exactly right, and more robust than the review asked for.

* Resolve an affected draft's true issue: try the source PR's closing-refs first
* (most authoritative), then follow the stored issueNumber if it is itself a PR.
*/
function resolveAffectedIssue(src: SourcePr, issueNumber: number | undefined, gh: GhCtx): ResolveResult {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: verified this recovers the nested case. Trying the source PR first, then following the stored issueNumber when the source closes nothing, is what turns 10399 into 10183 (source 10399 has empty closing-refs, so it falls to issueNumber 10182 [a PR], whose closing issue is 10183). Multi-issue correctly short-circuits to a flag rather than guessing off issueNumber. Nicely done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Hareet

Hareet commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Done. Ran relink-issues --apply on agent-memory/domains/data-sync/issues, and 10399-fix10182-… now carries the real issue.

Once merged into main, I'll rebase the other memory/promote-* branches and relink the issues per domain before re-requesting the review on the PR.

@sugat009 sugat009 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep re-review at 58e1e0c. The data is now whole: all 14 data-sync drafts resolve to real issues (checked against GitHub closing-refs), the 10399 -> 10183 relink is correct, and the id-collision is now documented in schema.json with the #135 dedup contract. The resolution core (gh-classify / issue-linkage / the relink tool) is sound: frontmatter-only rewrites verified, regexes linear, fails closed. Approving. A few non-blocking refinements inline. The fail-loud gap in hydrateIssue is the one I'd most want your read on, here or as a follow-up. Nothing blocks on data grounds.

raw = exec('gh', ['api', `repos/${repo}/issues/${n}`]);
} catch (err) {
const text = errText(err);
if (/HTTP 404/i.test(text)) return null; // 404 only — never treat a transient "not found" as missing

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The GhTransientError design here is the right backbone for this PR. Treating only HTTP 404 as missing and raising on everything else means a rate-limited lookup can never demote a real issue to missing, the exact failure that would silently mis-attribute a draft. resolveRealIssue's one-hop, single-closing-issue conservatism (0 or >1 both yield null) is nicely defensive too.

Comment thread src/scripts/scraper.ts
const parsed = JSON.parse(raw);
const comments: string[] = (parsed.comments ?? []).map((c: { body: string }) => c.body);
return { number: n, body: parsed.body ?? '', comments };
} catch {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (non-blocking): hydrateIssue swallows every gh issue view failure into null, transient (rate-limit / 5xx) as well as a real 404. That undoes the fail-loud guarantee the rest of the module works for: resolveRef (L87) trusts closing-ref numbers directly without a classify call, so for the highest-authority source this is the only gh touch and has no transient protection. Under bulk-run throttling, a closing-ref issue can be dropped silently and a lower-authority body ref promoted into linkedIssues[0], the same silent mis-attribution this PR exists to prevent. resolveRef's own docstring (L80-84) even promises transient errors propagate rather than drop a linkage. Could hydrateIssue mirror fetchIssueRecord, return null only on HTTP 404 and throw GhTransientError otherwise? Open to whether that's worth doing here or as a follow-up, since the pattern predates this PR.


function readCtx(file: string): FileCtx {
const content = fs.readFileSync(file, 'utf8');
const fm = matter(content).data as Record<string, unknown>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (non-blocking): matter(content) isn't guarded, so a single draft with malformed YAML frontmatter throws out of planFile and aborts the whole relinkIssues run with a raw stack and no report. It fails closed (all planning precedes applyRelinks, so nothing is half-written), which is the important part, but as a corpus-maintenance tool it'd be friendlier to catch per-file and flag it as unparseable alongside the other flagged files. Minor, since real drafts are schema-validated on the way in.

Comment thread src/scripts/scraper.ts
/** Resolve + dedup + hydrate one ref; marks `seen` only on a successful new issue. */
function linkOneRef(ref: IssueRef, repo: string, cache: ClassifyCache, seen: Set<number>): LinkedIssue | null {
const issueNum = resolveRef(ref, repo, cache);
if (issueNum === null || seen.has(issueNum)) return null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): This resolveRef -> null -> drop line is the safety valve that stops a PR number being recorded as the issue, but it isn't pinned by a scrapePR-level test. The PR-following tests cover the single-closing-issue happy path; none drives a title/body ref whose resolveRealIssue returns null (multi-issue / no-issue / 404). If this regressed to ... ?? ref.number, the original PR-as-issue bug returns and every scraper test still passes (it's only unit-tested in gh-classify.spec). A scrapePR case where a title ref points at a PR closing 0 or >1 issues, asserting the ref is dropped, would lock it down.

*/

/** The source that contributed a linked-issue number, by descending authority. */
export type IssueSource = 'closing-ref' | 'title' | 'body';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought (non-blocking): IssueSource is a nice model, but the resolved source is used only for ordering and isn't persisted onto the draft. In the current corpus 4 of 14 links are title-derived (the PR closes nothing per GitHub's closing-refs, so the number comes from the fix(#N) title, the exact error-prone signal this PR fixes). They all resolve to real issues here, but they're indistinguishable from authoritative links once written. Persisting a linkSource/confidence field, or at least flagging title-only links, would let a human audit the low-confidence ones. Future-auditing thought, not a blocker.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

2 participants