stackvitals 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (465) hide show
  1. mergescout/__init__.py +1 -0
  2. mergescout/archive/__init__.py +0 -0
  3. mergescout/archive/__main__.py +30 -0
  4. mergescout/archive/convert.py +83 -0
  5. mergescout/archive/events.py +55 -0
  6. mergescout/archive/manifest.py +45 -0
  7. mergescout/archive/stream.py +83 -0
  8. mergescout/bench/__init__.py +1 -0
  9. mergescout/bench/__main__.py +135 -0
  10. mergescout/bench/ci.py +257 -0
  11. mergescout/bench/deploys.py +115 -0
  12. mergescout/bench/matched.py +22 -0
  13. mergescout/bench/prior.py +42 -0
  14. mergescout/bench/report.py +230 -0
  15. mergescout/bench/shared.py +112 -0
  16. mergescout/bench/stars.py +57 -0
  17. mergescout/cli.py +89 -0
  18. mergescout/config.py +35 -0
  19. mergescout/crawl/__init__.py +0 -0
  20. mergescout/crawl/__main__.py +97 -0
  21. mergescout/crawl/chain.py +34 -0
  22. mergescout/crawl/fetch.py +146 -0
  23. mergescout/crawl/outcomes.py +69 -0
  24. mergescout/crawl/queries.py +250 -0
  25. mergescout/crawl/runner.py +176 -0
  26. mergescout/crawl/seed.py +77 -0
  27. mergescout/crawl/state.py +169 -0
  28. mergescout/crawl/writer.py +87 -0
  29. mergescout/discover/__init__.py +0 -0
  30. mergescout/discover/__main__.py +58 -0
  31. mergescout/discover/sweep.py +286 -0
  32. mergescout/github/__init__.py +0 -0
  33. mergescout/github/graphql.py +202 -0
  34. mergescout/github/identities.py +35 -0
  35. mergescout/github/releases.py +137 -0
  36. mergescout/labels.py +20 -0
  37. mergescout/mcp_server.py +228 -0
  38. mergescout/measure/__init__.py +0 -0
  39. mergescout/measure/__main__.py +75 -0
  40. mergescout/measure/api_cost.py +91 -0
  41. mergescout/measure/count_repos.py +30 -0
  42. mergescout/measure/jsonl.py +14 -0
  43. mergescout/measure/report.py +56 -0
  44. mergescout/measure/sample.py +37 -0
  45. mergescout/owners.py +22 -0
  46. mergescout/setup_check.py +64 -0
  47. mergescout/signals/__init__.py +0 -0
  48. mergescout/signals/__main__.py +96 -0
  49. mergescout/signals/bump_ledger.py +258 -0
  50. mergescout/signals/bumps.py +104 -0
  51. mergescout/signals/check.py +141 -0
  52. mergescout/signals/community_bench.py +149 -0
  53. mergescout/signals/delivery_bench.py +103 -0
  54. mergescout/signals/deps_map.py +261 -0
  55. mergescout/signals/deps_trends.py +117 -0
  56. mergescout/signals/evidence.py +94 -0
  57. mergescout/signals/exploits.py +43 -0
  58. mergescout/signals/flow_bench.py +258 -0
  59. mergescout/signals/identity.py +73 -0
  60. mergescout/signals/issues.py +76 -0
  61. mergescout/signals/labels.py +79 -0
  62. mergescout/signals/osv.py +232 -0
  63. mergescout/signals/owners_watch.py +80 -0
  64. mergescout/signals/repo_stats.py +96 -0
  65. mergescout/signals/rules.py +70 -0
  66. mergescout/signals/silver.py +33 -0
  67. mergescout/signals/top_packages.py +26 -0
  68. mergescout/signals/triage.py +71 -0
  69. mergescout/signals/triage_math.py +120 -0
  70. mergescout/signals/walls.py +91 -0
  71. mergescout/snapshot/__init__.py +0 -0
  72. mergescout/snapshot/__main__.py +215 -0
  73. mergescout/snapshot/build.py +147 -0
  74. mergescout/snapshot/publish.py +70 -0
  75. mergescout/snapshot/schema.py +34 -0
  76. mergescout/snapshot/score.py +30 -0
  77. mergescout/store/__init__.py +0 -0
  78. mergescout/store/__main__.py +46 -0
  79. mergescout/store/sync.py +148 -0
  80. mergescout/timeutil.py +11 -0
  81. mergescout/tool/__init__.py +0 -0
  82. mergescout/tool/action_pack.py +62 -0
  83. mergescout/tool/live.py +201 -0
  84. mergescout/tool/queries.py +293 -0
  85. mergescout/tool/refs.py +31 -0
  86. mergescout/tool/store.py +129 -0
  87. mergescout/tool/tracking.py +83 -0
  88. stackvitals/__init__.py +2 -0
  89. stackvitals/__main__.py +4 -0
  90. stackvitals/ci/__init__.py +1 -0
  91. stackvitals/ci/action.py +574 -0
  92. stackvitals/ci/analyze.py +577 -0
  93. stackvitals/ci/api.py +218 -0
  94. stackvitals/ci/automation.py +212 -0
  95. stackvitals/ci/bench.py +195 -0
  96. stackvitals/ci/burnin.py +102 -0
  97. stackvitals/ci/cli.py +570 -0
  98. stackvitals/ci/collect.py +239 -0
  99. stackvitals/ci/cost.py +107 -0
  100. stackvitals/ci/coverage.py +155 -0
  101. stackvitals/ci/feedback.py +699 -0
  102. stackvitals/ci/fingerprint.py +220 -0
  103. stackvitals/ci/gitlab.py +121 -0
  104. stackvitals/ci/gitlab_action.py +125 -0
  105. stackvitals/ci/impact.py +125 -0
  106. stackvitals/ci/investigate.py +59 -0
  107. stackvitals/ci/kinds.py +52 -0
  108. stackvitals/ci/monitors.py +105 -0
  109. stackvitals/ci/owners.py +116 -0
  110. stackvitals/ci/quarantine_runtime.py +90 -0
  111. stackvitals/ci/report.py +435 -0
  112. stackvitals/ci/retry.py +67 -0
  113. stackvitals/ci/selfheal.py +102 -0
  114. stackvitals/ci/shards.py +120 -0
  115. stackvitals/ci/share.py +59 -0
  116. stackvitals/ci/store.py +93 -0
  117. stackvitals/ci/testreports.py +351 -0
  118. stackvitals/ci/tickets.py +106 -0
  119. stackvitals/ci/track_record.json +11 -0
  120. stackvitals/ci/triage.py +130 -0
  121. stackvitals/ci/triggers.py +147 -0
  122. stackvitals/ci/upload.py +85 -0
  123. stackvitals/ci/validate.py +91 -0
  124. stackvitals/cli.py +111 -0
  125. stackvitals/codeowners.py +69 -0
  126. stackvitals/community/__init__.py +0 -0
  127. stackvitals/community/action.py +140 -0
  128. stackvitals/community/affiliation.py +51 -0
  129. stackvitals/community/analyze.py +507 -0
  130. stackvitals/community/build.py +56 -0
  131. stackvitals/community/chaoss.py +70 -0
  132. stackvitals/community/cli.py +79 -0
  133. stackvitals/community/collect.py +240 -0
  134. stackvitals/community/context.py +54 -0
  135. stackvitals/community/report.py +250 -0
  136. stackvitals/connectors/__init__.py +244 -0
  137. stackvitals/connectors/ai_spend.py +190 -0
  138. stackvitals/connectors/base.py +82 -0
  139. stackvitals/connectors/check.py +686 -0
  140. stackvitals/connectors/copilot.py +104 -0
  141. stackvitals/connectors/discord.py +103 -0
  142. stackvitals/connectors/discourse.py +117 -0
  143. stackvitals/connectors/incident_io.py +106 -0
  144. stackvitals/connectors/jira.py +210 -0
  145. stackvitals/connectors/linear.py +140 -0
  146. stackvitals/connectors/pagerduty.py +96 -0
  147. stackvitals/connectors/slack.py +110 -0
  148. stackvitals/connectors/sonar.py +77 -0
  149. stackvitals/connectors/store.py +120 -0
  150. stackvitals/connectors/tracker.py +108 -0
  151. stackvitals/connectors/writeback.py +105 -0
  152. stackvitals/contribute/__init__.py +1 -0
  153. stackvitals/contribute/brief.py +297 -0
  154. stackvitals/contribute/cli.py +64 -0
  155. stackvitals/core/__init__.py +1 -0
  156. stackvitals/core/actors.py +103 -0
  157. stackvitals/core/agent_ledger.py +120 -0
  158. stackvitals/core/agreements.py +95 -0
  159. stackvitals/core/ai_outcomes.py +276 -0
  160. stackvitals/core/alerts.py +224 -0
  161. stackvitals/core/appdata.py +112 -0
  162. stackvitals/core/bucket.py +190 -0
  163. stackvitals/core/bugs.py +43 -0
  164. stackvitals/core/calendar.py +34 -0
  165. stackvitals/core/capex.py +57 -0
  166. stackvitals/core/change_risk.py +181 -0
  167. stackvitals/core/changes.py +133 -0
  168. stackvitals/core/chat.py +143 -0
  169. stackvitals/core/config.py +373 -0
  170. stackvitals/core/dataflow.py +168 -0
  171. stackvitals/core/decisions.py +275 -0
  172. stackvitals/core/delivery.py +954 -0
  173. stackvitals/core/delivery_peers.py +98 -0
  174. stackvitals/core/delivery_report.py +429 -0
  175. stackvitals/core/deliverycli.py +147 -0
  176. stackvitals/core/devlake_dora.sql +485 -0
  177. stackvitals/core/digest.py +333 -0
  178. stackvitals/core/explaincli.py +107 -0
  179. stackvitals/core/explore.py +296 -0
  180. stackvitals/core/export.py +342 -0
  181. stackvitals/core/exportcli.py +117 -0
  182. stackvitals/core/findings.py +350 -0
  183. stackvitals/core/health.py +94 -0
  184. stackvitals/core/identity.py +82 -0
  185. stackvitals/core/insights.py +365 -0
  186. stackvitals/core/insightscli.py +43 -0
  187. stackvitals/core/installdoc.py +69 -0
  188. stackvitals/core/investment.py +187 -0
  189. stackvitals/core/joins.py +43 -0
  190. stackvitals/core/llmsdoc.py +40 -0
  191. stackvitals/core/merge_queue.py +70 -0
  192. stackvitals/core/metrics.py +109 -0
  193. stackvitals/core/model.py +285 -0
  194. stackvitals/core/projectcli.py +66 -0
  195. stackvitals/core/propose.py +86 -0
  196. stackvitals/core/pulse.py +85 -0
  197. stackvitals/core/recorded.py +145 -0
  198. stackvitals/core/releases.py +39 -0
  199. stackvitals/core/requests.py +528 -0
  200. stackvitals/core/resync.py +35 -0
  201. stackvitals/core/schema.py +205 -0
  202. stackvitals/core/services.py +203 -0
  203. stackvitals/core/settings.py +96 -0
  204. stackvitals/core/sqlcli.py +111 -0
  205. stackvitals/core/stats.py +222 -0
  206. stackvitals/core/store.py +125 -0
  207. stackvitals/core/summary.py +104 -0
  208. stackvitals/core/time.py +29 -0
  209. stackvitals/core/tomledit.py +149 -0
  210. stackvitals/core/uicli.py +138 -0
  211. stackvitals/core/webapp.py +79 -0
  212. stackvitals/core/what_changed.py +76 -0
  213. stackvitals/core/workspace.py +194 -0
  214. stackvitals/deps/__init__.py +1 -0
  215. stackvitals/deps/action.py +299 -0
  216. stackvitals/deps/actions_vulns.py +89 -0
  217. stackvitals/deps/cli.py +77 -0
  218. stackvitals/deps/dossier.py +341 -0
  219. stackvitals/deps/evidence.py +44 -0
  220. stackvitals/deps/fixplan.py +40 -0
  221. stackvitals/deps/guarddog.py +32 -0
  222. stackvitals/deps/hygiene.py +47 -0
  223. stackvitals/deps/imports.py +113 -0
  224. stackvitals/deps/lockfiles.py +198 -0
  225. stackvitals/deps/lookalike.py +80 -0
  226. stackvitals/deps/reach.py +59 -0
  227. stackvitals/deps/release.py +110 -0
  228. stackvitals/deps/runtimes.py +161 -0
  229. stackvitals/deps/supply.py +454 -0
  230. stackvitals/deps/vulns.py +588 -0
  231. stackvitals/editors/__init__.py +40 -0
  232. stackvitals/editors/vscode/extension.js +101 -0
  233. stackvitals/editors/vscode/marks.js +92 -0
  234. stackvitals/editors/vscode/package.json +26 -0
  235. stackvitals/flow/__init__.py +1 -0
  236. stackvitals/flow/action.py +178 -0
  237. stackvitals/flow/analyze.py +418 -0
  238. stackvitals/flow/automate.py +72 -0
  239. stackvitals/flow/build.py +46 -0
  240. stackvitals/flow/cli.py +93 -0
  241. stackvitals/flow/code_hosts.py +146 -0
  242. stackvitals/flow/collect.py +451 -0
  243. stackvitals/flow/gitlab_mr.py +75 -0
  244. stackvitals/flow/knowledge.py +202 -0
  245. stackvitals/flow/peers.py +90 -0
  246. stackvitals/flow/preflight.py +210 -0
  247. stackvitals/flow/report.py +253 -0
  248. stackvitals/flow/rules.py +135 -0
  249. stackvitals/flow/store.py +108 -0
  250. stackvitals/flow/survival.py +84 -0
  251. stackvitals/flow/triage.py +62 -0
  252. stackvitals/mcp_server.py +670 -0
  253. stackvitals/portal/__init__.py +0 -0
  254. stackvitals/portal/action.py +205 -0
  255. stackvitals/portal/batch.py +80 -0
  256. stackvitals/portal/campaigns.py +104 -0
  257. stackvitals/portal/catalog.py +104 -0
  258. stackvitals/portal/checks.py +63 -0
  259. stackvitals/portal/cli.py +172 -0
  260. stackvitals/portal/descriptor.py +105 -0
  261. stackvitals/portal/export.py +43 -0
  262. stackvitals/portal/github.py +48 -0
  263. stackvitals/portal/golden.py +95 -0
  264. stackvitals/portal/history.py +30 -0
  265. stackvitals/portal/inventory.py +264 -0
  266. stackvitals/portal/owners.py +78 -0
  267. stackvitals/portal/pages.py +153 -0
  268. stackvitals/portal/rollup.py +32 -0
  269. stackvitals/portal/scorecard.py +366 -0
  270. stackvitals/security/__init__.py +2 -0
  271. stackvitals/security/probes.py +154 -0
  272. stackvitals/security/repo_checks.py +132 -0
  273. stackvitals/security/workflows.py +111 -0
  274. stackvitals/shell.py +107 -0
  275. stackvitals/site.py +51 -0
  276. stackvitals/web/app/LICENSES.md +428 -0
  277. stackvitals/web/app/boot.js +1 -0
  278. stackvitals/web/app/chunks/AddRepo-KRPZXWCY.js +1 -0
  279. stackvitals/web/app/chunks/SetupRunning-XWFQAKRZ.js +1 -0
  280. stackvitals/web/app/chunks/accuracy-data-OQTDXI7Y.js +1 -0
  281. stackvitals/web/app/chunks/agents-data-BAL6QAOF.js +11 -0
  282. stackvitals/web/app/chunks/chunk-24LFKFZ6.js +1 -0
  283. stackvitals/web/app/chunks/chunk-3G4SQNUC.js +1 -0
  284. stackvitals/web/app/chunks/chunk-3IXWEOIJ.js +154 -0
  285. stackvitals/web/app/chunks/chunk-3ROQFHPV.js +1 -0
  286. stackvitals/web/app/chunks/chunk-45PGAQRO.js +8 -0
  287. stackvitals/web/app/chunks/chunk-4H53OYLG.js +1 -0
  288. stackvitals/web/app/chunks/chunk-4LUI7O5P.js +1 -0
  289. stackvitals/web/app/chunks/chunk-4SQPQ5TB.js +1 -0
  290. stackvitals/web/app/chunks/chunk-4WKQTWUC.js +2 -0
  291. stackvitals/web/app/chunks/chunk-576YHDEK.js +8 -0
  292. stackvitals/web/app/chunks/chunk-6BDBXA6V.js +1 -0
  293. stackvitals/web/app/chunks/chunk-6RLVD2DT.js +1 -0
  294. stackvitals/web/app/chunks/chunk-763NH7PP.js +1 -0
  295. stackvitals/web/app/chunks/chunk-76WAVKUG.js +1 -0
  296. stackvitals/web/app/chunks/chunk-7II2BD5F.js +1 -0
  297. stackvitals/web/app/chunks/chunk-7TJCBOAY.js +1 -0
  298. stackvitals/web/app/chunks/chunk-ASYNLVOP.js +1 -0
  299. stackvitals/web/app/chunks/chunk-CTKDYZMG.js +1 -0
  300. stackvitals/web/app/chunks/chunk-CVB26NMC.js +1 -0
  301. stackvitals/web/app/chunks/chunk-G7NGB5UX.js +1 -0
  302. stackvitals/web/app/chunks/chunk-GPVLJRZD.js +1 -0
  303. stackvitals/web/app/chunks/chunk-GQYFN4PE.js +75 -0
  304. stackvitals/web/app/chunks/chunk-GT4EAKAO.js +2 -0
  305. stackvitals/web/app/chunks/chunk-IYMBFPCV.js +1 -0
  306. stackvitals/web/app/chunks/chunk-LAAGCPU3.js +1 -0
  307. stackvitals/web/app/chunks/chunk-MAPYGK3P.js +1 -0
  308. stackvitals/web/app/chunks/chunk-P5TXDD45.js +1 -0
  309. stackvitals/web/app/chunks/chunk-PABICGBL.js +1 -0
  310. stackvitals/web/app/chunks/chunk-RPXHAG6F.js +1 -0
  311. stackvitals/web/app/chunks/chunk-RQZPSJ2Y.js +1 -0
  312. stackvitals/web/app/chunks/chunk-S2ZBMEVA.js +1 -0
  313. stackvitals/web/app/chunks/chunk-S654BBEP.js +6 -0
  314. stackvitals/web/app/chunks/chunk-T7QVTNXF.js +1 -0
  315. stackvitals/web/app/chunks/chunk-THFUOCPC.js +273 -0
  316. stackvitals/web/app/chunks/chunk-TO4VVRZ7.js +1 -0
  317. stackvitals/web/app/chunks/chunk-VQ5YXO54.js +1 -0
  318. stackvitals/web/app/chunks/chunk-VWKSMFWJ.js +1 -0
  319. stackvitals/web/app/chunks/chunk-Y6LUXDM6.js +1 -0
  320. stackvitals/web/app/chunks/chunk-YTANGYYH.js +1 -0
  321. stackvitals/web/app/chunks/connect-KONYWYTT.js +1 -0
  322. stackvitals/web/app/chunks/connections-data-2DYS4UVW.js +32 -0
  323. stackvitals/web/app/chunks/logos-N5IINUAM.js +1 -0
  324. stackvitals/web/app/chunks/more-KMJMJQD3.js +10 -0
  325. stackvitals/web/app/chunks/more-PC6FVS3P.js +1 -0
  326. stackvitals/web/app/chunks/more-RCIVCUDE.js +2 -0
  327. stackvitals/web/app/chunks/more-ZKKRY6NO.css +1 -0
  328. stackvitals/web/app/chunks/sealbox-35YL4EUH.js +3 -0
  329. stackvitals/web/app/chunks/session-DYYTOE2K.js +1 -0
  330. stackvitals/web/app/chunks/settingsApply-2HRJEZYM.js +1 -0
  331. stackvitals/web/app/chunks/settingsReview-GX2FZQ4M.js +3 -0
  332. stackvitals/web/app/chunks/uPlot.esm-OIWT3AHJ.js +3 -0
  333. stackvitals/web/app/demo/ci/impact.json +1 -0
  334. stackvitals/web/app/demo/ci/quarantine.json +1 -0
  335. stackvitals/web/app/demo/ci/tickets.json +1 -0
  336. stackvitals/web/app/demo/mobile/alerts.json +1 -0
  337. stackvitals/web/app/demo/mobile/app.json +1 -0
  338. stackvitals/web/app/demo/mobile/community.json +1 -0
  339. stackvitals/web/app/demo/mobile/connectors.json +1 -0
  340. stackvitals/web/app/demo/mobile/data.json +1 -0
  341. stackvitals/web/app/demo/mobile/decisions.json +1 -0
  342. stackvitals/web/app/demo/mobile/delivery.json +1 -0
  343. stackvitals/web/app/demo/mobile/deps.json +1 -0
  344. stackvitals/web/app/demo/mobile/flow.json +1 -0
  345. stackvitals/web/app/demo/mobile/health.json +1 -0
  346. stackvitals/web/app/demo/mobile/insights.json +1 -0
  347. stackvitals/web/app/demo/mobile/pulse.json +1 -0
  348. stackvitals/web/app/demo/mobile/security.json +1 -0
  349. stackvitals/web/app/demo/mobile/summary/ci_jobs.jsonl +5 -0
  350. stackvitals/web/app/demo/mobile/summary/devlake_project_pr_metrics.jsonl +14 -0
  351. stackvitals/web/app/demo/mobile/summary/devlake_pull_requests.jsonl +14 -0
  352. stackvitals/web/app/demo/mobile/summary/manifest.json +1 -0
  353. stackvitals/web/app/demo/mobile/summary/prs.jsonl +19 -0
  354. stackvitals/web/app/demo/mobile/summary/tests.jsonl +1 -0
  355. stackvitals/web/app/demo/mobile/supply.json +1 -0
  356. stackvitals/web/app/demo/mobile/vulns.json +1 -0
  357. stackvitals/web/app/demo/payments-api/alerts.json +1 -0
  358. stackvitals/web/app/demo/payments-api/app.json +1 -0
  359. stackvitals/web/app/demo/payments-api/community.json +1 -0
  360. stackvitals/web/app/demo/payments-api/connectors.json +1 -0
  361. stackvitals/web/app/demo/payments-api/data.json +1 -0
  362. stackvitals/web/app/demo/payments-api/decisions.json +1 -0
  363. stackvitals/web/app/demo/payments-api/delivery.json +1 -0
  364. stackvitals/web/app/demo/payments-api/deps.json +1 -0
  365. stackvitals/web/app/demo/payments-api/flow.json +1 -0
  366. stackvitals/web/app/demo/payments-api/health.json +1 -0
  367. stackvitals/web/app/demo/payments-api/insights.json +1 -0
  368. stackvitals/web/app/demo/payments-api/pulse.json +1 -0
  369. stackvitals/web/app/demo/payments-api/security.json +1 -0
  370. stackvitals/web/app/demo/payments-api/summary/ci_jobs.jsonl +5 -0
  371. stackvitals/web/app/demo/payments-api/summary/devlake_project_pr_metrics.jsonl +14 -0
  372. stackvitals/web/app/demo/payments-api/summary/devlake_pull_requests.jsonl +14 -0
  373. stackvitals/web/app/demo/payments-api/summary/manifest.json +1 -0
  374. stackvitals/web/app/demo/payments-api/summary/prs.jsonl +22 -0
  375. stackvitals/web/app/demo/payments-api/summary/tests.jsonl +3 -0
  376. stackvitals/web/app/demo/payments-api/supply.json +1 -0
  377. stackvitals/web/app/demo/payments-api/vulns.json +1 -0
  378. stackvitals/web/app/demo/portal.json +1 -0
  379. stackvitals/web/app/demo/settings.json +1 -0
  380. stackvitals/web/app/demo/web-app/alerts.json +1 -0
  381. stackvitals/web/app/demo/web-app/app.json +1 -0
  382. stackvitals/web/app/demo/web-app/community.json +1 -0
  383. stackvitals/web/app/demo/web-app/connectors.json +1 -0
  384. stackvitals/web/app/demo/web-app/data.json +1 -0
  385. stackvitals/web/app/demo/web-app/decisions.json +1 -0
  386. stackvitals/web/app/demo/web-app/delivery.json +1 -0
  387. stackvitals/web/app/demo/web-app/deps.json +1 -0
  388. stackvitals/web/app/demo/web-app/flow.json +1 -0
  389. stackvitals/web/app/demo/web-app/health.json +1 -0
  390. stackvitals/web/app/demo/web-app/insights.json +1 -0
  391. stackvitals/web/app/demo/web-app/pulse.json +1 -0
  392. stackvitals/web/app/demo/web-app/security.json +1 -0
  393. stackvitals/web/app/demo/web-app/summary/ci_jobs.jsonl +5 -0
  394. stackvitals/web/app/demo/web-app/summary/devlake_project_pr_metrics.jsonl +14 -0
  395. stackvitals/web/app/demo/web-app/summary/devlake_pull_requests.jsonl +14 -0
  396. stackvitals/web/app/demo/web-app/summary/manifest.json +1 -0
  397. stackvitals/web/app/demo/web-app/summary/prs.jsonl +18 -0
  398. stackvitals/web/app/demo/web-app/summary/tests.jsonl +2 -0
  399. stackvitals/web/app/demo/web-app/supply.json +1 -0
  400. stackvitals/web/app/demo/web-app/vulns.json +1 -0
  401. stackvitals/web/app/fonts/plex-mono-400.woff2 +0 -0
  402. stackvitals/web/app/fonts/plex-sans-400.woff2 +0 -0
  403. stackvitals/web/app/fonts/plex-sans-600.woff2 +0 -0
  404. stackvitals/web/app/fonts/plex-sans-condensed-700.woff2 +0 -0
  405. stackvitals/web/app/help/accuracy.json +1 -0
  406. stackvitals/web/app/help/connectors.json +1 -0
  407. stackvitals/web/app/help/getting-started.json +1 -0
  408. stackvitals/web/app/help/index.json +1 -0
  409. stackvitals/web/app/help/modules.json +1 -0
  410. stackvitals/web/app/help/privacy-and-security.json +1 -0
  411. stackvitals/web/app/help/reference.json +1 -0
  412. stackvitals/web/app/main.css +1 -0
  413. stackvitals/web/app/main.js +1 -0
  414. stackvitals/web/app/pages/about.css +1 -0
  415. stackvitals/web/app/pages/about.js +1 -0
  416. stackvitals/web/app/pages/activity.css +1 -0
  417. stackvitals/web/app/pages/activity.js +1 -0
  418. stackvitals/web/app/pages/agents.css +1 -0
  419. stackvitals/web/app/pages/agents.js +2 -0
  420. stackvitals/web/app/pages/ci.css +1 -0
  421. stackvitals/web/app/pages/ci.js +1 -0
  422. stackvitals/web/app/pages/community.css +1 -0
  423. stackvitals/web/app/pages/community.js +1 -0
  424. stackvitals/web/app/pages/connections.css +1 -0
  425. stackvitals/web/app/pages/connections.js +6 -0
  426. stackvitals/web/app/pages/delivery.css +1 -0
  427. stackvitals/web/app/pages/delivery.js +45 -0
  428. stackvitals/web/app/pages/deps.css +1 -0
  429. stackvitals/web/app/pages/deps.js +7 -0
  430. stackvitals/web/app/pages/explore.css +1 -0
  431. stackvitals/web/app/pages/explore.js +7 -0
  432. stackvitals/web/app/pages/health.css +1 -0
  433. stackvitals/web/app/pages/health.js +1 -0
  434. stackvitals/web/app/pages/help.css +1 -0
  435. stackvitals/web/app/pages/help.js +6 -0
  436. stackvitals/web/app/pages/insights.css +1 -0
  437. stackvitals/web/app/pages/insights.js +1 -0
  438. stackvitals/web/app/pages/investment.css +1 -0
  439. stackvitals/web/app/pages/investment.js +3 -0
  440. stackvitals/web/app/pages/overview.css +1 -0
  441. stackvitals/web/app/pages/overview.js +1 -0
  442. stackvitals/web/app/pages/portal.css +1 -0
  443. stackvitals/web/app/pages/portal.js +51 -0
  444. stackvitals/web/app/pages/prs.css +1 -0
  445. stackvitals/web/app/pages/prs.js +4 -0
  446. stackvitals/web/app/pages/settings.css +1 -0
  447. stackvitals/web/app/pages/settings.js +1 -0
  448. stackvitals/web/app/pages/setup.css +1 -0
  449. stackvitals/web/app/pages/setup.js +2 -0
  450. stackvitals/web/app/pages/standing.css +1 -0
  451. stackvitals/web/app/pages/standing.js +1 -0
  452. stackvitals/web/app/pages/welcome-sheet.js +1 -0
  453. stackvitals/web/app/pages/welcome.js +1 -0
  454. stackvitals/web/app/settings-workflow.json +1 -0
  455. stackvitals/web/index.html +34 -0
  456. stackvitals/web/site/index.html +108 -0
  457. stackvitals/web/site/integrations.html +15 -0
  458. stackvitals/web/site/setup.html +15 -0
  459. stackvitals/web/site/site.css +68 -0
  460. stackvitals-0.1.0.dist-info/METADATA +134 -0
  461. stackvitals-0.1.0.dist-info/RECORD +465 -0
  462. stackvitals-0.1.0.dist-info/WHEEL +4 -0
  463. stackvitals-0.1.0.dist-info/entry_points.txt +3 -0
  464. stackvitals-0.1.0.dist-info/licenses/LICENSE +202 -0
  465. stackvitals-0.1.0.dist-info/licenses/NOTICE +16 -0
@@ -0,0 +1,350 @@
1
+ """What to fix first, across modules: each finding is one plain sentence with its number, n, links to the evidence and
2
+ the action, ranked by the hours of waiting or outage at stake. Built from the public outputs only (never a reviewer's
3
+ name, D5), so the same list can sit on a public page."""
4
+ import html
5
+ from collections import defaultdict
6
+ from datetime import datetime, timedelta
7
+
8
+ from stackvitals.core.time import iso
9
+
10
+ MIN_PRS = 10 # the red-CI share needs this many merged PRs
11
+ FIX_MIN_HOURS = 1.0
12
+ FIX_MIN_PRS = 2
13
+ GAP_MIN_HOURS = 4.0
14
+ GAP_MIN_GROUP = 10
15
+ STALE_HOURS = 30 * 24 # a review request older than this is backlog to triage, not today's queue
16
+ # The crawl's upgrade ledger as evidence: enough PRs and repos, and how far above these repos' usual failure rate counts.
17
+ CRAWL_MIN_PRS, CRAWL_MIN_REPOS, CRAWL_EXCESS = 20, 10, 0.15
18
+
19
+
20
+ def _h(hours: float) -> str:
21
+ return f"{hours / 24:.1f} days" if hours >= 48 else f"{hours:.1f} h"
22
+
23
+
24
+ def _run_links(repo: str, runs) -> list[dict]:
25
+ return [{"label": f"run {r[0]} attempt {r[1]}", "url": f"https://github.com/{repo}/actions/runs/{int(r[0])}/attempts/{int(r[1])}"}
26
+ for r in (runs or [])[:5]]
27
+
28
+
29
+ def _pr_link(repo: str, n: int, kind: str = "pull") -> dict:
30
+ return {"label": f"#{int(n)}", "url": f"https://github.com/{repo}/{kind}/{int(n)}"}
31
+
32
+
33
+ def _deployment_link(repo: str, dep: str | None) -> dict | None:
34
+ if not dep:
35
+ return None
36
+ if dep.startswith("release:"):
37
+ tag = dep.split(":", 1)[1]
38
+ return {"label": f"release {tag}", "url": f"https://github.com/{repo}/releases/tag/{tag}"}
39
+ if dep.startswith("run:"):
40
+ return {"label": f"deploy run {dep[4:]}", "url": f"https://github.com/{repo}/actions/runs/{dep[4:]}"}
41
+ return {"label": "production deployment", "url": f"https://github.com/{repo}/deployments"}
42
+
43
+
44
+ def _red_ci(repo: str, r: dict | None) -> list[dict]:
45
+ if not r or r.get("prs", 0) < MIN_PRS or not r.get("red_prs"):
46
+ return []
47
+ share = r["measures"].get("insight.red_ci_share") or {}
48
+ rng = f" (likely {share['low']:.0%}-{share['high']:.0%})" if share.get("low") is not None else ""
49
+ return [{"kind": "red_ci", "hours": r["red_hours_total"], "n": r["prs"],
50
+ "title": f"{r['red_prs']} of {r['prs']} merged pull requests{rng} sat ready with failing checks: "
51
+ f"{_h(r['red_hours_total'])} in all, {_h(r.get('rerun_hours_total') or 0)} of it cleared only by re-runs.",
52
+ "action": "Start with the flaky jobs and tests below; the PRs that waited longest are linked.",
53
+ "evidence": [_pr_link(repo, t["pr"]) for t in r.get("top") or []][:5] or [{"label": "Insights page", "url": "insights.html"}]}]
54
+
55
+
56
+ def began_after(ci_doc: dict | None, prs: list[dict]) -> dict:
57
+ """For each flaky job or test whose start is clear (no flakes in the runs before, Fisher's test), the pull requests
58
+ whose merge commits reached the default branch the day before it first flaked: where to look first."""
59
+ by_commit = {p.get("mc"): p["n"] for p in prs if p.get("mc") and p.get("mg")}
60
+ out = {}
61
+ for kind, rows, name in (("job", (ci_doc or {}).get("jobs") or [], "job"), ("test", (ci_doc or {}).get("tests") or [], "id")):
62
+ # Findings name a job without its workflow file, so a job name two workflows share gets no note.
63
+ seen = [row.get(name) for row in rows]
64
+ for row in rows:
65
+ if seen.count(row.get(name)) > 1:
66
+ continue
67
+ b = row.get("began") or {}
68
+ merged = [by_commit[s] for s in b.get("landed") or [] if s in by_commit]
69
+ if b.get("clear") and merged and row.get(name):
70
+ out[f"{kind}:{row[name]}"] = {"day": b["first"][:10], "prs": merged[-3:]}
71
+ return out
72
+
73
+
74
+ def _began_note(repo: str, info: dict | None) -> tuple[str, list[dict]]:
75
+ if not info:
76
+ return "", []
77
+ prs = ", ".join(f"#{n}" for n in info["prs"])
78
+ return (f" It began flaking around {info['day']}, right after {prs} merged: look there first.",
79
+ [_pr_link(repo, n) for n in info["prs"]])
80
+
81
+
82
+ def _fixes(repo: str, r: dict | None, began: dict | None = None) -> list[dict]:
83
+ out = []
84
+ for j in (r or {}).get("rerun_jobs") or []:
85
+ if j["hours"] >= FIX_MIN_HOURS and j["prs"] >= FIX_MIN_PRS:
86
+ note, links = _began_note(repo, (began or {}).get(f"job:{j['job']}"))
87
+ out.append({"kind": "fix_job", "key": f"fix_job:{j['workflow']} / {j['job']}", "hours": j["hours"], "n": j["prs"],
88
+ "title": f"{j['workflow']} / {j['job']} failed and then passed on the same code, keeping {j['prs']} "
89
+ f"pull requests red for {_h(j['hours'])}.{note}",
90
+ "action": "Fix it, or quarantine its flaky tests in .github/stackvitals.toml so PRs stop waiting on it.",
91
+ "evidence": links + (_run_links(repo, j.get("runs")) or [{"label": "CI Health page", "url": "ci.html"}])})
92
+ for t in (r or {}).get("rerun_tests") or []:
93
+ if t["hours"] >= FIX_MIN_HOURS and t["prs"] >= FIX_MIN_PRS:
94
+ note, links = _began_note(repo, (began or {}).get(f"test:{t['test']}"))
95
+ out.append({"kind": "fix_test", "key": f"fix_test:{t['test']}", "hours": t["hours"], "n": t["prs"],
96
+ "title": f"Test {t['test']} failed in runs that a re-run then passed, keeping {t['prs']} pull requests "
97
+ f"red for {_h(t['hours'])}.{note}",
98
+ "action": "Fix the test or quarantine it (`stackvitals ci quarantine`); its failure group is on the CI page.",
99
+ "evidence": links + (_run_links(repo, t.get("runs")) or [{"label": "CI Health page", "url": "ci.html"}])})
100
+ return out
101
+
102
+
103
+ def _review_waits(repo: str, d: dict | None) -> list[dict]:
104
+ items = ((d or {}).get("stuck") or {}).get("hanging_review") or []
105
+ cap = ((d or {}).get("window_days") or 90) * 24 # a request older than the window counts the window's hours
106
+ now_waiting = sorted((x for x in items if (x.get("hours") or 0) <= STALE_HOURS), key=lambda x: -(x.get("hours") or 0))
107
+ stale = sorted((x for x in items if (x.get("hours") or 0) > STALE_HOURS), key=lambda x: x["since"])
108
+ out = []
109
+ if now_waiting:
110
+ out.append({"kind": "review_wait", "hours": sum(x.get("hours") or 0 for x in now_waiting), "n": len(now_waiting),
111
+ "title": f"{len(now_waiting)} pull requests have waited 3 to 30 days for a review someone was asked for "
112
+ f"(the longest {_h(now_waiting[0].get('hours') or 0)}).",
113
+ "action": "Pick them up or ask someone else; open review requests per reviewer are on the Team Flow page.",
114
+ "evidence": [_pr_link(repo, x["pr"]) for x in now_waiting[:10]]})
115
+ if stale:
116
+ out.append({"kind": "stale_review", "hours": sum(min(x.get("hours") or 0, cap) for x in stale), "n": len(stale),
117
+ "title": f"{len(stale)} review requests have gone unanswered for over 30 days, the oldest since "
118
+ f"{stale[0]['since'][:10]}.",
119
+ "action": "Review them, or close the pull requests nobody will take, so contributors are not left waiting.",
120
+ "evidence": [_pr_link(repo, x["pr"]) for x in stale[:10]]})
121
+ return out
122
+
123
+
124
+ def _incidents(repo: str, d: dict | None) -> list[dict]:
125
+ out = []
126
+ for i in (d or {}).get("incidents") or []:
127
+ if not i.get("deployment") or i.get("hours_to_restore") is None:
128
+ continue
129
+ after = (datetime.fromisoformat(i["created"].replace("Z", "+00:00"))
130
+ - datetime.fromisoformat(i["deployment_finished"].replace("Z", "+00:00"))).total_seconds() / 3600
131
+ what = {"revert": "A revert", "hotfix": "A hotfix", "issue": "An incident"}.get(i["source"], "An incident")
132
+ dep = _deployment_link(repo, i["deployment"])
133
+ out.append({"kind": "incident", "key": f"incident:{int(i['number'])}", "hours": i["hours_to_restore"], "n": 1,
134
+ "title": f"{what} (#{int(i['number'])}) began {_h(after)} after {dep['label']}; it was fixed "
135
+ f"{_h(i['hours_to_restore'])} after that deployment.",
136
+ "action": "Look at what that deployment shipped; it counts in the change failure rate.",
137
+ "evidence": [_pr_link(repo, i["number"], "issues" if i["source"] == "issue" else "pull"), dep]})
138
+ return out
139
+
140
+
141
+ def _outsider_gap(repo: str, f: dict | None) -> list[dict]:
142
+ groups = (f or {}).get("groups") or {}
143
+ ins, out = (groups.get(k, {}).get("pickup") or {} for k in ("insider", "outsider"))
144
+ if (ins.get("n") or 0) < GAP_MIN_GROUP or (out.get("n") or 0) < GAP_MIN_GROUP:
145
+ return []
146
+ a, b = (ins.get("median_s") or 0) / 3600, (out.get("median_s") or 0) / 3600
147
+ if b - a < GAP_MIN_HOURS or b < 2 * a:
148
+ return []
149
+ return [{"kind": "outsider_wait", "hours": round((b - a) * out["n"], 1), "n": out["n"],
150
+ "title": f"Outside contributors wait {_h(b)} for a first review (median of {out['n']} PRs); "
151
+ f"insiders wait {_h(a)} (n={ins['n']}).",
152
+ "action": "Say who answers new contributors; Team Flow's queue shows the outside PRs waiting now.",
153
+ "evidence": [{"label": "Team Flow page", "url": "flow.html"}]}]
154
+
155
+
156
+ def _crawl(b: dict, ledger: dict | None) -> tuple[str, bool] | None:
157
+ """How the same update went across the crawled repos: (sentence, likely the update's fault), when there is enough."""
158
+ cells = (ledger or {}).get(f"{b.get('ecosystem')}:{b.get('package')}") or []
159
+ c = next((c for c in cells if c.get("bump") == b.get("bump")), None)
160
+ if not c or (c.get("ci_known") or 0) < CRAWL_MIN_PRS or (c.get("repos") or 0) < CRAWL_MIN_REPOS:
161
+ return None
162
+ fail, usual = c["ci_failed"] / c["ci_known"], (c.get("expected_failed") or 0) / c["ci_known"]
163
+ return (f"across {c['repos']} crawled repos, {b['package']} {b['to']} failed CI in {fail:.0%} of {c['ci_known']} "
164
+ f"checked pull requests (their other updates fail {usual:.0%})"), fail - usual >= CRAWL_EXCESS
165
+
166
+
167
+ def _bumps(repo: str, bumps: list[dict] | None, ledger: dict | None = None) -> list[dict]:
168
+ red = [b for b in bumps or [] if b.get("hours")]
169
+ if not red:
170
+ return []
171
+ hours = sum(b["hours"] for b in red)
172
+ names = ", ".join(f"{b['package']} {b['to']}" + (" (still open)" if b.get("state") == "open" else "") for b in red[:5])
173
+ seen = [(b, found) for b in red[:5] if (found := _crawl(b, ledger))]
174
+ title = f"{len(red)} dependency updates sat with failing checks for {_h(hours)}: {names}."
175
+ if seen:
176
+ title += " " + "; ".join(text for _b, (text, _bad) in seen) + "."
177
+ guilty = [b["package"] for b, (_text, bad) in seen if bad]
178
+ if guilty:
179
+ action = (f"For {', '.join(guilty)} it is likely the update itself (it fails far more often than these repos' other "
180
+ "updates): pin it or wait for a fix; for the rest, look at this repo's own checks first.")
181
+ elif seen:
182
+ action = ("Across the crawl these updates fail no more often than other updates, so look at this repo's own checks "
183
+ "first (a flaky test, or a check the update exposed).")
184
+ else:
185
+ action = "Check whether the update or a flaky test failed; fix or pin the package."
186
+ return [{"kind": "bump_red", "hours": round(hours, 1), "n": len(red), "title": title, "action": action,
187
+ "evidence": [_pr_link(repo, b["pr"]) for b in red[:10]]}]
188
+
189
+
190
+ def build(repo: str, red_ci: dict | None, delivery: dict | None, flow: dict | None, bumps: list[dict] | None,
191
+ now: datetime, ledger: dict | None = None, began: dict | None = None) -> list[dict]:
192
+ """Every finding with enough evidence, most hours first."""
193
+ out = (_review_waits(repo, delivery) + _red_ci(repo, red_ci) + _fixes(repo, red_ci, began) + _incidents(repo, delivery)
194
+ + _outsider_gap(repo, flow) + _bumps(repo, bumps, ledger))
195
+ red = any(f["kind"] == "red_ci" for f in out)
196
+ for f in out:
197
+ f["hours"] = round(f["hours"], 1)
198
+ f["as_of"] = iso(now)
199
+ f.setdefault("key", f["kind"]) # one finding of its kind per repo; per-item kinds carry their subject
200
+ if red and f["kind"] in ("fix_job", "fix_test"):
201
+ # Waits that only a re-run cleared are inside the red-CI hours: counted there, not added again.
202
+ f["part_of"] = "red_ci"
203
+ return sorted(out, key=lambda f: -f["hours"])
204
+
205
+
206
+ def opportunity(found: list[dict]) -> dict:
207
+ """The estimated hours at stake across open findings, each hour counted once (findings inside another are left out
208
+ of the sum). An estimate of what fixing could win back, not hours proven lost."""
209
+ open_ = [f for f in found if not f.get("decision")]
210
+ fixes = [f for f in open_ if f["kind"] in ("fix_job", "fix_test")]
211
+ rest = sum(f["hours"] for f in open_ if f not in fixes)
212
+ if any(f["kind"] == "red_ci" for f in open_):
213
+ inside = len(fixes) # re-run waits sit inside the open red-CI hours
214
+ else:
215
+ # Jobs and tests split the same re-run waits, so only the larger of the two counts.
216
+ inside = 0
217
+ rest += max(sum(f["hours"] for f in fixes if f["kind"] == k) for k in ("fix_job", "fix_test"))
218
+ return {"hours": round(rest, 1), "findings": len(open_), "inside_another": inside}
219
+
220
+
221
+ LEDGER_DAYS = 90
222
+ SET_ASIDE = ("acknowledge", "snooze")
223
+
224
+
225
+ def update_ledger(ledger: dict, found: list[dict], now: datetime, set_aside: set | None = None) -> dict:
226
+ """Each finding's first day and the days it was active in the last 90 (today counted once, so a second run on the same
227
+ day changes nothing); a finding quiet for 90 days is forgotten."""
228
+ today = now.date()
229
+ floor = (today - timedelta(days=LEDGER_DAYS - 1)).isoformat()
230
+ active, out = {f["key"]: f for f in found}, {}
231
+ # found is ranked, most hours first; the top is the first nobody acknowledged or snoozed.
232
+ top = next((f["key"] for f in found if f["key"] not in (set_aside or set())), None)
233
+ for key in sorted(set(ledger) | set(active)):
234
+ entry = ledger.get(key) or {}
235
+ kept = {d for d in entry.get("days") or [] if d >= floor}
236
+ days = sorted(kept | ({today.isoformat()} if key in active else set()))
237
+ if days:
238
+ # Still seen within the window: the same finding since its first day; otherwise it starts again today.
239
+ again = bool(kept and entry.get("first"))
240
+ hours = active[key]["hours"] if key in active else entry.get("hours_last")
241
+ tops = {d for d in (entry.get("top_days") or []) if d >= floor and again} | ({today.isoformat()} if key == top else set())
242
+ out[key] = {"first": entry["first"] if again else days[0], "last": days[-1], "days": days,
243
+ "hours_first": entry.get("hours_first") if again else hours, "hours_last": hours,
244
+ **({"top_days": sorted(tops)} if tops else {})}
245
+ return out
246
+
247
+
248
+ CLEARED_DAYS = 7 # a top finding not seen for this long counts as cleared
249
+
250
+
251
+ def track_record(ledger: dict, now: datetime) -> dict:
252
+ """Whether the finding ranked first was acted on: of the findings that ever topped the list in the last 90 days, how
253
+ many later shrank to half their hours or less, and how many cleared (not seen for a week)."""
254
+ tops = {k: e for k, e in ledger.items() if e.get("top_days")}
255
+ cutoff = (now.date() - timedelta(days=CLEARED_DAYS)).isoformat()
256
+ shrank = sum(1 for e in tops.values() if e.get("hours_first") and (e.get("hours_last") or 0) <= e["hours_first"] / 2)
257
+ return {"top_findings": len(tops), "shrank_by_half": shrank,
258
+ "cleared": sum(1 for e in tops.values() if e["last"] < cutoff),
259
+ "since": min((e["top_days"][0] for e in tops.values()), default=None)}
260
+
261
+
262
+ def annotate(found: list[dict], ledger: dict, decisions: dict, now: datetime) -> list[dict]:
263
+ """Open since, the share of the last 90 days it was active (over half: structural, Swarmia's rule), and the decision
264
+ someone made on it (acknowledged or snoozed findings leave the top of the list until the decision ends)."""
265
+ out = []
266
+ for f in found:
267
+ g = {k: v for k, v in f.items() if k != "decision"}
268
+ entry = ledger.get(f["key"])
269
+ if entry:
270
+ share = round(len(entry["days"]) / LEDGER_DAYS, 2)
271
+ g.update(open_since=entry["first"], active_share_90d=share, structural=share > 0.5)
272
+ decided = decisions.get(f"finding:{f['key']}")
273
+ if decided and decided.get("verb") in SET_ASIDE:
274
+ g["decision"] = {k: decided.get(k) for k in ("verb", "by", "at", "until", "reason")}
275
+ out.append(g)
276
+ return out
277
+
278
+
279
+ def bump_reds(prs: list[dict], ci: dict, now: datetime, meta: dict | None = None, since: str | None = None) -> list[dict]:
280
+ """Dependency-update PRs (Dependabot, Renovate) with the hours their newest commit sat with failing checks."""
281
+ from mergescout.signals.bumps import parse_bump
282
+ from stackvitals.core.insights import red_intervals
283
+ from stackvitals.core.joins import runs_by_pr
284
+ by_pr = runs_by_pr(ci.get("runs") or [], prs)
285
+ jobs_by_run = defaultdict(list)
286
+ for j in ci.get("jobs") or []:
287
+ jobs_by_run[j["run"]].append(j)
288
+ out = []
289
+ for p in prs:
290
+ info = parse_bump(p.get("ti"), p.get("head"), p.get("au"))
291
+ runs = by_pr.get(p["n"]) or []
292
+ if not info or not runs or (since and p["cr"] < since):
293
+ continue
294
+ mine = [j for rid in {r["id"] for r in runs} for j in jobs_by_run.get(rid, [])]
295
+ # A bot's PR is never a draft and nobody marks it ready, so every red hour counts.
296
+ waits = red_intervals({**p, "draft": False, "ev": []}, runs, mine, meta or {}, iso(now))
297
+ hours = sum(w["hours"] for w in waits)
298
+ if hours:
299
+ out.append({"pr": p["n"], "package": info.get("package") or "?", "to": info.get("to_version") or "?",
300
+ "ecosystem": info.get("ecosystem"), "bump": info.get("bump"),
301
+ "hours": round(hours, 1), "state": "merged" if p.get("mg") else "closed" if p.get("cl") else "open"})
302
+ return sorted(out, key=lambda b: -b["hours"])
303
+
304
+
305
+ def _safe_md(text: str) -> str:
306
+ # No pings or links from PR titles or package names in the job summary.
307
+ return text.replace("@", "@​").replace("](", "]​(").replace("www.", "www​.")
308
+
309
+
310
+ def render_markdown(found: list[dict], repo: str, top: int = 3) -> str:
311
+ open_ = [f for f in found if not f.get("decision")]
312
+ if not found:
313
+ return ""
314
+ lines = ["### StackVitals: what to fix first"]
315
+ for k, f in enumerate(open_[:top], 1):
316
+ links = ", ".join(f"[{_safe_md(e['label'])}]({e['url']})" for e in f["evidence"][:3] if e["url"].startswith("https://"))
317
+ lines.append(f"{k}. {_safe_md(f['title'])} {_safe_md(f['action'] or '')}" + (f" ({links})" if links else ""))
318
+ if len(open_) > top:
319
+ lines.append(f"\n{len(open_) - top} more on the Insights page.")
320
+ if len(open_) < len(found):
321
+ lines.append(f"\n{len(found) - len(open_)} acknowledged or snoozed by the team (on the Insights page).")
322
+ return "\n".join(lines) + "\n"
323
+
324
+
325
+ def render_html(found: list[dict], repo: str, now: datetime, pages=(), red_ci_html: str = "") -> str:
326
+ from stackvitals import shell
327
+ e = html.escape
328
+ total = opportunity(found)
329
+ body = ["<h2>What to fix first</h2><p class=mut>Ranked by the estimated hours of waiting or outage at stake; each with its "
330
+ "sample size and the evidence. Built from the public pages only: no one is named.</p>",
331
+ f"<p>Estimated opportunity: {e(_h(total['hours']))} across {total['findings']} findings, each hour counted once"
332
+ + (f" ({total['inside_another']} flaky-CI findings are inside the red-CI hours, not added)" if total["inside_another"] else "")
333
+ + ". An estimate of what fixing could win back, not hours proven lost.</p>" if total["findings"] else ""]
334
+ open_, aside = [f for f in found if not f.get("decision")], [f for f in found if f.get("decision")]
335
+ if not open_:
336
+ body.append("<p>Nothing stands out yet.</p>")
337
+
338
+ def card(f):
339
+ links = " ".join(f"<a href='{e(x['url'])}'>{e(x['label'])}</a>" for x in f["evidence"][:10])
340
+ since = (f"; open since {e(f['open_since'])}, active {f['active_share_90d']:.0%} of the last 90 days"
341
+ + (" (structural)" if f.get("structural") else "")) if f.get("open_since") else ""
342
+ d = f.get("decision")
343
+ said = (f"<p class=mut>{e(d['verb'])} by {e(d.get('by') or '?')}" + (f" until {e(d['until'])}" if d.get("until") else "")
344
+ + (f": {e(d['reason'])}" if d.get("reason") else "") + "</p>") if d else ""
345
+ return (f"<div class=card><p><b>{e(f['title'])}</b></p><p>{e(f['action'] or '')}</p>"
346
+ f"<p class=mut>n={f['n']}; {f['hours']} h at stake{since}; evidence: {links}</p>{said}</div>")
347
+ body += [card(f) for f in open_]
348
+ if aside:
349
+ body.append("<h2>Acknowledged or snoozed</h2>" + "".join(card(f) for f in aside))
350
+ return shell.page("Insights", repo, "insights.html", f"Updated {e(iso(now))}.", "".join(body) + red_ci_html, pages)
@@ -0,0 +1,94 @@
1
+ """Data health: is the data current, did collection finish, how far back does each history go, and what was dropped.
2
+ Written once a run (docs/health.json) from each step's result, the connectors' status and the data branch itself."""
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from stackvitals.core.store import DAY_FILE, STREAMS, published, write_if_changed
7
+ from stackvitals.core.time import iso
8
+
9
+ COUNTS = 8 # the numbers kept from a step's result
10
+
11
+
12
+ def _read(path: Path) -> dict:
13
+ try:
14
+ return json.loads(path.read_text(encoding="utf-8"))
15
+ except (OSError, ValueError):
16
+ return {}
17
+
18
+
19
+ def _stats(result) -> dict | None:
20
+ """The part of a step's result that says what it collected: the result itself, or a "collect" inside it (CI Health)."""
21
+ if not isinstance(result, dict):
22
+ return None
23
+ inner = result.get("collect")
24
+ return inner if "complete" not in result and isinstance(inner, dict) else None
25
+
26
+
27
+ def _counts(result) -> dict:
28
+ """The plain numbers in a step's result (runs read, requests used, ...), never its nested detail."""
29
+ if not isinstance(result, dict):
30
+ return {}
31
+ nums = {k: v for k, v in {**result, **(_stats(result) or {})}.items() if isinstance(v, (int, float)) and not isinstance(v, bool)}
32
+ return dict(list(nums.items())[:COUNTS])
33
+
34
+
35
+ def _complete(result):
36
+ for part in (result, _stats(result)):
37
+ if isinstance(part, dict) and isinstance(part.get("complete"), bool):
38
+ return part["complete"]
39
+ return None
40
+
41
+
42
+ def _entry(old: dict, kind: str, at: str, error: str | None, complete, read: dict) -> dict:
43
+ """One source's line: a failure keeps the last good run and the first failing one, so "since when" stays true."""
44
+ return {"kind": kind, "last_try": at, "last_ok": old.get("last_ok") if error else at,
45
+ "failing_since": (old.get("failing_since") or at) if error else None, "error": error,
46
+ "complete": complete, "read": read}
47
+
48
+
49
+ def _history(root: Path) -> dict:
50
+ out = {}
51
+ for stream, keeps in STREAMS.items():
52
+ folder = root / stream
53
+ if not folder.is_dir():
54
+ continue
55
+ days = sorted((m.group(1), p.stat().st_size) for p in folder.iterdir() if (m := DAY_FILE.match(p.name)))
56
+ if days:
57
+ out[stream] = {"oldest": days[0][0], "newest": days[-1][0], "files": len(days),
58
+ "bytes": sum(size for _d, size in days), "keeps_days": keeps}
59
+ return out
60
+
61
+
62
+ def write_health(ws, results: dict, errors: dict, house: dict) -> dict:
63
+ docs = ws.path / "docs"
64
+ old = _read(docs / "health.json")
65
+ at = iso(ws.now)
66
+ sources = dict(old.get("sources") or {}) # a step that didn't run this time keeps its last line
67
+ for label in {*results, *errors}:
68
+ result = results.get(label)
69
+ complete = _complete(result)
70
+ sources[label] = _entry(sources.get(label) or {}, "module", at, errors.get(label), complete, _counts(result))
71
+ for entry in ((_read(docs / "connectors.json").get("connectors")) or {}).values():
72
+ if not isinstance(entry, dict) or not entry.get("as_of"):
73
+ continue # a tested service that isn't pulled (chat webhooks, the bucket)
74
+ name = entry.get("name") or "connector"
75
+ sources[name] = _entry(sources.get(name) or {}, "connector", entry["as_of"], entry.get("error"),
76
+ entry.get("complete"), entry.get("rows") or {})
77
+ removed = {k: v for k, v in ((house or {}).get("removed") or {}).items() if v}
78
+ capped = {k: v for k, v in ((house or {}).get("dropped") or {}).items() if v}
79
+ # The last time retention or the size cap removed anything is remembered, so a gap it left is explained.
80
+ dropped = {"at": at, "retention": removed, "size_cap": capped} if removed or capped else old.get("dropped")
81
+ out = {"as_of": at, "sources": dict(sorted(sources.items())), "history": _history(ws.path), "dropped": dropped}
82
+ # Times and this run's counts alone never rewrite the file (a run would commit just to move them), as with the
83
+ # modules' own files; a new failure, a recovery, finishing the history or a new day of data do.
84
+ if docs.is_dir() and _steady(out) != _steady(old):
85
+ write_if_changed(docs / "health.json", published(out))
86
+ return out
87
+
88
+
89
+ def _steady(h: dict) -> dict:
90
+ return {"sources": {k: {f: v for f, v in e.items() if f not in ("last_try", "last_ok", "read")}
91
+ for k, e in (h.get("sources") or {}).items()},
92
+ # Sizes on disk can move with how a checkout writes line ends; the days held are what count.
93
+ "history": {k: {f: v for f, v in e.items() if f != "bytes"} for k, e in (h.get("history") or {}).items()},
94
+ "dropped": {k: v for k, v in (h.get("dropped") or {}).items() if k != "at"}}
@@ -0,0 +1,82 @@
1
+ """One person across tools: [identity] aliases map a GitHub login's old names, and also a tool's id ("jira:<accountId>",
2
+ "linear:<id>", "slack:<user id>", "discord:<user id>") or an email, to the login used everywhere else."""
3
+ import re
4
+
5
+
6
+ def _aliases(ident: dict | None) -> dict[str, str]:
7
+ return {k.lower(): v for k, v in ((ident or {}).get("aliases") or {}).items()}
8
+
9
+
10
+ def login(ident: dict | None, ref: str | None, email: str | None = None) -> str | None:
11
+ """The GitHub login a tool's id or an email belongs to; None when no alias names it."""
12
+ aliases = _aliases(ident)
13
+ for key in (ref, email):
14
+ if key and key.lower() in aliases:
15
+ return aliases[key.lower()]
16
+ return None
17
+
18
+
19
+ def team_of(ident: dict | None, who: str | None) -> list[str]:
20
+ """The teams in the identity settings that list this login."""
21
+ low = (who or "").lower()
22
+ return [team for team, logins in ((ident or {}).get("teams") or {}).items() if low and low in {x.lower() for x in logins}]
23
+
24
+
25
+ def tool_ids(ident: dict | None, tool: str) -> set[str]:
26
+ """A chat tool's user ids that map to a team member or a listed person, so the team is never listed twice."""
27
+ members = {x.lower() for logins in ((ident or {}).get("teams") or {}).values() for x in logins}
28
+ members |= {x.lower() for x in (ident or {}).get("people") or []}
29
+ prefix = tool + ":"
30
+ return {k[len(prefix):] for k, v in ((ident or {}).get("aliases") or {}).items()
31
+ if k.lower().startswith(prefix) and v.lower() in members}
32
+
33
+
34
+ def _plain(text: str) -> str:
35
+ return re.sub(r"[^a-z0-9]", "", text.lower())
36
+
37
+
38
+ def match(ident: dict | None, ref: str | None, email: str | None, name: str | None, logins: set[str]) -> tuple:
39
+ """(login, confidence) for a tool's user: "alias" from the settings, "high" when the email's name part is a login
40
+ (GitHub's noreply address too), "medium" when the email or display name only reads like one login; else (None, None)."""
41
+ if who := login(ident, ref, email):
42
+ return who, "alias"
43
+ by_low = {x.lower(): x for x in logins}
44
+ local = (email or "").split("@")[0].lower()
45
+ if "@users.noreply.github.com" in (email or "").lower():
46
+ local = local.split("+")[-1]
47
+ if local in by_low:
48
+ return by_low[local], "high"
49
+ by_plain = {}
50
+ for x in logins:
51
+ by_plain.setdefault(_plain(x), []).append(x)
52
+ for key in (_plain(local), _plain(name or "")):
53
+ if key and len(by_plain.get(key, [])) == 1:
54
+ return by_plain[key][0], "medium"
55
+ return None, None
56
+
57
+
58
+ def issues_by_team(issues: list[dict], ident: dict | None, since: str, logins: set[str] | None = None) -> dict:
59
+ """Tracker issues done since `since`, counted for each team of the person they were assigned to; plus how many done
60
+ issues had an assignee that no alias names. With the repo's logins, an email that is a login counts too, and a
61
+ name that only looks like one is suggested as an alias line to paste (never counted)."""
62
+ teams, matched, unmatched, by_email, guesses = {}, 0, 0, 0, {}
63
+ for i in issues:
64
+ if i.get("cat") != "DONE" or (i.get("resolved") or "") < since or not i.get("assignee"):
65
+ continue
66
+ who, sure = match(ident, i["assignee"], i.get("assignee_email"), i.get("assignee_name"), logins or set())
67
+ if sure == "medium":
68
+ guesses.setdefault((i["assignee"], who), 0)
69
+ guesses[(i["assignee"], who)] += 1
70
+ if sure not in ("alias", "high"):
71
+ unmatched += 1
72
+ continue
73
+ matched += 1
74
+ by_email += sure == "high"
75
+ for team in team_of(ident, who):
76
+ teams.setdefault(team, {"done": 0})["done"] += 1
77
+ out = {"teams": teams, "matched": matched, "unmatched": unmatched}
78
+ if logins is not None:
79
+ out["by_email"] = by_email
80
+ out["suggested"] = [{"ref": ref, "login": who, "issues": n, "line": f'"{ref}" = "{who}"'}
81
+ for (ref, who), n in sorted(guesses.items(), key=lambda kv: -kv[1])][:20]
82
+ return out