canopy-cli 3.1.0__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.
- canopy/__init__.py +2 -0
- canopy/actions/__init__.py +32 -0
- canopy/actions/aliases.py +421 -0
- canopy/actions/augments.py +55 -0
- canopy/actions/bootstrap.py +249 -0
- canopy/actions/bot_resolutions.py +123 -0
- canopy/actions/bot_status.py +133 -0
- canopy/actions/commit.py +511 -0
- canopy/actions/conflicts.py +314 -0
- canopy/actions/doctor.py +1459 -0
- canopy/actions/draft_replies.py +185 -0
- canopy/actions/drift.py +241 -0
- canopy/actions/errors.py +115 -0
- canopy/actions/evacuate.py +192 -0
- canopy/actions/feature_state.py +607 -0
- canopy/actions/historian.py +612 -0
- canopy/actions/ide_workspace.py +49 -0
- canopy/actions/last_visit.py +83 -0
- canopy/actions/migrate_slots.py +313 -0
- canopy/actions/preflight_state.py +97 -0
- canopy/actions/push.py +199 -0
- canopy/actions/reads.py +304 -0
- canopy/actions/resume.py +582 -0
- canopy/actions/review_filter.py +135 -0
- canopy/actions/ship.py +399 -0
- canopy/actions/slot_details.py +208 -0
- canopy/actions/slot_load.py +383 -0
- canopy/actions/slots.py +221 -0
- canopy/actions/stash.py +230 -0
- canopy/actions/switch.py +775 -0
- canopy/actions/switch_preflight.py +192 -0
- canopy/actions/thread_actions.py +88 -0
- canopy/actions/thread_resolutions.py +101 -0
- canopy/actions/triage.py +286 -0
- canopy/agent/__init__.py +5 -0
- canopy/agent/runner.py +129 -0
- canopy/agent_setup/__init__.py +264 -0
- canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
- canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
- canopy/cli/__init__.py +0 -0
- canopy/cli/main.py +4152 -0
- canopy/cli/render.py +98 -0
- canopy/cli/ui.py +150 -0
- canopy/features/__init__.py +2 -0
- canopy/features/coordinator.py +1256 -0
- canopy/git/__init__.py +0 -0
- canopy/git/hooks.py +173 -0
- canopy/git/multi.py +435 -0
- canopy/git/repo.py +859 -0
- canopy/git/templates/post-checkout.py +67 -0
- canopy/graph/__init__.py +0 -0
- canopy/integrations/__init__.py +0 -0
- canopy/integrations/github.py +983 -0
- canopy/integrations/linear.py +307 -0
- canopy/integrations/precommit.py +239 -0
- canopy/mcp/__init__.py +0 -0
- canopy/mcp/client.py +329 -0
- canopy/mcp/server.py +1797 -0
- canopy/providers/__init__.py +105 -0
- canopy/providers/github_issues.py +289 -0
- canopy/providers/linear.py +341 -0
- canopy/providers/types.py +149 -0
- canopy/workspace/__init__.py +4 -0
- canopy/workspace/config.py +378 -0
- canopy/workspace/context.py +224 -0
- canopy/workspace/discovery.py +197 -0
- canopy/workspace/workspace.py +173 -0
- canopy_cli-3.1.0.dist-info/METADATA +282 -0
- canopy_cli-3.1.0.dist-info/RECORD +71 -0
- canopy_cli-3.1.0.dist-info/WHEEL +4 -0
- canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Linear integration via MCP.
|
|
3
|
+
|
|
4
|
+
Fetches issue data from a Linear MCP server configured in .canopy/mcps.json.
|
|
5
|
+
Canopy never talks to Linear directly — it always goes through the MCP layer.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..mcp.client import (
|
|
14
|
+
get_mcp_config,
|
|
15
|
+
is_mcp_configured,
|
|
16
|
+
call_tool,
|
|
17
|
+
McpClientError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LinearNotConfiguredError(Exception):
|
|
22
|
+
"""Linear MCP is not configured in .canopy/mcps.json."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LinearIssueNotFoundError(Exception):
|
|
26
|
+
"""Could not find the requested Linear issue."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LinearCallError(Exception):
|
|
30
|
+
"""Every attempt to call the Linear MCP server failed.
|
|
31
|
+
|
|
32
|
+
Carries the per-attempt log so callers (e.g. the MCP server tool wrapper)
|
|
33
|
+
can convert this into a structured ``BlockerError`` and the agent sees
|
|
34
|
+
why every attempt failed instead of an empty list.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, attempts: list[tuple[str, dict, str]]):
|
|
38
|
+
self.attempts = attempts
|
|
39
|
+
summary = "\n ".join(
|
|
40
|
+
f"- {tool}({args}): {err}" for tool, args, err in attempts
|
|
41
|
+
)
|
|
42
|
+
super().__init__(f"All Linear MCP attempts failed:\n {summary}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_MCP_ERROR_PATTERN = re.compile(r"^(error\s*:|mcp error -?\d+\s*:)", re.IGNORECASE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _looks_like_mcp_error(text: str) -> bool:
|
|
49
|
+
"""True when an MCP tool's text response is itself an error payload.
|
|
50
|
+
|
|
51
|
+
The Linear MCP returns validation errors as a normal text content block
|
|
52
|
+
rather than as a JSON-RPC error, so a naive parser sees them as success.
|
|
53
|
+
Detect the leading ``Error:``/``MCP error -32602:`` marker (and the
|
|
54
|
+
common ``Input validation error`` body) and treat as failure.
|
|
55
|
+
"""
|
|
56
|
+
if not text:
|
|
57
|
+
return False
|
|
58
|
+
head = text.strip()[:200]
|
|
59
|
+
return bool(_MCP_ERROR_PATTERN.match(head)) or "Input validation error" in head
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_OPEN_STATUS_TYPES = {"backlog", "unstarted", "started", "triage"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _get_linear_config(workspace_root: Path) -> dict:
|
|
66
|
+
"""Get Linear MCP config, raising if not configured."""
|
|
67
|
+
config = get_mcp_config(workspace_root, "linear")
|
|
68
|
+
if config is None:
|
|
69
|
+
raise LinearNotConfiguredError(
|
|
70
|
+
"Linear MCP not configured.\n"
|
|
71
|
+
"Add a 'linear' entry to .canopy/mcps.json:\n"
|
|
72
|
+
" {\n"
|
|
73
|
+
' "linear": {\n'
|
|
74
|
+
' "command": "npx",\n'
|
|
75
|
+
' "args": ["-y", "linear-mcp-server"],\n'
|
|
76
|
+
' "env": {"LINEAR_API_KEY": "lin_api_..."}\n'
|
|
77
|
+
" }\n"
|
|
78
|
+
" }"
|
|
79
|
+
)
|
|
80
|
+
return config
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_linear_configured(workspace_root: Path) -> bool:
|
|
84
|
+
"""Check if Linear MCP is set up."""
|
|
85
|
+
return is_mcp_configured(workspace_root, "linear")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_issue_result(result: Any) -> dict | None:
|
|
89
|
+
"""Extract issue data from an MCP tool call result.
|
|
90
|
+
|
|
91
|
+
MCP results come as a CallToolResult with .content blocks.
|
|
92
|
+
Linear MCP tools typically return a single text block with JSON.
|
|
93
|
+
"""
|
|
94
|
+
if result is None:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
# result is a CallToolResult — iterate content blocks
|
|
98
|
+
for block in result.content:
|
|
99
|
+
if hasattr(block, "text") and block.text:
|
|
100
|
+
text = block.text.strip()
|
|
101
|
+
# Inline MCP error surfaced as text content (Linear MCP does this
|
|
102
|
+
# for validation failures). Treat as failure so the caller falls
|
|
103
|
+
# through to the next tool/args attempt instead of normalizing
|
|
104
|
+
# into an empty issue.
|
|
105
|
+
if _looks_like_mcp_error(text):
|
|
106
|
+
return None
|
|
107
|
+
# Try to parse as JSON
|
|
108
|
+
if text.startswith("{") or text.startswith("["):
|
|
109
|
+
import json
|
|
110
|
+
try:
|
|
111
|
+
return json.loads(text)
|
|
112
|
+
except json.JSONDecodeError:
|
|
113
|
+
pass
|
|
114
|
+
# If not JSON, return as raw text
|
|
115
|
+
return {"raw": text}
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_issue(workspace_root: Path, issue_id: str) -> dict:
|
|
120
|
+
"""Fetch a Linear issue by identifier (e.g. ENG-123).
|
|
121
|
+
|
|
122
|
+
Returns a dict with at least: identifier, title, state, url.
|
|
123
|
+
The exact shape depends on the Linear MCP server's response.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
LinearNotConfiguredError: If Linear MCP isn't in mcps.json.
|
|
127
|
+
LinearIssueNotFoundError: If the issue doesn't exist.
|
|
128
|
+
McpClientError: If the MCP call fails.
|
|
129
|
+
"""
|
|
130
|
+
config = _get_linear_config(workspace_root)
|
|
131
|
+
|
|
132
|
+
# Canonical Linear MCP (mcp.linear.app) is first; legacy servers follow.
|
|
133
|
+
tool_attempts = [
|
|
134
|
+
("get_issue", {"id": issue_id}),
|
|
135
|
+
("get_issue", {"issue_id": issue_id}),
|
|
136
|
+
("linear_get_issue", {"issueId": issue_id}),
|
|
137
|
+
("get_issue", {"issueId": issue_id}),
|
|
138
|
+
("search_issues", {"query": issue_id}),
|
|
139
|
+
("linear_search_issues", {"query": issue_id}),
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
last_error = None
|
|
143
|
+
for tool_name, args in tool_attempts:
|
|
144
|
+
try:
|
|
145
|
+
result = call_tool(config, tool_name, args, timeout=15.0, server_name="linear")
|
|
146
|
+
parsed = _parse_issue_result(result)
|
|
147
|
+
if parsed:
|
|
148
|
+
return _normalize_issue(parsed, issue_id)
|
|
149
|
+
except McpClientError as e:
|
|
150
|
+
last_error = e
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
raise LinearIssueNotFoundError(
|
|
154
|
+
f"Could not fetch issue '{issue_id}' from Linear MCP. "
|
|
155
|
+
f"Last error: {last_error}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _normalize_issue(data: dict, original_id: str) -> dict:
|
|
160
|
+
"""Normalize issue data into a consistent shape.
|
|
161
|
+
|
|
162
|
+
Different MCP servers return different schemas. This normalizes
|
|
163
|
+
to: {identifier, title, state, url, description, raw}.
|
|
164
|
+
"""
|
|
165
|
+
# If it's a search result (list), take the first match
|
|
166
|
+
if isinstance(data, list):
|
|
167
|
+
if not data:
|
|
168
|
+
raise LinearIssueNotFoundError(f"No results for '{original_id}'")
|
|
169
|
+
data = data[0]
|
|
170
|
+
|
|
171
|
+
# Handle nested results (some MCPs wrap in {issues: [...]})
|
|
172
|
+
if "issues" in data and isinstance(data["issues"], list):
|
|
173
|
+
if not data["issues"]:
|
|
174
|
+
raise LinearIssueNotFoundError(f"No results for '{original_id}'")
|
|
175
|
+
data = data["issues"][0]
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
"identifier": data.get("identifier") or data.get("id") or original_id,
|
|
179
|
+
"title": data.get("title") or data.get("name") or "",
|
|
180
|
+
"state": (
|
|
181
|
+
data.get("state", {}).get("name")
|
|
182
|
+
if isinstance(data.get("state"), dict)
|
|
183
|
+
else data.get("state") or data.get("status") or ""
|
|
184
|
+
),
|
|
185
|
+
"url": data.get("url") or "",
|
|
186
|
+
"description": data.get("description") or "",
|
|
187
|
+
"raw": data,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def list_my_issues_strict(workspace_root: Path, limit: int = 25) -> list[dict]:
|
|
192
|
+
"""Fetch open Linear issues assigned to the current user; raise on failure.
|
|
193
|
+
|
|
194
|
+
Canonical Linear MCP (mcp.linear.app) attempts come first; legacy server
|
|
195
|
+
shapes follow as fallbacks. When the canonical attempt succeeds with a
|
|
196
|
+
full issue list, results are filtered agent-side to ``statusType in
|
|
197
|
+
{backlog, unstarted, started, triage}`` (Linear's ``state`` arg isn't
|
|
198
|
+
accepted on ``list_issues`` — filtering server-side is impossible).
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
LinearNotConfiguredError: Linear MCP not in mcps.json.
|
|
202
|
+
LinearCallError: Every tool/args combination failed. Carries the
|
|
203
|
+
per-attempt log so the caller can build a structured error.
|
|
204
|
+
"""
|
|
205
|
+
config = _get_linear_config(workspace_root)
|
|
206
|
+
|
|
207
|
+
tool_attempts = [
|
|
208
|
+
("list_issues", {"assignee": "me"}),
|
|
209
|
+
("list_my_issues", {}),
|
|
210
|
+
("linear_list_my_issues", {}),
|
|
211
|
+
("get_my_issues", {}),
|
|
212
|
+
("list_issues", {"assignee": "me", "state": "open"}),
|
|
213
|
+
("linear_list_issues", {"assignee": "me", "state": "open"}),
|
|
214
|
+
("search_issues", {"query": "assignee:me state:open"}),
|
|
215
|
+
]
|
|
216
|
+
attempts_log: list[tuple[str, dict, str]] = []
|
|
217
|
+
|
|
218
|
+
for tool_name, args in tool_attempts:
|
|
219
|
+
try:
|
|
220
|
+
result = call_tool(
|
|
221
|
+
config, tool_name, args, timeout=15.0, server_name="linear",
|
|
222
|
+
)
|
|
223
|
+
except McpClientError as e:
|
|
224
|
+
attempts_log.append((tool_name, args, str(e)))
|
|
225
|
+
continue
|
|
226
|
+
parsed = _parse_issue_result(result)
|
|
227
|
+
if parsed is None:
|
|
228
|
+
attempts_log.append(
|
|
229
|
+
(tool_name, args, "no usable response (parse failed or inline MCP error)"),
|
|
230
|
+
)
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
items = parsed
|
|
234
|
+
if isinstance(items, dict):
|
|
235
|
+
for key in ("issues", "results", "data", "items"):
|
|
236
|
+
if isinstance(items.get(key), list):
|
|
237
|
+
items = items[key]
|
|
238
|
+
break
|
|
239
|
+
if not isinstance(items, list):
|
|
240
|
+
attempts_log.append(
|
|
241
|
+
(tool_name, args, f"unexpected response shape: {type(items).__name__}"),
|
|
242
|
+
)
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
normalized = []
|
|
246
|
+
for entry in items[:limit]:
|
|
247
|
+
if not isinstance(entry, dict):
|
|
248
|
+
continue
|
|
249
|
+
status_type = entry.get("statusType")
|
|
250
|
+
if status_type and str(status_type).lower() not in _OPEN_STATUS_TYPES:
|
|
251
|
+
continue
|
|
252
|
+
issue = _normalize_issue(
|
|
253
|
+
entry, entry.get("identifier", entry.get("id", "")),
|
|
254
|
+
)
|
|
255
|
+
normalized.append({
|
|
256
|
+
"identifier": issue["identifier"],
|
|
257
|
+
"title": issue["title"],
|
|
258
|
+
"state": issue["state"],
|
|
259
|
+
"url": issue["url"],
|
|
260
|
+
})
|
|
261
|
+
if normalized:
|
|
262
|
+
return normalized
|
|
263
|
+
attempts_log.append((tool_name, args, "no open issues in response"))
|
|
264
|
+
|
|
265
|
+
raise LinearCallError(attempts_log)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def list_my_issues(workspace_root: Path, limit: int = 25) -> list[dict]:
|
|
269
|
+
"""Soft wrapper around :func:`list_my_issues_strict`.
|
|
270
|
+
|
|
271
|
+
Returns ``[]`` whenever Linear isn't configured or every attempt failed,
|
|
272
|
+
preserving the existing "no autocomplete available" contract for the
|
|
273
|
+
VSCode extension's Create Feature quick pick.
|
|
274
|
+
|
|
275
|
+
Agent-facing surfaces should call :func:`list_my_issues_strict` and
|
|
276
|
+
convert :class:`LinearCallError` into a structured ``BlockerError`` so
|
|
277
|
+
the agent sees the real reason instead of an empty list.
|
|
278
|
+
"""
|
|
279
|
+
if not is_linear_configured(workspace_root):
|
|
280
|
+
return []
|
|
281
|
+
try:
|
|
282
|
+
return list_my_issues_strict(workspace_root, limit=limit)
|
|
283
|
+
except (LinearNotConfiguredError, LinearCallError):
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def format_branch_name(issue_id: str, title: str = "", custom_name: str = "") -> str:
|
|
288
|
+
"""Format a branch name from a Linear issue.
|
|
289
|
+
|
|
290
|
+
If custom_name is provided, use it as-is.
|
|
291
|
+
Otherwise, format as: <issue-id>-<slugified-title>
|
|
292
|
+
|
|
293
|
+
Examples:
|
|
294
|
+
format_branch_name("ENG-123", "Add payment flow") → "ENG-123-add-payment-flow"
|
|
295
|
+
format_branch_name("ENG-123", "", "payment-flow") → "payment-flow"
|
|
296
|
+
"""
|
|
297
|
+
if custom_name:
|
|
298
|
+
return custom_name
|
|
299
|
+
|
|
300
|
+
if not title:
|
|
301
|
+
return issue_id.lower()
|
|
302
|
+
|
|
303
|
+
slug = re.sub(r"[^a-zA-Z0-9\s-]", "", title)
|
|
304
|
+
slug = re.sub(r"\s+", "-", slug.strip()).lower()
|
|
305
|
+
slug = slug[:50].rstrip("-") # Cap length
|
|
306
|
+
|
|
307
|
+
return f"{issue_id}-{slug}".lower()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pre-commit hook detection and execution.
|
|
3
|
+
|
|
4
|
+
Detects whether a repo uses the pre-commit framework (.pre-commit-config.yaml),
|
|
5
|
+
bare git hooks (.git/hooks/pre-commit), or neither. Runs whichever is present
|
|
6
|
+
and returns structured results.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PrecommitError(Exception):
|
|
15
|
+
"""A pre-commit hook failed."""
|
|
16
|
+
def __init__(self, message: str, output: str = "", returncode: int = 1):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.output = output
|
|
19
|
+
self.returncode = returncode
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def detect_precommit(repo_path: Path) -> str:
|
|
23
|
+
"""Detect which pre-commit system a repo uses.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
"framework" — .pre-commit-config.yaml exists (pre-commit framework)
|
|
27
|
+
"git_hook" — .git/hooks/pre-commit exists and is executable
|
|
28
|
+
"none" — no pre-commit hooks detected
|
|
29
|
+
"""
|
|
30
|
+
# Check for pre-commit framework
|
|
31
|
+
if (repo_path / ".pre-commit-config.yaml").exists():
|
|
32
|
+
return "framework"
|
|
33
|
+
|
|
34
|
+
# Check for bare git hook
|
|
35
|
+
# Handle both normal repos (.git is directory) and worktrees (.git is file)
|
|
36
|
+
git_path = repo_path / ".git"
|
|
37
|
+
if git_path.is_dir():
|
|
38
|
+
hook = git_path / "hooks" / "pre-commit"
|
|
39
|
+
if hook.exists() and _is_executable(hook):
|
|
40
|
+
return "git_hook"
|
|
41
|
+
elif git_path.is_file():
|
|
42
|
+
# Worktree — .git is a file pointing to the main repo's .git dir
|
|
43
|
+
# Hooks live in the main repo's hooks dir, but git resolves this
|
|
44
|
+
# automatically when we run `git hook run`
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
["git", "rev-parse", "--git-common-dir"],
|
|
47
|
+
capture_output=True, text=True, cwd=repo_path,
|
|
48
|
+
)
|
|
49
|
+
if result.returncode == 0:
|
|
50
|
+
common_dir = (repo_path / result.stdout.strip()).resolve()
|
|
51
|
+
hook = common_dir / "hooks" / "pre-commit"
|
|
52
|
+
if hook.exists() and _is_executable(hook):
|
|
53
|
+
return "git_hook"
|
|
54
|
+
|
|
55
|
+
return "none"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_executable(path: Path) -> bool:
|
|
59
|
+
"""Check if a file is executable."""
|
|
60
|
+
import os
|
|
61
|
+
return os.access(path, os.X_OK)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_precommit(repo_path: Path, augments: dict | None = None) -> dict:
|
|
65
|
+
"""Run pre-commit hooks for a repo.
|
|
66
|
+
|
|
67
|
+
If ``augments`` contains ``preflight_cmd``, runs that command instead
|
|
68
|
+
of the auto-detected hook system. Otherwise detects the hook system
|
|
69
|
+
(pre-commit framework / git hook / none) and runs it.
|
|
70
|
+
|
|
71
|
+
Returns a result dict with:
|
|
72
|
+
type: "custom" | "framework" | "git_hook" | "none"
|
|
73
|
+
passed: bool
|
|
74
|
+
output: str (combined stdout + stderr)
|
|
75
|
+
applied_augment: bool (True iff preflight_cmd from augments ran)
|
|
76
|
+
|
|
77
|
+
Does not raise on hook failure — the caller decides what to do.
|
|
78
|
+
"""
|
|
79
|
+
custom_cmd = (augments or {}).get("preflight_cmd")
|
|
80
|
+
if custom_cmd:
|
|
81
|
+
return _run_custom_preflight(repo_path, str(custom_cmd))
|
|
82
|
+
|
|
83
|
+
hook_type = detect_precommit(repo_path)
|
|
84
|
+
|
|
85
|
+
if hook_type == "none":
|
|
86
|
+
return {
|
|
87
|
+
"type": "none",
|
|
88
|
+
"passed": True,
|
|
89
|
+
"output": "No pre-commit hooks detected.",
|
|
90
|
+
"applied_augment": False,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if hook_type == "framework":
|
|
94
|
+
result = _run_framework(repo_path)
|
|
95
|
+
else:
|
|
96
|
+
result = _run_git_hook(repo_path)
|
|
97
|
+
result["applied_augment"] = False
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _run_custom_preflight(repo_path: Path, command: str) -> dict:
|
|
102
|
+
"""Run a user-configured preflight command via the shell.
|
|
103
|
+
|
|
104
|
+
Honors the augment's ``preflight_cmd`` literal — supports pipes /
|
|
105
|
+
chaining (e.g. ``ruff check . && pyright``) by passing through ``sh -c``.
|
|
106
|
+
"""
|
|
107
|
+
result = subprocess.run(
|
|
108
|
+
["sh", "-c", command],
|
|
109
|
+
capture_output=True,
|
|
110
|
+
text=True,
|
|
111
|
+
cwd=repo_path,
|
|
112
|
+
timeout=120,
|
|
113
|
+
)
|
|
114
|
+
output = result.stdout
|
|
115
|
+
if result.stderr:
|
|
116
|
+
output += "\n" + result.stderr
|
|
117
|
+
return {
|
|
118
|
+
"type": "custom",
|
|
119
|
+
"passed": result.returncode == 0,
|
|
120
|
+
"output": output.strip(),
|
|
121
|
+
"applied_augment": True,
|
|
122
|
+
"command": command,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _run_framework(repo_path: Path) -> dict:
|
|
127
|
+
"""Run `pre-commit run --all-files`."""
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
["pre-commit", "run", "--all-files"],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
cwd=repo_path,
|
|
133
|
+
timeout=120,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
output = result.stdout
|
|
137
|
+
if result.stderr:
|
|
138
|
+
output += "\n" + result.stderr
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
"type": "framework",
|
|
142
|
+
"passed": result.returncode == 0,
|
|
143
|
+
"output": output.strip(),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _run_git_hook(repo_path: Path) -> dict:
|
|
148
|
+
"""Run git hook via `git hook run pre-commit`."""
|
|
149
|
+
# Try `git hook run` first (Git 2.36+), fall back to direct execution
|
|
150
|
+
result = subprocess.run(
|
|
151
|
+
["git", "hook", "run", "pre-commit"],
|
|
152
|
+
capture_output=True,
|
|
153
|
+
text=True,
|
|
154
|
+
cwd=repo_path,
|
|
155
|
+
timeout=120,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Check if `git hook run` isn't available (older Git versions)
|
|
159
|
+
stderr_lower = result.stderr.lower()
|
|
160
|
+
needs_fallback = (
|
|
161
|
+
result.returncode != 0
|
|
162
|
+
and ("not a git command" in stderr_lower
|
|
163
|
+
or "not found" in stderr_lower
|
|
164
|
+
or "unknown command" in stderr_lower)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if needs_fallback:
|
|
168
|
+
# Direct execution fallback
|
|
169
|
+
hook_path = _resolve_hook_path(repo_path)
|
|
170
|
+
if hook_path and hook_path.exists():
|
|
171
|
+
result = subprocess.run(
|
|
172
|
+
[str(hook_path)],
|
|
173
|
+
capture_output=True,
|
|
174
|
+
text=True,
|
|
175
|
+
cwd=repo_path,
|
|
176
|
+
timeout=120,
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
return {
|
|
180
|
+
"type": "git_hook",
|
|
181
|
+
"passed": True,
|
|
182
|
+
"output": "Git hook not found at resolved path.",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
output = result.stdout
|
|
186
|
+
if result.stderr:
|
|
187
|
+
output += "\n" + result.stderr
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
"type": "git_hook",
|
|
191
|
+
"passed": result.returncode == 0,
|
|
192
|
+
"output": output.strip(),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _resolve_hook_path(repo_path: Path) -> Path | None:
|
|
197
|
+
"""Resolve the pre-commit hook path, handling worktrees."""
|
|
198
|
+
git_path = repo_path / ".git"
|
|
199
|
+
if git_path.is_dir():
|
|
200
|
+
return git_path / "hooks" / "pre-commit"
|
|
201
|
+
|
|
202
|
+
# Worktree
|
|
203
|
+
result = subprocess.run(
|
|
204
|
+
["git", "rev-parse", "--git-common-dir"],
|
|
205
|
+
capture_output=True, text=True, cwd=repo_path,
|
|
206
|
+
)
|
|
207
|
+
if result.returncode == 0:
|
|
208
|
+
common_dir = (repo_path / result.stdout.strip()).resolve()
|
|
209
|
+
return common_dir / "hooks" / "pre-commit"
|
|
210
|
+
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def run_precommit_all(repo_paths: dict[str, Path]) -> dict[str, dict]:
|
|
215
|
+
"""Run pre-commit hooks across multiple repos.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
repo_paths: {repo_name: repo_path}
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
{repo_name: {type, passed, output}}
|
|
222
|
+
"""
|
|
223
|
+
results = {}
|
|
224
|
+
for name, path in repo_paths.items():
|
|
225
|
+
try:
|
|
226
|
+
results[name] = run_precommit(path)
|
|
227
|
+
except subprocess.TimeoutExpired:
|
|
228
|
+
results[name] = {
|
|
229
|
+
"type": detect_precommit(path),
|
|
230
|
+
"passed": False,
|
|
231
|
+
"output": "Pre-commit hook timed out (120s).",
|
|
232
|
+
}
|
|
233
|
+
except Exception as e:
|
|
234
|
+
results[name] = {
|
|
235
|
+
"type": "error",
|
|
236
|
+
"passed": False,
|
|
237
|
+
"output": f"Error running pre-commit: {e}",
|
|
238
|
+
}
|
|
239
|
+
return results
|
canopy/mcp/__init__.py
ADDED
|
File without changes
|