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