diff --git a/docs/content/docs/manual/board-tooling.md b/docs/content/docs/manual/board-tooling.md index 3167eb52..438fff83 100644 --- a/docs/content/docs/manual/board-tooling.md +++ b/docs/content/docs/manual/board-tooling.md @@ -72,8 +72,7 @@ bash scripts/board-snapshot.sh configure PVT_kwHNjWrOAZaiCg bash scripts/board-snapshot.sh metadata schema ``` -The schema is Status (`Todo`, `In progress`, `Blocked`, `In review`, `Ready to -push`, `Done`), Priority (`P1`, `P2`, `P3`) and Session (configured project lanes). +The schema is Status (`Todo`, `In progress`, `Blocked`, `In review`, `Done`), Priority (`P1`, `P2`, `P3`) and Session (configured project lanes). Views show Title, Status, Priority, built-in Assignees, Session and Milestone. Assignees identify the accountable human; configuration does not change issue assignees. Assign a human only with the appropriate authorization. @@ -91,9 +90,10 @@ population alone does not establish that an issue has reached review. | Active work | In progress | | Review queue | In review | | Blocked work | Blocked | -| Release and milestones | Ready to push | +| Release and milestones | (all open issues) | -Each filter restricts results to open Issues in the configured repository. +Each filter restricts results to open Issues in the configured repository; +**Release and milestones** adds no Status clause. The [documented Projects API](https://docs.github.com/en/graphql/reference/projects) supports view layout, visible fields and filters. In the UI, open **Dispatch queue**, choose **Sort**, set **Priority** ascending (`P1`, `P2`, `P3`) and save @@ -117,7 +117,8 @@ an enabled flag alone is insufficient evidence. Verify a real closure only when the human has authorized closing that issue. `move` still refuses `Done`, which follows from the human closing or merging -the PR. The board's own **Ready to push** option is unused. Configuration never promotes -cards. Dispatch enforces worker limits from the project profile. Board column +the PR. Configuration never promotes +cards. A board that still has a legacy `Ready to push` option keeps it (configuration +does not remove it); `move` never sets it. Dispatch enforces worker limits from the project profile. Board column limits are visual aids and do not enforce concurrency; verify profile limits and saved board settings separately. diff --git a/scripts/board-snapshot.sh b/scripts/board-snapshot.sh index ca8481be..c992e1ff 100755 --- a/scripts/board-snapshot.sh +++ b/scripts/board-snapshot.sh @@ -533,10 +533,11 @@ if [ "$mode" = configure ] || [ "$mode" = configure-fields ]; then schema_fetch || die "could not read complete schema metadata; no configuration changed" fi desired=$(jq -cn --arg prefix "$lane_prefix" --arg roles "$roles" --argjson fields "$schema_fields" '{ - Status:["Todo","In progress","Blocked","In review","Ready to push","Done"], + Status:["Todo","In progress","Blocked","In review","Done"], Priority:["P1","P2","P3"], Session:($roles | split(",") | map($prefix + "/" + .)) - } | if $prefix == "wh" or any($fields[]; .name == "Session" and any(.options[]?; .name == "Werner")) then .Session += ["Werner"] else . end') + } | if any($fields[]; .name == "Status" and any(.options[]?; .name == "Ready to push")) then .Status = ["Todo","In progress","Blocked","In review","Ready to push","Done"] else . end + | if $prefix == "wh" or any($fields[]; .name == "Session" and any(.options[]?; .name == "Werner")) then .Session += ["Werner"] else . end') normalized_fields=$(printf '%s' "$schema_fields" | jq -c --arg prefix "$lane_prefix" 'map(if .name == "Session" then .options |= map(if (.name | startswith("wh/")) then .name = ($prefix + "/" + (.name | ltrimstr("wh/"))) else . end) else . end)') @@ -581,9 +582,10 @@ if [ "$mode" = configure ] || [ "$mode" = configure-fields ]; then "Active work") status="In progress" ;; "Review queue") status="In review" ;; "Blocked work") status=Blocked ;; - "Release and milestones") status="Ready to push" ;; + "Release and milestones") status="" ;; esac - filter="repo:$repository is:issue -is:closed status:\"$status\"" + filter="repo:$repository is:issue -is:closed" + [ -z "$status" ] || filter="$filter status:\"$status\"" current=$(printf '%s' "$schema_views" | jq -c --arg name "$name" '[.[] | select(.name == $name)][0] // {}') vid=$(printf '%s' "$current" | jq -r '.id // empty') if [ -z "$vid" ]; then @@ -599,9 +601,9 @@ if [ "$mode" = configure ] || [ "$mode" = configure-fields ]; then schema_fetch || die "configuration may be partial: final metadata readback failed" printf '%s' "$schema_fields" | jq -e --argjson desired "$desired" '. as $fields | all(["Status","Priority","Session"][]; . as $name | [$fields[] | select(.name == $name)] | length == 1 and (.[0].options | map(.name)) == $desired[$name])' >/dev/null || die "configuration readback does not match required options" printf '%s' "$schema_views" | jq -e --arg repo "$repository" --argjson visible "$visible" '. as $views | - {"Dispatch queue":"Todo","Active work":"In progress","Review queue":"In review","Blocked work":"Blocked","Release and milestones":"Ready to push"} | to_entries | + {"Dispatch queue":"Todo","Active work":"In progress","Review queue":"In review","Blocked work":"Blocked","Release and milestones":""} | to_entries | all(.[]; . as $expected | [$views[] | select(.name == $expected.key)] | length == 1 and - .[0].filter == ("repo:" + $repo + " is:issue -is:closed status:\"" + $expected.value + "\"") and .[0].layout == "TABLE_LAYOUT" and + .[0].filter == ("repo:" + $repo + " is:issue -is:closed" + (if $expected.value == "" then "" else " status:\"" + $expected.value + "\"" end)) and .[0].layout == "TABLE_LAYOUT" and ([.[0].configuration.visibleFields.nodes[].id] == $visible))' >/dev/null || die "configuration readback does not match required views" schema_print exit 0 diff --git a/scripts/board_snapshot_test.go b/scripts/board_snapshot_test.go index df22633e..d187687b 100644 --- a/scripts/board_snapshot_test.go +++ b/scripts/board_snapshot_test.go @@ -366,7 +366,7 @@ func TestBoardSnapshotConfigureRepeatPreservesOptions(t *testing.T) { } for _, field := range metadata.Fields { if field.Name == "Status" { - if len(field.Options) != 6 || field.Options[0].ID != "O_todo" || field.Options[5].ID != "O_done" { + if len(field.Options) != 5 || field.Options[0].ID != "O_todo" || field.Options[4].ID != "O_done" { t.Fatalf("status identities changed: %+v", field.Options) } } @@ -462,7 +462,7 @@ func sessionSchemaFixture(t *testing.T, options string) board { current := field.(map[string]any) if current["name"] == "Status" { statuses := []map[string]any{} - for index, name := range []string{"Todo", "In progress", "Blocked", "In review", "Ready to push", "Done"} { + for index, name := range []string{"Todo", "In progress", "Blocked", "In review", "Done"} { statuses = append(statuses, map[string]any{"id": "status-" + strconv.Itoa(index), "name": name, "color": "GRAY", "description": ""}) } current["options"] = statuses @@ -580,7 +580,7 @@ case "$*" in *updateProjectV2ItemFieldValue*) for a in "$@"; do case "$a" in i=PVTI_*) item=${a#i=PVTI_} ;; o=*) opt=${a#o=} ;; f=*) fld=${a#f=} ;; esac; done if [ -z "$FAKE_NOAPPLY" ] && [ "$fld" = F_status ]; then - case "$opt" in O_todo) st=Todo ;; O_ip) st="In progress" ;; O_bl) st=Blocked ;; O_ir) st="In review" ;; O_rp) st="Ready to push" ;; esac + case "$opt" in O_todo) st=Todo ;; O_ip) st="In progress" ;; O_bl) st=Blocked ;; O_ir) st="In review" ;; esac printf '%s' "$st" > "` + b.bin + `/cur.$item" fi echo '{"data":{}}'; exit 0 ;; @@ -590,7 +590,7 @@ case "$*" in status=null if [ -f "` + b.bin + `/cur.$number" ]; then status="{\"name\":\"$(cat "` + b.bin + `/cur.$number")\"}"; fi echo '{"data":{"repository":{"issue":{"projectItems":{"nodes":[{"id":"PVTI_other","project":{"id":"PVT_other"}},{"id":"PVTI_'$number'","project":{"id":"PVT_kwHNjWrOAZVCuA"},"status":'"$status"'}]}}}}}'; exit 0 ;; -*"fields(first"*) echo '{"data":{"node":{"id":"PVT_kwHNjWrOAZVCuA","fields":{"pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[{},{"id":"F_status","name":"Status","options":[{"id":"O_todo","name":"Todo"},{"id":"O_ip","name":"In progress"},{"id":"O_bl","name":"Blocked"},{"id":"O_ir","name":"In review"},{"id":"O_rp","name":"Ready to push"}]},{"id":"F_sess","name":"Session","options":[{"id":"O_s1","name":"wh/review"},{"id":"O_s2","name":"Werner"}]},{"id":"F_prio","name":"Priority","options":[{"id":"O_p1","name":"P1"},{"id":"O_p3","name":"P3"}]}]}}}}'; exit 0 ;; +*"fields(first"*) echo '{"data":{"node":{"id":"PVT_kwHNjWrOAZVCuA","fields":{"pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[{},{"id":"F_status","name":"Status","options":[{"id":"O_todo","name":"Todo"},{"id":"O_ip","name":"In progress"},{"id":"O_bl","name":"Blocked"},{"id":"O_ir","name":"In review"}]},{"id":"F_sess","name":"Session","options":[{"id":"O_s1","name":"wh/review"},{"id":"O_s2","name":"Werner"}]},{"id":"F_prio","name":"Priority","options":[{"id":"O_p1","name":"P1"},{"id":"O_p3","name":"P3"}]}]}}}}'; exit 0 ;; *"items(first"*) cur=first for a in "$@"; do case "$a" in after=*) cur=${a#after=} ;; esac; done @@ -1509,3 +1509,41 @@ func TestBoardSnapshotSyncRefusesAStaleSnapshot(t *testing.T) { t.Fatalf("a dry run may read a stale snapshot: %v %q", err, so) } } + +func TestBoardSnapshotConfigureToleratesLegacyReadyToPush(t *testing.T) { + t.Parallel() + fixture := sessionSchemaFixture(t, `[{"id":"s-werner","name":"Werner","color":"GRAY","description":"Human maintainer"}]`) + path := filepath.Join(fixture.bin, "schema.json") + data, err := os.ReadFile(path) //nolint:gosec // a test fixture path + if err != nil { + t.Fatal(err) + } + var state map[string]any + if err := json.Unmarshal(data, &state); err != nil { + t.Fatal(err) + } + for _, field := range state["fields"].([]any) { + current := field.(map[string]any) + if current["name"] == "Status" { + statuses := []map[string]any{} + for index, name := range []string{"Todo", "In progress", "Blocked", "In review", "Ready to push", "Done"} { + statuses = append(statuses, map[string]any{"id": "status-" + strconv.Itoa(index), "name": name, "color": "GRAY", "description": ""}) + } + current["options"] = statuses + } + } + updated, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, updated, 0o600); err != nil { + t.Fatal(err) + } + stdout, stderr, err := fixture.runEnv(t, schemaEnv(fixture), "configure", "PVT_crewbook") + if err != nil { + t.Fatalf("configure on a board that still has the option: %v %s", err, stderr) + } + if !strings.Contains(stdout, `"Ready to push"`) { + t.Fatalf("legacy option was dropped: %s", stdout) + } +}