diff --git a/cli/bash/commands/basectl/README.md b/cli/bash/commands/basectl/README.md index 3c63e606..81e532ae 100644 --- a/cli/bash/commands/basectl/README.md +++ b/cli/bash/commands/basectl/README.md @@ -117,7 +117,11 @@ such command directories exist. Optional utility CLIs such as `caff` and `documentation`, `ci`, and `security`, and derives branch names from those categories. `issue start` and `pr create` require the branch prefix to match the issue's single standard category label. `issue start` selects that issue - repository from `--repo`/`-R`, then `GH_REPO`, then `origin`. Issue creation + repository from `--repo`/`-R`, then `GH_REPO`, then `origin`, moves the linked + Project item to `In Progress` when available, and verifies that transition + before printing the worktree handoff. A missing or unlinked Project is + reported as a warning; `--no-project` intentionally skips the transition. + Issue creation is unassigned by default unless `--assignee` is passed or `.github/base-project.yml` sets `project.issue_defaults.assignee`. `basectl gh auth status` reports the active credential state without printing diff --git a/cli/bash/commands/basectl/subcommands/gh.sh b/cli/bash/commands/basectl/subcommands/gh.sh index 0285828d..2b0c8a78 100644 --- a/cli/bash/commands/basectl/subcommands/gh.sh +++ b/cli/bash/commands/basectl/subcommands/gh.sh @@ -54,7 +54,7 @@ Usage: basectl gh issue list [gh options...] basectl gh issue create [--category ] --title [--body <body>] [--repo <owner/name>] [--assignee <login>|--no-assignee] [--size <T|S|M|L>] [--allow-cross-repo] [project options...] basectl gh issue readiness <number> [--repo <owner/name>] [--project-owner <login> --project-number <number>] [--format <text|json>] - basectl gh issue start <number> [--category <bug|enhancement|documentation|ci|security>] [--title <title>] [--repo <owner/name>|-R <owner/name>] + basectl gh issue start <number> [--category <bug|enhancement|documentation|ci|security>] [--title <title>] [--repo <owner/name>|-R <owner/name>] [--project <title>] [--project-owner <login>] [--no-project] [--allow-cross-repo] basectl gh pr create [--no-fixes] [gh options...] basectl gh pr status [gh options...] basectl gh pr checks [gh options...] @@ -98,7 +98,8 @@ Notes: - When the GitHub repo is known, issue create also adds the issue to the repo-named Project and applies defaults from .github/base-project.yml. - Issue start resolves its issue repository from --repo/-R, then GH_REPO, - then the origin remote. + then the origin remote. It updates the linked Project to In Progress when + available; use --no-project for an intentional Project-independent start. - PR creation auto-injects Fixes #<issue> when the branch follows the Base naming convention. If base_manifest.yaml declares github.pr, the generated body also follows that project policy. Pass --no-fixes to suppress body @@ -882,6 +883,14 @@ base_gh_project_issue_set_fields_command() { printf '\n' } +base_gh_log_warn_lines() { + local output="$1" line + + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -n "$line" ]] && base_std_log_warn "$line" + done <<<"$output" +} + base_gh_apply_project_issue_fields() { local project_title="$1" config_path="$2" project_size="$3" issue_number="$4" local output status line @@ -897,17 +906,49 @@ base_gh_apply_project_issue_fields() { return 0 fi - if [[ -n "$output" ]]; then - while IFS= read -r line || [[ -n "$line" ]]; do - [[ -n "$line" ]] && base_std_log_warn "$line" - done <<<"$output" - fi + [[ -n "$output" ]] && base_gh_log_warn_lines "$output" base_std_log_warn "Issue #$issue_number was created, but Project metadata is incomplete." base_std_log_warn "Project field update failed. Set fields manually or resume the metadata update with:" base_std_log_warn "$(base_gh_project_issue_set_fields_command "$@")" return "$status" } +base_gh_set_issue_in_progress() { + local issue_number="$1" github_repo="$2" project_title="${3:-}" project_owner="${4:-}" + local allow_cross_repo="${5:-0}" output status + local -a field_args + + [[ -n "$project_title" ]] || project_title="$(base_gh_default_project_title "$github_repo")" + [[ -n "$project_owner" ]] || project_owner="$(base_gh_project_owner_from_repo "$github_repo")" + field_args=( + "$issue_number" + --project "$project_title" + --owner "$project_owner" + --repo "$github_repo" + --status "In Progress" + ) + ((allow_cross_repo)) && field_args+=(--allow-cross-repo) + output="$(base_gh_project_issue_set_fields "${field_args[@]}" 2>&1)" + status=$? + if ((status == 0)); then + printf 'verified\n' + return 0 + fi + + if [[ "$output" == *" was not found for owner "* || "$output" == *" is not linked to repository "* || "$output" == *"GitHub owner "*" was not found."* ]]; then + [[ -n "$output" ]] && base_gh_log_warn_lines "$output" + base_std_log_warn "No linked Project was found for issue #$issue_number; continuing with the worktree handoff." + base_std_log_warn "Use --project/--project-owner for an alternate Project or --no-project to acknowledge a Project-independent start." + printf 'unavailable\n' + return 0 + fi + [[ -n "$output" ]] && base_gh_log_warn_lines "$output" + base_std_log_warn "Unable to move issue #$issue_number to Project Status=In Progress." + base_std_log_warn "Worktree handoff was not printed because the lifecycle transition was not verified." + base_std_log_warn "Fix: run '$(base_gh_project_issue_set_fields_command "${field_args[@]}")'." + return "$status" +} + base_gh_pr_policy_body() { local issue="$1" local github_repo="$2" diff --git a/cli/bash/commands/basectl/subcommands/gh_issue.sh b/cli/bash/commands/basectl/subcommands/gh_issue.sh index 2bebca39..e2cca55b 100644 --- a/cli/bash/commands/basectl/subcommands/gh_issue.sh +++ b/cli/bash/commands/basectl/subcommands/gh_issue.sh @@ -10,7 +10,7 @@ Usage: basectl gh issue list [gh options...] basectl gh issue create [--category <bug|enhancement|documentation|ci|security>] --title <title> [--body <body>] [--repo <owner/name>] [--assignee <login>|--no-assignee] [--size <T|S|M|L>] [--allow-cross-repo] [project options...] basectl gh issue readiness <number> [--repo <owner/name>] [--project-owner <login> --project-number <number>] [--format <text|json>] - basectl gh issue start <number> [--category <bug|enhancement|documentation|ci|security>] [--title <title>] [--repo <owner/name>|-R <owner/name>] + basectl gh issue start <number> [--category <bug|enhancement|documentation|ci|security>] [--title <title>] [--repo <owner/name>|-R <owner/name>] [--project <title>] [--project-owner <login>] [--no-project] [--allow-cross-repo] Purpose: List, create, validate, and start GitHub issues using Base's issue-first workflow. @@ -40,6 +40,10 @@ Issue start options: the explicit option, GH_REPO, then the origin remote. --category <category> Must match the issue's single category label. --title <title> Override the issue title used to generate the slug. + --project <title> Project to update. Defaults to the repository name. + --project-owner <login> Project owner. Defaults to the repository owner. + --no-project Skip the Project status transition. + --allow-cross-repo Allow an intentional update to a Project not linked to the issue repository. Default category: enhancement. Default assignee: none unless project.issue_defaults.assignee is set in .github/base-project.yml. @@ -53,14 +57,19 @@ Usage: basectl gh issue start <number> [options] Purpose: - Print the canonical issue-backed branch and worktree commands after verifying - the issue's standard category label. + Move the linked Project item to In Progress when available, then print the + canonical issue-backed branch and worktree commands after verifying the + issue's standard category label. Options: --repo, -R <owner/name> Repository containing the issue. Selection order is the explicit option, GH_REPO, then the origin remote. --category <category> Must match the issue's single category label. --title <title> Override the issue title used to generate the slug. + --project <title> Project to update. Defaults to the repository name. + --project-owner <login> Project owner. Defaults to the repository owner. + --no-project Skip the Project status transition. + --allow-cross-repo Allow an intentional update to a Project not linked to the issue repository. -h, --help Show this help text. EOF } @@ -343,6 +352,8 @@ base_gh_issue_create() { base_gh_issue_start() { local issue="${1:-}" category="" issue_category="" github_repo="" title="" slug branch default_branch worktree_path + local project_title="" project_owner="" project_status="" + local no_project=0 allow_cross_repo=0 local option_value local repo_args=() local parser_args=() @@ -351,6 +362,10 @@ base_gh_issue_start() { "category|value|--category" "title|value|--title" "repo|value|--repo" + "project|value|--project" + "project_owner|value|--project-owner" + "no_project|flag|--no-project" + "allow_cross_repo|flag|--allow-cross-repo" ) local -a positionals=() local -A parsed_options=() @@ -415,6 +430,30 @@ base_gh_issue_start() { shift continue ;; + --project|--project-owner) + if (($# < 2)) || [[ -z "$2" || "$2" == -* ]]; then + base_gh_usage_error base_gh_issue_usage "Option '$1' requires an argument." + return $? + fi + parser_args+=("${1#--}=$2") + shift 2 + continue + ;; + --project=*|--project-owner=*) + option_value="${1#*=}" + if [[ -z "$option_value" ]]; then + base_gh_usage_error base_gh_issue_usage "Option '${1%%=*}' requires an argument." + return $? + fi + parser_args+=("${1%%=*}=$option_value") + shift + continue + ;; + --no-project|--allow-cross-repo) + parser_args+=("$1") + shift + continue + ;; -h|--help) base_gh_issue_start_usage return 0 @@ -438,6 +477,10 @@ base_gh_issue_start() { category="${parsed_options[category]:-}" title="${parsed_options[title]:-}" github_repo="${parsed_options[repo]:-}" + project_title="${parsed_options[project]:-}" + project_owner="${parsed_options[project_owner]:-}" + no_project="${parsed_options[no_project]:-0}" + allow_cross_repo="${parsed_options[allow_cross_repo]:-0}" if [[ -n "$github_repo" ]]; then repo_args=(--repo "$github_repo") fi @@ -487,8 +530,21 @@ base_gh_issue_start() { default_branch="$(base_gh_default_branch)" worktree_path="$(base_gh_issue_worktree_path "$issue" "$slug")" || return 1 + if ((no_project)); then + project_status="skipped" + else + project_status="$(base_gh_set_issue_in_progress "$issue" "$github_repo" "$project_title" "$project_owner" "$allow_cross_repo")" || return $? + fi + printf '%s\n' "$branch" printf '\n' printf 'To create a worktree:\n' printf ' git worktree add -b %s %s origin/%s\n' "$branch" "$worktree_path" "$default_branch" + if [[ "$project_status" == "verified" ]]; then + printf 'Project status: In Progress (verified for issue #%s).\n' "$issue" + elif ((no_project)); then + printf 'Project status: skipped (--no-project).\n' + else + printf 'Project status: not updated (no linked Project found; handoff continued).\n' + fi } diff --git a/cli/bash/commands/basectl/tests/basectl_helpers.bash b/cli/bash/commands/basectl/tests/basectl_helpers.bash index 62b30f76..ad24fde0 100644 --- a/cli/bash/commands/basectl/tests/basectl_helpers.bash +++ b/cli/bash/commands/basectl/tests/basectl_helpers.bash @@ -11,6 +11,7 @@ setup() { TEST_STATE_DIR="$TEST_TMPDIR/state" TEST_BASH_BIN_DIR="$(dirname "$(command -v bash)")" mkdir -p "$TEST_HOME" "$TEST_MOCKBIN" "$TEST_STATE_DIR" + unset BASE_GH_PROJECT_WRAPPER export BASH_ENV="$BASE_REPO_ROOT/cli/bash/commands/basectl/tests/command_protocol_fixtures.bash" } diff --git a/cli/bash/commands/basectl/tests/gh.bats b/cli/bash/commands/basectl/tests/gh.bats index f3a049fb..35509b4e 100644 --- a/cli/bash/commands/basectl/tests/gh.bats +++ b/cli/bash/commands/basectl/tests/gh.bats @@ -127,6 +127,23 @@ printf 'unexpected gh args: %s\n' "$*" >&2 exit 99 EOF chmod +x "$TEST_MOCKBIN/gh" + cat > "$TEST_MOCKBIN/project-wrapper" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" > "${BASE_GH_TEST_STATE_DIR:?}/wrapper-args" +case "${BASE_GH_TEST_PROJECT_STATUS_MODE:-success}" in + failure) + printf 'simulated Project status update failure\n' >&2 + exit 42 + ;; + missing-project) + printf "ERROR: Project 'base' was not found for owner 'upstream'.\n" >&2 + exit 1 + ;; +esac +exit 0 +EOF + chmod +x "$TEST_MOCKBIN/project-wrapper" + export BASE_GH_PROJECT_WRAPPER="$TEST_MOCKBIN/project-wrapper" } create_branch_pr_repo() { @@ -581,6 +598,10 @@ run_gh_subcommand() { [[ "$output" == *"--repo, -R <owner/name>"* ]] [[ "$output" == *"--category <category>"* ]] [[ "$output" == *"--title <title>"* ]] + [[ "$output" == *"--project <title>"* ]] + [[ "$output" == *"--project-owner <login>"* ]] + [[ "$output" == *"--no-project"* ]] + [[ "$output" == *"--allow-cross-repo"* ]] [[ "$output" != *"basectl gh issue create"* ]] [[ "$output" != *"ERROR:"* ]] } @@ -1356,6 +1377,78 @@ EOF [ "$(printf '%s\n' "$output" | sed -n '3p')" = "To create a worktree:" ] [[ "$(printf '%s\n' "$output" | sed -n '4p')" == " git worktree add -b enhancement/117-"*"-add-basectl-gh-workflow-for-issues "*"/repo-worktrees/117-add-basectl-gh-workflow-for-issues origin/master" ]] [ "$(git -C "$repo" branch --show-current)" = "master" ] + [[ "$output" == *"Project status: In Progress (verified for issue #117)."* ]] +} + +@test "basectl gh issue start moves the Project item to In Progress before handoff" { + local repo + + repo="$TEST_TMPDIR/repo" + init_git_repo "$repo" + add_github_origin "$repo" + printf 'hello\n' > "$repo/README.md" + commit_all "$repo" "Initial commit" + write_branch_issue_gh_mock + + BASE_GH_TEST_CWD="$repo" run_gh_subcommand issue start 117 + + [ "$status" -eq 0 ] + [[ "$output" == *"Project status: In Progress (verified for issue #117)."* ]] + [ "$(cat "$TEST_STATE_DIR/wrapper-args")" = "--project base base_github_projects project issue set-fields 117 --project base --owner basefoundry --repo basefoundry/base --status In Progress" ] +} + +@test "basectl gh issue start blocks the worktree handoff when Project status fails" { + local repo + + repo="$TEST_TMPDIR/repo" + init_git_repo "$repo" + add_github_origin "$repo" + printf 'hello\n' > "$repo/README.md" + commit_all "$repo" "Initial commit" + write_branch_issue_gh_mock + + BASE_GH_TEST_CWD="$repo" BASE_GH_TEST_PROJECT_STATUS_MODE=failure run_gh_subcommand issue start 117 + + [ "$status" -eq 42 ] + [[ "$output" == *"Unable to move issue #117 to Project Status=In Progress."* ]] + [[ "$output" == *"Worktree handoff was not printed"* ]] + [[ "$output" != *"To create a worktree:"* ]] +} + +@test "basectl gh issue start preserves the handoff when no linked Project exists" { + local repo + + repo="$TEST_TMPDIR/repo" + init_git_repo "$repo" + add_github_origin "$repo" + printf 'hello\n' > "$repo/README.md" + commit_all "$repo" "Initial commit" + write_branch_issue_gh_mock + + BASE_GH_TEST_CWD="$repo" BASE_GH_TEST_PROJECT_STATUS_MODE=missing-project run_gh_subcommand issue start 117 --repo upstream/base + + [ "$status" -eq 0 ] + [[ "$output" == *"enhancement/117-"*"-add-basectl-gh-workflow-for-issues"* ]] + [[ "$output" == *"No linked Project was found"* ]] + [[ "$output" == *"To create a worktree:"* ]] + [[ "$output" == *"Project status: not updated (no linked Project found; handoff continued)."* ]] +} + +@test "basectl gh issue start supports an intentional Project-independent handoff" { + local repo + + repo="$TEST_TMPDIR/repo" + init_git_repo "$repo" + add_github_origin "$repo" + printf 'hello\n' > "$repo/README.md" + commit_all "$repo" "Initial commit" + write_branch_issue_gh_mock + + BASE_GH_TEST_CWD="$repo" run_gh_subcommand issue start 117 --no-project + + [ "$status" -eq 0 ] + [[ "$output" == *"Project status: skipped (--no-project)."* ]] + [ ! -e "$TEST_STATE_DIR/wrapper-args" ] } @test "basectl gh issue start parses the selected leaf through the shared helper" { diff --git a/cli/python/base_github_projects/engine.py b/cli/python/base_github_projects/engine.py index 0abe67be..132d6637 100644 --- a/cli/python/base_github_projects/engine.py +++ b/cli/python/base_github_projects/engine.py @@ -27,7 +27,7 @@ from .project_git import split_repo from .project_model import BASE_PROJECT_SCHEMA, DEFAULT_TEMPLATE_PROJECT from .project_model import ConfigureAction, FieldUpdate, Finding, OwnerInfo -from .project_model import ProjectArguments, ProjectField, ProjectInfo, ProjectSchema, ProjectView +from .project_model import ProjectArguments, ProjectField, ProjectInfo, ProjectItemState, ProjectSchema, ProjectView from .project_model import SelectFieldSpec, SelectOption from .project_operations import ProjectOperations from .project_schema import ISSUE_DEFAULT_OUTPUT_ORDER # pylint: disable=unused-import @@ -135,6 +135,7 @@ def project_operations() -> ProjectOperations: fetch_project_views=fetch_project_views, find_owner_and_project=find_owner_and_project, find_project_item_id=find_project_item_id, + find_project_item_state=find_project_item_state, issue_field_values_for_args=issue_field_values_for_args, link_project_to_repository=link_project_to_repository, missing_option_names=missing_option_names, @@ -443,6 +444,22 @@ def find_project_item_id(project_id: str, issue_id: str) -> str | None: return None +def find_project_item_state(project_id: str, issue_id: str) -> ProjectItemState | None: + payload = run_graphql(queries.FIND_PROJECT_ITEM_WITH_FIELDS, {"projectId": project_id}) + for item in payload["data"]["node"]["items"]["nodes"]: + if (item.get("content") or {}).get("id") != issue_id: + continue + values: dict[str, str] = {} + for raw_value in item.get("fieldValues", {}).get("nodes", []): + if raw_value.get("__typename") != "ProjectV2ItemFieldSingleSelectValue": + continue + field_name = (raw_value.get("field") or {}).get("name") + if field_name and raw_value.get("name"): + values[field_name] = raw_value["name"] + return ProjectItemState(item_id=item["id"], values=values) + return None + + def add_project_item(project_id: str, issue_id: str) -> str: payload = run_graphql(queries.ADD_PROJECT_ITEM, {"projectId": project_id, "contentId": issue_id}) return payload["data"]["addProjectV2ItemById"]["item"]["id"] diff --git a/cli/python/base_github_projects/graphql_queries.py b/cli/python/base_github_projects/graphql_queries.py index 626f33e0..a673560a 100644 --- a/cli/python/base_github_projects/graphql_queries.py +++ b/cli/python/base_github_projects/graphql_queries.py @@ -180,6 +180,30 @@ } """ +FIND_PROJECT_ITEM_WITH_FIELDS = """ +query($projectId: ID!) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: 100) { + nodes { + id + content { ... on Issue { id } } + fieldValues(first: 50) { + nodes { + __typename + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { ... on ProjectV2SingleSelectField { name } } + } + } + } + } + } + } + } +} +""" + ADD_PROJECT_ITEM = """ mutation($projectId: ID!, $contentId: ID!) { addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { diff --git a/cli/python/base_github_projects/project_issue_fields_command.py b/cli/python/base_github_projects/project_issue_fields_command.py index 4c021ab9..cb612c93 100644 --- a/cli/python/base_github_projects/project_issue_fields_command.py +++ b/cli/python/base_github_projects/project_issue_fields_command.py @@ -42,15 +42,24 @@ def issue_set_fields_command(args: ProjectArguments, ops: ProjectOperations) -> if not updates: raise ops.ProjectUsageError("At least one field option must be provided.") issue_id = ops.fetch_issue_id(repo_owner, repo_name, args.issue_number or 0) - item_id = ops.find_project_item_id(project.project_id, issue_id) + item_state = ops.find_project_item_state(project.project_id, issue_id) if args.dry_run: print(f"[DRY-RUN] Would add issue #{args.issue_number} to Project '{args.project_title}' if needed.") for update in updates: print(f"[DRY-RUN] Would set {update.field_name} to {update.option_name}.") return base_cli.ExitCode.SUCCESS - if item_id is None: + if item_state is None: item_id = ops.add_project_item(project.project_id, issue_id) - for update in updates: + pending_updates = updates + else: + item_id = item_state.item_id + pending_updates = tuple( + update for update in updates if item_state.values.get(update.field_name) != update.option_name + ) + for update in pending_updates: ops.update_item_field(project.project_id, item_id, update) - print(f"✓ Updated Project metadata for issue #{args.issue_number}") + if pending_updates: + print(f"✓ Updated Project metadata for issue #{args.issue_number}") + else: + print(f"✓ Project metadata already current for issue #{args.issue_number}") return base_cli.ExitCode.SUCCESS diff --git a/cli/python/base_github_projects/project_model.py b/cli/python/base_github_projects/project_model.py index 7f471c0f..09634b04 100644 --- a/cli/python/base_github_projects/project_model.py +++ b/cli/python/base_github_projects/project_model.py @@ -58,6 +58,12 @@ class FieldUpdate: option_name: str +@dataclass(frozen=True) +class ProjectItemState: + item_id: str + values: dict[str, str] + + @dataclass(frozen=True) class ProjectInfo: project_id: str diff --git a/cli/python/base_github_projects/project_operations.py b/cli/python/base_github_projects/project_operations.py index 94a0d370..6f3a4854 100644 --- a/cli/python/base_github_projects/project_operations.py +++ b/cli/python/base_github_projects/project_operations.py @@ -13,6 +13,7 @@ from .project_model import ProjectArguments from .project_model import ProjectField from .project_model import ProjectInfo +from .project_model import ProjectItemState from .project_model import ProjectSchema from .project_model import ProjectView from .project_model import SelectFieldSpec @@ -72,6 +73,7 @@ class ProjectOperations: fetch_project_views: Callable[[str], tuple[ProjectView, ...]] find_owner_and_project: Callable[[str, str], OwnerInfo] find_project_item_id: Callable[[str, str], str | None] + find_project_item_state: Callable[[str, str], ProjectItemState | None] issue_field_values_for_args: Callable[[ProjectArguments], dict[str, str]] link_project_to_repository: Callable[[str, str], None] missing_option_names: Callable[[ProjectField, SelectFieldSpec], tuple[str, ...]] diff --git a/cli/python/base_github_projects/tests/test_graphql_data_access.py b/cli/python/base_github_projects/tests/test_graphql_data_access.py index d5140502..5eda5e8a 100644 --- a/cli/python/base_github_projects/tests/test_graphql_data_access.py +++ b/cli/python/base_github_projects/tests/test_graphql_data_access.py @@ -363,6 +363,47 @@ def test_missing_issue_and_project_item_are_reported(monkeypatch: pytest.MonkeyP assert len(calls) == 2 +def test_find_project_item_state_reads_existing_single_select_values(monkeypatch: pytest.MonkeyPatch) -> None: + calls = graphql_responses( + monkeypatch, + [ + { + "data": { + "node": { + "items": { + "nodes": [ + { + "id": "item-id", + "content": {"id": "issue-id"}, + "fieldValues": { + "nodes": [ + { + "__typename": "ProjectV2ItemFieldSingleSelectValue", + "name": "In Progress", + "field": {"name": "Status"}, + }, + { + "__typename": "ProjectV2ItemFieldTextValue", + "text": "ignored", + }, + ] + }, + } + ] + } + } + } + } + ], + ) + + assert engine.find_project_item_state("project-id", "issue-id") == engine.ProjectItemState( + item_id="item-id", + values={"Status": "In Progress"}, + ) + assert calls == [(queries.FIND_PROJECT_ITEM_WITH_FIELDS, {"projectId": "project-id"})] + + def test_single_select_field_mutations_build_option_payloads(monkeypatch: pytest.MonkeyPatch) -> None: calls = graphql_responses(monkeypatch, [{"data": {}}, {"data": {}}]) spec = engine.SelectFieldSpec( diff --git a/cli/python/base_github_projects/tests/test_project_issue_fields_command.py b/cli/python/base_github_projects/tests/test_project_issue_fields_command.py index e9709d00..cecb00c0 100644 --- a/cli/python/base_github_projects/tests/test_project_issue_fields_command.py +++ b/cli/python/base_github_projects/tests/test_project_issue_fields_command.py @@ -73,6 +73,10 @@ def find_item(project_id: str, issue_id: str) -> str: assert (project_id, issue_id) == ("project-id", "issue-id") return "item-id" + def find_item_state(project_id: str, issue_id: str) -> engine.ProjectItemState: + assert (project_id, issue_id) == ("project-id", "issue-id") + return engine.ProjectItemState(item_id="item-id", values={}) + return replace( engine.project_operations(), require_owner=lambda args: args.owner or pytest.fail("owner is required"), @@ -87,6 +91,7 @@ def find_item(project_id: str, issue_id: str) -> str: split_repo=engine.split_repo, fetch_issue_id=fetch_issue, find_project_item_id=find_item, + find_project_item_state=find_item_state, add_project_item=unexpected("add_project_item"), update_item_field=unexpected("update_item_field"), ) @@ -196,7 +201,7 @@ def update_item(project_id: str, item_id: str, update: engine.FieldUpdate) -> No if (owner, name, number) == ("basefoundry", "base-cli", 1604) else pytest.fail("unexpected issue lookup") ), - find_project_item_id=find_item, + find_project_item_state=find_item, add_project_item=add_item, update_item_field=update_item, ) @@ -249,7 +254,7 @@ def test_dry_run_with_missing_item_performs_no_mutations( ) -> None: ops = replace( base_operations(), - find_project_item_id=lambda _project_id, _issue_id: None, + find_project_item_state=lambda _project_id, _issue_id: None, ) status = project_issue_fields_command.issue_set_fields_command(issue_arguments(dry_run=True), ops) @@ -267,16 +272,16 @@ def test_existing_item_receives_multiple_updates_in_resolved_order( ) -> None: events: list[tuple[str, ...]] = [] - def find_item(project_id: str, issue_id: str) -> str: + def find_item(project_id: str, issue_id: str) -> engine.ProjectItemState: events.append(("find", project_id, issue_id)) - return "existing-item" + return engine.ProjectItemState(item_id="existing-item", values={}) def update_item(project_id: str, item_id: str, update: engine.FieldUpdate) -> None: events.append(("update", project_id, item_id, update.field_name, update.option_name)) ops = replace( base_operations(), - find_project_item_id=find_item, + find_project_item_state=find_item, update_item_field=update_item, ) @@ -306,7 +311,7 @@ def update_item(project_id: str, item_id: str, update: engine.FieldUpdate) -> No ops = replace( base_operations(), - find_project_item_id=find_item, + find_project_item_state=find_item, add_project_item=add_item, update_item_field=update_item, ) @@ -322,6 +327,23 @@ def update_item(project_id: str, item_id: str, update: engine.FieldUpdate) -> No ] +def test_existing_item_with_current_values_skips_field_mutations( + capsys: pytest.CaptureFixture[str], +) -> None: + ops = replace( + base_operations(), + find_project_item_state=lambda _project_id, _issue_id: engine.ProjectItemState( + item_id="existing-item", + values={"Status": "In Progress", "Priority": "P2"}, + ), + ) + + status = project_issue_fields_command.issue_set_fields_command(issue_arguments(), ops) + + assert status == base_cli.ExitCode.SUCCESS + assert capsys.readouterr().out == "✓ Project metadata already current for issue #1604\n" + + def test_issue_lookup_failure_propagates_controlled_error_without_mutation() -> None: def fail_issue_lookup(_owner: str, _name: str, number: int) -> str: raise engine.ProjectError(f"Issue #{number} was not found in basefoundry/base.") @@ -340,7 +362,7 @@ def fail_add(_project_id: str, _issue_id: str) -> str: ops = replace( base_operations(), - find_project_item_id=lambda _project_id, _issue_id: None, + find_project_item_state=lambda _project_id, _issue_id: None, add_project_item=fail_add, ) diff --git a/docs/command-reference.md b/docs/command-reference.md index 70008271..47218737 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -234,7 +234,7 @@ daily project loop commands from the local checkout. | `basectl gh issue list` | List GitHub issues through `gh`. | passes through `gh` options | | `basectl gh issue create` | Create an issue with Base category conventions, assign it, and add repo Project metadata when the repo is known. Defaults to `--category enhancement` and Project `Size=S` when omitted. Use `--assignee <login>` to assign explicitly or `--no-assignee` to bypass a repository default. Project updates are limited to Projects linked to the issue repository unless `--allow-cross-repo` is explicitly supplied. | `--category <bug\|enhancement\|documentation\|ci\|security>`, `--title <title>`, `--body <body>`, `--repo <owner/name>`, `--assignee <login>`, `--no-assignee`, `--project <title>`, `--project-owner <login>`, `--size <T\|S\|M\|L>`, `--no-project`, `--allow-cross-repo` | | `basectl gh issue readiness <number>` | Check whether an issue has the required body sections and, when Project coordinates are supplied, Base Project fields before assignment. Omitting Project coordinates reports a partial result. Stable inspection JSON uses the shared v1 envelope. | `--repo <owner/name>`, `--project-owner <login>`, `--project-number <number>`, `--format <text\|json>` | -| `basectl gh issue start <number>` | Start the issue-backed branch workflow after verifying the issue has exactly one standard category; an explicit `--category` must match that label. The issue repository resolves from an explicit selector, then `GH_REPO`, then `origin`. | `--category <category>`, `--title <title>`, `--repo <owner/name>`, `-R <owner/name>` | +| `basectl gh issue start <number>` | Move the linked Project item to `In Progress` and verify it when a linked Project is available, then start the issue-backed branch workflow after verifying the issue has exactly one standard category; an explicit `--category` must match that label. A missing or unlinked Project is reported as a warning and does not block the handoff; use `--no-project` for an intentional Project-independent start. | `--category <category>`, `--title <title>`, `--repo <owner/name>`, `-R <owner/name>`, `--project <title>`, `--project-owner <login>`, `--no-project`, `--allow-cross-repo` | | `basectl gh auth status` | Inspect GitHub authentication state without displaying token values. Warns when an environment token takes precedence over stored credentials and distinguishes network failures from unavailable login. | `--hostname <host>` | | `basectl gh auth refresh` | Explicitly refresh the stored GitHub credential and optionally request additional OAuth scopes. It does not alter `GH_TOKEN` or other environment-provided credentials. | `--hostname <host>`, repeatable `--scope <scope>`, `--scopes <scope,...>`, `--clipboard` | | `basectl gh pr create/status/checks/ready/merge` | Create and manage pull requests through Base's workflow wrapper. `pr create` rejects a noncanonical current branch before invoking GitHub, auto-injects `Fixes #<issue>` unless `--no-fixes` is passed, and uses `github.pr` from `base_manifest.yaml` when present. | passes through `gh` options; `pr create` also accepts `--no-fixes` | diff --git a/docs/github-workflow.md b/docs/github-workflow.md index 7eab1d79..1ef2b628 100644 --- a/docs/github-workflow.md +++ b/docs/github-workflow.md @@ -261,6 +261,12 @@ default for one issue. `basectl gh pr create` requires the current branch to follow the Base `<category>/<issue>-<YYYYMMDD>-<slug>` convention and auto-injects `Fixes #<issue>`; pass `--no-fixes` when the PR should not close the issue automatically. An invalid branch fails before `gh pr create` runs. +`basectl gh issue start` updates the linked repo Project to `In Progress` and +verifies the transition before printing the worktree handoff. If the target +repository has no linked Project, it reports that condition and continues; +pass `--no-project` for an intentional Project-independent handoff, or use +`--project`, `--project-owner`, and `--allow-cross-repo` for an explicitly +selected Project. When `base_manifest.yaml` declares `github.pr`, PR creation renders the body from that project policy and still preserves the issue link. diff --git a/lib/shell/completions/basectl_completion.sh b/lib/shell/completions/basectl_completion.sh index a1e50f87..f880450b 100644 --- a/lib/shell/completions/basectl_completion.sh +++ b/lib/shell/completions/basectl_completion.sh @@ -878,7 +878,7 @@ _base_basectl_completion() { _base_basectl_completion_compgen "--repo --project-owner --project-number --format -h --help" "$cur" ;; start) - _base_basectl_completion_compgen "--category --title --repo -R -h --help" "$cur" + _base_basectl_completion_compgen "--category --title --repo -R --project --project-owner --no-project --allow-cross-repo -h --help" "$cur" ;; esac fi diff --git a/lib/shell/completions/basectl_completion.zsh b/lib/shell/completions/basectl_completion.zsh index 2bbdce96..81fee817 100644 --- a/lib/shell/completions/basectl_completion.zsh +++ b/lib/shell/completions/basectl_completion.zsh @@ -1075,6 +1075,10 @@ _base_basectl_completion() { '--category[Issue category]:category:(bug enhancement documentation ci security)' \ '--title[Issue title]:title:' \ '(-R --repo)'{-R,--repo}'[Repository containing the issue]:repo:' \ + '--project[GitHub Project title]:title:' \ + '--project-owner[GitHub Project owner]:owner:' \ + '--no-project[Skip the Project status transition]' \ + '--allow-cross-repo[Allow updating a Project not linked to the issue repository]' \ '(-h --help)'{-h,--help}'[Show help text]' ;; readiness) diff --git a/lib/shell/completions/tests/completions.bats b/lib/shell/completions/tests/completions.bats index 5311be61..93fc80e4 100644 --- a/lib/shell/completions/tests/completions.bats +++ b/lib/shell/completions/tests/completions.bats @@ -598,6 +598,10 @@ run_zsh_positional_completion() { [[ "$block" == *"-R"* ]] [[ "$block" == *"--category"* ]] [[ "$block" == *"--title"* ]] + [[ "$block" == *"--project"* ]] + [[ "$block" == *"--project-owner"* ]] + [[ "$block" == *"--no-project"* ]] + [[ "$block" == *"--allow-cross-repo"* ]] [[ "$block" != *"--assignee"* ]] }