@oriro/orirocli 0.1.9 → 0.1.12
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/README.md +16 -18
- package/dist/cli.js +4776 -2964
- package/package.json +2 -2
- package/skills/craft/ai-engineering/SKILL.md +2 -2
- package/skills/graphify/SKILL.md +0 -619
- package/skills/graphify/__init__.py +0 -28
- package/skills/graphify/__main__.py +0 -4582
- package/skills/graphify/affected.py +0 -154
- package/skills/graphify/always_on/agents-md.md +0 -12
- package/skills/graphify/always_on/antigravity-rules.md +0 -14
- package/skills/graphify/always_on/claude-md.md +0 -9
- package/skills/graphify/always_on/gemini-md.md +0 -9
- package/skills/graphify/always_on/kiro-steering.md +0 -5
- package/skills/graphify/always_on/vscode-instructions.md +0 -17
- package/skills/graphify/analyze.py +0 -724
- package/skills/graphify/benchmark.py +0 -155
- package/skills/graphify/build.py +0 -487
- package/skills/graphify/cache.py +0 -417
- package/skills/graphify/callflow_html.py +0 -2020
- package/skills/graphify/cluster.py +0 -272
- package/skills/graphify/command-kilo.md +0 -15
- package/skills/graphify/dedup.py +0 -429
- package/skills/graphify/detect.py +0 -1379
- package/skills/graphify/diagnostics.py +0 -390
- package/skills/graphify/export.py +0 -1408
- package/skills/graphify/extract.py +0 -11570
- package/skills/graphify/global_graph.py +0 -159
- package/skills/graphify/google_workspace.py +0 -223
- package/skills/graphify/hooks.py +0 -457
- package/skills/graphify/ingest.py +0 -331
- package/skills/graphify/llm.py +0 -1896
- package/skills/graphify/manifest.py +0 -4
- package/skills/graphify/mcp_ingest.py +0 -392
- package/skills/graphify/multigraph_compat.py +0 -212
- package/skills/graphify/pg_introspect.py +0 -142
- package/skills/graphify/prs.py +0 -748
- package/skills/graphify/querylog.py +0 -70
- package/skills/graphify/report.py +0 -218
- package/skills/graphify/scip_ingest.py +0 -363
- package/skills/graphify/security.py +0 -336
- package/skills/graphify/semantic_cleanup.py +0 -319
- package/skills/graphify/serve.py +0 -1309
- package/skills/graphify/skill-aider.md +0 -1246
- package/skills/graphify/skill-amp.md +0 -613
- package/skills/graphify/skill-claw.md +0 -616
- package/skills/graphify/skill-codex.md +0 -613
- package/skills/graphify/skill-copilot.md +0 -616
- package/skills/graphify/skill-devin.md +0 -1372
- package/skills/graphify/skill-droid.md +0 -613
- package/skills/graphify/skill-kilo.md +0 -625
- package/skills/graphify/skill-kiro.md +0 -615
- package/skills/graphify/skill-opencode.md +0 -608
- package/skills/graphify/skill-pi.md +0 -615
- package/skills/graphify/skill-trae.md +0 -614
- package/skills/graphify/skill-vscode.md +0 -612
- package/skills/graphify/skill-windows.md +0 -651
- package/skills/graphify/skills/amp/references/add-watch.md +0 -56
- package/skills/graphify/skills/amp/references/exports.md +0 -71
- package/skills/graphify/skills/amp/references/extraction-spec.md +0 -68
- package/skills/graphify/skills/amp/references/github-and-merge.md +0 -46
- package/skills/graphify/skills/amp/references/hooks.md +0 -33
- package/skills/graphify/skills/amp/references/query.md +0 -249
- package/skills/graphify/skills/amp/references/transcribe.md +0 -48
- package/skills/graphify/skills/amp/references/update.md +0 -179
- package/skills/graphify/skills/claude/references/add-watch.md +0 -56
- package/skills/graphify/skills/claude/references/exports.md +0 -71
- package/skills/graphify/skills/claude/references/extraction-spec.md +0 -68
- package/skills/graphify/skills/claude/references/github-and-merge.md +0 -46
- package/skills/graphify/skills/claude/references/hooks.md +0 -33
- package/skills/graphify/skills/claude/references/query.md +0 -103
- package/skills/graphify/skills/claude/references/transcribe.md +0 -48
- package/skills/graphify/skills/claude/references/update.md +0 -179
- package/skills/graphify/skills/claw/references/add-watch.md +0 -56
- package/skills/graphify/skills/claw/references/exports.md +0 -71
- package/skills/graphify/skills/claw/references/extraction-spec.md +0 -29
- package/skills/graphify/skills/claw/references/github-and-merge.md +0 -46
- package/skills/graphify/skills/claw/references/hooks.md +0 -33
- package/skills/graphify/skills/claw/references/query.md +0 -249
- package/skills/graphify/skills/claw/references/transcribe.md +0 -48
- package/skills/graphify/skills/claw/references/update.md +0 -179
- package/skills/graphify/skills/codex/references/add-watch.md +0 -56
- package/skills/graphify/skills/codex/references/exports.md +0 -71
- package/skills/graphify/skills/codex/references/extraction-spec.md +0 -29
- package/skills/graphify/skills/codex/references/github-and-merge.md +0 -46
- package/skills/graphify/skills/codex/references/hooks.md +0 -33
- package/skills/graphify/skills/codex/references/query.md +0 -249
- package/skills/graphify/skills/codex/references/transcribe.md +0 -48
- package/skills/graphify/skills/codex/references/update.md +0 -179
- package/skills/graphify/skills/copilot/references/add-watch.md +0 -56
- package/skills/graphify/skills/copilot/references/exports.md +0 -71
- package/skills/graphify/skills/copilot/references/extraction-spec.md +0 -68
- package/skills/graphify/skills/copilot/references/github-and-merge.md +0 -46
- package/skills/graphify/skills/copilot/references/hooks.md +0 -33
- package/skills/graphify/skills/copilot/references/query.md +0 -249
- package/skills/graphify/skills/copilot/references/transcribe.md +0 -48
- package/skills/graphify/skills/copilot/references/update.md +0 -179
- package/skills/graphify/skills/droid/references/add-watch.md +0 -56
- package/skills/graphify/skills/droid/references/exports.md +0 -71
- package/skills/graphify/skills/droid/references/extraction-spec.md +0 -68
- package/skills/graphify/skills/droid/references/github-and-merge.md +0 -46
- package/skills/graphify/skills/droid/references/hooks.md +0 -33
- package/skills/graphify/skills/droid/references/query.md +0 -249
- package/skills/graphify/skills/droid/references/transcribe.md +0 -48
- package/skills/graphify/skills/droid/references/update.md +0 -179
- package/skills/graphify/skills/kilo/references/add-watch.md +0 -56
- package/skills/graphify/skills/kilo/references/exports.md +0 -71
- package/skills/graphify/skills/kilo/references/extraction-spec.md +0 -68
- package/skills/graphify/skills/kilo/references/github-and-merge.md +0 -46
- package/skills/graphify/skills/kilo/references/hooks.md +0 -33
- package/skills/graphify/skills/kilo/references/query.md +0 -249
- package/skills/graphify/skills/kilo/references/transcribe.md +0 -48
- package/skills/graphify/skills/kilo/references/update.md +0 -179
- package/skills/graphify/skills/kiro/references/add-watch.md +0 -56
- package/skills/graphify/skills/kiro/references/exports.md +0 -71
- package/skills/graphify/skills/kiro/references/extraction-spec.md +0 -29
- package/skills/graphify/skills/kiro/references/github-and-merge.md +0 -46
- package/skills/graphify/skills/kiro/references/hooks.md +0 -33
- package/skills/graphify/skills/kiro/references/query.md +0 -249
- package/skills/graphify/skills/kiro/references/transcribe.md +0 -48
- package/skills/graphify/skills/kiro/references/update.md +0 -179
- package/skills/graphify/skills/opencode/references/add-watch.md +0 -56
- package/skills/graphify/skills/opencode/references/exports.md +0 -71
- package/skills/graphify/skills/opencode/references/extraction-spec.md +0 -68
- package/skills/graphify/skills/opencode/references/github-and-merge.md +0 -46
- package/skills/graphify/skills/opencode/references/hooks.md +0 -33
- package/skills/graphify/skills/opencode/references/query.md +0 -249
- package/skills/graphify/skills/opencode/references/transcribe.md +0 -48
- package/skills/graphify/skills/opencode/references/update.md +0 -179
- package/skills/graphify/skills/pi/references/add-watch.md +0 -56
- package/skills/graphify/skills/pi/references/exports.md +0 -71
- package/skills/graphify/skills/pi/references/extraction-spec.md +0 -29
- package/skills/graphify/skills/pi/references/github-and-merge.md +0 -46
- package/skills/graphify/skills/pi/references/hooks.md +0 -33
- package/skills/graphify/skills/pi/references/query.md +0 -249
- package/skills/graphify/skills/pi/references/transcribe.md +0 -48
- package/skills/graphify/skills/pi/references/update.md +0 -179
- package/skills/graphify/skills/trae/references/add-watch.md +0 -56
- package/skills/graphify/skills/trae/references/exports.md +0 -71
- package/skills/graphify/skills/trae/references/extraction-spec.md +0 -68
- package/skills/graphify/skills/trae/references/github-and-merge.md +0 -46
- package/skills/graphify/skills/trae/references/hooks.md +0 -35
- package/skills/graphify/skills/trae/references/query.md +0 -249
- package/skills/graphify/skills/trae/references/transcribe.md +0 -48
- package/skills/graphify/skills/trae/references/update.md +0 -179
- package/skills/graphify/skills/vscode/references/add-watch.md +0 -56
- package/skills/graphify/skills/vscode/references/exports.md +0 -71
- package/skills/graphify/skills/vscode/references/extraction-spec.md +0 -68
- package/skills/graphify/skills/vscode/references/github-and-merge.md +0 -46
- package/skills/graphify/skills/vscode/references/hooks.md +0 -33
- package/skills/graphify/skills/vscode/references/query.md +0 -249
- package/skills/graphify/skills/vscode/references/transcribe.md +0 -48
- package/skills/graphify/skills/vscode/references/update.md +0 -179
- package/skills/graphify/skills/windows/references/add-watch.md +0 -56
- package/skills/graphify/skills/windows/references/exports.md +0 -71
- package/skills/graphify/skills/windows/references/extraction-spec.md +0 -68
- package/skills/graphify/skills/windows/references/github-and-merge.md +0 -46
- package/skills/graphify/skills/windows/references/hooks.md +0 -33
- package/skills/graphify/skills/windows/references/query.md +0 -249
- package/skills/graphify/skills/windows/references/transcribe.md +0 -48
- package/skills/graphify/skills/windows/references/update.md +0 -179
- package/skills/graphify/symbol_resolution.py +0 -538
- package/skills/graphify/transcribe.py +0 -184
- package/skills/graphify/tree_html.py +0 -582
- package/skills/graphify/validate.py +0 -72
- package/skills/graphify/watch.py +0 -898
- package/skills/graphify/wiki.py +0 -282
package/skills/graphify/prs.py
DELETED
|
@@ -1,748 +0,0 @@
|
|
|
1
|
-
"""graphify prs — graph-aware PR dashboard.
|
|
2
|
-
|
|
3
|
-
Fast terminal overview of open PRs with CI/review state, worktree mapping,
|
|
4
|
-
and optional graph-impact analysis (which communities a PR touches) and
|
|
5
|
-
Opus-powered triage ranking.
|
|
6
|
-
|
|
7
|
-
Usage:
|
|
8
|
-
graphify prs # dashboard of all open PRs
|
|
9
|
-
graphify prs <number> # deep dive on one PR
|
|
10
|
-
graphify prs --triage # Opus ranks your review queue
|
|
11
|
-
graphify prs --worktrees # show worktree → branch → PR mapping
|
|
12
|
-
graphify prs --conflicts # PRs sharing graph communities (merge-order risk)
|
|
13
|
-
graphify prs --base <branch> # filter to PRs targeting this base (default: v8)
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
from __future__ import annotations
|
|
17
|
-
|
|
18
|
-
import json
|
|
19
|
-
import os
|
|
20
|
-
import re
|
|
21
|
-
import subprocess
|
|
22
|
-
import sys
|
|
23
|
-
from collections import defaultdict
|
|
24
|
-
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
25
|
-
from dataclasses import dataclass, field
|
|
26
|
-
from datetime import datetime, timezone
|
|
27
|
-
from pathlib import Path
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
# ── ANSI colours ─────────────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
_NO_COLOR = not sys.stdout.isatty() or os.environ.get("NO_COLOR")
|
|
33
|
-
|
|
34
|
-
def _c(code: str, text: str) -> str:
|
|
35
|
-
if _NO_COLOR:
|
|
36
|
-
return text
|
|
37
|
-
return f"\033[{code}m{text}\033[0m"
|
|
38
|
-
|
|
39
|
-
def green(t: str) -> str: return _c("32", t)
|
|
40
|
-
def red(t: str) -> str: return _c("31", t)
|
|
41
|
-
def yellow(t: str) -> str: return _c("33", t)
|
|
42
|
-
def cyan(t: str) -> str: return _c("36", t)
|
|
43
|
-
def bold(t: str) -> str: return _c("1", t)
|
|
44
|
-
def dim(t: str) -> str: return _c("2", t)
|
|
45
|
-
def magenta(t: str) -> str: return _c("35", t)
|
|
46
|
-
|
|
47
|
-
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
48
|
-
|
|
49
|
-
def _pad(s: str, width: int) -> str:
|
|
50
|
-
"""Pad an ANSI-colored string to visible width (strips escape codes for length calc)."""
|
|
51
|
-
visible_len = len(_ANSI_RE.sub("", s))
|
|
52
|
-
return s + " " * max(0, width - visible_len)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
# ── Data model ────────────────────────────────────────────────────────────────
|
|
56
|
-
|
|
57
|
-
@dataclass
|
|
58
|
-
class PRInfo:
|
|
59
|
-
number: int
|
|
60
|
-
title: str
|
|
61
|
-
branch: str
|
|
62
|
-
base_branch: str
|
|
63
|
-
author: str
|
|
64
|
-
is_draft: bool
|
|
65
|
-
review_decision: str # APPROVED | CHANGES_REQUESTED | ""
|
|
66
|
-
ci_status: str # SUCCESS | FAILURE | PENDING | NONE
|
|
67
|
-
updated_at: datetime
|
|
68
|
-
expected_base: str = "main" # set by fetch_prs via _detect_default_branch
|
|
69
|
-
worktree_path: str | None = None
|
|
70
|
-
# Graph impact — populated when graph.json exists
|
|
71
|
-
communities_touched: list[int] = field(default_factory=list)
|
|
72
|
-
nodes_affected: int = 0
|
|
73
|
-
files_changed: list[str] = field(default_factory=list)
|
|
74
|
-
|
|
75
|
-
@property
|
|
76
|
-
def status(self) -> str:
|
|
77
|
-
return _classify(self, self.expected_base)
|
|
78
|
-
|
|
79
|
-
@property
|
|
80
|
-
def days_old(self) -> int:
|
|
81
|
-
return (datetime.now(timezone.utc) - self.updated_at).days
|
|
82
|
-
|
|
83
|
-
@property
|
|
84
|
-
def blast_radius(self) -> str:
|
|
85
|
-
if not self.nodes_affected:
|
|
86
|
-
return ""
|
|
87
|
-
n = self.nodes_affected
|
|
88
|
-
c = len(self.communities_touched)
|
|
89
|
-
return f"{n} node{'s' if n != 1 else ''} / {c} communit{'ies' if c != 1 else 'y'}"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
# ── Classification ────────────────────────────────────────────────────────────
|
|
93
|
-
|
|
94
|
-
_STATUS_ORDER = ["WRONG-BASE", "CI-FAIL", "CHANGES-REQ", "DRAFT", "STALE", "PENDING", "APPROVED", "READY"]
|
|
95
|
-
_STALE_DAYS = 14
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _classify(pr: "PRInfo", base: str = "v8") -> str:
|
|
99
|
-
if pr.base_branch != base:
|
|
100
|
-
return "WRONG-BASE"
|
|
101
|
-
if pr.ci_status == "FAILURE":
|
|
102
|
-
return "CI-FAIL"
|
|
103
|
-
if pr.review_decision == "CHANGES_REQUESTED":
|
|
104
|
-
return "CHANGES-REQ"
|
|
105
|
-
if pr.is_draft:
|
|
106
|
-
return "DRAFT"
|
|
107
|
-
if pr.days_old >= _STALE_DAYS:
|
|
108
|
-
return "STALE"
|
|
109
|
-
if pr.review_decision == "APPROVED":
|
|
110
|
-
return "APPROVED"
|
|
111
|
-
if pr.ci_status == "PENDING":
|
|
112
|
-
return "PENDING"
|
|
113
|
-
return "READY"
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _status_color(status: str) -> str:
|
|
117
|
-
return {
|
|
118
|
-
"READY": green(status),
|
|
119
|
-
"APPROVED": bold(green(status)),
|
|
120
|
-
"CI-FAIL": red(status),
|
|
121
|
-
"CHANGES-REQ": red(status),
|
|
122
|
-
"WRONG-BASE": dim(status),
|
|
123
|
-
"STALE": dim(status),
|
|
124
|
-
"DRAFT": yellow(status),
|
|
125
|
-
"PENDING": yellow(status),
|
|
126
|
-
}.get(status, status)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _ci_icon(status: str) -> str:
|
|
130
|
-
return {"SUCCESS": green("✓"), "FAILURE": red("✗"), "PENDING": yellow("…"), "NONE": dim("–")}.get(status, "?")
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
# ── GitHub data fetching ──────────────────────────────────────────────────────
|
|
134
|
-
|
|
135
|
-
def _gh(*args: str) -> list | dict | None:
|
|
136
|
-
try:
|
|
137
|
-
result = subprocess.run(
|
|
138
|
-
["gh", *args],
|
|
139
|
-
capture_output=True, text=True, timeout=30
|
|
140
|
-
)
|
|
141
|
-
if result.returncode != 0:
|
|
142
|
-
return None
|
|
143
|
-
return json.loads(result.stdout)
|
|
144
|
-
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
|
145
|
-
return None
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def _detect_default_branch(repo: str | None = None) -> str:
|
|
149
|
-
"""Auto-detect the repo's default branch via gh, then git, then fall back to 'main'."""
|
|
150
|
-
# Try gh first — works for any repo, not just the current directory
|
|
151
|
-
args = ["repo", "view", "--json", "defaultBranchRef"]
|
|
152
|
-
if repo:
|
|
153
|
-
args += ["--repo", repo]
|
|
154
|
-
data = _gh(*args)
|
|
155
|
-
if data and data.get("defaultBranchRef", {}).get("name"):
|
|
156
|
-
return data["defaultBranchRef"]["name"]
|
|
157
|
-
# Fall back to git symbolic-ref for the current repo
|
|
158
|
-
try:
|
|
159
|
-
result = subprocess.run(
|
|
160
|
-
["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
|
|
161
|
-
capture_output=True, text=True, timeout=5
|
|
162
|
-
)
|
|
163
|
-
if result.returncode == 0:
|
|
164
|
-
# refs/remotes/origin/main → main
|
|
165
|
-
ref = result.stdout.strip()
|
|
166
|
-
return ref.split("/")[-1] if ref else "main"
|
|
167
|
-
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
168
|
-
pass
|
|
169
|
-
return "main"
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
_CI_FAILURE_CONCLUSIONS = frozenset({"FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"})
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def _parse_ci(rollup: list) -> str:
|
|
176
|
-
if not rollup:
|
|
177
|
-
return "NONE"
|
|
178
|
-
conclusions = {r.get("conclusion") for r in rollup if r.get("conclusion")}
|
|
179
|
-
if conclusions & _CI_FAILURE_CONCLUSIONS:
|
|
180
|
-
return "FAILURE"
|
|
181
|
-
statuses = {r.get("status") for r in rollup}
|
|
182
|
-
if "IN_PROGRESS" in statuses or "QUEUED" in statuses:
|
|
183
|
-
return "PENDING"
|
|
184
|
-
if "SUCCESS" in conclusions:
|
|
185
|
-
return "SUCCESS"
|
|
186
|
-
return "NONE"
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def fetch_prs(repo: str | None = None, base: str | None = None, limit: int = 50) -> list[PRInfo]:
|
|
190
|
-
resolved_base = base or _detect_default_branch(repo)
|
|
191
|
-
args = [
|
|
192
|
-
"pr", "list", "--state", "open", "--limit", str(limit),
|
|
193
|
-
"--json", "number,title,headRefName,baseRefName,author,isDraft,"
|
|
194
|
-
"reviewDecision,statusCheckRollup,updatedAt",
|
|
195
|
-
]
|
|
196
|
-
if repo:
|
|
197
|
-
args += ["--repo", repo]
|
|
198
|
-
|
|
199
|
-
raw = _gh(*args)
|
|
200
|
-
if raw is None:
|
|
201
|
-
raise RuntimeError("gh CLI not found or not authenticated. Run: gh auth login")
|
|
202
|
-
|
|
203
|
-
prs = []
|
|
204
|
-
for item in raw:
|
|
205
|
-
updated = datetime.fromisoformat(item["updatedAt"].replace("Z", "+00:00"))
|
|
206
|
-
prs.append(PRInfo(
|
|
207
|
-
number=item["number"],
|
|
208
|
-
title=item["title"],
|
|
209
|
-
branch=item["headRefName"],
|
|
210
|
-
base_branch=item["baseRefName"],
|
|
211
|
-
author=item["author"]["login"] if item.get("author") else "?",
|
|
212
|
-
is_draft=item.get("isDraft", False),
|
|
213
|
-
review_decision=item.get("reviewDecision") or "",
|
|
214
|
-
ci_status=_parse_ci(item.get("statusCheckRollup") or []),
|
|
215
|
-
updated_at=updated,
|
|
216
|
-
expected_base=resolved_base,
|
|
217
|
-
))
|
|
218
|
-
return prs
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def fetch_pr_files(number: int, repo: str | None = None) -> list[str]:
|
|
222
|
-
args = ["pr", "diff", str(number), "--name-only"]
|
|
223
|
-
if repo:
|
|
224
|
-
args += ["--repo", repo]
|
|
225
|
-
try:
|
|
226
|
-
result = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=30)
|
|
227
|
-
if result.returncode != 0:
|
|
228
|
-
return []
|
|
229
|
-
return [l.strip() for l in result.stdout.splitlines() if l.strip()]
|
|
230
|
-
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
231
|
-
return []
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
# ── Graph-native impact (used by MCP tools — works on nx.Graph directly) ─────
|
|
235
|
-
|
|
236
|
-
def _path_match(graph_src: str, pr_file: str) -> bool:
|
|
237
|
-
"""True if graph_src and pr_file refer to the same file (path-boundary safe)."""
|
|
238
|
-
if graph_src == pr_file:
|
|
239
|
-
return True
|
|
240
|
-
return graph_src.endswith("/" + pr_file) or pr_file.endswith("/" + graph_src)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def compute_pr_impact(files: list[str], G: "nx.Graph") -> tuple[list[int], int]:
|
|
244
|
-
"""Return (communities_touched, nodes_affected) for a set of changed files.
|
|
245
|
-
|
|
246
|
-
Builds a file→(communities, count) index first so lookup is O(nodes + files)
|
|
247
|
-
rather than O(nodes × files).
|
|
248
|
-
"""
|
|
249
|
-
# Build index once
|
|
250
|
-
file_comms: dict[str, set[int]] = {}
|
|
251
|
-
file_count: dict[str, int] = {}
|
|
252
|
-
for _, data in G.nodes(data=True):
|
|
253
|
-
src = data.get("source_file") or ""
|
|
254
|
-
if not src:
|
|
255
|
-
continue
|
|
256
|
-
if src not in file_comms:
|
|
257
|
-
file_comms[src] = set()
|
|
258
|
-
file_count[src] = 0
|
|
259
|
-
c = data.get("community")
|
|
260
|
-
if c is not None:
|
|
261
|
-
file_comms[src].add(int(c))
|
|
262
|
-
file_count[src] += 1
|
|
263
|
-
|
|
264
|
-
comms: set[int] = set()
|
|
265
|
-
nodes = 0
|
|
266
|
-
matched: set[str] = set()
|
|
267
|
-
for f in files:
|
|
268
|
-
for src, src_comms in file_comms.items():
|
|
269
|
-
if src not in matched and _path_match(src, f):
|
|
270
|
-
comms |= src_comms
|
|
271
|
-
nodes += file_count[src]
|
|
272
|
-
matched.add(src)
|
|
273
|
-
return sorted(comms), nodes
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def format_prs_text(prs: list["PRInfo"], base: str) -> str:
|
|
277
|
-
"""Plain-text PR summary for MCP output (no ANSI)."""
|
|
278
|
-
actionable = [p for p in prs if p.base_branch == base]
|
|
279
|
-
wrong = len(prs) - len(actionable)
|
|
280
|
-
lines = [f"Open PRs targeting {base}: {len(actionable)} ({wrong} on wrong base, not shown)\n"]
|
|
281
|
-
for p in sorted(actionable, key=lambda x: (_STATUS_ORDER.index(x.status) if x.status in _STATUS_ORDER else 99, x.days_old)):
|
|
282
|
-
impact = f" blast_radius={p.blast_radius}" if p.blast_radius else ""
|
|
283
|
-
lines.append(
|
|
284
|
-
f"#{p.number} [{p.status}] CI={p.ci_status} review={p.review_decision or 'none'} "
|
|
285
|
-
f"age={p.days_old}d author={p.author}{impact}\n {p.title}"
|
|
286
|
-
)
|
|
287
|
-
return "\n\n".join(lines)
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
# ── Worktree mapping ──────────────────────────────────────────────────────────
|
|
291
|
-
|
|
292
|
-
def fetch_worktrees() -> dict[str, str]:
|
|
293
|
-
"""Returns {branch: worktree_path}."""
|
|
294
|
-
try:
|
|
295
|
-
result = subprocess.run(
|
|
296
|
-
["git", "worktree", "list", "--porcelain"],
|
|
297
|
-
capture_output=True, text=True, timeout=10
|
|
298
|
-
)
|
|
299
|
-
if result.returncode != 0:
|
|
300
|
-
return {}
|
|
301
|
-
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
302
|
-
return {}
|
|
303
|
-
|
|
304
|
-
mapping: dict[str, str] = {}
|
|
305
|
-
current_path = None
|
|
306
|
-
for line in result.stdout.splitlines():
|
|
307
|
-
if not line:
|
|
308
|
-
current_path = None # blank line = record separator; reset to avoid leaking across detached HEADs
|
|
309
|
-
elif line.startswith("worktree "):
|
|
310
|
-
current_path = line[9:]
|
|
311
|
-
elif line.startswith("branch refs/heads/") and current_path:
|
|
312
|
-
mapping[line[18:]] = current_path
|
|
313
|
-
return mapping
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
# ── Graph impact analysis ─────────────────────────────────────────────────────
|
|
317
|
-
|
|
318
|
-
def _load_graph_json(graph_path: Path) -> dict | None:
|
|
319
|
-
if not graph_path.exists():
|
|
320
|
-
return None
|
|
321
|
-
from graphify.security import check_graph_file_size_cap
|
|
322
|
-
try:
|
|
323
|
-
check_graph_file_size_cap(graph_path)
|
|
324
|
-
return json.loads(graph_path.read_text(encoding="utf-8"))
|
|
325
|
-
except (json.JSONDecodeError, OSError, ValueError):
|
|
326
|
-
return None
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def build_community_labels(data: dict, top_n: int = 4) -> dict[int, list[str]]:
|
|
330
|
-
"""Return {community_id: [top_labels]} extracted from graph node data."""
|
|
331
|
-
comm_labels: dict[int, list[str]] = defaultdict(list)
|
|
332
|
-
for node in data.get("nodes", []):
|
|
333
|
-
c = node.get("community")
|
|
334
|
-
if c is None:
|
|
335
|
-
continue
|
|
336
|
-
label = node.get("label") or node.get("id") or ""
|
|
337
|
-
if label:
|
|
338
|
-
comm_labels[int(c)].append(label)
|
|
339
|
-
return {c: labels[:top_n] for c, labels in comm_labels.items()}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def attach_graph_impact(
|
|
343
|
-
prs: list[PRInfo], graph_path: Path, repo: str | None = None
|
|
344
|
-
) -> dict[int, list[str]]:
|
|
345
|
-
"""Fetch PR file lists concurrently, compute graph impact, return community labels."""
|
|
346
|
-
data = _load_graph_json(graph_path)
|
|
347
|
-
if not data:
|
|
348
|
-
return {}
|
|
349
|
-
|
|
350
|
-
# Build file → {community, node_count} index
|
|
351
|
-
file_to_communities: dict[str, set[int]] = {}
|
|
352
|
-
file_to_nodes: dict[str, int] = {}
|
|
353
|
-
for node in data.get("nodes", []):
|
|
354
|
-
src = node.get("source_file") or ""
|
|
355
|
-
if not src:
|
|
356
|
-
continue
|
|
357
|
-
comm = node.get("community")
|
|
358
|
-
if src not in file_to_communities:
|
|
359
|
-
file_to_communities[src] = set()
|
|
360
|
-
file_to_nodes[src] = 0
|
|
361
|
-
if comm is not None:
|
|
362
|
-
file_to_communities[src].add(int(comm))
|
|
363
|
-
file_to_nodes[src] += 1
|
|
364
|
-
|
|
365
|
-
# Fetch diffs concurrently — gh pr diff is the bottleneck (network I/O)
|
|
366
|
-
actionable = [pr for pr in prs if pr.status != "WRONG-BASE"]
|
|
367
|
-
workers = min(8, len(actionable)) if actionable else 1
|
|
368
|
-
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
369
|
-
future_to_pr = {
|
|
370
|
-
pool.submit(fetch_pr_files, pr.number, repo): pr
|
|
371
|
-
for pr in actionable
|
|
372
|
-
}
|
|
373
|
-
for fut in as_completed(future_to_pr):
|
|
374
|
-
pr = future_to_pr[fut]
|
|
375
|
-
try:
|
|
376
|
-
files = fut.result()
|
|
377
|
-
except Exception:
|
|
378
|
-
files = []
|
|
379
|
-
pr.files_changed = files
|
|
380
|
-
|
|
381
|
-
comms: set[int] = set()
|
|
382
|
-
nodes = 0
|
|
383
|
-
matched: set[str] = set()
|
|
384
|
-
for f in files:
|
|
385
|
-
for gf, gcomms in file_to_communities.items():
|
|
386
|
-
if gf not in matched and _path_match(gf, f):
|
|
387
|
-
comms |= gcomms
|
|
388
|
-
nodes += file_to_nodes.get(gf, 0)
|
|
389
|
-
matched.add(gf)
|
|
390
|
-
pr.communities_touched = sorted(comms)
|
|
391
|
-
pr.nodes_affected = nodes
|
|
392
|
-
|
|
393
|
-
return build_community_labels(data)
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
# ── Dashboard rendering ───────────────────────────────────────────────────────
|
|
397
|
-
|
|
398
|
-
def _truncate(s: str, n: int) -> str:
|
|
399
|
-
return s if len(s) <= n else s[:n - 1] + "…"
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def render_dashboard(prs: list[PRInfo], base: str = "v8", show_wrong_base: bool = False) -> None:
|
|
403
|
-
actionable = [p for p in prs if p.base_branch == base]
|
|
404
|
-
wrong_base = [p for p in prs if p.base_branch != base]
|
|
405
|
-
|
|
406
|
-
# Sort: READY first, then by status order, then by recency
|
|
407
|
-
actionable.sort(key=lambda p: (_STATUS_ORDER.index(p.status) if p.status in _STATUS_ORDER else 99, p.days_old))
|
|
408
|
-
|
|
409
|
-
print()
|
|
410
|
-
print(bold(f" graphify prs · base: {base} · {len(actionable)} PRs"))
|
|
411
|
-
print()
|
|
412
|
-
|
|
413
|
-
if not actionable:
|
|
414
|
-
print(dim(" No open PRs targeting this base branch."))
|
|
415
|
-
else:
|
|
416
|
-
# Header
|
|
417
|
-
print(f" {'#':>4} {'CI':2} {'STATUS':13} {'UPDATED':8} {'IMPACT':22} TITLE")
|
|
418
|
-
print(f" {'─'*4} {'─'*2} {'─'*13} {'─'*8} {'─'*22} {'─'*40}")
|
|
419
|
-
|
|
420
|
-
for pr in actionable:
|
|
421
|
-
status_str = _pad(_status_color(pr.status), 13)
|
|
422
|
-
ci_str = _ci_icon(pr.ci_status)
|
|
423
|
-
age = f"{pr.days_old}d" if pr.days_old > 0 else "today"
|
|
424
|
-
impact = _pad(dim(_truncate(pr.blast_radius, 22)), 22) if pr.blast_radius else _pad(dim("–"), 22)
|
|
425
|
-
wt = f" {cyan('⬡')}" if pr.worktree_path else " "
|
|
426
|
-
draft = dim(" [draft]") if pr.is_draft else ""
|
|
427
|
-
title = _truncate(pr.title, 52)
|
|
428
|
-
num = _pad(bold(f"#{pr.number}"), 6)
|
|
429
|
-
print(f" {num}{wt} {ci_str} {status_str} {age:>6} {impact} {title}{draft}")
|
|
430
|
-
|
|
431
|
-
# Summary line
|
|
432
|
-
by_status: dict[str, int] = {}
|
|
433
|
-
for p in actionable:
|
|
434
|
-
by_status[p.status] = by_status.get(p.status, 0) + 1
|
|
435
|
-
|
|
436
|
-
parts = []
|
|
437
|
-
if by_status.get("READY"): parts.append(green(f"{by_status['READY']} ready"))
|
|
438
|
-
if by_status.get("APPROVED"): parts.append(bold(green(f"{by_status['APPROVED']} approved")))
|
|
439
|
-
if by_status.get("PENDING"): parts.append(yellow(f"{by_status['PENDING']} pending CI"))
|
|
440
|
-
if by_status.get("CI-FAIL"): parts.append(red(f"{by_status['CI-FAIL']} CI failing"))
|
|
441
|
-
if by_status.get("CHANGES-REQ"):parts.append(red(f"{by_status['CHANGES-REQ']} changes requested"))
|
|
442
|
-
if by_status.get("DRAFT"): parts.append(yellow(f"{by_status['DRAFT']} draft"))
|
|
443
|
-
if by_status.get("STALE"): parts.append(dim(f"{by_status['STALE']} stale"))
|
|
444
|
-
|
|
445
|
-
if wrong_base:
|
|
446
|
-
parts.append(dim(f"{len(wrong_base)} wrong base"))
|
|
447
|
-
|
|
448
|
-
print()
|
|
449
|
-
print(f" {' · '.join(parts)}")
|
|
450
|
-
print()
|
|
451
|
-
|
|
452
|
-
if wrong_base and show_wrong_base:
|
|
453
|
-
print(dim(f" ── {len(wrong_base)} PRs targeting wrong base ──"))
|
|
454
|
-
for pr in sorted(wrong_base, key=lambda p: p.number, reverse=True):
|
|
455
|
-
print(dim(f" #{pr.number:4} base={pr.base_branch:12} {_truncate(pr.title, 60)}"))
|
|
456
|
-
print()
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
def render_worktrees(prs: list[PRInfo], worktrees: dict[str, str]) -> None:
|
|
460
|
-
print()
|
|
461
|
-
print(bold(" Worktrees"))
|
|
462
|
-
print()
|
|
463
|
-
if not worktrees:
|
|
464
|
-
print(dim(" No active worktrees found."))
|
|
465
|
-
print()
|
|
466
|
-
return
|
|
467
|
-
|
|
468
|
-
pr_by_branch = {p.branch: p for p in prs}
|
|
469
|
-
for branch, path in sorted(worktrees.items()):
|
|
470
|
-
pr = pr_by_branch.get(branch)
|
|
471
|
-
if pr:
|
|
472
|
-
status = _status_color(pr.status)
|
|
473
|
-
print(f" {cyan(path)}")
|
|
474
|
-
print(f" {dim('branch:')} {branch} -> PR {bold(f'#{pr.number}')} [{status}] {_truncate(pr.title, 50)}")
|
|
475
|
-
else:
|
|
476
|
-
print(f" {cyan(path)}")
|
|
477
|
-
print(f" {dim('branch:')} {branch} {dim('(no open PR)')}")
|
|
478
|
-
print()
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
def render_conflicts(
|
|
482
|
-
prs: list[PRInfo],
|
|
483
|
-
base: str = "v8",
|
|
484
|
-
community_labels: dict[int, list[str]] | None = None,
|
|
485
|
-
) -> None:
|
|
486
|
-
actionable = [p for p in prs if p.base_branch == base and p.communities_touched]
|
|
487
|
-
if not actionable:
|
|
488
|
-
print(dim("\n No graph impact data - run with a valid graph.json to detect conflicts.\n"))
|
|
489
|
-
return
|
|
490
|
-
|
|
491
|
-
# Build community → [PRs] map
|
|
492
|
-
comm_to_prs: dict[int, list[PRInfo]] = {}
|
|
493
|
-
for pr in actionable:
|
|
494
|
-
for c in pr.communities_touched:
|
|
495
|
-
comm_to_prs.setdefault(c, []).append(pr)
|
|
496
|
-
|
|
497
|
-
conflicts = {c: ps for c, ps in comm_to_prs.items() if len(ps) > 1}
|
|
498
|
-
if not conflicts:
|
|
499
|
-
print(green("\n No community overlap between open PRs - safe to merge in any order.\n"))
|
|
500
|
-
return
|
|
501
|
-
|
|
502
|
-
print()
|
|
503
|
-
print(bold(" Community conflicts (PRs sharing the same graph community)"))
|
|
504
|
-
print()
|
|
505
|
-
labels = community_labels or {}
|
|
506
|
-
for comm, ps in sorted(conflicts.items(), key=lambda x: -len(x[1])):
|
|
507
|
-
comm_label_str = ""
|
|
508
|
-
if comm in labels and labels[comm]:
|
|
509
|
-
comm_label_str = dim(" — " + ", ".join(labels[comm]))
|
|
510
|
-
print(f" {yellow(f'Community {comm}')}{comm_label_str} ({len(ps)} PRs overlap)")
|
|
511
|
-
for pr in ps:
|
|
512
|
-
print(f" #{pr.number:4} {_pad(_status_color(pr.status), 13)} {_truncate(pr.title, 55)}")
|
|
513
|
-
print()
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
def render_pr_detail(pr: PRInfo, repo: str | None = None) -> None:
|
|
517
|
-
print()
|
|
518
|
-
print(bold(f" PR #{pr.number} · {_status_color(pr.status)}"))
|
|
519
|
-
print(f" {pr.title}")
|
|
520
|
-
print()
|
|
521
|
-
print(f" {dim('branch:')} {pr.branch} -> {pr.base_branch}")
|
|
522
|
-
print(f" {dim('author:')} {pr.author}")
|
|
523
|
-
print(f" {dim('updated:')} {pr.days_old}d ago")
|
|
524
|
-
print(f" {dim('CI:')} {_ci_icon(pr.ci_status)} {pr.ci_status}")
|
|
525
|
-
if pr.review_decision:
|
|
526
|
-
print(f" {dim('review:')} {pr.review_decision}")
|
|
527
|
-
if pr.worktree_path:
|
|
528
|
-
print(f" {dim('worktree:')} {cyan(pr.worktree_path)}")
|
|
529
|
-
if pr.blast_radius:
|
|
530
|
-
print()
|
|
531
|
-
print(f" {bold('Graph impact:')} {pr.blast_radius}")
|
|
532
|
-
print(f" {dim('communities:')} {pr.communities_touched}")
|
|
533
|
-
if pr.files_changed:
|
|
534
|
-
print(f" {dim('files changed:')} {len(pr.files_changed)}")
|
|
535
|
-
for f in pr.files_changed[:10]:
|
|
536
|
-
print(f" {dim(f)}")
|
|
537
|
-
if len(pr.files_changed) > 10:
|
|
538
|
-
print(dim(f" … and {len(pr.files_changed) - 10} more"))
|
|
539
|
-
print()
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
# ── Triage (multi-backend) ────────────────────────────────────────────────────
|
|
543
|
-
|
|
544
|
-
# Best model per backend for reasoning tasks (different from extraction defaults)
|
|
545
|
-
_TRIAGE_MODEL_DEFAULTS: dict[str, str] = {
|
|
546
|
-
"claude": "claude-opus-4-7",
|
|
547
|
-
"kimi": "kimi-k2.6",
|
|
548
|
-
"openai": "gpt-4.1-mini",
|
|
549
|
-
"gemini": "gemini-3-flash-preview",
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
def _resolve_triage_backend() -> tuple[str, str]:
|
|
554
|
-
"""Return (backend, model) using GRAPHIFY_TRIAGE_BACKEND or first available key."""
|
|
555
|
-
from graphify.llm import BACKENDS, _get_backend_api_key, _default_model_for_backend
|
|
556
|
-
|
|
557
|
-
explicit = os.environ.get("GRAPHIFY_TRIAGE_BACKEND", "").strip()
|
|
558
|
-
if explicit in BACKENDS:
|
|
559
|
-
model = (os.environ.get("GRAPHIFY_TRIAGE_MODEL")
|
|
560
|
-
or _TRIAGE_MODEL_DEFAULTS.get(explicit)
|
|
561
|
-
or _default_model_for_backend(explicit))
|
|
562
|
-
return explicit, model
|
|
563
|
-
|
|
564
|
-
for b in ("claude", "kimi", "openai", "gemini"):
|
|
565
|
-
if _get_backend_api_key(b):
|
|
566
|
-
model = (os.environ.get("GRAPHIFY_TRIAGE_MODEL")
|
|
567
|
-
or _TRIAGE_MODEL_DEFAULTS.get(b)
|
|
568
|
-
or _default_model_for_backend(b))
|
|
569
|
-
return b, model
|
|
570
|
-
|
|
571
|
-
import shutil
|
|
572
|
-
if shutil.which("claude"):
|
|
573
|
-
return "claude-cli", "claude-code-plan"
|
|
574
|
-
|
|
575
|
-
return "ollama", _default_model_for_backend("ollama")
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
def triage_with_opus(prs: list[PRInfo], base: str) -> None:
|
|
579
|
-
try:
|
|
580
|
-
from graphify.llm import BACKENDS, _get_backend_api_key
|
|
581
|
-
except ImportError:
|
|
582
|
-
print(red(" graphify.llm not available - cannot run triage."), file=sys.stderr)
|
|
583
|
-
sys.exit(1)
|
|
584
|
-
|
|
585
|
-
candidates = [p for p in prs if p.base_branch == base and p.status not in ("WRONG-BASE", "STALE")]
|
|
586
|
-
if not candidates:
|
|
587
|
-
print(dim(" No actionable PRs to triage."))
|
|
588
|
-
return
|
|
589
|
-
|
|
590
|
-
lines = []
|
|
591
|
-
for pr in candidates:
|
|
592
|
-
impact = f", blast_radius={pr.blast_radius}" if pr.blast_radius else ""
|
|
593
|
-
lines.append(
|
|
594
|
-
f"PR #{pr.number} [{pr.status}] CI={pr.ci_status} review={pr.review_decision or 'none'} "
|
|
595
|
-
f"age={pr.days_old}d author={pr.author}{impact}\n title: {pr.title}"
|
|
596
|
-
)
|
|
597
|
-
|
|
598
|
-
prompt = (
|
|
599
|
-
"You are a senior engineer helping triage a PR review queue. "
|
|
600
|
-
"Given these open PRs, rank them by review priority for the repo maintainer. "
|
|
601
|
-
"For each PR give: priority number, one sentence on what action to take and why. "
|
|
602
|
-
"Be direct and specific. Format each as: #<number> — <action>.\n\n"
|
|
603
|
-
+ "\n\n".join(lines)
|
|
604
|
-
)
|
|
605
|
-
|
|
606
|
-
try:
|
|
607
|
-
backend, model = _resolve_triage_backend()
|
|
608
|
-
except Exception as e:
|
|
609
|
-
print(red(f" Could not resolve triage backend: {e}"), file=sys.stderr)
|
|
610
|
-
sys.exit(1)
|
|
611
|
-
|
|
612
|
-
print()
|
|
613
|
-
print(bold(" Triage") + dim(f" ({backend} / {model})"))
|
|
614
|
-
print()
|
|
615
|
-
|
|
616
|
-
try:
|
|
617
|
-
if backend == "claude":
|
|
618
|
-
import anthropic
|
|
619
|
-
client = anthropic.Anthropic(api_key=_get_backend_api_key("claude"))
|
|
620
|
-
with client.messages.stream(
|
|
621
|
-
model=model, max_tokens=1024,
|
|
622
|
-
messages=[{"role": "user", "content": prompt}],
|
|
623
|
-
) as stream:
|
|
624
|
-
print(" ", end="", flush=True)
|
|
625
|
-
for text in stream.text_stream:
|
|
626
|
-
print(text.replace("\n", "\n "), end="", flush=True)
|
|
627
|
-
print("\n")
|
|
628
|
-
|
|
629
|
-
elif backend in ("kimi", "openai", "gemini", "ollama"):
|
|
630
|
-
from openai import OpenAI
|
|
631
|
-
cfg = BACKENDS[backend]
|
|
632
|
-
api_key = _get_backend_api_key(backend) or "ollama"
|
|
633
|
-
client = OpenAI(api_key=api_key, base_url=cfg.get("base_url", ""))
|
|
634
|
-
with client.chat.completions.create(
|
|
635
|
-
model=model, max_tokens=1024, stream=True,
|
|
636
|
-
messages=[{"role": "user", "content": prompt}],
|
|
637
|
-
) as stream:
|
|
638
|
-
print(" ", end="", flush=True)
|
|
639
|
-
for chunk in stream:
|
|
640
|
-
delta = chunk.choices[0].delta.content if chunk.choices else None
|
|
641
|
-
if delta:
|
|
642
|
-
print(delta.replace("\n", "\n "), end="", flush=True)
|
|
643
|
-
print("\n")
|
|
644
|
-
|
|
645
|
-
elif backend == "claude-cli":
|
|
646
|
-
import subprocess as _sp
|
|
647
|
-
proc = _sp.run(
|
|
648
|
-
["claude", "-p", "--no-session-persistence"],
|
|
649
|
-
input=prompt, capture_output=True, text=True, timeout=120,
|
|
650
|
-
)
|
|
651
|
-
if proc.returncode != 0:
|
|
652
|
-
print(red(f" claude -p failed: {proc.stderr.strip()[:300]}"), file=sys.stderr)
|
|
653
|
-
else:
|
|
654
|
-
try:
|
|
655
|
-
result = json.loads(proc.stdout).get("result") or proc.stdout
|
|
656
|
-
except json.JSONDecodeError:
|
|
657
|
-
result = proc.stdout
|
|
658
|
-
for line in result.splitlines():
|
|
659
|
-
print(f" {line}")
|
|
660
|
-
print()
|
|
661
|
-
|
|
662
|
-
except Exception as e:
|
|
663
|
-
print(f"\n\n {red(f'Triage failed: {e}')}", file=sys.stderr)
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
667
|
-
|
|
668
|
-
def cmd_prs(argv: list[str]) -> None:
|
|
669
|
-
base: str | None = None # auto-detected from repo if not given
|
|
670
|
-
repo: str | None = None
|
|
671
|
-
do_triage = False
|
|
672
|
-
do_worktrees = False
|
|
673
|
-
do_conflicts = False
|
|
674
|
-
show_wrong_base = False
|
|
675
|
-
pr_number: int | None = None
|
|
676
|
-
graph_path = Path("graphify-out/graph.json")
|
|
677
|
-
|
|
678
|
-
i = 0
|
|
679
|
-
while i < len(argv):
|
|
680
|
-
arg = argv[i]
|
|
681
|
-
if arg == "--triage":
|
|
682
|
-
do_triage = True
|
|
683
|
-
elif arg == "--worktrees":
|
|
684
|
-
do_worktrees = True
|
|
685
|
-
elif arg == "--conflicts":
|
|
686
|
-
do_conflicts = True
|
|
687
|
-
elif arg == "--wrong-base":
|
|
688
|
-
show_wrong_base = True
|
|
689
|
-
elif arg in ("--base", "-b") and i + 1 < len(argv):
|
|
690
|
-
base = argv[i + 1]; i += 1
|
|
691
|
-
elif arg.startswith("--base="):
|
|
692
|
-
base = arg.split("=", 1)[1]
|
|
693
|
-
elif arg in ("--repo", "-R") and i + 1 < len(argv):
|
|
694
|
-
repo = argv[i + 1]; i += 1
|
|
695
|
-
elif arg.startswith("--graph="):
|
|
696
|
-
graph_path = Path(arg.split("=", 1)[1])
|
|
697
|
-
elif arg == "--graph" and i + 1 < len(argv):
|
|
698
|
-
graph_path = Path(argv[i + 1]); i += 1
|
|
699
|
-
elif arg.lstrip("#").isdigit():
|
|
700
|
-
pr_number = int(arg.lstrip("#"))
|
|
701
|
-
elif arg in ("-h", "--help"):
|
|
702
|
-
print(__doc__)
|
|
703
|
-
return
|
|
704
|
-
i += 1
|
|
705
|
-
|
|
706
|
-
if base is None:
|
|
707
|
-
base = _detect_default_branch(repo)
|
|
708
|
-
|
|
709
|
-
try:
|
|
710
|
-
prs = fetch_prs(repo=repo, base=base)
|
|
711
|
-
except RuntimeError as e:
|
|
712
|
-
print(red(f" Error: {e}"), file=sys.stderr)
|
|
713
|
-
sys.exit(1)
|
|
714
|
-
|
|
715
|
-
worktrees = fetch_worktrees()
|
|
716
|
-
for pr in prs:
|
|
717
|
-
pr.worktree_path = worktrees.get(pr.branch)
|
|
718
|
-
|
|
719
|
-
# Graph impact is expensive (concurrent gh pr diff calls) — only fetch when
|
|
720
|
-
# the user actually needs it: deep dive, triage, and conflict detection.
|
|
721
|
-
community_labels: dict[int, list[str]] = {}
|
|
722
|
-
needs_impact = graph_path.exists() and (pr_number is not None or do_triage or do_conflicts)
|
|
723
|
-
if needs_impact:
|
|
724
|
-
community_labels = attach_graph_impact(prs, graph_path, repo)
|
|
725
|
-
|
|
726
|
-
if pr_number is not None:
|
|
727
|
-
match = next((p for p in prs if p.number == pr_number), None)
|
|
728
|
-
if not match:
|
|
729
|
-
print(red(f" PR #{pr_number} not found in open PRs."), file=sys.stderr)
|
|
730
|
-
sys.exit(1)
|
|
731
|
-
render_pr_detail(match, repo)
|
|
732
|
-
return
|
|
733
|
-
|
|
734
|
-
if do_triage:
|
|
735
|
-
render_dashboard(prs, base, show_wrong_base)
|
|
736
|
-
triage_with_opus(prs, base)
|
|
737
|
-
return
|
|
738
|
-
|
|
739
|
-
if do_worktrees:
|
|
740
|
-
render_worktrees(prs, worktrees)
|
|
741
|
-
return
|
|
742
|
-
|
|
743
|
-
if do_conflicts:
|
|
744
|
-
render_dashboard(prs, base, show_wrong_base)
|
|
745
|
-
render_conflicts(prs, base, community_labels)
|
|
746
|
-
return
|
|
747
|
-
|
|
748
|
-
render_dashboard(prs, base, show_wrong_base)
|