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,140 @@
1
+ """Linear: issues of the chosen teams, their state and cycle changes, and the teams' cycles, read with a personal API key
2
+ over GraphQL. A pass reads everything updated in a fixed span; a capped pass carries on from its cursor next run."""
3
+ from datetime import datetime, timedelta
4
+
5
+ import httpx
6
+
7
+ from stackvitals.connectors.base import BACKFILL_DAYS, PAGES, ConnectorError, std_type, utc
8
+ from stackvitals.core.time import iso
9
+
10
+ NAME, TOKEN_ENV = "linear", "STACKVITALS_LINEAR_TOKEN"
11
+ URL = "https://api.linear.app/graphql"
12
+ # What is asked for: 2 adds the assignee and 3 its name, so answers kept before are read again, not rebuilt.
13
+ ASKED = 3
14
+ CATEGORY = {"triage": "TODO", "backlog": "TODO", "unstarted": "TODO", "started": "IN_PROGRESS", "completed": "DONE",
15
+ "canceled": "DONE"}
16
+ PRIORITY = {0: None, 1: "Urgent", 2: "High", 3: "Medium", 4: "Low"}
17
+ ISSUES = """query Issues($filter: IssueFilter, $after: String) {
18
+ issues(first: 50, after: $after, orderBy: updatedAt, includeArchived: true, filter: $filter) {
19
+ nodes { id identifier title url priority estimate createdAt updatedAt startedAt completedAt canceledAt
20
+ state { name type } team { key } labels(first: 10) { nodes { name } } cycle { id } project { id name } assignee { id email name }
21
+ history(first: 30) { nodes { createdAt fromState { name } toState { name } fromCycleId toCycleId } } }
22
+ pageInfo { hasNextPage endCursor } } }"""
23
+ CYCLES = """query Cycles($filter: CycleFilter, $after: String) {
24
+ cycles(first: 50, after: $after, filter: $filter) {
25
+ nodes { id number name startsAt endsAt completedAt team { key } }
26
+ pageInfo { hasNextPage endCursor } } }"""
27
+
28
+ __all__ = ["ConnectorError", "configured", "pull"]
29
+
30
+
31
+ def configured(env: dict, cfg: dict) -> bool:
32
+ return bool((env.get(TOKEN_ENV) or "").strip())
33
+
34
+
35
+ def _post(client: httpx.Client, key: str, query: str, variables: dict) -> dict:
36
+ try:
37
+ resp = client.post(URL, json={"query": query, "variables": variables},
38
+ headers={"Authorization": key, "Content-Type": "application/json"})
39
+ except httpx.HTTPError as exc:
40
+ raise ConnectorError(f"Linear could not be reached ({type(exc).__name__}); the next run tries again.") from None
41
+ try:
42
+ body = resp.json()
43
+ except ValueError:
44
+ body = {}
45
+ body = body if isinstance(body, dict) else {}
46
+ errors = body.get("errors") or []
47
+ codes = {str((e.get("extensions") or {}).get("code") or "") for e in errors}
48
+ if resp.status_code in (401, 403) or "AUTHENTICATION_ERROR" in codes:
49
+ raise ConnectorError(f"Linear refused the key ({resp.status_code}): check the LINEAR_API_KEY secret.")
50
+ if resp.status_code == 429 or "RATELIMITED" in codes:
51
+ raise ConnectorError("Linear's hourly limit for this key is used up; the next run carries on.")
52
+ if errors or resp.status_code != 200:
53
+ said = "; ".join(str(e.get("message") or "") for e in errors)[:200]
54
+ raise ConnectorError(f"Linear could not run the query ({said or resp.status_code}); check teams under [connectors.linear].")
55
+ return body.get("data") or {}
56
+
57
+
58
+ def _issue_records(n: dict, incident_labels: set[str]) -> list[dict]:
59
+ state = n.get("state") or {}
60
+ labels = [x.get("name") or "" for x in (n.get("labels") or {}).get("nodes") or []]
61
+ low = {x.lower() for x in labels}
62
+ incident = bool(low & incident_labels)
63
+ iid = f"linear:{n['id']}"
64
+ history = (n.get("history") or {}).get("nodes") or []
65
+ # The cycles an issue was ever in: its current one and every one its history moved it into or out of.
66
+ cycles = {c for c in [(n.get("cycle") or {}).get("id"), *(h.get(k) for h in history for k in ("fromCycleId", "toCycleId"))] if c}
67
+ out = [{"t": "issue", "src": NAME, "id": iid, "key": n["identifier"], "title": n.get("title"),
68
+ "type": "INCIDENT" if incident else next((std_type(x) for x in labels if std_type(x) != "TASK"), "TASK"),
69
+ "original_type": None, "status": state.get("name"), "cat": CATEGORY.get(state.get("type"), "TODO"),
70
+ "created": utc(n.get("createdAt")), "updated": utc(n.get("updatedAt")),
71
+ "resolved": utc(n.get("completedAt") or n.get("canceledAt")),
72
+ "points": n.get("estimate") if isinstance(n.get("estimate"), (int, float)) else None,
73
+ "priority": PRIORITY.get(n.get("priority")), "labels": labels,
74
+ "sprints": sorted(f"linear:cycle:{c}" for c in cycles), "incident": incident,
75
+ "canceled": state.get("type") == "canceled", "url": n.get("url"),
76
+ # Who it is assigned to, for [identity] aliases to name the GitHub login; never kept on a public repo.
77
+ **({"assignee": f"linear:{n['assignee']['id']}", "assignee_email": n["assignee"].get("email"),
78
+ "assignee_name": n["assignee"].get("name")}
79
+ if (n.get("assignee") or {}).get("id") else {}),
80
+ # A Linear project stands for the epic: what a body of work belongs to.
81
+ **({"epic": f"linear:project:{n['project']['id']}", "epic_title": n["project"].get("name")}
82
+ if (n.get("project") or {}).get("id") else {})}]
83
+ for k, h in enumerate(history):
84
+ if h.get("fromState") or h.get("toState"):
85
+ out.append({"t": "change", "src": NAME, "id": f"{iid}:{h.get('createdAt')}:{k}:status", "issue": iid,
86
+ "field": "status", "from": (h.get("fromState") or {}).get("name"),
87
+ "to": (h.get("toState") or {}).get("name"), "at": utc(h.get("createdAt"))})
88
+ if h.get("fromCycleId") or h.get("toCycleId"):
89
+ out.append({"t": "change", "src": NAME, "id": f"{iid}:{h.get('createdAt')}:{k}:cycle", "issue": iid,
90
+ "field": "sprint", "from": [f"linear:cycle:{h['fromCycleId']}"] if h.get("fromCycleId") else [],
91
+ "to": [f"linear:cycle:{h['toCycleId']}"] if h.get("toCycleId") else [], "at": utc(h.get("createdAt"))})
92
+ return out
93
+
94
+
95
+ def _cycle_record(c: dict, now: datetime) -> dict:
96
+ start = utc(c.get("startsAt"))
97
+ state = "CLOSED" if c.get("completedAt") else "ACTIVE" if start and start <= iso(now) else "FUTURE"
98
+ return {"t": "sprint", "src": NAME, "id": f"linear:cycle:{c['id']}", "name": c.get("name") or f"Cycle {c.get('number')}",
99
+ "state": state, "start": start, "end": utc(c.get("endsAt")), "complete": utc(c.get("completedAt"))}
100
+
101
+
102
+ def pull(store, env: dict, cfg: dict, now: datetime, client: httpx.Client | None = None, pages: int = PAGES) -> dict:
103
+ c = cfg["connectors"]["linear"]
104
+ key = env.get(TOKEN_ENV, "").strip()
105
+ teams = {"team": {"key": {"in": list(c["teams"])}}} if c["teams"] else {}
106
+ labels = {x.lower() for x in c["incident_labels"]}
107
+ own = client is None
108
+ client = client or httpx.Client(timeout=30)
109
+ try:
110
+ state = store.state()
111
+ since = state.get("since") or iso(now - timedelta(days=BACKFILL_DAYS))
112
+ until = state.get("until") or iso(now)
113
+ cursor, day = state.get("cursor"), now.date().isoformat()
114
+ # Every cycle that ended inside the window or is still to come, page by page.
115
+ after, floor = None, iso(now - timedelta(days=BACKFILL_DAYS))
116
+ for _ in range(pages):
117
+ cycles = _post(client, key, CYCLES, {"filter": {**teams, "endsAt": {"gte": floor}}, "after": after}).get("cycles") or {}
118
+ store.add([_cycle_record(x, now) for x in cycles.get("nodes") or []], day)
119
+ info = cycles.get("pageInfo") or {}
120
+ after = info.get("endCursor") if info.get("hasNextPage") else None
121
+ if after is None:
122
+ break
123
+ read, complete = 0, False
124
+ for _ in range(pages):
125
+ found = _post(client, key, ISSUES, {"filter": {"updatedAt": {"gte": since, "lte": until}, **teams},
126
+ "after": cursor}).get("issues") or {}
127
+ nodes = found.get("nodes") or []
128
+ store.add([r for n in nodes for r in _issue_records(n, labels)], day)
129
+ read += len(nodes)
130
+ info = found.get("pageInfo") or {}
131
+ cursor = info.get("endCursor") if info.get("hasNextPage") else None
132
+ if cursor is None:
133
+ complete = True
134
+ break
135
+ # A finished pass moves on to whatever changed after it began; a capped one keeps its span and cursor.
136
+ store.save_state({"since": until} if complete else {"since": since, "until": until, "cursor": cursor})
137
+ return {"read": read, "incidents": 0, "complete": complete}
138
+ finally:
139
+ if own:
140
+ client.close()
@@ -0,0 +1,96 @@
1
+ """PagerDuty: incidents of the chosen services and urgencies, read with a REST API key (v2 List incidents). Incidents are
2
+ found by when they began, so each pull reads the last two weeks again to catch the ones resolved since."""
3
+ from datetime import datetime, timedelta
4
+
5
+ import httpx
6
+
7
+ from stackvitals.connectors.base import BACKFILL_DAYS, PAGES, ConnectorError, request, utc
8
+ from stackvitals.core.time import iso, parse
9
+
10
+ NAME, TOKEN_ENV = "pagerduty", "STACKVITALS_PAGERDUTY_TOKEN"
11
+ URL = "https://api.pagerduty.com/incidents"
12
+ PAGE = 100
13
+ REREAD = timedelta(days=14)
14
+ CHUNK = timedelta(days=90) # PagerDuty takes at most six months per query
15
+ CATEGORY = {"triggered": "TODO", "acknowledged": "IN_PROGRESS", "resolved": "DONE"}
16
+
17
+ __all__ = ["ConnectorError", "configured", "extract", "pull"]
18
+
19
+
20
+ def configured(env: dict, cfg: dict) -> bool:
21
+ return bool((env.get(TOKEN_ENV) or "").strip())
22
+
23
+
24
+ def scope(cfg: dict) -> str | None:
25
+ """Said on the Integrations page while every service's incidents count for this repo."""
26
+ if not cfg["connectors"]["pagerduty"]["services"]:
27
+ return "reads every service in the account; list this repo's under services in [connectors.pagerduty] to count only those"
28
+ return None
29
+
30
+
31
+ def _get(client: httpx.Client, key: str, params: list, sleep=None) -> dict:
32
+ return request(client, "GET", URL, "PagerDuty", refused="check the PAGERDUTY_API_KEY secret",
33
+ bad=lambda r: f"PagerDuty answered {r.status_code}; check services and urgencies under [connectors.pagerduty].",
34
+ params=params, headers={"Authorization": f"Token token={key}",
35
+ "Accept": "application/vnd.pagerduty+json;version=2"},
36
+ **({"sleep": sleep} if sleep else {}))
37
+
38
+
39
+ def _record(i: dict) -> dict:
40
+ status = i.get("status")
41
+ resolved = utc(i.get("resolved_at") or (i.get("last_status_change_at") if status == "resolved" else None))
42
+ service = (i.get("service") or {}).get("summary")
43
+ return {"t": "issue", "src": NAME, "id": f"pagerduty:{i['id']}", "key": f"PD-{i.get('incident_number')}",
44
+ "title": i.get("title"), "type": "INCIDENT", "original_type": None, "status": status,
45
+ "cat": CATEGORY.get(status, "TODO"), "created": utc(i.get("created_at")), "updated": utc(i.get("updated_at")),
46
+ "resolved": resolved if status == "resolved" else None, "points": None, "priority": i.get("urgency"),
47
+ "labels": [x for x in (i.get("urgency"), service) if x], "sprints": [], "incident": True, "canceled": False,
48
+ "url": i.get("html_url")}
49
+
50
+
51
+ def extract(kind: str, ctx: dict, body: dict) -> list[dict]:
52
+ """The records in one kept answer (a page of incidents)."""
53
+ return [_record(i) for i in body.get("incidents") or []]
54
+
55
+
56
+ def pull(store, env: dict, cfg: dict, now: datetime, client: httpx.Client | None = None, pages: int = PAGES) -> dict:
57
+ c = cfg["connectors"]["pagerduty"]
58
+ key = env.get(TOKEN_ENV, "").strip()
59
+ own = client is None
60
+ client = client or httpx.Client(timeout=30)
61
+ try:
62
+ state = store.state()
63
+ floor = now - timedelta(days=BACKFILL_DAYS)
64
+ if state.get("since"):
65
+ # A capped pass carries on from the last incident it read; only a finished one reads two weeks again.
66
+ since = parse(state["since"])
67
+ start = max(since if state.get("catching_up") else since - REREAD, floor)
68
+ else:
69
+ start = floor
70
+ read, used, day = 0, 0, now.date().isoformat()
71
+ while start < now and used < pages:
72
+ end, last = min(start + CHUNK, now), None
73
+ for offset in range(0, PAGE * (pages - used), PAGE):
74
+ params = [("since", iso(start)), ("until", iso(end)), ("time_zone", "UTC"), ("sort_by", "created_at:asc"),
75
+ ("limit", PAGE), ("offset", offset), *(("service_ids[]", s) for s in c["services"]),
76
+ *(("urgencies[]", u) for u in c["urgencies"])]
77
+ body = _get(client, key, params)
78
+ used += 1
79
+ found = body.get("incidents") or []
80
+ store.keep("incidents", {}, body, day)
81
+ store.add(extract("incidents", {}, body), day)
82
+ read += len(found)
83
+ last = max([last or "", *(utc(i.get("created_at")) or "" for i in found)]) or last
84
+ if not body.get("more"):
85
+ break
86
+ else:
87
+ # Capped inside a chunk: the next run starts at the newest incident read, always a little further on.
88
+ start = max(parse(last), start + timedelta(seconds=1)) if last else start
89
+ break
90
+ start = end
91
+ complete = start >= now
92
+ store.save_state({"since": iso(now)} if complete else {"since": iso(start), "catching_up": True})
93
+ return {"read": read, "incidents": read, "complete": complete}
94
+ finally:
95
+ if own:
96
+ client.close()
@@ -0,0 +1,110 @@
1
+ """Slack: support channels read with a bot token the user installs (channels:history, groups:history): each top-level
2
+ message, when anyone first replied in its thread and when the team did (the user ids under `team`). The first pull reads
3
+ 180 days, later ones the last 30 (where new replies land); what was read is kept even when a later call fails. Only
4
+ times and flags are kept: no text and no user ids."""
5
+ import time
6
+ from datetime import datetime, timedelta, timezone
7
+
8
+ import httpx
9
+
10
+ from stackvitals.connectors.base import BACKFILL_DAYS, PAGES, ConnectorError, json_of
11
+ from stackvitals.core import identity
12
+
13
+ NAME, TOKEN_ENV = "slack", "STACKVITALS_SLACK_TOKEN"
14
+ API = "https://slack.com/api"
15
+ THREADS = 150 # thread reads a run; the next run carries on
16
+ RECENT_DAYS = 30
17
+ MAX_WAIT = 60
18
+
19
+ __all__ = ["ConnectorError", "configured", "pull"]
20
+
21
+
22
+ def configured(env: dict, cfg: dict) -> bool:
23
+ return bool((env.get(TOKEN_ENV) or "").strip())
24
+
25
+
26
+ def _iso(ts: str | None) -> str | None:
27
+ return datetime.fromtimestamp(float(ts), timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if ts else None
28
+
29
+
30
+ def _call(client: httpx.Client, key: str, method: str, sleep, **params) -> dict:
31
+ for attempt in (1, 2):
32
+ try:
33
+ resp = client.get(f"{API}/{method}", params=params, headers={"Authorization": f"Bearer {key}"})
34
+ except httpx.HTTPError as exc:
35
+ raise ConnectorError(f"Slack could not be reached ({type(exc).__name__}); the next run tries again.") from None
36
+ if resp.status_code == 429:
37
+ wait = float(resp.headers.get("retry-after", MAX_WAIT))
38
+ if attempt == 1 and wait <= MAX_WAIT:
39
+ sleep(wait) # Slack says how long; one wait, then the run carries on next time
40
+ continue
41
+ raise ConnectorError("Slack's rate limit was reached; what was read is kept and the next run carries on.")
42
+ body = json_of(resp, "Slack")
43
+ if not body.get("ok"):
44
+ raise ConnectorError(f"Slack refused {method} ({body.get('error')}): the bot needs channels:history and to be "
45
+ "in each channel under [connectors.slack] channels.")
46
+ return body
47
+ raise ConnectorError("Slack's rate limit was reached; the next run carries on.")
48
+
49
+
50
+ def _people(messages: list[dict]) -> list[dict]:
51
+ return [m for m in messages if m.get("user") and not m.get("bot_id") and m.get("subtype") in (None, "thread_broadcast")]
52
+
53
+
54
+ def pull(store, env: dict, cfg: dict, now: datetime, client: httpx.Client | None = None, pages: int = PAGES,
55
+ sleep=time.sleep) -> dict:
56
+ c = cfg["connectors"][NAME]
57
+ # Team members are the ids listed here, plus those [identity] aliases map to a team member.
58
+ key, team = env.get(TOKEN_ENV, "").strip(), set(c["team"]) | identity.tool_ids(cfg.get("identity"), NAME)
59
+ state = store.state()
60
+ days = RECENT_DAYS if state.get("backfilled") else BACKFILL_DAYS
61
+ oldest = f"{(now - timedelta(days=days)).timestamp():.6f}"
62
+ known = {r["id"]: r for r in store.load().get("topic") or []}
63
+ own = client is None
64
+ client = client or httpx.Client(timeout=30)
65
+ fresh, threads, complete, failed = [], 0, True, []
66
+ try:
67
+ for channel in c["channels"]:
68
+ try:
69
+ cursor = None
70
+ for _ in range(pages):
71
+ body = _call(client, key, "conversations.history", sleep, channel=channel, oldest=oldest, limit=200,
72
+ **({"cursor": cursor} if cursor else {}))
73
+ for m in _people(body.get("messages") or []):
74
+ if m.get("thread_ts") not in (None, m["ts"]):
75
+ continue # a reply shown in the channel
76
+ rid = f"{NAME}:{channel}:{m['ts']}"
77
+ stored = known.get(rid)
78
+ if stored and (stored["first_team_reply"] or not m.get("reply_count")):
79
+ continue
80
+ replies = []
81
+ if m.get("reply_count"):
82
+ if threads >= THREADS:
83
+ complete = False
84
+ continue
85
+ threads += 1
86
+ got = _call(client, key, "conversations.replies", sleep, channel=channel, ts=m["ts"])
87
+ replies = [r for r in _people(got.get("messages") or []) if r["ts"] != m["ts"] and r["user"] != m["user"]]
88
+ team_reply = next((r for r in replies if r["user"] in team), None)
89
+ fresh.append({"t": "topic", "src": NAME, "id": rid, "created": _iso(m["ts"]), "by_team": m["user"] in team,
90
+ "first_reply": _iso(replies[0]["ts"]) if replies else None,
91
+ "first_team_reply": _iso(team_reply["ts"]) if team_reply else None,
92
+ "solved": None, "closed": False})
93
+ cursor = (body.get("response_metadata") or {}).get("next_cursor")
94
+ if not body.get("has_more") or not cursor:
95
+ break
96
+ else:
97
+ complete = False
98
+ except ConnectorError as exc:
99
+ # One channel's trouble (the bot not in it, a limit) is said; the other channels go on.
100
+ failed.append(f"{channel}: {exc}")
101
+ complete = False
102
+ finally:
103
+ if own:
104
+ client.close()
105
+ store.add(fresh, now.date().isoformat())
106
+ if len(failed) == len(c["channels"]) and failed:
107
+ raise ConnectorError(failed[0])
108
+ if complete:
109
+ store.save_state({**store.state(), "backfilled": True})
110
+ return {"read": len(fresh), "complete": complete, **({"note": "; ".join(failed[:2])} if failed else {})}
@@ -0,0 +1,77 @@
1
+ """SonarQube or SonarCloud code quality (DevLake's SonarQube plugin): the project's quality gate and its measures (bugs,
2
+ vulnerabilities, code smells, coverage, duplication, ratings), read once a day and kept, so the trend shows beside the
3
+ delivery numbers. SonarCloud's public projects need no key; a private project or your own SonarQube takes a token."""
4
+ import httpx
5
+
6
+ from stackvitals.connectors.base import ConnectorError, json_of
7
+
8
+ NAME, TOKEN_ENV = "sonar", "STACKVITALS_SONAR_TOKEN"
9
+ METRICS = ("alert_status", "bugs", "vulnerabilities", "code_smells", "security_hotspots", "coverage", "duplicated_lines_density",
10
+ "ncloc", "reliability_rating", "security_rating", "sqale_rating")
11
+ KEEP_DAYS = 400
12
+
13
+ __all__ = ["ConnectorError", "configured", "pull", "summary"]
14
+
15
+
16
+ def configured(env: dict, cfg: dict) -> bool:
17
+ return bool(cfg["connectors"]["sonar"].get("project"))
18
+
19
+
20
+ def _get(client: httpx.Client, url: str, params: dict, token: str) -> dict:
21
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
22
+ try:
23
+ resp = client.get(url, params=params, headers=headers)
24
+ except httpx.HTTPError as exc:
25
+ raise ConnectorError(f"Sonar could not be reached ({type(exc).__name__}); the next run tries again.") from None
26
+ if resp.status_code in (401, 403):
27
+ raise ConnectorError("Sonar refused the request: a private project needs the SONAR_TOKEN secret.")
28
+ if resp.status_code == 404:
29
+ raise ConnectorError("Sonar has no project with that key: check project under [connectors.sonar].")
30
+ if resp.status_code != 200:
31
+ raise ConnectorError(f"Sonar answered {resp.status_code}; the next run tries again.")
32
+ return json_of(resp, "Sonar")
33
+
34
+
35
+ def _number(value):
36
+ try:
37
+ return float(value)
38
+ except (TypeError, ValueError):
39
+ return value
40
+
41
+
42
+ def pull(store, env: dict, cfg: dict, now, client: httpx.Client | None = None) -> dict:
43
+ c = cfg["connectors"]["sonar"]
44
+ base, project, token = (c.get("url") or "https://sonarcloud.io").rstrip("/"), c["project"], (env.get(TOKEN_ENV) or "").strip()
45
+ own = client is None
46
+ client = client or httpx.Client(timeout=30)
47
+ try:
48
+ got = _get(client, f"{base}/api/measures/component", {"component": project, "metricKeys": ",".join(METRICS)}, token)
49
+ gate = _get(client, f"{base}/api/qualitygates/project_status", {"projectKey": project}, token)
50
+ finally:
51
+ if own:
52
+ client.close()
53
+ measures = {m["metric"]: _number(m.get("value")) for m in (got.get("component") or {}).get("measures") or [] if "value" in m}
54
+ status = (gate.get("projectStatus") or {})
55
+ day = now.date().isoformat()
56
+ row = {"t": "quality", "id": f"sonar:{day}", "day": day, "project": project, "measures": measures,
57
+ "gate": status.get("status"),
58
+ "failing": [x.get("metricKey") for x in status.get("conditions") or [] if x.get("status") == "ERROR"]}
59
+ store.add([row], day)
60
+ return {"read": 1, "complete": True}
61
+
62
+
63
+ def summary(root, now) -> dict | None:
64
+ """The newest reading, the one about 30 days before, and each day's for the charts; None before the first pull."""
65
+ from datetime import timedelta
66
+
67
+ from stackvitals.connectors.store import ConnectorStore
68
+ rows = sorted((r for r in ConnectorStore(root, NAME).load().get("quality") or [] if r.get("day")), key=lambda r: r["day"])
69
+ if not rows:
70
+ return None
71
+ newest = rows[-1]
72
+ month = (now - timedelta(days=30)).date().isoformat()
73
+ before = next((r for r in reversed(rows) if r["day"] <= month), None)
74
+ return {"project": newest.get("project"), "day": newest["day"], "gate": newest.get("gate"), "failing": newest.get("failing") or [],
75
+ "measures": newest.get("measures") or {}, "month_ago": (before or {}).get("measures"),
76
+ "days": [{"day": r["day"], **{k: (r.get("measures") or {}).get(k) for k in ("bugs", "vulnerabilities", "code_smells", "coverage")}}
77
+ for r in rows[-180:]]}
@@ -0,0 +1,120 @@
1
+ """What a connector pulled, in the data branch under connectors/<name>/: one NDJSON file per pull day (compressed once the
2
+ day is over), the newest copy of each record winning, and a small state file to resume from. A private repo also keeps
3
+ each answer as the service sent it (raw/), so a better reading of it rebuilds the records without asking again."""
4
+ import gzip
5
+ import json
6
+ import os
7
+ import shutil
8
+ from pathlib import Path
9
+
10
+ # Words a public repo must never hold: its data branch is as public as its pages.
11
+ REDACT = {"issue": ("title", "url", "epic_title", "assignee", "assignee_email", "assignee_name", "team"), "sprint": ("name",)}
12
+
13
+
14
+ def _clean(r: dict) -> dict:
15
+ hidden = REDACT.get(r.get("t"), ())
16
+ return {k: v for k, v in r.items() if k not in hidden} if hidden else r
17
+
18
+
19
+ class ConnectorStore:
20
+ def __init__(self, root: Path, name: str, redact: bool = False):
21
+ self.dir = Path(root) / "connectors" / name
22
+ self.days = self.dir / "days"
23
+ self.rawdir = self.dir / "raw"
24
+ self.redact = redact
25
+ self._known = None # the stored copy of each record, read on the first add
26
+
27
+ def add(self, records: list[dict], day: str) -> None:
28
+ if self._known is None:
29
+ self._known = {(r.get("t"), r.get("id")): r for r in self._lines()}
30
+ fresh = []
31
+ for r in records:
32
+ r = _clean(r) if self.redact else r
33
+ # A record read again unchanged (an overlap, a cycle every issue names) is not written again.
34
+ if self._known.get((r.get("t"), r.get("id"))) != r:
35
+ self._known[(r.get("t"), r.get("id"))] = r
36
+ fresh.append(r)
37
+ if not fresh:
38
+ return
39
+ self.days.mkdir(parents=True, exist_ok=True)
40
+ with (self.days / f"{day}.ndjson").open("a", encoding="utf-8", newline="\n") as f:
41
+ for r in fresh:
42
+ f.write(json.dumps(r, separators=(",", ":"), ensure_ascii=False) + "\n")
43
+
44
+ def keep(self, kind: str, ctx: dict, body, day: str) -> None:
45
+ """One answer as the service sent it; never on a public repo, whose data branch anyone can read."""
46
+ if self.redact:
47
+ return
48
+ self.rawdir.mkdir(parents=True, exist_ok=True)
49
+ with (self.rawdir / f"{day}.ndjson").open("a", encoding="utf-8", newline="\n") as f:
50
+ f.write(json.dumps({"k": kind, "c": ctx, "b": body}, separators=(",", ":"), ensure_ascii=False) + "\n")
51
+
52
+ def raw(self):
53
+ """The kept answers, oldest first: {"k": kind, "c": what the reading needs, "b": the body}."""
54
+ yield from self._lines(self.rawdir)
55
+
56
+ def clear_records(self) -> None:
57
+ """The records only; the kept answers and the state stay, for a rebuild from them."""
58
+ shutil.rmtree(self.days, ignore_errors=True)
59
+ self._known = None
60
+
61
+ def _lines(self, folder: Path | None = None):
62
+ folder = folder or self.days
63
+ if not folder.is_dir():
64
+ return
65
+ for path in sorted(folder.iterdir()):
66
+ if path.name.endswith(".ndjson.gz"):
67
+ text = gzip.decompress(path.read_bytes()).decode("utf-8")
68
+ elif path.name.endswith(".ndjson"):
69
+ text = path.read_text(encoding="utf-8")
70
+ else:
71
+ continue
72
+ # Only "\n" ends a record: splitlines() also splits on U+2028 and the like, which titles may hold.
73
+ for line in text.split("\n"):
74
+ if line.strip():
75
+ try:
76
+ yield json.loads(line)
77
+ except ValueError:
78
+ continue # a damaged line (a cut-off write) never stops every later run
79
+
80
+ def load(self) -> dict[str, list[dict]]:
81
+ """{"issue": [...], "change": [...], "sprint": [...]}, later records winning by id."""
82
+ found = {}
83
+ for r in self._lines():
84
+ found[(r.get("t"), r.get("id"))] = _clean(r) if self.redact else r
85
+ out = {"issue": [], "change": [], "sprint": []}
86
+ for (kind, _id), r in found.items():
87
+ out.setdefault(kind, []).append(r)
88
+ return out
89
+
90
+ def state(self) -> dict:
91
+ try:
92
+ return json.loads((self.dir / "state.json").read_text(encoding="utf-8"))
93
+ except (OSError, ValueError):
94
+ return {}
95
+
96
+ def save_state(self, state: dict) -> None:
97
+ text = json.dumps(state, indent=1, sort_keys=True)
98
+ path = self.dir / "state.json"
99
+ if path.is_file() and path.read_text(encoding="utf-8") == text:
100
+ return
101
+ self.dir.mkdir(parents=True, exist_ok=True)
102
+ tmp = path.with_suffix(".tmp")
103
+ tmp.write_text(text, encoding="utf-8", newline="\n")
104
+ os.replace(tmp, path)
105
+
106
+ def reset(self) -> None:
107
+ """Everything pulled so far is dropped, so the next pull reads from the start."""
108
+ shutil.rmtree(self.dir, ignore_errors=True)
109
+ self._known = None
110
+
111
+ def compress(self, today: str) -> None:
112
+ for folder in (self.days, self.rawdir):
113
+ if not folder.is_dir():
114
+ continue
115
+ for path in sorted(folder.glob("*.ndjson")):
116
+ if path.name.split(".")[0] < today:
117
+ gz = path.with_name(path.name + ".gz")
118
+ old = gzip.decompress(gz.read_bytes()) if gz.exists() else b""
119
+ gz.write_bytes(gzip.compress(old + path.read_bytes(), mtime=0))
120
+ path.unlink()
@@ -0,0 +1,108 @@
1
+ """What tracker issues add to delivery: PRs linked to issues by key, lead and cycle time by issue type, sprint scope and
2
+ carry-over, and the incidents a tracker holds."""
3
+ import re
4
+ from collections import defaultdict
5
+ from datetime import datetime
6
+
7
+ from stackvitals.core.stats import percentile
8
+ from stackvitals.core.time import iso, parse
9
+
10
+ KEY = re.compile(r"(?<![A-Za-z0-9])([A-Za-z][A-Za-z0-9]{1,9})-(\d{1,7})(?![0-9])")
11
+ # A status no current issue holds is guessed from its name.
12
+ BUSY = re.compile(r"(?i)progress|review|doing|started|develop|testing|qa\b")
13
+ SPRINTS_SHOWN = 6
14
+
15
+
16
+ def pr_links(prs: list[dict], issues: list[dict]) -> list[tuple[int, str]]:
17
+ """(PR number, issue key) for every known issue key in a PR's title or head branch (any case: feature/web-12-x)."""
18
+ known = {i["key"].upper(): i["key"] for i in issues}
19
+ out = []
20
+ for p in prs:
21
+ found = []
22
+ for text in (p.get("ti") or "", p.get("head") or ""):
23
+ for prefix, number in KEY.findall(text):
24
+ key = known.get(f"{prefix.upper()}-{number}")
25
+ if key and key not in found:
26
+ found.append(key)
27
+ out += [(p["n"], key) for key in found]
28
+ return out
29
+
30
+
31
+ def _hours(a: str | None, b: str | None) -> float | None:
32
+ if not a or not b or b < a:
33
+ return None
34
+ return round((parse(b) - parse(a)).total_seconds() / 3600, 2)
35
+
36
+
37
+ def _stats(values: list[float], prefix: str, min_n: int = 10) -> dict:
38
+ # Under ten issues the times are left out (the count stays): a P75 of three says little.
39
+ v = [x for x in values if x is not None]
40
+ return {f"{prefix}_n": len(v), f"{prefix}_p50_h": percentile(v, 0.5) if len(v) >= min_n else None,
41
+ f"{prefix}_p75_h": percentile(v, 0.75) if len(v) >= min_n else None}
42
+
43
+
44
+ def lead_times(issues: list[dict], changes: list[dict], start: datetime, end: datetime, min_n: int = 10) -> dict:
45
+ """{type: {n, lead_p50_h, lead_p75_h, cycle_n, cycle_p50_h, cycle_p75_h}} over issues done in the range: lead time from
46
+ created, cycle time from the first move to an in-progress status; canceled ones are left out."""
47
+ s, e = iso(start), iso(end)
48
+ category = {i.get("status"): i.get("cat") for i in issues}
49
+ started = {}
50
+ for c in sorted((c for c in changes if c.get("field") == "status"), key=lambda c: c.get("at") or ""):
51
+ busy = category.get(c.get("to")) == "IN_PROGRESS" or (c.get("to") not in category and BUSY.search(c.get("to") or ""))
52
+ if busy:
53
+ started.setdefault(c["issue"], c["at"])
54
+ groups = defaultdict(list)
55
+ for i in issues:
56
+ if i.get("cat") == "DONE" and not i.get("canceled") and i.get("resolved") and s <= i["resolved"] <= e:
57
+ groups[i.get("type") or "TASK"].append(i)
58
+ out = {}
59
+ for kind in sorted(set(groups) | {"REQUIREMENT", "BUG"}):
60
+ rows = groups.get(kind, [])
61
+ lead = _stats([_hours(i.get("created"), i["resolved"]) for i in rows], "lead", min_n)
62
+ cycle = _stats([_hours(started.get(i["id"]), i["resolved"]) for i in rows], "cycle", min_n)
63
+ out[kind] = {"n": len(rows), **{k: v for k, v in lead.items() if k != "lead_n"}, **cycle}
64
+ return out
65
+
66
+
67
+ def incidents(issues: list[dict]) -> list[dict]:
68
+ """A tracker's incidents in the shape Delivery's own incidents take, with a link to each."""
69
+ return [{"source": i["src"], "number": i["key"], "created": i["created"],
70
+ "resolved": i.get("resolved") if i.get("cat") == "DONE" else None, "url": i.get("url"),
71
+ **({"severity": i["severity"]} if i.get("severity") else {})}
72
+ for i in issues if i.get("incident") and i.get("created")]
73
+
74
+
75
+ def planning(issues: list[dict], changes: list[dict], sprints: list[dict], now: datetime, last: int = SPRINTS_SHOWN,
76
+ since: datetime | None = None) -> list[dict]:
77
+ """The last sprints or cycles, newest last: scope, done by the sprint's end, carried over (open when it closed), added
78
+ after it started, and story points when the team uses them. Sprints that began before `since` (what the connector
79
+ reads back to) are left out, since only some of their issues were read."""
80
+ members = defaultdict(list)
81
+ for i in issues:
82
+ for sid in i.get("sprints") or []:
83
+ members[sid].append(i)
84
+ added = {}
85
+ for c in changes:
86
+ if c.get("field") == "sprint" and c.get("at"):
87
+ for sid in set(c.get("to") or []) - set(c.get("from") or []):
88
+ key = (c["issue"], sid)
89
+ added[key] = min(added.get(key, c["at"]), c["at"])
90
+ floor = iso(since) if since else ""
91
+ shown = sorted((s for s in sprints if s.get("start") and s["start"] >= floor and s.get("state") in ("ACTIVE", "CLOSED")),
92
+ key=lambda s: s["start"])
93
+ rows = []
94
+ for s in shown[-last:]:
95
+ scope = members.get(s["id"], [])
96
+ end = s.get("complete") or (iso(now) if s["state"] == "ACTIVE" else s.get("end")) or iso(now)
97
+ done = [i for i in scope if i.get("cat") == "DONE" and not i.get("canceled") and (i.get("resolved") or "9") <= end]
98
+ carried = [i for i in scope if s["state"] == "CLOSED" and i not in done and not i.get("canceled")]
99
+ late = [i for i in scope if (i["id"], s["id"]) in added and added[(i["id"], s["id"])] > s["start"]]
100
+ pointed = any(i.get("points") is not None for i in scope)
101
+ points = (lambda xs: sum(i.get("points") or 0 for i in xs)) if pointed else (lambda xs: None)
102
+ # A public repo keeps no sprint names: the dates name it instead.
103
+ name = s.get("name") or f"Sprint ending {(s.get('end') or '')[:10] or 'later'}"
104
+ rows.append({"id": s["id"], "name": name, "state": s["state"], "start": s["start"],
105
+ "end": s.get("complete") or s.get("end"), "scope": len(scope), "done": len(done),
106
+ "carried_over": len(carried), "added_after_start": len(late), "points_scope": points(scope),
107
+ "points_done": points(done)})
108
+ return rows