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
6 changes: 5 additions & 1 deletion cli/bash/commands/basectl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 48 additions & 7 deletions cli/bash/commands/basectl/subcommands/gh.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,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]
basectl gh pr create [--no-fixes] [gh options...]
basectl gh pr status [gh options...]
basectl gh pr checks [gh options...]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
62 changes: 59 additions & 3 deletions cli/bash/commands/basectl/subcommands/gh_issue.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
}
Expand Down Expand Up @@ -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=()
Expand All @@ -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=()
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
1 change: 1 addition & 0 deletions cli/bash/commands/basectl/tests/basectl_helpers.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
93 changes: 93 additions & 0 deletions cli/bash/commands/basectl/tests/gh.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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:"* ]]
}
Expand Down Expand Up @@ -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" {
Expand Down
19 changes: 18 additions & 1 deletion cli/python/base_github_projects/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"]
Expand Down
Loading
Loading