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.
- mergescout/__init__.py +1 -0
- mergescout/archive/__init__.py +0 -0
- mergescout/archive/__main__.py +30 -0
- mergescout/archive/convert.py +83 -0
- mergescout/archive/events.py +55 -0
- mergescout/archive/manifest.py +45 -0
- mergescout/archive/stream.py +83 -0
- mergescout/bench/__init__.py +1 -0
- mergescout/bench/__main__.py +135 -0
- mergescout/bench/ci.py +257 -0
- mergescout/bench/deploys.py +115 -0
- mergescout/bench/matched.py +22 -0
- mergescout/bench/prior.py +42 -0
- mergescout/bench/report.py +230 -0
- mergescout/bench/shared.py +112 -0
- mergescout/bench/stars.py +57 -0
- mergescout/cli.py +89 -0
- mergescout/config.py +35 -0
- mergescout/crawl/__init__.py +0 -0
- mergescout/crawl/__main__.py +97 -0
- mergescout/crawl/chain.py +34 -0
- mergescout/crawl/fetch.py +146 -0
- mergescout/crawl/outcomes.py +69 -0
- mergescout/crawl/queries.py +250 -0
- mergescout/crawl/runner.py +176 -0
- mergescout/crawl/seed.py +77 -0
- mergescout/crawl/state.py +169 -0
- mergescout/crawl/writer.py +87 -0
- mergescout/discover/__init__.py +0 -0
- mergescout/discover/__main__.py +58 -0
- mergescout/discover/sweep.py +286 -0
- mergescout/github/__init__.py +0 -0
- mergescout/github/graphql.py +202 -0
- mergescout/github/identities.py +35 -0
- mergescout/github/releases.py +137 -0
- mergescout/labels.py +20 -0
- mergescout/mcp_server.py +228 -0
- mergescout/measure/__init__.py +0 -0
- mergescout/measure/__main__.py +75 -0
- mergescout/measure/api_cost.py +91 -0
- mergescout/measure/count_repos.py +30 -0
- mergescout/measure/jsonl.py +14 -0
- mergescout/measure/report.py +56 -0
- mergescout/measure/sample.py +37 -0
- mergescout/owners.py +22 -0
- mergescout/setup_check.py +64 -0
- mergescout/signals/__init__.py +0 -0
- mergescout/signals/__main__.py +96 -0
- mergescout/signals/bump_ledger.py +258 -0
- mergescout/signals/bumps.py +104 -0
- mergescout/signals/check.py +141 -0
- mergescout/signals/community_bench.py +149 -0
- mergescout/signals/delivery_bench.py +103 -0
- mergescout/signals/deps_map.py +261 -0
- mergescout/signals/deps_trends.py +117 -0
- mergescout/signals/evidence.py +94 -0
- mergescout/signals/exploits.py +43 -0
- mergescout/signals/flow_bench.py +258 -0
- mergescout/signals/identity.py +73 -0
- mergescout/signals/issues.py +76 -0
- mergescout/signals/labels.py +79 -0
- mergescout/signals/osv.py +232 -0
- mergescout/signals/owners_watch.py +80 -0
- mergescout/signals/repo_stats.py +96 -0
- mergescout/signals/rules.py +70 -0
- mergescout/signals/silver.py +33 -0
- mergescout/signals/top_packages.py +26 -0
- mergescout/signals/triage.py +71 -0
- mergescout/signals/triage_math.py +120 -0
- mergescout/signals/walls.py +91 -0
- mergescout/snapshot/__init__.py +0 -0
- mergescout/snapshot/__main__.py +215 -0
- mergescout/snapshot/build.py +147 -0
- mergescout/snapshot/publish.py +70 -0
- mergescout/snapshot/schema.py +34 -0
- mergescout/snapshot/score.py +30 -0
- mergescout/store/__init__.py +0 -0
- mergescout/store/__main__.py +46 -0
- mergescout/store/sync.py +148 -0
- mergescout/timeutil.py +11 -0
- mergescout/tool/__init__.py +0 -0
- mergescout/tool/action_pack.py +62 -0
- mergescout/tool/live.py +201 -0
- mergescout/tool/queries.py +293 -0
- mergescout/tool/refs.py +31 -0
- mergescout/tool/store.py +129 -0
- mergescout/tool/tracking.py +83 -0
- stackvitals/__init__.py +2 -0
- stackvitals/__main__.py +4 -0
- stackvitals/ci/__init__.py +1 -0
- stackvitals/ci/action.py +574 -0
- stackvitals/ci/analyze.py +577 -0
- stackvitals/ci/api.py +218 -0
- stackvitals/ci/automation.py +212 -0
- stackvitals/ci/bench.py +195 -0
- stackvitals/ci/burnin.py +102 -0
- stackvitals/ci/cli.py +570 -0
- stackvitals/ci/collect.py +239 -0
- stackvitals/ci/cost.py +107 -0
- stackvitals/ci/coverage.py +155 -0
- stackvitals/ci/feedback.py +699 -0
- stackvitals/ci/fingerprint.py +220 -0
- stackvitals/ci/gitlab.py +121 -0
- stackvitals/ci/gitlab_action.py +125 -0
- stackvitals/ci/impact.py +125 -0
- stackvitals/ci/investigate.py +59 -0
- stackvitals/ci/kinds.py +52 -0
- stackvitals/ci/monitors.py +105 -0
- stackvitals/ci/owners.py +116 -0
- stackvitals/ci/quarantine_runtime.py +90 -0
- stackvitals/ci/report.py +435 -0
- stackvitals/ci/retry.py +67 -0
- stackvitals/ci/selfheal.py +102 -0
- stackvitals/ci/shards.py +120 -0
- stackvitals/ci/share.py +59 -0
- stackvitals/ci/store.py +93 -0
- stackvitals/ci/testreports.py +351 -0
- stackvitals/ci/tickets.py +106 -0
- stackvitals/ci/track_record.json +11 -0
- stackvitals/ci/triage.py +130 -0
- stackvitals/ci/triggers.py +147 -0
- stackvitals/ci/upload.py +85 -0
- stackvitals/ci/validate.py +91 -0
- stackvitals/cli.py +111 -0
- stackvitals/codeowners.py +69 -0
- stackvitals/community/__init__.py +0 -0
- stackvitals/community/action.py +140 -0
- stackvitals/community/affiliation.py +51 -0
- stackvitals/community/analyze.py +507 -0
- stackvitals/community/build.py +56 -0
- stackvitals/community/chaoss.py +70 -0
- stackvitals/community/cli.py +79 -0
- stackvitals/community/collect.py +240 -0
- stackvitals/community/context.py +54 -0
- stackvitals/community/report.py +250 -0
- stackvitals/connectors/__init__.py +244 -0
- stackvitals/connectors/ai_spend.py +190 -0
- stackvitals/connectors/base.py +82 -0
- stackvitals/connectors/check.py +686 -0
- stackvitals/connectors/copilot.py +104 -0
- stackvitals/connectors/discord.py +103 -0
- stackvitals/connectors/discourse.py +117 -0
- stackvitals/connectors/incident_io.py +106 -0
- stackvitals/connectors/jira.py +210 -0
- stackvitals/connectors/linear.py +140 -0
- stackvitals/connectors/pagerduty.py +96 -0
- stackvitals/connectors/slack.py +110 -0
- stackvitals/connectors/sonar.py +77 -0
- stackvitals/connectors/store.py +120 -0
- stackvitals/connectors/tracker.py +108 -0
- stackvitals/connectors/writeback.py +105 -0
- stackvitals/contribute/__init__.py +1 -0
- stackvitals/contribute/brief.py +297 -0
- stackvitals/contribute/cli.py +64 -0
- stackvitals/core/__init__.py +1 -0
- stackvitals/core/actors.py +103 -0
- stackvitals/core/agent_ledger.py +120 -0
- stackvitals/core/agreements.py +95 -0
- stackvitals/core/ai_outcomes.py +276 -0
- stackvitals/core/alerts.py +224 -0
- stackvitals/core/appdata.py +112 -0
- stackvitals/core/bucket.py +190 -0
- stackvitals/core/bugs.py +43 -0
- stackvitals/core/calendar.py +34 -0
- stackvitals/core/capex.py +57 -0
- stackvitals/core/change_risk.py +181 -0
- stackvitals/core/changes.py +133 -0
- stackvitals/core/chat.py +143 -0
- stackvitals/core/config.py +373 -0
- stackvitals/core/dataflow.py +168 -0
- stackvitals/core/decisions.py +275 -0
- stackvitals/core/delivery.py +954 -0
- stackvitals/core/delivery_peers.py +98 -0
- stackvitals/core/delivery_report.py +429 -0
- stackvitals/core/deliverycli.py +147 -0
- stackvitals/core/devlake_dora.sql +485 -0
- stackvitals/core/digest.py +333 -0
- stackvitals/core/explaincli.py +107 -0
- stackvitals/core/explore.py +296 -0
- stackvitals/core/export.py +342 -0
- stackvitals/core/exportcli.py +117 -0
- stackvitals/core/findings.py +350 -0
- stackvitals/core/health.py +94 -0
- stackvitals/core/identity.py +82 -0
- stackvitals/core/insights.py +365 -0
- stackvitals/core/insightscli.py +43 -0
- stackvitals/core/installdoc.py +69 -0
- stackvitals/core/investment.py +187 -0
- stackvitals/core/joins.py +43 -0
- stackvitals/core/llmsdoc.py +40 -0
- stackvitals/core/merge_queue.py +70 -0
- stackvitals/core/metrics.py +109 -0
- stackvitals/core/model.py +285 -0
- stackvitals/core/projectcli.py +66 -0
- stackvitals/core/propose.py +86 -0
- stackvitals/core/pulse.py +85 -0
- stackvitals/core/recorded.py +145 -0
- stackvitals/core/releases.py +39 -0
- stackvitals/core/requests.py +528 -0
- stackvitals/core/resync.py +35 -0
- stackvitals/core/schema.py +205 -0
- stackvitals/core/services.py +203 -0
- stackvitals/core/settings.py +96 -0
- stackvitals/core/sqlcli.py +111 -0
- stackvitals/core/stats.py +222 -0
- stackvitals/core/store.py +125 -0
- stackvitals/core/summary.py +104 -0
- stackvitals/core/time.py +29 -0
- stackvitals/core/tomledit.py +149 -0
- stackvitals/core/uicli.py +138 -0
- stackvitals/core/webapp.py +79 -0
- stackvitals/core/what_changed.py +76 -0
- stackvitals/core/workspace.py +194 -0
- stackvitals/deps/__init__.py +1 -0
- stackvitals/deps/action.py +299 -0
- stackvitals/deps/actions_vulns.py +89 -0
- stackvitals/deps/cli.py +77 -0
- stackvitals/deps/dossier.py +341 -0
- stackvitals/deps/evidence.py +44 -0
- stackvitals/deps/fixplan.py +40 -0
- stackvitals/deps/guarddog.py +32 -0
- stackvitals/deps/hygiene.py +47 -0
- stackvitals/deps/imports.py +113 -0
- stackvitals/deps/lockfiles.py +198 -0
- stackvitals/deps/lookalike.py +80 -0
- stackvitals/deps/reach.py +59 -0
- stackvitals/deps/release.py +110 -0
- stackvitals/deps/runtimes.py +161 -0
- stackvitals/deps/supply.py +454 -0
- stackvitals/deps/vulns.py +588 -0
- stackvitals/editors/__init__.py +40 -0
- stackvitals/editors/vscode/extension.js +101 -0
- stackvitals/editors/vscode/marks.js +92 -0
- stackvitals/editors/vscode/package.json +26 -0
- stackvitals/flow/__init__.py +1 -0
- stackvitals/flow/action.py +178 -0
- stackvitals/flow/analyze.py +418 -0
- stackvitals/flow/automate.py +72 -0
- stackvitals/flow/build.py +46 -0
- stackvitals/flow/cli.py +93 -0
- stackvitals/flow/code_hosts.py +146 -0
- stackvitals/flow/collect.py +451 -0
- stackvitals/flow/gitlab_mr.py +75 -0
- stackvitals/flow/knowledge.py +202 -0
- stackvitals/flow/peers.py +90 -0
- stackvitals/flow/preflight.py +210 -0
- stackvitals/flow/report.py +253 -0
- stackvitals/flow/rules.py +135 -0
- stackvitals/flow/store.py +108 -0
- stackvitals/flow/survival.py +84 -0
- stackvitals/flow/triage.py +62 -0
- stackvitals/mcp_server.py +670 -0
- stackvitals/portal/__init__.py +0 -0
- stackvitals/portal/action.py +205 -0
- stackvitals/portal/batch.py +80 -0
- stackvitals/portal/campaigns.py +104 -0
- stackvitals/portal/catalog.py +104 -0
- stackvitals/portal/checks.py +63 -0
- stackvitals/portal/cli.py +172 -0
- stackvitals/portal/descriptor.py +105 -0
- stackvitals/portal/export.py +43 -0
- stackvitals/portal/github.py +48 -0
- stackvitals/portal/golden.py +95 -0
- stackvitals/portal/history.py +30 -0
- stackvitals/portal/inventory.py +264 -0
- stackvitals/portal/owners.py +78 -0
- stackvitals/portal/pages.py +153 -0
- stackvitals/portal/rollup.py +32 -0
- stackvitals/portal/scorecard.py +366 -0
- stackvitals/security/__init__.py +2 -0
- stackvitals/security/probes.py +154 -0
- stackvitals/security/repo_checks.py +132 -0
- stackvitals/security/workflows.py +111 -0
- stackvitals/shell.py +107 -0
- stackvitals/site.py +51 -0
- stackvitals/web/app/LICENSES.md +428 -0
- stackvitals/web/app/boot.js +1 -0
- stackvitals/web/app/chunks/AddRepo-KRPZXWCY.js +1 -0
- stackvitals/web/app/chunks/SetupRunning-XWFQAKRZ.js +1 -0
- stackvitals/web/app/chunks/accuracy-data-OQTDXI7Y.js +1 -0
- stackvitals/web/app/chunks/agents-data-BAL6QAOF.js +11 -0
- stackvitals/web/app/chunks/chunk-24LFKFZ6.js +1 -0
- stackvitals/web/app/chunks/chunk-3G4SQNUC.js +1 -0
- stackvitals/web/app/chunks/chunk-3IXWEOIJ.js +154 -0
- stackvitals/web/app/chunks/chunk-3ROQFHPV.js +1 -0
- stackvitals/web/app/chunks/chunk-45PGAQRO.js +8 -0
- stackvitals/web/app/chunks/chunk-4H53OYLG.js +1 -0
- stackvitals/web/app/chunks/chunk-4LUI7O5P.js +1 -0
- stackvitals/web/app/chunks/chunk-4SQPQ5TB.js +1 -0
- stackvitals/web/app/chunks/chunk-4WKQTWUC.js +2 -0
- stackvitals/web/app/chunks/chunk-576YHDEK.js +8 -0
- stackvitals/web/app/chunks/chunk-6BDBXA6V.js +1 -0
- stackvitals/web/app/chunks/chunk-6RLVD2DT.js +1 -0
- stackvitals/web/app/chunks/chunk-763NH7PP.js +1 -0
- stackvitals/web/app/chunks/chunk-76WAVKUG.js +1 -0
- stackvitals/web/app/chunks/chunk-7II2BD5F.js +1 -0
- stackvitals/web/app/chunks/chunk-7TJCBOAY.js +1 -0
- stackvitals/web/app/chunks/chunk-ASYNLVOP.js +1 -0
- stackvitals/web/app/chunks/chunk-CTKDYZMG.js +1 -0
- stackvitals/web/app/chunks/chunk-CVB26NMC.js +1 -0
- stackvitals/web/app/chunks/chunk-G7NGB5UX.js +1 -0
- stackvitals/web/app/chunks/chunk-GPVLJRZD.js +1 -0
- stackvitals/web/app/chunks/chunk-GQYFN4PE.js +75 -0
- stackvitals/web/app/chunks/chunk-GT4EAKAO.js +2 -0
- stackvitals/web/app/chunks/chunk-IYMBFPCV.js +1 -0
- stackvitals/web/app/chunks/chunk-LAAGCPU3.js +1 -0
- stackvitals/web/app/chunks/chunk-MAPYGK3P.js +1 -0
- stackvitals/web/app/chunks/chunk-P5TXDD45.js +1 -0
- stackvitals/web/app/chunks/chunk-PABICGBL.js +1 -0
- stackvitals/web/app/chunks/chunk-RPXHAG6F.js +1 -0
- stackvitals/web/app/chunks/chunk-RQZPSJ2Y.js +1 -0
- stackvitals/web/app/chunks/chunk-S2ZBMEVA.js +1 -0
- stackvitals/web/app/chunks/chunk-S654BBEP.js +6 -0
- stackvitals/web/app/chunks/chunk-T7QVTNXF.js +1 -0
- stackvitals/web/app/chunks/chunk-THFUOCPC.js +273 -0
- stackvitals/web/app/chunks/chunk-TO4VVRZ7.js +1 -0
- stackvitals/web/app/chunks/chunk-VQ5YXO54.js +1 -0
- stackvitals/web/app/chunks/chunk-VWKSMFWJ.js +1 -0
- stackvitals/web/app/chunks/chunk-Y6LUXDM6.js +1 -0
- stackvitals/web/app/chunks/chunk-YTANGYYH.js +1 -0
- stackvitals/web/app/chunks/connect-KONYWYTT.js +1 -0
- stackvitals/web/app/chunks/connections-data-2DYS4UVW.js +32 -0
- stackvitals/web/app/chunks/logos-N5IINUAM.js +1 -0
- stackvitals/web/app/chunks/more-KMJMJQD3.js +10 -0
- stackvitals/web/app/chunks/more-PC6FVS3P.js +1 -0
- stackvitals/web/app/chunks/more-RCIVCUDE.js +2 -0
- stackvitals/web/app/chunks/more-ZKKRY6NO.css +1 -0
- stackvitals/web/app/chunks/sealbox-35YL4EUH.js +3 -0
- stackvitals/web/app/chunks/session-DYYTOE2K.js +1 -0
- stackvitals/web/app/chunks/settingsApply-2HRJEZYM.js +1 -0
- stackvitals/web/app/chunks/settingsReview-GX2FZQ4M.js +3 -0
- stackvitals/web/app/chunks/uPlot.esm-OIWT3AHJ.js +3 -0
- stackvitals/web/app/demo/ci/impact.json +1 -0
- stackvitals/web/app/demo/ci/quarantine.json +1 -0
- stackvitals/web/app/demo/ci/tickets.json +1 -0
- stackvitals/web/app/demo/mobile/alerts.json +1 -0
- stackvitals/web/app/demo/mobile/app.json +1 -0
- stackvitals/web/app/demo/mobile/community.json +1 -0
- stackvitals/web/app/demo/mobile/connectors.json +1 -0
- stackvitals/web/app/demo/mobile/data.json +1 -0
- stackvitals/web/app/demo/mobile/decisions.json +1 -0
- stackvitals/web/app/demo/mobile/delivery.json +1 -0
- stackvitals/web/app/demo/mobile/deps.json +1 -0
- stackvitals/web/app/demo/mobile/flow.json +1 -0
- stackvitals/web/app/demo/mobile/health.json +1 -0
- stackvitals/web/app/demo/mobile/insights.json +1 -0
- stackvitals/web/app/demo/mobile/pulse.json +1 -0
- stackvitals/web/app/demo/mobile/security.json +1 -0
- stackvitals/web/app/demo/mobile/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/mobile/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/mobile/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/mobile/summary/manifest.json +1 -0
- stackvitals/web/app/demo/mobile/summary/prs.jsonl +19 -0
- stackvitals/web/app/demo/mobile/summary/tests.jsonl +1 -0
- stackvitals/web/app/demo/mobile/supply.json +1 -0
- stackvitals/web/app/demo/mobile/vulns.json +1 -0
- stackvitals/web/app/demo/payments-api/alerts.json +1 -0
- stackvitals/web/app/demo/payments-api/app.json +1 -0
- stackvitals/web/app/demo/payments-api/community.json +1 -0
- stackvitals/web/app/demo/payments-api/connectors.json +1 -0
- stackvitals/web/app/demo/payments-api/data.json +1 -0
- stackvitals/web/app/demo/payments-api/decisions.json +1 -0
- stackvitals/web/app/demo/payments-api/delivery.json +1 -0
- stackvitals/web/app/demo/payments-api/deps.json +1 -0
- stackvitals/web/app/demo/payments-api/flow.json +1 -0
- stackvitals/web/app/demo/payments-api/health.json +1 -0
- stackvitals/web/app/demo/payments-api/insights.json +1 -0
- stackvitals/web/app/demo/payments-api/pulse.json +1 -0
- stackvitals/web/app/demo/payments-api/security.json +1 -0
- stackvitals/web/app/demo/payments-api/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/payments-api/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/payments-api/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/payments-api/summary/manifest.json +1 -0
- stackvitals/web/app/demo/payments-api/summary/prs.jsonl +22 -0
- stackvitals/web/app/demo/payments-api/summary/tests.jsonl +3 -0
- stackvitals/web/app/demo/payments-api/supply.json +1 -0
- stackvitals/web/app/demo/payments-api/vulns.json +1 -0
- stackvitals/web/app/demo/portal.json +1 -0
- stackvitals/web/app/demo/settings.json +1 -0
- stackvitals/web/app/demo/web-app/alerts.json +1 -0
- stackvitals/web/app/demo/web-app/app.json +1 -0
- stackvitals/web/app/demo/web-app/community.json +1 -0
- stackvitals/web/app/demo/web-app/connectors.json +1 -0
- stackvitals/web/app/demo/web-app/data.json +1 -0
- stackvitals/web/app/demo/web-app/decisions.json +1 -0
- stackvitals/web/app/demo/web-app/delivery.json +1 -0
- stackvitals/web/app/demo/web-app/deps.json +1 -0
- stackvitals/web/app/demo/web-app/flow.json +1 -0
- stackvitals/web/app/demo/web-app/health.json +1 -0
- stackvitals/web/app/demo/web-app/insights.json +1 -0
- stackvitals/web/app/demo/web-app/pulse.json +1 -0
- stackvitals/web/app/demo/web-app/security.json +1 -0
- stackvitals/web/app/demo/web-app/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/web-app/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/web-app/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/web-app/summary/manifest.json +1 -0
- stackvitals/web/app/demo/web-app/summary/prs.jsonl +18 -0
- stackvitals/web/app/demo/web-app/summary/tests.jsonl +2 -0
- stackvitals/web/app/demo/web-app/supply.json +1 -0
- stackvitals/web/app/demo/web-app/vulns.json +1 -0
- stackvitals/web/app/fonts/plex-mono-400.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-400.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-600.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-condensed-700.woff2 +0 -0
- stackvitals/web/app/help/accuracy.json +1 -0
- stackvitals/web/app/help/connectors.json +1 -0
- stackvitals/web/app/help/getting-started.json +1 -0
- stackvitals/web/app/help/index.json +1 -0
- stackvitals/web/app/help/modules.json +1 -0
- stackvitals/web/app/help/privacy-and-security.json +1 -0
- stackvitals/web/app/help/reference.json +1 -0
- stackvitals/web/app/main.css +1 -0
- stackvitals/web/app/main.js +1 -0
- stackvitals/web/app/pages/about.css +1 -0
- stackvitals/web/app/pages/about.js +1 -0
- stackvitals/web/app/pages/activity.css +1 -0
- stackvitals/web/app/pages/activity.js +1 -0
- stackvitals/web/app/pages/agents.css +1 -0
- stackvitals/web/app/pages/agents.js +2 -0
- stackvitals/web/app/pages/ci.css +1 -0
- stackvitals/web/app/pages/ci.js +1 -0
- stackvitals/web/app/pages/community.css +1 -0
- stackvitals/web/app/pages/community.js +1 -0
- stackvitals/web/app/pages/connections.css +1 -0
- stackvitals/web/app/pages/connections.js +6 -0
- stackvitals/web/app/pages/delivery.css +1 -0
- stackvitals/web/app/pages/delivery.js +45 -0
- stackvitals/web/app/pages/deps.css +1 -0
- stackvitals/web/app/pages/deps.js +7 -0
- stackvitals/web/app/pages/explore.css +1 -0
- stackvitals/web/app/pages/explore.js +7 -0
- stackvitals/web/app/pages/health.css +1 -0
- stackvitals/web/app/pages/health.js +1 -0
- stackvitals/web/app/pages/help.css +1 -0
- stackvitals/web/app/pages/help.js +6 -0
- stackvitals/web/app/pages/insights.css +1 -0
- stackvitals/web/app/pages/insights.js +1 -0
- stackvitals/web/app/pages/investment.css +1 -0
- stackvitals/web/app/pages/investment.js +3 -0
- stackvitals/web/app/pages/overview.css +1 -0
- stackvitals/web/app/pages/overview.js +1 -0
- stackvitals/web/app/pages/portal.css +1 -0
- stackvitals/web/app/pages/portal.js +51 -0
- stackvitals/web/app/pages/prs.css +1 -0
- stackvitals/web/app/pages/prs.js +4 -0
- stackvitals/web/app/pages/settings.css +1 -0
- stackvitals/web/app/pages/settings.js +1 -0
- stackvitals/web/app/pages/setup.css +1 -0
- stackvitals/web/app/pages/setup.js +2 -0
- stackvitals/web/app/pages/standing.css +1 -0
- stackvitals/web/app/pages/standing.js +1 -0
- stackvitals/web/app/pages/welcome-sheet.js +1 -0
- stackvitals/web/app/pages/welcome.js +1 -0
- stackvitals/web/app/settings-workflow.json +1 -0
- stackvitals/web/index.html +34 -0
- stackvitals/web/site/index.html +108 -0
- stackvitals/web/site/integrations.html +15 -0
- stackvitals/web/site/setup.html +15 -0
- stackvitals/web/site/site.css +68 -0
- stackvitals-0.1.0.dist-info/METADATA +134 -0
- stackvitals-0.1.0.dist-info/RECORD +465 -0
- stackvitals-0.1.0.dist-info/WHEEL +4 -0
- stackvitals-0.1.0.dist-info/entry_points.txt +3 -0
- stackvitals-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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"
|