diff --git a/cli/bash/commands/basectl/subcommands/gh_issue_readiness.sh b/cli/bash/commands/basectl/subcommands/gh_issue_readiness.sh index 0fa7e200..1ea84197 100644 --- a/cli/bash/commands/basectl/subcommands/gh_issue_readiness.sh +++ b/cli/bash/commands/basectl/subcommands/gh_issue_readiness.sh @@ -57,21 +57,64 @@ base_gh_lines_to_csv() { fi } -base_gh_jq_string_literal() { - local value="$1" +base_gh_issue_readiness_project_api_prefix() { + local project_owner="$1" owner_type - value="${value//\\/\\\\}" - value="${value//\"/\\\"}" - printf '"%s"\n' "$value" + owner_type="$(base_cli_gh_run api "users/$project_owner" --jq .type)" || return $? + case "$owner_type" in + Organization) + printf 'orgs/%s\n' "$project_owner" + ;; + User) + printf 'users/%s\n' "$project_owner" + ;; + *) + base_gh_error "Unsupported GitHub Project owner type '$owner_type'." + return 1 + ;; + esac } base_gh_issue_readiness_project_row() { local issue="$1" repo="$2" project_owner="$3" project_number="$4" - local repo_literal query + local api_prefix fields_json field_ids_csv items_json + + api_prefix="$(base_gh_issue_readiness_project_api_prefix "$project_owner")" || return $? + fields_json="$(base_cli_gh_run api --method GET \ + "$api_prefix/projectsV2/$project_number/fields" -f per_page=100)" || return $? + field_ids_csv="$(jq -r ' + [ .[] + | select(.name == "Status" or .name == "Priority" or .name == "Size" or .name == "Area" or .name == "Initiative") + | .id + ] | join(",") + ' <<<"$fields_json")" || return $? + [[ -n "$field_ids_csv" ]] || { + base_gh_error "GitHub Project is missing one or more required readiness fields." + return 1 + } - repo_literal="$(base_gh_jq_string_literal "$repo")" - query=".items[] | select((.content.number == $issue) and (.content.repository == $repo_literal)) | [.status // \"\", .priority // \"\", .size // \"\", .area // \"\", .initiative // \"\"] | join(\"\u001f\")" - base_cli_gh_run project item-list "$project_number" --owner "$project_owner" --format json --limit 1000 --jq "$query" + items_json="$(base_cli_gh_run api --method GET --paginate --slurp \ + "$api_prefix/projectsV2/$project_number/items" \ + -f per_page=100 -f fields="$field_ids_csv")" || return $? + jq -r --argjson issue "$issue" --arg repo "$repo" ' + def field_value($name): + [ (.fields // [])[] + | select(.name == $name) + | (.value.name.raw // .value.raw // .value // "") + ][0] + | if type == "string" then . else "" end; + + [ .[][] + | select((.content.number == $issue) and (.content.repository.full_name == $repo)) + ][0] + | if . == null then + empty + else + [field_value("Status"), field_value("Priority"), field_value("Size"), + field_value("Area"), field_value("Initiative")] + | join("\u001f") + end + ' <<<"$items_json" } base_gh_issue_readiness_format_error() { @@ -301,7 +344,7 @@ base_gh_issue_readiness_collect_project_findings() { "$_BASE_GH_ISSUE_READINESS_PROJECT_OWNER" "$_BASE_GH_ISSUE_READINESS_PROJECT_NUMBER")" status=$? ((status == 0)) || { - base_gh_issue_readiness_upstream_error "$_BASE_GH_ISSUE_READINESS_OUTPUT_FORMAT" project_item_list "$status" + base_gh_issue_readiness_upstream_error "$_BASE_GH_ISSUE_READINESS_OUTPUT_FORMAT" project_item_lookup "$status" return $? } if [[ -z "$_BASE_GH_ISSUE_READINESS_PROJECT_ROW" ]]; then diff --git a/cli/bash/commands/basectl/tests/gh.bats b/cli/bash/commands/basectl/tests/gh.bats index 38a76854..f3a049fb 100644 --- a/cli/bash/commands/basectl/tests/gh.bats +++ b/cli/bash/commands/basectl/tests/gh.bats @@ -186,18 +186,33 @@ if [[ "$1" == "issue" && "$2" == "view" ]]; then exit 0 fi fi -if [[ "$1" == "project" && "$2" == "item-list" ]]; then - case "${BASE_GH_TEST_PROJECT_MODE:-complete}" in - complete) - printf 'Ready\037P2\037M\037CLI\037Agentic Coding Platform\n' - ;; - missing) - printf 'Ready\037P2\037\037CLI\037\n' - ;; - none) - ;; - esac - exit 0 +if [[ "$1" == "api" ]]; then + if [[ "${BASE_GH_TEST_FAIL_PROJECT:-0}" == "1" && "$*" == *"projectsV2"* ]]; then + printf 'Project API unavailable\n' >&2 + exit 1 + fi + if [[ "$*" == *"users/basefoundry"* ]]; then + printf 'Organization\n' + exit 0 + fi + if [[ "$*" == *"projectsV2/10/fields"* ]]; then + printf '%s\n' '[{"id":"status-id","name":"Status"},{"id":"priority-id","name":"Priority"},{"id":"size-id","name":"Size"},{"id":"area-id","name":"Area"},{"id":"initiative-id","name":"Initiative"}]' + exit 0 + fi + if [[ "$*" == *"projectsV2/10/items"* ]]; then + case "${BASE_GH_TEST_PROJECT_MODE:-complete}" in + complete) + printf '%s\n' '[[{"content":{"number":123,"repository":{"full_name":"basefoundry/base"}},"fields":[{"name":"Status","value":{"name":{"raw":"Ready"}}},{"name":"Priority","value":{"name":{"raw":"P2"}}},{"name":"Size","value":{"name":{"raw":"M"}}},{"name":"Area","value":{"name":{"raw":"CLI"}}},{"name":"Initiative","value":{"name":{"raw":"Agentic Coding Platform"}}}]}]]' + ;; + missing) + printf '%s\n' '[[{"content":{"number":123,"repository":{"full_name":"basefoundry/base"}},"fields":[{"name":"Status","value":{"name":{"raw":"Ready"}}},{"name":"Priority","value":{"name":{"raw":"P2"}}},{"name":"Size","value":null},{"name":"Area","value":{"name":{"raw":"CLI"}}},{"name":"Initiative","value":null}]}]]' + ;; + none) + printf '%s\n' '[[]]' + ;; + esac + exit 0 + fi fi printf 'unexpected gh args: %s\n' "$*" >&2 exit 99 @@ -605,6 +620,17 @@ run_gh_subcommand() { [[ "$output" == *"Assignees: codeforester"* ]] } +@test "basectl gh issue readiness paginates Project items through the REST API" { + write_issue_readiness_gh_mock + write_complete_issue_readiness_body + + run_gh_subcommand issue readiness 123 --repo basefoundry/base --project-owner basefoundry --project-number 10 + + [ "$status" -eq 0 ] + [[ "$(cat "$TEST_STATE_DIR/gh-args")" == *"api --method GET --paginate --slurp orgs/basefoundry/projectsV2/10/items"* ]] + [[ "$(cat "$TEST_STATE_DIR/gh-args")" != *"project item-list"* ]] +} + @test "basectl gh issue readiness reports partial when Project validation is omitted" { write_issue_readiness_gh_mock write_complete_issue_readiness_body @@ -656,6 +682,29 @@ run_gh_subcommand() { [[ "$output" == *"Fix hint: set missing Project fields before assigning implementation work."* ]] } +@test "basectl gh issue readiness distinguishes a missing Project item" { + write_issue_readiness_gh_mock + write_complete_issue_readiness_body + + BASE_GH_TEST_PROJECT_MODE=none \ + run_gh_subcommand issue readiness 123 --repo basefoundry/base --project-owner basefoundry --project-number 10 + + [ "$status" -eq 1 ] + [[ "$output" == *"Project fields: missing Project item, Status, Priority, Size, Area, Initiative"* ]] +} + +@test "basectl gh issue readiness reports Project API failures" { + write_issue_readiness_gh_mock + write_complete_issue_readiness_body + + BASE_GH_TEST_FAIL_PROJECT=1 \ + run_gh_subcommand issue readiness 123 --repo basefoundry/base --project-owner basefoundry --project-number 10 + + [ "$status" -eq 1 ] + [[ "$output" == *"Project API unavailable"* ]] + [[ "$output" == *"GitHub command failed: gh api --method GET orgs/basefoundry/projectsV2/10/fields -f per_page=100"* ]] +} + @test "basectl gh issue readiness reports GitHub API failures" { write_issue_readiness_gh_mock write_complete_issue_readiness_body diff --git a/cli/bash/commands/basectl/tests/inspection-json.bats b/cli/bash/commands/basectl/tests/inspection-json.bats index c5346066..a1f494b4 100644 --- a/cli/bash/commands/basectl/tests/inspection-json.bats +++ b/cli/bash/commands/basectl/tests/inspection-json.bats @@ -41,13 +41,23 @@ if [[ "$1" == "issue" && "$2" == "view" ]]; then exit 0 fi fi -if [[ "$1" == "project" && "$2" == "item-list" ]]; then - if [[ "${BASE_GH_TEST_PROJECT_MISSING:-0}" == "1" ]]; then - printf 'Ready\037P2\037\037CLI\037\n' - else - printf 'Ready\037P2\037M\037CLI\037Contract Hardening\n' +if [[ "$1" == "api" ]]; then + if [[ "$*" == *"users/basefoundry"* ]]; then + printf 'Organization\n' + exit 0 + fi + if [[ "$*" == *projectsV2/*/fields* ]]; then + printf '%s\n' '[{"id":"status-id","name":"Status"},{"id":"priority-id","name":"Priority"},{"id":"size-id","name":"Size"},{"id":"area-id","name":"Area"},{"id":"initiative-id","name":"Initiative"}]' + exit 0 + fi + if [[ "$*" == *projectsV2/*/items* ]]; then + if [[ "${BASE_GH_TEST_PROJECT_MISSING:-0}" == "1" ]]; then + printf '%s\n' '[[{"content":{"number":123,"repository":{"full_name":"basefoundry/base"}},"fields":[{"name":"Status","value":{"name":{"raw":"Ready"}}},{"name":"Priority","value":{"name":{"raw":"P2"}}},{"name":"Size","value":null},{"name":"Area","value":{"name":{"raw":"CLI"}}},{"name":"Initiative","value":null}]}]]' + else + printf '%s\n' '[[{"content":{"number":123,"repository":{"full_name":"basefoundry/base"}},"fields":[{"name":"Status","value":{"name":{"raw":"Ready"}}},{"name":"Priority","value":{"name":{"raw":"P2"}}},{"name":"Size","value":{"name":{"raw":"M"}}},{"name":"Area","value":{"name":{"raw":"CLI"}}},{"name":"Initiative","value":{"name":{"raw":"Contract Hardening"}}}]}]]' + fi + exit 0 fi - exit 0 fi printf 'unexpected gh args: %s\n' "$*" >&2 exit 99