devcouncil 0.1.0 → 0.2.0
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.
- package/LICENSE +201 -201
- package/README.md +197 -494
- package/package.json +9 -2
- package/pyproject.toml +62 -27
- package/src/devcouncil/__main__.py +4 -4
- package/src/devcouncil/app/__init__.py +28 -28
- package/src/devcouncil/app/config.py +297 -108
- package/src/devcouncil/app/errors.py +23 -23
- package/src/devcouncil/app/events.py +44 -44
- package/src/devcouncil/app/orchestrator.py +67 -67
- package/src/devcouncil/app/project_status.py +29 -0
- package/src/devcouncil/app/run_context.py +39 -39
- package/src/devcouncil/app/state_machine.py +108 -108
- package/src/devcouncil/artifacts/__init__.py +1 -1
- package/src/devcouncil/artifacts/coverage.py +96 -96
- package/src/devcouncil/artifacts/graph.py +163 -143
- package/src/devcouncil/artifacts/migrations.py +20 -20
- package/src/devcouncil/artifacts/schemas.py +23 -23
- package/src/devcouncil/artifacts/serializer.py +21 -21
- package/src/devcouncil/artifacts/validators.py +27 -27
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +54 -48
- package/src/devcouncil/cli/commands/ast.py +22 -0
- package/src/devcouncil/cli/commands/baseline.py +35 -32
- package/src/devcouncil/cli/commands/check.py +209 -0
- package/src/devcouncil/cli/commands/config.py +115 -54
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +31 -0
- package/src/devcouncil/cli/commands/doctor.py +291 -47
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +656 -0
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +209 -33
- package/src/devcouncil/cli/commands/init.py +204 -57
- package/src/devcouncil/cli/commands/integrate.py +1171 -76
- package/src/devcouncil/cli/commands/lsp.py +20 -0
- package/src/devcouncil/cli/commands/map.py +96 -22
- package/src/devcouncil/cli/commands/plan.py +422 -210
- package/src/devcouncil/cli/commands/prompt.py +48 -34
- package/src/devcouncil/cli/commands/repair.py +89 -69
- package/src/devcouncil/cli/commands/report.py +120 -54
- package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
- package/src/devcouncil/cli/commands/rollback.py +55 -54
- package/src/devcouncil/cli/commands/run.py +285 -220
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +300 -20
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/show.py +76 -57
- package/src/devcouncil/cli/commands/skills.py +88 -0
- package/src/devcouncil/cli/commands/status.py +141 -105
- package/src/devcouncil/cli/commands/tasks.py +55 -41
- package/src/devcouncil/cli/commands/trace.py +49 -4
- package/src/devcouncil/cli/commands/verify.py +293 -128
- package/src/devcouncil/cli/commands/version.py +20 -20
- package/src/devcouncil/cli/commands/watch.py +574 -0
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +92 -25
- package/src/devcouncil/council/prompts/arbiter.md +19 -19
- package/src/devcouncil/council/prompts/critic_a.md +10 -10
- package/src/devcouncil/council/prompts/critic_b.md +10 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
- package/src/devcouncil/council/prompts/planner_a.md +16 -16
- package/src/devcouncil/council/prompts/planner_b.md +16 -16
- package/src/devcouncil/council/prompts/rebuttal.md +10 -10
- package/src/devcouncil/council/prompts/spec_writer.md +12 -12
- package/src/devcouncil/domain/assumption.py +17 -17
- package/src/devcouncil/domain/critique.py +32 -32
- package/src/devcouncil/domain/evidence.py +47 -27
- package/src/devcouncil/domain/gap.py +52 -26
- package/src/devcouncil/domain/requirement.py +22 -22
- package/src/devcouncil/domain/task.py +55 -26
- package/src/devcouncil/execution/__init__.py +1 -1
- package/src/devcouncil/execution/checkpoints.py +246 -0
- package/src/devcouncil/execution/context_builder.py +54 -54
- package/src/devcouncil/execution/executor.py +15 -15
- package/src/devcouncil/execution/fs_watcher.py +180 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +186 -77
- package/src/devcouncil/execution/patch.py +77 -28
- package/src/devcouncil/execution/permissions.py +52 -59
- package/src/devcouncil/execution/policy_engine.py +343 -0
- package/src/devcouncil/execution/prompt_builder.py +650 -38
- package/src/devcouncil/execution/shell_session.py +225 -0
- package/src/devcouncil/execution/task_runner.py +68 -64
- package/src/devcouncil/executors/__init__.py +1 -1
- package/src/devcouncil/executors/agent_registry.py +575 -0
- package/src/devcouncil/executors/coding_cli.py +736 -0
- package/src/devcouncil/executors/mini_swe.py +63 -63
- package/src/devcouncil/executors/native/agent.py +186 -85
- package/src/devcouncil/executors/openhands.py +56 -56
- package/src/devcouncil/gating/__init__.py +1 -1
- package/src/devcouncil/gating/checks/clean_git.py +52 -45
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
- package/src/devcouncil/gating/policy.py +315 -167
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/__init__.py +1 -1
- package/src/devcouncil/indexing/ast_matcher.py +168 -0
- package/src/devcouncil/indexing/graph_index.py +48 -48
- package/src/devcouncil/indexing/lsp.py +161 -0
- package/src/devcouncil/indexing/repo_mapper.py +1455 -204
- package/src/devcouncil/indexing/semantic_index.py +205 -0
- package/src/devcouncil/integrations/actions.py +146 -0
- package/src/devcouncil/integrations/check.py +423 -0
- package/src/devcouncil/integrations/github.py +35 -35
- package/src/devcouncil/integrations/github_intent.py +142 -0
- package/src/devcouncil/integrations/gitnexus.py +62 -27
- package/src/devcouncil/integrations/graphify.py +34 -34
- package/src/devcouncil/integrations/mcp/server.py +2072 -96
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +62 -0
- package/src/devcouncil/live/__init__.py +2 -0
- package/src/devcouncil/live/cards.py +349 -0
- package/src/devcouncil/live/models.py +63 -0
- package/src/devcouncil/live/repair_prompt.py +83 -0
- package/src/devcouncil/live/reviewer.py +70 -0
- package/src/devcouncil/live/signals.py +135 -0
- package/src/devcouncil/live/summary.py +34 -0
- package/src/devcouncil/live/tasks.py +18 -0
- package/src/devcouncil/live/transcripts.py +141 -0
- package/src/devcouncil/llm/__init__.py +1 -1
- package/src/devcouncil/llm/cache.py +42 -38
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +627 -125
- package/src/devcouncil/llm/router.py +303 -118
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/planning/__init__.py +1 -1
- package/src/devcouncil/planning/arbiter_service.py +57 -57
- package/src/devcouncil/planning/correction_manifest.py +303 -0
- package/src/devcouncil/planning/critique_service.py +71 -66
- package/src/devcouncil/planning/plan_service.py +60 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
- package/src/devcouncil/planning/repair_service.py +39 -39
- package/src/devcouncil/planning/spec_service.py +70 -44
- package/src/devcouncil/repo/ci_scaffold.py +157 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +374 -0
- package/src/devcouncil/reporting/github_check.py +32 -32
- package/src/devcouncil/reporting/json_report.py +30 -17
- package/src/devcouncil/reporting/markdown_report.py +83 -46
- package/src/devcouncil/reporting/report_builder.py +14 -14
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +330 -0
- package/src/devcouncil/storage/db.py +147 -66
- package/src/devcouncil/storage/models.py +204 -83
- package/src/devcouncil/storage/native.py +557 -0
- package/src/devcouncil/storage/repositories.py +388 -249
- package/src/devcouncil/telemetry/cost.py +140 -34
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +52 -49
- package/src/devcouncil/ui/__init__.py +1 -0
- package/src/devcouncil/ui/dashboard.py +423 -0
- package/src/devcouncil/utils/__init__.py +1 -1
- package/src/devcouncil/utils/redaction.py +147 -141
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/__init__.py +1 -1
- package/src/devcouncil/verification/acceptance_compiler.py +125 -0
- package/src/devcouncil/verification/ad_hoc_check.py +129 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +55 -55
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +178 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1342 -307
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Shared integration readiness checks for `dev integrate check`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from devcouncil.executors.agent_registry import (
|
|
14
|
+
CODING_CLI_INTEGRATION_INFO,
|
|
15
|
+
CODING_CLI_VERSION_COMMANDS,
|
|
16
|
+
detect_available_coding_cli,
|
|
17
|
+
resolve_automated_executor,
|
|
18
|
+
resolve_coding_cli_executable,
|
|
19
|
+
resolve_coding_cli_probe_order,
|
|
20
|
+
)
|
|
21
|
+
from devcouncil.utils.subprocess_env import clean_subprocess_env
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class IntegrationCheckRow:
|
|
26
|
+
name: str
|
|
27
|
+
status: str
|
|
28
|
+
details: str
|
|
29
|
+
|
|
30
|
+
def as_dict(self) -> dict[str, str]:
|
|
31
|
+
return {"name": self.name, "status": self.status, "details": self.details}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class IntegrationCheckReport:
|
|
36
|
+
checks: tuple[IntegrationCheckRow, ...]
|
|
37
|
+
recommended_executor: str | None
|
|
38
|
+
failures: int
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def ok(self) -> bool:
|
|
42
|
+
return self.failures == 0
|
|
43
|
+
|
|
44
|
+
def as_dict(self) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"ok": self.ok,
|
|
47
|
+
"failures": self.failures,
|
|
48
|
+
"recommended_executor": self.recommended_executor,
|
|
49
|
+
"checks": [row.as_dict() for row in self.checks],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def to_json(self, *, indent: int | None = 2) -> str:
|
|
53
|
+
return json.dumps(self.as_dict(), indent=indent)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
CODING_CLI_CHECK_LABELS: dict[str, str] = {
|
|
57
|
+
"codex": "Codex CLI",
|
|
58
|
+
"gemini": "Gemini CLI",
|
|
59
|
+
"claude": "Claude Code",
|
|
60
|
+
"cursor": "Cursor",
|
|
61
|
+
"opencode": "OpenCode",
|
|
62
|
+
"antigravity": "Google Antigravity CLI",
|
|
63
|
+
"warp": "Warp / Oz",
|
|
64
|
+
"aider": "Aider",
|
|
65
|
+
"copilot": "GitHub Copilot CLI",
|
|
66
|
+
"goose": "Goose",
|
|
67
|
+
"amp": "Amp (Sourcegraph)",
|
|
68
|
+
"qwen": "Qwen Code",
|
|
69
|
+
"crush": "Crush (Charm)",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def probe_cli_version(command: list[str], *, timeout: int = 10) -> tuple[int, str]:
|
|
74
|
+
executable = shutil.which(command[0])
|
|
75
|
+
if not executable:
|
|
76
|
+
return 127, f"{command[0]} not found on PATH"
|
|
77
|
+
|
|
78
|
+
resolved = [executable, *command[1:]]
|
|
79
|
+
use_shell = sys.platform == "win32" and Path(executable).suffix.lower() in {".bat", ".cmd", ".ps1"}
|
|
80
|
+
invocation = subprocess.list2cmdline(resolved) if use_shell else resolved
|
|
81
|
+
try:
|
|
82
|
+
result = subprocess.run(
|
|
83
|
+
invocation,
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
encoding="utf-8",
|
|
87
|
+
errors="replace",
|
|
88
|
+
shell=use_shell,
|
|
89
|
+
timeout=timeout,
|
|
90
|
+
env=clean_subprocess_env(),
|
|
91
|
+
)
|
|
92
|
+
except subprocess.TimeoutExpired:
|
|
93
|
+
return 124, "timed out"
|
|
94
|
+
except (FileNotFoundError, OSError) as exc:
|
|
95
|
+
return 127, f"{command[0]} could not be executed: {exc}"
|
|
96
|
+
return result.returncode, (result.stdout + result.stderr).strip()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def probe_coding_cli_version(client: str) -> tuple[bool, str]:
|
|
100
|
+
label = CODING_CLI_CHECK_LABELS.get(client, client)
|
|
101
|
+
commands = CODING_CLI_VERSION_COMMANDS.get(client, ())
|
|
102
|
+
for command in commands:
|
|
103
|
+
code, output = probe_cli_version(list(command))
|
|
104
|
+
if code == 0:
|
|
105
|
+
first_line = output.splitlines()[0] if output else "installed"
|
|
106
|
+
return True, first_line
|
|
107
|
+
return False, f"Optional; install {label} to use this integration."
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def recommended_executor_status(project_root: Path) -> tuple[bool, str]:
|
|
111
|
+
detected = detect_available_coding_cli(project_root)
|
|
112
|
+
if not detected:
|
|
113
|
+
return False, "No built-in coding CLI on PATH. Run dev integrate recommend after installing one."
|
|
114
|
+
resolved = resolve_automated_executor(project_root, None)
|
|
115
|
+
return True, f"Use --executor {resolved} for dev go / dev run (detected: {detected})."
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def coding_clis_on_path(project_root: Path) -> list[str]:
|
|
119
|
+
order = resolve_coding_cli_probe_order(project_root)
|
|
120
|
+
return [client for client in order if resolve_coding_cli_executable(project_root, client)]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _load_json_file(path: Path) -> dict[str, Any]:
|
|
124
|
+
if not path.exists():
|
|
125
|
+
return {}
|
|
126
|
+
try:
|
|
127
|
+
loaded = json.loads(path.read_text(encoding="utf-8")) or {}
|
|
128
|
+
except json.JSONDecodeError:
|
|
129
|
+
return {}
|
|
130
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _cursor_config_status(project_root: Path) -> tuple[str, bool, list[str]]:
|
|
134
|
+
path = project_root / ".cursor" / "mcp.json"
|
|
135
|
+
data = _load_json_file(path)
|
|
136
|
+
server = ((data.get("mcpServers") or {}).get("devcouncil") or {}) if data else {}
|
|
137
|
+
ok = (
|
|
138
|
+
server.get("type") == "stdio"
|
|
139
|
+
and server.get("command") == "devcouncil"
|
|
140
|
+
and server.get("args") == ["mcp-server"]
|
|
141
|
+
and (server.get("env") or {}).get("DEVCOUNCIL_PROJECT_ROOT") == str(project_root)
|
|
142
|
+
)
|
|
143
|
+
if ok:
|
|
144
|
+
return "ok", False, [str(path)]
|
|
145
|
+
return ("missing" if not path.exists() else "drifted"), True, [str(path)]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _opencode_config_status(project_root: Path) -> tuple[str, bool, list[str]]:
|
|
149
|
+
path = project_root / "opencode.json"
|
|
150
|
+
data = _load_json_file(path)
|
|
151
|
+
server = ((data.get("mcp") or {}).get("devcouncil") or {}) if data else {}
|
|
152
|
+
plugin = data.get("plugin") or []
|
|
153
|
+
plugin_ok = "./.devcouncil/integrations/opencode_devcouncil_plugin.mjs" in plugin
|
|
154
|
+
ok = (
|
|
155
|
+
server.get("type") == "local"
|
|
156
|
+
and server.get("command") == ["devcouncil", "mcp-server"]
|
|
157
|
+
and (server.get("environment") or {}).get("DEVCOUNCIL_PROJECT_ROOT") == str(project_root)
|
|
158
|
+
)
|
|
159
|
+
status = "ok" if ok else ("missing" if not path.exists() else "drifted")
|
|
160
|
+
return status, not ok or not plugin_ok, [str(path), str(project_root / ".devcouncil" / "integrations" / "opencode_devcouncil_plugin.mjs")]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _antigravity_config_status(project_root: Path) -> tuple[str, bool, list[str]]:
|
|
164
|
+
path = project_root / ".agents" / "mcp_config.json"
|
|
165
|
+
data = _load_json_file(path)
|
|
166
|
+
server = ((data.get("mcpServers") or {}).get("devcouncil") or {}) if data else {}
|
|
167
|
+
ok = (
|
|
168
|
+
server.get("command") == "devcouncil"
|
|
169
|
+
and server.get("args") == ["mcp-server"]
|
|
170
|
+
and server.get("cwd") == str(project_root)
|
|
171
|
+
and (server.get("env") or {}).get("DEVCOUNCIL_PROJECT_ROOT") == str(project_root)
|
|
172
|
+
)
|
|
173
|
+
if ok:
|
|
174
|
+
return "ok", False, [str(path)]
|
|
175
|
+
return ("missing" if not path.exists() else "drifted"), True, [str(path)]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _warp_config_status(project_root: Path) -> tuple[str, bool, list[str]]:
|
|
179
|
+
path = project_root / ".devcouncil" / "integrations" / "warp-mcp.json"
|
|
180
|
+
data = _load_json_file(path)
|
|
181
|
+
server = data.get("devcouncil") or {}
|
|
182
|
+
ok = (
|
|
183
|
+
"mcpServers" not in data
|
|
184
|
+
and server.get("command") == "devcouncil"
|
|
185
|
+
and server.get("args") == ["mcp-server"]
|
|
186
|
+
and (server.get("env") or {}).get("DEVCOUNCIL_PROJECT_ROOT") == str(project_root)
|
|
187
|
+
)
|
|
188
|
+
if ok:
|
|
189
|
+
return "ok", False, [str(path)]
|
|
190
|
+
return ("missing" if not path.exists() else "drifted"), True, [str(path)]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def integration_capability_rows(project_root: Path) -> list[dict[str, object]]:
|
|
194
|
+
order = resolve_coding_cli_probe_order(project_root)
|
|
195
|
+
rows: list[dict[str, object]] = []
|
|
196
|
+
for client in order:
|
|
197
|
+
info = CODING_CLI_INTEGRATION_INFO.get(client)
|
|
198
|
+
if info is None:
|
|
199
|
+
continue
|
|
200
|
+
config_status = "not_applicable"
|
|
201
|
+
fixable = bool(info.mcp or info.hooks or info.launcher_shim)
|
|
202
|
+
paths: list[str] = []
|
|
203
|
+
if client == "cursor":
|
|
204
|
+
config_status, fixable, paths = _cursor_config_status(project_root)
|
|
205
|
+
elif client == "opencode":
|
|
206
|
+
config_status, fixable, paths = _opencode_config_status(project_root)
|
|
207
|
+
elif client == "antigravity":
|
|
208
|
+
config_status, fixable, paths = _antigravity_config_status(project_root)
|
|
209
|
+
elif client == "warp":
|
|
210
|
+
config_status, fixable, paths = _warp_config_status(project_root)
|
|
211
|
+
|
|
212
|
+
rows.append({
|
|
213
|
+
"name": info.name,
|
|
214
|
+
"label": info.label,
|
|
215
|
+
"on_path": resolve_coding_cli_executable(project_root, client) is not None,
|
|
216
|
+
"tier": info.tier,
|
|
217
|
+
"headless": info.headless,
|
|
218
|
+
"mcp": info.mcp,
|
|
219
|
+
"hooks": info.hooks,
|
|
220
|
+
"enforcement": info.enforcement,
|
|
221
|
+
"launcher_shim": info.launcher_shim,
|
|
222
|
+
"notes": info.notes,
|
|
223
|
+
"configured": config_status == "ok",
|
|
224
|
+
"config_status": config_status,
|
|
225
|
+
"fixable": fixable,
|
|
226
|
+
"paths": paths,
|
|
227
|
+
"apply_target": client,
|
|
228
|
+
})
|
|
229
|
+
return rows
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _hook_config_references_devcouncil(path: Path) -> bool | None:
|
|
233
|
+
"""Return whether a client hook config still wires DevCouncil's gate.
|
|
234
|
+
|
|
235
|
+
``True`` -> the file exists and references ``devcouncil`` somewhere in its hooks.
|
|
236
|
+
``False`` -> the file exists but no longer references it (tampered/disarmed).
|
|
237
|
+
``None`` -> the file does not exist (client was never integrated here).
|
|
238
|
+
|
|
239
|
+
Reads the raw text rather than parsing each client's bespoke schema so it works
|
|
240
|
+
uniformly across JSON hook files and is resilient to format drift; the goal is a
|
|
241
|
+
tamper tripwire, not full schema validation."""
|
|
242
|
+
if not path.exists():
|
|
243
|
+
return None
|
|
244
|
+
try:
|
|
245
|
+
text = path.read_text(encoding="utf-8")
|
|
246
|
+
except OSError:
|
|
247
|
+
return False
|
|
248
|
+
return "devcouncil" in text.lower()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _hook_config_tamper_targets(project_root: Path) -> list[tuple[str, Path]]:
|
|
252
|
+
return [
|
|
253
|
+
("Claude", project_root / ".claude" / "settings.local.json"),
|
|
254
|
+
("Codex", project_root / ".codex" / "hooks.json"),
|
|
255
|
+
("Gemini", project_root / ".gemini" / "settings.json"),
|
|
256
|
+
("Cursor", project_root / ".cursor" / "hooks.json"),
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def build_integration_check_report(project_root: Path, *, strict: bool = False) -> IntegrationCheckReport:
|
|
261
|
+
from devcouncil.cli.commands import integrate
|
|
262
|
+
|
|
263
|
+
rows: list[IntegrationCheckRow] = []
|
|
264
|
+
failures = 0
|
|
265
|
+
|
|
266
|
+
def add(ok: bool, name: str, details: str) -> None:
|
|
267
|
+
nonlocal failures
|
|
268
|
+
rows.append(IntegrationCheckRow(name=name, status="ok" if ok else "fail", details=details))
|
|
269
|
+
if not ok:
|
|
270
|
+
failures += 1
|
|
271
|
+
|
|
272
|
+
def add_optional(ok: bool, name: str, details: str) -> None:
|
|
273
|
+
if strict and not ok:
|
|
274
|
+
add(False, name, details)
|
|
275
|
+
return
|
|
276
|
+
rows.append(IntegrationCheckRow(name=name, status="ok" if ok else "missing", details=details))
|
|
277
|
+
|
|
278
|
+
def add_skip(name: str, details: str) -> None:
|
|
279
|
+
rows.append(IntegrationCheckRow(name=name, status="skip", details=details))
|
|
280
|
+
|
|
281
|
+
root = project_root.expanduser().resolve()
|
|
282
|
+
add((root / ".devcouncil").exists(), "Project state", str(root / ".devcouncil"))
|
|
283
|
+
devcouncil_path = shutil.which("devcouncil")
|
|
284
|
+
add(
|
|
285
|
+
devcouncil_path is not None or Path(sys.executable).exists(),
|
|
286
|
+
"devcouncil CLI",
|
|
287
|
+
devcouncil_path or f"{sys.executable} -m devcouncil",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
devcouncil_launch = [devcouncil_path] if devcouncil_path else [sys.executable, "-m", "devcouncil"]
|
|
291
|
+
code, output = integrate._run_capture([*devcouncil_launch, "--help"])
|
|
292
|
+
add(code == 0, "devcouncil command", output.splitlines()[0] if output else "No output")
|
|
293
|
+
|
|
294
|
+
for client in resolve_coding_cli_probe_order(root):
|
|
295
|
+
cli_ok, cli_details = probe_coding_cli_version(client)
|
|
296
|
+
add_optional(cli_ok, CODING_CLI_CHECK_LABELS.get(client, client), cli_details)
|
|
297
|
+
|
|
298
|
+
rec_ok, rec_details = recommended_executor_status(root)
|
|
299
|
+
add_optional(rec_ok, "Recommended coding CLI", rec_details)
|
|
300
|
+
|
|
301
|
+
for row in integration_capability_rows(root):
|
|
302
|
+
name = str(row["name"])
|
|
303
|
+
status = str(row["config_status"])
|
|
304
|
+
if status == "ok":
|
|
305
|
+
paths = row.get("paths")
|
|
306
|
+
path_list = paths if isinstance(paths, list) else []
|
|
307
|
+
add(True, f"{row['label']} config", ", ".join(str(path) for path in path_list))
|
|
308
|
+
elif status in {"missing", "drifted"}:
|
|
309
|
+
add_skip(f"{row['label']} config", f"Run dev integrate {name} --apply to repair.")
|
|
310
|
+
|
|
311
|
+
raw_config = integrate._load_raw_config(root) if (root / ".devcouncil").exists() else {}
|
|
312
|
+
|
|
313
|
+
cursor_hooks = root / ".cursor" / "hooks.json"
|
|
314
|
+
cursor_enabled = bool(raw_config.get("integrations", {}).get("cursor", {}).get("enabled"))
|
|
315
|
+
if cursor_enabled or cursor_hooks.exists():
|
|
316
|
+
hooks_ok = False
|
|
317
|
+
if cursor_hooks.exists():
|
|
318
|
+
try:
|
|
319
|
+
hooks_data = json.loads(cursor_hooks.read_text(encoding="utf-8")) or {}
|
|
320
|
+
hooks_ok = "preToolUse" in hooks_data.get("hooks", {})
|
|
321
|
+
except json.JSONDecodeError:
|
|
322
|
+
hooks_ok = False
|
|
323
|
+
add(
|
|
324
|
+
hooks_ok,
|
|
325
|
+
"Cursor hooks",
|
|
326
|
+
str(cursor_hooks) if hooks_ok else "Run dev integrate hooks --apply --tool cursor.",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
opencode_config = integrate._opencode_config_path(root)
|
|
330
|
+
opencode_enabled = bool(raw_config.get("integrations", {}).get("opencode", {}).get("enabled"))
|
|
331
|
+
opencode_plugin = integrate._opencode_plugin_path(root)
|
|
332
|
+
if opencode_enabled or opencode_plugin.exists():
|
|
333
|
+
plugin_ok = opencode_plugin.exists()
|
|
334
|
+
plugin_registered = False
|
|
335
|
+
if plugin_ok and opencode_config.exists():
|
|
336
|
+
try:
|
|
337
|
+
opencode_data = json.loads(opencode_config.read_text(encoding="utf-8")) or {}
|
|
338
|
+
plugin_registered = (
|
|
339
|
+
f"./.devcouncil/integrations/{integrate.OPENCODE_HOOK_PLUGIN_NAME}"
|
|
340
|
+
in (opencode_data.get("plugin") or [])
|
|
341
|
+
)
|
|
342
|
+
except json.JSONDecodeError:
|
|
343
|
+
plugin_registered = False
|
|
344
|
+
add(
|
|
345
|
+
plugin_ok and plugin_registered,
|
|
346
|
+
"OpenCode hook plugin",
|
|
347
|
+
str(opencode_plugin) if plugin_ok and plugin_registered else "Run dev integrate hooks --apply --tool opencode.",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
bundled_plugin = integrate._opencode_plugin_source()
|
|
351
|
+
add(
|
|
352
|
+
bundled_plugin.exists(),
|
|
353
|
+
"Bundled OpenCode hook plugin",
|
|
354
|
+
str(bundled_plugin) if bundled_plugin.exists() else "Reinstall DevCouncil; package asset is missing.",
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
custom_agents = raw_config.get("integrations", {}).get("cli_agents", {}).get("agents", {})
|
|
358
|
+
if custom_agents:
|
|
359
|
+
for name, agent in sorted(custom_agents.items()):
|
|
360
|
+
command = str(agent.get("command", "")).strip()
|
|
361
|
+
found = shutil.which(command) if command else None
|
|
362
|
+
add(found is not None, f"CLI agent: {name}", found or f"{command or 'command'} not found on PATH")
|
|
363
|
+
else:
|
|
364
|
+
add_skip("Custom CLI agents", "No agents registered.")
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
tools = integrate._probe_mcp_tools(root)
|
|
368
|
+
expected = {"devcouncil_status", "devcouncil_report", "devcouncil_get_task"}
|
|
369
|
+
add(expected.issubset(set(tools)), "MCP server", ", ".join(tools))
|
|
370
|
+
except Exception as exc:
|
|
371
|
+
add(False, "MCP server", str(exc))
|
|
372
|
+
|
|
373
|
+
# Tamper tripwire: any installed client hook config must still reference the
|
|
374
|
+
# DevCouncil gate. A present-but-unreferenced file means the pre-action gate was
|
|
375
|
+
# disarmed (by an agent or by hand) and is reported as a failure.
|
|
376
|
+
for label, hook_path in _hook_config_tamper_targets(root):
|
|
377
|
+
references = _hook_config_references_devcouncil(hook_path)
|
|
378
|
+
if references is None:
|
|
379
|
+
continue
|
|
380
|
+
add(
|
|
381
|
+
references,
|
|
382
|
+
f"{label} hook integrity",
|
|
383
|
+
str(hook_path) if references else f"{hook_path} no longer references devcouncil (tampered/disarmed).",
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
detected = detect_available_coding_cli(root)
|
|
387
|
+
recommended = resolve_automated_executor(root, None) if detected else None
|
|
388
|
+
return IntegrationCheckReport(tuple(rows), recommended, failures)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def integration_status_summary(project_root: Path) -> dict[str, Any]:
|
|
392
|
+
from devcouncil.app.config import load_config
|
|
393
|
+
|
|
394
|
+
probe_order = resolve_coding_cli_probe_order(project_root)
|
|
395
|
+
on_path = coding_clis_on_path(project_root)
|
|
396
|
+
detected = on_path[0] if on_path else None
|
|
397
|
+
try:
|
|
398
|
+
execution = load_config(project_root).execution
|
|
399
|
+
default_executor = execution.default_executor
|
|
400
|
+
stream_cli_output = execution.stream_cli_output
|
|
401
|
+
cursor_resume_mode = execution.cursor_resume_mode
|
|
402
|
+
custom_probe_order = list(execution.coding_cli_probe_order)
|
|
403
|
+
except Exception:
|
|
404
|
+
default_executor = "manual"
|
|
405
|
+
stream_cli_output = False
|
|
406
|
+
cursor_resume_mode = "off"
|
|
407
|
+
custom_probe_order = []
|
|
408
|
+
|
|
409
|
+
resolved = resolve_automated_executor(project_root, None) if detected or default_executor != "manual" else "manual"
|
|
410
|
+
config_path = project_root / ".devcouncil" / "config.yaml"
|
|
411
|
+
return {
|
|
412
|
+
"project_initialized": (project_root / ".devcouncil").is_dir(),
|
|
413
|
+
"config_path": str(config_path) if config_path.exists() else None,
|
|
414
|
+
"default_executor": default_executor,
|
|
415
|
+
"resolved_executor": resolved,
|
|
416
|
+
"detected_executor": detected,
|
|
417
|
+
"coding_clis_on_path": on_path,
|
|
418
|
+
"probe_order": list(probe_order),
|
|
419
|
+
"custom_probe_order": custom_probe_order,
|
|
420
|
+
"stream_cli_output": stream_cli_output,
|
|
421
|
+
"cursor_resume_mode": cursor_resume_mode,
|
|
422
|
+
"capabilities": integration_capability_rows(project_root),
|
|
423
|
+
}
|
|
@@ -2,38 +2,38 @@ import httpx
|
|
|
2
2
|
import logging
|
|
3
3
|
from devcouncil.artifacts.graph import ArtifactGraph
|
|
4
4
|
from devcouncil.reporting.github_check import GitHubCheckGenerator
|
|
5
|
-
|
|
6
|
-
logger = logging.getLogger(__name__)
|
|
7
|
-
|
|
8
|
-
class GitHubIntegration:
|
|
9
|
-
"""Manages interactions with GitHub API, specifically PR Checks."""
|
|
10
|
-
|
|
11
|
-
def __init__(self, github_token: str, repository: str, commit_sha: str):
|
|
12
|
-
self.github_token = github_token
|
|
13
|
-
self.repository = repository
|
|
14
|
-
self.commit_sha = commit_sha
|
|
15
|
-
self.base_url = f"https://api.github.com/repos/{repository}"
|
|
16
|
-
|
|
17
|
-
async def report_verification(self, graph: ArtifactGraph):
|
|
18
|
-
"""Creates or updates a GitHub Check Run with the current verification status."""
|
|
19
|
-
payload = GitHubCheckGenerator.generate(graph)
|
|
20
|
-
payload["head_sha"] = self.commit_sha
|
|
21
|
-
|
|
22
|
-
headers = {
|
|
23
|
-
"Authorization": f"Bearer {self.github_token}",
|
|
24
|
-
"Accept": "application/vnd.github.v3+json",
|
|
25
|
-
"Content-Type": "application/json"
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async with httpx.AsyncClient() as client:
|
|
29
|
-
try:
|
|
30
|
-
response = await client.post(
|
|
31
|
-
f"{self.base_url}/check-runs",
|
|
32
|
-
headers=headers,
|
|
33
|
-
json=payload
|
|
34
|
-
)
|
|
35
|
-
response.raise_for_status()
|
|
36
|
-
logger.info(f"GitHub PR Check updated for {self.repository} at {self.commit_sha}")
|
|
37
|
-
except Exception as e:
|
|
38
|
-
logger.error(f"Failed to report to GitHub: {e}")
|
|
39
|
-
raise
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
class GitHubIntegration:
|
|
9
|
+
"""Manages interactions with GitHub API, specifically PR Checks."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, github_token: str, repository: str, commit_sha: str):
|
|
12
|
+
self.github_token = github_token
|
|
13
|
+
self.repository = repository
|
|
14
|
+
self.commit_sha = commit_sha
|
|
15
|
+
self.base_url = f"https://api.github.com/repos/{repository}"
|
|
16
|
+
|
|
17
|
+
async def report_verification(self, graph: ArtifactGraph):
|
|
18
|
+
"""Creates or updates a GitHub Check Run with the current verification status."""
|
|
19
|
+
payload = GitHubCheckGenerator.generate(graph)
|
|
20
|
+
payload["head_sha"] = self.commit_sha
|
|
21
|
+
|
|
22
|
+
headers = {
|
|
23
|
+
"Authorization": f"Bearer {self.github_token}",
|
|
24
|
+
"Accept": "application/vnd.github.v3+json",
|
|
25
|
+
"Content-Type": "application/json"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async with httpx.AsyncClient() as client:
|
|
29
|
+
try:
|
|
30
|
+
response = await client.post(
|
|
31
|
+
f"{self.base_url}/check-runs",
|
|
32
|
+
headers=headers,
|
|
33
|
+
json=payload
|
|
34
|
+
)
|
|
35
|
+
response.raise_for_status()
|
|
36
|
+
logger.info(f"GitHub PR Check updated for {self.repository} at {self.commit_sha}")
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.error(f"Failed to report to GitHub: {e}")
|
|
39
|
+
raise
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Resolve a DevCouncil goal from a GitHub issue or pull-request reference.
|
|
2
|
+
|
|
3
|
+
A terse goal like ``"#142"`` or a full issue URL carries far more intent than a
|
|
4
|
+
one-line argument — the issue body usually *is* the spec. This module detects
|
|
5
|
+
such references and expands them into a rich goal string (title + body + a few
|
|
6
|
+
comments) by shelling out to the authenticated ``gh`` CLI, so private repos work
|
|
7
|
+
without any token plumbing. When ``gh`` is unavailable or the lookup fails, the
|
|
8
|
+
caller keeps the original goal text unchanged — expansion is strictly additive.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from devcouncil.utils.subprocess_env import clean_subprocess_env
|
|
21
|
+
|
|
22
|
+
# Cap each pulled discussion comment so a long thread can't dominate the prompt.
|
|
23
|
+
_MAX_COMMENT_CHARS = 600
|
|
24
|
+
|
|
25
|
+
# "#142", "GH-142", "owner/repo#142", or a full issues/pull URL.
|
|
26
|
+
_SHORT_REF = re.compile(r"^\s*(?:GH-|#)(\d+)\s*$", re.IGNORECASE)
|
|
27
|
+
_OWNER_REPO_REF = re.compile(r"^\s*([\w.-]+/[\w.-]+)#(\d+)\s*$")
|
|
28
|
+
_URL_REF = re.compile(
|
|
29
|
+
r"^\s*https?://github\.com/([\w.-]+/[\w.-]+)/(issues|pull)/(\d+)",
|
|
30
|
+
re.IGNORECASE,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class IntentRef:
|
|
36
|
+
number: int
|
|
37
|
+
kind: str # "issue" | "pull" | "auto"
|
|
38
|
+
repo: str | None # "owner/repo" when explicit, else None (current repo)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_intent_ref(goal: str) -> IntentRef | None:
|
|
42
|
+
"""Return the GitHub reference a goal points at, or None if it's plain text."""
|
|
43
|
+
text = goal.strip()
|
|
44
|
+
m = _URL_REF.match(text)
|
|
45
|
+
if m:
|
|
46
|
+
kind = "pull" if m.group(2).lower() == "pull" else "issue"
|
|
47
|
+
return IntentRef(number=int(m.group(3)), kind=kind, repo=m.group(1))
|
|
48
|
+
m = _OWNER_REPO_REF.match(text)
|
|
49
|
+
if m:
|
|
50
|
+
return IntentRef(number=int(m.group(2)), kind="auto", repo=m.group(1))
|
|
51
|
+
m = _SHORT_REF.match(text)
|
|
52
|
+
if m:
|
|
53
|
+
return IntentRef(number=int(m.group(1)), kind="auto", repo=None)
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _gh_view(ref: IntentRef, sub: str, root: Path) -> dict | None:
|
|
58
|
+
"""Run ``gh <issue|pr> view`` and return the parsed JSON, or None on failure."""
|
|
59
|
+
gh = shutil.which("gh")
|
|
60
|
+
if not gh:
|
|
61
|
+
return None
|
|
62
|
+
cmd = [gh, sub, "view", str(ref.number), "--json", "title,body,comments,url,state"]
|
|
63
|
+
if ref.repo:
|
|
64
|
+
cmd += ["--repo", ref.repo]
|
|
65
|
+
try:
|
|
66
|
+
result = subprocess.run(
|
|
67
|
+
cmd, cwd=root, capture_output=True, text=True,
|
|
68
|
+
encoding="utf-8", errors="replace", timeout=20, env=clean_subprocess_env(),
|
|
69
|
+
)
|
|
70
|
+
except Exception:
|
|
71
|
+
return None
|
|
72
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
data = json.loads(result.stdout)
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
return None
|
|
78
|
+
return data if isinstance(data, dict) else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _compose_goal(ref: IntentRef, data: dict, source: str) -> str:
|
|
82
|
+
title = str(data.get("title") or "").strip()
|
|
83
|
+
body = str(data.get("body") or "").strip()
|
|
84
|
+
url = str(data.get("url") or "").strip()
|
|
85
|
+
lines = [f"Implement {source} #{ref.number}: {title}".rstrip(": ").rstrip()]
|
|
86
|
+
if url:
|
|
87
|
+
lines.append(f"Source: {url}")
|
|
88
|
+
if body:
|
|
89
|
+
lines += ["", body]
|
|
90
|
+
# Pull in up to three discussion comments — clarifications often live there.
|
|
91
|
+
# Cap each so a long thread can't bloat the planning prompt; the issue body
|
|
92
|
+
# above is the primary spec, comments are secondary context.
|
|
93
|
+
comments = data.get("comments")
|
|
94
|
+
if isinstance(comments, list) and comments:
|
|
95
|
+
snippets = []
|
|
96
|
+
for comment in comments[:3]:
|
|
97
|
+
text = str((comment or {}).get("body") or "").strip()
|
|
98
|
+
if text:
|
|
99
|
+
if len(text) > _MAX_COMMENT_CHARS:
|
|
100
|
+
text = text[:_MAX_COMMENT_CHARS].rstrip() + " […]"
|
|
101
|
+
snippets.append(text)
|
|
102
|
+
if snippets:
|
|
103
|
+
lines += ["", "Discussion notes:"]
|
|
104
|
+
lines += [f"- {s}" for s in snippets]
|
|
105
|
+
return "\n".join(lines).strip()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def resolve_goal_intent(goal: str, root: Path) -> tuple[str, str | None]:
|
|
109
|
+
"""Expand a GitHub issue/PR reference into a full goal.
|
|
110
|
+
|
|
111
|
+
Returns ``(goal, note)``. When ``goal`` is a reference and the lookup
|
|
112
|
+
succeeds, the first element is the composed goal and ``note`` describes the
|
|
113
|
+
expansion (for display). Otherwise the original goal is returned with a
|
|
114
|
+
``note`` explaining why it could not be expanded (or ``None`` when the goal
|
|
115
|
+
was plain text and no expansion was attempted).
|
|
116
|
+
"""
|
|
117
|
+
ref = parse_intent_ref(goal)
|
|
118
|
+
if ref is None:
|
|
119
|
+
return goal, None
|
|
120
|
+
|
|
121
|
+
if not shutil.which("gh"):
|
|
122
|
+
return goal, (
|
|
123
|
+
f"Goal looks like GitHub reference #{ref.number}, but the `gh` CLI is not on "
|
|
124
|
+
"PATH — using the literal text. Install/auth gh to pull the issue/PR body."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
order = (
|
|
128
|
+
["pull", "issue"] if ref.kind == "pull"
|
|
129
|
+
else ["issue", "pull"] if ref.kind == "issue"
|
|
130
|
+
else ["issue", "pull"]
|
|
131
|
+
)
|
|
132
|
+
for sub in order:
|
|
133
|
+
data = _gh_view(ref, "pr" if sub == "pull" else "issue", root)
|
|
134
|
+
if data is not None:
|
|
135
|
+
source = "pull request" if sub == "pull" else "issue"
|
|
136
|
+
composed = _compose_goal(ref, data, source)
|
|
137
|
+
return composed, f"Pulled intent from {source} #{ref.number} via gh."
|
|
138
|
+
|
|
139
|
+
return goal, (
|
|
140
|
+
f"Could not fetch GitHub #{ref.number} via gh (not found, no access, or not a "
|
|
141
|
+
"git/GitHub repo) — using the literal text."
|
|
142
|
+
)
|