The pr-finder action starts from the Objective issues on a Projects v2 board, walks each one's sub-issue hierarchy down to N levels (default 5, across repos/orgs, read-only), and lists the pull requests that close those issues.
Files are named by a PI/Sprint slug — pr_finder_pi-27.2_sprint-5.*, pr_finder_pi-27.2.*,
pr_finder_sprint-5.*, or pr_finder_all.* when unfiltered. <base> below = pr_finder_<slug>.
| File | What |
|---|---|
<base>.csv |
one row per (issue, closing-PR): objective_number, objective_title, objective_repo, issue_number, issue_title, issue_repo, depth, pr_number, pr_title, pr_repo, pr_state, pr_url. Issues with no PR still get a row (blank pr_*) unless --only-with-prs. |
<base>.md |
GitHub-flavored: an H2 per objective, then an unordered list of its PRs. Also appended to the Actions Step Summary. |
<base>.html |
self-contained, USWDS-styled (inline CSS, opens offline). |
<base>_stats.json |
{objectives, issues_crawled, prs_found, max_depth_reached, api_calls, truncated}. |
pr_finder_manifest.json |
fixed name — {slug, pi, sprint, generated, board_title, files, stats}; the action/dashboard read this to locate the slugged files. |
index.json |
on the pr-finder/report branch only — the accumulated list of every published report (dedup by slug, newest first), driving the dashboard's report picker. |
Re-run behavior: the filename is the slug (no timestamp), so re-running the same PI/sprint
overwrites its report + index.json entry; a different PI/sprint is added. Each run's
commit diff carries only what changed.
- Objective detection: a board item is an objective if its issue title contains
"objective"(case-insensitive) — same rule as the FTE report. Seed root titles must include the word. - Sprint / PI filtering:
--pi/--sprint(action inputspi/sprint, or the workflow's manual-dispatch inputs) keep only objectives whose Program Increment / Sprint board field matches. Case/space-insensitive and thePI/Sprintprefix is optional (27.2==PI 27.2); handles both single-select and iteration fields; warns if the board has no such field; filters AND together. Board items are read with--limit 2000so large boards (e.g. 1,000+ items) aren't truncated before filtering. - Traversal: iterative BFS by level over the GraphQL
subIssuesconnection, alias-batched (≤20 parents per request). It never deep-nests a single query (that blows up GraphQL node cost), and it paginatessubIssues/closedByPullRequestsReferencesviapageInfo. - Cross-repo/org: every node carries
repository.nameWithOwner, so the crawl follows sub-issues into other repos and orgs. Repos we can't read degrade gracefully — a per-aliasFORBIDDEN/NOT_FOUNDlogs a warning and yields empty children; the crawl keeps going. - PR linkage — closing only:
closedByPullRequestsReferences(includeClosedPrs: true). This is the "closes #N" / linked-branch relationship, not mere cross-references.includeClosedPrssurfaces closed-but-unmerged PRs alongside open/merged ones. - Budget:
--max-api-calls(default 400) caps GraphQL requests; hitting it flags the reporttruncatedinstead of failing. Depth is clamped to0..5. - Reads only
content.url+content.titlefrom the board — any extra board fields are ignored, so board schema changes can't break it.
Same token as the FTE action: a classic PAT with repo + read:org + project, a fine-grained PAT,
or a GitHub App token. The action exports it as GH_TOKEN/GITHUB_TOKEN for gh.
The demo board is flat (50 objectives, no sub-issues, no PRs). seed/seed_subissue_tree.py
builds real data in a repo you own (cross-org depth is simulated there — you can't create issues
in other orgs). Shape per objective (--depth 5 --branch-factor 2): 1 root + 5 spine + 5 branch
leaves; PRs land on the leaves, the deepest spine node, and the root, with mixed OPEN/CLOSED/MERGED.
gh repo create you/veda-subissue-seed --private --add-readme # a repo you own
python seed/seed_subissue_tree.py --repo you/veda-subissue-seed --dry-run # preview the plan
python seed/seed_subissue_tree.py --repo you/veda-subissue-seed # create itSeed order (each step persisted to seed/subissue_seed.json, resumable with the SAME args):
- create issues —
gh issue create(parents first); labeledsubissue-seed. - backfill ids — one batched GraphQL pass for each issue's
id(node) +databaseId(REST). - link —
addSubIssueGraphQL mutation (parentissueId, childsubIssueId); REST fallbackPOST /repos/{repo}/issues/{n}/sub_issueswithsub_issue_id= child databaseId (not node id). - board — add root objectives to the project (
gh project item-add). - PRs — per selected issue: create a branch ref from default HEAD → PUT a scratch file
(
seed-scratch/close-<n>.md, base64) →gh pr create --body "Closes #<n>"; MERGED/CLOSED targets are then merged/closed.
Cleanup: python seed/cleanup_subissue_tree.py (dry-run) then --close or --delete. It removes
PRs → branches → board items → issues (children before parents), from the tracking file (or
--by-label subissue-seed).
export GH_TOKEN=<PAT repo+read:org+project>
python pr-finder/generate_pr_finder.py \
--project-url https://github.com/users/kyle-lesinger/projects/2 \
--max-depth 5 --out-dir reports
open reports/pr_finder.htmlOffline (no network), against a canned tree fixture:
python pr-finder/generate_pr_finder.py --tree-json seed/sample_tree.json --out-dir reports- Sub-issue GraphQL works without any
GraphQL-Featuresheader (verified on the live board); theaddSubIssuemutation may not — the seeder falls back to REST automatically. - REST sub-issue link needs the child's numeric
databaseId, not the GraphQL node id. - Rate limits (GraphQL 5,000/hr): batching + BFS keep requests low; the crawler backs off on
RATE_LIMITED/secondary limits and, if the budget is exhausted, flagstruncated. - Cycles/diamonds: a repeat sub-issue ref is not re-descended (guarded by a per-tree seen set).
- Merging seed PRs can occasionally fail (branch protection, non-fast-forward) — the seeder warns and leaves such a PR open rather than aborting.