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,104 @@
1
+ """GitHub Copilot usage reports (the report API that replaced the old metrics API in April 2026), kept day by day: the
2
+ org's active users, suggestions accepted and Copilot pull requests, and this repo's Copilot pull requests. GitHub keeps
3
+ these reports for a limited time; the copy in the data branch keeps them as long as the branch does."""
4
+ import json
5
+ from datetime import date, datetime, timedelta
6
+
7
+ import httpx
8
+
9
+ from stackvitals.ci.api import API # GitHub Enterprise Server's too, when Actions names it
10
+ from stackvitals.connectors.base import ConnectorError, json_of
11
+
12
+ NAME, TOKEN_ENV = "copilot", "STACKVITALS_COPILOT_TOKEN"
13
+ VERSION = "2026-03-10"
14
+ DAYS = 60 # the most days one run reads, and how far back a first run starts
15
+ LAG = 2 # a day is first asked for two days on; one GitHub hasn't finished yet is asked for again
16
+ SETTLED = 4 # a day still without a report this long after (and none after it) had no usage
17
+ FIRST = date(2025, 12, 12) # the oldest organization report
18
+ ORG_FIELDS = ("daily_active_users", "weekly_active_users", "monthly_active_users", "monthly_active_chat_users",
19
+ "monthly_active_agent_users", "code_generation_activity_count", "code_acceptance_activity_count",
20
+ "loc_suggested_to_add_sum", "loc_added_sum", "loc_deleted_sum")
21
+ PR_FIELDS = ("total_created", "total_reviewed", "total_merged", "median_minutes_to_merge", "total_created_by_copilot",
22
+ "total_reviewed_by_copilot", "total_merged_created_by_copilot", "total_merged_reviewed_by_copilot",
23
+ "median_minutes_to_merge_copilot_authored")
24
+
25
+ __all__ = ["ConnectorError", "configured", "pull"]
26
+
27
+
28
+ def configured(env: dict, cfg: dict) -> bool:
29
+ return bool((env.get(TOKEN_ENV) or "").strip())
30
+
31
+
32
+ def _report(client: httpx.Client, key: str, path: str, day: str) -> list[dict] | None:
33
+ """The NDJSON rows of one day's report, or None when GitHub has none for that day."""
34
+ try:
35
+ resp = client.get(f"{API}{path}", params={"day": day}, headers={
36
+ "Authorization": f"Bearer {key}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": VERSION})
37
+ except httpx.HTTPError as exc:
38
+ raise ConnectorError(f"GitHub could not be reached for Copilot reports ({type(exc).__name__}).") from None
39
+ if resp.status_code in (401, 403):
40
+ raise ConnectorError(f"GitHub refused the Copilot reports key ({resp.status_code}): it needs \"Organization Copilot "
41
+ "metrics: read\" (an org App or a fine-grained token) from an org owner.")
42
+ if resp.status_code == 404:
43
+ raise ConnectorError("GitHub has no Copilot usage reports for this organization (404): check org under "
44
+ "[connectors.copilot] and that Copilot usage metrics are turned on.")
45
+ if resp.status_code == 204:
46
+ return None
47
+ if resp.status_code != 200:
48
+ raise ConnectorError(f"GitHub answered {resp.status_code} for Copilot reports; the next run tries again.")
49
+ rows = []
50
+ for link in json_of(resp, "GitHub").get("download_links") or []:
51
+ try:
52
+ # A signed link: fetched without the key, so the key never reaches the file host.
53
+ got = client.get(link)
54
+ except httpx.HTTPError as exc:
55
+ raise ConnectorError(f"A Copilot report file could not be downloaded ({type(exc).__name__}).") from None
56
+ if got.status_code != 200:
57
+ raise ConnectorError(f"A Copilot report file answered {got.status_code}; the next run tries again.")
58
+ try:
59
+ rows += [json.loads(line) for line in got.text.split("\n") if line.strip()]
60
+ except ValueError:
61
+ raise ConnectorError("A Copilot report file was not JSON lines; the next run tries again.") from None
62
+ return rows
63
+
64
+
65
+ def _prs(row: dict) -> dict:
66
+ return {k: v for k, v in (row.get("pull_requests") or {}).items() if k in PR_FIELDS}
67
+
68
+
69
+ def pull(store, env: dict, cfg: dict, now: datetime, client: httpx.Client | None = None, days: int = DAYS) -> dict:
70
+ owner, _, repo = (env.get("GITHUB_REPOSITORY") or "/").partition("/")
71
+ org = cfg["connectors"]["copilot"]["org"] or owner
72
+ key = env.get(TOKEN_ENV, "").strip()
73
+ own = client is None
74
+ client = client or httpx.Client(timeout=60)
75
+ try:
76
+ latest = now.date() - timedelta(days=LAG)
77
+ through = store.state().get("through")
78
+ start = date.fromisoformat(through) + timedelta(days=1) if through else max(FIRST, latest - timedelta(days=days - 1))
79
+ read, day, last, newest = 0, start, through, None
80
+ while day <= latest and (day - start).days < days:
81
+ org_rows = _report(client, key, f"/orgs/{org}/copilot/metrics/reports/organization-1-day", day.isoformat())
82
+ records = []
83
+ for row in org_rows or []:
84
+ gen, acc = row.get("code_generation_activity_count") or 0, row.get("code_acceptance_activity_count") or 0
85
+ records.append({"t": "org_day", "id": f"copilot:org:{day}", "day": day.isoformat(), "org": org,
86
+ **{k: row.get(k) for k in ORG_FIELDS}, "acceptance": round(acc / gen, 4) if gen else None,
87
+ "pull_requests": _prs(row)})
88
+ if org_rows:
89
+ read, newest = read + 1, day
90
+ repo_rows = _report(client, key, f"/orgs/{org}/copilot/metrics/reports/repos-1-day", day.isoformat()) or []
91
+ records += [{"t": "repo_day", "id": f"copilot:repo:{day}", "day": day.isoformat(), "pull_requests": _prs(row)}
92
+ for row in repo_rows if row.get("repo_name") == repo and (row.get("repo_owner_name") or owner) == owner]
93
+ store.add(records, now.date().isoformat())
94
+ # Settled: it has a report, a later day does, or it ended long enough ago; else the next run asks again.
95
+ if org_rows or day < now.date() - timedelta(days=SETTLED):
96
+ last = day.isoformat()
97
+ day += timedelta(days=1)
98
+ if newest and newest.isoformat() > (last or ""):
99
+ last = newest.isoformat()
100
+ store.save_state({"through": last} if last else {})
101
+ return {"read": read, "incidents": 0, "complete": day > latest}
102
+ finally:
103
+ if own:
104
+ client.close()
@@ -0,0 +1,103 @@
1
+ """Discord: support text channels read with a bot token the user installs (View Channel and Read Message History; message
2
+ content isn't needed): each message that isn't a reply, when anyone first replied to it and when the team did (the user
3
+ ids under `team`). The first pull reads 180 days, later ones the last 30; what was read is kept even when a later call
4
+ fails, and a channel the bot can't read (a forum channel, say) is said while the others go on. Only times and flags are
5
+ kept: no text and no user ids."""
6
+ import time
7
+ from datetime import datetime, timedelta
8
+
9
+ import httpx
10
+
11
+ from stackvitals.connectors.base import BACKFILL_DAYS, PAGES, ConnectorError, json_of, utc
12
+ from stackvitals.core import identity
13
+
14
+ NAME, TOKEN_ENV = "discord", "STACKVITALS_DISCORD_TOKEN"
15
+ API = "https://discord.com/api/v10"
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 _messages(client: httpx.Client, key: str, channel: str, before: str | None, sleep) -> list[dict]:
27
+ for attempt in (1, 2):
28
+ try:
29
+ resp = client.get(f"{API}/channels/{channel}/messages", params={"limit": 100, **({"before": before} if before else {})},
30
+ headers={"Authorization": f"Bot {key}"})
31
+ except httpx.HTTPError as exc:
32
+ raise ConnectorError(f"Discord could not be reached ({type(exc).__name__}); the next run tries again.") from None
33
+ if resp.status_code == 429:
34
+ wait = float(resp.headers.get("retry-after", MAX_WAIT))
35
+ if attempt == 1 and wait <= MAX_WAIT:
36
+ sleep(wait)
37
+ continue
38
+ raise ConnectorError("Discord's rate limit was reached; what was read is kept and the next run carries on.")
39
+ if resp.status_code in (401, 403):
40
+ raise ConnectorError(f"Discord refused the bot ({resp.status_code}): it needs View Channel and Read Message History "
41
+ f"in channel {channel}.")
42
+ if resp.status_code != 200:
43
+ raise ConnectorError(f"Discord answered {resp.status_code} for channel {channel} (a forum channel isn't read).")
44
+ return json_of(resp, "Discord", (list,))
45
+ raise ConnectorError("Discord's rate limit was reached; the next run carries on.")
46
+
47
+
48
+ def _topics(msgs: list[dict], channel: str, team: set, since: str) -> list[dict]:
49
+ replies = {}
50
+ for m in sorted(msgs, key=lambda m: m["timestamp"]):
51
+ ref = (m.get("message_reference") or {}).get("message_id")
52
+ if ref:
53
+ replies.setdefault(ref, []).append(m)
54
+ out = []
55
+ for m in msgs:
56
+ created = utc(m.get("timestamp"))
57
+ if (m.get("message_reference") or {}).get("message_id") or not created or created < since:
58
+ continue
59
+ author = (m.get("author") or {}).get("id")
60
+ theirs = [r for r in replies.get(m["id"], []) if (r.get("author") or {}).get("id") != author]
61
+ ours = next((r for r in theirs if (r.get("author") or {}).get("id") in team), None)
62
+ out.append({"t": "topic", "src": NAME, "id": f"{NAME}:{channel}:{m['id']}", "created": created,
63
+ "by_team": author in team, "first_reply": utc(theirs[0]["timestamp"]) if theirs else None,
64
+ "first_team_reply": utc(ours["timestamp"]) if ours else None, "solved": None, "closed": False})
65
+ return out
66
+
67
+
68
+ def pull(store, env: dict, cfg: dict, now: datetime, client: httpx.Client | None = None, pages: int = PAGES,
69
+ sleep=time.sleep) -> dict:
70
+ c = cfg["connectors"][NAME]
71
+ # Team members are the ids listed here, plus those [identity] aliases map to a team member.
72
+ key, team = env.get(TOKEN_ENV, "").strip(), set(c["team"]) | identity.tool_ids(cfg.get("identity"), NAME)
73
+ days = RECENT_DAYS if store.state().get("backfilled") else BACKFILL_DAYS
74
+ since = utc((now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ"))
75
+ own = client is None
76
+ client = client or httpx.Client(timeout=30)
77
+ fresh, complete, failed = [], True, []
78
+ try:
79
+ for channel in c["channels"]:
80
+ msgs, before = [], None
81
+ try:
82
+ for _ in range(pages):
83
+ batch = _messages(client, key, channel, before, sleep)
84
+ msgs += [m for m in batch if not (m.get("author") or {}).get("bot")]
85
+ if not batch or (utc(batch[-1].get("timestamp")) or "") < since:
86
+ break
87
+ before = batch[-1]["id"]
88
+ else:
89
+ complete = False
90
+ except ConnectorError as exc:
91
+ failed.append(f"{channel}: {exc}")
92
+ complete = False
93
+ # What a channel gave before it failed still counts.
94
+ fresh += _topics(msgs, channel, team, since)
95
+ finally:
96
+ if own:
97
+ client.close()
98
+ store.add(fresh, now.date().isoformat())
99
+ if len(failed) == len(c["channels"]) and failed:
100
+ raise ConnectorError(failed[0])
101
+ if complete:
102
+ store.save_state({**store.state(), "backfilled": True})
103
+ return {"read": len(fresh), "complete": complete, **({"note": "; ".join(failed[:2])} if failed else {})}
@@ -0,0 +1,117 @@
1
+ """Discourse: a project's forum, read from its public JSON (no key): each topic of the last 180 days, when anyone first
2
+ replied, when the team first did (forum staff, the usernames under `team`, or members of the `groups`), and whether it
3
+ was solved. Only times and flags are kept: no usernames, titles or post text."""
4
+ import time
5
+ from datetime import datetime, timedelta
6
+
7
+ import httpx
8
+
9
+ from stackvitals.connectors.base import BACKFILL_DAYS, ConnectorError, json_of, utc
10
+ from stackvitals.core import stats
11
+
12
+ NAME = "discourse"
13
+ LIST_PAGES, TOPICS = 10, 120 # a run reads at most 10 list pages and 120 topics; the next run carries on
14
+ PAUSE, MAX_WAIT = 0.25, 60
15
+
16
+ __all__ = ["ConnectorError", "configured", "pull", "answers"]
17
+
18
+
19
+ def configured(env: dict, cfg: dict) -> bool:
20
+ return bool(((cfg.get("connectors") or {}).get(NAME) or {}).get("url"))
21
+
22
+
23
+ def _get(client: httpx.Client, path: str, sleep=time.sleep, **params) -> dict:
24
+ for attempt in (1, 2):
25
+ sleep(PAUSE) # a public forum, read politely: well under Discourse's anonymous limit
26
+ try:
27
+ resp = client.get(path, params=params, headers={"Accept": "application/json"})
28
+ except httpx.HTTPError as exc:
29
+ raise ConnectorError(f"The forum could not be reached ({type(exc).__name__}); the next run tries again.") from None
30
+ if resp.status_code != 429:
31
+ break
32
+ wait = float(resp.headers.get("retry-after", MAX_WAIT))
33
+ if attempt == 2 or wait > MAX_WAIT:
34
+ raise ConnectorError("The forum's rate limit was reached; what was read is kept and the next run carries on.")
35
+ sleep(wait)
36
+ if resp.status_code != 200:
37
+ raise ConnectorError(f"The forum answered {resp.status_code} for {path}; check url under [connectors.discourse].")
38
+ return json_of(resp, "The forum")
39
+
40
+
41
+ def _record(topic: dict, posts: list[dict], team: set[str], groups: set[str]) -> dict:
42
+ def ours(p):
43
+ return bool(p.get("staff")) or (p.get("username") or "").lower() in team or (p.get("primary_group_name") or "").lower() in groups
44
+ # Only people's regular posts are replies: moderator notes ("closed", "moved") and the system account's aren't.
45
+ posts = [p for p in posts if p.get("post_type", 1) == 1 and p.get("username") != "system"]
46
+ first, replies = posts[0] if posts else {}, posts[1:]
47
+ reply = next((p for p in replies if p.get("username") != first.get("username")), None)
48
+ team_reply = next((p for p in replies if ours(p) and p.get("username") != first.get("username")), None)
49
+ return {"t": "topic", "src": NAME, "id": f"{NAME}:{topic['id']}", "created": utc(topic.get("created_at")),
50
+ "updated": utc(topic.get("last_posted_at") or topic.get("created_at")), "by_team": ours(first),
51
+ "first_reply": utc((reply or {}).get("created_at")), "first_team_reply": utc((team_reply or {}).get("created_at")),
52
+ "solved": bool(topic.get("has_accepted_answer")), "closed": bool(topic.get("closed"))}
53
+
54
+
55
+ def pull(store, env: dict, cfg: dict, now: datetime, client: httpx.Client | None = None, pages: int = LIST_PAGES,
56
+ sleep=time.sleep) -> dict:
57
+ c = cfg["connectors"][NAME]
58
+ team, groups = {x.lower() for x in c["team"]}, {x.lower() for x in c["groups"]}
59
+ since = utc((now - timedelta(days=BACKFILL_DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ"))
60
+ known = {r["id"]: r for r in store.load().get("topic") or []}
61
+ own = client is None
62
+ client = client or httpx.Client(base_url=c["url"].rstrip("/"), timeout=30, follow_redirects=False)
63
+ read, complete, fresh = 0, True, []
64
+ try:
65
+ for page in range(pages):
66
+ listing = (_get(client, "/latest.json", sleep, order="created", page=page).get("topic_list") or {})
67
+ topics = listing.get("topics") or []
68
+ old = False
69
+ for t in topics:
70
+ created = utc(t.get("created_at"))
71
+ if not created or created < since:
72
+ old = True
73
+ continue
74
+ stored = known.get(f"{NAME}:{t['id']}")
75
+ # A topic is read again only when it has new posts and is not settled yet.
76
+ if stored and stored["updated"] >= (utc(t.get("last_posted_at")) or "") or (stored and stored["first_team_reply"]):
77
+ continue
78
+ if read >= TOPICS:
79
+ complete = False
80
+ break
81
+ posts = (_get(client, f"/t/{t['id']}.json", sleep).get("post_stream") or {}).get("posts") or []
82
+ fresh.append(_record(t, posts, team, groups))
83
+ read += 1
84
+ if old or not topics or not listing.get("more_topics_url") or not complete:
85
+ break
86
+ else:
87
+ complete = False
88
+ finally:
89
+ if own:
90
+ client.close()
91
+ store.add(fresh, now.date().isoformat()) # kept even when a later request failed
92
+ return {"read": read, "complete": complete}
93
+
94
+
95
+ def answers(topics: list[dict], now: datetime, days: int = 90) -> dict | None:
96
+ """Topics opened by people outside the team in the last `days`: how fast the team first replied (topics still
97
+ waiting stay in the median), how often anyone replied first, and the share solved."""
98
+ since = (now - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
99
+ mine = [t for t in topics if not t.get("by_team") and (t.get("created") or "") >= since]
100
+ if not mine:
101
+ return None
102
+
103
+ def hours(a, b):
104
+ return (datetime.fromisoformat(b.replace("Z", "+00:00")) - datetime.fromisoformat(a.replace("Z", "+00:00"))).total_seconds() / 3600
105
+ end = now.strftime("%Y-%m-%dT%H:%M:%SZ")
106
+ # A topic closed without a team reply stops the clock at its last post; only open ones are still waiting.
107
+ km = stats.km_median([hours(t["created"], t["first_team_reply"] or (t.get("updated") if t.get("closed") else None) or end)
108
+ for t in mine], [bool(t["first_team_reply"]) for t in mine])
109
+ waits = sorted(hours(t["created"], t["first_team_reply"]) for t in mine if t["first_team_reply"])
110
+ mid = len(waits) // 2
111
+ median = (waits[mid] if len(waits) % 2 else (waits[mid - 1] + waits[mid]) / 2) if waits else None
112
+ return {"days": days, "topics": len(mine), "km": km, "median_hours": round(median, 2) if median is not None else None,
113
+ "team_answered": round(len(waits) / len(mine), 4),
114
+ "anyone_answered": round(sum(1 for t in mine if t["first_reply"]) / len(mine), 4),
115
+ # Chat has no solved mark: None there, not zero.
116
+ "solved_share": round(sum(1 for t in mine if t["solved"]) / len(mine), 4)
117
+ if any(t.get("solved") is not None for t in mine) else None}
@@ -0,0 +1,106 @@
1
+ """incident.io: real incidents (declined, canceled and merged ones are kept as not-incidents), read with an API key (v2 List
2
+ incidents), resuming from the newest update read; the resolve time is the incident's "Resolved at" timestamp."""
3
+ from datetime import datetime, timedelta
4
+
5
+ import httpx
6
+
7
+ from stackvitals.connectors.base import (
8
+ BACKFILL_DAYS,
9
+ PAGES,
10
+ ConnectorError,
11
+ json_of,
12
+ utc,
13
+ )
14
+ from stackvitals.core.time import iso, parse
15
+
16
+ NAME, TOKEN_ENV = "incident_io", "STACKVITALS_INCIDENT_IO_TOKEN"
17
+ URL = "https://api.incident.io/v2/incidents"
18
+ PAGE = 250
19
+ OVERLAP = timedelta(hours=1)
20
+ CATEGORY = {"triage": "TODO", "live": "IN_PROGRESS", "paused": "IN_PROGRESS", "learning": "DONE", "closed": "DONE"}
21
+ NOT_INCIDENTS = {"declined", "canceled", "merged"}
22
+ NOT_REAL = {"test", "tutorial"}
23
+
24
+ __all__ = ["ConnectorError", "configured", "pull"]
25
+
26
+
27
+ def configured(env: dict, cfg: dict) -> bool:
28
+ return bool((env.get(TOKEN_ENV) or "").strip())
29
+
30
+
31
+ def scope(cfg: dict) -> str | None:
32
+ """Said on the Integrations page while every incident type counts for this repo."""
33
+ if not cfg["connectors"]["incident_io"]["incident_types"]:
34
+ return ("counts every incident type; list this repo's under incident_types in [connectors.incident_io] to count "
35
+ "only those")
36
+ return None
37
+
38
+
39
+ def _get(client: httpx.Client, key: str, params: dict) -> dict:
40
+ try:
41
+ resp = client.get(URL, params=params, headers={"Authorization": f"Bearer {key}", "Accept": "application/json"})
42
+ except httpx.HTTPError as exc:
43
+ raise ConnectorError(f"incident.io could not be reached ({type(exc).__name__}); the next run tries again.") from None
44
+ if resp.status_code in (401, 403):
45
+ raise ConnectorError(f"incident.io refused the key ({resp.status_code}): check the INCIDENT_IO_API_KEY secret.")
46
+ if resp.status_code == 429:
47
+ raise ConnectorError("incident.io's limit for listing incidents (60 a minute) was reached; the next run carries on.")
48
+ if resp.status_code in (400, 404, 422) and "after" in params:
49
+ return None # the saved place in a pass is no longer known
50
+ if resp.status_code != 200:
51
+ raise ConnectorError(f"incident.io answered {resp.status_code}; the next run tries again.")
52
+ return json_of(resp, "incident.io")
53
+
54
+
55
+ def _resolved(i: dict) -> str | None:
56
+ for stamp in i.get("incident_timestamp_values") or []:
57
+ if ((stamp.get("incident_timestamp") or {}).get("name") or "").lower() == "resolved at":
58
+ return utc((stamp.get("value") or {}).get("value"))
59
+ return None
60
+
61
+
62
+ def _record(i: dict, types: set[str]) -> dict:
63
+ category = (i.get("incident_status") or {}).get("category")
64
+ cat = CATEGORY.get(category, "TODO")
65
+ return {"t": "issue", "src": NAME, "id": f"incident_io:{i['id']}", "key": i.get("reference"), "title": i.get("name"),
66
+ "type": "INCIDENT", "original_type": None, "status": (i.get("incident_status") or {}).get("name"), "cat": cat,
67
+ "created": utc(i.get("created_at")), "updated": utc(i.get("updated_at")), "resolved": _resolved(i) if cat == "DONE" else None,
68
+ "points": None, "priority": (i.get("severity") or {}).get("name"),
69
+ "labels": [x for x in ((i.get("severity") or {}).get("name"),) if x], "sprints": [], "incident": True,
70
+ "canceled": False, "url": i.get("permalink"),
71
+ # One declined or merged after it was read stays stored, but no longer as an incident; so do test runs and
72
+ # (when incident_types is set) other teams' types.
73
+ **({"incident": False, "canceled": True} if category in NOT_INCIDENTS or i.get("mode") in NOT_REAL
74
+ or types and ((i.get("incident_type") or {}).get("name") or "").lower() not in types else {})}
75
+
76
+
77
+ def pull(store, env: dict, cfg: dict, now: datetime, client: httpx.Client | None = None, pages: int = PAGES) -> dict:
78
+ key = env.get(TOKEN_ENV, "").strip()
79
+ own = client is None
80
+ client = client or httpx.Client(timeout=30)
81
+ try:
82
+ types = {t.lower() for t in cfg["connectors"]["incident_io"]["incident_types"]}
83
+ state = store.state()
84
+ since = state.get("since") or iso(now - timedelta(days=BACKFILL_DAYS))
85
+ params = {"page_size": PAGE, "updated_at[gte]": iso(parse(since) - OVERLAP), "sort_by": "created_at_oldest_first"}
86
+ # Sorted by creation, not update, so a capped pass keeps its place and only a finished one moves `since` on.
87
+ after, newest = state.get("after"), state.get("newest") or since
88
+ read, complete, day = 0, False, now.date().isoformat()
89
+ for _ in range(pages):
90
+ body = _get(client, key, {**params, **({"after": after} if after else {})})
91
+ if body is None:
92
+ after, newest = None, since
93
+ continue
94
+ found = body.get("incidents") or []
95
+ store.add([_record(i, types) for i in found], day)
96
+ read += len(found)
97
+ newest = max([newest, *(utc(i.get("updated_at")) or "" for i in found)])
98
+ after = (body.get("pagination_meta") or {}).get("after")
99
+ if not after:
100
+ complete = True
101
+ break
102
+ store.save_state({"since": newest} if complete else {"since": since, **({"after": after, "newest": newest} if after else {})})
103
+ return {"read": read, "incidents": read, "complete": complete}
104
+ finally:
105
+ if own:
106
+ client.close()
@@ -0,0 +1,210 @@
1
+ """Jira Cloud: issues in the scope query, their status and sprint changes, and their sprints, read with the account's email
2
+ and an API token (REST v3 enhanced search), resuming from the newest update read last time."""
3
+ import base64
4
+ import re
5
+ from datetime import datetime, timedelta
6
+
7
+ import httpx
8
+
9
+ from stackvitals.connectors.base import BACKFILL_DAYS, PAGES, ConnectorError, request, std_type, utc
10
+ from stackvitals.core.time import iso, parse
11
+
12
+ NAME, TOKEN_ENV, EMAIL_ENV = "jira", "STACKVITALS_JIRA_TOKEN", "STACKVITALS_JIRA_EMAIL"
13
+ # What is asked for: 2 adds the assignee, so answers kept before it are read again, not rebuilt.
14
+ ASKED = 2
15
+ PAGE = 100
16
+ # JQL reads dates in the account's own time zone, so each pull starts a day early; records seen twice just win again.
17
+ OVERLAP = timedelta(days=1)
18
+ SPRINT_FIELD = "com.pyxis.greenhopper.jira:gh-sprint"
19
+ POINTS_FIELDS = {"com.pyxis.greenhopper.jira:jsw-story-points", "com.atlassian.jira.plugin.system.customfieldtypes:float"}
20
+ CATEGORY = {"new": "TODO", "indeterminate": "IN_PROGRESS", "done": "DONE"}
21
+ NOT_DONE = {"won't do", "won't fix", "duplicate", "cannot reproduce", "declined", "rejected"}
22
+
23
+ __all__ = ["ConnectorError", "configured", "extract", "pull"]
24
+
25
+
26
+ def configured(env: dict, cfg: dict) -> bool:
27
+ return bool((env.get(TOKEN_ENV) or "").strip())
28
+
29
+
30
+ def _bad(resp) -> str | None:
31
+ if resp.status_code != 400:
32
+ return None
33
+ try:
34
+ said = "; ".join((resp.json() or {}).get("errorMessages") or [])[:200]
35
+ except (ValueError, AttributeError):
36
+ said = ""
37
+ return f"Jira could not run the query ({said or 'bad request'}): check scope_jql and incident_jql under [connectors.jira]."
38
+
39
+
40
+ def _get(client: httpx.Client, path: str, params: dict | None, auth: str):
41
+ return request(client, "GET", path, "Jira", kinds=(dict, list), bad=_bad, params=params,
42
+ refused="check the JIRA_EMAIL and JIRA_API_TOKEN secrets and that the token has not expired",
43
+ headers={"Authorization": auth, "Accept": "application/json"})
44
+
45
+
46
+ def _ids(text: str | None) -> list[str]:
47
+ return [f"jira:sprint:{x}" for x in re.findall(r"\d+", text or "")]
48
+
49
+
50
+ def _epic(parent) -> dict:
51
+ # The epic an issue belongs to is its parent when that parent is an epic (a sub-task's parent is a story).
52
+ f = (parent or {}).get("fields") or {}
53
+ if not parent or "epic" not in ((f.get("issuetype") or {}).get("name") or "").lower():
54
+ return {}
55
+ return {"epic": parent.get("key"), "epic_title": f.get("summary")}
56
+
57
+
58
+ def _text(value) -> str | None:
59
+ """A custom field's value as text: an option's value, a team's or user's name, or the first of a list."""
60
+ if isinstance(value, list):
61
+ value = value[0] if value else None
62
+ if isinstance(value, dict):
63
+ value = value.get("value") or value.get("name") or value.get("title") or value.get("displayName")
64
+ return str(value)[:100] if value not in (None, "") else None
65
+
66
+
67
+ def field_id(fields: list[dict], wanted: str) -> str | None:
68
+ """The id of the field the settings name, by its id or its name (any case)."""
69
+ w = (wanted or "").strip().lower()
70
+ return next((f["id"] for f in fields if w and w in ((f.get("id") or "").lower(), (f.get("name") or "").lower())), None)
71
+
72
+
73
+ def _records(raw: dict, site: str, sprint_f: str | None, points_f: str | None, incident: bool, extra: dict | None = None) -> list[dict]:
74
+ f = raw.get("fields") or {}
75
+ status = f.get("status") or {}
76
+ kind = (f.get("issuetype") or {}).get("name") or ""
77
+ sprints = [s for s in (f.get(sprint_f) or [] if sprint_f else []) if isinstance(s, dict) and s.get("id") is not None]
78
+ points = f.get(points_f) if points_f else None
79
+ iid = f"jira:{raw['id']}"
80
+ histories = (raw.get("changelog") or {}).get("histories") or []
81
+ cat = CATEGORY.get((status.get("statusCategory") or {}).get("key"), "TODO")
82
+ resolved = utc(f.get("resolutiondate"))
83
+ if not resolved and cat == "DONE":
84
+ # A workflow that never sets a resolution: done when it last moved into the status it is in.
85
+ resolved = max((utc(h.get("created")) or "" for h in histories for item in h.get("items") or []
86
+ if (item.get("field") or "").lower() == "status" and item.get("toString") == status.get("name")),
87
+ default=None) or None
88
+ out = [{"t": "issue", "src": NAME, "id": iid, "key": raw["key"], "title": f.get("summary"),
89
+ "type": std_type(kind), "original_type": kind, "status": status.get("name"),
90
+ "cat": cat, "created": utc(f.get("created")), "updated": utc(f.get("updated")), "resolved": resolved,
91
+ "points": points if isinstance(points, (int, float)) and not isinstance(points, bool) else None,
92
+ "priority": (f.get("priority") or {}).get("name"), "labels": [str(x) for x in f.get("labels") or []],
93
+ "sprints": [f"jira:sprint:{s['id']}" for s in sprints], "incident": False,
94
+ "canceled": ((f.get("resolution") or {}).get("name") or "").lower() in NOT_DONE,
95
+ "url": f"{site}/browse/{raw['key']}", **_epic(f.get("parent")),
96
+ # The fields the settings map (team_field, severity_field), as text.
97
+ **{k: v for k, fid in (extra or {}).items() if fid and (v := _text(f.get(fid)))},
98
+ # Who it is assigned to, for [identity] aliases to name the GitHub login; never kept on a public repo.
99
+ **({"assignee": f"jira:{f['assignee']['accountId']}", "assignee_email": f["assignee"].get("emailAddress"),
100
+ "assignee_name": f["assignee"].get("displayName")}
101
+ if (f.get("assignee") or {}).get("accountId") else {})}]
102
+ for h in histories:
103
+ for k, item in enumerate(h.get("items") or []):
104
+ field = (item.get("field") or "").lower()
105
+ if field == "status":
106
+ out.append({"t": "change", "src": NAME, "id": f"{iid}:{h.get('id')}:{k}", "issue": iid, "field": "status",
107
+ "from": item.get("fromString"), "to": item.get("toString"), "at": utc(h.get("created"))})
108
+ elif field == "sprint":
109
+ out.append({"t": "change", "src": NAME, "id": f"{iid}:{h.get('id')}:{k}", "issue": iid, "field": "sprint",
110
+ "from": _ids(item.get("from")), "to": _ids(item.get("to")), "at": utc(h.get("created"))})
111
+ if incident:
112
+ # Both queries write the issue alike; this mark makes it an incident at load while it is as new as the issue.
113
+ out.append({"t": "mark", "src": NAME, "id": f"{iid}:incident", "issue": iid, "updated": utc(f.get("updated"))})
114
+ for s in sprints:
115
+ out.append({"t": "sprint", "src": NAME, "id": f"jira:sprint:{s['id']}", "name": s.get("name"),
116
+ "state": str(s.get("state") or "").upper(), "start": utc(s.get("startDate")),
117
+ "end": utc(s.get("endDate")), "complete": utc(s.get("completeDate"))})
118
+ return out
119
+
120
+
121
+ def finish(got: dict) -> dict:
122
+ """An issue is an incident while the incident query's mark is as new as the issue's latest update."""
123
+ marks = {m["issue"]: m.get("updated") or "" for m in got.get("mark") or []}
124
+ for i in got.get("issue") or []:
125
+ if i["id"] in marks and marks[i["id"]] >= (i.get("updated") or ""):
126
+ i["incident"], i["type"] = True, "INCIDENT"
127
+ return got
128
+
129
+
130
+ def extract(kind: str, ctx: dict, body: dict) -> list[dict]:
131
+ """The records in one kept answer (a page of the scope or incident search)."""
132
+ return [r for raw in body.get("issues") or [] for r in _records(raw, ctx["site"], ctx["sprint_f"], ctx["points_f"], ctx["incident"], ctx.get("extra"))]
133
+
134
+
135
+ def _search(client, store, jql: str, wanted: list[str], auth: str, site: str, sprint_f, points_f, incident: bool,
136
+ pages: int, day: str, extra: dict | None = None) -> tuple[int, str | None, str | None, bool]:
137
+ """(issues read, newest update in UTC, that update as Jira wrote it in the account's zone, finished)."""
138
+ read, newest, local, token = 0, None, None, None
139
+ for _ in range(pages):
140
+ body = _get(client, "/rest/api/3/search/jql", {"jql": jql, "fields": ",".join(wanted), "expand": "changelog",
141
+ "maxResults": PAGE, **({"nextPageToken": token} if token else {})}, auth)
142
+ issues = body.get("issues") or []
143
+ ctx = {"site": site, "sprint_f": sprint_f, "points_f": points_f, "incident": incident, "extra": extra or {}}
144
+ store.keep("search", ctx, body, day)
145
+ store.add(extract("search", ctx, body), day)
146
+ read += len(issues)
147
+ for raw in issues:
148
+ stamp = (raw.get("fields") or {}).get("updated")
149
+ if (utc(stamp) or "") > (newest or ""):
150
+ newest, local = utc(stamp), stamp
151
+ token = body.get("nextPageToken")
152
+ if body.get("isLast") or not token:
153
+ return read, newest, local, True
154
+ return read, newest, local, False
155
+
156
+
157
+ def _minute(text: str, add: int = 0) -> str:
158
+ return f"{datetime.strptime(text, '%Y/%m/%d %H:%M') + timedelta(minutes=add):%Y/%m/%d %H:%M}"
159
+
160
+
161
+ def pull(store, env: dict, cfg: dict, now: datetime, client: httpx.Client | None = None, pages: int = PAGES) -> dict:
162
+ c = cfg["connectors"]["jira"]
163
+ if not c["url"]:
164
+ raise ConnectorError("Set url under [connectors.jira] in .github/stackvitals.toml to your Jira Cloud site "
165
+ "(https://<name>.atlassian.net).")
166
+ email = (env.get(EMAIL_ENV) or "").strip() or c["email"]
167
+ if not email:
168
+ raise ConnectorError("Jira needs the account's email: the JIRA_EMAIL secret, or email under [connectors.jira].")
169
+ auth = "Basic " + base64.b64encode(f"{email}:{env.get(TOKEN_ENV, '').strip()}".encode()).decode()
170
+ own = client is None
171
+ client = client or httpx.Client(base_url=c["url"], timeout=30, follow_redirects=False)
172
+ try:
173
+ fields = _get(client, "/rest/api/3/field", None, auth)
174
+ custom = lambda f: (f.get("schema") or {}).get("custom") # noqa: E731
175
+ sprint_f = next((f["id"] for f in fields if custom(f) == SPRINT_FIELD), None)
176
+ points_f = next((f["id"] for f in fields if custom(f) in POINTS_FIELDS
177
+ and re.fullmatch(r"(?i)story points?( estimate)?", f.get("name") or "")), None)
178
+ extra = {k: field_id(fields, c.get(f"{k}_field") or "") for k in ("team", "severity")}
179
+ wanted = ["summary", "issuetype", "status", "created", "updated", "resolutiondate", "resolution", "priority", "labels", "parent",
180
+ "assignee",
181
+ *(x for x in (sprint_f, points_f, *extra.values()) if x)]
182
+ state, day = store.state(), now.date().isoformat()
183
+ new, got, notes = dict(state), {}, []
184
+ # Sleuth's way for teams with no pager tool: the issues the incident query finds are the incidents.
185
+ queries = [("", c["scope_jql"].strip(), False)]
186
+ if c["incident_jql"].strip():
187
+ queries.append(("incident_", c["incident_jql"].strip(), True))
188
+ for prefix, jql, incident in queries:
189
+ since = state.get(prefix + "since") or iso(now - timedelta(days=BACKFILL_DAYS))
190
+ # A capped pass carries on from the minute it reached, in the account's zone; a finished one reads a day again.
191
+ start = state.get(prefix + "resume") or f"{parse(since) - OVERLAP:%Y/%m/%d %H:%M}"
192
+ where = f'updated >= "{start}" ORDER BY updated ASC'
193
+ read, newest, local, done = _search(client, store, f"({jql}) AND {where}" if jql else where, wanted, auth, c["url"],
194
+ sprint_f, points_f, incident, pages, day, extra)
195
+ new[prefix + "since"] = max(since, newest or since)
196
+ new.pop(prefix + "resume", None)
197
+ if not done and local:
198
+ resume = f"{datetime.fromisoformat(local):%Y/%m/%d %H:%M}"
199
+ if resume <= start:
200
+ resume = _minute(start, 1)
201
+ notes.append(f"more issues changed in one minute ({start}) than one run reads ({PAGE * pages:,}); "
202
+ "the rest of that minute was skipped.")
203
+ new[prefix + "resume"] = resume
204
+ got[prefix] = (read, done)
205
+ store.save_state(new)
206
+ return {"read": got[""][0], "incidents": got.get("incident_", (0, True))[0], "complete": all(d for _r, d in got.values()),
207
+ **({"note": " ".join(notes)} if notes else {})}
208
+ finally:
209
+ if own:
210
+ client.close()