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,172 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from datetime import datetime, timezone
5
+
6
+ from stackvitals.ci.api import ActionsAPI
7
+ from stackvitals.ci.cli import ACTION_REF, home, token
8
+ from stackvitals.flow.cli import local_gql as _gql
9
+ from stackvitals.portal.catalog import build_catalog
10
+ from stackvitals.portal.github import NA, read, read_file
11
+ from stackvitals.portal.inventory import list_repos
12
+
13
+ WORKFLOW = """name: StackVitals Portal
14
+ on:
15
+ schedule:
16
+ - cron: "41 5 * * *" # once a day
17
+ workflow_dispatch:
18
+ inputs:
19
+ request:
20
+ description: "A request from the StackVitals app, like a golden path (leave empty for a normal run)"
21
+ required: false
22
+ default: ""
23
+ issues:
24
+ types: [opened] # golden path requests from the app, run at once
25
+ issue_comment:
26
+ types: [created] # "/stackvitals approve" on a golden path that needs an approval
27
+ permissions:
28
+ contents: write # keeps the catalog, its history and the pages in this repo's stackvitals-data branch
29
+ issues: write # one tracking issue per campaign, and answers to requests, in this repo only
30
+ concurrency:
31
+ group: stackvitals-portal
32
+ cancel-in-progress: false
33
+ jobs:
34
+ portal:
35
+ # Request issues start a run only when someone with access to this repo opened one with a StackVitals block.
36
+ if: >-
37
+ (github.event_name != 'issues' ||
38
+ (contains(github.event.issue.body, '```stackvitals') &&
39
+ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association))) &&
40
+ (github.event_name != 'issue_comment' ||
41
+ (startsWith(github.event.comment.body, '/stackvitals approve') &&
42
+ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)))
43
+ runs-on: ubuntu-latest
44
+ timeout-minutes: 30
45
+ steps:
46
+ # Private repos: create an org-owned read-only GitHub App (see docs/modules.md), store its client ID and key, then switch on.
47
+ # - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3
48
+ # id: app
49
+ # with:
50
+ # client-id: ${{{{ vars.PORTAL_CLIENT_ID }}}}
51
+ # private-key: ${{{{ secrets.PORTAL_PRIVATE_KEY }}}}
52
+ # owner: ${{{{ github.repository_owner }}}}
53
+ - uses: {ref}
54
+ with:
55
+ modules: portal
56
+ # portal-token: ${{{{ steps.app.outputs.token }}}}
57
+ """
58
+
59
+
60
+ def _scorecard_client():
61
+ import httpx
62
+ return httpx.Client(timeout=20)
63
+
64
+
65
+ def app_link(org: str, writes: bool = False) -> str:
66
+ """GitHub's "register an App" form for the org, filled in: read-only permissions Portal needs, no webhook, private;
67
+ with `writes`, also what required checks and golden paths need (checks, contents, pull requests, administration)."""
68
+ from urllib.parse import urlencode
69
+ params = {"name": f"{org} portal {'' if writes else 'reader'}".strip()[:34],
70
+ "description": "StackVitals Portal: the org catalog, owners and scorecards"
71
+ + ("; required checks and golden paths." if writes else " (read-only)."),
72
+ "url": f"https://github.com/{org}", "public": "false", "webhook_active": "false",
73
+ "actions": "read", "contents": "write" if writes else "read", "metadata": "read", "members": "read",
74
+ "organization_custom_properties": "read"}
75
+ if writes:
76
+ params |= {"checks": "write", "pull_requests": "write", "administration": "write", "workflows": "write"}
77
+ return f"https://github.com/organizations/{org}/settings/apps/new?{urlencode(params)}"
78
+
79
+
80
+ def check(api, org: str) -> list[str]:
81
+ """What this token can and can't read, one line per kind of data, so setup problems show before the first run."""
82
+ repos = list_repos(api, org, "local")
83
+ private = [r for r in repos if r["private"]]
84
+ sample = (private or repos or [None])[0]
85
+ out = [f"{len(repos)} repos visible ({len(private)} private)"]
86
+ if sample:
87
+ found = any(read_file(api, sample["name"], p, sample["default_branch"]) is not NA
88
+ for p in (".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"))
89
+ out.append(f"CODEOWNERS: {'readable' if found else 'none found in ' + sample['name']}")
90
+ out.append("branch rules: " + ("readable" if read(api, f"/repos/{sample['name']}/rules/branches/{sample['default_branch']}")
91
+ is not NA else "not available"))
92
+ out.append("teams: " + ("readable" if read(api, f"/orgs/{org}/teams", {"per_page": 1}) is not NA else "not available"))
93
+ out.append("custom properties: " + ("readable" if read(api, f"/orgs/{org}/properties/values", {"per_page": 1}) is not NA
94
+ else "not available"))
95
+ return out
96
+
97
+
98
+ def main(argv: list[str] | None = None, now: datetime | None = None, make_api=ActionsAPI, tok: str | None = None,
99
+ make_gql=None) -> int:
100
+ parser = argparse.ArgumentParser(prog="stackvitals portal", description="Portal: what an org owns, who owns it, and its shape.")
101
+ sub = parser.add_subparsers(dest="cmd", required=True)
102
+ rep = sub.add_parser("report", help="Read every repo of an org you can see and show owners with evidence")
103
+ rep.add_argument("org", help="org or user name")
104
+ rep.add_argument("--json", action="store_true")
105
+ rep.add_argument("--budget", type=int, default=3000, help="most API requests to spend; the next run continues")
106
+ rep.add_argument("--no-scorecard", dest="scorecard", action="store_false", help="skip OpenSSF Scorecard results")
107
+ chk = sub.add_parser("check", help="Say what your token can read in an org before the first run")
108
+ chk.add_argument("org", help="org or user name")
109
+ ini = sub.add_parser("init", help="Print the workflow for the org's private portal repo")
110
+ ini.add_argument("--ref", default=ACTION_REF)
111
+ lnk = sub.add_parser("app-link", help="Print a link that opens GitHub's App form with Portal's read-only permissions filled in")
112
+ lnk.add_argument("org", help="org name")
113
+ lnk.add_argument("--writes", action="store_true", help="also the write permissions required checks and golden paths need")
114
+ args = parser.parse_args(argv)
115
+ if args.cmd == "init":
116
+ print(WORKFLOW.format(ref=args.ref), end="")
117
+ return 0
118
+ if args.cmd == "app-link":
119
+ print(app_link(args.org, args.writes))
120
+ print("Then: create the App, install it on the org's repos, save its client ID as the variable PORTAL_CLIENT_ID and a "
121
+ "private key as the secret PORTAL_PRIVATE_KEY, and switch on the token step in the portal workflow.")
122
+ return 0
123
+ tok = tok or token()
124
+ if not tok:
125
+ print("A GitHub token is needed. Set GITHUB_TOKEN, or run `gh auth login`.", file=sys.stderr)
126
+ return 1
127
+ if args.cmd == "check":
128
+ for line in check(make_api(tok, f"{args.org}/portal", max_requests=50), args.org):
129
+ print(line)
130
+ return 0
131
+ now = now or datetime.now(timezone.utc)
132
+ path = home() / "portal" / f"{args.org}.json"
133
+ try:
134
+ previous = json.loads(path.read_text(encoding="utf-8"))
135
+ except (OSError, ValueError):
136
+ previous = None
137
+ from stackvitals.ci import bench as ci_bench
138
+ from stackvitals.flow import peers as flow_peers
139
+ from stackvitals.portal.scorecard import rules_or_default, score_catalog
140
+ make_gql = make_gql or _gql
141
+ catalog = build_catalog(make_api(tok, f"{args.org}/portal", max_requests=args.budget), args.org, "local", now, previous,
142
+ scorecard_client=_scorecard_client() if args.scorecard else None, gql=make_gql(tok))
143
+ rules, exemptions, _ = rules_or_default(None)
144
+ score_catalog(catalog, rules, exemptions, now, {"ci": ci_bench.load(tok, home() / "bench"),
145
+ "flow": flow_peers.load(tok, home() / "bench")})
146
+ path.parent.mkdir(parents=True, exist_ok=True)
147
+ path.write_text(json.dumps(catalog, indent=1), encoding="utf-8")
148
+ if args.json:
149
+ print(json.dumps(catalog, indent=1))
150
+ return 0
151
+ active = [r for r in catalog["repos"] if not r.get("archived") and not r.get("fork")]
152
+ judged = [r for r in active if r["name"].split("/")[-1].lower() != ".github"]
153
+ counts = {s: sum(1 for r in judged if (r.get("owners") or {}).get("status") == s) for s in ("valid", "weak", "none")}
154
+ print(f"{args.org}: {len(catalog['repos'])} repos, {len(active)} active (archived repos and forks are listed, not checked). "
155
+ f"Owners: {counts['valid']} valid, {counts['weak']} weak, {counts['none']} none.")
156
+ from stackvitals.portal.pages import catalog_entities
157
+ entities, problems, files = catalog_entities(judged)
158
+ if files:
159
+ print(f"Catalog: {len(entities)} entities from {files} repos' catalog-info.yaml, {len(problems)} problems in them.")
160
+ for r in active:
161
+ o = r.get("owners") or {}
162
+ if r["name"].split("/")[-1].lower() == ".github":
163
+ print(f" {r['name']:40} {'-':12} {'-':8} the org's shared settings repo: not judged")
164
+ continue
165
+ why = "; ".join(o.get("reasons") or []) + (
166
+ f" (suggested from {o.get('suggested_from') or 'recent commits'}: {', '.join(o['suggested'])})" if o.get("suggested") else "")
167
+ # A scored repo below bronze says so; "-" is left for repos not scored at all.
168
+ level = ((r.get("score") or {}).get("level") or "below bronze") if r.get("score") else "-"
169
+ print(f" {r['name']:40} {level:12} {o.get('status') or 'not checked yet':8} {why}")
170
+ if not catalog["complete"]:
171
+ print("\n(Not every repo was checked yet; run the same command again to continue.)")
172
+ return 0
@@ -0,0 +1,105 @@
1
+ """A repo's catalog-info.yaml, Backstage's descriptor format (Port and Cortex read it too): what the repo says it is, who
2
+ owns it, which system it belongs to and what it depends on. Read with safe YAML only; problems are listed, never fatal."""
3
+ import re
4
+
5
+ import yaml
6
+
7
+ FILE = "catalog-info.yaml"
8
+ MAX_BYTES = 256_000
9
+ MAX_DOCS = 50
10
+ KINDS = {k.lower(): k for k in ("Component", "API", "Resource", "System", "Domain", "Group", "User", "Location", "Template")}
11
+ # The spec fields Backstage itself requires for these kinds.
12
+ NEEDS = {"Component": ("type", "lifecycle", "owner"), "API": ("type", "lifecycle", "owner"), "Resource": ("type", "owner"),
13
+ "System": ("owner",), "Domain": ("owner",)}
14
+ REF = re.compile(r"^(?:([A-Za-z][\w-]*):)?(?:([\w.-]+)/)?([\w.-]+)$")
15
+
16
+
17
+ def _text(value, cap: int = 200) -> str | None:
18
+ # Scalars only (str() of a container could expand an alias bomb), and no huge numbers.
19
+ if isinstance(value, bool) or not isinstance(value, (str, int, float)) or isinstance(value, int) and value.bit_length() > 256:
20
+ return None
21
+ return " ".join(str(value).split())[:cap] or None
22
+
23
+
24
+ def _names(value) -> list[str]:
25
+ return [t for x in value[:50] if (t := _text(x))] if isinstance(value, list) else []
26
+
27
+
28
+ def ref(value: str | None, default_kind: str) -> tuple[str, str, str] | None:
29
+ """Backstage's entity reference `[kind:][namespace/]name`, lower-cased, or None."""
30
+ found = REF.match(str(value or "").strip())
31
+ if not found:
32
+ return None
33
+ kind, namespace, name = found.groups()
34
+ return (kind or default_kind).lower(), (namespace or "default").lower(), name.lower()
35
+
36
+
37
+ def _entity(doc: dict) -> dict:
38
+ meta = doc.get("metadata") if isinstance(doc.get("metadata"), dict) else {}
39
+ spec = doc.get("spec") if isinstance(doc.get("spec"), dict) else {}
40
+ links = [{"url": str(x["url"])[:300], "title": _text(x.get("title"), 80) or str(x["url"])[:80]}
41
+ for x in (meta.get("links") if isinstance(meta.get("links"), list) else [])[:5]
42
+ if isinstance(x, dict) and re.match(r"https?://", str(x.get("url") or ""))]
43
+ return {"kind": KINDS[str(doc["kind"]).lower()], "name": _text(meta.get("name"), 100), "title": _text(meta.get("title"), 100),
44
+ "description": _text(meta.get("description")), "type": _text(spec.get("type"), 60),
45
+ "lifecycle": _text(spec.get("lifecycle"), 60), "owner": _text(spec.get("owner"), 120),
46
+ "system": _text(spec.get("system"), 120), "domain": _text(spec.get("domain"), 120),
47
+ "part_of": _text(spec.get("subcomponentOf"), 120), "depends_on": _names(spec.get("dependsOn")),
48
+ "provides_apis": _names(spec.get("providesApis")), "consumes_apis": _names(spec.get("consumesApis")),
49
+ "tags": _names(meta.get("tags"))[:10], "links": links}
50
+
51
+
52
+ def parse(text: str) -> tuple[list[dict], list[str]]:
53
+ """The entities a catalog-info.yaml declares and the problems in it, in plain words."""
54
+ if len(text.encode("utf-8", "replace")) > MAX_BYTES:
55
+ return [], [f"{FILE} is over {MAX_BYTES // 1000} KB; not read"]
56
+ try:
57
+ docs = [d for d in yaml.safe_load_all(text)][:MAX_DOCS + 1]
58
+ except yaml.YAMLError as exc:
59
+ mark = getattr(exc, "problem_mark", None)
60
+ where = f" (line {mark.line + 1})" if mark is not None else ""
61
+ return [], [f"not valid YAML{where}: {getattr(exc, 'problem', None) or type(exc).__name__}"]
62
+ except Exception as exc: # deep nesting, impossible dates, numbers too long to convert: the file is the problem
63
+ return [], [f"not valid YAML: {type(exc).__name__}"]
64
+ docs = [d for d in docs if d is not None]
65
+ if not docs:
66
+ return [], [f"{FILE} is empty"]
67
+ entities, problems = [], []
68
+ if len(docs) > MAX_DOCS:
69
+ problems.append(f"only the first {MAX_DOCS} documents were read")
70
+ for i, doc in enumerate(docs[:MAX_DOCS], 1):
71
+ if not isinstance(doc, dict):
72
+ problems.append(f"document {i} is not a mapping")
73
+ continue
74
+ kind = doc.get("kind")
75
+ if not isinstance(kind, str) or kind.lower() not in KINDS:
76
+ problems.append(f"document {i}: {_text(kind, 40) or 'no kind'} is not a Backstage kind")
77
+ continue
78
+ try:
79
+ e = _entity(doc)
80
+ except Exception as exc: # one odd document never hides the others
81
+ problems.append(f"document {i}: could not be read ({type(exc).__name__})")
82
+ continue
83
+ if not e["name"]:
84
+ problems.append(f"document {i}: no metadata.name")
85
+ continue
86
+ missing = [f for f in NEEDS.get(e["kind"], ()) if not e[f]]
87
+ if missing:
88
+ problems.append(f"{e['kind']} {e['name']}: no {', '.join(missing)}")
89
+ entities.append(e)
90
+ return entities, problems
91
+
92
+
93
+ def owner_check(owner: str | None, codeowners: list[str]) -> tuple[str, str]:
94
+ """pass when the catalog's owner is named in CODEOWNERS (a team as @org/team, a person as @login), fail when not."""
95
+ if not owner:
96
+ return "fail", f"{FILE} names no owner"
97
+ found = ref(owner, "group")
98
+ named = {}
99
+ for o in codeowners:
100
+ if o.startswith("@"):
101
+ named.setdefault(o[1:].split("/")[-1].lower(), o)
102
+ if found and found[2] in named:
103
+ return "pass", f"{found[2]} is {named[found[2]]} in CODEOWNERS"
104
+ listed = [o for o in codeowners if o.startswith("@")]
105
+ return "fail", f"{FILE} names {owner}; CODEOWNERS names {', '.join(listed) or 'nobody'}"
@@ -0,0 +1,43 @@
1
+ """Backstage entities for the repos that don't describe themselves: one file of Components (portal/entities.yaml on the
2
+ data branch) Backstage can register as a single location, each with its owner from CODEOWNERS, the StackVitals level as
3
+ an annotation and a link back to the portal. Repos with their own catalog-info.yaml are left to it."""
4
+ import re
5
+
6
+ import yaml
7
+
8
+ LIFECYCLE = "production"
9
+
10
+
11
+ def _name(repo: str) -> str:
12
+ # Backstage names: letters, digits and [-_.], at most 63 characters, starting and ending alphanumeric.
13
+ name = re.sub(r"[^A-Za-z0-9._-]", "-", repo.split("/")[-1])[:63].strip("-_.")
14
+ return name or "repo"
15
+
16
+
17
+ def _owner(owners: dict | None) -> str:
18
+ first = next(iter((owners or {}).get("owners") or []), "")
19
+ if first.startswith("@") and "/" in first:
20
+ return "group:" + first.split("/", 1)[1]
21
+ if first.startswith("@"):
22
+ return "user:" + first[1:]
23
+ return "unknown"
24
+
25
+
26
+ def entity(repo: dict, portal_url: str | None) -> dict:
27
+ level = (repo.get("score") or {}).get("level")
28
+ tags = sorted({t.lower() for t in (repo.get("topics") or [])} | ({repo["language"].lower()} if repo.get("language") else set()))
29
+ tags = [re.sub(r"[^a-z0-9+#-]", "-", t)[:63] for t in tags]
30
+ meta = {"name": _name(repo["name"]), "description": repo.get("description") or "",
31
+ "annotations": {"github.com/project-slug": repo["name"], "stackvitals.dev/level": level or "below bronze",
32
+ "stackvitals.dev/owners": (repo.get("owners") or {}).get("status") or "unknown"},
33
+ "tags": tags}
34
+ if portal_url:
35
+ meta["links"] = [{"url": f"{portal_url.rstrip('/')}/#/portal?tab=repos", "title": "StackVitals portal"}]
36
+ return {"apiVersion": "backstage.io/v1alpha1", "kind": "Component", "metadata": meta,
37
+ "spec": {"type": "service", "lifecycle": LIFECYCLE, "owner": _owner(repo.get("owners"))}}
38
+
39
+
40
+ def entities_yaml(repos: list[dict], portal_url: str | None) -> str:
41
+ live = [r for r in repos if not r.get("archived") and not r.get("fork") and not (r.get("descriptor") or {}).get("present")
42
+ and r["name"].split("/")[-1].lower() != ".github"]
43
+ return yaml.safe_dump_all([entity(r, portal_url) for r in live], sort_keys=False, allow_unicode=True)
@@ -0,0 +1,48 @@
1
+ """Every Portal read goes through here: a refusal or a missing item is "not available", never a failure, because GitHub
2
+ keeps changing what a token may read (stargazer lists in July 2026, the SBOM export endpoint ending 2026-11-13)."""
3
+ import httpx
4
+
5
+ from stackvitals.ci.api import API, APIError, OutOfBudget
6
+
7
+ REFUSED = ("HTTP 401", "HTTP 403", "HTTP 404", "HTTP 409", "HTTP 410", "HTTP 451")
8
+
9
+
10
+ class _NotAvailable:
11
+ def __repr__(self) -> str:
12
+ return "not available"
13
+
14
+ def __bool__(self) -> bool:
15
+ return False
16
+
17
+
18
+ NA = _NotAvailable()
19
+
20
+
21
+ def read(api, path: str, params: dict | None = None):
22
+ try:
23
+ return api._get(path, params)
24
+ except APIError as exc:
25
+ if any(code in str(exc) for code in REFUSED):
26
+ return NA
27
+ raise
28
+
29
+
30
+ def read_file(api, repo: str, path: str, ref: str | None = None):
31
+ try:
32
+ return api.file(path, ref, repo=repo).decode("utf-8", "replace")
33
+ except APIError as exc:
34
+ if any(code in str(exc) for code in REFUSED):
35
+ return NA
36
+ raise
37
+
38
+
39
+ def status(api, path: str) -> int | None:
40
+ """Only the HTTP status (membership checks answer 204 or 404 with no body)."""
41
+ with api._lock:
42
+ if api.max_requests is not None and api.requests >= api.max_requests:
43
+ raise OutOfBudget(f"stopped after {api.requests} requests (budget)")
44
+ api.requests += 1
45
+ try:
46
+ return api.client.get(f"{API}{path}", headers=api.headers).status_code
47
+ except httpx.TransportError:
48
+ return None
@@ -0,0 +1,95 @@
1
+ """Golden paths (opt-in): the portal repo's portal.toml names templates people can run from the app or a request, and
2
+ the Portal's org App carries them out: a new repo from a template repo, or a pull request that adds a folder of the
3
+ portal repo's files (a standard CI workflow, a CODEOWNERS, a catalog-info.yaml) to an existing repo. Only people with
4
+ write access to the portal repo can run one, each run is recorded as a decision, and nothing runs without the App."""
5
+ import base64
6
+ import re
7
+ import tomllib
8
+
9
+ from stackvitals.ci.api import APIError
10
+
11
+ NAME = re.compile(r"[A-Za-z0-9._-]{1,100}")
12
+ KINDS = ("repo", "files")
13
+
14
+
15
+ class GoldenError(Exception):
16
+ """A golden path that can't run as asked; the message says why."""
17
+
18
+
19
+ def load_paths(text: str | None) -> dict[str, dict]:
20
+ """Each [[golden_path]]: id, title, kind ("repo" with a template, or "files" with a folder of the portal repo)."""
21
+ doc = tomllib.loads(text or "")
22
+ out = {}
23
+ for g in doc.get("golden_path") or []:
24
+ if not isinstance(g, dict) or not NAME.fullmatch(str(g.get("id") or "")) or g.get("kind") not in KINDS:
25
+ raise GoldenError("Each [[golden_path]] needs an id and a kind (repo or files).")
26
+ if g["kind"] == "repo" and not re.fullmatch(r"[\w.-]+/[\w.-]+", str(g.get("template") or "")):
27
+ raise GoldenError(f"Golden path {g['id']} needs template = \"owner/template-repo\".")
28
+ if g["kind"] == "files" and not str(g.get("folder") or "").strip("/"):
29
+ raise GoldenError(f"Golden path {g['id']} needs folder = \"golden/{g['id']}\" in the portal repo.")
30
+ if g.get("approvals", 0) not in (0, 1):
31
+ raise GoldenError(f"Golden path {g['id']}: approvals is 0 or 1 (someone other than the requester approves).")
32
+ out[g["id"]] = {"id": g["id"], "title": str(g.get("title") or g["id"]), "kind": g["kind"],
33
+ "template": g.get("template"), "folder": str(g.get("folder") or "").strip("/"),
34
+ "private": bool(g.get("private", True)), "description": str(g.get("description") or ""),
35
+ "approvals": int(g.get("approvals", 0))}
36
+ return out
37
+
38
+
39
+ def _new_repo(app, org: str, path: dict, inputs: dict) -> tuple[str, str]:
40
+ name = str(inputs.get("name") or "")
41
+ if not NAME.fullmatch(name) or name.startswith("."):
42
+ raise GoldenError("The new repo's name may hold letters, digits, dots, dashes and underscores only.")
43
+ made = app.send("POST", f"/repos/{path['template']}/generate",
44
+ {"owner": org, "name": name, "description": str(inputs.get("description") or "")[:300],
45
+ "private": path["private"]})
46
+ url = made.get("html_url") or f"https://github.com/{org}/{name}"
47
+ return f"Created {org}/{name} from {path['template']} ({'private' if path['private'] else 'public'}).", url
48
+
49
+
50
+ def _files_pr(app, portal_api, portal_repo: str, org: str, path: dict, inputs: dict, requester: str) -> tuple[str, str]:
51
+ target = str(inputs.get("repo") or "")
52
+ owner, _, name = target.partition("/")
53
+ if owner.lower() != org.lower() or not NAME.fullmatch(name):
54
+ raise GoldenError(f"Golden paths only change repos in {org}; say which one as repo = \"{org}/<name>\".")
55
+ # Write access to the portal repo isn't write access to every repo: the requester must be able to push to the target.
56
+ try:
57
+ level = app._get(f"/repos/{target}/collaborators/{requester}/permission").get("permission")
58
+ except APIError:
59
+ level = None
60
+ if level not in ("admin", "maintain", "write"):
61
+ raise GoldenError(f"{requester} can't write to {target}, so this golden path won't open a pull request there.")
62
+ prefix = path["folder"] + "/"
63
+ tree = portal_api._get(f"/repos/{portal_repo}/git/trees/HEAD", {"recursive": "1"}).get("tree") or []
64
+ files = [t["path"] for t in tree if t.get("type") == "blob" and t["path"].startswith(prefix)]
65
+ if not files:
66
+ raise GoldenError(f"The portal repo has no files under {prefix}.")
67
+ base = app._get(f"/repos/{target}").get("default_branch") or "main"
68
+ sha = app._get(f"/repos/{target}/git/ref/heads/{base}")["object"]["sha"]
69
+ branch = f"stackvitals/golden-{path['id']}"
70
+ try:
71
+ app.send("POST", f"/repos/{target}/git/refs", {"ref": f"refs/heads/{branch}", "sha": sha})
72
+ except APIError as exc:
73
+ if "HTTP 422" not in str(exc): # the branch is there from an earlier try: carry on with it
74
+ raise
75
+ for f in files:
76
+ content, dest = portal_api.file(f, None, repo=portal_repo), f[len(prefix):]
77
+ # A file that already exists (a CODEOWNERS being replaced) is updated, which needs its current sha.
78
+ try:
79
+ current = app._get(f"/repos/{target}/contents/{dest}", {"ref": branch}).get("sha")
80
+ except APIError:
81
+ current = None
82
+ app.send("PUT", f"/repos/{target}/contents/{dest}",
83
+ {"message": f"Add {path['title']} (StackVitals golden path)", "branch": branch,
84
+ "content": base64.b64encode(content).decode(), **({"sha": current} if current else {})})
85
+ pr = app.send("POST", f"/repos/{target}/pulls",
86
+ {"title": f"Add {path['title']}", "head": branch, "base": base,
87
+ "body": f"Golden path `{path['id']}` from {portal_repo}: {len(files)} file(s). Review and merge as usual."})
88
+ return f"Opened a pull request in {target} adding {len(files)} file(s).", pr.get("html_url") or ""
89
+
90
+
91
+ def run(app, portal_api, portal_repo: str, org: str, path: dict, inputs: dict, requester: str) -> tuple[str, str]:
92
+ """Carries out one golden path for `requester`; returns what happened and a link to it."""
93
+ if path["kind"] == "repo":
94
+ return _new_repo(app, org, path, inputs)
95
+ return _files_pr(app, portal_api, portal_repo, org, path, inputs, requester)
@@ -0,0 +1,30 @@
1
+ """Since when: the daily history lines say how long a repo has had its owner status and level, which no one-off look can."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+
6
+ def since(folder: Path, repos: list[dict], today: str, keep: int = 400) -> dict[str, dict[str, str]]:
7
+ """For each repo, the first day of the unbroken run of days with today's owner status and level (today if new)."""
8
+ days = []
9
+ for path in sorted(Path(folder).glob("*.json"))[-keep:]:
10
+ if path.stem >= today:
11
+ continue
12
+ try:
13
+ rows = json.loads(path.read_text(encoding="utf-8")).get("repos") or []
14
+ except (OSError, ValueError):
15
+ continue
16
+ days.append((path.stem, {r.get("name"): r for r in rows if isinstance(r, dict)}))
17
+ out = {}
18
+ for r in repos:
19
+ now = {"owners": (r.get("owners") or {}).get("status"), "level": (r.get("score") or {}).get("level")}
20
+ found = {}
21
+ for key, value in now.items():
22
+ start = today
23
+ for day, rows in reversed(days):
24
+ row = rows.get(r["name"])
25
+ if not row or row.get(key) != value:
26
+ break
27
+ start = day
28
+ found[key] = start
29
+ out[r["name"]] = found
30
+ return out