5.6 KiB
name, description
| name | description |
|---|---|
| project-fields | Reconcile the "Interview Prep" GitHub Project (user project |
Interview Prep Project fields
The user-level Project "Interview Prep" (prdlk project #2, id PVT_kwHOADoNjs4BhV1a)
is a board over the issue catalog, never a source of truth. Every field is a projection
of something owned elsewhere.
Field ownership — the rule that matters
| Field | Owner | Written by |
|---|---|---|
Set (Core/Optional/Deferred) |
issue label set:* |
sync-project-fields, last step of close-solved.yml |
Difficulty (Easy/Medium/Hard) |
issue label diff:* |
sync-project-fields, last step of close-solved.yml |
Target Date |
D1 next_review |
Worker mirror, per log (apps/api/src/mirror.ts) |
Status, Labels, Milestone, Parent issue, … |
GitHub built-ins | GitHub |
SRS stage and first-attempt result are deliberately NOT Project fields. They live only
in D1; the charts, digest and /api/stats read them from there. Duplicating them into
Project single-selects bought nothing but drift and two extra writes per log. Do not re-add
them to the project, and do not re-add setStage/setFirstAttempt to mirror.ts.
Reconciling Set / Difficulty
apps/cli/sync-project-fields.ts recomputes every desired value from the issue labels and
writes only what differs — same reconcile-don't-react shape as close-solved.
In CI it runs itself. It is the final step of .github/workflows/close-solved.yml, so
any push under work/ reconciles the board along with the issues and D1. The step is
continue-on-error: true and skips cleanly when PROJECT_PAT is unset — D1 and the issues
are the truth, so a Project failure must never fail a run that already closed issues and
logged the solve. It honours the workflow's dry_run input via DRY_RUN=true.
Run it by hand when issues changed without a work/ push (e.g. you just filed a problem
issue, or relabelled one):
# needs a token with the `project` scope — neither `gh auth token` nor GITHUB_TOKEN has it.
export GH_TOKEN=$(grep '^GH_PAT=' apps/api/.dev.vars | cut -d= -f2-)
bun run sync-project-fields -- --dry-run # or DRY_RUN=1|true; prints the table, writes nothing
bun run sync-project-fields # apply
bun run sync-project-fields # re-run: must report 0 writes
Invariants the script holds — preserve them in any edit:
- Only
problem-labelled items are touched. Topic, cadence and review issues have no set/difficulty; they are skipped, never written and never cleared. - Idempotent. A second run reports
0 field value(s). That re-run is the verification. - Labels are truth. Never "fix" a field by hand in the UI — fix the label, then reconcile. A hand-edited field is silently reverted on the next run.
- Fields and options resolve by NAME, so re-creating a field in the UI doesn't strand a dead id. A missing field or option is a hard error, not a silent skip.
- A problem missing a
set:/diff:label warns and is left untouched (it means the issue is mislabelled — fix it with thecampaign-issuesskill).
Auth gotcha
Project v2 needs the project scope. The local gh login has only
gist, read:org, repo, workflow, so any gh project/GraphQL project call fails with
the default token. Use the Worker's GH_PAT from apps/api/.dev.vars via GH_TOKEN
(never echo it). Symptom of the wrong token: Resource not accessible by personal access token or an empty projectsV2.nodes.
Inspecting the project
export GH_TOKEN=$(grep '^GH_PAT=' apps/api/.dev.vars | cut -d= -f2-)
# fields + single-select options
gh api graphql -f owner=prdlk -f title="Interview Prep" -f query='
query($owner: String!, $title: String!) {
user(login: $owner) { projectsV2(first: 10, query: $title) { nodes { id title number
items(first: 1) { totalCount }
fields(first: 30) { nodes {
... on ProjectV2FieldCommon { id name dataType }
... on ProjectV2SingleSelectField { options { id name } } } } } } }
}' --jq '.data.user.projectsV2.nodes[] | select(.title=="Interview Prep")
| .fields.nodes[] | "\(.name)\t\(.dataType)\t\((.options // []) | map(.name) | join(","))"'
Writes use updateProjectV2ItemFieldValue with
value: { singleSelectOptionId: … } (dates use { date: "YYYY-MM-DD" }); clearing uses
clearProjectV2ItemFieldValue. An item id is not an issue number — resolve it from the
project's items connection (or the issue's projectItems), as mirror.ts does.
Adding a new projected field
- Create the field + options in the Project UI.
- Add it to the
FIELDStable inapps/cli/sync-project-fields.ts— one entry mapping label → option name. Nothing else needs to change; resolution, diffing, dry-run and reporting are generic over that table. - Dry-run, apply, re-run to prove idempotency.
If the new field's truth lives in D1 rather than a label, it belongs in the Worker
mirror instead (apps/api/src/mirror.ts) — but read the ownership rule above first and
default to not mirroring.
Known trap
resolve() in mirror.ts throws if a field it asks for is absent, before caching
info — so one missing field breaks every mirrored write, including Target Date,
and only as a console.warn. If Target Date silently stops updating, check that every
field resolve() looks up still exists in the project.