erk 0.4.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- erk/__init__.py +12 -0
- erk/__main__.py +6 -0
- erk/agent_docs/__init__.py +5 -0
- erk/agent_docs/models.py +123 -0
- erk/agent_docs/operations.py +666 -0
- erk/artifacts/__init__.py +5 -0
- erk/artifacts/artifact_health.py +623 -0
- erk/artifacts/detection.py +16 -0
- erk/artifacts/discovery.py +343 -0
- erk/artifacts/models.py +63 -0
- erk/artifacts/staleness.py +56 -0
- erk/artifacts/state.py +100 -0
- erk/artifacts/sync.py +624 -0
- erk/cli/__init__.py +0 -0
- erk/cli/activation.py +132 -0
- erk/cli/alias.py +53 -0
- erk/cli/cli.py +221 -0
- erk/cli/commands/__init__.py +0 -0
- erk/cli/commands/admin.py +153 -0
- erk/cli/commands/artifact/__init__.py +1 -0
- erk/cli/commands/artifact/check.py +260 -0
- erk/cli/commands/artifact/group.py +31 -0
- erk/cli/commands/artifact/list_cmd.py +89 -0
- erk/cli/commands/artifact/show.py +62 -0
- erk/cli/commands/artifact/sync_cmd.py +39 -0
- erk/cli/commands/branch/__init__.py +26 -0
- erk/cli/commands/branch/assign_cmd.py +152 -0
- erk/cli/commands/branch/checkout_cmd.py +357 -0
- erk/cli/commands/branch/create_cmd.py +161 -0
- erk/cli/commands/branch/list_cmd.py +82 -0
- erk/cli/commands/branch/unassign_cmd.py +197 -0
- erk/cli/commands/cc/__init__.py +15 -0
- erk/cli/commands/cc/jsonl_cmd.py +20 -0
- erk/cli/commands/cc/session/AGENTS.md +30 -0
- erk/cli/commands/cc/session/CLAUDE.md +1 -0
- erk/cli/commands/cc/session/__init__.py +15 -0
- erk/cli/commands/cc/session/list_cmd.py +167 -0
- erk/cli/commands/cc/session/show_cmd.py +175 -0
- erk/cli/commands/completion.py +89 -0
- erk/cli/commands/completions.py +165 -0
- erk/cli/commands/config.py +327 -0
- erk/cli/commands/docs/__init__.py +1 -0
- erk/cli/commands/docs/group.py +16 -0
- erk/cli/commands/docs/sync.py +121 -0
- erk/cli/commands/docs/validate.py +102 -0
- erk/cli/commands/doctor.py +243 -0
- erk/cli/commands/down.py +171 -0
- erk/cli/commands/exec/__init__.py +1 -0
- erk/cli/commands/exec/group.py +164 -0
- erk/cli/commands/exec/scripts/AGENTS.md +79 -0
- erk/cli/commands/exec/scripts/CLAUDE.md +1 -0
- erk/cli/commands/exec/scripts/__init__.py +5 -0
- erk/cli/commands/exec/scripts/add_reaction_to_comment.py +69 -0
- erk/cli/commands/exec/scripts/add_remote_execution_note.py +68 -0
- erk/cli/commands/exec/scripts/check_impl.py +152 -0
- erk/cli/commands/exec/scripts/ci_update_pr_body.py +294 -0
- erk/cli/commands/exec/scripts/create_extraction_branch.py +138 -0
- erk/cli/commands/exec/scripts/create_extraction_plan.py +242 -0
- erk/cli/commands/exec/scripts/create_issue_from_session.py +103 -0
- erk/cli/commands/exec/scripts/create_plan_from_context.py +103 -0
- erk/cli/commands/exec/scripts/create_worker_impl_from_issue.py +93 -0
- erk/cli/commands/exec/scripts/detect_trunk_branch.py +121 -0
- erk/cli/commands/exec/scripts/exit_plan_mode_hook.py +777 -0
- erk/cli/commands/exec/scripts/extract_latest_plan.py +49 -0
- erk/cli/commands/exec/scripts/extract_session_from_issue.py +150 -0
- erk/cli/commands/exec/scripts/find_project_dir.py +214 -0
- erk/cli/commands/exec/scripts/generate_pr_summary.py +112 -0
- erk/cli/commands/exec/scripts/get_closing_text.py +98 -0
- erk/cli/commands/exec/scripts/get_embedded_prompt.py +62 -0
- erk/cli/commands/exec/scripts/get_plan_metadata.py +95 -0
- erk/cli/commands/exec/scripts/get_pr_body_footer.py +70 -0
- erk/cli/commands/exec/scripts/get_pr_discussion_comments.py +149 -0
- erk/cli/commands/exec/scripts/get_pr_review_comments.py +155 -0
- erk/cli/commands/exec/scripts/impl_init.py +158 -0
- erk/cli/commands/exec/scripts/impl_signal.py +375 -0
- erk/cli/commands/exec/scripts/impl_verify.py +49 -0
- erk/cli/commands/exec/scripts/issue_title_to_filename.py +34 -0
- erk/cli/commands/exec/scripts/list_sessions.py +296 -0
- erk/cli/commands/exec/scripts/mark_impl_ended.py +188 -0
- erk/cli/commands/exec/scripts/mark_impl_started.py +188 -0
- erk/cli/commands/exec/scripts/marker.py +163 -0
- erk/cli/commands/exec/scripts/objective_save_to_issue.py +109 -0
- erk/cli/commands/exec/scripts/plan_save_to_issue.py +269 -0
- erk/cli/commands/exec/scripts/plan_update_issue.py +147 -0
- erk/cli/commands/exec/scripts/post_extraction_comment.py +237 -0
- erk/cli/commands/exec/scripts/post_or_update_pr_summary.py +133 -0
- erk/cli/commands/exec/scripts/post_pr_inline_comment.py +143 -0
- erk/cli/commands/exec/scripts/post_workflow_started_comment.py +168 -0
- erk/cli/commands/exec/scripts/preprocess_session.py +777 -0
- erk/cli/commands/exec/scripts/quick_submit.py +32 -0
- erk/cli/commands/exec/scripts/rebase_with_conflict_resolution.py +260 -0
- erk/cli/commands/exec/scripts/reply_to_discussion_comment.py +173 -0
- erk/cli/commands/exec/scripts/resolve_review_thread.py +170 -0
- erk/cli/commands/exec/scripts/session_id_injector_hook.py +52 -0
- erk/cli/commands/exec/scripts/setup_impl_from_issue.py +159 -0
- erk/cli/commands/exec/scripts/slot_objective.py +102 -0
- erk/cli/commands/exec/scripts/tripwires_reminder_hook.py +20 -0
- erk/cli/commands/exec/scripts/update_dispatch_info.py +116 -0
- erk/cli/commands/exec/scripts/user_prompt_hook.py +113 -0
- erk/cli/commands/exec/scripts/validate_plan_content.py +98 -0
- erk/cli/commands/exec/scripts/wrap_plan_in_metadata_block.py +34 -0
- erk/cli/commands/implement.py +695 -0
- erk/cli/commands/implement_shared.py +649 -0
- erk/cli/commands/info/__init__.py +14 -0
- erk/cli/commands/info/release_notes_cmd.py +128 -0
- erk/cli/commands/init.py +801 -0
- erk/cli/commands/land_cmd.py +690 -0
- erk/cli/commands/log_cmd.py +137 -0
- erk/cli/commands/md/__init__.py +5 -0
- erk/cli/commands/md/check.py +118 -0
- erk/cli/commands/md/group.py +14 -0
- erk/cli/commands/navigation_helpers.py +430 -0
- erk/cli/commands/objective/__init__.py +16 -0
- erk/cli/commands/objective/list_cmd.py +47 -0
- erk/cli/commands/objective_helpers.py +132 -0
- erk/cli/commands/plan/__init__.py +32 -0
- erk/cli/commands/plan/check_cmd.py +174 -0
- erk/cli/commands/plan/close_cmd.py +69 -0
- erk/cli/commands/plan/create_cmd.py +120 -0
- erk/cli/commands/plan/docs/__init__.py +18 -0
- erk/cli/commands/plan/docs/extract_cmd.py +53 -0
- erk/cli/commands/plan/docs/unextract_cmd.py +38 -0
- erk/cli/commands/plan/docs/unextracted_cmd.py +72 -0
- erk/cli/commands/plan/extraction/__init__.py +16 -0
- erk/cli/commands/plan/extraction/complete_cmd.py +101 -0
- erk/cli/commands/plan/extraction/create_raw_cmd.py +63 -0
- erk/cli/commands/plan/get.py +71 -0
- erk/cli/commands/plan/list_cmd.py +754 -0
- erk/cli/commands/plan/log_cmd.py +440 -0
- erk/cli/commands/plan/start_cmd.py +459 -0
- erk/cli/commands/planner/__init__.py +40 -0
- erk/cli/commands/planner/configure_cmd.py +73 -0
- erk/cli/commands/planner/connect_cmd.py +96 -0
- erk/cli/commands/planner/create_cmd.py +148 -0
- erk/cli/commands/planner/list_cmd.py +51 -0
- erk/cli/commands/planner/register_cmd.py +105 -0
- erk/cli/commands/planner/set_default_cmd.py +23 -0
- erk/cli/commands/planner/unregister_cmd.py +43 -0
- erk/cli/commands/pr/__init__.py +23 -0
- erk/cli/commands/pr/check_cmd.py +112 -0
- erk/cli/commands/pr/checkout_cmd.py +165 -0
- erk/cli/commands/pr/fix_conflicts_cmd.py +82 -0
- erk/cli/commands/pr/parse_pr_reference.py +10 -0
- erk/cli/commands/pr/submit_cmd.py +360 -0
- erk/cli/commands/pr/sync_cmd.py +181 -0
- erk/cli/commands/prepare_cwd_recovery.py +60 -0
- erk/cli/commands/project/__init__.py +16 -0
- erk/cli/commands/project/init_cmd.py +91 -0
- erk/cli/commands/run/__init__.py +17 -0
- erk/cli/commands/run/list_cmd.py +189 -0
- erk/cli/commands/run/logs_cmd.py +54 -0
- erk/cli/commands/run/shared.py +19 -0
- erk/cli/commands/shell_integration.py +29 -0
- erk/cli/commands/slot/__init__.py +23 -0
- erk/cli/commands/slot/check_cmd.py +277 -0
- erk/cli/commands/slot/common.py +314 -0
- erk/cli/commands/slot/init_pool_cmd.py +157 -0
- erk/cli/commands/slot/list_cmd.py +228 -0
- erk/cli/commands/slot/repair_cmd.py +190 -0
- erk/cli/commands/stack/__init__.py +23 -0
- erk/cli/commands/stack/consolidate_cmd.py +470 -0
- erk/cli/commands/stack/list_cmd.py +79 -0
- erk/cli/commands/stack/move_cmd.py +309 -0
- erk/cli/commands/stack/split_old/README.md +64 -0
- erk/cli/commands/stack/split_old/__init__.py +5 -0
- erk/cli/commands/stack/split_old/command.py +233 -0
- erk/cli/commands/stack/split_old/display.py +116 -0
- erk/cli/commands/stack/split_old/plan.py +216 -0
- erk/cli/commands/status.py +58 -0
- erk/cli/commands/submit.py +768 -0
- erk/cli/commands/up.py +154 -0
- erk/cli/commands/upgrade.py +82 -0
- erk/cli/commands/wt/__init__.py +29 -0
- erk/cli/commands/wt/checkout_cmd.py +110 -0
- erk/cli/commands/wt/create_cmd.py +998 -0
- erk/cli/commands/wt/current_cmd.py +35 -0
- erk/cli/commands/wt/delete_cmd.py +573 -0
- erk/cli/commands/wt/list_cmd.py +332 -0
- erk/cli/commands/wt/rename_cmd.py +66 -0
- erk/cli/config.py +242 -0
- erk/cli/constants.py +29 -0
- erk/cli/core.py +65 -0
- erk/cli/debug.py +9 -0
- erk/cli/ensure-conversion-tasks.md +288 -0
- erk/cli/ensure.py +628 -0
- erk/cli/github_parsing.py +96 -0
- erk/cli/graphite.py +81 -0
- erk/cli/graphite_command.py +80 -0
- erk/cli/help_formatter.py +345 -0
- erk/cli/output.py +361 -0
- erk/cli/presets/dagster.toml +12 -0
- erk/cli/presets/generic.toml +12 -0
- erk/cli/prompt_hooks_templates/README.md +68 -0
- erk/cli/script_output.py +32 -0
- erk/cli/shell_integration/bash_wrapper.sh +32 -0
- erk/cli/shell_integration/fish_wrapper.fish +39 -0
- erk/cli/shell_integration/handler.py +338 -0
- erk/cli/shell_integration/zsh_wrapper.sh +32 -0
- erk/cli/shell_utils.py +171 -0
- erk/cli/subprocess_utils.py +92 -0
- erk/cli/uvx_detection.py +59 -0
- erk/core/__init__.py +0 -0
- erk/core/claude_executor.py +511 -0
- erk/core/claude_settings.py +317 -0
- erk/core/command_log.py +406 -0
- erk/core/commit_message_generator.py +234 -0
- erk/core/completion.py +10 -0
- erk/core/consolidation_utils.py +177 -0
- erk/core/context.py +570 -0
- erk/core/display/__init__.py +4 -0
- erk/core/display/abc.py +24 -0
- erk/core/display/real.py +30 -0
- erk/core/display_utils.py +526 -0
- erk/core/file_utils.py +87 -0
- erk/core/health_checks.py +1315 -0
- erk/core/health_checks_dogfooder/__init__.py +85 -0
- erk/core/health_checks_dogfooder/deprecated_dot_agent_config.py +64 -0
- erk/core/health_checks_dogfooder/legacy_claude_docs.py +69 -0
- erk/core/health_checks_dogfooder/legacy_config_locations.py +122 -0
- erk/core/health_checks_dogfooder/legacy_erk_docs_agent.py +61 -0
- erk/core/health_checks_dogfooder/legacy_erk_kits_folder.py +60 -0
- erk/core/health_checks_dogfooder/legacy_hook_settings.py +104 -0
- erk/core/health_checks_dogfooder/legacy_kit_yaml.py +78 -0
- erk/core/health_checks_dogfooder/legacy_kits_toml.py +43 -0
- erk/core/health_checks_dogfooder/outdated_erk_skill.py +43 -0
- erk/core/implementation_queue/__init__.py +1 -0
- erk/core/implementation_queue/github/__init__.py +8 -0
- erk/core/implementation_queue/github/abc.py +7 -0
- erk/core/implementation_queue/github/noop.py +38 -0
- erk/core/implementation_queue/github/printing.py +43 -0
- erk/core/implementation_queue/github/real.py +119 -0
- erk/core/init_utils.py +227 -0
- erk/core/output_filter.py +338 -0
- erk/core/plan_store/__init__.py +6 -0
- erk/core/planner/__init__.py +1 -0
- erk/core/planner/registry_abc.py +8 -0
- erk/core/planner/registry_fake.py +129 -0
- erk/core/planner/registry_real.py +195 -0
- erk/core/planner/types.py +7 -0
- erk/core/pr_utils.py +30 -0
- erk/core/release_notes.py +263 -0
- erk/core/repo_discovery.py +126 -0
- erk/core/script_writer.py +41 -0
- erk/core/services/__init__.py +1 -0
- erk/core/services/plan_list_service.py +94 -0
- erk/core/shell.py +51 -0
- erk/core/user_feedback.py +11 -0
- erk/core/version_check.py +55 -0
- erk/core/workflow_display.py +75 -0
- erk/core/worktree_pool.py +190 -0
- erk/core/worktree_utils.py +300 -0
- erk/data/CHANGELOG.md +438 -0
- erk/data/__init__.py +1 -0
- erk/data/claude/agents/devrun.md +180 -0
- erk/data/claude/commands/erk/__init__.py +0 -0
- erk/data/claude/commands/erk/create-extraction-plan.md +360 -0
- erk/data/claude/commands/erk/fix-conflicts.md +25 -0
- erk/data/claude/commands/erk/git-pr-push.md +345 -0
- erk/data/claude/commands/erk/implement-stacked-plan.md +96 -0
- erk/data/claude/commands/erk/land.md +193 -0
- erk/data/claude/commands/erk/objective-create.md +370 -0
- erk/data/claude/commands/erk/objective-list.md +34 -0
- erk/data/claude/commands/erk/objective-next-plan.md +220 -0
- erk/data/claude/commands/erk/objective-update-with-landed-pr.md +216 -0
- erk/data/claude/commands/erk/plan-implement.md +202 -0
- erk/data/claude/commands/erk/plan-save.md +45 -0
- erk/data/claude/commands/erk/plan-submit.md +39 -0
- erk/data/claude/commands/erk/pr-address.md +367 -0
- erk/data/claude/commands/erk/pr-submit.md +58 -0
- erk/data/claude/skills/dignified-python/SKILL.md +48 -0
- erk/data/claude/skills/dignified-python/cli-patterns.md +155 -0
- erk/data/claude/skills/dignified-python/dignified-python-core.md +1190 -0
- erk/data/claude/skills/dignified-python/subprocess.md +99 -0
- erk/data/claude/skills/dignified-python/versions/python-3.10.md +517 -0
- erk/data/claude/skills/dignified-python/versions/python-3.11.md +536 -0
- erk/data/claude/skills/dignified-python/versions/python-3.12.md +662 -0
- erk/data/claude/skills/dignified-python/versions/python-3.13.md +653 -0
- erk/data/claude/skills/erk-diff-analysis/SKILL.md +27 -0
- erk/data/claude/skills/erk-diff-analysis/references/commit-message-prompt.md +78 -0
- erk/data/claude/skills/learned-docs/SKILL.md +362 -0
- erk/data/github/actions/setup-claude-erk/action.yml +11 -0
- erk/data/github/prompts/dignified-python-review.md +125 -0
- erk/data/github/workflows/dignified-python-review.yml +61 -0
- erk/data/github/workflows/erk-impl.yml +251 -0
- erk/hooks/__init__.py +1 -0
- erk/hooks/decorators.py +319 -0
- erk/status/__init__.py +8 -0
- erk/status/collectors/__init__.py +9 -0
- erk/status/collectors/base.py +52 -0
- erk/status/collectors/git.py +76 -0
- erk/status/collectors/github.py +81 -0
- erk/status/collectors/graphite.py +80 -0
- erk/status/collectors/impl.py +145 -0
- erk/status/models/__init__.py +4 -0
- erk/status/models/status_data.py +404 -0
- erk/status/orchestrator.py +169 -0
- erk/status/renderers/__init__.py +5 -0
- erk/status/renderers/simple.py +322 -0
- erk/tui/AGENTS.md +193 -0
- erk/tui/CLAUDE.md +1 -0
- erk/tui/__init__.py +1 -0
- erk/tui/app.py +1404 -0
- erk/tui/commands/__init__.py +1 -0
- erk/tui/commands/executor.py +66 -0
- erk/tui/commands/provider.py +165 -0
- erk/tui/commands/real_executor.py +63 -0
- erk/tui/commands/registry.py +121 -0
- erk/tui/commands/types.py +36 -0
- erk/tui/data/__init__.py +1 -0
- erk/tui/data/provider.py +492 -0
- erk/tui/data/types.py +104 -0
- erk/tui/filtering/__init__.py +1 -0
- erk/tui/filtering/logic.py +43 -0
- erk/tui/filtering/types.py +55 -0
- erk/tui/jsonl_viewer/__init__.py +1 -0
- erk/tui/jsonl_viewer/app.py +61 -0
- erk/tui/jsonl_viewer/models.py +208 -0
- erk/tui/jsonl_viewer/widgets.py +204 -0
- erk/tui/sorting/__init__.py +6 -0
- erk/tui/sorting/logic.py +55 -0
- erk/tui/sorting/types.py +68 -0
- erk/tui/styles/dash.tcss +95 -0
- erk/tui/widgets/__init__.py +1 -0
- erk/tui/widgets/command_output.py +112 -0
- erk/tui/widgets/plan_table.py +276 -0
- erk/tui/widgets/status_bar.py +116 -0
- erk-0.4.5.dist-info/METADATA +376 -0
- erk-0.4.5.dist-info/RECORD +331 -0
- erk-0.4.5.dist-info/WHEEL +4 -0
- erk-0.4.5.dist-info/entry_points.txt +2 -0
- erk-0.4.5.dist-info/licenses/LICENSE.md +3 -0
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
"""Submit issue for remote AI implementation via GitHub Actions."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import tomllib
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from erk.cli.commands.slot.common import is_placeholder_branch
|
|
14
|
+
from erk.cli.constants import (
|
|
15
|
+
DISPATCH_WORKFLOW_METADATA_NAME,
|
|
16
|
+
DISPATCH_WORKFLOW_NAME,
|
|
17
|
+
ERK_PLAN_LABEL,
|
|
18
|
+
)
|
|
19
|
+
from erk.cli.core import discover_repo_context
|
|
20
|
+
from erk.cli.ensure import Ensure
|
|
21
|
+
from erk.core.context import ErkContext
|
|
22
|
+
from erk.core.repo_discovery import RepoContext
|
|
23
|
+
from erk_shared.gateway.gt.operations.finalize import ERK_SKIP_EXTRACTION_LABEL
|
|
24
|
+
from erk_shared.github.issues import IssueInfo
|
|
25
|
+
from erk_shared.github.metadata.core import (
|
|
26
|
+
create_submission_queued_block,
|
|
27
|
+
find_metadata_block,
|
|
28
|
+
render_erk_issue_event,
|
|
29
|
+
)
|
|
30
|
+
from erk_shared.github.metadata.plan_header import update_plan_header_dispatch
|
|
31
|
+
from erk_shared.github.parsing import (
|
|
32
|
+
construct_pr_url,
|
|
33
|
+
construct_workflow_run_url,
|
|
34
|
+
extract_owner_repo_from_github_url,
|
|
35
|
+
)
|
|
36
|
+
from erk_shared.github.pr_footer import build_pr_body_footer
|
|
37
|
+
from erk_shared.github.types import PRNotFound
|
|
38
|
+
from erk_shared.naming import (
|
|
39
|
+
format_branch_timestamp_suffix,
|
|
40
|
+
sanitize_worktree_name,
|
|
41
|
+
)
|
|
42
|
+
from erk_shared.output.output import user_output
|
|
43
|
+
from erk_shared.worker_impl_folder import create_worker_impl_folder
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _format_issue_ref(issue_number: int, plans_repo: str | None) -> str:
|
|
49
|
+
"""Format issue reference for PR body.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
issue_number: The issue number
|
|
53
|
+
plans_repo: Target repo in "owner/repo" format, or None for same repo
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
"#N" for same-repo, "owner/repo#N" for cross-repo
|
|
57
|
+
"""
|
|
58
|
+
if plans_repo is None:
|
|
59
|
+
return f"#{issue_number}"
|
|
60
|
+
return f"{plans_repo}#{issue_number}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@contextmanager
|
|
64
|
+
def branch_rollback(ctx: ErkContext, repo_root: Path, original_branch: str) -> Iterator[None]:
|
|
65
|
+
"""Context manager that restores original branch on exception.
|
|
66
|
+
|
|
67
|
+
On success, does nothing (caller handles cleanup).
|
|
68
|
+
On exception, checks out original_branch and re-raises.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
yield
|
|
72
|
+
except Exception:
|
|
73
|
+
user_output(
|
|
74
|
+
click.style("Error: ", fg="red") + "Operation failed, restoring original branch..."
|
|
75
|
+
)
|
|
76
|
+
ctx.git.checkout_branch(repo_root, original_branch)
|
|
77
|
+
raise
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_issue_extraction_plan(issue_body: str) -> bool:
|
|
81
|
+
"""Check if an issue is an extraction plan by examining its plan-header metadata.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
issue_body: The full issue body text
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
True if the issue has plan_type: "extraction" in its plan-header block,
|
|
88
|
+
False otherwise (including if no plan-header block exists)
|
|
89
|
+
"""
|
|
90
|
+
block = find_metadata_block(issue_body, "plan-header")
|
|
91
|
+
|
|
92
|
+
if block is None:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
plan_type = block.data.get("plan_type")
|
|
96
|
+
return plan_type == "extraction"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_workflow_config(repo_root: Path, workflow_name: str) -> dict[str, str]:
|
|
100
|
+
"""Load workflow config from .erk/config.toml [workflows.<name>] section.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
repo_root: Repository root path
|
|
104
|
+
workflow_name: Workflow filename (with or without .yml/.yaml extension).
|
|
105
|
+
Only the basename is used for config lookup.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Dict of string key-value pairs for workflow inputs.
|
|
109
|
+
Returns empty dict if config file or section doesn't exist.
|
|
110
|
+
|
|
111
|
+
Example:
|
|
112
|
+
For workflow_name="erk-impl.yml", reads from:
|
|
113
|
+
.erk/config.toml -> [workflows.erk-impl] section
|
|
114
|
+
"""
|
|
115
|
+
config_path = repo_root / ".erk" / "config.toml"
|
|
116
|
+
|
|
117
|
+
if not config_path.exists():
|
|
118
|
+
return {}
|
|
119
|
+
|
|
120
|
+
with open(config_path, "rb") as f:
|
|
121
|
+
data = tomllib.load(f)
|
|
122
|
+
|
|
123
|
+
# Extract basename and strip .yml/.yaml extension
|
|
124
|
+
basename = Path(workflow_name).name
|
|
125
|
+
config_name = basename.removesuffix(".yml").removesuffix(".yaml")
|
|
126
|
+
|
|
127
|
+
# Get [workflows.<name>] section
|
|
128
|
+
workflows_section = data.get("workflows", {})
|
|
129
|
+
workflow_config = workflows_section.get(config_name, {})
|
|
130
|
+
|
|
131
|
+
# Convert all values to strings (workflow inputs are always strings)
|
|
132
|
+
return {k: str(v) for k, v in workflow_config.items()}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class ValidatedIssue:
|
|
137
|
+
"""Issue that passed all validation checks."""
|
|
138
|
+
|
|
139
|
+
number: int
|
|
140
|
+
issue: IssueInfo
|
|
141
|
+
branch_name: str
|
|
142
|
+
branch_exists: bool
|
|
143
|
+
pr_number: int | None
|
|
144
|
+
is_extraction_origin: bool
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class SubmitResult:
|
|
149
|
+
"""Result of submitting a single issue."""
|
|
150
|
+
|
|
151
|
+
issue_number: int
|
|
152
|
+
issue_title: str
|
|
153
|
+
issue_url: str
|
|
154
|
+
pr_number: int | None
|
|
155
|
+
pr_url: str | None
|
|
156
|
+
workflow_run_id: str
|
|
157
|
+
workflow_url: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _build_workflow_run_url(issue_url: str, run_id: str) -> str:
|
|
161
|
+
"""Construct GitHub Actions workflow run URL from issue URL and run ID.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
issue_url: GitHub issue URL (e.g., https://github.com/owner/repo/issues/123)
|
|
165
|
+
run_id: Workflow run ID
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Workflow run URL (e.g., https://github.com/owner/repo/actions/runs/1234567890)
|
|
169
|
+
"""
|
|
170
|
+
owner_repo = extract_owner_repo_from_github_url(issue_url)
|
|
171
|
+
if owner_repo is not None:
|
|
172
|
+
owner, repo = owner_repo
|
|
173
|
+
return construct_workflow_run_url(owner, repo, run_id)
|
|
174
|
+
return f"https://github.com/actions/runs/{run_id}"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _strip_plan_markers(title: str) -> str:
|
|
178
|
+
"""Strip 'Plan:' prefix and '[erk-plan]' suffix from issue title for use as PR title."""
|
|
179
|
+
result = title
|
|
180
|
+
# Strip "Plan: " prefix if present
|
|
181
|
+
if result.startswith("Plan: "):
|
|
182
|
+
result = result[6:]
|
|
183
|
+
# Strip " [erk-plan]" suffix if present
|
|
184
|
+
if result.endswith(" [erk-plan]"):
|
|
185
|
+
result = result[:-11]
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _build_pr_url(issue_url: str, pr_number: int) -> str:
|
|
190
|
+
"""Construct GitHub PR URL from issue URL and PR number.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
issue_url: GitHub issue URL (e.g., https://github.com/owner/repo/issues/123)
|
|
194
|
+
pr_number: PR number
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
PR URL (e.g., https://github.com/owner/repo/pull/456)
|
|
198
|
+
"""
|
|
199
|
+
owner_repo = extract_owner_repo_from_github_url(issue_url)
|
|
200
|
+
if owner_repo is not None:
|
|
201
|
+
owner, repo = owner_repo
|
|
202
|
+
return construct_pr_url(owner, repo, pr_number)
|
|
203
|
+
return f"https://github.com/pull/{pr_number}"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _close_orphaned_draft_prs(
|
|
207
|
+
ctx: ErkContext,
|
|
208
|
+
repo_root: Path,
|
|
209
|
+
issue_number: int,
|
|
210
|
+
keep_pr_number: int,
|
|
211
|
+
) -> list[int]:
|
|
212
|
+
"""Close old draft PRs linked to an issue, keeping the specified one.
|
|
213
|
+
|
|
214
|
+
Returns list of PR numbers that were closed.
|
|
215
|
+
"""
|
|
216
|
+
linked_prs = ctx.issues.get_prs_referencing_issue(repo_root, issue_number)
|
|
217
|
+
|
|
218
|
+
closed_prs: list[int] = []
|
|
219
|
+
for pr in linked_prs:
|
|
220
|
+
# Close orphaned drafts: draft PRs that are OPEN and not the one we just created
|
|
221
|
+
# Any draft PR linked to an erk-plan issue is fair game to close
|
|
222
|
+
if pr.is_draft and pr.state == "OPEN" and pr.number != keep_pr_number:
|
|
223
|
+
ctx.github.close_pr(repo_root, pr.number)
|
|
224
|
+
closed_prs.append(pr.number)
|
|
225
|
+
|
|
226
|
+
return closed_prs
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _validate_issue_for_submit(
|
|
230
|
+
ctx: ErkContext,
|
|
231
|
+
repo: RepoContext,
|
|
232
|
+
issue_number: int,
|
|
233
|
+
base_branch: str,
|
|
234
|
+
) -> ValidatedIssue:
|
|
235
|
+
"""Validate a single issue for submission.
|
|
236
|
+
|
|
237
|
+
Fetches the issue, validates constraints, derives branch name, and checks
|
|
238
|
+
if branch/PR already exist.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
ctx: ErkContext with git operations
|
|
242
|
+
repo: Repository context
|
|
243
|
+
issue_number: GitHub issue number to validate
|
|
244
|
+
base_branch: Base branch for PR (trunk or custom feature branch)
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
SystemExit: If issue doesn't exist, missing label, or closed.
|
|
248
|
+
"""
|
|
249
|
+
# Fetch issue from GitHub
|
|
250
|
+
try:
|
|
251
|
+
issue = ctx.issues.get_issue(repo.root, issue_number)
|
|
252
|
+
except RuntimeError as e:
|
|
253
|
+
user_output(click.style("Error: ", fg="red") + str(e))
|
|
254
|
+
raise SystemExit(1) from None
|
|
255
|
+
|
|
256
|
+
# Validate: must have erk-plan label
|
|
257
|
+
if ERK_PLAN_LABEL not in issue.labels:
|
|
258
|
+
user_output(
|
|
259
|
+
click.style("Error: ", fg="red")
|
|
260
|
+
+ f"Issue #{issue_number} does not have {ERK_PLAN_LABEL} label\n\n"
|
|
261
|
+
"Cannot submit non-plan issues for automated implementation.\n"
|
|
262
|
+
"To create a plan, use Plan Mode then /erk:plan-save"
|
|
263
|
+
)
|
|
264
|
+
raise SystemExit(1)
|
|
265
|
+
|
|
266
|
+
# Validate: must be OPEN
|
|
267
|
+
if issue.state != "OPEN":
|
|
268
|
+
user_output(
|
|
269
|
+
click.style("Error: ", fg="red") + f"Issue #{issue_number} is {issue.state}\n\n"
|
|
270
|
+
"Cannot submit closed issues for automated implementation."
|
|
271
|
+
)
|
|
272
|
+
raise SystemExit(1)
|
|
273
|
+
|
|
274
|
+
# Use provided base_branch instead of detecting trunk
|
|
275
|
+
logger.debug("base_branch=%s", base_branch)
|
|
276
|
+
|
|
277
|
+
# Compute branch name: P prefix + issue number + sanitized title + timestamp
|
|
278
|
+
# Apply P prefix AFTER sanitization since sanitize_worktree_name lowercases input
|
|
279
|
+
# Truncate total to 31 chars before adding timestamp suffix
|
|
280
|
+
prefix = f"P{issue_number}-"
|
|
281
|
+
sanitized_title = sanitize_worktree_name(issue.title)
|
|
282
|
+
base_branch_name = (prefix + sanitized_title)[:31].rstrip("-")
|
|
283
|
+
logger.debug("base_branch_name=%s", base_branch_name)
|
|
284
|
+
timestamp_suffix = format_branch_timestamp_suffix(ctx.time.now())
|
|
285
|
+
logger.debug("timestamp_suffix=%s", timestamp_suffix)
|
|
286
|
+
branch_name = base_branch_name + timestamp_suffix
|
|
287
|
+
logger.debug("branch_name=%s", branch_name)
|
|
288
|
+
user_output(f"Computed branch: {click.style(branch_name, fg='cyan')}")
|
|
289
|
+
|
|
290
|
+
# Check if branch already exists on remote and has a PR
|
|
291
|
+
branch_exists = ctx.git.branch_exists_on_remote(repo.root, "origin", branch_name)
|
|
292
|
+
logger.debug("branch_exists_on_remote(%s)=%s", branch_name, branch_exists)
|
|
293
|
+
|
|
294
|
+
pr_number: int | None = None
|
|
295
|
+
if branch_exists:
|
|
296
|
+
pr_details = ctx.github.get_pr_for_branch(repo.root, branch_name)
|
|
297
|
+
if not isinstance(pr_details, PRNotFound):
|
|
298
|
+
pr_number = pr_details.number
|
|
299
|
+
|
|
300
|
+
# Check if this issue is an extraction plan
|
|
301
|
+
is_extraction_origin = is_issue_extraction_plan(issue.body)
|
|
302
|
+
|
|
303
|
+
return ValidatedIssue(
|
|
304
|
+
number=issue_number,
|
|
305
|
+
issue=issue,
|
|
306
|
+
branch_name=branch_name,
|
|
307
|
+
branch_exists=branch_exists,
|
|
308
|
+
pr_number=pr_number,
|
|
309
|
+
is_extraction_origin=is_extraction_origin,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _create_branch_and_pr(
|
|
314
|
+
ctx: ErkContext,
|
|
315
|
+
repo: RepoContext,
|
|
316
|
+
validated: ValidatedIssue,
|
|
317
|
+
branch_name: str,
|
|
318
|
+
base_branch: str,
|
|
319
|
+
submitted_by: str,
|
|
320
|
+
original_branch: str,
|
|
321
|
+
) -> int:
|
|
322
|
+
"""Create branch, commit, push, and create draft PR.
|
|
323
|
+
|
|
324
|
+
This function is called within the branch_rollback context manager.
|
|
325
|
+
On any exception, the context manager will restore the original branch.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
ctx: ErkContext with git operations
|
|
329
|
+
repo: Repository context
|
|
330
|
+
validated: Validated issue information
|
|
331
|
+
branch_name: Name of branch to create
|
|
332
|
+
base_branch: Base branch for PR
|
|
333
|
+
submitted_by: GitHub username of submitter
|
|
334
|
+
original_branch: Original branch name (for cleanup on success)
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
PR number of the created draft PR.
|
|
338
|
+
"""
|
|
339
|
+
issue = validated.issue
|
|
340
|
+
issue_number = validated.number
|
|
341
|
+
|
|
342
|
+
ctx.git.checkout_branch(repo.root, branch_name)
|
|
343
|
+
|
|
344
|
+
# Get plan content and create .worker-impl/ folder
|
|
345
|
+
user_output("Fetching plan content...")
|
|
346
|
+
plan = ctx.plan_store.get_plan(repo.root, str(issue_number))
|
|
347
|
+
|
|
348
|
+
user_output("Creating .worker-impl/ folder...")
|
|
349
|
+
create_worker_impl_folder(
|
|
350
|
+
plan_content=plan.body,
|
|
351
|
+
issue_number=issue_number,
|
|
352
|
+
issue_url=issue.url,
|
|
353
|
+
repo_root=repo.root,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Stage, commit, and push
|
|
357
|
+
ctx.git.stage_files(repo.root, [".worker-impl"])
|
|
358
|
+
ctx.git.commit(repo.root, f"Add plan for issue #{issue_number}")
|
|
359
|
+
ctx.git.push_to_remote(repo.root, "origin", branch_name, set_upstream=True)
|
|
360
|
+
user_output(click.style("✓", fg="green") + " Branch pushed to remote")
|
|
361
|
+
|
|
362
|
+
# Create draft PR
|
|
363
|
+
# IMPORTANT: "Closes owner/repo#N" (cross-repo) or "Closes #N" (same-repo)
|
|
364
|
+
# MUST be in the initial body passed to create_pr(), NOT added via update.
|
|
365
|
+
# GitHub's willCloseTarget API field is set at PR creation time and is NOT
|
|
366
|
+
# updated when the body is edited afterward.
|
|
367
|
+
user_output("Creating draft PR...")
|
|
368
|
+
plans_repo = ctx.local_config.plans_repo if ctx.local_config else None
|
|
369
|
+
issue_ref = _format_issue_ref(issue_number, plans_repo)
|
|
370
|
+
pr_body = (
|
|
371
|
+
f"**Author:** @{submitted_by}\n"
|
|
372
|
+
f"**Plan:** {issue_ref}\n\n"
|
|
373
|
+
f"**Status:** Queued for implementation\n\n"
|
|
374
|
+
f"This PR will be marked ready for review after implementation completes.\n\n"
|
|
375
|
+
f"---\n\n"
|
|
376
|
+
f"Closes {issue_ref}"
|
|
377
|
+
)
|
|
378
|
+
pr_title = _strip_plan_markers(issue.title)
|
|
379
|
+
pr_number = ctx.github.create_pr(
|
|
380
|
+
repo_root=repo.root,
|
|
381
|
+
branch=branch_name,
|
|
382
|
+
title=pr_title,
|
|
383
|
+
body=pr_body,
|
|
384
|
+
base=base_branch,
|
|
385
|
+
draft=True,
|
|
386
|
+
)
|
|
387
|
+
user_output(click.style("✓", fg="green") + f" Draft PR #{pr_number} created")
|
|
388
|
+
|
|
389
|
+
# Update PR body with checkout command footer
|
|
390
|
+
footer = build_pr_body_footer(
|
|
391
|
+
pr_number=pr_number, issue_number=issue_number, plans_repo=plans_repo
|
|
392
|
+
)
|
|
393
|
+
ctx.github.update_pr_body(repo.root, pr_number, pr_body + footer)
|
|
394
|
+
|
|
395
|
+
# Add extraction skip label if this is an extraction plan
|
|
396
|
+
if validated.is_extraction_origin:
|
|
397
|
+
ctx.github.add_label_to_pr(repo.root, pr_number, ERK_SKIP_EXTRACTION_LABEL)
|
|
398
|
+
|
|
399
|
+
# Close any orphaned draft PRs for this issue
|
|
400
|
+
closed_prs = _close_orphaned_draft_prs(ctx, repo.root, issue_number, pr_number)
|
|
401
|
+
if closed_prs:
|
|
402
|
+
user_output(
|
|
403
|
+
click.style("✓", fg="green")
|
|
404
|
+
+ f" Closed {len(closed_prs)} orphaned draft PR(s): "
|
|
405
|
+
+ ", ".join(f"#{n}" for n in closed_prs)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Restore local state
|
|
409
|
+
user_output("Restoring local state...")
|
|
410
|
+
ctx.git.checkout_branch(repo.root, original_branch)
|
|
411
|
+
ctx.git.delete_branch(repo.root, branch_name, force=True)
|
|
412
|
+
user_output(click.style("✓", fg="green") + " Local branch cleaned up")
|
|
413
|
+
|
|
414
|
+
return pr_number
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _submit_single_issue(
|
|
418
|
+
ctx: ErkContext,
|
|
419
|
+
repo: RepoContext,
|
|
420
|
+
validated: ValidatedIssue,
|
|
421
|
+
submitted_by: str,
|
|
422
|
+
original_branch: str,
|
|
423
|
+
base_branch: str,
|
|
424
|
+
) -> SubmitResult:
|
|
425
|
+
"""Submit a single validated issue for implementation.
|
|
426
|
+
|
|
427
|
+
Creates branch/PR if needed and triggers workflow.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
ctx: ErkContext with git operations
|
|
431
|
+
repo: Repository context
|
|
432
|
+
validated: Validated issue information
|
|
433
|
+
submitted_by: GitHub username of submitter
|
|
434
|
+
original_branch: Original branch name (to restore after)
|
|
435
|
+
base_branch: Base branch for PR (trunk or custom feature branch)
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
SubmitResult with URLs and identifiers.
|
|
439
|
+
"""
|
|
440
|
+
issue = validated.issue
|
|
441
|
+
issue_number = validated.number
|
|
442
|
+
branch_name = validated.branch_name
|
|
443
|
+
branch_exists = validated.branch_exists
|
|
444
|
+
pr_number = validated.pr_number
|
|
445
|
+
|
|
446
|
+
if branch_exists:
|
|
447
|
+
if pr_number is not None:
|
|
448
|
+
user_output(
|
|
449
|
+
f"PR #{pr_number} already exists for branch '{branch_name}' (state: existing)"
|
|
450
|
+
)
|
|
451
|
+
user_output("Skipping branch/PR creation, triggering workflow...")
|
|
452
|
+
else:
|
|
453
|
+
# Branch exists but no PR - need to add a commit for PR creation
|
|
454
|
+
user_output(f"Branch '{branch_name}' exists but no PR. Adding placeholder commit...")
|
|
455
|
+
|
|
456
|
+
# Fetch and checkout the remote branch locally
|
|
457
|
+
ctx.git.fetch_branch(repo.root, "origin", branch_name)
|
|
458
|
+
|
|
459
|
+
# Only create tracking branch if it doesn't exist locally (LBYL)
|
|
460
|
+
local_branches = ctx.git.list_local_branches(repo.root)
|
|
461
|
+
if branch_name not in local_branches:
|
|
462
|
+
ctx.git.create_tracking_branch(repo.root, branch_name, f"origin/{branch_name}")
|
|
463
|
+
|
|
464
|
+
ctx.git.checkout_branch(repo.root, branch_name)
|
|
465
|
+
|
|
466
|
+
# Create empty commit as placeholder for PR creation
|
|
467
|
+
ctx.git.commit(
|
|
468
|
+
repo.root,
|
|
469
|
+
f"[erk-plan] Initialize implementation for issue #{issue_number}",
|
|
470
|
+
)
|
|
471
|
+
ctx.git.push_to_remote(repo.root, "origin", branch_name)
|
|
472
|
+
user_output(click.style("✓", fg="green") + " Placeholder commit pushed")
|
|
473
|
+
|
|
474
|
+
# Now create the PR
|
|
475
|
+
# IMPORTANT: "Closes owner/repo#N" (cross-repo) or "Closes #N" (same-repo)
|
|
476
|
+
# MUST be in the initial body passed to create_pr(), NOT added via update.
|
|
477
|
+
# GitHub's willCloseTarget API field is set at PR creation time and is NOT
|
|
478
|
+
# updated when the body is edited afterward.
|
|
479
|
+
plans_repo = ctx.local_config.plans_repo if ctx.local_config else None
|
|
480
|
+
issue_ref = _format_issue_ref(issue_number, plans_repo)
|
|
481
|
+
pr_body = (
|
|
482
|
+
f"**Author:** @{submitted_by}\n"
|
|
483
|
+
f"**Plan:** {issue_ref}\n\n"
|
|
484
|
+
f"**Status:** Queued for implementation\n\n"
|
|
485
|
+
f"This PR will be marked ready for review after implementation completes.\n\n"
|
|
486
|
+
f"---\n\n"
|
|
487
|
+
f"Closes {issue_ref}"
|
|
488
|
+
)
|
|
489
|
+
pr_title = _strip_plan_markers(issue.title)
|
|
490
|
+
pr_number = ctx.github.create_pr(
|
|
491
|
+
repo_root=repo.root,
|
|
492
|
+
branch=branch_name,
|
|
493
|
+
title=pr_title,
|
|
494
|
+
body=pr_body,
|
|
495
|
+
base=base_branch,
|
|
496
|
+
draft=True,
|
|
497
|
+
)
|
|
498
|
+
user_output(click.style("✓", fg="green") + f" Draft PR #{pr_number} created")
|
|
499
|
+
|
|
500
|
+
# Update PR body with checkout command footer
|
|
501
|
+
footer = build_pr_body_footer(
|
|
502
|
+
pr_number=pr_number, issue_number=issue_number, plans_repo=plans_repo
|
|
503
|
+
)
|
|
504
|
+
ctx.github.update_pr_body(repo.root, pr_number, pr_body + footer)
|
|
505
|
+
|
|
506
|
+
# Add extraction skip label if this is an extraction plan
|
|
507
|
+
if validated.is_extraction_origin:
|
|
508
|
+
ctx.github.add_label_to_pr(repo.root, pr_number, ERK_SKIP_EXTRACTION_LABEL)
|
|
509
|
+
|
|
510
|
+
# Close any orphaned draft PRs
|
|
511
|
+
closed_prs = _close_orphaned_draft_prs(ctx, repo.root, issue_number, pr_number)
|
|
512
|
+
if closed_prs:
|
|
513
|
+
user_output(
|
|
514
|
+
click.style("✓", fg="green")
|
|
515
|
+
+ f" Closed {len(closed_prs)} orphaned draft PR(s): "
|
|
516
|
+
+ ", ".join(f"#{n}" for n in closed_prs)
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
# Restore local state
|
|
520
|
+
ctx.git.checkout_branch(repo.root, original_branch)
|
|
521
|
+
ctx.git.delete_branch(repo.root, branch_name, force=True)
|
|
522
|
+
user_output(click.style("✓", fg="green") + " Local branch cleaned up")
|
|
523
|
+
else:
|
|
524
|
+
# Create branch and initial commit
|
|
525
|
+
user_output(f"Creating branch from origin/{base_branch}...")
|
|
526
|
+
|
|
527
|
+
# Fetch base branch
|
|
528
|
+
ctx.git.fetch_branch(repo.root, "origin", base_branch)
|
|
529
|
+
|
|
530
|
+
# Create and checkout new branch from base
|
|
531
|
+
ctx.git.create_branch(repo.root, branch_name, f"origin/{base_branch}")
|
|
532
|
+
user_output(f"Created branch: {click.style(branch_name, fg='cyan')}")
|
|
533
|
+
|
|
534
|
+
# Use context manager to restore original branch on failure
|
|
535
|
+
with branch_rollback(ctx, repo.root, original_branch):
|
|
536
|
+
pr_number = _create_branch_and_pr(
|
|
537
|
+
ctx=ctx,
|
|
538
|
+
repo=repo,
|
|
539
|
+
validated=validated,
|
|
540
|
+
branch_name=branch_name,
|
|
541
|
+
base_branch=base_branch,
|
|
542
|
+
submitted_by=submitted_by,
|
|
543
|
+
original_branch=original_branch,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# Gather submission metadata
|
|
547
|
+
queued_at = datetime.now(UTC).isoformat()
|
|
548
|
+
|
|
549
|
+
# Validate pr_number is set before workflow dispatch
|
|
550
|
+
if pr_number is None:
|
|
551
|
+
user_output(
|
|
552
|
+
click.style("Error: ", fg="red")
|
|
553
|
+
+ "Failed to create or find PR. Cannot trigger workflow."
|
|
554
|
+
)
|
|
555
|
+
raise SystemExit(1)
|
|
556
|
+
|
|
557
|
+
# Load workflow-specific config
|
|
558
|
+
workflow_config = load_workflow_config(repo.root, DISPATCH_WORKFLOW_NAME)
|
|
559
|
+
|
|
560
|
+
# Trigger workflow via direct dispatch
|
|
561
|
+
user_output("")
|
|
562
|
+
user_output(f"Triggering workflow: {click.style(DISPATCH_WORKFLOW_NAME, fg='cyan')}")
|
|
563
|
+
user_output(f" Display name: {DISPATCH_WORKFLOW_METADATA_NAME}")
|
|
564
|
+
|
|
565
|
+
# Build inputs dict, merging workflow config
|
|
566
|
+
inputs = {
|
|
567
|
+
# Required inputs (always passed)
|
|
568
|
+
"issue_number": str(issue_number),
|
|
569
|
+
"submitted_by": submitted_by,
|
|
570
|
+
"issue_title": issue.title,
|
|
571
|
+
"branch_name": branch_name,
|
|
572
|
+
"pr_number": str(pr_number),
|
|
573
|
+
# Config-based inputs (from .erk/workflows/)
|
|
574
|
+
**workflow_config,
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
run_id = ctx.github.trigger_workflow(
|
|
578
|
+
repo_root=repo.root,
|
|
579
|
+
workflow=DISPATCH_WORKFLOW_NAME,
|
|
580
|
+
inputs=inputs,
|
|
581
|
+
)
|
|
582
|
+
user_output(click.style("✓", fg="green") + " Workflow triggered.")
|
|
583
|
+
|
|
584
|
+
# Write dispatch metadata synchronously to fix race condition with erk dash
|
|
585
|
+
# This ensures the issue body has the run info before we return to the user
|
|
586
|
+
node_id = ctx.github.get_workflow_run_node_id(repo.root, run_id)
|
|
587
|
+
if node_id is not None:
|
|
588
|
+
try:
|
|
589
|
+
# Fetch fresh issue body and update dispatch metadata
|
|
590
|
+
fresh_issue = ctx.issues.get_issue(repo.root, issue_number)
|
|
591
|
+
updated_body = update_plan_header_dispatch(
|
|
592
|
+
issue_body=fresh_issue.body,
|
|
593
|
+
run_id=run_id,
|
|
594
|
+
node_id=node_id,
|
|
595
|
+
dispatched_at=queued_at,
|
|
596
|
+
)
|
|
597
|
+
ctx.issues.update_issue_body(repo.root, issue_number, updated_body)
|
|
598
|
+
user_output(click.style("✓", fg="green") + " Dispatch metadata written to issue")
|
|
599
|
+
except Exception as e:
|
|
600
|
+
# Log warning but don't block - workflow is already triggered
|
|
601
|
+
user_output(
|
|
602
|
+
click.style("Warning: ", fg="yellow") + f"Failed to update dispatch metadata: {e}"
|
|
603
|
+
)
|
|
604
|
+
else:
|
|
605
|
+
user_output(click.style("Warning: ", fg="yellow") + "Could not fetch workflow run node_id")
|
|
606
|
+
|
|
607
|
+
validation_results = {
|
|
608
|
+
"issue_is_open": True,
|
|
609
|
+
"has_erk_plan_label": True,
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
# Create and post queued event comment
|
|
613
|
+
workflow_url = _build_workflow_run_url(issue.url, run_id)
|
|
614
|
+
try:
|
|
615
|
+
metadata_block = create_submission_queued_block(
|
|
616
|
+
queued_at=queued_at,
|
|
617
|
+
submitted_by=submitted_by,
|
|
618
|
+
issue_number=issue_number,
|
|
619
|
+
validation_results=validation_results,
|
|
620
|
+
expected_workflow=DISPATCH_WORKFLOW_METADATA_NAME,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
comment_body = render_erk_issue_event(
|
|
624
|
+
title="🔄 Issue Queued for Implementation",
|
|
625
|
+
metadata=metadata_block,
|
|
626
|
+
description=(
|
|
627
|
+
f"Issue submitted by **{submitted_by}** at {queued_at}.\n\n"
|
|
628
|
+
f"The `{DISPATCH_WORKFLOW_METADATA_NAME}` workflow has been "
|
|
629
|
+
f"triggered via direct dispatch.\n\n"
|
|
630
|
+
f"**Workflow run:** {workflow_url}\n\n"
|
|
631
|
+
f"Branch and draft PR were created locally for correct commit attribution."
|
|
632
|
+
),
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
user_output("Posting queued event comment...")
|
|
636
|
+
ctx.issues.add_comment(repo.root, issue_number, comment_body)
|
|
637
|
+
user_output(click.style("✓", fg="green") + " Queued event comment posted")
|
|
638
|
+
except Exception as e:
|
|
639
|
+
# Log warning but don't block - workflow is already triggered
|
|
640
|
+
user_output(
|
|
641
|
+
click.style("Warning: ", fg="yellow")
|
|
642
|
+
+ f"Failed to post queued comment: {e}\n"
|
|
643
|
+
+ "Workflow is already running."
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
pr_url = _build_pr_url(issue.url, pr_number) if pr_number else None
|
|
647
|
+
|
|
648
|
+
return SubmitResult(
|
|
649
|
+
issue_number=issue_number,
|
|
650
|
+
issue_title=issue.title,
|
|
651
|
+
issue_url=issue.url,
|
|
652
|
+
pr_number=pr_number,
|
|
653
|
+
pr_url=pr_url,
|
|
654
|
+
workflow_run_id=run_id,
|
|
655
|
+
workflow_url=workflow_url,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@click.command("submit")
|
|
660
|
+
@click.argument("issue_numbers", type=int, nargs=-1, required=True)
|
|
661
|
+
@click.option(
|
|
662
|
+
"--base",
|
|
663
|
+
type=str,
|
|
664
|
+
default=None,
|
|
665
|
+
help="Base branch for PR (defaults to current branch).",
|
|
666
|
+
)
|
|
667
|
+
@click.pass_obj
|
|
668
|
+
def submit_cmd(ctx: ErkContext, issue_numbers: tuple[int, ...], base: str | None) -> None:
|
|
669
|
+
"""Submit issues for remote AI implementation via GitHub Actions.
|
|
670
|
+
|
|
671
|
+
Creates branch and draft PR locally (for correct commit attribution),
|
|
672
|
+
then triggers the dispatch-erk-queue.yml GitHub Actions workflow.
|
|
673
|
+
|
|
674
|
+
Arguments:
|
|
675
|
+
ISSUE_NUMBERS: One or more GitHub issue numbers to submit
|
|
676
|
+
|
|
677
|
+
Example:
|
|
678
|
+
erk submit 123
|
|
679
|
+
erk submit 123 456 789
|
|
680
|
+
erk submit 123 --base master
|
|
681
|
+
|
|
682
|
+
Requires:
|
|
683
|
+
- All issues must have erk-plan label
|
|
684
|
+
- All issues must be OPEN
|
|
685
|
+
- Working directory must be clean (no uncommitted changes)
|
|
686
|
+
"""
|
|
687
|
+
# Validate GitHub CLI prerequisites upfront (LBYL)
|
|
688
|
+
Ensure.gh_authenticated(ctx)
|
|
689
|
+
|
|
690
|
+
# Get repository context
|
|
691
|
+
if isinstance(ctx.repo, RepoContext):
|
|
692
|
+
repo = ctx.repo
|
|
693
|
+
else:
|
|
694
|
+
repo = discover_repo_context(ctx, ctx.cwd)
|
|
695
|
+
|
|
696
|
+
# Save current state (needed for both default base and restoration)
|
|
697
|
+
original_branch = ctx.git.get_current_branch(repo.root)
|
|
698
|
+
if original_branch is None:
|
|
699
|
+
user_output(
|
|
700
|
+
click.style("Error: ", fg="red")
|
|
701
|
+
+ "Not on a branch (detached HEAD state). Cannot submit from here."
|
|
702
|
+
)
|
|
703
|
+
raise SystemExit(1)
|
|
704
|
+
|
|
705
|
+
# Validate base branch if provided, otherwise default to current branch (LBYL)
|
|
706
|
+
if base is not None:
|
|
707
|
+
if not ctx.git.branch_exists_on_remote(repo.root, "origin", base):
|
|
708
|
+
user_output(
|
|
709
|
+
click.style("Error: ", fg="red") + f"Base branch '{base}' does not exist on remote"
|
|
710
|
+
)
|
|
711
|
+
raise SystemExit(1)
|
|
712
|
+
target_branch = base
|
|
713
|
+
else:
|
|
714
|
+
# If on a placeholder branch (local-only), use trunk as base
|
|
715
|
+
if is_placeholder_branch(original_branch):
|
|
716
|
+
target_branch = ctx.git.detect_trunk_branch(repo.root)
|
|
717
|
+
elif not ctx.git.branch_exists_on_remote(repo.root, "origin", original_branch):
|
|
718
|
+
# Current branch not pushed to remote - fall back to trunk
|
|
719
|
+
target_branch = ctx.git.detect_trunk_branch(repo.root)
|
|
720
|
+
else:
|
|
721
|
+
target_branch = original_branch
|
|
722
|
+
|
|
723
|
+
# Get GitHub username (authentication already validated)
|
|
724
|
+
_, username, _ = ctx.github.check_auth_status()
|
|
725
|
+
submitted_by = username or "unknown"
|
|
726
|
+
|
|
727
|
+
# Phase 1: Validate ALL issues upfront (atomic - fail fast before any side effects)
|
|
728
|
+
user_output(f"Validating {len(issue_numbers)} issue(s)...")
|
|
729
|
+
user_output("")
|
|
730
|
+
|
|
731
|
+
validated: list[ValidatedIssue] = []
|
|
732
|
+
for issue_number in issue_numbers:
|
|
733
|
+
user_output(f"Validating issue #{issue_number}...")
|
|
734
|
+
validated_issue = _validate_issue_for_submit(ctx, repo, issue_number, target_branch)
|
|
735
|
+
validated.append(validated_issue)
|
|
736
|
+
|
|
737
|
+
user_output("")
|
|
738
|
+
user_output(click.style("✓", fg="green") + f" All {len(validated)} issue(s) validated")
|
|
739
|
+
user_output("")
|
|
740
|
+
|
|
741
|
+
# Display validated issues
|
|
742
|
+
for v in validated:
|
|
743
|
+
user_output(f" #{v.number}: {click.style(v.issue.title, fg='yellow')}")
|
|
744
|
+
user_output("")
|
|
745
|
+
|
|
746
|
+
# Phase 2: Submit all validated issues
|
|
747
|
+
results: list[SubmitResult] = []
|
|
748
|
+
for i, v in enumerate(validated):
|
|
749
|
+
if len(validated) > 1:
|
|
750
|
+
user_output(f"--- Submitting issue {i + 1}/{len(validated)}: #{v.number} ---")
|
|
751
|
+
else:
|
|
752
|
+
user_output(f"Submitting issue #{v.number}...")
|
|
753
|
+
user_output("")
|
|
754
|
+
result = _submit_single_issue(ctx, repo, v, submitted_by, original_branch, target_branch)
|
|
755
|
+
results.append(result)
|
|
756
|
+
user_output("")
|
|
757
|
+
|
|
758
|
+
# Success output
|
|
759
|
+
user_output("")
|
|
760
|
+
user_output(click.style("✓", fg="green") + f" {len(results)} issue(s) submitted successfully!")
|
|
761
|
+
user_output("")
|
|
762
|
+
user_output("Submitted issues:")
|
|
763
|
+
for r in results:
|
|
764
|
+
user_output(f" • #{r.issue_number}: {r.issue_title}")
|
|
765
|
+
user_output(f" Issue: {r.issue_url}")
|
|
766
|
+
if r.pr_url:
|
|
767
|
+
user_output(f" PR: {r.pr_url}")
|
|
768
|
+
user_output(f" Workflow: {r.workflow_url}")
|