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
erk/core/pr_utils.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Utility functions for PR handling."""
|
|
2
|
+
|
|
3
|
+
from erk_shared.github.types import PullRequestInfo
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def select_display_pr(prs: list[PullRequestInfo]) -> PullRequestInfo | None:
|
|
7
|
+
"""Select PR to display: prefer open, then merged, then closed.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
prs: List of PRs sorted by created_at descending (most recent first)
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
PR to display, or None if no PRs
|
|
14
|
+
"""
|
|
15
|
+
# Check for open PRs (published or draft)
|
|
16
|
+
open_prs = [pr for pr in prs if pr.state in ("OPEN", "DRAFT")]
|
|
17
|
+
if open_prs:
|
|
18
|
+
return open_prs[0] # Most recent open
|
|
19
|
+
|
|
20
|
+
# Fallback to merged PRs
|
|
21
|
+
merged_prs = [pr for pr in prs if pr.state == "MERGED"]
|
|
22
|
+
if merged_prs:
|
|
23
|
+
return merged_prs[0] # Most recent merged
|
|
24
|
+
|
|
25
|
+
# Fallback to closed PRs
|
|
26
|
+
closed_prs = [pr for pr in prs if pr.state == "CLOSED"]
|
|
27
|
+
if closed_prs:
|
|
28
|
+
return closed_prs[0] # Most recent closed
|
|
29
|
+
|
|
30
|
+
return None
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Release notes management for erk.
|
|
2
|
+
|
|
3
|
+
Provides functionality for:
|
|
4
|
+
- Parsing CHANGELOG.md into structured data
|
|
5
|
+
- Detecting version changes since last run
|
|
6
|
+
- Displaying upgrade banners
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib.metadata
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from functools import cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from erk_shared.gateway.erk_installation.abc import ErkInstallation
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ReleaseEntry:
|
|
20
|
+
"""A single release entry from the changelog.
|
|
21
|
+
|
|
22
|
+
Items are stored as tuples of (text, indent_level) where indent_level
|
|
23
|
+
is 0 for top-level bullets, 1 for first nesting level, etc.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
version: str
|
|
27
|
+
date: str | None
|
|
28
|
+
content: str
|
|
29
|
+
items: list[tuple[str, int]] = field(default_factory=list)
|
|
30
|
+
categories: dict[str, list[tuple[str, int]]] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@cache
|
|
34
|
+
def _changelog_path() -> Path | None:
|
|
35
|
+
"""Get the path to CHANGELOG.md.
|
|
36
|
+
|
|
37
|
+
In development, reads from repo root. In installed package, reads from bundled data dir.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Path to CHANGELOG.md if found, None otherwise
|
|
41
|
+
"""
|
|
42
|
+
# Bundled location (installed package via force-include)
|
|
43
|
+
bundled = Path(__file__).parent.parent / "data" / "CHANGELOG.md"
|
|
44
|
+
if bundled.exists():
|
|
45
|
+
return bundled
|
|
46
|
+
|
|
47
|
+
# Development fallback: repo root (3 levels up from src/erk/core/)
|
|
48
|
+
dev_root = Path(__file__).parent.parent.parent.parent / "CHANGELOG.md"
|
|
49
|
+
if dev_root.exists():
|
|
50
|
+
return dev_root
|
|
51
|
+
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_current_version() -> str:
|
|
56
|
+
"""Get the currently installed version of erk.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Version string (e.g., "0.2.1")
|
|
60
|
+
"""
|
|
61
|
+
return importlib.metadata.version("erk")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_version(version: str) -> tuple[int, ...]:
|
|
65
|
+
"""Parse a semantic version string into a tuple of integers.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
version: Version string (e.g., "0.2.4")
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Tuple of integers (e.g., (0, 2, 4))
|
|
72
|
+
"""
|
|
73
|
+
return tuple(int(part) for part in version.split("."))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_upgrade(current: str, last_seen: str) -> bool:
|
|
77
|
+
"""Check if current version is newer than last_seen version.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
current: Current version string
|
|
81
|
+
last_seen: Previously seen version string
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if current is a newer version than last_seen
|
|
85
|
+
"""
|
|
86
|
+
return _parse_version(current) > _parse_version(last_seen)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_last_seen_version(erk_installation: ErkInstallation) -> str | None:
|
|
90
|
+
"""Get the last version the user was notified about.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
erk_installation: ErkInstallation gateway for accessing ~/.erk/
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Version string if tracking file exists, None otherwise
|
|
97
|
+
"""
|
|
98
|
+
return erk_installation.get_last_seen_version()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def update_last_seen_version(erk_installation: ErkInstallation, version: str) -> None:
|
|
102
|
+
"""Update the last seen version tracking file.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
erk_installation: ErkInstallation gateway for accessing ~/.erk/
|
|
106
|
+
version: Version string to record
|
|
107
|
+
"""
|
|
108
|
+
erk_installation.update_last_seen_version(version)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_changelog(content: str) -> list[ReleaseEntry]:
|
|
112
|
+
"""Parse CHANGELOG.md content into structured release entries.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
content: Raw markdown content of CHANGELOG.md
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
List of ReleaseEntry objects, one per version section
|
|
119
|
+
"""
|
|
120
|
+
entries: list[ReleaseEntry] = []
|
|
121
|
+
|
|
122
|
+
# Match "## [0.2.1] - 2025-12-11" or "## [0.2.1] - 2025-12-11 14:30 PT" or "## [Unreleased]"
|
|
123
|
+
version_pattern = re.compile(
|
|
124
|
+
r"^## \[([^\]]+)\](?:\s*-\s*(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2} PT)?))?",
|
|
125
|
+
re.MULTILINE,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
matches = list(version_pattern.finditer(content))
|
|
129
|
+
|
|
130
|
+
for i, match in enumerate(matches):
|
|
131
|
+
version = match.group(1)
|
|
132
|
+
date = match.group(2)
|
|
133
|
+
|
|
134
|
+
# Extract content between this header and the next
|
|
135
|
+
start = match.end()
|
|
136
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(content)
|
|
137
|
+
section_content = content[start:end].strip()
|
|
138
|
+
|
|
139
|
+
# Extract bullet items grouped by category (### Added, ### Changed, etc.)
|
|
140
|
+
# Items are stored as (text, indent_level) tuples to preserve nesting
|
|
141
|
+
items: list[tuple[str, int]] = []
|
|
142
|
+
categories: dict[str, list[tuple[str, int]]] = {}
|
|
143
|
+
current_category: str | None = None
|
|
144
|
+
|
|
145
|
+
for line in section_content.split("\n"):
|
|
146
|
+
# Count leading spaces to detect nesting level
|
|
147
|
+
stripped = line.lstrip()
|
|
148
|
+
leading_spaces = len(line) - len(stripped)
|
|
149
|
+
indent_level = leading_spaces // 2 # 2 spaces = 1 nesting level
|
|
150
|
+
|
|
151
|
+
# Check for category header (### Added, ### Changed, ### Fixed, etc.)
|
|
152
|
+
if stripped.startswith("### "):
|
|
153
|
+
current_category = stripped[4:]
|
|
154
|
+
categories[current_category] = []
|
|
155
|
+
elif stripped.startswith("- "):
|
|
156
|
+
item_text = stripped[2:]
|
|
157
|
+
item_tuple = (item_text, indent_level)
|
|
158
|
+
items.append(item_tuple)
|
|
159
|
+
if current_category is not None:
|
|
160
|
+
categories[current_category].append(item_tuple)
|
|
161
|
+
|
|
162
|
+
entries.append(
|
|
163
|
+
ReleaseEntry(
|
|
164
|
+
version=version,
|
|
165
|
+
date=date,
|
|
166
|
+
content=section_content,
|
|
167
|
+
items=items,
|
|
168
|
+
categories=categories,
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return entries
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_changelog_content() -> str | None:
|
|
176
|
+
"""Read the CHANGELOG.md content.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Changelog content if file exists, None otherwise
|
|
180
|
+
"""
|
|
181
|
+
path = _changelog_path()
|
|
182
|
+
if path is None:
|
|
183
|
+
return None
|
|
184
|
+
return path.read_text(encoding="utf-8")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_releases() -> list[ReleaseEntry]:
|
|
188
|
+
"""Get all release entries from the bundled changelog.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
List of ReleaseEntry objects, empty if changelog not found
|
|
192
|
+
"""
|
|
193
|
+
content = get_changelog_content()
|
|
194
|
+
if content is None:
|
|
195
|
+
return []
|
|
196
|
+
return parse_changelog(content)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_release_for_version(version: str) -> ReleaseEntry | None:
|
|
200
|
+
"""Get the release entry for a specific version.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
version: Version string to look up
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
ReleaseEntry if found, None otherwise
|
|
207
|
+
"""
|
|
208
|
+
releases = get_releases()
|
|
209
|
+
for release in releases:
|
|
210
|
+
if release.version == version:
|
|
211
|
+
return release
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def check_for_version_change(
|
|
216
|
+
erk_installation: ErkInstallation,
|
|
217
|
+
) -> tuple[bool, list[ReleaseEntry]]:
|
|
218
|
+
"""Check if the version has changed since last run.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
erk_installation: ErkInstallation gateway for accessing ~/.erk/
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Tuple of (changed: bool, new_releases: list[ReleaseEntry])
|
|
225
|
+
where new_releases contains all releases newer than last seen
|
|
226
|
+
"""
|
|
227
|
+
current = get_current_version()
|
|
228
|
+
last_seen = get_last_seen_version(erk_installation)
|
|
229
|
+
|
|
230
|
+
# First run - no notification needed, just update tracking
|
|
231
|
+
if last_seen is None:
|
|
232
|
+
update_last_seen_version(erk_installation, current)
|
|
233
|
+
return (False, [])
|
|
234
|
+
|
|
235
|
+
# No change
|
|
236
|
+
if current == last_seen:
|
|
237
|
+
return (False, [])
|
|
238
|
+
|
|
239
|
+
# Only show banner for upgrades, not downgrades
|
|
240
|
+
# This prevents repeated banners when switching between worktrees
|
|
241
|
+
# with different erk versions installed
|
|
242
|
+
if not _is_upgrade(current, last_seen):
|
|
243
|
+
# Don't update tracking on downgrade - keep tracking the max version seen
|
|
244
|
+
# This prevents repeated banners when switching between worktrees
|
|
245
|
+
return (False, [])
|
|
246
|
+
|
|
247
|
+
# Upgrade detected - find all releases between last_seen and current
|
|
248
|
+
releases = get_releases()
|
|
249
|
+
new_releases: list[ReleaseEntry] = []
|
|
250
|
+
|
|
251
|
+
for release in releases:
|
|
252
|
+
# Skip unreleased section
|
|
253
|
+
if release.version == "Unreleased":
|
|
254
|
+
continue
|
|
255
|
+
# Stop at last seen version
|
|
256
|
+
if release.version == last_seen:
|
|
257
|
+
break
|
|
258
|
+
new_releases.append(release)
|
|
259
|
+
|
|
260
|
+
# Update tracking file
|
|
261
|
+
update_last_seen_version(erk_installation, current)
|
|
262
|
+
|
|
263
|
+
return (True, new_releases)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Repository discovery functionality.
|
|
2
|
+
|
|
3
|
+
Discovers git repository information from a given path without requiring
|
|
4
|
+
full ErkContext (enables config loading before context creation).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# Re-export context types from erk_shared for backwards compatibility
|
|
10
|
+
from erk_shared.context.types import NoRepoSentinel as NoRepoSentinel
|
|
11
|
+
from erk_shared.context.types import RepoContext as RepoContext
|
|
12
|
+
from erk_shared.git.abc import Git
|
|
13
|
+
from erk_shared.git.real import RealGit
|
|
14
|
+
from erk_shared.github.parsing import parse_git_remote_url
|
|
15
|
+
from erk_shared.github.types import GitHubRepoId
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def in_erk_repo(repo_root: Path) -> bool:
|
|
19
|
+
"""Check if the given path is inside the erk development repository.
|
|
20
|
+
|
|
21
|
+
This is used internally to detect when erk is running in its own development
|
|
22
|
+
repo, where artifacts are read directly from source rather than installed.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
repo_root: Repository root path to check
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
True if this appears to be the erk development repo
|
|
29
|
+
"""
|
|
30
|
+
return (repo_root / "packages" / "erk-shared").exists()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def discover_repo_or_sentinel(
|
|
34
|
+
cwd: Path, erk_root: Path, git_ops: Git | None = None
|
|
35
|
+
) -> RepoContext | NoRepoSentinel:
|
|
36
|
+
"""Walk up from `cwd` to find a directory containing `.git`.
|
|
37
|
+
|
|
38
|
+
Returns a RepoContext pointing to the repo root and the worktrees directory
|
|
39
|
+
for this repository, or NoRepoSentinel if not inside a git repo.
|
|
40
|
+
|
|
41
|
+
Note: For worktrees, `root` is the worktree directory (where git commands run),
|
|
42
|
+
while `repo_name` is derived from the main repository (for consistent metadata paths).
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
cwd: Current working directory to start search from
|
|
46
|
+
erk_root: Global erks root directory (from config)
|
|
47
|
+
git_ops: Git operations interface (defaults to RealGit)
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
RepoContext if inside a git repository, NoRepoSentinel otherwise
|
|
51
|
+
"""
|
|
52
|
+
ops = git_ops if git_ops is not None else RealGit()
|
|
53
|
+
|
|
54
|
+
if not ops.path_exists(cwd):
|
|
55
|
+
return NoRepoSentinel(message=f"Start path '{cwd}' does not exist")
|
|
56
|
+
|
|
57
|
+
cur = cwd.resolve()
|
|
58
|
+
|
|
59
|
+
# root: the actual working tree root (where git commands should run)
|
|
60
|
+
# main_repo_root: the main repository root (for deriving repo_name and metadata paths)
|
|
61
|
+
root: Path | None = None
|
|
62
|
+
main_repo_root: Path | None = None
|
|
63
|
+
|
|
64
|
+
git_common_dir = ops.get_git_common_dir(cur)
|
|
65
|
+
if git_common_dir is not None:
|
|
66
|
+
# We're in a git repository (possibly a worktree)
|
|
67
|
+
# git_common_dir points to the main repo's .git directory
|
|
68
|
+
main_repo_root = git_common_dir.parent.resolve()
|
|
69
|
+
# Use --show-toplevel to get the actual worktree root
|
|
70
|
+
root = ops.get_repository_root(cur)
|
|
71
|
+
else:
|
|
72
|
+
for parent in [cur, *cur.parents]:
|
|
73
|
+
git_path = parent / ".git"
|
|
74
|
+
if not ops.path_exists(git_path):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
if ops.is_dir(git_path):
|
|
78
|
+
root = parent
|
|
79
|
+
main_repo_root = parent
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
if root is None or main_repo_root is None:
|
|
83
|
+
return NoRepoSentinel(message="Not inside a git repository (no .git found up the tree)")
|
|
84
|
+
|
|
85
|
+
# Use main_repo_root for repo_name to ensure consistent metadata paths across worktrees
|
|
86
|
+
repo_name = main_repo_root.name
|
|
87
|
+
repo_dir = erk_root / "repos" / repo_name
|
|
88
|
+
worktrees_dir = repo_dir / "worktrees"
|
|
89
|
+
pool_json_path = repo_dir / "pool.json"
|
|
90
|
+
|
|
91
|
+
# Extract GitHub identity from remote URL
|
|
92
|
+
repo_id: GitHubRepoId | None = None
|
|
93
|
+
try:
|
|
94
|
+
remote_url = ops.get_remote_url(root, "origin")
|
|
95
|
+
owner_repo = parse_git_remote_url(remote_url)
|
|
96
|
+
repo_id = GitHubRepoId(owner=owner_repo[0], repo=owner_repo[1])
|
|
97
|
+
except ValueError:
|
|
98
|
+
# No origin remote or not a GitHub URL - continue without GitHub identity
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
return RepoContext(
|
|
102
|
+
root=root,
|
|
103
|
+
main_repo_root=main_repo_root,
|
|
104
|
+
repo_name=repo_name,
|
|
105
|
+
repo_dir=repo_dir,
|
|
106
|
+
worktrees_dir=worktrees_dir,
|
|
107
|
+
pool_json_path=pool_json_path,
|
|
108
|
+
github=repo_id,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def ensure_erk_metadata_dir(repo: RepoContext) -> Path:
|
|
113
|
+
"""Ensure the erk metadata directory and worktrees subdirectory exist.
|
|
114
|
+
|
|
115
|
+
Creates repo.repo_dir (~/.erk/repos/<repo-name>) and repo.worktrees_dir
|
|
116
|
+
subdirectory if they don't exist.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
repo: Repository context containing metadata paths
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Path to the erk metadata directory (repo.repo_dir), not git root
|
|
123
|
+
"""
|
|
124
|
+
repo.repo_dir.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
repo.worktrees_dir.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
return repo.repo_dir
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Activation script writing operations.
|
|
2
|
+
|
|
3
|
+
This module provides the RealScriptWriter implementation.
|
|
4
|
+
ABC and types are imported from erk_shared.core.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from erk.cli.shell_utils import write_script_to_temp
|
|
8
|
+
from erk_shared.core.script_writer import ScriptResult as ScriptResult
|
|
9
|
+
from erk_shared.core.script_writer import ScriptWriter as ScriptWriter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RealScriptWriter(ScriptWriter):
|
|
13
|
+
"""Production implementation that writes real temp files."""
|
|
14
|
+
|
|
15
|
+
def write_activation_script(
|
|
16
|
+
self,
|
|
17
|
+
content: str,
|
|
18
|
+
*,
|
|
19
|
+
command_name: str,
|
|
20
|
+
comment: str,
|
|
21
|
+
) -> ScriptResult:
|
|
22
|
+
"""Write activation script to temp file.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
content: The shell script content
|
|
26
|
+
command_name: Command generating the script
|
|
27
|
+
comment: Description for the script header
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
ScriptResult with path to created temp file and full content
|
|
31
|
+
"""
|
|
32
|
+
script_path = write_script_to_temp(
|
|
33
|
+
content,
|
|
34
|
+
command_name=command_name,
|
|
35
|
+
comment=comment,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Read back the full content that was written (includes headers)
|
|
39
|
+
full_content = script_path.read_text(encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
return ScriptResult(path=script_path, content=full_content)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Service layer for erk operations."""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Service for efficiently fetching plan list data via batched API calls.
|
|
2
|
+
|
|
3
|
+
Uses GraphQL nodes(ids: [...]) for O(1) batch lookup of workflow runs (~200ms for any N).
|
|
4
|
+
All plan issues store last_dispatched_node_id in the plan-header metadata block.
|
|
5
|
+
|
|
6
|
+
Performance optimization: When PR linkages are needed, uses unified GraphQL query via
|
|
7
|
+
get_issues_with_pr_linkages() to fetch issues + PR linkages in a single API call (~600ms),
|
|
8
|
+
instead of separate calls for issues (~500ms) and PR linkages (~1500ms).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from erk_shared.core.plan_list_service import PlanListData as PlanListData
|
|
12
|
+
from erk_shared.core.plan_list_service import PlanListService
|
|
13
|
+
from erk_shared.github.abc import GitHub
|
|
14
|
+
from erk_shared.github.issues import GitHubIssues
|
|
15
|
+
from erk_shared.github.metadata.plan_header import extract_plan_header_dispatch_info
|
|
16
|
+
from erk_shared.github.types import GitHubRepoLocation, WorkflowRun
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RealPlanListService(PlanListService):
|
|
20
|
+
"""Service for efficiently fetching plan list data.
|
|
21
|
+
|
|
22
|
+
Composes GitHub and GitHubIssues integrations to batch fetch all data
|
|
23
|
+
needed for plan listing. Uses GraphQL nodes(ids: [...]) for efficient
|
|
24
|
+
batch lookup of workflow runs by node_id.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, github: GitHub, github_issues: GitHubIssues) -> None:
|
|
28
|
+
"""Initialize PlanListService with required integrations.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
github: GitHub integration for PR and workflow operations
|
|
32
|
+
github_issues: GitHub issues integration for issue operations
|
|
33
|
+
"""
|
|
34
|
+
self._github = github
|
|
35
|
+
self._github_issues = github_issues
|
|
36
|
+
|
|
37
|
+
def get_plan_list_data(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
location: GitHubRepoLocation,
|
|
41
|
+
labels: list[str],
|
|
42
|
+
state: str | None = None,
|
|
43
|
+
limit: int | None = None,
|
|
44
|
+
skip_workflow_runs: bool = False,
|
|
45
|
+
creator: str | None = None,
|
|
46
|
+
) -> PlanListData:
|
|
47
|
+
"""Batch fetch all data needed for plan listing.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
location: GitHub repository location (local root + repo identity)
|
|
51
|
+
labels: Labels to filter issues by (e.g., ["erk-plan"])
|
|
52
|
+
state: Filter by state ("open", "closed", or None for all)
|
|
53
|
+
limit: Maximum number of issues to return (None for no limit)
|
|
54
|
+
skip_workflow_runs: If True, skip fetching workflow runs (for performance)
|
|
55
|
+
creator: Filter by creator username (e.g., "octocat"). If provided,
|
|
56
|
+
only issues created by this user are returned.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
PlanListData containing issues, PR linkages, and workflow runs
|
|
60
|
+
"""
|
|
61
|
+
# Always use unified path: issues + PR linkages in one API call (~600ms)
|
|
62
|
+
issues, pr_linkages = self._github.get_issues_with_pr_linkages(
|
|
63
|
+
location,
|
|
64
|
+
labels,
|
|
65
|
+
state=state,
|
|
66
|
+
limit=limit,
|
|
67
|
+
creator=creator,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Conditionally fetch workflow runs (skip for performance when not needed)
|
|
71
|
+
workflow_runs: dict[int, WorkflowRun | None] = {}
|
|
72
|
+
if not skip_workflow_runs:
|
|
73
|
+
# Extract node_ids from plan-header metadata
|
|
74
|
+
node_id_to_issue: dict[str, int] = {}
|
|
75
|
+
for issue in issues:
|
|
76
|
+
_, node_id, _ = extract_plan_header_dispatch_info(issue.body)
|
|
77
|
+
if node_id is not None:
|
|
78
|
+
node_id_to_issue[node_id] = issue.number
|
|
79
|
+
|
|
80
|
+
# Batch fetch workflow runs via GraphQL nodes(ids: [...])
|
|
81
|
+
if node_id_to_issue:
|
|
82
|
+
runs_by_node_id = self._github.get_workflow_runs_by_node_ids(
|
|
83
|
+
location.root,
|
|
84
|
+
list(node_id_to_issue.keys()),
|
|
85
|
+
)
|
|
86
|
+
for node_id, run in runs_by_node_id.items():
|
|
87
|
+
issue_number = node_id_to_issue[node_id]
|
|
88
|
+
workflow_runs[issue_number] = run
|
|
89
|
+
|
|
90
|
+
return PlanListData(
|
|
91
|
+
issues=issues,
|
|
92
|
+
pr_linkages=pr_linkages,
|
|
93
|
+
workflow_runs=workflow_runs,
|
|
94
|
+
)
|
erk/core/shell.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shell detection and tool availability operations.
|
|
2
|
+
|
|
3
|
+
This module provides abstraction over shell-specific operations like detecting
|
|
4
|
+
the current shell and checking if command-line tools are installed. This abstraction
|
|
5
|
+
enables dependency injection for testing without mock.patch.
|
|
6
|
+
|
|
7
|
+
The Shell ABC and implementations (RealShell, FakeShell) are defined in erk_shared
|
|
8
|
+
and re-exported here. Erk-specific helper functions remain in this module.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
# Re-export all Shell types from erk_shared
|
|
14
|
+
from erk_shared.gateway.shell import FakeShell as FakeShell
|
|
15
|
+
from erk_shared.gateway.shell import RealShell as RealShell
|
|
16
|
+
from erk_shared.gateway.shell import Shell as Shell
|
|
17
|
+
from erk_shared.gateway.shell import detect_shell_from_env as detect_shell_from_env
|
|
18
|
+
from erk_shared.subprocess_utils import run_subprocess_with_context as run_subprocess_with_context
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _extract_issue_url_from_output(output: str) -> str | None:
|
|
22
|
+
"""Extract issue_url from Claude CLI output that may contain mixed content.
|
|
23
|
+
|
|
24
|
+
Claude CLI with --print mode can output conversation/thinking text before
|
|
25
|
+
the final JSON. This function searches from the end of the output to find
|
|
26
|
+
a JSON object containing issue_url.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
output: The stdout from Claude CLI (may contain non-JSON text)
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The issue_url string if found, None otherwise.
|
|
33
|
+
"""
|
|
34
|
+
if not output:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
# Search from the end of output to find JSON with issue_url
|
|
38
|
+
for line in reversed(output.strip().split("\n")):
|
|
39
|
+
line = line.strip()
|
|
40
|
+
if not line:
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(line)
|
|
44
|
+
if isinstance(data, dict):
|
|
45
|
+
issue_url = data.get("issue_url")
|
|
46
|
+
if isinstance(issue_url, str):
|
|
47
|
+
return issue_url
|
|
48
|
+
except json.JSONDecodeError:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
return None
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""User-facing diagnostic output with mode awareness.
|
|
2
|
+
|
|
3
|
+
This is a thin shim that re-exports from erk_shared.gateway.feedback.
|
|
4
|
+
All implementations are in erk_shared for sharing across packages.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Re-export all UserFeedback types from erk_shared
|
|
8
|
+
from erk_shared.gateway.feedback import FakeUserFeedback as FakeUserFeedback
|
|
9
|
+
from erk_shared.gateway.feedback import InteractiveFeedback as InteractiveFeedback
|
|
10
|
+
from erk_shared.gateway.feedback import SuppressedFeedback as SuppressedFeedback
|
|
11
|
+
from erk_shared.gateway.feedback import UserFeedback as UserFeedback
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Version checking for erk tool installation.
|
|
2
|
+
|
|
3
|
+
Compares the installed version against a repository-specified required version.
|
|
4
|
+
Used to warn users when their installed erk is outdated compared to what the
|
|
5
|
+
repository requires.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from packaging.version import Version
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_required_version(repo_root: Path) -> str | None:
|
|
14
|
+
"""Read required version from .erk/required-erk-uv-tool-version.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
repo_root: Path to the git repository root
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Version string if file exists, None otherwise
|
|
21
|
+
"""
|
|
22
|
+
version_file = repo_root / ".erk" / "required-erk-uv-tool-version"
|
|
23
|
+
if not version_file.exists():
|
|
24
|
+
return None
|
|
25
|
+
return version_file.read_text(encoding="utf-8").strip()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_version_mismatch(installed: str, required: str) -> bool:
|
|
29
|
+
"""Check if installed version doesn't match required version exactly.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
installed: Currently installed version (e.g., "0.2.7")
|
|
33
|
+
required: Required version from repo (e.g., "0.2.8")
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True if versions don't match exactly, False if they match
|
|
37
|
+
"""
|
|
38
|
+
return Version(installed) != Version(required)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def format_version_warning(installed: str, required: str) -> str:
|
|
42
|
+
"""Format warning message for version mismatch.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
installed: Currently installed version
|
|
46
|
+
required: Required version from repo
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Formatted warning message
|
|
50
|
+
"""
|
|
51
|
+
return (
|
|
52
|
+
f"⚠️ Your erk ({installed}) doesn't match required ({required})\n"
|
|
53
|
+
f" You must update or erk may not work properly.\n"
|
|
54
|
+
f" Update: uv tool upgrade erk"
|
|
55
|
+
)
|