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