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.
Files changed (331) hide show
  1. erk/__init__.py +12 -0
  2. erk/__main__.py +6 -0
  3. erk/agent_docs/__init__.py +5 -0
  4. erk/agent_docs/models.py +123 -0
  5. erk/agent_docs/operations.py +666 -0
  6. erk/artifacts/__init__.py +5 -0
  7. erk/artifacts/artifact_health.py +623 -0
  8. erk/artifacts/detection.py +16 -0
  9. erk/artifacts/discovery.py +343 -0
  10. erk/artifacts/models.py +63 -0
  11. erk/artifacts/staleness.py +56 -0
  12. erk/artifacts/state.py +100 -0
  13. erk/artifacts/sync.py +624 -0
  14. erk/cli/__init__.py +0 -0
  15. erk/cli/activation.py +132 -0
  16. erk/cli/alias.py +53 -0
  17. erk/cli/cli.py +221 -0
  18. erk/cli/commands/__init__.py +0 -0
  19. erk/cli/commands/admin.py +153 -0
  20. erk/cli/commands/artifact/__init__.py +1 -0
  21. erk/cli/commands/artifact/check.py +260 -0
  22. erk/cli/commands/artifact/group.py +31 -0
  23. erk/cli/commands/artifact/list_cmd.py +89 -0
  24. erk/cli/commands/artifact/show.py +62 -0
  25. erk/cli/commands/artifact/sync_cmd.py +39 -0
  26. erk/cli/commands/branch/__init__.py +26 -0
  27. erk/cli/commands/branch/assign_cmd.py +152 -0
  28. erk/cli/commands/branch/checkout_cmd.py +357 -0
  29. erk/cli/commands/branch/create_cmd.py +161 -0
  30. erk/cli/commands/branch/list_cmd.py +82 -0
  31. erk/cli/commands/branch/unassign_cmd.py +197 -0
  32. erk/cli/commands/cc/__init__.py +15 -0
  33. erk/cli/commands/cc/jsonl_cmd.py +20 -0
  34. erk/cli/commands/cc/session/AGENTS.md +30 -0
  35. erk/cli/commands/cc/session/CLAUDE.md +1 -0
  36. erk/cli/commands/cc/session/__init__.py +15 -0
  37. erk/cli/commands/cc/session/list_cmd.py +167 -0
  38. erk/cli/commands/cc/session/show_cmd.py +175 -0
  39. erk/cli/commands/completion.py +89 -0
  40. erk/cli/commands/completions.py +165 -0
  41. erk/cli/commands/config.py +327 -0
  42. erk/cli/commands/docs/__init__.py +1 -0
  43. erk/cli/commands/docs/group.py +16 -0
  44. erk/cli/commands/docs/sync.py +121 -0
  45. erk/cli/commands/docs/validate.py +102 -0
  46. erk/cli/commands/doctor.py +243 -0
  47. erk/cli/commands/down.py +171 -0
  48. erk/cli/commands/exec/__init__.py +1 -0
  49. erk/cli/commands/exec/group.py +164 -0
  50. erk/cli/commands/exec/scripts/AGENTS.md +79 -0
  51. erk/cli/commands/exec/scripts/CLAUDE.md +1 -0
  52. erk/cli/commands/exec/scripts/__init__.py +5 -0
  53. erk/cli/commands/exec/scripts/add_reaction_to_comment.py +69 -0
  54. erk/cli/commands/exec/scripts/add_remote_execution_note.py +68 -0
  55. erk/cli/commands/exec/scripts/check_impl.py +152 -0
  56. erk/cli/commands/exec/scripts/ci_update_pr_body.py +294 -0
  57. erk/cli/commands/exec/scripts/create_extraction_branch.py +138 -0
  58. erk/cli/commands/exec/scripts/create_extraction_plan.py +242 -0
  59. erk/cli/commands/exec/scripts/create_issue_from_session.py +103 -0
  60. erk/cli/commands/exec/scripts/create_plan_from_context.py +103 -0
  61. erk/cli/commands/exec/scripts/create_worker_impl_from_issue.py +93 -0
  62. erk/cli/commands/exec/scripts/detect_trunk_branch.py +121 -0
  63. erk/cli/commands/exec/scripts/exit_plan_mode_hook.py +777 -0
  64. erk/cli/commands/exec/scripts/extract_latest_plan.py +49 -0
  65. erk/cli/commands/exec/scripts/extract_session_from_issue.py +150 -0
  66. erk/cli/commands/exec/scripts/find_project_dir.py +214 -0
  67. erk/cli/commands/exec/scripts/generate_pr_summary.py +112 -0
  68. erk/cli/commands/exec/scripts/get_closing_text.py +98 -0
  69. erk/cli/commands/exec/scripts/get_embedded_prompt.py +62 -0
  70. erk/cli/commands/exec/scripts/get_plan_metadata.py +95 -0
  71. erk/cli/commands/exec/scripts/get_pr_body_footer.py +70 -0
  72. erk/cli/commands/exec/scripts/get_pr_discussion_comments.py +149 -0
  73. erk/cli/commands/exec/scripts/get_pr_review_comments.py +155 -0
  74. erk/cli/commands/exec/scripts/impl_init.py +158 -0
  75. erk/cli/commands/exec/scripts/impl_signal.py +375 -0
  76. erk/cli/commands/exec/scripts/impl_verify.py +49 -0
  77. erk/cli/commands/exec/scripts/issue_title_to_filename.py +34 -0
  78. erk/cli/commands/exec/scripts/list_sessions.py +296 -0
  79. erk/cli/commands/exec/scripts/mark_impl_ended.py +188 -0
  80. erk/cli/commands/exec/scripts/mark_impl_started.py +188 -0
  81. erk/cli/commands/exec/scripts/marker.py +163 -0
  82. erk/cli/commands/exec/scripts/objective_save_to_issue.py +109 -0
  83. erk/cli/commands/exec/scripts/plan_save_to_issue.py +269 -0
  84. erk/cli/commands/exec/scripts/plan_update_issue.py +147 -0
  85. erk/cli/commands/exec/scripts/post_extraction_comment.py +237 -0
  86. erk/cli/commands/exec/scripts/post_or_update_pr_summary.py +133 -0
  87. erk/cli/commands/exec/scripts/post_pr_inline_comment.py +143 -0
  88. erk/cli/commands/exec/scripts/post_workflow_started_comment.py +168 -0
  89. erk/cli/commands/exec/scripts/preprocess_session.py +777 -0
  90. erk/cli/commands/exec/scripts/quick_submit.py +32 -0
  91. erk/cli/commands/exec/scripts/rebase_with_conflict_resolution.py +260 -0
  92. erk/cli/commands/exec/scripts/reply_to_discussion_comment.py +173 -0
  93. erk/cli/commands/exec/scripts/resolve_review_thread.py +170 -0
  94. erk/cli/commands/exec/scripts/session_id_injector_hook.py +52 -0
  95. erk/cli/commands/exec/scripts/setup_impl_from_issue.py +159 -0
  96. erk/cli/commands/exec/scripts/slot_objective.py +102 -0
  97. erk/cli/commands/exec/scripts/tripwires_reminder_hook.py +20 -0
  98. erk/cli/commands/exec/scripts/update_dispatch_info.py +116 -0
  99. erk/cli/commands/exec/scripts/user_prompt_hook.py +113 -0
  100. erk/cli/commands/exec/scripts/validate_plan_content.py +98 -0
  101. erk/cli/commands/exec/scripts/wrap_plan_in_metadata_block.py +34 -0
  102. erk/cli/commands/implement.py +695 -0
  103. erk/cli/commands/implement_shared.py +649 -0
  104. erk/cli/commands/info/__init__.py +14 -0
  105. erk/cli/commands/info/release_notes_cmd.py +128 -0
  106. erk/cli/commands/init.py +801 -0
  107. erk/cli/commands/land_cmd.py +690 -0
  108. erk/cli/commands/log_cmd.py +137 -0
  109. erk/cli/commands/md/__init__.py +5 -0
  110. erk/cli/commands/md/check.py +118 -0
  111. erk/cli/commands/md/group.py +14 -0
  112. erk/cli/commands/navigation_helpers.py +430 -0
  113. erk/cli/commands/objective/__init__.py +16 -0
  114. erk/cli/commands/objective/list_cmd.py +47 -0
  115. erk/cli/commands/objective_helpers.py +132 -0
  116. erk/cli/commands/plan/__init__.py +32 -0
  117. erk/cli/commands/plan/check_cmd.py +174 -0
  118. erk/cli/commands/plan/close_cmd.py +69 -0
  119. erk/cli/commands/plan/create_cmd.py +120 -0
  120. erk/cli/commands/plan/docs/__init__.py +18 -0
  121. erk/cli/commands/plan/docs/extract_cmd.py +53 -0
  122. erk/cli/commands/plan/docs/unextract_cmd.py +38 -0
  123. erk/cli/commands/plan/docs/unextracted_cmd.py +72 -0
  124. erk/cli/commands/plan/extraction/__init__.py +16 -0
  125. erk/cli/commands/plan/extraction/complete_cmd.py +101 -0
  126. erk/cli/commands/plan/extraction/create_raw_cmd.py +63 -0
  127. erk/cli/commands/plan/get.py +71 -0
  128. erk/cli/commands/plan/list_cmd.py +754 -0
  129. erk/cli/commands/plan/log_cmd.py +440 -0
  130. erk/cli/commands/plan/start_cmd.py +459 -0
  131. erk/cli/commands/planner/__init__.py +40 -0
  132. erk/cli/commands/planner/configure_cmd.py +73 -0
  133. erk/cli/commands/planner/connect_cmd.py +96 -0
  134. erk/cli/commands/planner/create_cmd.py +148 -0
  135. erk/cli/commands/planner/list_cmd.py +51 -0
  136. erk/cli/commands/planner/register_cmd.py +105 -0
  137. erk/cli/commands/planner/set_default_cmd.py +23 -0
  138. erk/cli/commands/planner/unregister_cmd.py +43 -0
  139. erk/cli/commands/pr/__init__.py +23 -0
  140. erk/cli/commands/pr/check_cmd.py +112 -0
  141. erk/cli/commands/pr/checkout_cmd.py +165 -0
  142. erk/cli/commands/pr/fix_conflicts_cmd.py +82 -0
  143. erk/cli/commands/pr/parse_pr_reference.py +10 -0
  144. erk/cli/commands/pr/submit_cmd.py +360 -0
  145. erk/cli/commands/pr/sync_cmd.py +181 -0
  146. erk/cli/commands/prepare_cwd_recovery.py +60 -0
  147. erk/cli/commands/project/__init__.py +16 -0
  148. erk/cli/commands/project/init_cmd.py +91 -0
  149. erk/cli/commands/run/__init__.py +17 -0
  150. erk/cli/commands/run/list_cmd.py +189 -0
  151. erk/cli/commands/run/logs_cmd.py +54 -0
  152. erk/cli/commands/run/shared.py +19 -0
  153. erk/cli/commands/shell_integration.py +29 -0
  154. erk/cli/commands/slot/__init__.py +23 -0
  155. erk/cli/commands/slot/check_cmd.py +277 -0
  156. erk/cli/commands/slot/common.py +314 -0
  157. erk/cli/commands/slot/init_pool_cmd.py +157 -0
  158. erk/cli/commands/slot/list_cmd.py +228 -0
  159. erk/cli/commands/slot/repair_cmd.py +190 -0
  160. erk/cli/commands/stack/__init__.py +23 -0
  161. erk/cli/commands/stack/consolidate_cmd.py +470 -0
  162. erk/cli/commands/stack/list_cmd.py +79 -0
  163. erk/cli/commands/stack/move_cmd.py +309 -0
  164. erk/cli/commands/stack/split_old/README.md +64 -0
  165. erk/cli/commands/stack/split_old/__init__.py +5 -0
  166. erk/cli/commands/stack/split_old/command.py +233 -0
  167. erk/cli/commands/stack/split_old/display.py +116 -0
  168. erk/cli/commands/stack/split_old/plan.py +216 -0
  169. erk/cli/commands/status.py +58 -0
  170. erk/cli/commands/submit.py +768 -0
  171. erk/cli/commands/up.py +154 -0
  172. erk/cli/commands/upgrade.py +82 -0
  173. erk/cli/commands/wt/__init__.py +29 -0
  174. erk/cli/commands/wt/checkout_cmd.py +110 -0
  175. erk/cli/commands/wt/create_cmd.py +998 -0
  176. erk/cli/commands/wt/current_cmd.py +35 -0
  177. erk/cli/commands/wt/delete_cmd.py +573 -0
  178. erk/cli/commands/wt/list_cmd.py +332 -0
  179. erk/cli/commands/wt/rename_cmd.py +66 -0
  180. erk/cli/config.py +242 -0
  181. erk/cli/constants.py +29 -0
  182. erk/cli/core.py +65 -0
  183. erk/cli/debug.py +9 -0
  184. erk/cli/ensure-conversion-tasks.md +288 -0
  185. erk/cli/ensure.py +628 -0
  186. erk/cli/github_parsing.py +96 -0
  187. erk/cli/graphite.py +81 -0
  188. erk/cli/graphite_command.py +80 -0
  189. erk/cli/help_formatter.py +345 -0
  190. erk/cli/output.py +361 -0
  191. erk/cli/presets/dagster.toml +12 -0
  192. erk/cli/presets/generic.toml +12 -0
  193. erk/cli/prompt_hooks_templates/README.md +68 -0
  194. erk/cli/script_output.py +32 -0
  195. erk/cli/shell_integration/bash_wrapper.sh +32 -0
  196. erk/cli/shell_integration/fish_wrapper.fish +39 -0
  197. erk/cli/shell_integration/handler.py +338 -0
  198. erk/cli/shell_integration/zsh_wrapper.sh +32 -0
  199. erk/cli/shell_utils.py +171 -0
  200. erk/cli/subprocess_utils.py +92 -0
  201. erk/cli/uvx_detection.py +59 -0
  202. erk/core/__init__.py +0 -0
  203. erk/core/claude_executor.py +511 -0
  204. erk/core/claude_settings.py +317 -0
  205. erk/core/command_log.py +406 -0
  206. erk/core/commit_message_generator.py +234 -0
  207. erk/core/completion.py +10 -0
  208. erk/core/consolidation_utils.py +177 -0
  209. erk/core/context.py +570 -0
  210. erk/core/display/__init__.py +4 -0
  211. erk/core/display/abc.py +24 -0
  212. erk/core/display/real.py +30 -0
  213. erk/core/display_utils.py +526 -0
  214. erk/core/file_utils.py +87 -0
  215. erk/core/health_checks.py +1315 -0
  216. erk/core/health_checks_dogfooder/__init__.py +85 -0
  217. erk/core/health_checks_dogfooder/deprecated_dot_agent_config.py +64 -0
  218. erk/core/health_checks_dogfooder/legacy_claude_docs.py +69 -0
  219. erk/core/health_checks_dogfooder/legacy_config_locations.py +122 -0
  220. erk/core/health_checks_dogfooder/legacy_erk_docs_agent.py +61 -0
  221. erk/core/health_checks_dogfooder/legacy_erk_kits_folder.py +60 -0
  222. erk/core/health_checks_dogfooder/legacy_hook_settings.py +104 -0
  223. erk/core/health_checks_dogfooder/legacy_kit_yaml.py +78 -0
  224. erk/core/health_checks_dogfooder/legacy_kits_toml.py +43 -0
  225. erk/core/health_checks_dogfooder/outdated_erk_skill.py +43 -0
  226. erk/core/implementation_queue/__init__.py +1 -0
  227. erk/core/implementation_queue/github/__init__.py +8 -0
  228. erk/core/implementation_queue/github/abc.py +7 -0
  229. erk/core/implementation_queue/github/noop.py +38 -0
  230. erk/core/implementation_queue/github/printing.py +43 -0
  231. erk/core/implementation_queue/github/real.py +119 -0
  232. erk/core/init_utils.py +227 -0
  233. erk/core/output_filter.py +338 -0
  234. erk/core/plan_store/__init__.py +6 -0
  235. erk/core/planner/__init__.py +1 -0
  236. erk/core/planner/registry_abc.py +8 -0
  237. erk/core/planner/registry_fake.py +129 -0
  238. erk/core/planner/registry_real.py +195 -0
  239. erk/core/planner/types.py +7 -0
  240. erk/core/pr_utils.py +30 -0
  241. erk/core/release_notes.py +263 -0
  242. erk/core/repo_discovery.py +126 -0
  243. erk/core/script_writer.py +41 -0
  244. erk/core/services/__init__.py +1 -0
  245. erk/core/services/plan_list_service.py +94 -0
  246. erk/core/shell.py +51 -0
  247. erk/core/user_feedback.py +11 -0
  248. erk/core/version_check.py +55 -0
  249. erk/core/workflow_display.py +75 -0
  250. erk/core/worktree_pool.py +190 -0
  251. erk/core/worktree_utils.py +300 -0
  252. erk/data/CHANGELOG.md +438 -0
  253. erk/data/__init__.py +1 -0
  254. erk/data/claude/agents/devrun.md +180 -0
  255. erk/data/claude/commands/erk/__init__.py +0 -0
  256. erk/data/claude/commands/erk/create-extraction-plan.md +360 -0
  257. erk/data/claude/commands/erk/fix-conflicts.md +25 -0
  258. erk/data/claude/commands/erk/git-pr-push.md +345 -0
  259. erk/data/claude/commands/erk/implement-stacked-plan.md +96 -0
  260. erk/data/claude/commands/erk/land.md +193 -0
  261. erk/data/claude/commands/erk/objective-create.md +370 -0
  262. erk/data/claude/commands/erk/objective-list.md +34 -0
  263. erk/data/claude/commands/erk/objective-next-plan.md +220 -0
  264. erk/data/claude/commands/erk/objective-update-with-landed-pr.md +216 -0
  265. erk/data/claude/commands/erk/plan-implement.md +202 -0
  266. erk/data/claude/commands/erk/plan-save.md +45 -0
  267. erk/data/claude/commands/erk/plan-submit.md +39 -0
  268. erk/data/claude/commands/erk/pr-address.md +367 -0
  269. erk/data/claude/commands/erk/pr-submit.md +58 -0
  270. erk/data/claude/skills/dignified-python/SKILL.md +48 -0
  271. erk/data/claude/skills/dignified-python/cli-patterns.md +155 -0
  272. erk/data/claude/skills/dignified-python/dignified-python-core.md +1190 -0
  273. erk/data/claude/skills/dignified-python/subprocess.md +99 -0
  274. erk/data/claude/skills/dignified-python/versions/python-3.10.md +517 -0
  275. erk/data/claude/skills/dignified-python/versions/python-3.11.md +536 -0
  276. erk/data/claude/skills/dignified-python/versions/python-3.12.md +662 -0
  277. erk/data/claude/skills/dignified-python/versions/python-3.13.md +653 -0
  278. erk/data/claude/skills/erk-diff-analysis/SKILL.md +27 -0
  279. erk/data/claude/skills/erk-diff-analysis/references/commit-message-prompt.md +78 -0
  280. erk/data/claude/skills/learned-docs/SKILL.md +362 -0
  281. erk/data/github/actions/setup-claude-erk/action.yml +11 -0
  282. erk/data/github/prompts/dignified-python-review.md +125 -0
  283. erk/data/github/workflows/dignified-python-review.yml +61 -0
  284. erk/data/github/workflows/erk-impl.yml +251 -0
  285. erk/hooks/__init__.py +1 -0
  286. erk/hooks/decorators.py +319 -0
  287. erk/status/__init__.py +8 -0
  288. erk/status/collectors/__init__.py +9 -0
  289. erk/status/collectors/base.py +52 -0
  290. erk/status/collectors/git.py +76 -0
  291. erk/status/collectors/github.py +81 -0
  292. erk/status/collectors/graphite.py +80 -0
  293. erk/status/collectors/impl.py +145 -0
  294. erk/status/models/__init__.py +4 -0
  295. erk/status/models/status_data.py +404 -0
  296. erk/status/orchestrator.py +169 -0
  297. erk/status/renderers/__init__.py +5 -0
  298. erk/status/renderers/simple.py +322 -0
  299. erk/tui/AGENTS.md +193 -0
  300. erk/tui/CLAUDE.md +1 -0
  301. erk/tui/__init__.py +1 -0
  302. erk/tui/app.py +1404 -0
  303. erk/tui/commands/__init__.py +1 -0
  304. erk/tui/commands/executor.py +66 -0
  305. erk/tui/commands/provider.py +165 -0
  306. erk/tui/commands/real_executor.py +63 -0
  307. erk/tui/commands/registry.py +121 -0
  308. erk/tui/commands/types.py +36 -0
  309. erk/tui/data/__init__.py +1 -0
  310. erk/tui/data/provider.py +492 -0
  311. erk/tui/data/types.py +104 -0
  312. erk/tui/filtering/__init__.py +1 -0
  313. erk/tui/filtering/logic.py +43 -0
  314. erk/tui/filtering/types.py +55 -0
  315. erk/tui/jsonl_viewer/__init__.py +1 -0
  316. erk/tui/jsonl_viewer/app.py +61 -0
  317. erk/tui/jsonl_viewer/models.py +208 -0
  318. erk/tui/jsonl_viewer/widgets.py +204 -0
  319. erk/tui/sorting/__init__.py +6 -0
  320. erk/tui/sorting/logic.py +55 -0
  321. erk/tui/sorting/types.py +68 -0
  322. erk/tui/styles/dash.tcss +95 -0
  323. erk/tui/widgets/__init__.py +1 -0
  324. erk/tui/widgets/command_output.py +112 -0
  325. erk/tui/widgets/plan_table.py +276 -0
  326. erk/tui/widgets/status_bar.py +116 -0
  327. erk-0.4.5.dist-info/METADATA +376 -0
  328. erk-0.4.5.dist-info/RECORD +331 -0
  329. erk-0.4.5.dist-info/WHEEL +4 -0
  330. erk-0.4.5.dist-info/entry_points.txt +2 -0
  331. 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}")