gobby 0.2.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.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
gobby/tasks/commits.py
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""Commit linking and diff functionality for Task System V2.
|
|
2
|
+
|
|
3
|
+
Provides utilities for linking commits to tasks and computing diffs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from gobby.utils.git import run_git_command
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from gobby.storage.tasks import LocalTaskManager
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class TaskDiffResult:
|
|
22
|
+
"""Result of computing a task's diff.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
diff: Combined diff content from all linked commits
|
|
26
|
+
commits: List of commit SHAs included in the diff
|
|
27
|
+
has_uncommitted_changes: Whether uncommitted changes were included
|
|
28
|
+
file_count: Number of files modified in the diff
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
diff: str
|
|
32
|
+
commits: list[str] = field(default_factory=list)
|
|
33
|
+
has_uncommitted_changes: bool = False
|
|
34
|
+
file_count: int = 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_task_diff(
|
|
38
|
+
task_id: str,
|
|
39
|
+
task_manager: "LocalTaskManager",
|
|
40
|
+
include_uncommitted: bool = False,
|
|
41
|
+
cwd: str | Path | None = None,
|
|
42
|
+
) -> TaskDiffResult:
|
|
43
|
+
"""Get the combined diff for all commits linked to a task.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
task_id: The task ID to get diff for.
|
|
47
|
+
task_manager: LocalTaskManager instance to fetch task data.
|
|
48
|
+
include_uncommitted: If True, include uncommitted changes in diff.
|
|
49
|
+
cwd: Working directory for git commands. Defaults to current directory.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
TaskDiffResult with combined diff and metadata.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If task not found.
|
|
56
|
+
"""
|
|
57
|
+
# Get the task (raises ValueError if not found)
|
|
58
|
+
task = task_manager.get_task(task_id)
|
|
59
|
+
|
|
60
|
+
# Handle no commits
|
|
61
|
+
commits = task.commits or []
|
|
62
|
+
if not commits and not include_uncommitted:
|
|
63
|
+
return TaskDiffResult(diff="", commits=[], has_uncommitted_changes=False)
|
|
64
|
+
|
|
65
|
+
working_dir = Path(cwd) if cwd else Path.cwd()
|
|
66
|
+
diff_parts = []
|
|
67
|
+
has_uncommitted = False
|
|
68
|
+
|
|
69
|
+
# Get diff for each linked commit
|
|
70
|
+
if commits:
|
|
71
|
+
# For multiple commits, we get the combined diff
|
|
72
|
+
# git diff <first_commit>^..<last_commit> shows all changes
|
|
73
|
+
if len(commits) == 1:
|
|
74
|
+
# Single commit: show its changes
|
|
75
|
+
result = run_git_command(
|
|
76
|
+
["git", "show", "--format=", commits[0]],
|
|
77
|
+
cwd=working_dir,
|
|
78
|
+
)
|
|
79
|
+
if result:
|
|
80
|
+
diff_parts.append(result)
|
|
81
|
+
else:
|
|
82
|
+
# Multiple commits: get combined diff
|
|
83
|
+
# Commits are stored in chronological order (oldest at index 0, newest at index -1)
|
|
84
|
+
# git diff oldest^..newest shows all changes in the range
|
|
85
|
+
result = run_git_command(
|
|
86
|
+
["git", "diff", f"{commits[0]}^..{commits[-1]}"],
|
|
87
|
+
cwd=working_dir,
|
|
88
|
+
)
|
|
89
|
+
if result:
|
|
90
|
+
diff_parts.append(result)
|
|
91
|
+
|
|
92
|
+
# Include uncommitted changes if requested
|
|
93
|
+
if include_uncommitted:
|
|
94
|
+
uncommitted = run_git_command(
|
|
95
|
+
["git", "diff", "HEAD"],
|
|
96
|
+
cwd=working_dir,
|
|
97
|
+
)
|
|
98
|
+
if uncommitted:
|
|
99
|
+
diff_parts.append(uncommitted)
|
|
100
|
+
has_uncommitted = True
|
|
101
|
+
|
|
102
|
+
# Combine all diff parts
|
|
103
|
+
combined_diff = "\n".join(diff_parts)
|
|
104
|
+
|
|
105
|
+
# Count files in the diff
|
|
106
|
+
file_count = len(re.findall(r"^diff --git", combined_diff, re.MULTILINE))
|
|
107
|
+
|
|
108
|
+
return TaskDiffResult(
|
|
109
|
+
diff=combined_diff,
|
|
110
|
+
commits=commits,
|
|
111
|
+
has_uncommitted_changes=has_uncommitted,
|
|
112
|
+
file_count=file_count,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Doc file extensions that don't need LLM validation
|
|
117
|
+
DOC_EXTENSIONS = {".md", ".txt", ".rst", ".adoc", ".markdown"}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def is_doc_only_diff(diff: str) -> bool:
|
|
121
|
+
"""Check if a diff only affects documentation files.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
diff: Git diff string.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
True if all modified files are documentation files.
|
|
128
|
+
"""
|
|
129
|
+
if not diff:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
# Find all file paths in the diff
|
|
133
|
+
file_pattern = r"^diff --git a/(.+?) b/"
|
|
134
|
+
matches = re.findall(file_pattern, diff, re.MULTILINE)
|
|
135
|
+
|
|
136
|
+
if not matches:
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
# Check if all files are doc files
|
|
140
|
+
for file_path in matches:
|
|
141
|
+
ext = Path(file_path).suffix.lower()
|
|
142
|
+
if ext not in DOC_EXTENSIONS:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def summarize_diff_for_validation(
|
|
149
|
+
diff: str,
|
|
150
|
+
max_chars: int = 30000,
|
|
151
|
+
max_hunk_lines: int = 50,
|
|
152
|
+
priority_files: list[str] | None = None,
|
|
153
|
+
) -> str:
|
|
154
|
+
"""Summarize a diff for LLM validation, ensuring all files are visible.
|
|
155
|
+
|
|
156
|
+
For large diffs, this:
|
|
157
|
+
1. Always shows the complete file list with stats
|
|
158
|
+
2. Truncates individual hunks to avoid overwhelming the LLM
|
|
159
|
+
3. Prioritizes showing file names over full content
|
|
160
|
+
4. When priority_files provided, shows those files first with more space
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
diff: Full git diff string.
|
|
164
|
+
max_chars: Maximum characters to return.
|
|
165
|
+
max_hunk_lines: Maximum lines per hunk before truncation.
|
|
166
|
+
priority_files: Optional list of file paths to prioritize.
|
|
167
|
+
These files appear first and get 60% of the space allocation.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Summarized diff string that fits within max_chars.
|
|
171
|
+
"""
|
|
172
|
+
if not diff or len(diff) <= max_chars:
|
|
173
|
+
return diff
|
|
174
|
+
|
|
175
|
+
# Parse the diff into files
|
|
176
|
+
file_diffs = re.split(r"(?=^diff --git)", diff, flags=re.MULTILINE)
|
|
177
|
+
file_diffs = [f for f in file_diffs if f.strip()]
|
|
178
|
+
|
|
179
|
+
if not file_diffs:
|
|
180
|
+
return diff[:max_chars] + "\n\n... [diff truncated] ..."
|
|
181
|
+
|
|
182
|
+
# First, collect file stats
|
|
183
|
+
file_stats: list[dict[str, str | int]] = []
|
|
184
|
+
for file_diff in file_diffs:
|
|
185
|
+
# Extract file name
|
|
186
|
+
name_match = re.match(r"diff --git a/(.+?) b/", file_diff)
|
|
187
|
+
if name_match:
|
|
188
|
+
file_name = name_match.group(1)
|
|
189
|
+
else:
|
|
190
|
+
file_name = "(unknown)"
|
|
191
|
+
|
|
192
|
+
# Count additions/deletions
|
|
193
|
+
additions = len(re.findall(r"^\+[^+]", file_diff, re.MULTILINE))
|
|
194
|
+
deletions = len(re.findall(r"^-[^-]", file_diff, re.MULTILINE))
|
|
195
|
+
|
|
196
|
+
file_stats.append(
|
|
197
|
+
{
|
|
198
|
+
"name": file_name,
|
|
199
|
+
"additions": additions,
|
|
200
|
+
"deletions": deletions,
|
|
201
|
+
"diff": file_diff,
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Separate into priority and non-priority groups if priority_files provided
|
|
206
|
+
priority_stats: list[dict[str, str | int]] = []
|
|
207
|
+
non_priority_stats: list[dict[str, str | int]] = []
|
|
208
|
+
|
|
209
|
+
if priority_files:
|
|
210
|
+
priority_set = set(priority_files)
|
|
211
|
+
for f in file_stats:
|
|
212
|
+
if str(f["name"]) in priority_set:
|
|
213
|
+
priority_stats.append(f)
|
|
214
|
+
else:
|
|
215
|
+
non_priority_stats.append(f)
|
|
216
|
+
else:
|
|
217
|
+
# No priority files - all are non-priority (original behavior)
|
|
218
|
+
non_priority_stats = file_stats
|
|
219
|
+
|
|
220
|
+
# Build summary header
|
|
221
|
+
total_additions = sum(int(f["additions"]) for f in file_stats)
|
|
222
|
+
total_deletions = sum(int(f["deletions"]) for f in file_stats)
|
|
223
|
+
|
|
224
|
+
summary_parts: list[str] = [
|
|
225
|
+
f"## Diff Summary ({len(file_stats)} files, +{total_additions}/-{total_deletions})\n",
|
|
226
|
+
"### Files Changed:\n",
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
# Show priority files first in the summary
|
|
230
|
+
if priority_stats:
|
|
231
|
+
summary_parts.append("#### Priority Files:\n")
|
|
232
|
+
for f in priority_stats:
|
|
233
|
+
summary_parts.append(f"- {f['name']} (+{f['additions']}/-{f['deletions']})\n")
|
|
234
|
+
if non_priority_stats:
|
|
235
|
+
summary_parts.append("\n#### Other Files:\n")
|
|
236
|
+
|
|
237
|
+
for f in non_priority_stats:
|
|
238
|
+
summary_parts.append(f"- {f['name']} (+{f['additions']}/-{f['deletions']})\n")
|
|
239
|
+
|
|
240
|
+
summary_parts.append("\n### File Details:\n\n")
|
|
241
|
+
|
|
242
|
+
# Calculate remaining space for file contents
|
|
243
|
+
header_size = sum(len(p) for p in summary_parts)
|
|
244
|
+
remaining_chars = max_chars - header_size - 100 # Buffer for truncation message
|
|
245
|
+
|
|
246
|
+
# Allocate space: 60% to priority files, 40% to non-priority (if priority_files provided)
|
|
247
|
+
if priority_files and priority_stats:
|
|
248
|
+
priority_space = int(remaining_chars * 0.6)
|
|
249
|
+
non_priority_space = remaining_chars - priority_space
|
|
250
|
+
|
|
251
|
+
chars_per_priority = priority_space // len(priority_stats) if priority_stats else 0
|
|
252
|
+
chars_per_non_priority = (
|
|
253
|
+
non_priority_space // len(non_priority_stats) if non_priority_stats else 0
|
|
254
|
+
)
|
|
255
|
+
else:
|
|
256
|
+
# Original behavior: equal distribution
|
|
257
|
+
chars_per_priority = 0
|
|
258
|
+
chars_per_non_priority = (
|
|
259
|
+
remaining_chars // len(file_stats) if file_stats else remaining_chars
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def truncate_file_content(file_content: str, max_file_chars: int) -> str:
|
|
263
|
+
"""Truncate a file diff to fit within max_file_chars."""
|
|
264
|
+
if len(file_content) <= max_file_chars:
|
|
265
|
+
return file_content
|
|
266
|
+
|
|
267
|
+
# Truncate this file's diff but keep the header
|
|
268
|
+
header_end = file_content.find("@@")
|
|
269
|
+
if header_end > 0:
|
|
270
|
+
header = file_content[:header_end]
|
|
271
|
+
hunks = file_content[header_end:]
|
|
272
|
+
# Keep first part of hunks
|
|
273
|
+
truncated_hunks = hunks[: max_file_chars - len(header) - 50]
|
|
274
|
+
return header + truncated_hunks + "\n... [file diff truncated] ...\n"
|
|
275
|
+
else:
|
|
276
|
+
return file_content[:max_file_chars] + "\n... [file diff truncated] ...\n"
|
|
277
|
+
|
|
278
|
+
# Add priority files first
|
|
279
|
+
for f in priority_stats:
|
|
280
|
+
file_content = truncate_file_content(str(f["diff"]), chars_per_priority)
|
|
281
|
+
summary_parts.append(file_content)
|
|
282
|
+
|
|
283
|
+
# Add non-priority files
|
|
284
|
+
for f in non_priority_stats:
|
|
285
|
+
file_content = truncate_file_content(str(f["diff"]), chars_per_non_priority)
|
|
286
|
+
summary_parts.append(file_content)
|
|
287
|
+
|
|
288
|
+
result = "".join(summary_parts)
|
|
289
|
+
|
|
290
|
+
# Final safety check
|
|
291
|
+
if len(result) > max_chars:
|
|
292
|
+
result = result[:max_chars] + "\n\n... [diff truncated] ..."
|
|
293
|
+
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _build_file_patterns(
|
|
298
|
+
file_extensions: list[str] | None = None,
|
|
299
|
+
path_prefixes: list[str] | None = None,
|
|
300
|
+
) -> list[str]:
|
|
301
|
+
"""Build regex patterns for file path extraction.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
file_extensions: List of file extensions to match (e.g., [".py", ".ts"]).
|
|
305
|
+
If None, uses a basic default set.
|
|
306
|
+
path_prefixes: List of path prefixes to match (e.g., ["src/", "tests/"]).
|
|
307
|
+
If None, uses a basic default set.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
List of regex patterns for file path matching.
|
|
311
|
+
"""
|
|
312
|
+
# Build extension pattern from config
|
|
313
|
+
if file_extensions:
|
|
314
|
+
# Strip leading dots and escape for regex
|
|
315
|
+
exts = [ext.lstrip(".") for ext in file_extensions]
|
|
316
|
+
ext_pattern = "|".join(re.escape(ext) for ext in exts)
|
|
317
|
+
else:
|
|
318
|
+
ext_pattern = "py|ts|js|json|yaml|yml|toml|md|go|rs|cfg|ini|sh"
|
|
319
|
+
|
|
320
|
+
# Build prefix pattern from config
|
|
321
|
+
if path_prefixes:
|
|
322
|
+
# Strip trailing slashes for regex alternation
|
|
323
|
+
prefixes = [p.rstrip("/") for p in path_prefixes]
|
|
324
|
+
prefix_pattern = "|".join(re.escape(p) for p in prefixes)
|
|
325
|
+
else:
|
|
326
|
+
prefix_pattern = "src|tests?|lib|config|scripts?|docs?|bin|pkg|internal|cmd"
|
|
327
|
+
|
|
328
|
+
return [
|
|
329
|
+
# Backtick-quoted paths: `path/to/file.py`
|
|
330
|
+
r"`([^`]+/[^`]+)`",
|
|
331
|
+
r"`([^`]+\.[a-zA-Z0-9]+)`",
|
|
332
|
+
# Paths with directory separators and extensions
|
|
333
|
+
r"(?<![a-zA-Z0-9_])([a-zA-Z0-9_./-]+/[a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)",
|
|
334
|
+
# Paths starting with common prefixes (using config)
|
|
335
|
+
rf"(?<![a-zA-Z0-9_])((?:{prefix_pattern})/[a-zA-Z0-9_./+-]+)",
|
|
336
|
+
# Absolute paths
|
|
337
|
+
r"(/[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)+)",
|
|
338
|
+
# Relative paths with ./
|
|
339
|
+
r"(\./[a-zA-Z0-9_./+-]+)",
|
|
340
|
+
# Standalone filenames with common extensions (using config)
|
|
341
|
+
rf"(?<![a-zA-Z0-9_/])([a-zA-Z0-9_-]+\.(?:{ext_pattern}))\b",
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Default known files (used when no config provided)
|
|
346
|
+
_DEFAULT_KNOWN_FILES = {
|
|
347
|
+
"Makefile",
|
|
348
|
+
"Dockerfile",
|
|
349
|
+
"Jenkinsfile",
|
|
350
|
+
"Vagrantfile",
|
|
351
|
+
"Rakefile",
|
|
352
|
+
"Gemfile",
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def extract_mentioned_files(
|
|
357
|
+
task: dict[str, Any],
|
|
358
|
+
file_extensions: list[str] | None = None,
|
|
359
|
+
known_files: list[str] | None = None,
|
|
360
|
+
path_prefixes: list[str] | None = None,
|
|
361
|
+
) -> list[str]:
|
|
362
|
+
"""Extract file paths mentioned in task title, description, and validation_criteria.
|
|
363
|
+
|
|
364
|
+
Searches for file path patterns in the task's text fields and returns
|
|
365
|
+
a deduplicated list of file paths. Useful for prioritizing relevant files
|
|
366
|
+
in validation context.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
task: Task dictionary with title, description, and optionally validation_criteria.
|
|
370
|
+
file_extensions: List of file extensions to recognize (from config).
|
|
371
|
+
If None, uses basic defaults.
|
|
372
|
+
known_files: List of known filenames without extensions (from config).
|
|
373
|
+
If None, uses basic defaults.
|
|
374
|
+
path_prefixes: List of common path prefixes (from config).
|
|
375
|
+
If None, uses basic defaults.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
List of unique file paths mentioned in the task.
|
|
379
|
+
"""
|
|
380
|
+
# Combine text from all relevant fields
|
|
381
|
+
text_parts = []
|
|
382
|
+
if task.get("title"):
|
|
383
|
+
text_parts.append(task["title"])
|
|
384
|
+
if task.get("description"):
|
|
385
|
+
text_parts.append(task["description"])
|
|
386
|
+
if task.get("validation_criteria"):
|
|
387
|
+
text_parts.append(task["validation_criteria"])
|
|
388
|
+
|
|
389
|
+
if not text_parts:
|
|
390
|
+
return []
|
|
391
|
+
|
|
392
|
+
combined_text = "\n".join(text_parts)
|
|
393
|
+
found_paths: set[str] = set()
|
|
394
|
+
|
|
395
|
+
# Build patterns based on config
|
|
396
|
+
patterns = _build_file_patterns(file_extensions, path_prefixes)
|
|
397
|
+
|
|
398
|
+
# Apply each pattern
|
|
399
|
+
for pattern in patterns:
|
|
400
|
+
matches = re.findall(pattern, combined_text)
|
|
401
|
+
for match in matches:
|
|
402
|
+
# Clean up the match
|
|
403
|
+
path = match.strip()
|
|
404
|
+
# Skip if it looks like a URL
|
|
405
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
406
|
+
continue
|
|
407
|
+
# Skip if too short or doesn't look like a path
|
|
408
|
+
if len(path) < 3:
|
|
409
|
+
continue
|
|
410
|
+
found_paths.add(path)
|
|
411
|
+
|
|
412
|
+
# Check for known filenames without extensions
|
|
413
|
+
files_to_check = set(known_files) if known_files else _DEFAULT_KNOWN_FILES
|
|
414
|
+
for filename in files_to_check:
|
|
415
|
+
if filename in combined_text:
|
|
416
|
+
# Only add if it appears as a word boundary (escape special chars in filename)
|
|
417
|
+
escaped_filename = re.escape(filename)
|
|
418
|
+
if re.search(rf"(?<![a-zA-Z0-9_/]){escaped_filename}(?![a-zA-Z0-9_])", combined_text):
|
|
419
|
+
found_paths.add(filename)
|
|
420
|
+
|
|
421
|
+
return list(found_paths)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def extract_mentioned_symbols(task: dict[str, Any]) -> list[str]:
|
|
425
|
+
"""Extract function/class names mentioned in task description.
|
|
426
|
+
|
|
427
|
+
Searches for symbol patterns in backticks and extracts function/class names.
|
|
428
|
+
Useful for providing enhanced context to validators.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
task: Task dictionary with title, description, and optionally validation_criteria.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
List of unique symbol names mentioned in the task.
|
|
435
|
+
"""
|
|
436
|
+
# Combine text from all relevant fields
|
|
437
|
+
text_parts = []
|
|
438
|
+
if task.get("title"):
|
|
439
|
+
text_parts.append(task["title"])
|
|
440
|
+
if task.get("description"):
|
|
441
|
+
text_parts.append(task["description"])
|
|
442
|
+
if task.get("validation_criteria"):
|
|
443
|
+
text_parts.append(task["validation_criteria"])
|
|
444
|
+
|
|
445
|
+
if not text_parts:
|
|
446
|
+
return []
|
|
447
|
+
|
|
448
|
+
combined_text = "\n".join(text_parts)
|
|
449
|
+
found_symbols: set[str] = set()
|
|
450
|
+
|
|
451
|
+
# Pattern to match backtick-quoted content
|
|
452
|
+
backtick_pattern = r"`([^`]+)`"
|
|
453
|
+
backtick_matches = re.findall(backtick_pattern, combined_text)
|
|
454
|
+
|
|
455
|
+
for match in backtick_matches:
|
|
456
|
+
match = match.strip()
|
|
457
|
+
|
|
458
|
+
# Skip if it looks like a file path (contains / or has file extension pattern)
|
|
459
|
+
if "/" in match:
|
|
460
|
+
continue
|
|
461
|
+
# Skip if it looks like a filename with common extensions
|
|
462
|
+
if re.search(r"\.[a-zA-Z]{1,4}$", match) and "." in match:
|
|
463
|
+
# But allow method calls like obj.method()
|
|
464
|
+
if not re.search(r"^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*(?:\(\))?$", match):
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
# Extract the symbol name
|
|
468
|
+
# Remove trailing () if present
|
|
469
|
+
symbol = re.sub(r"\(\)$", "", match)
|
|
470
|
+
|
|
471
|
+
# Handle Class.method pattern - extract the method name
|
|
472
|
+
if "." in symbol:
|
|
473
|
+
parts = symbol.split(".")
|
|
474
|
+
# Add the method name (last part)
|
|
475
|
+
method_name = parts[-1]
|
|
476
|
+
if re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", method_name):
|
|
477
|
+
found_symbols.add(method_name)
|
|
478
|
+
# Optionally also add the full reference
|
|
479
|
+
if re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$", symbol):
|
|
480
|
+
found_symbols.add(symbol)
|
|
481
|
+
else:
|
|
482
|
+
# Simple identifier (function name, class name, etc.)
|
|
483
|
+
if re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", symbol):
|
|
484
|
+
found_symbols.add(symbol)
|
|
485
|
+
|
|
486
|
+
return list(found_symbols)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# Task ID patterns to search for in commit messages
|
|
490
|
+
# Supports #N format (e.g., #1, #47) - human-friendly task references
|
|
491
|
+
TASK_ID_PATTERNS = [
|
|
492
|
+
# [#N] - bracket format
|
|
493
|
+
r"\[#(\d+)\]",
|
|
494
|
+
# #N: - hash-colon format (at start of line or after space)
|
|
495
|
+
r"(?:^|\s)#(\d+):",
|
|
496
|
+
# Implements/Fixes/Closes/Refs #N (supports multiple: #1, #2, #3)
|
|
497
|
+
r"(?:implements|fixes|closes|refs)\s+#(\d+)",
|
|
498
|
+
# Standalone #N after whitespace (with word boundary to avoid false positives)
|
|
499
|
+
r"(?:^|\s)#(\d+)\b(?![\d.])",
|
|
500
|
+
]
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def extract_task_ids_from_message(message: str) -> list[str]:
|
|
504
|
+
"""Extract task IDs from a commit message.
|
|
505
|
+
|
|
506
|
+
Supports patterns:
|
|
507
|
+
- [#N] - bracket format
|
|
508
|
+
- #N: - hash-colon format (at start of message)
|
|
509
|
+
- Implements/Fixes/Closes/Refs #N
|
|
510
|
+
- Multiple references: #1, #2, #3
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
message: Commit message to parse.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
List of unique task references found (e.g., ["#1", "#42"]).
|
|
517
|
+
"""
|
|
518
|
+
task_ids = set()
|
|
519
|
+
|
|
520
|
+
for pattern in TASK_ID_PATTERNS:
|
|
521
|
+
matches = re.findall(pattern, message, re.IGNORECASE | re.MULTILINE)
|
|
522
|
+
for match in matches:
|
|
523
|
+
# Format as #N
|
|
524
|
+
task_id = f"#{match}"
|
|
525
|
+
task_ids.add(task_id)
|
|
526
|
+
|
|
527
|
+
return list(task_ids)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@dataclass
|
|
531
|
+
class AutoLinkResult:
|
|
532
|
+
"""Result of auto-linking commits to tasks.
|
|
533
|
+
|
|
534
|
+
Attributes:
|
|
535
|
+
linked_tasks: Dict mapping task_id -> list of newly linked commit SHAs.
|
|
536
|
+
total_linked: Total number of commits newly linked.
|
|
537
|
+
skipped: Number of commits skipped (already linked or task not found).
|
|
538
|
+
"""
|
|
539
|
+
|
|
540
|
+
linked_tasks: dict[str, list[str]] = field(default_factory=dict)
|
|
541
|
+
total_linked: int = 0
|
|
542
|
+
skipped: int = 0
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def auto_link_commits(
|
|
546
|
+
task_manager: "LocalTaskManager",
|
|
547
|
+
task_id: str | None = None,
|
|
548
|
+
since: str | None = None,
|
|
549
|
+
cwd: str | Path | None = None,
|
|
550
|
+
) -> AutoLinkResult:
|
|
551
|
+
"""Auto-detect and link commits that mention task IDs.
|
|
552
|
+
|
|
553
|
+
Searches commit messages for task ID patterns and links matching commits
|
|
554
|
+
to the corresponding tasks.
|
|
555
|
+
|
|
556
|
+
Args:
|
|
557
|
+
task_manager: LocalTaskManager instance for task operations.
|
|
558
|
+
task_id: Optional specific task ID to filter for.
|
|
559
|
+
since: Optional git --since parameter (e.g., "1 week ago", "2024-01-01").
|
|
560
|
+
cwd: Working directory for git commands.
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
AutoLinkResult with details of linked and skipped commits.
|
|
564
|
+
"""
|
|
565
|
+
working_dir = Path(cwd) if cwd else Path.cwd()
|
|
566
|
+
|
|
567
|
+
# Build git log command
|
|
568
|
+
# Format: "sha|message" for easy parsing
|
|
569
|
+
git_cmd = ["git", "log", "--pretty=format:%h|%s"]
|
|
570
|
+
|
|
571
|
+
if since:
|
|
572
|
+
git_cmd.append(f"--since={since}")
|
|
573
|
+
|
|
574
|
+
# Get git log output
|
|
575
|
+
log_output = run_git_command(git_cmd, cwd=working_dir)
|
|
576
|
+
|
|
577
|
+
if not log_output:
|
|
578
|
+
return AutoLinkResult()
|
|
579
|
+
|
|
580
|
+
result = AutoLinkResult()
|
|
581
|
+
|
|
582
|
+
# Parse each commit line
|
|
583
|
+
for line in log_output.strip().split("\n"):
|
|
584
|
+
if not line or "|" not in line:
|
|
585
|
+
continue
|
|
586
|
+
|
|
587
|
+
parts = line.split("|", 1)
|
|
588
|
+
if len(parts) != 2:
|
|
589
|
+
continue
|
|
590
|
+
|
|
591
|
+
commit_sha, message = parts
|
|
592
|
+
|
|
593
|
+
# Extract task IDs from message
|
|
594
|
+
found_task_ids = extract_task_ids_from_message(message)
|
|
595
|
+
|
|
596
|
+
if not found_task_ids:
|
|
597
|
+
continue
|
|
598
|
+
|
|
599
|
+
# Filter to specific task if requested
|
|
600
|
+
if task_id:
|
|
601
|
+
if task_id not in found_task_ids:
|
|
602
|
+
continue
|
|
603
|
+
found_task_ids = [task_id]
|
|
604
|
+
|
|
605
|
+
# Try to link each found task
|
|
606
|
+
for tid in found_task_ids:
|
|
607
|
+
try:
|
|
608
|
+
task = task_manager.get_task(tid)
|
|
609
|
+
|
|
610
|
+
# Check if already linked
|
|
611
|
+
existing_commits = task.commits or []
|
|
612
|
+
if commit_sha in existing_commits:
|
|
613
|
+
result.skipped += 1
|
|
614
|
+
continue
|
|
615
|
+
|
|
616
|
+
# Link the commit
|
|
617
|
+
task_manager.link_commit(tid, commit_sha)
|
|
618
|
+
|
|
619
|
+
# Track in result
|
|
620
|
+
if tid not in result.linked_tasks:
|
|
621
|
+
result.linked_tasks[tid] = []
|
|
622
|
+
result.linked_tasks[tid].append(commit_sha)
|
|
623
|
+
result.total_linked += 1
|
|
624
|
+
|
|
625
|
+
logger.debug(f"Auto-linked commit {commit_sha} to task {tid}")
|
|
626
|
+
|
|
627
|
+
except ValueError:
|
|
628
|
+
# Task doesn't exist, skip
|
|
629
|
+
logger.debug(f"Skipping commit {commit_sha}: task {tid} not found")
|
|
630
|
+
result.skipped += 1
|
|
631
|
+
continue
|
|
632
|
+
|
|
633
|
+
return result
|