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,244 @@
1
+ """Connectors to outside services, each read with one optional key from the repo's secrets: the Action's Connectors step,
2
+ the status the Integrations page shows, and everything pulled so far for Delivery to join."""
3
+ import hashlib
4
+ import json
5
+ from datetime import date, datetime, timedelta
6
+ from pathlib import Path
7
+
8
+ from stackvitals.connectors import ai_spend, copilot, discord, discourse, incident_io, jira, linear, pagerduty, slack, sonar
9
+ from stackvitals.connectors.base import BACKFILL_DAYS, ConnectorError
10
+ from stackvitals.connectors.store import ConnectorStore
11
+
12
+ CONNECTORS = {"jira": jira, "linear": linear, "pagerduty": pagerduty, "incident_io": incident_io, "copilot": copilot,
13
+ "discourse": discourse, "slack": slack, "discord": discord, "ai_spend": ai_spend, "sonar": sonar}
14
+ NAMES = {"jira": "Jira", "linear": "Linear", "pagerduty": "PagerDuty", "incident_io": "incident.io", "copilot": "Copilot",
15
+ "discourse": "Discourse", "slack": "Slack", "discord": "Discord", "ai_spend": "AI spend",
16
+ "sonar": "SonarQube"}
17
+ TRACKERS = ("jira", "linear", "pagerduty", "incident_io")
18
+ COPILOT_WINDOW = 28
19
+ WARN_DAYS = 14
20
+
21
+
22
+ def public(ws) -> bool:
23
+ """Unless the repo is known to be private, its data branch is readable by anyone: no titles, links or names."""
24
+ return not (ws.cache.get("repo_info") or {}).get("private")
25
+
26
+
27
+ def active(env: dict, cfg: dict) -> list[str]:
28
+ """The trackers and pager tools with a key in this job: one whose key was removed stops counting."""
29
+ return [name for name in TRACKERS if name in CONNECTORS and CONNECTORS[name].configured(env, cfg)]
30
+
31
+
32
+ def load(root: Path, redact: bool = False, names=TRACKERS) -> dict | None:
33
+ """Everything the named connectors pulled: {"sources", "issues", "changes", "sprints"}, or None before the first pull."""
34
+ out = {"sources": [], "issues": [], "changes": [], "sprints": []}
35
+ for name in (n for n in TRACKERS if n in names):
36
+ got = ConnectorStore(root, name, redact).load()
37
+ got = getattr(CONNECTORS.get(name), "finish", lambda g: g)(got)
38
+ if got["issue"] or got["sprint"]:
39
+ out["sources"].append(name)
40
+ out["issues"] += got["issue"]
41
+ out["changes"] += got["change"]
42
+ out["sprints"] += got["sprint"]
43
+ return out if out["sources"] else None
44
+
45
+
46
+ # What a record holds; a new field (2: an issue's epic or project, 3: its assignee, 4: its name) reads everything again once.
47
+ RECORDS = 4
48
+
49
+
50
+ def fingerprint(cfg: dict, name: str, redact: bool, records: int | None = None) -> str:
51
+ """What decides which records a connector keeps: its read settings (not the key's expiry or write-back), whether
52
+ titles are hidden, and what a record holds (records=0 leaves that out: what decides which answers are read)."""
53
+ kept = {k: v for k, v in cfg["connectors"][name].items() if k not in ("key_expires", "write_back", "done_transition", "done_state")}
54
+ shape = RECORDS if records is None else records
55
+ # ASKED counts what a connector asks its service for: kept answers from before a new field can't be rebuilt from.
56
+ asked = getattr(CONNECTORS.get(name), "ASKED", 1)
57
+ return hashlib.sha1(json.dumps([kept, redact, *([shape] if shape else []), *([asked] if asked > 1 else [])],
58
+ sort_keys=True).encode()).hexdigest()[:12]
59
+
60
+
61
+ # What the framework keeps in a connector's state across a rewind (connectors_step's own keys).
62
+ KEEP = ("scope", "reads", "raw_full")
63
+
64
+
65
+ def rewind(root: Path, name: str, since: str | None, now: datetime) -> str:
66
+ """Make a connector's next pull read again from `since` (a date; None or older than the window means the whole
67
+ window), keeping its records; a sentence saying what will happen."""
68
+ if name not in CONNECTORS:
69
+ raise ConnectorError(f"{name!r} has nothing to read again; use one of {', '.join(CONNECTORS)}.")
70
+ floor = (now - timedelta(days=BACKFILL_DAYS)).date()
71
+ day = date.fromisoformat(since) if since else None
72
+ if day and day > now.date():
73
+ raise ConnectorError(f"{since} is in the future; pick a past date to read {NAMES[name]} again from.")
74
+ store = ConnectorStore(root, name)
75
+ kept = {k: v for k, v in store.state().items() if k in KEEP}
76
+ whole = day is None or day <= floor or name in ("slack", "discord", "discourse")
77
+ if name == "copilot":
78
+ new = {} if whole else {"through": (day - timedelta(days=1)).isoformat()}
79
+ elif whole:
80
+ new = {}
81
+ else:
82
+ start = f"{day.isoformat()}T00:00:00Z"
83
+ new = {"since": start, **({"incident_since": start} if name == "jira" else {})}
84
+ store.save_state({**kept, **new})
85
+ return f"{NAMES[name]} reads again from {'180 days back' if whole else day.isoformat()}."
86
+
87
+
88
+ def reprocess(root: Path, name: str, day: str | None = None) -> int:
89
+ """Rebuild a connector's records from the answers it kept, with no call to the service; the records written."""
90
+ mod, store = CONNECTORS[name], ConnectorStore(root, name)
91
+ kept = list(store.raw())
92
+ if not kept or not hasattr(mod, "extract"):
93
+ raise ConnectorError(f"{NAMES[name]} kept no answers to rebuild from; it reads them again instead.")
94
+ store.clear_records()
95
+ day = day or date.today().isoformat()
96
+ written = 0
97
+ for entry in kept:
98
+ records = mod.extract(entry["k"], entry["c"], entry["b"])
99
+ store.add(records, day)
100
+ written += len(records)
101
+ return written
102
+
103
+
104
+ def copilot_summary(root: Path, now: datetime) -> dict | None:
105
+ """The last 28 days of the org's Copilot reports and this repo's: active users, suggestions accepted, Copilot's pull
106
+ requests; None before the first report."""
107
+ got = ConnectorStore(root, "copilot").load()
108
+ days = sorted(got.get("org_day") or [], key=lambda d: d["day"])
109
+ if not days:
110
+ return None
111
+ since = (now.date() - timedelta(days=COPILOT_WINDOW)).isoformat()
112
+ recent = [d for d in days if d["day"] >= since] or days[-1:]
113
+ repo = [d for d in got.get("repo_day") or [] if d["day"] >= since]
114
+ total = lambda rows, k: sum((r.get("pull_requests") or {}).get(k) or 0 for r in rows) # noqa: E731
115
+ gen = sum(d.get("code_generation_activity_count") or 0 for d in recent)
116
+ acc = sum(d.get("code_acceptance_activity_count") or 0 for d in recent)
117
+ latest = recent[-1]
118
+ return {"org": latest.get("org"), "days_kept": len(days), "first_day": days[0]["day"], "last_day": latest["day"],
119
+ "monthly_active_users": latest.get("monthly_active_users"), "monthly_active_agent_users": latest.get("monthly_active_agent_users"),
120
+ "acceptance": round(acc / gen, 4) if gen else None,
121
+ "org_prs_by_copilot": total(recent, "total_created_by_copilot"),
122
+ "org_prs_merged_by_copilot": total(recent, "total_merged_created_by_copilot"),
123
+ "repo_prs_by_copilot": total(repo, "total_created_by_copilot"),
124
+ "repo_prs_merged_by_copilot": total(repo, "total_merged_created_by_copilot"), "window_days": COPILOT_WINDOW}
125
+
126
+
127
+ def _write_back(ws, name: str, store, env: dict, cfg: dict, now: datetime, client=None) -> dict:
128
+ """Opt-in: comment on (and move) the Jira or Linear issues that newly merged pull requests name. The pairs are picked
129
+ and recorded before the data branch is saved and sent after it, so a retried push never sends them twice."""
130
+ import base64
131
+
132
+ import httpx
133
+
134
+ from stackvitals.connectors import jira, linear, writeback
135
+ from stackvitals.flow.store import FlowStore
136
+ c = cfg["connectors"][name]
137
+ prs = (ws.cache.get("flow") or FlowStore(ws.path).load()).get("closed") or []
138
+ pairs = writeback.plan(store, prs, now.strftime("%Y-%m-%dT%H:%M:%SZ"))
139
+ if not pairs:
140
+ return {"planned": 0}
141
+
142
+ def after_commit():
143
+ http = client or (httpx.Client(base_url=c["url"], timeout=30, follow_redirects=False) if name == "jira"
144
+ else httpx.Client(timeout=30))
145
+ try:
146
+ if name == "jira":
147
+ email = (env.get(jira.EMAIL_ENV) or "").strip() or c["email"]
148
+ auth = "Basic " + base64.b64encode(f"{email}:{env.get(jira.TOKEN_ENV, '').strip()}".encode()).decode()
149
+ got = writeback.send(pairs, ws.repo, lambda i, text: writeback.jira_comment(http, auth, i["key"], text),
150
+ lambda i: writeback.jira_move(http, auth, i["key"], c["done_transition"]))
151
+ else:
152
+ key = env.get(linear.TOKEN_ENV, "").strip()
153
+ got = writeback.send(pairs, ws.repo, lambda i, text: writeback.linear_comment(http, key, i["id"], text),
154
+ lambda i: writeback.linear_move(http, key, i["id"], i["key"], c["done_state"]))
155
+ finally:
156
+ if client is None:
157
+ http.close()
158
+ lines = [f"{NAMES[name]} write-back: {got['wrote']} comment(s), {got['moved']} issue(s) moved."]
159
+ if got["refused"]:
160
+ lines.append(f"Writing stopped ({got['refused']}); the rest of this run's {len(pairs)} aren't retried. "
161
+ "Give the key permission to comment, or set write_back = false.")
162
+ lines += [f"Not moved: {u}" for u in got["unmoved"][:3]]
163
+ summary = env.get("GITHUB_STEP_SUMMARY")
164
+ if summary:
165
+ with open(summary, "a", encoding="utf-8") as f:
166
+ f.write("\n\n".join(lines) + "\n")
167
+ ws.cache.setdefault("write_back", {})[name] = got
168
+ ws.after_commit(after_commit, f"{NAMES[name]} write-back")
169
+ return {"planned": len(pairs)}
170
+
171
+
172
+ def connectors_step(ws, env: dict, now: datetime, clients: dict | None = None) -> dict | None:
173
+ """Pull every connector with a key; a refused key or a missing setting is said on the Integrations page and in the job
174
+ summary, and what was pulled before stays."""
175
+ from stackvitals.core.config import parse
176
+ from stackvitals.core.store import published, write_if_changed
177
+ cfg = ws.cache.get("settings") or parse("")[0]
178
+ try:
179
+ old = json.loads((ws.path / "docs" / "connectors.json").read_text(encoding="utf-8")).get("connectors") or {}
180
+ except (OSError, ValueError):
181
+ old = {}
182
+ status, results = {}, {}
183
+ for name, mod in CONNECTORS.items():
184
+ if not mod.configured(env, cfg):
185
+ continue
186
+ store, before = ConnectorStore(ws.path, name, redact=public(ws)), old.get(name) or {}
187
+ scope, reads = fingerprint(cfg, name, public(ws)), fingerprint(cfg, name, public(ws), records=0)
188
+ state = store.state()
189
+ if state.get("scope", scope) != scope and state.get("reads") == reads and state.get("raw_full") \
190
+ and hasattr(mod, "extract"):
191
+ # Only what a record holds changed, and every answer was kept: rebuild from them instead of asking again.
192
+ reprocess(ws.path, name, now.date().isoformat())
193
+ store = ConnectorStore(ws.path, name, redact=public(ws))
194
+ ws.notes.append(f"{NAMES[name]}: what StackVitals keeps changed, so its records were rebuilt from the answers "
195
+ "it kept, with no new reads.")
196
+ elif state.get("scope", scope) != scope:
197
+ # Narrower settings must drop what they now leave out, wider ones read back; a repo gone public loses titles.
198
+ store.reset()
199
+ ws.notes.append(f"{NAMES[name]}: its settings, the repo's visibility or what StackVitals keeps changed, so it "
200
+ "reads everything again "
201
+ f"({BACKFILL_DAYS} days back).")
202
+ expires = cfg["connectors"][name].get("key_expires") or None
203
+ entry = {"name": NAMES[name], "as_of": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "key_expires": expires,
204
+ "scope": getattr(mod, "scope", lambda cfg: None)(cfg),
205
+ "expiring": bool(expires and date.fromisoformat(expires) - now.date() <= timedelta(days=WARN_DAYS)),
206
+ **({"check": before["check"]} if before.get("check") else {})} # the last Test connection result stays
207
+ # Every answer is kept from the first read on (private repos only), or a rebuild would miss the older ones.
208
+ full = not public(ws) and hasattr(mod, "extract") and (store.state().get("raw_full") or not store.days.is_dir())
209
+ try:
210
+ got = mod.pull(store, env, cfg, now, client=(clients or {}).get(name))
211
+ if cfg["connectors"][name].get("write_back"):
212
+ entry["write_back"] = _write_back(ws, name, store, env, cfg, now, (clients or {}).get(name))
213
+ elif store.state().get("write_since"):
214
+ store.save_state({k: v for k, v in store.state().items() if k != "write_since"})
215
+ store.save_state({**{k: v for k, v in store.state().items() if k != "raw_full"}, "scope": scope, "reads": reads,
216
+ **({"raw_full": True} if full else {})})
217
+ store.compress(now.date().isoformat())
218
+ rows = {k: len(v) for k, v in store.load().items()}
219
+ entry.update(error=None, rows=rows, complete=got["complete"])
220
+ results[name] = {"read": got["read"], "complete": got["complete"], "error": None}
221
+ if got.get("note"):
222
+ ws.notes.append(f"{NAMES[name]}: {got['note']}")
223
+ except Exception as exc: # a bug or an answer no one expected stops this connector, never the whole job
224
+ said = str(exc) if isinstance(exc, ConnectorError) else \
225
+ f"stopped on an unexpected {type(exc).__name__}; what was pulled before stays, and the next run tries again."
226
+ entry.update(error=said, rows=before.get("rows") or {}, complete=before.get("complete"))
227
+ results[name] = {"read": 0, "complete": False, "error": said}
228
+ ws.notes.append(f"{NAMES[name]}: {said}")
229
+ if entry["expiring"]:
230
+ ws.notes.append(f"{NAMES[name]}: the key expires on {expires}; create a new one and update the secret.")
231
+ status[name] = entry
232
+ if not status:
233
+ return None
234
+ # Tested services that aren't pulled (chat webhooks, the bucket, model triage) keep their last check.
235
+ status.update({n: {"name": e.get("name"), "check": e["check"]} for n, e in old.items()
236
+ if n not in CONNECTORS and isinstance(e, dict) and e.get("check")})
237
+ docs = ws.path / "docs"
238
+ docs.mkdir(parents=True, exist_ok=True)
239
+ write_if_changed(docs / "connectors.json", published({"connectors": status}))
240
+ ws.cache["tracker"] = load(ws.path, redact=public(ws), names=active(env, cfg))
241
+ return results
242
+
243
+
244
+ __all__ = ["CONNECTORS", "ConnectorError", "active", "connectors_step", "copilot_summary", "load", "reprocess"]
@@ -0,0 +1,190 @@
1
+ """AI spend: what the organisation paid Anthropic and OpenAI each day (their cost reports) and Cursor this billing cycle
2
+ (plus its daily agent requests and accepted lines), each read with one optional admin key, so Delivery can set the
3
+ money beside the AI-involved pull requests merged in the same weeks."""
4
+ import base64
5
+ from datetime import datetime, timedelta, timezone
6
+ from decimal import ROUND_HALF_UP, Decimal
7
+
8
+ import httpx
9
+
10
+ from stackvitals.connectors.base import ConnectorError, request
11
+ from stackvitals.core.time import iso, parse
12
+
13
+ NAME = "ai_spend"
14
+ KEYS = {"anthropic": "STACKVITALS_ANTHROPIC_ADMIN_KEY", "openai": "STACKVITALS_OPENAI_ADMIN_KEY",
15
+ "cursor": "STACKVITALS_CURSOR_ADMIN_KEY"}
16
+ TOKEN_ENV = KEYS["anthropic"] # the first one; check.py names all three
17
+ WINDOW = 90 # days read back on the first pull
18
+ REREAD = 2 # cost reports fill in late: the last two days are read again
19
+ CURSOR_DAYS = 30 # Cursor's daily usage takes at most 30 days per call
20
+ ANTHROPIC = "https://api.anthropic.com/v1/organizations/cost_report"
21
+ CLAUDE_CODE = "https://api.anthropic.com/v1/organizations/usage_report/claude_code"
22
+ OPENAI = "https://api.openai.com/v1/organization/costs"
23
+ CURSOR = "https://api.cursor.com"
24
+ PAGES = 10
25
+
26
+ __all__ = ["ConnectorError", "configured", "pull", "summary"]
27
+
28
+
29
+ def configured(env: dict, cfg: dict) -> bool:
30
+ return any((env.get(k) or "").strip() for k in KEYS.values())
31
+
32
+
33
+ def _usd(amount) -> float:
34
+ """Money to the cent, rounded half up as an invoice is (decimal, so $124.455 is $124.46, never $124.45)."""
35
+ return float(Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
36
+
37
+
38
+ def _day(ts) -> str:
39
+ return datetime.fromtimestamp(ts / (1000 if ts > 10**11 else 1), timezone.utc).date().isoformat()
40
+
41
+
42
+ def _anthropic(client, key: str, start: datetime, now: datetime) -> list[dict]:
43
+ out, page = [], None
44
+ for _ in range(PAGES):
45
+ body = request(client, "GET", ANTHROPIC, "Anthropic", refused="check the ANTHROPIC_ADMIN_KEY secret (an Admin API key)",
46
+ params={"starting_at": iso(start), "ending_at": iso(now), "bucket_width": "1d", "limit": 31,
47
+ **({"page": page} if page else {})},
48
+ headers={"x-api-key": key, "anthropic-version": "2023-06-01", "user-agent": "stackvitals"})
49
+ for b in body.get("data") or []:
50
+ # Amounts are cents as decimal strings ("123.45" is $1.23).
51
+ cents = sum((Decimal(str(r.get("amount") or 0)) for r in b.get("results") or []), Decimal(0))
52
+ out.append({"t": "day", "id": f"anthropic:{b['starting_at'][:10]}", "src": "anthropic",
53
+ "day": b["starting_at"][:10], "usd": _usd(cents / 100)})
54
+ page = body.get("next_page")
55
+ if not body.get("has_more") or not page:
56
+ break
57
+ return out
58
+
59
+
60
+ def _openai(client, key: str, start: datetime, now: datetime) -> list[dict]:
61
+ out, page = [], None
62
+ for _ in range(PAGES):
63
+ body = request(client, "GET", OPENAI, "OpenAI", refused="check the OPENAI_ADMIN_KEY secret (an admin key)",
64
+ params={"start_time": int(start.timestamp()), "end_time": int(now.timestamp()), "bucket_width": "1d",
65
+ "limit": 31, **({"page": page} if page else {})},
66
+ headers={"Authorization": f"Bearer {key}"})
67
+ for b in body.get("data") or []:
68
+ usd = sum((Decimal(str((r.get("amount") or {}).get("value") or 0)) for r in b.get("results") or []), Decimal(0))
69
+ day = _day(b["start_time"])
70
+ out.append({"t": "day", "id": f"openai:{day}", "src": "openai", "day": day, "usd": _usd(usd)})
71
+ page = body.get("next_page")
72
+ if not body.get("has_more") or not page:
73
+ break
74
+ return out
75
+
76
+
77
+ def _claude_code(client, key: str, start: datetime, now: datetime) -> list[dict]:
78
+ """Claude Code's daily analytics for the organisation (one call per day): sessions, lines, commits, PRs and tool
79
+ proposals accepted; its estimated cost is already in Anthropic's cost report, so it is kept apart, never added."""
80
+ out, day = [], start.date()
81
+ while day < now.date():
82
+ totals = {"sessions": 0, "lines_added": 0, "lines_removed": 0, "commits": 0, "prs": 0, "accepted": 0, "rejected": 0,
83
+ "users": 0, "est_cents": Decimal(0)}
84
+ page = None
85
+ for _ in range(PAGES):
86
+ body = request(client, "GET", CLAUDE_CODE, "Anthropic", refused="check the ANTHROPIC_ADMIN_KEY secret (an Admin API key)",
87
+ params={"starting_at": day.isoformat(), "limit": 1000, **({"page": page} if page else {})},
88
+ headers={"x-api-key": key, "anthropic-version": "2023-06-01", "user-agent": "stackvitals"})
89
+ for r in body.get("data") or []:
90
+ core = r.get("core_metrics") or {}
91
+ totals["users"] += 1
92
+ totals["sessions"] += core.get("num_sessions") or 0
93
+ totals["lines_added"] += (core.get("lines_of_code") or {}).get("added") or 0
94
+ totals["lines_removed"] += (core.get("lines_of_code") or {}).get("removed") or 0
95
+ totals["commits"] += core.get("commits_by_claude_code") or 0
96
+ totals["prs"] += core.get("pull_requests_by_claude_code") or 0
97
+ for tool in (r.get("tool_actions") or {}).values():
98
+ totals["accepted"] += (tool or {}).get("accepted") or 0
99
+ totals["rejected"] += (tool or {}).get("rejected") or 0
100
+ totals["est_cents"] += sum((Decimal(str((m.get("estimated_cost") or {}).get("amount") or 0))
101
+ for m in r.get("model_breakdown") or []), Decimal(0))
102
+ page = body.get("next_page")
103
+ if not body.get("has_more") or not page:
104
+ break
105
+ if totals["users"]:
106
+ cents = totals.pop("est_cents")
107
+ out.append({"t": "day", "id": f"claude_code:{day.isoformat()}", "src": "claude_code", "day": day.isoformat(),
108
+ **totals, "est_usd": _usd(cents / 100)})
109
+ day += timedelta(days=1)
110
+ return out
111
+
112
+
113
+ def _cursor(client, key: str, now: datetime) -> list[dict]:
114
+ auth = {"Authorization": "Basic " + base64.b64encode(f"{key}:".encode()).decode()}
115
+ refused = "check the CURSOR_ADMIN_KEY secret (a Cursor admin API key)"
116
+ spend = request(client, "POST", f"{CURSOR}/teams/spend", "Cursor", refused=refused, json={}, headers=auth)
117
+ members = spend.get("teamMemberSpend") or []
118
+ start = _day(spend["subscriptionCycleStart"]) if spend.get("subscriptionCycleStart") else None
119
+ out = [{"t": "cycle", "id": f"cursor:{start}", "src": "cursor", "start": start, "members": len(members),
120
+ # All of the cycle's usage, included and on-demand, in dollars.
121
+ "usd": _usd(Decimal(sum(m.get("overallSpendCents") or m.get("spendCents") or 0 for m in members)) / 100)}] if start else []
122
+ end = int(now.timestamp() * 1000)
123
+ use = request(client, "POST", f"{CURSOR}/teams/daily-usage-data", "Cursor", refused=refused, headers=auth,
124
+ json={"startDate": end - CURSOR_DAYS * 86_400_000, "endDate": end})
125
+ days = {}
126
+ for r in use.get("data") or []:
127
+ d = days.setdefault(_day(r["date"]), {"agent_requests": 0, "accepted_lines": 0})
128
+ d["agent_requests"] += r.get("agentRequests") or 0
129
+ d["accepted_lines"] += r.get("acceptedLinesAdded") or 0
130
+ out += [{"t": "day", "id": f"cursor:{day}", "src": "cursor", "day": day, **v} for day, v in sorted(days.items())]
131
+ return out
132
+
133
+
134
+ def pull(store, env: dict, cfg: dict, now: datetime, client: httpx.Client | None = None) -> dict:
135
+ state = store.state()
136
+ floor = now - timedelta(days=WINDOW)
137
+ start = max(parse(state["since"]) - timedelta(days=REREAD), floor) if state.get("since") else floor
138
+ start = start.replace(hour=0, minute=0, second=0, microsecond=0)
139
+ own = client is None
140
+ client = client or httpx.Client(timeout=30)
141
+ read, day = 0, now.date().isoformat()
142
+ try:
143
+ for name, fetch in (("anthropic", lambda k: _anthropic(client, k, start, now) + _claude_code(client, k, start, now)),
144
+ ("openai", lambda k: _openai(client, k, start, now)),
145
+ ("cursor", lambda k: _cursor(client, k, now))):
146
+ key = (env.get(KEYS[name]) or "").strip()
147
+ if key:
148
+ rows = fetch(key)
149
+ store.add(rows, day)
150
+ read += len(rows)
151
+ store.save_state({"since": iso(now)})
152
+ return {"read": read, "complete": True}
153
+ finally:
154
+ if own:
155
+ client.close()
156
+
157
+
158
+ def summary(root, now: datetime, ai_prs: int, window_days: int = 30) -> dict | None:
159
+ """Spend per provider over the last `window_days` (Cursor: its current billing cycle), the total, and the total over
160
+ the AI-involved pull requests merged in the same days; None before the first pull."""
161
+ from stackvitals.connectors.store import ConnectorStore
162
+ got = ConnectorStore(root, NAME).load()
163
+ if not got.get("day") and not got.get("cycle"):
164
+ return None
165
+ since = (now - timedelta(days=window_days)).date().isoformat()
166
+ providers = {}
167
+ for r in got.get("day") or []:
168
+ if r.get("usd") is not None and r["day"] >= since:
169
+ providers[r["src"]] = _usd(Decimal(str(providers.get(r["src"], 0))) + Decimal(str(r["usd"])))
170
+ cycles = sorted((r for r in got.get("cycle") or [] if r.get("start")), key=lambda r: r["start"])
171
+ if cycles and cycles[-1]["start"] >= since:
172
+ providers[cycles[-1]["src"]] = cycles[-1]["usd"]
173
+ total = _usd(sum((Decimal(str(v)) for v in providers.values()), Decimal(0)))
174
+ use = [r for r in got.get("day") or [] if r["src"] == "cursor" and r["day"] >= since]
175
+ cc = [r for r in got.get("day") or [] if r["src"] == "claude_code" and r["day"] >= since]
176
+ # The coding tools side by side over the same days: what each was used for, next to the money.
177
+ tools = {}
178
+ if cc:
179
+ acted = sum(r.get("accepted", 0) + r.get("rejected", 0) for r in cc)
180
+ tools["claude_code"] = {"sessions": sum(r.get("sessions", 0) for r in cc), "lines_added": sum(r.get("lines_added", 0) for r in cc),
181
+ "commits": sum(r.get("commits", 0) for r in cc), "prs": sum(r.get("prs", 0) for r in cc),
182
+ "acceptance": round(sum(r.get("accepted", 0) for r in cc) / acted, 4) if acted else None,
183
+ "est_usd": _usd(sum((Decimal(str(r.get("est_usd") or 0)) for r in cc), Decimal(0)))}
184
+ if use:
185
+ tools["cursor"] = {"agent_requests": sum(r.get("agent_requests") or 0 for r in use),
186
+ "lines_added": sum(r.get("accepted_lines") or 0 for r in use)}
187
+ return {"window_days": window_days, "providers": providers, "total_usd": total, "ai_prs": ai_prs,
188
+ "per_ai_pr_usd": _usd(Decimal(str(total)) / ai_prs) if ai_prs else None,
189
+ "cursor_agent_requests": sum(r.get("agent_requests") or 0 for r in use) if use else None,
190
+ **({"tools": tools} if tools else {})}
@@ -0,0 +1,82 @@
1
+ """What every connector shares: one way to ask a service (retries, rate limits, plain errors), its error, dates in UTC, and
2
+ DevLake's issue types and status categories."""
3
+ import time
4
+ from datetime import datetime, timezone
5
+
6
+ from stackvitals.core.time import iso
7
+
8
+ BACKFILL_DAYS = 180
9
+ PAGES = 20 # the most pages one run reads per connector; the next run carries on
10
+ TRIES = 3
11
+ LONGEST_WAIT = 60 # a rate limit asking for longer stops this run; the next one carries on
12
+
13
+
14
+ class ConnectorError(Exception):
15
+ """A plain sentence for the job summary and the Integrations page; never holds a key."""
16
+
17
+
18
+ def json_of(resp, service: str, kinds: tuple = (dict,)):
19
+ """The answer's JSON; a page that is not (a maintenance or login page) is said plainly instead of failing the job."""
20
+ try:
21
+ out = resp.json()
22
+ except ValueError:
23
+ out = None
24
+ if not isinstance(out, kinds):
25
+ raise ConnectorError(f"{service} answered with a page that is not JSON ({resp.status_code}); the next run tries again.")
26
+ return out
27
+
28
+
29
+ def request(client, method: str, url: str, service: str, *, refused: str, bad=None, kinds: tuple = (dict,),
30
+ sleep=time.sleep, **kw):
31
+ """The JSON a service answers. A dropped connection or a server error is tried again, a short rate-limit wait is waited
32
+ out, and anything else is a plain ConnectorError: `refused` says what to check on 401/403, `bad(resp)` may word others."""
33
+ import httpx
34
+ for attempt in range(TRIES):
35
+ last = attempt == TRIES - 1
36
+ try:
37
+ resp = client.request(method, url, **kw)
38
+ except httpx.HTTPError as exc:
39
+ if last:
40
+ raise ConnectorError(f"{service} could not be reached ({type(exc).__name__}); the next run tries again.") from None
41
+ sleep(2 * (attempt + 1))
42
+ continue
43
+ if resp.status_code == 429:
44
+ try:
45
+ wait = float(resp.headers.get("retry-after", 5))
46
+ except ValueError:
47
+ wait = 5.0
48
+ if last or wait > LONGEST_WAIT:
49
+ raise ConnectorError(f"{service}'s rate limit was reached; the next run carries on.")
50
+ sleep(wait)
51
+ continue
52
+ if resp.status_code >= 500 and not last:
53
+ sleep(2 * (attempt + 1))
54
+ continue
55
+ if resp.status_code in (401, 403):
56
+ raise ConnectorError(f"{service} refused the key ({resp.status_code}): {refused}.")
57
+ if resp.status_code != 200:
58
+ said = bad(resp) if bad else None
59
+ raise ConnectorError(said or f"{service} answered {resp.status_code}; the next run tries again.")
60
+ return json_of(resp, service, kinds)
61
+
62
+
63
+ def utc(text: str | None) -> str | None:
64
+ """Any ISO time (Jira's +0200 offsets, Linear's milliseconds) as UTC with a Z."""
65
+ if not text:
66
+ return None
67
+ try:
68
+ return iso(datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc))
69
+ except ValueError:
70
+ return None
71
+
72
+
73
+ def std_type(name: str | None) -> str:
74
+ """DevLake's standard issue types from a tracker's own type or label names."""
75
+ low = (name or "").lower()
76
+ if "incident" in low:
77
+ return "INCIDENT"
78
+ if "bug" in low or "defect" in low:
79
+ return "BUG"
80
+ if any(w in low for w in ("story", "feature", "epic", "requirement", "improvement")):
81
+ return "REQUIREMENT"
82
+ return "TASK"