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,105 @@
|
|
|
1
|
+
"""Opt-in write-back to Jira and Linear (`write_back = true` under the connector, with a key that may write): when a pull
|
|
2
|
+
request naming an issue merges, a comment on the issue links it, and the issue moves to the team's done status when one
|
|
3
|
+
is named. Each pull request and issue once, a few a run; a refusal is said, never fatal."""
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from stackvitals.connectors.base import ConnectorError
|
|
7
|
+
from stackvitals.connectors.tracker import pr_links
|
|
8
|
+
|
|
9
|
+
MAX_WRITES = 10
|
|
10
|
+
LINEAR = "https://api.linear.app/graphql"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def due(prs: list[dict], issues: list[dict], done: set[str], since: str = "") -> list[tuple[dict, dict]]:
|
|
14
|
+
"""(merged PR, issue) pairs merged since write-back was turned on and not written yet, oldest merge first."""
|
|
15
|
+
merged = [p for p in prs if p.get("mg") and p["mg"] >= since and not (p.get("ti") or "").startswith("Revert ")
|
|
16
|
+
and not (p.get("head") or "").startswith("revert-")]
|
|
17
|
+
by_n, by_key = {p["n"]: p for p in merged}, {i["key"]: i for i in issues}
|
|
18
|
+
pairs = [(by_n[n], by_key[key]) for n, key in pr_links(merged, issues) if f"{n}|{key}" not in done]
|
|
19
|
+
return sorted(pairs, key=lambda x: x[0]["mg"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ok(resp: httpx.Response, what: str) -> httpx.Response:
|
|
23
|
+
if resp.status_code >= 300:
|
|
24
|
+
raise ConnectorError(f"{what} was refused ({resp.status_code}); the key needs permission to write.")
|
|
25
|
+
return resp
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def jira_comment(client: httpx.Client, auth: str, key: str, text: str) -> None:
|
|
29
|
+
headers = {"Authorization": auth, "Accept": "application/json"}
|
|
30
|
+
doc = {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]}
|
|
31
|
+
_ok(client.post(f"/rest/api/3/issue/{key}/comment", json={"body": doc}, headers=headers), f"A comment on {key}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def jira_move(client: httpx.Client, auth: str, key: str, transition: str) -> bool:
|
|
35
|
+
"""Moves the issue through the named transition when it offers one; False when it doesn't (already done)."""
|
|
36
|
+
if not transition:
|
|
37
|
+
return False
|
|
38
|
+
headers = {"Authorization": auth, "Accept": "application/json"}
|
|
39
|
+
found = _ok(client.get(f"/rest/api/3/issue/{key}/transitions", headers=headers), f"{key}'s transitions").json()
|
|
40
|
+
pick = next((t for t in found.get("transitions") or [] if (t.get("name") or "").lower() == transition.lower()), None)
|
|
41
|
+
if pick:
|
|
42
|
+
_ok(client.post(f"/rest/api/3/issue/{key}/transitions", json={"transition": {"id": pick["id"]}}, headers=headers),
|
|
43
|
+
f"Moving {key} to {transition}")
|
|
44
|
+
return bool(pick)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _gql(client: httpx.Client, key: str, query: str, variables: dict) -> dict:
|
|
48
|
+
resp = _ok(client.post(LINEAR, json={"query": query, "variables": variables},
|
|
49
|
+
headers={"Authorization": key, "Content-Type": "application/json"}), "Linear")
|
|
50
|
+
body = resp.json()
|
|
51
|
+
if body.get("errors"):
|
|
52
|
+
raise ConnectorError("Linear refused the write; the key needs permission to write.")
|
|
53
|
+
return body.get("data") or {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def linear_comment(client: httpx.Client, key: str, issue_id: str, text: str) -> None:
|
|
57
|
+
_gql(client, key, "mutation($input: CommentCreateInput!) { ok: commentCreate(input: $input) { success } }",
|
|
58
|
+
{"input": {"issueId": issue_id.split(":", 1)[1], "body": text}})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def linear_move(client: httpx.Client, key: str, issue_id: str, identifier: str, state: str) -> bool:
|
|
62
|
+
"""Moves the issue to the team's workflow state of that name; False when the team has none by that name."""
|
|
63
|
+
if not state:
|
|
64
|
+
return False
|
|
65
|
+
found = _gql(client, key, "query($name: String!, $team: String!) { workflowStates(filter: "
|
|
66
|
+
"{name: {eqIgnoreCase: $name}, team: {key: {eq: $team}}}) { nodes { id } } }",
|
|
67
|
+
{"name": state, "team": identifier.split("-", 1)[0]})
|
|
68
|
+
nodes = (found.get("workflowStates") or {}).get("nodes") or []
|
|
69
|
+
if nodes:
|
|
70
|
+
_gql(client, key, "mutation($id: String!, $state: String!) { ok: issueUpdate(id: $id, input: {stateId: $state}) "
|
|
71
|
+
"{ success } }", {"id": issue_id.split(":", 1)[1], "state": nodes[0]["id"]})
|
|
72
|
+
return bool(nodes)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def plan(store, prs: list[dict], now: str) -> list[tuple[dict, dict]]:
|
|
76
|
+
"""The pairs to write this run, recorded as written before the data branch is saved, so a retried or failed push
|
|
77
|
+
never sends them twice (at most once, like the rest of the team automation). Merges from before write-back was last
|
|
78
|
+
turned on are left alone, so switching it on never floods old issues."""
|
|
79
|
+
issues = [r for r in store.load().get("issue") or [] if r.get("key")]
|
|
80
|
+
state = store.state()
|
|
81
|
+
since = state.get("write_since") or now
|
|
82
|
+
done = set(state.get("written") or [])
|
|
83
|
+
picked = due(prs, issues, done, since)[:MAX_WRITES]
|
|
84
|
+
done |= {f"{p['n']}|{i['key']}" for p, i in picked}
|
|
85
|
+
store.save_state({**store.state(), "write_since": since, "written": sorted(done)[-2000:]})
|
|
86
|
+
return picked
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def send(pairs: list[tuple[dict, dict]], repo: str, comment, move) -> dict:
|
|
90
|
+
"""After the commit: one comment per pair, then the move to done; a refused comment stops the run (the key can't
|
|
91
|
+
write), a refused move is said and the next pair goes on."""
|
|
92
|
+
wrote, moved, refused, unmoved = 0, 0, None, []
|
|
93
|
+
for p, issue in pairs:
|
|
94
|
+
text = f"Pull request {repo}#{p['n']} merged on {p['mg'][:10]}: https://github.com/{repo}/pull/{p['n']} (StackVitals)"
|
|
95
|
+
try:
|
|
96
|
+
comment(issue, text)
|
|
97
|
+
except (ConnectorError, httpx.HTTPError) as exc:
|
|
98
|
+
refused = str(exc)
|
|
99
|
+
break
|
|
100
|
+
wrote += 1
|
|
101
|
+
try:
|
|
102
|
+
moved += bool(move(issue))
|
|
103
|
+
except (ConnectorError, httpx.HTTPError) as exc:
|
|
104
|
+
unmoved.append(f"{issue['key']}: {exc}")
|
|
105
|
+
return {"wrote": wrote, "moved": moved, "refused": refused, "unmoved": unmoved}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Contribute: what it takes to get a change merged in a repo, measured from its history."""
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""A short brief for coding agents (and people) about how changes get merged in one repo. Only measured facts that an
|
|
2
|
+
agent can't read off the repo's files: per-folder habits of merged PRs, who reviews there, known-flaky CI, and how
|
|
3
|
+
outside PRs fare against similar repos. Every number carries the count it comes from; thin history is said, not guessed.
|
|
4
|
+
Text that contributors control (test names, folder names) is neutralised before it reaches files agents obey."""
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from stackvitals import codeowners
|
|
12
|
+
from stackvitals.community.analyze import answered_at
|
|
13
|
+
from stackvitals.core.actors import People, kind_of
|
|
14
|
+
from stackvitals.core.stats import wilson
|
|
15
|
+
from stackvitals.flow import peers
|
|
16
|
+
from stackvitals.flow.analyze import _iso, _median, is_bot, stages
|
|
17
|
+
from stackvitals.flow.knowledge import _areas, expertise
|
|
18
|
+
from stackvitals.flow.preflight import CHANGELOG_FILE, HABIT, Habits, preflight
|
|
19
|
+
|
|
20
|
+
MIN_AREA_PRS = 10
|
|
21
|
+
ASK_MIN = 0.1 # an ask seen on fewer PRs than this is not a habit
|
|
22
|
+
MAX_AREAS = 6
|
|
23
|
+
LOW_SAMPLE = 30
|
|
24
|
+
SIZES = (("under 150 lines", 0, 150), ("150-500 lines", 150, 500), ("over 500 lines", 500, None))
|
|
25
|
+
LABELS = {"tests": "tests", "changelog": "a changelog entry", "docs": "docs"}
|
|
26
|
+
START, END = "<!-- stackvitals:brief -->", "<!-- /stackvitals:brief -->"
|
|
27
|
+
STAMP = "Written by StackVitals"
|
|
28
|
+
SAFE_PART = re.compile(r"^[A-Za-z0-9_@+-][A-Za-z0-9._@+-]*$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BriefError(RuntimeError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _clean(text, limit: int = 100) -> str:
|
|
36
|
+
# Contributor-controlled text stays one plain line: no newlines, comment markers or code-format breaks.
|
|
37
|
+
s = re.sub(r"[\x00-\x1f\x7f]+", " ", str(text)).replace("<!--", "").replace("-->", "").replace("`", "'").strip()
|
|
38
|
+
return s if len(s) <= limit else s[:limit - 1] + "…"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _code(text) -> str:
|
|
42
|
+
return f"`{_clean(text)}`"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _safe_area(name: str) -> bool:
|
|
46
|
+
return name != "(root)" and all(SAFE_PART.match(part) for part in name.split("/"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_agent(p: dict) -> bool:
|
|
50
|
+
# The crawl's own rule: agent accounts, or plain names (codex, claude) only when the account is a bot.
|
|
51
|
+
return kind_of(p.get("au"), p.get("at")) == "agent"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _hours(a: str, b: str) -> float | None:
|
|
55
|
+
da, db = (datetime.fromisoformat(x.replace("Z", "+00:00")) for x in (a, b))
|
|
56
|
+
return (db - da).total_seconds() / 3600 if db >= da else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _lines(p: dict) -> int | None:
|
|
60
|
+
if p.get("add") is None and p.get("del") is None:
|
|
61
|
+
return None
|
|
62
|
+
return (p.get("add") or 0) + (p.get("del") or 0)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _outside(prs: list[dict], since: str, now: datetime, bench, language, stars) -> dict:
|
|
66
|
+
# Maintainers count by what they do too (a lead shown as CONTRIBUTOR who merges PRs is not an outsider).
|
|
67
|
+
people = People(prs)
|
|
68
|
+
ins = people.insiders()
|
|
69
|
+
outside = [p for p in prs if people.is_outside(p)]
|
|
70
|
+
closed = [p for p in outside if p.get("st") in ("MERGED", "CLOSED") and (p.get("cl") or "") >= since]
|
|
71
|
+
merged = sum(p["st"] == "MERGED" for p in closed)
|
|
72
|
+
n = len(closed)
|
|
73
|
+
out = {"closed": n, "merged": merged, "rate": round(merged / n, 4) if n else None, "low_sample": n < LOW_SAMPLE}
|
|
74
|
+
if n:
|
|
75
|
+
lo, hi = wilson(merged, n)
|
|
76
|
+
out.update(low=round(lo, 4), high=round(hi, 4))
|
|
77
|
+
out["by_size"] = []
|
|
78
|
+
sized = [(p, x) for p in closed if (x := _lines(p)) is not None]
|
|
79
|
+
for label, lo_lines, hi_lines in SIZES:
|
|
80
|
+
group = [p for p, x in sized if x >= lo_lines and (hi_lines is None or x < hi_lines)]
|
|
81
|
+
if len(group) >= 5:
|
|
82
|
+
out["by_size"].append({"size": label, "closed": len(group),
|
|
83
|
+
"rate": round(sum(p["st"] == "MERGED" for p in group) / len(group), 4)})
|
|
84
|
+
# A maintainer's first answer (review, comment or close by a maintainer) to outside PRs at least a day old.
|
|
85
|
+
day_old = _iso(now - timedelta(days=1))
|
|
86
|
+
asked = [p for p in outside if since <= p["cr"] < day_old]
|
|
87
|
+
waits = [w for p in asked if (a := answered_at(p, ins)) and (w := _hours(p["cr"], a)) is not None]
|
|
88
|
+
out.update(asked=len(asked), answered=len(waits),
|
|
89
|
+
first_answer_hours=round(_median(waits), 1) if len(waits) >= 5 else None)
|
|
90
|
+
found = peers.compare({"outsider_merge_rate": out["rate"]}, bench, language, stars) if n else None
|
|
91
|
+
if found:
|
|
92
|
+
m = found["metrics"][0]
|
|
93
|
+
out["peers"] = {"group": found["group"], "repos": m["repos"], "place": m["place"], "typical": m["typical"]}
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _group(bench: dict | None, section: str, language: str | None, stars: int | None) -> tuple[str, dict] | None:
|
|
98
|
+
from mergescout.bench.ci import tier
|
|
99
|
+
from stackvitals.ci.bench import _label
|
|
100
|
+
groups = (bench or {}).get(section) or {}
|
|
101
|
+
size = tier(stars)
|
|
102
|
+
key = next((k for k in (f"{language}|{size}", f"{language}|all", f"all|{size}", "all|all") if k in groups), None)
|
|
103
|
+
return (_label(key), groups[key]) if key else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _agents(prs: list[dict], since: str) -> dict | None:
|
|
107
|
+
mine = [p for p in prs if _is_agent(p) and p.get("st") in ("MERGED", "CLOSED") and (p.get("cl") or "") >= since]
|
|
108
|
+
if len(mine) < 10:
|
|
109
|
+
return None
|
|
110
|
+
merged = sum(p["st"] == "MERGED" for p in mine)
|
|
111
|
+
return {"closed": len(mine), "merged": merged, "rate": round(merged / len(mine), 4)}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _areas_brief(prs: list[dict], asks: dict, rules, now: datetime, months: int) -> list[dict]:
|
|
115
|
+
habits = Habits(prs, now, months)
|
|
116
|
+
ex = expertise(prs, now)
|
|
117
|
+
since = _iso(now - timedelta(days=30 * months))
|
|
118
|
+
merged = [p for p in prs if p.get("mg") and p["mg"] >= since and not is_bot(p)]
|
|
119
|
+
out = []
|
|
120
|
+
for name, idx in sorted(habits.by_area.items(), key=lambda kv: -len(kv[1])):
|
|
121
|
+
if len(idx) < MIN_AREA_PRS or len(out) >= MAX_AREAS:
|
|
122
|
+
break
|
|
123
|
+
if CHANGELOG_FILE.search(f"{name}/_"):
|
|
124
|
+
continue # a changelog-fragment folder (news/, changelog.d/): said once for the whole repo instead
|
|
125
|
+
probe = "_" if name == "(root)" else f"{name}/_"
|
|
126
|
+
norms = habits.norms([probe])
|
|
127
|
+
pickups = [s["pickup"] / 3600 for p in merged if name in _areas(p.get("paths")) and (s := stages(p))["pickup"] is not None]
|
|
128
|
+
asked = preflight(prs, asks, [probe], now, months)
|
|
129
|
+
reviewers = sorted(ex.reviewed.get(name, {}).items(), key=lambda kv: -kv[1])
|
|
130
|
+
out.append({"area": name, "merged_prs": norms["merged_prs"], "include": norms["include"], "size": norms["size"],
|
|
131
|
+
"asks": [x for x in asked["asks"] if x["share"] >= ASK_MIN and x["prs"] >= 3][:2]
|
|
132
|
+
if asked["scope"] != "whole repo" else [], "reviewed_prs": asked["reviewed_prs"],
|
|
133
|
+
"reviewers": [{"login": who, "reviews": c} for who, c in reviewers[:3] if c >= 3],
|
|
134
|
+
# A folder's review speed only when two or more people review there, so it is never one person's.
|
|
135
|
+
"first_review_hours": round(_median(pickups), 1) if len(pickups) >= 5 and len(reviewers) >= 2 else None,
|
|
136
|
+
"owners": codeowners.owners_for(probe, rules) if name != "(root)" and rules else []})
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _flaky(ci: dict | None) -> list[dict]:
|
|
141
|
+
def ok(i):
|
|
142
|
+
return i.get("state") == "flaky" and isinstance(i.get("flake_events"), int) and isinstance(i.get("runs"), int)
|
|
143
|
+
items = [{"name": f"{(j.get('workflow') or '?').rsplit('/', 1)[-1]} / {j.get('job')}", "kind": "job",
|
|
144
|
+
"flakes": j["flake_events"], "runs": j["runs"]} for j in (ci or {}).get("jobs") or [] if ok(j) and j.get("job")]
|
|
145
|
+
items += [{"name": t["id"], "kind": "test", "flakes": t["flake_events"], "runs": t["runs"]}
|
|
146
|
+
for t in (ci or {}).get("tests") or [] if ok(t) and t.get("id")]
|
|
147
|
+
return sorted(items, key=lambda i: (i["kind"] != "job", -i["flakes"]))[:5]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_brief(repo: str, data: dict, repo_info: dict, asks: dict, ci: dict | None, bench: dict | None, now: datetime,
|
|
151
|
+
months: int = 12) -> dict:
|
|
152
|
+
prs = (data.get("open") or []) + (data.get("closed") or [])
|
|
153
|
+
since = _iso(now - timedelta(days=30 * months))
|
|
154
|
+
people = [p for p in prs if not is_bot(p) and (p.get("cr") or "") >= since]
|
|
155
|
+
rules = codeowners.parse(repo_info.get("codeowners")) if repo_info.get("codeowners") else []
|
|
156
|
+
outside = _outside(prs, since, now, bench, repo_info.get("language"), repo_info.get("stars"))
|
|
157
|
+
# A repo's past merge rate runs high for its next PRs (many stay open); the crawl's backtest says by how much.
|
|
158
|
+
bt = (bench or {}).get("odds_backtest") or {}
|
|
159
|
+
if outside["rate"] is not None and outside["closed"] >= 5:
|
|
160
|
+
cell = next((c for c in bt.get("calibration_raw") or [] if c["bin"] == f"{min(int(outside['rate'] * 10), 9) / 10:.1f}-"
|
|
161
|
+
f"{min(int(outside['rate'] * 10), 9) / 10 + 0.1:.1f}"), None)
|
|
162
|
+
if cell:
|
|
163
|
+
outside["estimate"] = cell["observed"]
|
|
164
|
+
check = {"repos": bt.get("repos"), "mae": (bt.get("mae") or {}).get("calibrated")}
|
|
165
|
+
return {"repo": repo, "generated": now.strftime("%Y-%m-%d"), "months": months, "prs": len(people),
|
|
166
|
+
# The history actually read: a store first filled for 3 months is said as such, not as 12.
|
|
167
|
+
"since": min((p["cr"] for p in people), default=now.strftime("%Y-%m-%d"))[:10],
|
|
168
|
+
"outside": outside, "agents": _agents(prs, since), "repo_habits": Habits(prs, now, months).norms([]),
|
|
169
|
+
"agents_nearby": _group(bench, "agent_prs", repo_info.get("language"), repo_info.get("stars")),
|
|
170
|
+
"backtest": check if check.get("mae") is not None else None,
|
|
171
|
+
"areas": _areas_brief(prs, asks or {}, rules, now, months), "flaky": _flaky(ci)}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _h(hours) -> str:
|
|
175
|
+
if hours is None:
|
|
176
|
+
return "-"
|
|
177
|
+
return f"{hours:.0f} h" if hours < 48 else f"{hours / 24:.1f} days"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _area_lines(a: dict) -> list[str]:
|
|
181
|
+
habits = [f"{LABELS[k]} ({v:.0%})" for k, v in a["include"].items() if v >= HABIT]
|
|
182
|
+
size = a["size"]
|
|
183
|
+
lines = [("Merged PRs here include " + ", ".join(habits) + "; " if habits else "Merged PRs here: ")
|
|
184
|
+
+ f"median {size['median_lines']} lines, 75% under {size['p75_lines']}."]
|
|
185
|
+
if a["asks"]:
|
|
186
|
+
lines.append("Maintainers most often ask for: " + ", ".join(
|
|
187
|
+
f"{_clean(x['ask'])} ({x['share']:.0%} of {a['reviewed_prs']} PRs with maintainer comments)" for x in a["asks"]) + ".")
|
|
188
|
+
who = ", ".join(f"@{_clean(r['login'], 40)} ({r['reviews']} reviews)" for r in a["reviewers"])
|
|
189
|
+
parts = ([f"Reviewed most by {who}"] if who else []) + (
|
|
190
|
+
[f"first review: median {_h(a['first_review_hours'])}"] if a["first_review_hours"] is not None else [])
|
|
191
|
+
if a["owners"]:
|
|
192
|
+
parts.append("CODEOWNERS: " + " ".join(_clean(o, 60) for o in a["owners"]))
|
|
193
|
+
if parts:
|
|
194
|
+
lines.append("; ".join(parts) + ".")
|
|
195
|
+
return lines
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def render(b: dict) -> str:
|
|
199
|
+
"""The brief as Markdown, at most about 40 lines."""
|
|
200
|
+
o = b["outside"]
|
|
201
|
+
out = [f"## How changes get merged in {_clean(b['repo'])}", "",
|
|
202
|
+
f"Measured by StackVitals from {b['prs']} pull requests since {b['since']} ({b['generated']}); "
|
|
203
|
+
"each number says how many PRs it comes from. Bots are left out.", ""]
|
|
204
|
+
if o["closed"] >= 5:
|
|
205
|
+
line = f"- **Outside contributors:** {o['merged']} of {o['closed']} closed PRs merged ({o['rate']:.0%}, likely {o['low']:.0%}-{o['high']:.0%})"
|
|
206
|
+
if o.get("peers"):
|
|
207
|
+
line += f"; {o['peers']['place']} of {o['peers']['group']} (typical {o['peers']['typical']:.0%})"
|
|
208
|
+
line += "."
|
|
209
|
+
if o["by_size"]:
|
|
210
|
+
line += " By size: " + ", ".join(f"{s['size']} {s['rate']:.0%} (n={s['closed']})" for s in o["by_size"]) + "."
|
|
211
|
+
if o["first_answer_hours"] is not None:
|
|
212
|
+
line += f" First maintainer answer: median {_h(o['first_answer_hours'])} for the {o['answered']} answered"
|
|
213
|
+
line += (f"; {o['asked'] - o['answered']} of {o['asked']} never answered." if o["asked"] > o["answered"] else ".")
|
|
214
|
+
if o["low_sample"]:
|
|
215
|
+
line += " Few outside PRs, so treat these as rough."
|
|
216
|
+
out.append(line)
|
|
217
|
+
if o.get("estimate") is not None:
|
|
218
|
+
check = b.get("backtest")
|
|
219
|
+
out.append(f" Of new outside PRs here, about {o['estimate']:.0%} get merged within 3 months (this repo's record, "
|
|
220
|
+
"corrected by what happened next on other repos with a similar record, since many new PRs stay open"
|
|
221
|
+
+ (f"; checked on {check['repos']} repos, off by {check['mae'] * 100:.0f} points on average" if check else "")
|
|
222
|
+
+ ").")
|
|
223
|
+
r = b.get("repo_habits") or {}
|
|
224
|
+
habits = [f"{LABELS[k]} ({v:.0%})" for k, v in (r.get("include") or {}).items() if v >= HABIT]
|
|
225
|
+
if r.get("merged_prs", 0) >= MIN_AREA_PRS and habits:
|
|
226
|
+
out.append(f"- **Across the repo:** merged PRs include {', '.join(habits)}; median {r['size']['median_lines']} lines "
|
|
227
|
+
f"(n={r['merged_prs']}).")
|
|
228
|
+
if b.get("agents"):
|
|
229
|
+
a = b["agents"]
|
|
230
|
+
out.append(f"- **PRs opened by coding agents here:** {a['merged']} of {a['closed']} merged ({a['rate']:.0%}).")
|
|
231
|
+
if b.get("agents_nearby"):
|
|
232
|
+
label, g = b["agents_nearby"]
|
|
233
|
+
people = f" vs {g['outsider']['merged']:.0%} for people's outside PRs" if g["outsider"].get("merged") is not None else ""
|
|
234
|
+
out.append(f"- **PRs opened by coding agents in {label}** (often started by the repos' own maintainers): the typical "
|
|
235
|
+
f"repo merges {g['agent']['merged']:.0%} of them{people} ({g['repos_with_agent_prs']} repos, "
|
|
236
|
+
f"n={g['agent']['decided']}).")
|
|
237
|
+
if b["flaky"]:
|
|
238
|
+
out.append("- **Known flaky CI (if one of these fails, check the error is not from your change, then re-run):** " + "; ".join(
|
|
239
|
+
f"{_code(f['name'])} {f['flakes']} of {f['runs']} runs" for f in b["flaky"]) + ".")
|
|
240
|
+
for a in b["areas"]:
|
|
241
|
+
label = "files at the repo root" if a["area"] == "(root)" else _code(a["area"])
|
|
242
|
+
out += ["", f"### {label} ({a['merged_prs']} merged PRs)"] + [f"- {x}" for x in _area_lines(a)]
|
|
243
|
+
if not b["areas"] and o["closed"] < 5:
|
|
244
|
+
out += ["", "Not enough history here yet to say how changes usually get merged."]
|
|
245
|
+
return "\n".join(out) + "\n"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _write(path: Path, text: str) -> None:
|
|
249
|
+
# Written whole or not at all: an interrupted run never leaves half a file.
|
|
250
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
251
|
+
tmp.write_text(text, encoding="utf-8")
|
|
252
|
+
os.replace(tmp, path)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _agents_md(old: str, section: str) -> str:
|
|
256
|
+
"""The file with our section replaced (or added at the end); markers count only as whole lines. Anything else
|
|
257
|
+
(one marker, reversed, two sections) is refused rather than guessed, so a maintainer's text is never lost."""
|
|
258
|
+
lines = old.splitlines(keepends=True)
|
|
259
|
+
starts = [i for i, line in enumerate(lines) if line.strip() == START]
|
|
260
|
+
ends = [i for i, line in enumerate(lines) if line.strip() == END]
|
|
261
|
+
if not starts and not ends:
|
|
262
|
+
return (old.rstrip("\n") + "\n\n" if old.strip() else "") + section
|
|
263
|
+
if len(starts) == 1 and len(ends) == 1 and starts[0] < ends[0]:
|
|
264
|
+
return "".join(lines[:starts[0]]) + section + "".join(lines[ends[0] + 1:])
|
|
265
|
+
raise BriefError("AGENTS.md has StackVitals markers that don't pair up (one start line and one end line after it); "
|
|
266
|
+
"fix or remove them by hand, then run again.")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def write_files(b: dict, root: Path) -> list[str]:
|
|
270
|
+
"""Opt-in: the brief as a marked section of AGENTS.md (the rest of the file kept) and one Copilot path-instructions
|
|
271
|
+
file per folder. Only files written by StackVitals are replaced or removed."""
|
|
272
|
+
root = Path(root)
|
|
273
|
+
agents = root / "AGENTS.md"
|
|
274
|
+
old = agents.read_text(encoding="utf-8") if agents.exists() else ""
|
|
275
|
+
_write(agents, _agents_md(old, f"{START}\n{render(b)}{END}\n"))
|
|
276
|
+
written = ["AGENTS.md"]
|
|
277
|
+
folder = root / ".github" / "instructions"
|
|
278
|
+
keep = set()
|
|
279
|
+
for a in b["areas"]:
|
|
280
|
+
if not _safe_area(a["area"]):
|
|
281
|
+
continue # odd folder names never reach a file name or applyTo
|
|
282
|
+
slug = re.sub(r"[^a-z0-9]+", "-", a["area"].lower()).strip("-")
|
|
283
|
+
name = f"stackvitals-{slug}-{hashlib.sha1(a['area'].encode('utf-8')).hexdigest()[:6]}.instructions.md"
|
|
284
|
+
keep.add(name)
|
|
285
|
+
# A two-level area ("src/db") is everything under it; a one-level one ("src") only its own files.
|
|
286
|
+
apply_to = f"{a['area']}/**" if "/" in a["area"] else f"{a['area']}/*"
|
|
287
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
body = "\n".join(f"- {x}" for x in _area_lines(a))
|
|
289
|
+
_write(folder / name, f'---\napplyTo: "{apply_to}"\n---\n<!-- {STAMP} from {_clean(b["repo"])}\'s merged PRs '
|
|
290
|
+
f"({b['generated']}); run `stackvitals contribute brief` again to refresh. -->\n"
|
|
291
|
+
f"How merged PRs in {a['area']} look ({a['merged_prs']} merged PRs):\n{body}\n")
|
|
292
|
+
written.append(f".github/instructions/{name}")
|
|
293
|
+
if folder.is_dir():
|
|
294
|
+
for old_file in folder.glob("stackvitals-*.instructions.md"):
|
|
295
|
+
if old_file.name not in keep and STAMP in old_file.read_text(encoding="utf-8", errors="replace"):
|
|
296
|
+
old_file.unlink()
|
|
297
|
+
return written
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from stackvitals.ci import cli as ci_cli
|
|
10
|
+
from stackvitals.ci.api import ActionsAPI, APIError, OutOfBudget
|
|
11
|
+
from stackvitals.contribute.brief import BriefError, build_brief, render, write_files
|
|
12
|
+
from stackvitals.core import services
|
|
13
|
+
from stackvitals.flow import peers
|
|
14
|
+
from stackvitals.flow.cli import local_gql
|
|
15
|
+
from stackvitals.flow.collect import collect as flow_collect
|
|
16
|
+
from stackvitals.flow.preflight import collect_asks
|
|
17
|
+
from stackvitals.flow.store import FlowStore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ci_history(tok: str, repo: str, home: Path, now: datetime, days: int, make_api=ActionsAPI) -> tuple[dict | None, str]:
|
|
21
|
+
"""CI Health's findings: the repo's own published file when it runs StackVitals, else a short live read (the same
|
|
22
|
+
reader as the MCP server and the CI CLI)."""
|
|
23
|
+
try:
|
|
24
|
+
result, source = services.ci_result(repo, days, tok, home, make_api=make_api, now=now)
|
|
25
|
+
except (APIError, OutOfBudget, httpx.HTTPError) as exc:
|
|
26
|
+
return None, f"CI history not read ({exc})"
|
|
27
|
+
return result, "the repo's stackvitals-data branch" if source == "stackvitals-data branch" else source
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def gather(repo: str, tok: str, now: datetime, months: int, ci_days: int, make_gql=local_gql, make_api=ActionsAPI):
|
|
31
|
+
home = ci_cli.home()
|
|
32
|
+
owner, name = repo.split("/", 1)
|
|
33
|
+
store = FlowStore(home / "flow" / repo.replace("/", "__"))
|
|
34
|
+
gql = make_gql(tok)
|
|
35
|
+
flow_collect(gql, store, owner, name, now, months=months, log=lambda line: print(line, file=sys.stderr, flush=True))
|
|
36
|
+
collect_asks(gql, store, owner, name, store.load()["closed"], cap=1000)
|
|
37
|
+
ci, ci_source = ci_history(tok, repo, home, now, ci_days, make_api) if ci_days else (None, "CI not read")
|
|
38
|
+
return store.load(), store.repo(), store.asks(), ci, peers.load(tok, home / "bench"), ci_source
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main(argv: list[str] | None = None, now: datetime | None = None, tok: str | None = None, gather_fn=gather) -> int:
|
|
42
|
+
parser = argparse.ArgumentParser(prog="stackvitals contribute brief",
|
|
43
|
+
description="How changes get merged in a repo, measured from its history (for agents and people).")
|
|
44
|
+
parser.add_argument("repo", help="owner/name")
|
|
45
|
+
parser.add_argument("--months", type=int, default=12, help="pull request history to read")
|
|
46
|
+
parser.add_argument("--ci-days", type=int, default=14, help="days of CI runs to read when the repo doesn't run StackVitals (0: skip)")
|
|
47
|
+
parser.add_argument("--json", action="store_true")
|
|
48
|
+
parser.add_argument("--write", metavar="DIR", help="also write an AGENTS.md section and Copilot path files into this folder")
|
|
49
|
+
args = parser.parse_args(argv)
|
|
50
|
+
tok = tok or ci_cli.token()
|
|
51
|
+
if not tok:
|
|
52
|
+
print("A GitHub token is needed. Set GITHUB_TOKEN, or run `gh auth login`.", file=sys.stderr)
|
|
53
|
+
return 1
|
|
54
|
+
now = now or datetime.now(timezone.utc)
|
|
55
|
+
data, repo_info, asks, ci, bench, ci_source = gather_fn(args.repo, tok, now, args.months, args.ci_days)
|
|
56
|
+
brief = build_brief(args.repo, data, repo_info, asks, ci, bench, now, months=args.months)
|
|
57
|
+
if args.write:
|
|
58
|
+
try:
|
|
59
|
+
for path in write_files(brief, Path(args.write)):
|
|
60
|
+
print(f"wrote {path}", file=sys.stderr)
|
|
61
|
+
except BriefError as exc:
|
|
62
|
+
print(f"Nothing written: {exc}", file=sys.stderr)
|
|
63
|
+
print(json.dumps({**brief, "ci_source": ci_source}, indent=1) if args.json else render(brief))
|
|
64
|
+
return 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared parts of the suite: one model, one role rule, one metric registry, one set of stats and time helpers."""
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Who is a person, a bot or a coding agent, and who is an insider: one rule for every module, the crawl's rule
|
|
2
|
+
(`mergescout.signals.identity`), so a repo's own numbers and its peer benchmarks mean the same thing."""
|
|
3
|
+
import re
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from mergescout.signals.rules import (AI_AGENT_BOT_NAMES, AI_AGENT_LOGINS, BOT_NAME_PATTERN, HUMAN_BOT_NAMES,
|
|
8
|
+
KNOWN_BOTS, UNKNOWN_LOGINS)
|
|
9
|
+
|
|
10
|
+
INSIDER_ASSOC = frozenset({"OWNER", "MEMBER", "COLLABORATOR"})
|
|
11
|
+
_BOT_NAME = re.compile(BOT_NAME_PATTERN)
|
|
12
|
+
_HUMAN_NAME = re.compile(HUMAN_BOT_NAMES)
|
|
13
|
+
# The crawl's reason order, so both sides print the same text.
|
|
14
|
+
REASONS = ("bot account", "listed role", "merges others", "merges own", "in owner files")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def kind_of(login: str | None, typ: str | None = None) -> str:
|
|
18
|
+
"""person, bot, agent or unknown, from the login and GitHub's account type when known."""
|
|
19
|
+
if not login or login.lower() in UNKNOWN_LOGINS:
|
|
20
|
+
return "unknown"
|
|
21
|
+
if typ == "Person": # set by a repo's identity settings, so it beats the name rules
|
|
22
|
+
return "person"
|
|
23
|
+
low = login.lower()
|
|
24
|
+
base = low.removesuffix("[bot]")
|
|
25
|
+
bot = typ == "Bot" or low.endswith("[bot]") or low in KNOWN_BOTS or bool(_BOT_NAME.search(low) and not _HUMAN_NAME.search(low))
|
|
26
|
+
if base in AI_AGENT_LOGINS or (base in AI_AGENT_BOT_NAMES and bot):
|
|
27
|
+
return "agent"
|
|
28
|
+
return "bot" if bot else "person"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Actor:
|
|
33
|
+
login: str
|
|
34
|
+
kind: str # person, bot, agent, unknown
|
|
35
|
+
role: str # insider, outsider, or the kind again for non-people
|
|
36
|
+
reasons: tuple[str, ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class People:
|
|
40
|
+
"""Insiders are found by what they do: a listed role anywhere, merging anyone's PR, or a name in the owner files."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, prs: list[dict], issues: list[dict] = (), owner_logins: list[str] = ()):
|
|
43
|
+
self._typ: dict[str, str | None] = {}
|
|
44
|
+
self._name: dict[str, str] = {}
|
|
45
|
+
self._flags: dict[str, set[str]] = defaultdict(set)
|
|
46
|
+
for r in list(prs) + list(issues):
|
|
47
|
+
au = r.get("au")
|
|
48
|
+
self._see(au, r.get("at"), r.get("as"))
|
|
49
|
+
for e in r.get("ev") or []:
|
|
50
|
+
if e[0] == "review" and len(e) > 5:
|
|
51
|
+
self._see(e[2], e[5], e[4])
|
|
52
|
+
elif e[0] == "merged" and len(e) > 2 and e[2]:
|
|
53
|
+
self._see(e[2], None, None)
|
|
54
|
+
self._flags[e[2].lower()].add("merges own" if e[2] == au else "merges others")
|
|
55
|
+
for c in r.get("cm") or []:
|
|
56
|
+
if len(c) > 3:
|
|
57
|
+
self._see(c[1], c[3], c[2])
|
|
58
|
+
for login in owner_logins:
|
|
59
|
+
if login:
|
|
60
|
+
self._see(login, None, None)
|
|
61
|
+
self._flags[login.lower()].add("in owner files")
|
|
62
|
+
|
|
63
|
+
def _see(self, login: str | None, typ: str | None, assoc: str | None) -> None:
|
|
64
|
+
if not login:
|
|
65
|
+
return
|
|
66
|
+
low = login.lower()
|
|
67
|
+
self._name.setdefault(low, login)
|
|
68
|
+
# Once seen as a bot account, always a bot.
|
|
69
|
+
if typ == "Bot" or self._typ.get(low) is None:
|
|
70
|
+
self._typ[low] = typ or self._typ.get(low)
|
|
71
|
+
if assoc in INSIDER_ASSOC:
|
|
72
|
+
self._flags[low].add("listed role")
|
|
73
|
+
|
|
74
|
+
def kind(self, login: str | None) -> str:
|
|
75
|
+
return kind_of(login, self._typ.get((login or "").lower()))
|
|
76
|
+
|
|
77
|
+
def role(self, login: str | None) -> str:
|
|
78
|
+
kind = self.kind(login)
|
|
79
|
+
if kind != "person":
|
|
80
|
+
return kind
|
|
81
|
+
return "insider" if self._flags.get(login.lower()) else "outsider"
|
|
82
|
+
|
|
83
|
+
def reasons(self, login: str | None) -> str:
|
|
84
|
+
if not login:
|
|
85
|
+
return ""
|
|
86
|
+
found = self._flags.get(login.lower(), set())
|
|
87
|
+
if self.kind(login) in ("bot", "agent"):
|
|
88
|
+
found = found | {"bot account"}
|
|
89
|
+
return ", ".join(r for r in REASONS if r in found)
|
|
90
|
+
|
|
91
|
+
def actor(self, login: str | None) -> Actor:
|
|
92
|
+
return Actor(login or "", self.kind(login), self.role(login), tuple(r for r in REASONS if r in self._flags.get((login or "").lower(), ())))
|
|
93
|
+
|
|
94
|
+
def is_insider(self, login: str | None) -> bool:
|
|
95
|
+
return self.role(login) == "insider"
|
|
96
|
+
|
|
97
|
+
def insiders(self) -> set[str]:
|
|
98
|
+
return {self._name[low] for low in self._flags if self.role(self._name[low]) == "insider"}
|
|
99
|
+
|
|
100
|
+
def is_outside(self, rec: dict) -> bool:
|
|
101
|
+
"""A person who is not an insider wrote it: never a bot, an agent, a ghost or a maintainer."""
|
|
102
|
+
au = rec.get("au")
|
|
103
|
+
return kind_of(au, rec.get("at")) == "person" and self.role(au) == "outsider"
|