From a530da445433969b3e9975e033bca3d11cfc249f Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:00:04 -0500 Subject: [PATCH 1/7] docs: design markdown relationship line restoration --- ...8-05-markdown-relationship-lines-design.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-markdown-relationship-lines-design.md diff --git a/docs/superpowers/specs/2026-08-05-markdown-relationship-lines-design.md b/docs/superpowers/specs/2026-08-05-markdown-relationship-lines-design.md new file mode 100644 index 0000000..6b84371 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-markdown-relationship-lines-design.md @@ -0,0 +1,104 @@ +# Markdown Relationship Line Restoration Design + +**Date:** 2026-08-05 +**Status:** Approved + +## Problem + +Stellr draws every dependency and parent relationship it receives, but the +active `teloverge/encrydle` snapshot reaches the renderer with no relationships. +The issue bodies contain 17 dependency references under `## Blocked by` and 26 +hierarchy references under `## Parent`. The GitHub fallback parser recognizes +only same-line forms such as `Blocked by #17`, so it ignores the references on +the following Markdown list lines. With an empty topology, the renderer has no +lines to draw. + +## Goals + +- Restore dependency lines described by `Blocked by` and `Blocks` sections. +- Restore hierarchy lines described by `Parent` sections when GitHub supplies + no native parent relationship. +- Preserve native GitHub relationships and the existing same-line fallback. +- Use issue bodies already fetched in an ordinary snapshot or stored in the + local cache; do not add a GitHub request. +- Keep the canvas renderer and its fixed spatial topology unchanged. + +## Non-goals + +- Mutating GitHub issues or recreating relationships in the tracker. +- Inferring relationships from arbitrary prose or task-list mentions. +- Changing line colors, geometry, animation, layout, or focus behavior. +- Fetching completed issue history or any additional provider page. + +## Accepted Markdown Grammar + +The relationship scanner continues to ignore fenced code blocks and existing +container prefixes. It additionally recognizes case-insensitive ATX headings +named exactly `Blocked by`, `Blocks`, or `Parent`, with an optional closing +heading marker. + +Within a recognized section, same-repository issue references such as `#17` +are collected from subsequent content lines until the next ATX heading. Empty +lines and `None` text are harmless. Existing same-line forms such as +`Blocked by #17, #19` and `Blocks #20` remain supported. + +Dependency references are sorted and deduplicated. A Markdown parent fallback +is accepted only when it names exactly one unique issue. If a native GitHub +parent exists, it wins. Multiple distinct Markdown parents are treated as +ambiguous and do not create a fallback parent relationship. + +## Data Flow + +Relationship enrichment remains in the GitHub adapter boundary: + +1. Obtain the ordinary issue snapshot or load the existing local cache. +2. Scan each already-present issue body for inline and section relationships. +3. Union parsed `Blocked by` references with native blockers. +4. Invert parsed `Blocks` references onto their target issues. +5. Fill a missing native parent from an unambiguous parsed `Parent` reference. +6. Pass the enriched `RawIssue` values through the existing derivation, + workflow-edge, layout, and canvas-rendering path. + +The enrichment operation is deterministic and idempotent, so applying it to an +old cache or a new provider snapshot produces the same relationships without +duplicates. Existing caches can therefore recover their visible lines without +a special history import or additional GitHub endpoint. + +## Failure Handling + +- Unknown headings do not affect section state beyond ending the current + recognized relationship section. +- References inside fenced code remain ignored. +- Missing target issues remain harmless; the existing workflow topology drops + dangling edges from the visible map. +- Ambiguous Markdown parents do not override or invent parent authority. +- Malformed references are ignored without failing the entire snapshot. + +## Testing + +Tests will be written before production changes and will cover: + +- Encrydle-style `## Blocked by` lists producing dependency references; +- `## Blocks` lists producing inverted dependency relationships; +- `## Parent` followed by one reference producing a fallback parent; +- native parent precedence over the Markdown fallback; +- multiple Markdown parents remaining ambiguous; +- section termination at the next heading; +- fenced examples remaining excluded; +- deduplication across native, inline, and section references; +- enrichment of a cache-shaped `RawIssue` collection without provider access; +- the existing renderer receiving and painting the recovered workflow edges. + +Verification uses the focused Rust and frontend suites first, followed by the +native Windows formatting, strict Clippy, complete workspace tests, frontend +check/tests/build, and the Windows-only NSIS development build when preparing +the local installer. + +## Acceptance Criteria + +- The current Encrydle issue-body format yields its dependency and parent + topology from already-available data. +- Connected nodes receive visible lines through the unchanged renderer. +- Existing inline and native relationship behavior remains green. +- No new GitHub API or GraphQL request is introduced. +- The checkout and Windows build remain reproducible with native toolchains. From e085882359c3e94db3c1a02a359ea50618b026b4 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:07:05 -0500 Subject: [PATCH 2/7] docs: plan markdown relationship line restoration --- .../2026-08-05-markdown-relationship-lines.md | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-markdown-relationship-lines.md diff --git a/docs/superpowers/plans/2026-08-05-markdown-relationship-lines.md b/docs/superpowers/plans/2026-08-05-markdown-relationship-lines.md new file mode 100644 index 0000000..0b8db37 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-markdown-relationship-lines.md @@ -0,0 +1,407 @@ +# Markdown Relationship Lines Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore visible dependency and parent lines when issue relationships are expressed in structured Markdown sections, including from an existing local cache. + +**Architecture:** Keep relationship interpretation inside `stellr-github`. Extend the existing fenced-code-aware scanner with exact relationship-section state, then apply one deterministic enrichment function to both fresh provider results and deserialized cache snapshots. Leave the core derivation, workflow topology, layout, and canvas renderer unchanged. + +**Tech Stack:** Rust 2024, Serde JSON, Octocrab-compatible GitHub GraphQL mapping, native Windows PowerShell, Cargo, Svelte 5, Vitest, Tauri 2, NSIS. + +## Global Constraints + +- Use native Windows 11 PowerShell and Windows toolchains only; do not use WSL or Linux tooling. +- Use issue bodies already present in the ordinary snapshot or local cache; add no GitHub request. +- Native GitHub relationships remain authoritative; Markdown is additive for dependencies and fallback-only for a missing parent. +- Recognize only exact case-insensitive ATX headings `Blocked by`, `Blocks`, and `Parent`, with an optional closing heading marker. +- Ignore fenced code, stop a relationship section at the next ATX heading, deduplicate references, and ignore an ambiguous Markdown parent. +- Do not change renderer colors, geometry, animation, layout, focus behavior, or fixed topology. +- Keep pending release notes in `Unreleased`; this focused fix does not create or rewrite a shipped-version section. + +--- + +### Task 1: Parse dependency relationship sections + +**Files:** +- Modify: `crates/github/src/textref.rs` +- Test: `crates/github/src/textref.rs` + +**Interfaces:** +- Consumes: `scan(body: &str) -> TextRefs`, existing inline relationship parsing, fence handling, and container-prefix handling. +- Produces: section-aware `blocked_by` and `blocks` vectors with the same sorted, deduplicated contract. + +- [ ] **Step 1: Write failing dependency-section tests** + +Add tests that name the production breaks they catch: + +```rust +#[test] +fn scans_dependency_references_beneath_markdown_headings() { + let refs = scan( + "## Blocked by\n\n- #17\n- #19\n## Blocks ##\n* #23\n", + ); + + assert_eq!(refs.blocked_by, vec![17, 19]); + assert_eq!(refs.blocks, vec![23]); +} + +#[test] +fn relationship_section_ends_at_the_next_heading() { + let refs = scan("## Blocked by\n- #17\n## Acceptance criteria\n- #99\n"); + + assert_eq!(refs.blocked_by, vec![17]); +} + +#[test] +fn section_references_still_ignore_fenced_examples_and_deduplicate_inline_refs() { + let refs = scan( + "Blocked by #17\n## Blocked by\n- #17\n```\n- #99\n```\n- #19\n", + ); + + assert_eq!(refs.blocked_by, vec![17, 19]); +} +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```powershell +cargo.exe test -p stellr-github textref::tests --locked +``` + +Expected: the new tests compile but fail because list items beneath relationship headings are not associated with a bucket. + +- [ ] **Step 3: Implement exact heading and section parsing** + +In `textref.rs`, introduce a private dependency-section enum and an ATX heading parser: + +```rust +#[derive(Clone, Copy)] +enum RelationshipSection { + BlockedBy, + Blocks, +} + +fn atx_heading(line: &str) -> Option<&str> { + let line = line.trim_start(); + let level = line.bytes().take_while(|byte| *byte == b'#').count(); + if !(1..=6).contains(&level) + || !line.as_bytes().get(level).is_some_and(u8::is_ascii_whitespace) + { + return None; + } + + let title = line[level..].trim(); + let without_markers = title.trim_end_matches('#'); + if without_markers.len() != title.len() + && without_markers + .chars() + .last() + .is_some_and(char::is_whitespace) + { + Some(without_markers.trim_end()) + } else { + Some(title) + } +} +``` + +Track `section: Option` beside the existing fence state. A heading always replaces or clears the section. For non-heading, non-fenced lines, preserve the existing inline `Blocked by`/`Blocks` detection; otherwise route the stripped content into the active section. Extract all `#` followed immediately by ASCII digits through one helper, then retain the existing sort/dedup finalization. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: + +```powershell +cargo.exe test -p stellr-github textref::tests --locked +``` + +Expected: all text-reference tests pass. + +- [ ] **Step 5: Commit the dependency-section slice** + +```powershell +git add -- crates/github/src/textref.rs +git -c gpg.ssh.program=C:/Windows/System32/OpenSSH/ssh-keygen.exe commit -m "fix(github): parse dependency relationship sections" +``` + +--- + +### Task 2: Enrich fresh snapshots with parent and dependency relationships + +**Files:** +- Modify: `crates/github/src/textref.rs` +- Modify: `crates/github/src/sync.rs` +- Test: `crates/github/tests/sync_test.rs` + +**Interfaces:** +- Consumes: `TextRefs`, `RawIssue`, native `blocked_by`, native `parent_issue`, and bodies already returned by the existing GraphQL issue query. +- Produces: `pub(crate) fn enrich_relationships(issues: &mut [RawIssue])`, which deterministically merges section and inline dependencies, applies `Blocks` inversions, and fills only a missing unambiguous parent. + +- [ ] **Step 1: Write a failing provider-boundary test** + +Add a `wiremock` test using the real `GithubProvider::fetch` mapping seam and the existing `node` fixture. Supply four issues: + +```rust +node(1, "Root", Some(""), "https://example.test/o/r/issues/1", "OPEN", None, &[], None, &[], &[], None), +node(2, "Markdown child", Some("## Parent\n\n#1\n## Blocked by\n\n- #1"), "https://example.test/o/r/issues/2", "OPEN", None, &[], None, &[], &[], None), +node(3, "Native parent wins", Some("## Parent\n\n#1\n## Blocks\n\n- #2"), "https://example.test/o/r/issues/3", "OPEN", None, &[], None, &[], &[], Some(9)), +node(4, "Ambiguous parent", Some("## Parent\n\n- #1\n- #2"), "https://example.test/o/r/issues/4", "OPEN", None, &[], None, &[], &[], None), +``` + +Assert hand-derived results: + +```rust +assert_eq!(issues[1].parent_issue, Some(1)); +assert_eq!(issues[1].blocked_by, vec![1, 3]); +assert_eq!(issues[2].parent_issue, Some(9)); +assert_eq!(issues[3].parent_issue, None); +``` + +- [ ] **Step 2: Run the provider test and verify RED** + +Run: + +```powershell +cargo.exe test -p stellr-github --test sync_test fetch_enriches_markdown_relationship_sections --locked -- --exact +``` + +Expected: the Markdown parent remains `None` and section relationships are incomplete. + +- [ ] **Step 3: Implement deterministic relationship enrichment** + +Extend `TextRefs` and the section enum with a parent bucket: + +```rust +pub struct TextRefs { + pub blocked_by: Vec, + pub blocks: Vec, + pub parents: Vec, +} +``` + +Sort and deduplicate `parents` with the other buckets. Add: + +```rust +pub(crate) fn enrich_relationships(issues: &mut [RawIssue]) { + let mut inversions = Vec::new(); + + for issue in issues.iter_mut() { + let refs = scan(&issue.body); + issue.blocked_by.extend(refs.blocked_by); + issue.blocked_by.sort_unstable(); + issue.blocked_by.dedup(); + inversions.extend(refs.blocks.into_iter().map(|target| (issue.number, target))); + if issue.parent_issue.is_none() && refs.parents.len() == 1 { + issue.parent_issue = Some(refs.parents[0]); + } + } + + let positions = issues + .iter() + .enumerate() + .map(|(index, issue)| (issue.number, index)) + .collect::>(); + for (blocker, target) in inversions { + if let Some(&index) = positions.get(&target) { + issues[index].blocked_by.push(blocker); + } + } + for issue in issues { + issue.blocked_by.sort_unstable(); + issue.blocked_by.dedup(); + } +} +``` + +Import `HashMap` and `RawIssue` in `textref.rs`. Simplify `map_issues` so it constructs `RawIssue` values with only native relationships, then calls `textref::enrich_relationships(&mut issues)` once before returning. Do not change the GraphQL query or pagination. + +- [ ] **Step 4: Run provider and parser suites and verify GREEN** + +Run: + +```powershell +cargo.exe test -p stellr-github --test sync_test --locked +cargo.exe test -p stellr-github textref::tests --locked +``` + +Expected: the new provider-boundary test and all existing mapping/parser tests pass. + +- [ ] **Step 5: Commit the fresh-snapshot slice** + +```powershell +git add -- crates/github/src/textref.rs crates/github/src/sync.rs crates/github/tests/sync_test.rs +git -c gpg.ssh.program=C:/Windows/System32/OpenSSH/ssh-keygen.exe commit -m "fix(github): enrich structured issue relationships" +``` + +--- + +### Task 3: Repair relationships from existing cache snapshots + +**Files:** +- Modify: `crates/github/src/cache.rs` +- Test: `crates/github/src/cache.rs` + +**Interfaces:** +- Consumes: `textref::enrich_relationships(&mut [RawIssue])` from Task 2 and deserialized `Snapshot` data. +- Produces: `Cache::load` returns an enriched in-memory snapshot without rewriting the cache or contacting GitHub. + +- [ ] **Step 1: Write a failing cache-boundary test** + +Construct and store a snapshot with three raw issues whose native relationship fields are empty: + +```rust +let mut source = snapshot("Root", 1_753_000_000); +source.issues.push(RawIssue { + number: 2, + parent_issue: None, + title: "Dependent".into(), + body: "## Parent\n\n#1\n## Blocked by\n\n- #1".into(), + state: IssueState::Open, + assignees: vec![], + milestone: None, + labels: vec![], + blocked_by: vec![], + url: "u2".into(), +}); +source.issues.push(RawIssue { + number: 3, + parent_issue: None, + title: "Blocker by inversion".into(), + body: "## Blocks\n\n- #2".into(), + state: IssueState::Open, + assignees: vec![], + milestone: None, + labels: vec![], + blocked_by: vec![], + url: "u3".into(), +}); +``` + +After `cache.store` and `cache.load`, assert: + +```rust +assert_eq!(loaded.issues[1].parent_issue, Some(1)); +assert_eq!(loaded.issues[1].blocked_by, vec![1, 3]); +``` + +Store the loaded snapshot and load it again; assert the second load equals the first to prove idempotence. + +- [ ] **Step 2: Run the cache test and verify RED** + +Run: + +```powershell +cargo.exe test -p stellr-github cache::tests::load_enriches_relationships_from_cached_bodies --locked -- --exact +``` + +Expected: the loaded relationship fields remain empty. + +- [ ] **Step 3: Enrich only the deserialized in-memory cache value** + +Change `Cache::load` to deserialize mutably, enrich, and return: + +```rust +pub fn load(&self, repo: &RepoRef) -> Option { + let bytes = fs::read(self.path_for(repo)).ok()?; + let mut snapshot: Snapshot = serde_json::from_slice(&bytes).ok()?; + crate::textref::enrich_relationships(&mut snapshot.issues); + Some(snapshot) +} +``` + +Do not write during load and do not add a provider call. + +- [ ] **Step 4: Run the complete GitHub crate tests and verify GREEN** + +Run: + +```powershell +cargo.exe test -p stellr-github --locked +``` + +Expected: all unit and integration tests pass. + +- [ ] **Step 5: Commit the cache-repair slice** + +```powershell +git add -- crates/github/src/cache.rs +git -c gpg.ssh.program=C:/Windows/System32/OpenSSH/ssh-keygen.exe commit -m "fix(github): recover relationships from cached bodies" +``` + +--- + +### Task 4: Verify the complete fix and prepare the Windows installer + +**Files:** +- Verify: `crates/github/src/textref.rs` +- Verify: `crates/github/src/sync.rs` +- Verify: `crates/github/src/cache.rs` +- Verify: `crates/github/tests/sync_test.rs` +- Verify: `web/src/lib/starmap/edge-visual.test.ts` +- Build output: `artifacts/windows-x64/Stellr_0.1.0_windows-x64_nsis_UNSIGNED-NOT-FOR-RELEASE.exe` + +**Interfaces:** +- Consumes: completed parser, fresh-snapshot, and cache enrichment slices. +- Produces: native Windows verification evidence, two-axis code-review findings resolved, and a locally installable unsigned NSIS artifact. + +- [ ] **Step 1: Run focused downstream renderer evidence** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test -- src/lib/starmap/edge-visual.test.ts +``` + +Expected: the real canvas seam continues to paint dependency/workflow strokes. + +- [ ] **Step 2: Run complete frontend gates** + +```powershell +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web run check +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web test +C:\Users\pfdev\.vite-plus\bin\npm.exe --prefix web run build +``` + +Expected: zero Svelte errors/warnings, all Vitest files pass, and the production bundle builds. + +- [ ] **Step 3: Run complete native Rust gates** + +```powershell +cargo.exe fmt --all -- --check +cargo.exe clippy --workspace --all-targets --locked -- -D warnings +cargo.exe test --workspace --locked -- --test-threads=1 +cargo.exe build --workspace --locked +git diff --check +``` + +Expected: all commands exit zero. Tests requiring live GitHub writes may remain explicitly ignored; no executed test may fail. + +- [ ] **Step 4: Run two-axis code review** + +Use the `code-review` skill with fixed point `a530da4`, reviewing repository standards and the approved design independently. Resolve required findings with a fresh focused red/green cycle, rerun affected suites, and retain unrelated code unchanged. + +- [ ] **Step 5: Build only the native Windows NSIS development installer** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\build-windows-nsis.ps1 +``` + +Expected output includes: + +```text +WINDOWS_NSIS_SIGNATURE=NotSigned +WINDOWS_NSIS_ARTIFACT=D:\dev\stellr\artifacts\windows-x64\Stellr_0.1.0_windows-x64_nsis_UNSIGNED-NOT-FOR-RELEASE.exe +``` + +Verify the file exists, its `.sha256` sidecar matches `Get-FileHash -Algorithm SHA256`, and no macOS or Linux packaging script ran. + +- [ ] **Step 6: Confirm final branch scope** + +```powershell +git status --short --branch +git log --oneline a530da4..HEAD +git diff --stat a530da4..HEAD +git diff --check a530da4..HEAD +``` + +Expected: the branch is clean; commits after the design cover only relationship parsing, snapshot/cache enrichment, and their tests. From e4d96db400e892379330946dac8936330fca008d Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:12:13 -0500 Subject: [PATCH 3/7] fix(github): parse dependency relationship sections --- crates/github/src/textref.rs | 100 ++++++++++++++++++++++++++++++----- 1 file changed, 87 insertions(+), 13 deletions(-) diff --git a/crates/github/src/textref.rs b/crates/github/src/textref.rs index 6f675f0..d2b4d43 100644 --- a/crates/github/src/textref.rs +++ b/crates/github/src/textref.rs @@ -10,6 +10,52 @@ struct Fence { length: usize, } +#[derive(Clone, Copy)] +enum RelationshipSection { + BlockedBy, + Blocks, +} + +fn atx_heading(line: &str) -> Option<&str> { + let line = line.trim_start(); + let level = line.bytes().take_while(|byte| *byte == b'#').count(); + if !(1..=6).contains(&level) + || !line + .as_bytes() + .get(level) + .is_some_and(u8::is_ascii_whitespace) + { + return None; + } + + let title = line[level..].trim(); + let without_markers = title.trim_end_matches('#'); + if without_markers.len() != title.len() + && without_markers + .chars() + .last() + .is_some_and(char::is_whitespace) + { + Some(without_markers.trim_end()) + } else { + Some(title) + } +} + +fn extract_issue_references(line: &str, bucket: &mut Vec) { + for (index, character) in line.char_indices() { + if character == '#' { + let digits: String = line[index + 1..] + .chars() + .take_while(|character| character.is_ascii_digit()) + .collect(); + if let Ok(number) = digits.parse::() { + bucket.push(number); + } + } + } +} + fn fence_at_start(line: &str, closing: bool) -> Option { let indentation = line.bytes().take_while(|byte| *byte == b' ').count(); if indentation > 3 { @@ -85,6 +131,7 @@ fn strip_leading_markers(mut line: &str) -> &str { pub fn scan(body: &str) -> TextRefs { let mut refs = TextRefs::default(); let mut open_fence: Option = None; + let mut section: Option = None; for raw in body.lines() { let fence_line = strip_container_prefixes(raw); @@ -105,26 +152,31 @@ pub fn scan(body: &str) -> TextRefs { None => {} } + if let Some(title) = atx_heading(raw) { + section = match title.to_ascii_lowercase().as_str() { + "blocked by" => Some(RelationshipSection::BlockedBy), + "blocks" => Some(RelationshipSection::Blocks), + _ => None, + }; + continue; + } + let stripped = strip_leading_markers(trimmed); let lower = stripped.to_ascii_lowercase(); let bucket = if lower.starts_with("blocked by") { - &mut refs.blocked_by + Some(&mut refs.blocked_by) } else if lower.starts_with("blocks") { - &mut refs.blocks + Some(&mut refs.blocks) } else { - continue; + match section { + Some(RelationshipSection::BlockedBy) => Some(&mut refs.blocked_by), + Some(RelationshipSection::Blocks) => Some(&mut refs.blocks), + None => None, + } }; - for (index, character) in stripped.char_indices() { - if character == '#' { - let digits: String = stripped[index + 1..] - .chars() - .take_while(|character| character.is_ascii_digit()) - .collect(); - if let Ok(number) = digits.parse::() { - bucket.push(number); - } - } + if let Some(bucket) = bucket { + extract_issue_references(stripped, bucket); } } @@ -223,4 +275,26 @@ mod tests { let refs = scan("This is blocked by #5 in spirit.\n"); assert_eq!(refs.blocked_by, Vec::::new()); } + + #[test] + fn scans_dependency_references_beneath_markdown_headings() { + let refs = scan("## Blocked by\n\n- #17\n- #19\n## Blocks ##\n* #23\n"); + + assert_eq!(refs.blocked_by, vec![17, 19]); + assert_eq!(refs.blocks, vec![23]); + } + + #[test] + fn relationship_section_ends_at_the_next_heading() { + let refs = scan("## Blocked by\n- #17\n## Acceptance criteria\n- #99\n"); + + assert_eq!(refs.blocked_by, vec![17]); + } + + #[test] + fn section_references_still_ignore_fenced_examples_and_deduplicate_inline_refs() { + let refs = scan("Blocked by #17\n## Blocked by\n- #17\n```\n- #99\n```\n- #19\n"); + + assert_eq!(refs.blocked_by, vec![17, 19]); + } } From 952d14fface7aaada215ddb95a17acd0a155344d Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:15:01 -0500 Subject: [PATCH 4/7] fix(github): parse container-prefixed relationship headings --- crates/github/src/textref.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/github/src/textref.rs b/crates/github/src/textref.rs index d2b4d43..533c5ab 100644 --- a/crates/github/src/textref.rs +++ b/crates/github/src/textref.rs @@ -152,7 +152,7 @@ pub fn scan(body: &str) -> TextRefs { None => {} } - if let Some(title) = atx_heading(raw) { + if let Some(title) = atx_heading(fence_line) { section = match title.to_ascii_lowercase().as_str() { "blocked by" => Some(RelationshipSection::BlockedBy), "blocks" => Some(RelationshipSection::Blocks), @@ -297,4 +297,12 @@ mod tests { assert_eq!(refs.blocked_by, vec![17, 19]); } + + #[test] + fn scans_dependency_references_beneath_container_prefixed_headings() { + let refs = scan("> ## Blocked by\n> - #17\n- ## Blocks\n- - #23\n"); + + assert_eq!(refs.blocked_by, vec![17]); + assert_eq!(refs.blocks, vec![23]); + } } From 9540e20ce2d26ba66cef1c89220abeb810331de2 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:24:50 -0500 Subject: [PATCH 5/7] fix(github): enrich structured issue relationships --- crates/github/src/sync.rs | 101 +++++++++++++------------------ crates/github/src/textref.rs | 40 ++++++++++++ crates/github/tests/sync_test.rs | 71 ++++++++++++++++++++++ 3 files changed, 152 insertions(+), 60 deletions(-) diff --git a/crates/github/src/sync.rs b/crates/github/src/sync.rs index 0b98ece..6f50d85 100644 --- a/crates/github/src/sync.rs +++ b/crates/github/src/sync.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use octocrab::{FromResponse, Octocrab}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -190,67 +188,50 @@ fn map_octocrab_error(error: octocrab::Error) -> ProviderError { } fn map_issues(nodes: Vec) -> Vec { - let mut issues = Vec::with_capacity(nodes.len()); - let mut inversions = Vec::new(); - - for node in nodes { - let body = node.body.unwrap_or_default(); - let refs = textref::scan(&body); - let mut blocked_by = node - .blocked_by - .nodes - .into_iter() - .map(|issue| issue.number) - .chain(refs.blocked_by) - .collect::>(); - blocked_by.sort_unstable(); - blocked_by.dedup(); - - inversions.extend(refs.blocks.into_iter().map(|target| (node.number, target))); - issues.push(RawIssue { - number: node.number, - parent_issue: node.parent.map(|parent| parent.number), - title: node.title, - body, - state: match node.state { - GithubIssueState::Open => IssueState::Open, - GithubIssueState::Closed if node.state_reason.as_deref() == Some("NOT_PLANNED") => { - IssueState::ClosedNotPlanned - } - GithubIssueState::Closed => IssueState::Closed, - }, - assignees: node - .assignees - .nodes - .into_iter() - .map(|assignee| assignee.login) - .collect(), - milestone: node.milestone.map(|milestone| milestone.title), - labels: node - .labels + let mut issues = nodes + .into_iter() + .map(|node| { + let body = node.body.unwrap_or_default(); + let blocked_by = node + .blocked_by .nodes .into_iter() - .map(|label| label.name) - .collect(), - blocked_by, - url: node.url, - }); - } + .map(|issue| issue.number) + .collect::>(); + RawIssue { + number: node.number, + parent_issue: node.parent.map(|parent| parent.number), + title: node.title, + body, + state: match node.state { + GithubIssueState::Open => IssueState::Open, + GithubIssueState::Closed + if node.state_reason.as_deref() == Some("NOT_PLANNED") => + { + IssueState::ClosedNotPlanned + } + GithubIssueState::Closed => IssueState::Closed, + }, + assignees: node + .assignees + .nodes + .into_iter() + .map(|assignee| assignee.login) + .collect(), + milestone: node.milestone.map(|milestone| milestone.title), + labels: node + .labels + .nodes + .into_iter() + .map(|label| label.name) + .collect(), + blocked_by, + url: node.url, + } + }) + .collect::>(); - let positions = issues - .iter() - .enumerate() - .map(|(index, issue)| (issue.number, index)) - .collect::>(); - for (blocker, target) in inversions { - if let Some(&index) = positions.get(&target) { - issues[index].blocked_by.push(blocker); - } - } - for issue in &mut issues { - issue.blocked_by.sort_unstable(); - issue.blocked_by.dedup(); - } + textref::enrich_relationships(&mut issues); issues } diff --git a/crates/github/src/textref.rs b/crates/github/src/textref.rs index 533c5ab..4984b9d 100644 --- a/crates/github/src/textref.rs +++ b/crates/github/src/textref.rs @@ -1,7 +1,12 @@ +use std::collections::HashMap; + +use stellr_core::RawIssue; + #[derive(Debug, Default, PartialEq)] pub struct TextRefs { pub blocked_by: Vec, pub blocks: Vec, + pub parents: Vec, } #[derive(Clone, Copy)] @@ -14,6 +19,7 @@ struct Fence { enum RelationshipSection { BlockedBy, Blocks, + Parent, } fn atx_heading(line: &str) -> Option<&str> { @@ -156,6 +162,7 @@ pub fn scan(body: &str) -> TextRefs { section = match title.to_ascii_lowercase().as_str() { "blocked by" => Some(RelationshipSection::BlockedBy), "blocks" => Some(RelationshipSection::Blocks), + "parent" => Some(RelationshipSection::Parent), _ => None, }; continue; @@ -171,6 +178,7 @@ pub fn scan(body: &str) -> TextRefs { match section { Some(RelationshipSection::BlockedBy) => Some(&mut refs.blocked_by), Some(RelationshipSection::Blocks) => Some(&mut refs.blocks), + Some(RelationshipSection::Parent) => Some(&mut refs.parents), None => None, } }; @@ -184,9 +192,41 @@ pub fn scan(body: &str) -> TextRefs { refs.blocked_by.dedup(); refs.blocks.sort_unstable(); refs.blocks.dedup(); + refs.parents.sort_unstable(); + refs.parents.dedup(); refs } +pub(crate) fn enrich_relationships(issues: &mut [RawIssue]) { + let mut inversions = Vec::new(); + + for issue in issues.iter_mut() { + let refs = scan(&issue.body); + issue.blocked_by.extend(refs.blocked_by); + issue.blocked_by.sort_unstable(); + issue.blocked_by.dedup(); + inversions.extend(refs.blocks.into_iter().map(|target| (issue.number, target))); + if issue.parent_issue.is_none() && refs.parents.len() == 1 { + issue.parent_issue = Some(refs.parents[0]); + } + } + + let positions = issues + .iter() + .enumerate() + .map(|(index, issue)| (issue.number, index)) + .collect::>(); + for (blocker, target) in inversions { + if let Some(&index) = positions.get(&target) { + issues[index].blocked_by.push(blocker); + } + } + for issue in issues { + issue.blocked_by.sort_unstable(); + issue.blocked_by.dedup(); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/github/tests/sync_test.rs b/crates/github/tests/sync_test.rs index 71a9c92..0bca715 100644 --- a/crates/github/tests/sync_test.rs +++ b/crates/github/tests/sync_test.rs @@ -347,6 +347,77 @@ async fn fetch_maps_complete_issue_shape_and_merges_dependency_sources() { ); } +#[tokio::test] +async fn fetch_enriches_markdown_relationship_sections() { + let server = MockServer::start().await; + mount_graphql_response( + &server, + page(json!([ + node( + 1, + "Root", + Some(""), + "https://example.test/o/r/issues/1", + "OPEN", + None, + &[], + None, + &[], + &[], + None, + ), + node( + 2, + "Markdown child", + Some("## Parent\n\n#1\n## Blocked by\n\n- #1"), + "https://example.test/o/r/issues/2", + "OPEN", + None, + &[], + None, + &[], + &[], + None, + ), + node( + 3, + "Native parent wins", + Some("## Parent\n\n#1\n## Blocks\n\n- #2"), + "https://example.test/o/r/issues/3", + "OPEN", + None, + &[], + None, + &[], + &[], + Some(9), + ), + node( + 4, + "Ambiguous parent", + Some("## Parent\n\n- #1\n- #2"), + "https://example.test/o/r/issues/4", + "OPEN", + None, + &[], + None, + &[], + &[], + None, + ), + ])), + ) + .await; + + let provider = GithubProvider::with_base_uri("tok".into(), &server.uri()).unwrap(); + let issues = provider.fetch(&repo()).await.unwrap(); + + assert_eq!(issues[1].parent_issue, Some(1)); + assert_eq!(issues[1].blocked_by, vec![1, 3]); + assert_eq!(issues[2].parent_issue, Some(9)); + assert_eq!(issues[3].parent_issue, None); +} + #[tokio::test] async fn fetch_maps_the_first_graphql_error_to_parse() { let server = MockServer::start().await; From db648a5de36f9629f18c874000c5b2099623292e Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:29:35 -0500 Subject: [PATCH 6/7] fix(github): recover relationships from cached bodies --- crates/github/src/cache.rs | 47 +++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/github/src/cache.rs b/crates/github/src/cache.rs index 5fc4c19..ae36b05 100644 --- a/crates/github/src/cache.rs +++ b/crates/github/src/cache.rs @@ -34,7 +34,9 @@ impl Cache { pub fn load(&self, repo: &RepoRef) -> Option { let bytes = fs::read(self.path_for(repo)).ok()?; - serde_json::from_slice(&bytes).ok() + let mut snapshot: Snapshot = serde_json::from_slice(&bytes).ok()?; + crate::textref::enrich_relationships(&mut snapshot.issues); + Some(snapshot) } /// Writes a snapshot through a same-directory temporary file. @@ -320,6 +322,49 @@ mod tests { assert_eq!(snapshot.issues[0].parent_issue, None); } + #[test] + fn load_enriches_relationships_from_cached_bodies() { + let dir = tempfile::tempdir().unwrap(); + let cache = Cache::new(dir.path().to_path_buf()); + let repo = repo(); + let mut source = snapshot("Root", 1_753_000_000); + source.issues.push(RawIssue { + number: 2, + parent_issue: None, + title: "Dependent".into(), + body: "## Parent\n\n#1\n## Blocked by\n\n- #1".into(), + state: IssueState::Open, + assignees: vec![], + milestone: None, + labels: vec![], + blocked_by: vec![], + url: "u2".into(), + }); + source.issues.push(RawIssue { + number: 3, + parent_issue: None, + title: "Blocker by inversion".into(), + body: "## Blocks\n\n- #2".into(), + state: IssueState::Open, + assignees: vec![], + milestone: None, + labels: vec![], + blocked_by: vec![], + url: "u3".into(), + }); + + cache.store(&repo, &source).unwrap(); + let loaded = cache.load(&repo).unwrap(); + + assert_eq!(loaded.issues[1].parent_issue, Some(1)); + assert_eq!(loaded.issues[1].blocked_by, vec![1, 3]); + + cache.store(&repo, &loaded).unwrap(); + let loaded_again = cache.load(&repo).unwrap(); + + assert_eq!(loaded_again, loaded); + } + #[test] fn store_replaces_an_existing_snapshot() { let dir = tempfile::tempdir().unwrap(); From 2335f89daf629a39bb0982b8db9cb6c0a845b822 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:52:03 -0500 Subject: [PATCH 7/7] fix(github): address relationship review findings --- CHANGELOG.md | 2 + crates/github/src/textref.rs | 72 +++++++++++++++++++++++++++++------- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b23b52b..887f38c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Restored dependency and parent relationship lines from cached Markdown issue + bodies without making additional GitHub requests. - Fixed installed desktop startup so a no-argument launch opens the existing empty repository-selection shell instead of treating the installation directory as a Git repository. diff --git a/crates/github/src/textref.rs b/crates/github/src/textref.rs index 4984b9d..176dc4d 100644 --- a/crates/github/src/textref.rs +++ b/crates/github/src/textref.rs @@ -23,13 +23,20 @@ enum RelationshipSection { } fn atx_heading(line: &str) -> Option<&str> { - let line = line.trim_start(); + let indentation = line.bytes().take_while(|byte| *byte == b' ').count(); + if indentation > 3 { + return None; + } + + let line = &line[indentation..]; let level = line.bytes().take_while(|byte| *byte == b'#').count(); - if !(1..=6).contains(&level) - || !line - .as_bytes() - .get(level) - .is_some_and(u8::is_ascii_whitespace) + if !(1..=6).contains(&level) { + return None; + } + if line + .as_bytes() + .get(level) + .is_some_and(|byte| !byte.is_ascii_whitespace()) { return None; } @@ -51,10 +58,25 @@ fn atx_heading(line: &str) -> Option<&str> { fn extract_issue_references(line: &str, bucket: &mut Vec) { for (index, character) in line.char_indices() { if character == '#' { - let digits: String = line[index + 1..] - .chars() - .take_while(|character| character.is_ascii_digit()) - .collect(); + let left_is_boundary = line[..index].chars().next_back().is_none_or(|character| { + !character.is_alphanumeric() && !matches!(character, '_' | '-' | '.' | '/' | '\\') + }); + if !left_is_boundary { + continue; + } + + let suffix = &line[index + 1..]; + let digit_count = suffix.bytes().take_while(u8::is_ascii_digit).count(); + if digit_count == 0 + || suffix[digit_count..] + .chars() + .next() + .is_some_and(|character| character.is_alphanumeric() || character == '_') + { + continue; + } + + let digits = &suffix[..digit_count]; if let Ok(number) = digits.parse::() { bucket.push(number); } @@ -203,8 +225,6 @@ pub(crate) fn enrich_relationships(issues: &mut [RawIssue]) { for issue in issues.iter_mut() { let refs = scan(&issue.body); issue.blocked_by.extend(refs.blocked_by); - issue.blocked_by.sort_unstable(); - issue.blocked_by.dedup(); inversions.extend(refs.blocks.into_iter().map(|target| (issue.number, target))); if issue.parent_issue.is_none() && refs.parents.len() == 1 { issue.parent_issue = Some(refs.parents[0]); @@ -318,7 +338,7 @@ mod tests { #[test] fn scans_dependency_references_beneath_markdown_headings() { - let refs = scan("## Blocked by\n\n- #17\n- #19\n## Blocks ##\n* #23\n"); + let refs = scan(" ## Blocked by\n\n- #17\n- #19\n## Blocks ##\n* #23\n"); assert_eq!(refs.blocked_by, vec![17, 19]); assert_eq!(refs.blocks, vec![23]); @@ -331,6 +351,30 @@ mod tests { assert_eq!(refs.blocked_by, vec![17]); } + #[test] + fn four_space_indented_pseudo_heading_does_not_end_relationship_section() { + let refs = scan("## Blocked by\n- #17\n ## Acceptance criteria\n- #99\n"); + + assert_eq!(refs.blocked_by, vec![17, 99]); + } + + #[test] + fn marker_only_atx_heading_ends_relationship_section() { + let refs = scan("## Blocked by\n- #17\n##\n- #99\n"); + + assert_eq!(refs.blocked_by, vec![17]); + } + + #[test] + fn relationship_sections_only_accept_standalone_same_repository_references() { + let refs = scan( + "## Blocked by\n- other/repo#17\n- #18oops\n- (#19), #20.\n## Parent\n- other/repo#21\n- #22oops\n", + ); + + assert_eq!(refs.blocked_by, vec![19, 20]); + assert_eq!(refs.parents, Vec::::new()); + } + #[test] fn section_references_still_ignore_fenced_examples_and_deduplicate_inline_refs() { let refs = scan("Blocked by #17\n## Blocked by\n- #17\n```\n- #99\n```\n- #19\n"); @@ -340,7 +384,7 @@ mod tests { #[test] fn scans_dependency_references_beneath_container_prefixed_headings() { - let refs = scan("> ## Blocked by\n> - #17\n- ## Blocks\n- - #23\n"); + let refs = scan("> ## Blocked by\n> - #17\n- ## Blocks\n- - #23\n"); assert_eq!(refs.blocked_by, vec![17]); assert_eq!(refs.blocks, vec![23]);