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
2 changes: 2 additions & 0 deletions .claude/agent-memory/product-owner/MEMORY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- [Board field IDs](project_board_field_ids.md) — GraphQL node/field/option IDs for Status/Priority/Awaiting; Priority has NO P0 option, only P1-P4
- [Digest column ≠ board presence](project_backlog_board_state_2026_08_16.md) — the digest derives a column whether or not a card exists; diff `item-list` against `issue list` before trusting sync
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
name: project_backlog_board_state_2026_08_16
description: The digest deriving a column for an issue does not mean the issue is on the board — learned when a 2026-08-16 pass found 1 card against 36 open issues
metadata:
type: project
---

Before this pass, the board (project 1) had exactly **one** card (#611,
P1/Backlog) despite 36 open issues existing. `backlog-digest.sh` derives a
`column` for every open issue regardless of whether it's on the board, which
made this easy to miss without diffing against `gh project item-list`
directly — the digest's presence doesn't imply board presence.

**Why:** board-init/bootstrap work (PRs around #609-611 per recent commits)
created the project and field schema but never did a bulk backlog import —
only the issue that happened to be filed around that time landed on it.

**How to apply:** before trusting "the board is roughly in sync," diff
`gh project item-list` counts against `gh issue list --state open` counts.
Don't assume prior passes kept the board populated.

See [[project_board_field_ids]] for the GraphQL mechanics used.
32 changes: 32 additions & 0 deletions .claude/agent-memory/product-owner/project_board_field_ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
name: project_board_field_ids
description: GraphQL node/field/option IDs for the backlog board (project 1) — Status, Priority, Awaiting — and the Priority field's real options
metadata:
type: project
---

Board GraphQL IDs, confirmed working 2026-08-16 via a full-board write pass
(all 36 open issues added, Status/Priority/Awaiting set on every card):

- Project id: `PVT_kwHOACEigM4Bgiwa`
- Status field: `PVTSSF_lAHOACEigM4Bgiwazhfh7Mg` — options: Backlog `f75ad846`,
Analysis `012dae50`, Ready for Dev `456880aa`, In Progress `47fc9ee4`,
In Review `58ad8ead`, Done `98236657`
- Priority field: `PVTSSF_lAHOACEigM4Bgiwazhfh7NQ` — options are **P1 `131c5c2f`,
P2 `107b9947`, P3 `6d4b1494`, P4 `4d153125`. There is no P0 option** — the
"P0 da61340b" that older skill and task text referenced does not exist on
the live field. Treat P1 as the top tier.
- Awaiting field: `PVTSSF_lAHOACEigM4Bgiwazhfh7Nw` — options: reporter
`71ef723a`, discussion `82098dd9`, upstream `16ca2f41`, analysis `c7538747`

Mutation shape that works: `addProjectV2ItemById(input: {projectId, contentId})`
to add a card (contentId = issue node id from `gh issue list --json id`), then
`updateProjectV2ItemFieldValue(input: {projectId, itemId, fieldId, value:
{singleSelectOptionId}})` per field. Run via
`scripts/gh-agent.sh --as po api graphql -f query='...' -f name=value ...`
from inside the repo checkout (it resolves `.env` via `git rev-parse
--git-common-dir`, so it fails silently with "BESS_PO_TOKEN not set" if run
from a non-repo cwd like a scratch tmpdir).

See [[project_backlog_board_state_2026_08_16]] for what was actually on the
board before/after this pass.
104 changes: 104 additions & 0 deletions backend/tests/test_backlog_board_init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Tests for scripts/backlog-board-init.sh — the one-shot board bootstrap.

The script's every effect is a `gh project` call, so `gh` is a shim on PATH
that records its arguments and replays canned output — the same technique
test_backlog_digest.py uses. That is enough to pin the three things worth
pinning: the Priority tiers it creates, that an existing board is left alone,
and that a failed lookup does not become a second board.

Priority is the reason this file exists. It was created as `P0,P1,P2` — a
tier the live board does not have, missing two the digest ranks on — and a
plain string literal can drift back with nothing to catch it.
"""

import os
import stat
import subprocess
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "backlog-board-init.sh"


def _make_gh(bin_dir: Path, log: Path, *, list_output: str, list_rc: int = 0) -> None:
"""A `gh` that logs every invocation and answers the calls the script makes.

`auth status` must mention the project scope or the script stops early;
`project list` is the idempotency lookup; `project create` returns a
number; `project field-create` is what we assert on.
"""
body = f"""
echo "$@" >> "{log}"
case "$1 $2" in
"auth status") echo "✓ Logged in, scopes: repo, project" ;;
"project list") printf '%s' '{list_output}'; exit {list_rc} ;;
"project create") echo 7 ;;
"project field-create") ;;
*) ;;
esac
exit 0
"""
p = bin_dir / "gh"
p.write_text("#!/bin/sh\n" + body)
p.chmod(p.stat().st_mode | stat.S_IEXEC)


def _run(
tmp_path: Path, **gh_kwargs: object
) -> tuple[subprocess.CompletedProcess, str]:
bin_dir = tmp_path / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
log = tmp_path / "gh.log"
log.write_text("")
_make_gh(bin_dir, log, **gh_kwargs) # type: ignore[arg-type]

env = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}")
proc = subprocess.run(
["bash", str(SCRIPT)],
cwd=tmp_path,
capture_output=True,
text=True,
env=env,
)
return proc, log.read_text()


def test_priority_field_is_created_with_the_tiers_the_board_has(
tmp_path: Path,
) -> None:
"""P1-P4, and no P0 — matching the live board, the skill and the digest."""
proc, calls = _run(tmp_path, list_output="")

assert proc.returncode == 0, proc.stderr
priority = [
line
for line in calls.splitlines()
if "field-create" in line and "Priority" in line
]
assert len(priority) == 1, calls
assert "P1,P2,P3,P4" in priority[0]
assert "P0" not in priority[0]


def test_an_existing_board_is_reported_and_left_alone(tmp_path: Path) -> None:
"""Idempotence: report the number, create nothing."""
proc, calls = _run(tmp_path, list_output="3")

assert proc.returncode == 0, proc.stderr
assert "PROJECT_NUMBER 3" in proc.stdout
assert "project create" not in calls
assert "field-create" not in calls


def test_a_failed_lookup_does_not_create_a_second_board(tmp_path: Path) -> None:
"""A lookup that errors must not read as "no board exists".

This is the whole reason the lookup is not `|| true`: an empty result and
a failed call are indistinguishable afterwards, and the script creates a
board when it sees the former.
"""
proc, calls = _run(tmp_path, list_output="", list_rc=1)

assert proc.returncode != 0
assert "project create" not in calls
assert "could not list projects" in proc.stderr
99 changes: 99 additions & 0 deletions scripts/backlog-board-init.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
#
# Create the Product Owner's kanban board, once. Idempotent: if a project with
# this title already exists, print its number and change nothing.
#
# Run this as the MAINTAINER, not as the PO identity. A GitHub user cannot
# create a Project inside another user's account, and the board deliberately
# lives under the maintainer's account — it is their backlog and where they
# will look for it. `scripts/gh-agent.sh --as po project list --owner
# johanzander` returns "unknown owner type" for exactly this reason.
#
# The PO then gets write access to the board as a project collaborator, which
# is a separate grant from repo collaboration — repo write does NOT imply
# project write. That step is manual (see the end of this script's output):
# the Projects v2 API exposes no collaborator-invite mutation.
#
# Usage:
# scripts/backlog-board-init.sh # create or report the board
#
# Output (stdout, last line):
# PROJECT_NUMBER <n>
set -euo pipefail

owner="${PROJECT_OWNER:-johanzander}"
title="BESS Manager Backlog"

# `gh project` needs the `project` scope. Fail with the fix rather than a raw
# GraphQL error — this is the single most common setup failure here.
if ! gh auth status 2>&1 | grep -q "project"; then
echo "backlog-board-init.sh: your gh token lacks the 'project' scope." >&2
echo " Fix: gh auth refresh -s project" >&2
exit 1
fi

# `|| true` here would be the difference between "no such board" and "the
# lookup failed", and this script creates a board when it sees the former. A
# rate limit or a transient GraphQL error would therefore produce a SECOND
# "BESS Manager Backlog" project, breaking the idempotence promised above. So
# a failed lookup stops the run instead of being read as an empty result.
if ! existing=$(gh project list --owner "$owner" --format json \
--jq ".projects[] | select(.title == \"$title\") | .number"); then
echo "backlog-board-init.sh: could not list projects for '$owner'." >&2
echo " Refusing to create a board without knowing whether one exists." >&2
exit 1
fi

if [ -n "$existing" ]; then
echo "Board already exists — nothing changed." >&2
echo "PROJECT_NUMBER $existing"
exit 0
fi

number=$(gh project create --owner "$owner" --title "$title" \
--format json --jq '.number')

echo "Created project #$number." >&2

# Custom fields. The built-in Status field carries the columns and is edited
# separately (see the closing instructions) — `gh` cannot rewrite the options
# of a built-in single-select field.
#
# Priority is P1-P4 with no P0: that is what the live board carries, what the
# backlog skill documents, and what the digest ranks on. A P0 here would
# create an option no consumer reads.
gh project field-create "$number" --owner "$owner" \
--name "Priority" --data-type SINGLE_SELECT \
--single-select-options "P1,P2,P3,P4" >/dev/null
echo " + Priority field (P1,P2,P3,P4)" >&2

gh project field-create "$number" --owner "$owner" \
--name "Source" --data-type SINGLE_SELECT \
--single-select-options "issue,TODO" >/dev/null
echo " + Source field (issue,TODO)" >&2

gh project field-create "$number" --owner "$owner" \
--name "Awaiting" --data-type SINGLE_SELECT \
--single-select-options "reporter,discussion,upstream,analysis" >/dev/null
echo " + Awaiting field (reporter,discussion,upstream,analysis)" >&2

cat >&2 <<REMAINING

Two steps remain and neither can be scripted — the Projects v2 API exposes no
mutation for either:

1. Columns. Open the board, edit the built-in Status field, and set its
options to exactly:
Backlog, Analysis, Ready for Dev, In Progress, In Review, Done
The digest derives these names; a mismatch silently strands cards.

2. PO access. Project -> ... -> Settings -> Manage access -> invite
bess-product-owner with write. Repo collaboration does NOT grant project
access, so without this every board write fails as the PO.

Then export PROJECT_NUMBER=$number (or add it to your shell profile) and run
scripts/backlog-digest.sh to confirm the board is readable.

REMAINING

echo "PROJECT_NUMBER $number"
Loading