From d61be82f9c2aba4c635e6fa1bbdbde9a941b4b90 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 25 May 2026 22:28:31 -0700 Subject: [PATCH 1/4] feat(mcp): harden private beta auth and publishing Add GitHub-backed MCP sessions, rate limiting, queue repair, and npm trusted-publishing readiness. Keep the Worker private, keep source uploads disabled, and add release gates for the MCP package. --- .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug_report.yml | 47 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 29 + .github/dependabot.yml | 18 + .github/pull_request_template.md | 14 + .github/workflows/ci.yml | 46 + .github/workflows/npm-publish.yml | 55 + .gitleaks.toml | 12 + CONTRIBUTING.md | 41 + LICENSE | 6 + README.md | 43 +- SECURITY.md | 25 + THIRD_PARTY_NOTICES.md | 11 + cliff.toml | 38 + migrations/0003_data_spine.sql | 196 ++ migrations/0004_scoring_intelligence.sql | 87 + migrations/0005_sync_fidelity.sql | 59 + migrations/0006_open_data_completeness.sql | 36 + .../0007_current_open_reconciliation.sql | 8 + migrations/0008_private_beta_auth.sql | 32 + package-lock.json | 659 ++++++ package.json | 34 +- packages/gittensory-mcp/LICENSE | 3 + packages/gittensory-mcp/README.md | 21 + packages/gittensory-mcp/bin/gittensory-mcp.js | 609 ++++++ packages/gittensory-mcp/lib/local-branch.js | 249 +++ packages/gittensory-mcp/package.json | 44 + scripts/check-mcp-package.mjs | 29 + src/api/routes.ts | 976 ++++++++- src/auth/github-oauth.ts | 116 ++ src/auth/rate-limit.ts | 123 ++ src/auth/security.ts | 109 + src/db/repositories.ts | 1543 +++++++++++++- src/db/schema.ts | 417 +++- src/env.d.ts | 9 + src/github/backfill.ts | 1818 +++++++++++++++++ src/gittensor/api.ts | 272 +++ src/index.ts | 32 +- src/mcp/server.ts | 574 +++++- src/openapi/schemas.ts | 812 ++++++++ src/openapi/spec.ts | 263 ++- src/queue/processors.ts | 408 +++- src/registry/sync.ts | 24 +- src/rules/advisory.ts | 10 +- src/scoring/model.ts | 140 ++ src/scoring/preview.ts | 236 +++ src/services/decision-pack.ts | 489 +++++ src/signals/data-quality.ts | 298 +++ src/signals/engine.ts | 1724 +++++++++++++++- src/signals/local-branch.ts | 420 ++++ src/signals/reward-risk.ts | 790 +++++++ src/types.ts | 438 ++++ src/utils/json.ts | 5 +- test/integration/api.test.ts | 1257 +++++++++++- test/integration/routes-errors.test.ts | 547 +++++ test/unit/adapters.test.ts | 126 ++ test/unit/auth.test.ts | 131 ++ test/unit/backfill.test.ts | 1186 +++++++++++ test/unit/crypto.test.ts | 15 + test/unit/data-quality.test.ts | 389 ++++ test/unit/data-spine.test.ts | 322 +++ test/unit/decision-pack.test.ts | 245 +++ test/unit/github-app.test.ts | 52 +- test/unit/github-comments.test.ts | 4 + test/unit/gittensor-api.test.ts | 185 ++ test/unit/index.test.ts | 153 ++ test/unit/local-branch.test.ts | 322 +++ test/unit/openapi.test.ts | 56 +- test/unit/queue.test.ts | 545 +++++ test/unit/registry.test.ts | 85 + test/unit/rules.test.ts | 85 + test/unit/scoring.test.ts | 198 ++ test/unit/signals-v2.test.ts | 1267 ++++++++++++ test/unit/signals.test.ts | 324 ++- test/workers/worker-runtime.test.ts | 19 + tsconfig.json | 4 +- vitest.config.ts | 12 + vitest.workers.config.ts | 14 + worker-configuration.d.ts | 9 +- wrangler.jsonc | 17 +- 81 files changed, 21758 insertions(+), 314 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/npm-publish.yml create mode 100644 .gitleaks.toml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 cliff.toml create mode 100644 migrations/0003_data_spine.sql create mode 100644 migrations/0004_scoring_intelligence.sql create mode 100644 migrations/0005_sync_fidelity.sql create mode 100644 migrations/0006_open_data_completeness.sql create mode 100644 migrations/0007_current_open_reconciliation.sql create mode 100644 migrations/0008_private_beta_auth.sql create mode 100644 packages/gittensory-mcp/LICENSE create mode 100644 packages/gittensory-mcp/README.md create mode 100755 packages/gittensory-mcp/bin/gittensory-mcp.js create mode 100644 packages/gittensory-mcp/lib/local-branch.js create mode 100644 packages/gittensory-mcp/package.json create mode 100644 scripts/check-mcp-package.mjs create mode 100644 src/auth/github-oauth.ts create mode 100644 src/auth/rate-limit.ts create mode 100644 src/auth/security.ts create mode 100644 src/github/backfill.ts create mode 100644 src/gittensor/api.ts create mode 100644 src/scoring/model.ts create mode 100644 src/scoring/preview.ts create mode 100644 src/services/decision-pack.ts create mode 100644 src/signals/data-quality.ts create mode 100644 src/signals/local-branch.ts create mode 100644 src/signals/reward-risk.ts create mode 100644 test/integration/routes-errors.test.ts create mode 100644 test/unit/adapters.test.ts create mode 100644 test/unit/auth.test.ts create mode 100644 test/unit/backfill.test.ts create mode 100644 test/unit/data-quality.test.ts create mode 100644 test/unit/data-spine.test.ts create mode 100644 test/unit/decision-pack.test.ts create mode 100644 test/unit/gittensor-api.test.ts create mode 100644 test/unit/index.test.ts create mode 100644 test/unit/local-branch.test.ts create mode 100644 test/unit/queue.test.ts create mode 100644 test/unit/scoring.test.ts create mode 100644 test/unit/signals-v2.test.ts create mode 100644 test/workers/worker-runtime.test.ts create mode 100644 vitest.workers.config.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..d09ea773aa --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @JSONbored diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..c03e278c33 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,47 @@ +name: Bug report +description: Report a backend, API, MCP, GitHub App, or signal issue. +title: "[Bug]: " +labels: + - bug +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What is wrong? + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - REST API + - MCP + - GitHub App + - Registry sync + - GitHub backfill + - Signal logic + - Tests/CI + - Documentation + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: textarea + id: validation + attributes: + label: Validation + description: Include commands, status codes, or sanitized logs. Do not include secrets, wallet details, or private tokens. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..e512e366e6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security issue + url: https://github.com/JSONbored/gittensory/security/advisories/new + about: Please report security issues privately. Do not open a public issue with secrets or sensitive data. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..743dc283b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest a backend signal, API, MCP, GitHub App, or operational improvement. +title: "[Feature]: " +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What contributor, maintainer, or repo-owner problem should this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: Describe the backend behavior, signal, or API shape. + validations: + required: true + - type: checkboxes + id: boundaries + attributes: + label: Boundaries + options: + - label: This is backend-only. + - label: This does not require storing user PATs. + - label: This does not expose wallet details, raw trust scores, or private rankings publicly. + - label: This does not auto-label, auto-close, or auto-merge contributor work. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..dee80a657a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + npm-minor-and-patch: + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..c933af3c17 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,14 @@ +## Summary + +- + +## Validation + +- [ ] `npm run test:ci` + +## Safety + +- [ ] Backend-only change +- [ ] No secrets, wallet details, user PATs, raw trust scores, or private rankings exposed +- [ ] Public text avoids compensation-seeking or optimization-tactic language +- [ ] OpenAPI/MCP behavior updated where needed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..9146df9e57 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + + - name: Setup Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test with coverage + run: npm run test:coverage + + - name: Worker runtime tests + run: npm run test:workers + + - name: MCP package check + run: npm run build:mcp && npm run test:mcp-pack + + - name: Audit dependencies + run: npm audit --audit-level=moderate diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000000..bac9161077 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,55 @@ +name: Publish MCP Package + +on: + push: + tags: + - "mcp-v*.*.*" + +permissions: + contents: read + id-token: write + +concurrency: + group: npm-publish-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + + - name: Setup Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: 24 + + - name: Verify release tag + env: + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + node -e 'const tag=process.env.REF_NAME; if (!/^mcp-v[0-9]+\.[0-9]+\.[0-9]+$/.test(tag)) { throw new Error("Invalid MCP release tag: " + tag); }' + export VERSION="${REF_NAME#mcp-v}" + node -e 'const pkg=require("./packages/gittensory-mcp/package.json"); const expected=process.env.VERSION; if (pkg.version !== expected) { throw new Error("package version " + pkg.version + " does not match tag " + expected); }' + + - name: Install dependencies + run: npm ci + + - name: Full validation gate + run: npm run test:ci + + - name: Packed tarball smoke test + run: | + set -euo pipefail + PACK_JSON="$(npm pack --workspace @jsonbored/gittensory-mcp --json)" + TARBALL="$(node -e 'const fs=require("fs"); const input=fs.readFileSync(0,"utf8"); process.stdout.write(JSON.parse(input)[0].filename)' <<< "$PACK_JSON")" + TMP="$(mktemp -d)" + npm --prefix "$TMP" init -y >/dev/null + npm --prefix "$TMP" install "$PWD/$TARBALL" >/dev/null + "$TMP/node_modules/.bin/gittensory-mcp" --help >/dev/null + + - name: Publish with npm trusted publishing + run: npm publish --workspace @jsonbored/gittensory-mcp --access public --provenance diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000000..41f5bddc98 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,12 @@ +title = "Gittensory secret scanning" + +[extend] +useDefault = true + +[[allowlists]] +description = "Public GitHub App OAuth client ID" +regexes = ['''Iv23li574mpdLo2PnVN4'''] +paths = [ + '''^wrangler\.jsonc$''', + '''^worker-configuration\.d\.ts$''', +] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..c17f811d59 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing + +Gittensory is a backend-only project. Contributions should improve the API, GitHub App, MCP +surface, registry/backfill jobs, signal logic, tests, or operational safety. + +## Scope + +Accepted contribution areas: + +- deterministic signal builders for contributors, maintainers, and repo owners +- GitHub App webhook, check-run, and sanitized comment behavior +- registry, bounty, issue, PR, label, queue, and collision ingestion +- Cloudflare Worker, D1, Queue, and scheduled job reliability +- MCP tools and the thin npm MCP wrapper +- test coverage, invariants, fixtures, OpenAPI/MCP contracts, and CI hardening + +Out of scope: + +- frontend UI work +- public leaderboards +- public wallet or raw trust-score exposure +- auto-labeling, auto-closing, auto-merging, or mutating contributor work +- storing contributor PATs +- public text that implies compensation estimates or optimization tactics + +## Quality Bar + +- Run `npm run test:ci` before opening a PR. +- Add or update tests for behavior changes. +- Keep API and MCP responses structured and machine-readable. +- Keep public GitHub comments advisory, sanitized, and non-spammy. +- Prefer deterministic, evidence-based rules over opaque scoring. +- Use Conventional Commit style for release-quality changelog output. + +## Pull Request Checklist + +- The change is backend-only. +- Tests cover the new behavior or regression. +- Public surfaces do not expose secrets, wallet details, raw trust scores, or private rankings. +- Public text avoids compensation-seeking or optimization-tactic language. +- OpenAPI and MCP schemas stay aligned with behavior. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..5b40a7def0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,6 @@ +SPDX-License-Identifier: AGPL-3.0-only + +Gittensory is licensed under the GNU Affero General Public License v3.0 only. + +The full license text is available at: +https://www.gnu.org/licenses/agpl-3.0.txt diff --git a/README.md b/README.md index 142af8ff38..a460afd53c 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,16 @@ Secrets are configured through Cloudflare, not committed: ```bash wrangler secret put GITHUB_WEBHOOK_SECRET wrangler secret put GITHUB_APP_PRIVATE_KEY +wrangler secret put GITHUB_PUBLIC_TOKEN wrangler secret put GITTENSORY_API_TOKEN wrangler secret put GITTENSORY_MCP_TOKEN wrangler secret put INTERNAL_JOB_TOKEN ``` +`GITHUB_PUBLIC_TOKEN` is a server-side GitHub API token used only to raise rate limits when +backfilling public registered repositories that have not installed the GitHub App. It is not a +contributor token, and Gittensory does not store user PATs. + For local development, put non-production test values in `.dev.vars`. ## API @@ -65,40 +70,74 @@ Private beta REST endpoints use `Authorization: Bearer `. - `GET /health` - `GET /openapi.json` - `GET /v1/registry/snapshot` +- `GET /v1/registry/changes` +- `GET /v1/sync/status` +- `GET /v1/readiness` +- `GET /v1/installations` +- `GET /v1/installations/:id/health` - `GET /v1/repos` - `GET /v1/repos/:owner/:repo` - `GET /v1/repos/:owner/:repo/advisory` +- `GET /v1/repos/:owner/:repo/lane` - `GET /v1/repos/:owner/:repo/workboard` - `GET /v1/repos/:owner/:repo/queue-health` - `GET /v1/repos/:owner/:repo/collisions` - `GET /v1/repos/:owner/:repo/config-quality` +- `GET /v1/repos/:owner/:repo/labels/audit` - `GET /v1/repos/:owner/:repo/settings` - `GET /v1/repos/:owner/:repo/maintainer-packet` +- `GET /v1/repos/:owner/:repo/pulls/:number/maintainer-packet` - `GET /v1/repos/:owner/:repo/pulls/:number/advisory` - `GET /v1/repos/:owner/:repo/issues/:number/advisory` - `GET /v1/contributors/:login/profile` - `GET /v1/contributors/:login/opportunities` +- `GET /v1/contributors/:login/fit` - `POST /v1/preflight/pr` +- `POST /v1/preflight/local-diff` - `GET /v1/bounties` - `GET /v1/bounties/:id/advisory` - `POST /mcp` - `POST /v1/github/webhook` - `POST /v1/internal/jobs/refresh-registry` +- `POST /v1/internal/jobs/refresh-registry/run` +- `POST /v1/internal/jobs/backfill-registered-repos` +- `POST /v1/internal/jobs/backfill-registered-repos/run` +- `POST /v1/internal/jobs/generate-signal-snapshots` +- `POST /v1/internal/jobs/refresh-installation-health/run` +- `POST /v1/internal/bounties/import` +- `POST /v1/internal/repos/:owner/:repo/settings` + +## Operational Readiness + +Use the protected readiness endpoint before widening beta access or making the repository public: + +```bash +curl -fsS "$GITTENSORY_URL/v1/readiness" \ + -H "Authorization: Bearer $GITTENSORY_API_TOKEN" +``` + +The response reports registry freshness, GitHub backfill state, installation health, required +secret presence, and blocking warnings. It does not expose secret values. ## MCP `POST /mcp` exposes private-beta MCP tools over JSON-RPC/Streamable HTTP style requests. Use `Authorization: Bearer `. -Initial tools: +Tools: - `gittensory_get_repo_context` - `gittensory_get_contributor_profile` - `gittensory_find_opportunities` +- `gittensory_get_contributor_fit` - `gittensory_preflight_pr` - `gittensory_get_queue_health` - `gittensory_get_collisions` - `gittensory_get_bounty_advisory` +- `gittensory_get_registry_changes` +- `gittensory_audit_repo_labels` +- `gittensory_explain_lane` +- `gittensory_preflight_local_diff` ## GitHub App PR Intelligence @@ -118,5 +157,5 @@ avoid raw trust scores, rankings, wallet data, or compensation estimates. ## Validation ```bash -npm run validate +npm run test:ci ``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..630f3d7d54 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security + +Gittensory is private beta software. Please do not include secrets, API tokens, private keys, +wallet details, webhook payload secrets, or sensitive contributor evidence in public issues, +pull requests, screenshots, or logs. + +## Reporting + +Until GitHub private vulnerability reporting is enabled for the public repository, report security +issues directly to the repository owner through a private channel. Once public vulnerability +reporting is enabled, use GitHub Security Advisories. + +## Privacy Posture + +- Gittensory does not store user GitHub PATs. +- Public PR comments are opt-in and sanitized. +- Detailed contributor evidence belongs in private API responses and GitHub check runs. +- Wallet details, raw trust scores, private rankings, and negative labels must not be published in + public comments or public issue templates. +- GitHub App private keys, webhook secrets, MCP tokens, API tokens, and internal job tokens must be + stored as Cloudflare secrets. + +## Supported Version + +Private beta support tracks the current `main` branch. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000000..e1a2f2e34f --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,11 @@ +# Third-Party Notices + +## Gittensor Scoring References + +Gittensory includes TypeScript scoring estimators and model snapshots informed by public Gittensor scoring behavior and documentation from `entrius/gittensor`. + +- Upstream project: `entrius/gittensor` +- Upstream license observed locally: MIT +- Use in Gittensory: no Python modules are vendored into the Worker backend; Gittensory stores normalized model constants and implements deterministic advisory estimators in TypeScript. + +Any optional local MCP score preview may invoke a user-configured local Gittensor checkout. That checkout remains separate from this repository. diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000000..b338a7d29d --- /dev/null +++ b/cliff.toml @@ -0,0 +1,38 @@ +[changelog] +header = "# Changelog\n\n" +body = """ +{% if version %}\ +## {{ version }} - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ +## Unreleased +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + +### {{ group | striptags | trim | upper_first }} +{% for commit in commits %} +- {{ commit.message | upper_first }}{% if commit.remote.pr_number %} ([#{{ commit.remote.pr_number }}]({{ commit.remote.pr_url }})){% endif %} +{% endfor %} +{% endfor %}\n +""" +trim = true +footer = "" + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +tag_pattern = "v[0-9]*" +sort_commits = "oldest" + +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Fixes" }, + { message = "^test", group = "Tests" }, + { message = "^ci", group = "CI" }, + { message = "^build", group = "Build" }, + { message = "^docs", group = "Docs" }, + { message = "^refactor", group = "Refactors" }, + { message = "^chore\\(deps\\)", group = "Dependencies" }, + { message = "^chore", group = "Chores" }, + { message = "^revert", group = "Reverts" }, +] diff --git a/migrations/0003_data_spine.sql b/migrations/0003_data_spine.sql new file mode 100644 index 0000000000..c73825f2d5 --- /dev/null +++ b/migrations/0003_data_spine.sql @@ -0,0 +1,196 @@ +ALTER TABLE repository_settings ADD COLUMN check_run_detail_level TEXT NOT NULL DEFAULT 'standard'; +ALTER TABLE repository_settings ADD COLUMN backfill_enabled INTEGER NOT NULL DEFAULT 1; +ALTER TABLE repository_settings ADD COLUMN private_trust_enabled INTEGER NOT NULL DEFAULT 1; + +CREATE TABLE IF NOT EXISTS repo_sync_state ( + repo_full_name TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'never_synced', + source_kind TEXT NOT NULL DEFAULT 'github', + primary_language TEXT, + default_branch TEXT, + is_private INTEGER, + open_issues_count INTEGER NOT NULL DEFAULT 0, + open_pull_requests_count INTEGER NOT NULL DEFAULT 0, + recent_merged_pull_requests_count INTEGER NOT NULL DEFAULT 0, + labels_synced_at TEXT, + issues_synced_at TEXT, + pull_requests_synced_at TEXT, + merged_pull_requests_synced_at TEXT, + last_started_at TEXT, + last_completed_at TEXT, + error_summary TEXT, + warnings_json TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS repo_labels ( + id TEXT PRIMARY KEY, + repo_full_name TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + description TEXT, + is_configured INTEGER NOT NULL DEFAULT 0, + observed_count INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL DEFAULT '{}', + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(repo_full_name, name) +); + +CREATE INDEX IF NOT EXISTS repo_labels_repo_idx ON repo_labels (repo_full_name); + +CREATE TABLE IF NOT EXISTS repo_snapshots ( + id TEXT PRIMARY KEY, + repo_full_name TEXT NOT NULL, + snapshot_kind TEXT NOT NULL, + source_kind TEXT NOT NULL DEFAULT 'github', + fetched_at TEXT NOT NULL, + primary_language TEXT, + default_branch TEXT, + open_issues_count INTEGER NOT NULL DEFAULT 0, + open_pull_requests_count INTEGER NOT NULL DEFAULT 0, + recent_merged_pull_requests_count INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL DEFAULT '{}' +); + +CREATE INDEX IF NOT EXISTS repo_snapshots_repo_idx ON repo_snapshots (repo_full_name, fetched_at); + +CREATE TABLE IF NOT EXISTS pull_request_files ( + id TEXT PRIMARY KEY, + repo_full_name TEXT NOT NULL, + pull_number INTEGER NOT NULL, + path TEXT NOT NULL, + status TEXT, + additions INTEGER NOT NULL DEFAULT 0, + deletions INTEGER NOT NULL DEFAULT 0, + changes INTEGER NOT NULL DEFAULT 0, + previous_filename TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(repo_full_name, pull_number, path) +); + +CREATE INDEX IF NOT EXISTS pull_request_files_repo_pull_idx ON pull_request_files (repo_full_name, pull_number); + +CREATE TABLE IF NOT EXISTS pull_request_reviews ( + id TEXT PRIMARY KEY, + repo_full_name TEXT NOT NULL, + pull_number INTEGER NOT NULL, + reviewer_login TEXT, + state TEXT NOT NULL, + author_association TEXT, + submitted_at TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS pull_request_reviews_repo_pull_idx ON pull_request_reviews (repo_full_name, pull_number); + +CREATE TABLE IF NOT EXISTS check_summaries ( + id TEXT PRIMARY KEY, + repo_full_name TEXT NOT NULL, + pull_number INTEGER, + head_sha TEXT, + name TEXT NOT NULL, + status TEXT NOT NULL, + conclusion TEXT, + started_at TEXT, + completed_at TEXT, + details_url TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(repo_full_name, head_sha, name) +); + +CREATE INDEX IF NOT EXISTS check_summaries_repo_pull_idx ON check_summaries (repo_full_name, pull_number); + +CREATE TABLE IF NOT EXISTS recent_merged_pull_requests ( + id TEXT PRIMARY KEY, + repo_full_name TEXT NOT NULL, + number INTEGER NOT NULL, + title TEXT NOT NULL, + author_login TEXT, + html_url TEXT, + merged_at TEXT, + labels_json TEXT NOT NULL DEFAULT '[]', + linked_issues_json TEXT NOT NULL DEFAULT '[]', + changed_files_json TEXT NOT NULL DEFAULT '[]', + payload_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(repo_full_name, number) +); + +CREATE INDEX IF NOT EXISTS recent_merged_pull_requests_repo_idx ON recent_merged_pull_requests (repo_full_name, merged_at); + +CREATE TABLE IF NOT EXISTS contributors ( + login TEXT PRIMARY KEY, + github_profile_json TEXT NOT NULL DEFAULT '{}', + top_languages_json TEXT NOT NULL DEFAULT '[]', + public_repos INTEGER, + followers INTEGER, + source TEXT NOT NULL DEFAULT 'github', + first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS contributor_repo_stats ( + id TEXT PRIMARY KEY, + login TEXT NOT NULL, + repo_full_name TEXT NOT NULL, + pull_requests INTEGER NOT NULL DEFAULT 0, + merged_pull_requests INTEGER NOT NULL DEFAULT 0, + open_pull_requests INTEGER NOT NULL DEFAULT 0, + issues INTEGER NOT NULL DEFAULT 0, + stale_pull_requests INTEGER NOT NULL DEFAULT 0, + unlinked_pull_requests INTEGER NOT NULL DEFAULT 0, + dominant_labels_json TEXT NOT NULL DEFAULT '[]', + last_activity_at TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(login, repo_full_name) +); + +CREATE INDEX IF NOT EXISTS contributor_repo_stats_login_idx ON contributor_repo_stats (login); +CREATE INDEX IF NOT EXISTS contributor_repo_stats_repo_idx ON contributor_repo_stats (repo_full_name); + +CREATE TABLE IF NOT EXISTS collision_edges ( + id TEXT PRIMARY KEY, + repo_full_name TEXT NOT NULL, + left_type TEXT NOT NULL, + left_number INTEGER NOT NULL, + left_title TEXT NOT NULL, + right_type TEXT NOT NULL, + right_number INTEGER NOT NULL, + right_title TEXT NOT NULL, + risk TEXT NOT NULL, + reason TEXT NOT NULL, + shared_terms_json TEXT NOT NULL DEFAULT '[]', + generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS collision_edges_repo_idx ON collision_edges (repo_full_name, generated_at); + +CREATE TABLE IF NOT EXISTS signal_snapshots ( + id TEXT PRIMARY KEY, + signal_type TEXT NOT NULL, + target_key TEXT NOT NULL, + repo_full_name TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS signal_snapshots_target_idx ON signal_snapshots (signal_type, target_key, generated_at); + +CREATE TABLE IF NOT EXISTS installation_health ( + installation_id INTEGER PRIMARY KEY, + account_login TEXT NOT NULL, + repository_selection TEXT, + installed_repos_count INTEGER NOT NULL DEFAULT 0, + registered_installed_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + missing_permissions_json TEXT NOT NULL DEFAULT '[]', + missing_events_json TEXT NOT NULL DEFAULT '[]', + permissions_json TEXT NOT NULL DEFAULT '{}', + events_json TEXT NOT NULL DEFAULT '[]', + checked_at TEXT NOT NULL, + error_summary TEXT +); diff --git a/migrations/0004_scoring_intelligence.sql b/migrations/0004_scoring_intelligence.sql new file mode 100644 index 0000000000..008021af91 --- /dev/null +++ b/migrations/0004_scoring_intelligence.sql @@ -0,0 +1,87 @@ +CREATE TABLE IF NOT EXISTS scoring_model_snapshots ( + id TEXT PRIMARY KEY, + source_kind TEXT NOT NULL, + source_url TEXT NOT NULL, + fetched_at TEXT NOT NULL, + active_model TEXT NOT NULL, + constants_json TEXT NOT NULL DEFAULT '{}', + programming_languages_json TEXT NOT NULL DEFAULT '{}', + registry_snapshot_id TEXT, + warnings_json TEXT NOT NULL DEFAULT '[]', + payload_json TEXT NOT NULL DEFAULT '{}' +); + +CREATE INDEX IF NOT EXISTS scoring_model_snapshots_fetched_at_idx + ON scoring_model_snapshots (fetched_at); + +CREATE TABLE IF NOT EXISTS score_previews ( + id TEXT PRIMARY KEY, + scoring_model_snapshot_id TEXT NOT NULL, + repo_full_name TEXT NOT NULL, + target_type TEXT NOT NULL, + target_key TEXT NOT NULL, + contributor_login TEXT, + input_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT NOT NULL DEFAULT '{}', + generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS score_previews_repo_target_idx + ON score_previews (repo_full_name, target_key, generated_at); + +CREATE TABLE IF NOT EXISTS contributor_evidence ( + login TEXT PRIMARY KEY, + payload_json TEXT NOT NULL DEFAULT '{}', + generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS contributor_scoring_profiles ( + login TEXT PRIMARY KEY, + scoring_model_snapshot_id TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS issue_quality_reports ( + id TEXT PRIMARY KEY, + repo_full_name TEXT NOT NULL, + issue_number INTEGER NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS issue_quality_reports_repo_issue_unique + ON issue_quality_reports (repo_full_name, issue_number); + +CREATE TABLE IF NOT EXISTS burden_forecasts ( + repo_full_name TEXT PRIMARY KEY, + payload_json TEXT NOT NULL DEFAULT '{}', + generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS registry_drift_events ( + id TEXT PRIMARY KEY, + repo_full_name TEXT NOT NULL, + drift_type TEXT NOT NULL, + detail TEXT NOT NULL, + previous_snapshot_id TEXT, + current_snapshot_id TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS registry_drift_events_repo_idx + ON registry_drift_events (repo_full_name, generated_at); + +CREATE TABLE IF NOT EXISTS bounty_lifecycle_events ( + id TEXT PRIMARY KEY, + bounty_id TEXT NOT NULL, + repo_full_name TEXT NOT NULL, + issue_number INTEGER NOT NULL, + status TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS bounty_lifecycle_events_bounty_idx + ON bounty_lifecycle_events (bounty_id, generated_at); diff --git a/migrations/0005_sync_fidelity.sql b/migrations/0005_sync_fidelity.sql new file mode 100644 index 0000000000..da5d7285bb --- /dev/null +++ b/migrations/0005_sync_fidelity.sql @@ -0,0 +1,59 @@ +CREATE TABLE IF NOT EXISTS repo_sync_segments ( + id TEXT PRIMARY KEY, + repo_full_name TEXT NOT NULL, + segment TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'never_synced', + source_kind TEXT NOT NULL DEFAULT 'github', + mode TEXT NOT NULL DEFAULT 'light', + last_cursor TEXT, + next_cursor TEXT, + fetched_count INTEGER NOT NULL DEFAULT 0, + expected_count INTEGER, + page_count INTEGER NOT NULL DEFAULT 0, + started_at TEXT, + completed_at TEXT, + stale_at TEXT, + rate_limit_reset_at TEXT, + etag TEXT, + last_modified TEXT, + warnings_json TEXT NOT NULL DEFAULT '[]', + error_summary TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(repo_full_name, segment) +); + +CREATE INDEX IF NOT EXISTS repo_sync_segments_repo_status_idx + ON repo_sync_segments (repo_full_name, status); + +CREATE INDEX IF NOT EXISTS repo_sync_segments_segment_status_idx + ON repo_sync_segments (segment, status, updated_at); + +CREATE TABLE IF NOT EXISTS github_rate_limit_observations ( + id TEXT PRIMARY KEY, + repo_full_name TEXT, + resource TEXT NOT NULL DEFAULT 'rest', + path TEXT NOT NULL, + status_code INTEGER NOT NULL, + limit_value INTEGER, + remaining INTEGER, + reset_at TEXT, + observed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS github_rate_limit_observations_repo_observed_idx + ON github_rate_limit_observations (repo_full_name, observed_at); + +CREATE INDEX IF NOT EXISTS github_rate_limit_observations_reset_idx + ON github_rate_limit_observations (reset_at); + +CREATE INDEX IF NOT EXISTS pull_requests_author_repo_state_idx + ON pull_requests (author_login, repo_full_name, state); + +CREATE INDEX IF NOT EXISTS issues_author_repo_state_idx + ON issues (author_login, repo_full_name, state); + +CREATE INDEX IF NOT EXISTS recent_merged_pull_requests_author_repo_merged_idx + ON recent_merged_pull_requests (author_login, repo_full_name, merged_at); + +CREATE INDEX IF NOT EXISTS signal_snapshots_repo_signal_generated_idx + ON signal_snapshots (repo_full_name, signal_type, generated_at); diff --git a/migrations/0006_open_data_completeness.sql b/migrations/0006_open_data_completeness.sql new file mode 100644 index 0000000000..cd4c2eb91a --- /dev/null +++ b/migrations/0006_open_data_completeness.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS repo_github_totals_snapshots ( + id TEXT PRIMARY KEY, + repo_full_name TEXT NOT NULL, + open_issues_total INTEGER NOT NULL DEFAULT 0, + open_pull_requests_total INTEGER NOT NULL DEFAULT 0, + merged_pull_requests_total INTEGER NOT NULL DEFAULT 0, + closed_unmerged_pull_requests_total INTEGER NOT NULL DEFAULT 0, + labels_total INTEGER NOT NULL DEFAULT 0, + source_kind TEXT NOT NULL DEFAULT 'github', + fetched_at TEXT NOT NULL, + rate_limit_remaining INTEGER, + rate_limit_reset_at TEXT, + payload_json TEXT NOT NULL DEFAULT '{}' +); + +CREATE INDEX IF NOT EXISTS repo_github_totals_repo_fetched_idx + ON repo_github_totals_snapshots(repo_full_name, fetched_at); + +CREATE TABLE IF NOT EXISTS pull_request_detail_sync_state ( + id TEXT PRIMARY KEY, + repo_full_name TEXT NOT NULL, + pull_number INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'never_synced', + files_synced_at TEXT, + reviews_synced_at TEXT, + checks_synced_at TEXT, + last_synced_at TEXT, + error_summary TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX IF NOT EXISTS pull_request_detail_sync_repo_pull_unique + ON pull_request_detail_sync_state(repo_full_name, pull_number); + +CREATE INDEX IF NOT EXISTS pull_request_detail_sync_repo_status_idx + ON pull_request_detail_sync_state(repo_full_name, status); diff --git a/migrations/0007_current_open_reconciliation.sql b/migrations/0007_current_open_reconciliation.sql new file mode 100644 index 0000000000..a0f8bb885e --- /dev/null +++ b/migrations/0007_current_open_reconciliation.sql @@ -0,0 +1,8 @@ +ALTER TABLE pull_requests ADD COLUMN last_seen_open_at TEXT; +ALTER TABLE issues ADD COLUMN last_seen_open_at TEXT; + +CREATE INDEX IF NOT EXISTS pull_requests_repo_state_seen_idx + ON pull_requests (repo_full_name, state, last_seen_open_at); + +CREATE INDEX IF NOT EXISTS issues_repo_state_seen_idx + ON issues (repo_full_name, state, last_seen_open_at); diff --git a/migrations/0008_private_beta_auth.sql b/migrations/0008_private_beta_auth.sql new file mode 100644 index 0000000000..372351ad51 --- /dev/null +++ b/migrations/0008_private_beta_auth.sql @@ -0,0 +1,32 @@ +CREATE TABLE IF NOT EXISTS auth_sessions ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + login TEXT NOT NULL, + github_user_id INTEGER, + scopes_json TEXT NOT NULL DEFAULT '[]', + expires_at TEXT NOT NULL, + revoked_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}' +); + +CREATE INDEX IF NOT EXISTS auth_sessions_login_idx ON auth_sessions(login); +CREATE INDEX IF NOT EXISTS auth_sessions_expires_idx ON auth_sessions(expires_at); +CREATE INDEX IF NOT EXISTS auth_sessions_revoked_idx ON auth_sessions(revoked_at); + +CREATE TABLE IF NOT EXISTS audit_events ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + actor TEXT, + route TEXT, + target_key TEXT, + outcome TEXT NOT NULL, + detail TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS audit_events_type_created_idx ON audit_events(event_type, created_at); +CREATE INDEX IF NOT EXISTS audit_events_actor_created_idx ON audit_events(actor, created_at); +CREATE INDEX IF NOT EXISTS audit_events_route_created_idx ON audit_events(route, created_at); diff --git a/package-lock.json b/package-lock.json index 9a9e05ab88..9c12941893 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "gittensory", "version": "0.1.0", + "license": "AGPL-3.0-only", + "workspaces": [ + "packages/*" + ], "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", "@hono/zod-validator": "^0.7.4", @@ -18,8 +22,12 @@ "zod": "^3.25.76" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.16.9", "@types/node": "^24.10.1", + "@vitest/coverage-v8": "^4.1.7", "drizzle-kit": "^0.31.7", + "fast-check": "^4.8.0", + "git-cliff": "^2.13.1", "node-addon-api": "^8.5.0", "node-gyp": "^12.1.0", "typescript": "^5.9.3", @@ -108,6 +116,42 @@ "zod": "^3.20.2" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -129,6 +173,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@cfworker/json-schema": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", @@ -161,6 +229,25 @@ } } }, + "node_modules/@cloudflare/vitest-pool-workers": { + "version": "0.16.9", + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.16.9.tgz", + "integrity": "sha512-qdohrJbLhuB3mq3j/Vg4nHQU+Gw0ZhQErlZ7xr9A2VpP1F4QzCewwwJmWZnXlwU2rMbvGVwwOD91Eb39EvfQmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cjs-module-lexer": "^1.2.3", + "esbuild": "0.27.3", + "miniflare": "4.20260521.0", + "wrangler": "4.94.0", + "zod": "^3.25.76" + }, + "peerDependencies": { + "@vitest/runner": "^4.1.0", + "@vitest/snapshot": "^4.1.0", + "vitest": "^4.1.0" + } + }, "node_modules/@cloudflare/workerd-darwin-64": { "version": "1.20260521.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260521.1.tgz", @@ -1380,6 +1467,10 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, + "node_modules/@jsonbored/gittensory-mcp": { + "resolved": "packages/gittensory-mcp", + "link": true + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", @@ -1878,6 +1969,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", @@ -1891,6 +1989,19 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@speed-highlight/core": { "version": "1.2.15", "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", @@ -1972,6 +2083,37 @@ "node": ">= 20" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", @@ -2275,6 +2417,29 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -2386,6 +2551,13 @@ "node": ">=18" } }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -2900,6 +3072,33 @@ "node": ">=18.0.0" } }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2996,6 +3195,29 @@ "node": ">= 0.6" } }, + "node_modules/fast-check": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-content-type-parse": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", @@ -3051,6 +3273,22 @@ } } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3172,6 +3410,23 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { "version": "4.14.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", @@ -3185,6 +3440,114 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/git-cliff": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/git-cliff/-/git-cliff-2.13.1.tgz", + "integrity": "sha512-2BzXwrom+SMHeNA5Ut+MtzqXejg0wbVwmzj3k7e9w62UQoyCDrM9UIcvtl6hnT3jocEQ1zLRQBaXXx97Gmnk7A==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "execa": "^9.6.0" + }, + "bin": { + "git-cliff": "lib/cli/cli.js" + }, + "engines": { + "node": "^18.19 || >=20.6" + }, + "optionalDependencies": { + "git-cliff-darwin-arm64": "2.13.1", + "git-cliff-darwin-x64": "2.13.1", + "git-cliff-linux-arm64": "2.13.1", + "git-cliff-linux-x64": "2.13.1", + "git-cliff-windows-arm64": "2.13.1", + "git-cliff-windows-x64": "2.13.1" + } + }, + "node_modules/git-cliff-darwin-arm64": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/git-cliff-darwin-arm64/-/git-cliff-darwin-arm64-2.13.1.tgz", + "integrity": "sha512-3ebPUnUlLmrSZDHknSZQmyZV7DEJVtKse8I25Am0cENET5Py9u9Hg9k8IRXdiDtHtPDs6MYZx7BOi11WtcfqSg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/git-cliff-darwin-x64": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/git-cliff-darwin-x64/-/git-cliff-darwin-x64-2.13.1.tgz", + "integrity": "sha512-KZfggGAiw1EvZH3BOUclEU4eGCP2+Z+lH/N2Ni3FH9L2M1U7FYJqqaMhqgO8azTj67betvDshH8WBUkIkSsVxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/git-cliff-linux-arm64": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/git-cliff-linux-arm64/-/git-cliff-linux-arm64-2.13.1.tgz", + "integrity": "sha512-clNRcNzdvk4GKyTt3ZhhWqcrjVRghcUcGNSV/Y87YJf3Mc/zl9ajUAhnXPOBSX/KWBr2od5SmkCt0qGYagY07g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/git-cliff-linux-x64": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/git-cliff-linux-x64/-/git-cliff-linux-x64-2.13.1.tgz", + "integrity": "sha512-gZcIQhIQ1lDUMD8UieUSEZAYoyZN7nPd8O3VQoj1Ddhqll4UAS1Zxms1SU3U8pb0UTdc2m3ia/wtOnyhvbjdjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/git-cliff-windows-arm64": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/git-cliff-windows-arm64/-/git-cliff-windows-arm64-2.13.1.tgz", + "integrity": "sha512-+XubuQv68DuDwF0u6Af5d889MEf/RD48VBQS7ZmPi4sUSCXAZqRm1/ApCsStLqxMDCf1+7s05B/kNbm9wjV80A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/git-cliff-windows-x64": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/git-cliff-windows-x64/-/git-cliff-windows-x64-2.13.1.tgz", + "integrity": "sha512-Bi8ehp1VMkomY7M/356PgovKQ8CBRiuOOkq+aWC2evQuMFfXWjG0GBlPED9QkZoUJ4iQ5ygB5DYdK3BjhaOyPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3204,6 +3567,16 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3237,6 +3610,13 @@ "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3257,6 +3637,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -3318,12 +3708,51 @@ "node": ">=0.10.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", @@ -3334,6 +3763,58 @@ "node": ">=20" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -3349,6 +3830,13 @@ "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", "license": "BSD-3-Clause" }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -3707,6 +4195,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3947,6 +4463,36 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4009,6 +4555,19 @@ "yaml": "^2.8.0" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4160,6 +4719,22 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -4183,6 +4758,23 @@ "node": ">= 0.10" } }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -4586,6 +5178,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -4672,6 +5277,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -4840,6 +5458,19 @@ "pathe": "^2.0.3" } }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universal-user-agent": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", @@ -5227,6 +5858,19 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", @@ -5269,6 +5913,21 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "packages/gittensory-mcp": { + "name": "@jsonbored/gittensory-mcp", + "version": "0.1.0", + "license": "AGPL-3.0-only", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "zod": "^3.25.76" + }, + "bin": { + "gittensory-mcp": "bin/gittensory-mcp.js" + }, + "engines": { + "node": ">=22.0.0" + } } } } diff --git a/package.json b/package.json index 05384fbdb4..395105a202 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,10 @@ "name": "gittensory", "version": "0.1.0", "private": true, + "license": "AGPL-3.0-only", + "workspaces": [ + "packages/*" + ], "type": "module", "description": "GitHub-native advisory backend for Gittensor registered repositories.", "scripts": { @@ -11,10 +15,18 @@ "db:migrate:local": "wrangler d1 migrations apply gittensory --local", "db:migrate:remote": "wrangler d1 migrations apply gittensory --remote", "drizzle:generate": "drizzle-kit generate", + "build:mcp": "npm --workspace @jsonbored/gittensory-mcp run build", + "test:mcp-pack": "node scripts/check-mcp-package.mjs", + "changelog": "git-cliff --config cliff.toml --output CHANGELOG.md", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:unit": "vitest run test/unit", + "test:integration": "vitest run test/integration", + "test:workers": "vitest run --config vitest.workers.config.ts", + "test:coverage": "vitest run --coverage", + "test:ci": "npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm audit --audit-level=moderate", "test:watch": "vitest", - "validate": "npm run typecheck && npm test" + "validate": "npm run typecheck && npm run test:coverage" }, "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", @@ -27,8 +39,12 @@ "zod": "^3.25.76" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.16.9", "@types/node": "^24.10.1", + "@vitest/coverage-v8": "^4.1.7", "drizzle-kit": "^0.31.7", + "fast-check": "^4.8.0", + "git-cliff": "^2.13.1", "node-addon-api": "^8.5.0", "node-gyp": "^12.1.0", "typescript": "^5.9.3", @@ -40,5 +56,19 @@ }, "overrides": { "esbuild": "^0.25.4" - } + }, + "main": "index.js", + "directories": { + "test": "test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/JSONbored/gittensory.git" + }, + "keywords": [], + "author": "", + "bugs": { + "url": "https://github.com/JSONbored/gittensory/issues" + }, + "homepage": "https://github.com/JSONbored/gittensory#readme" } diff --git a/packages/gittensory-mcp/LICENSE b/packages/gittensory-mcp/LICENSE new file mode 100644 index 0000000000..bf5e0ae1b1 --- /dev/null +++ b/packages/gittensory-mcp/LICENSE @@ -0,0 +1,3 @@ +AGPL-3.0-only + +The full license text is available in the root Gittensory repository. diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md new file mode 100644 index 0000000000..a0099c29df --- /dev/null +++ b/packages/gittensory-mcp/README.md @@ -0,0 +1,21 @@ +# @jsonbored/gittensory-mcp + +Local stdio MCP wrapper for Gittensory contributor intelligence. + +It inspects local git metadata and calls the private Gittensory API for branch preflight, score blockers, reward/risk reasoning, contributor decision packs, and public-safe PR packets. It does not upload source contents in v1. + +```bash +npx @jsonbored/gittensory-mcp login +npx @jsonbored/gittensory-mcp status +npx @jsonbored/gittensory-mcp analyze-branch --login jsonbored --json +npx @jsonbored/gittensory-mcp --stdio +``` + +Environment overrides: + +- `GITTENSORY_API_URL` +- `GITTENSORY_CONFIG_PATH` or `GITTENSORY_CONFIG_DIR` +- `GITTENSORY_API_TOKEN`, `GITTENSORY_MCP_TOKEN`, or `GITTENSORY_TOKEN` +- `GITHUB_TOKEN` for non-interactive login bootstrap +- `GITTENSOR_SCORE_PREVIEW_CMD` +- `GITTENSORY_UPLOAD_SOURCE=false` diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js new file mode 100755 index 0000000000..3d1ffd8d87 --- /dev/null +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -0,0 +1,609 @@ +#!/usr/bin/env node +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, setupGuidanceForLocalScorer } from "../lib/local-branch.js"; + +const defaultApiUrl = "https://gittensory-api.zeronode.workers.dev"; +const configPath = + process.env.GITTENSORY_CONFIG_PATH ?? + (process.env.GITTENSORY_CONFIG_DIR + ? join(process.env.GITTENSORY_CONFIG_DIR, "config.json") + : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "gittensory", "config.json")); +const config = loadConfig(); +const apiUrl = (process.env.GITTENSORY_API_URL ?? config.apiUrl ?? defaultApiUrl).replace(/\/+$/, ""); + +const ownerRepoShape = { + owner: z.string().min(1), + repo: z.string().min(1), +}; + +const loginShape = { + login: z.string().min(1), +}; + +const loginRepoShape = { + login: z.string().min(1), + owner: z.string().min(1), + repo: z.string().min(1), +}; + +const preflightShape = { + repoFullName: z.string().min(3), + contributorLogin: z.string().min(1).optional(), + title: z.string().min(1), + body: z.string().optional(), + labels: z.array(z.string()).optional(), + changedFiles: z.array(z.string()).optional(), + linkedIssues: z.array(z.number().int().positive()).optional(), + tests: z.array(z.string()).optional(), + authorAssociation: z.string().optional(), +}; + +const localDiffShape = { + repoFullName: z.string().min(3), + cwd: z.string().optional(), + baseRef: z.string().default("HEAD"), + contributorLogin: z.string().min(1).optional(), + title: z.string().optional(), + body: z.string().optional(), + labels: z.array(z.string()).optional(), + linkedIssues: z.array(z.number().int().positive()).optional(), + tests: z.array(z.string()).optional(), + authorAssociation: z.string().optional(), + commitMessage: z.string().optional(), +}; + +const localScoreShape = { + ...localDiffShape, + targetKey: z.string().optional(), + sourceTokenScore: z.number().min(0).optional(), + totalTokenScore: z.number().min(0).optional(), + sourceLines: z.number().min(0).optional(), + linkedIssueMode: z.enum(["none", "standard", "maintainer"]).default("none"), + openPrCount: z.number().int().min(0).optional(), + credibility: z.number().min(0).max(1).optional(), + changesRequestedCount: z.number().int().min(0).optional(), + scorePreviewCommand: z.string().optional(), +}; + +const variantsShape = { + variants: z.array(z.object(localScoreShape)).min(1).max(10), +}; + +const currentBranchShape = { + login: z.string().min(1), + cwd: z.string().optional(), + repoFullName: z.string().min(3).optional(), + baseRef: z.string().optional(), + headRef: z.string().optional(), + branchName: z.string().optional(), + title: z.string().optional(), + body: z.string().optional(), + labels: z.array(z.string()).optional(), + linkedIssues: z.array(z.number().int().positive()).optional(), + validation: z + .array( + z.object({ + command: z.string().min(1), + status: z.enum(["passed", "failed", "not_run"]), + summary: z.string().optional(), + }), + ) + .optional(), + scorePreviewCommand: z.string().optional(), +}; + +const currentBranchVariantsShape = { + variants: z.array(z.object(currentBranchShape)).min(1).max(10), +}; + +const cliArgs = process.argv.slice(2); +if (cliArgs[0] && cliArgs[0] !== "--stdio") { + await runCli(cliArgs); + process.exit(0); +} + +const server = new McpServer({ + name: "gittensory-local", + version: "0.1.0", +}); + +server.registerTool( + "gittensory_get_repo_context", + { + description: "Return the canonical repo intelligence bundle from the private Gittensory API.", + inputSchema: ownerRepoShape, + }, + async ({ owner, repo }) => { + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + return toolResult("Gittensory repo intelligence.", await apiGet(`${prefix}/intelligence`)); + }, +); + +server.registerTool( + "gittensory_preflight_pr", + { + description: "Preflight planned PR metadata against lane, duplicate, linked issue, test, and queue signals.", + inputSchema: preflightShape, + }, + async (input) => toolResult("Gittensory PR preflight.", await apiPost("/v1/preflight/pr", input)), +); + +server.registerTool( + "gittensory_preflight_local_diff", + { + description: "Inspect local git diff metadata and run Gittensory preflight without uploading source contents.", + inputSchema: localDiffShape, + }, + async (input) => { + const diff = collectLocalDiff(input.cwd ?? process.cwd(), input.baseRef); + const body = { + repoFullName: input.repoFullName, + contributorLogin: input.contributorLogin, + title: input.title ?? diff.title, + body: input.body, + labels: input.labels, + linkedIssues: input.linkedIssues, + tests: input.tests, + authorAssociation: input.authorAssociation, + commitMessage: input.commitMessage ?? diff.commitMessage, + changedFiles: diff.changedFiles, + testFiles: diff.testFiles, + changedLineCount: diff.changedLineCount, + }; + return toolResult("Gittensory local diff preflight.", await apiPost("/v1/preflight/local-diff", body)); + }, +); + +server.registerTool( + "gittensory_get_registry_changes", + { + description: "Return latest cached Gittensor registry change report.", + inputSchema: {}, + }, + async () => toolResult("Gittensory registry changes.", await apiGet("/v1/registry/changes")), +); + +server.registerTool( + "gittensory_preview_local_pr_score", + { + description: "Inspect local diff metadata and request a private Gittensory scoring preview. No source contents are uploaded.", + inputSchema: localScoreShape, + }, + async (input) => toolResult("Gittensory private local PR scoring preview.", await previewLocalScore(input)), +); + +server.registerTool( + "gittensory_get_decision_pack", + { + description: "Return the canonical private contributor decision pack for a GitHub login.", + inputSchema: loginShape, + }, + async ({ login }) => toolResult(`Gittensory decision pack for ${login}.`, await apiGet(`/v1/contributors/${encodeURIComponent(login)}/decision-pack`)), +); + +server.registerTool( + "gittensory_explain_repo_decision", + { + description: "Return the contributor/repo decision from the canonical decision pack.", + inputSchema: loginRepoShape, + }, + async ({ login, owner, repo }) => + toolResult( + `Gittensory repo decision for ${login} in ${owner}/${repo}.`, + await apiGet(`/v1/contributors/${encodeURIComponent(login)}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/decision`), + ), +); + +server.registerTool( + "gittensory_compare_pr_variants", + { + description: "Compare private Gittensory scoring previews across local/metadata variants.", + inputSchema: variantsShape, + }, + async ({ variants }) => { + const previews = []; + for (const variant of variants) previews.push(await previewLocalScore({ ...variant, targetKey: variant.targetKey ?? `variant:${previews.length + 1}` })); + previews.sort((left, right) => Number(right?.remotePreview?.result?.scoreEstimate?.estimatedMergedScore ?? 0) - Number(left?.remotePreview?.result?.scoreEstimate?.estimatedMergedScore ?? 0)); + return toolResult("Gittensory PR variant comparison.", { variants: previews }); + }, +); + +server.registerTool( + "gittensory_local_status", + { + description: "Return local Gittensory MCP status, inferred git repo metadata, and privacy defaults.", + inputSchema: { + cwd: z.string().optional(), + baseRef: z.string().optional(), + repoFullName: z.string().min(3).optional(), + }, + }, + async (input) => { + let git = null; + try { + git = collectLocalBranchMetadata({ cwd: input.cwd ?? process.cwd(), baseRef: input.baseRef, repoFullName: input.repoFullName, login: "local" }); + } catch (error) { + git = { error: error instanceof Error ? error.message : "local_status_failed" }; + } + return toolResult("Gittensory local MCP status.", { + apiUrl, + hasToken: Boolean(getApiToken()), + authLogin: config.session?.login ?? null, + sessionExpiresAt: config.session?.expiresAt ?? null, + sourceUploadDefault: false, + sourceUploadSupported: false, + git, + }); + }, +); + +server.registerTool( + "gittensory_preflight_current_branch", + { + description: "Analyze the current git branch and return PR readiness. Sends metadata only.", + inputSchema: currentBranchShape, + }, + async (input) => { + const result = await analyzeCurrentBranch(input); + return toolResult("Gittensory current-branch preflight.", { local: result.local, preflight: result.analysis.preflight, prPacket: result.analysis.prPacket }); + }, +); + +server.registerTool( + "gittensory_preview_current_branch_score", + { + description: "Analyze the current git branch and return private scoreability context. Sends metadata only.", + inputSchema: currentBranchShape, + }, + async (input) => { + const result = await analyzeCurrentBranch(input); + return toolResult("Gittensory current-branch private score preview.", { local: result.local, scorePreview: result.analysis.scorePreview, scoreBlockers: result.analysis.scoreBlockers }); + }, +); + +server.registerTool( + "gittensory_rank_local_next_actions", + { + description: "Analyze the current git branch and rank local next actions by private reward/risk and review friction.", + inputSchema: currentBranchShape, + }, + async (input) => { + const result = await analyzeCurrentBranch(input); + return toolResult("Gittensory local next-action ranking.", { local: result.local, nextActions: result.analysis.nextActions, rewardRisk: result.analysis.rewardRisk }); + }, +); + +server.registerTool( + "gittensory_explain_local_blockers", + { + description: "Analyze the current git branch and explain private scoreability, lane, and review blockers.", + inputSchema: currentBranchShape, + }, + async (input) => { + const result = await analyzeCurrentBranch(input); + return toolResult("Gittensory local blocker explanation.", { local: result.local, scoreBlockers: result.analysis.scoreBlockers, localFindings: result.analysis.localFindings }); + }, +); + +server.registerTool( + "gittensory_prepare_pr_packet", + { + description: "Analyze the current git branch and return a public-safe PR packet. Sends metadata only.", + inputSchema: currentBranchShape, + }, + async (input) => { + const result = await analyzeCurrentBranch(input); + return toolResult("Gittensory public-safe PR packet.", { local: result.local, prPacket: result.analysis.prPacket }); + }, +); + +server.registerTool( + "gittensory_compare_local_variants", + { + description: "Compare current-branch metadata variants without uploading source contents.", + inputSchema: currentBranchVariantsShape, + }, + async ({ variants }) => { + const analyses = []; + for (const variant of variants) analyses.push(await analyzeCurrentBranch(variant)); + analyses.sort( + (left, right) => + Number(right.analysis.nextActions?.[0]?.priorityScore ?? 0) - Number(left.analysis.nextActions?.[0]?.priorityScore ?? 0) || + Number(right.analysis.scorePreview?.scoreEstimate?.estimatedMergedScore ?? 0) - Number(left.analysis.scorePreview?.scoreEstimate?.estimatedMergedScore ?? 0), + ); + return toolResult("Gittensory local variant comparison.", { + variants: analyses.map((entry) => ({ + local: entry.local, + preflightStatus: entry.analysis.preflight.status, + scoreBlockers: entry.analysis.scoreBlockers, + topAction: entry.analysis.nextActions?.[0] ?? null, + prPacket: entry.analysis.prPacket, + })), + }); + }, +); + +await server.connect(new StdioServerTransport()); + +async function runCli(args) { + const command = args[0]; + if (command === "--help" || command === "help") return printHelp(); + const options = parseOptions(args.slice(1)); + if (command === "login") return login(options); + if (command === "logout") return logout(options); + if (command === "whoami") return whoami(options); + if (command === "status") return status(options); + if (command !== "analyze-branch" && command !== "preflight") throw new Error(`Unknown command: ${command}`); + const contributorLogin = options.login ?? process.env.GITTENSORY_LOGIN ?? process.env.GITHUB_LOGIN; + if (!contributorLogin) throw new Error("Pass --login or set GITTENSORY_LOGIN."); + const result = await analyzeCurrentBranch({ + login: contributorLogin, + cwd: options.cwd, + repoFullName: options.repo, + baseRef: options.base, + title: options.title, + body: options.body, + labels: options.label, + linkedIssues: options.issue?.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value > 0), + scorePreviewCommand: options.scorePreviewCommand, + }); + const payload = command === "preflight" ? { local: result.local, preflight: result.analysis.preflight, prPacket: result.analysis.prPacket } : result; + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write(`${result.analysis.summary}\n`); + process.stdout.write(`Top action: ${result.analysis.nextActions?.[0]?.actionKind ?? "none"}\n`); + process.stdout.write(`Preflight: ${result.analysis.preflight.status}\n`); + process.stdout.write(`Source upload: disabled\n`); +} + +function printHelp() { + process.stdout.write(`Usage: + gittensory-mcp --stdio + gittensory-mcp login [--github-token ] [--json] + gittensory-mcp logout [--json] + gittensory-mcp whoami [--json] + gittensory-mcp status [--json] + gittensory-mcp analyze-branch --login [--repo owner/repo] [--base origin/main] [--json] + gittensory-mcp preflight --login [--repo owner/repo] [--base origin/main] [--json] + +Environment: + GITTENSORY_API_URL + GITTENSORY_CONFIG_PATH or GITTENSORY_CONFIG_DIR + GITTENSORY_API_TOKEN, GITTENSORY_MCP_TOKEN, GITTENSORY_TOKEN, or a session from gittensory-mcp login + GITHUB_TOKEN for non-interactive login bootstrap + GITTENSOR_SCORE_PREVIEW_CMD + GITTENSOR_ROOT + GITTENSORY_UPLOAD_SOURCE=false +`); +} + +function parseOptions(args) { + const options = {}; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--json") { + options.json = true; + continue; + } + if (!arg?.startsWith("--")) continue; + const key = camel(arg.slice(2)); + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + options[key] = true; + continue; + } + index += 1; + if (key === "label" || key === "issue") options[key] = [...(options[key] ?? []), value]; + else options[key] = value; + } + return options; +} + +async function login(options) { + const githubToken = options.githubToken ?? process.env.GITHUB_TOKEN; + const session = githubToken ? await apiFetch("/v1/auth/github/session", { method: "POST", body: JSON.stringify({ githubToken }) }, { auth: false }) : await loginWithDeviceFlow(); + saveConfig({ + ...config, + apiUrl, + session: { + token: session.token, + login: session.login, + expiresAt: session.expiresAt, + scopes: session.scopes ?? [], + }, + }); + const payload = { status: "authenticated", login: session.login, apiUrl, expiresAt: session.expiresAt }; + if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + else process.stdout.write(`Authenticated as ${session.login}. Session expires ${session.expiresAt}.\n`); +} + +async function loginWithDeviceFlow() { + const start = await apiFetch("/v1/auth/github/device/start", { method: "POST", body: "{}" }, { auth: false }); + process.stderr.write(`Open ${start.verificationUri} and enter code ${start.userCode}.\n`); + const deadline = Date.now() + Number(start.expiresIn ?? 900) * 1000; + let intervalMs = Math.max(5, Number(start.interval ?? 5)) * 1000; + while (Date.now() < deadline) { + await sleep(intervalMs); + const result = await apiFetch("/v1/auth/github/device/poll", { method: "POST", body: JSON.stringify({ deviceCode: start.deviceCode }) }, { auth: false }); + if (result.token) return result; + if (result.status === "slow_down") intervalMs += 5000; + if (result.status && result.status !== "authorization_pending" && result.status !== "slow_down") throw new Error(`GitHub OAuth failed: ${result.status}`); + } + throw new Error("GitHub OAuth device flow expired."); +} + +async function logout(options) { + const token = getApiToken(); + let remote = null; + if (token) { + try { + remote = await apiFetch("/v1/auth/logout", { method: "POST", body: "{}" }); + } catch (error) { + remote = { error: error instanceof Error ? error.message : "logout_failed" }; + } + } + if (existsSync(configPath)) rmSync(configPath, { force: true }); + const payload = { status: "logged_out", apiUrl, remote }; + if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + else process.stdout.write("Logged out.\n"); +} + +async function whoami(options) { + const payload = await apiGet("/v1/auth/session"); + if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + else process.stdout.write(`${payload.login}\n`); +} + +async function status(options) { + let auth = { status: getApiToken() ? "token_configured" : "unauthenticated" }; + if (getApiToken()) { + try { + auth = await apiGet("/v1/auth/session"); + } catch (error) { + auth = { status: "token_configured", session: "unverified", error: error instanceof Error ? error.message : "status_failed" }; + } + } + const payload = { + apiUrl, + auth, + configPath, + sourceUploadDefault: false, + sourceUploadSupported: false, + }; + if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + else { + process.stdout.write(`API: ${apiUrl}\n`); + process.stdout.write(`Auth: ${auth.status}${auth.login ? ` (${auth.login})` : ""}\n`); + process.stdout.write("Source upload: disabled\n"); + } +} + +function getApiToken() { + return process.env.GITTENSORY_API_TOKEN ?? process.env.GITTENSORY_TOKEN ?? process.env.GITTENSORY_MCP_TOKEN ?? config.session?.token; +} + +function loadConfig() { + if (!existsSync(configPath)) return {}; + try { + return JSON.parse(readFileSync(configPath, "utf8")); + } catch { + return {}; + } +} + +function saveConfig(nextConfig) { + mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 }); + writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, { mode: 0o600 }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function apiGet(path) { + return apiFetch(path, { method: "GET" }); +} + +async function apiPost(path, body) { + return apiFetch(path, { method: "POST", body: JSON.stringify(body) }); +} + +async function apiFetch(path, init, options = {}) { + const token = getApiToken(); + if (options.auth !== false && !token) throw new Error("Run `gittensory-mcp login`, or set GITTENSORY_API_TOKEN, GITTENSORY_MCP_TOKEN, or GITTENSORY_TOKEN before starting the MCP wrapper."); + const response = await fetch(`${apiUrl}${path}`, { + ...init, + headers: { + ...(token && options.auth !== false ? { authorization: `Bearer ${token}` } : {}), + "content-type": "application/json", + accept: "application/json", + }, + }); + const text = await response.text(); + const payload = text ? JSON.parse(text) : {}; + if (!response.ok) { + const retry = response.headers.get("retry-after"); + throw new Error(`Gittensory API ${response.status}${retry ? ` retry-after=${retry}s` : ""}: ${JSON.stringify(payload).slice(0, 500)}`); + } + return payload; +} + +async function analyzeCurrentBranch(input) { + const payload = buildBranchAnalysisPayload(input); + const { localScorerStatus, ...body } = payload; + const analysis = await apiPost("/v1/local/branch-analysis", body); + return { + local: { + sourceUpload: false, + repoFullName: body.repoFullName, + baseRef: body.baseRef, + headRef: body.headRef, + branchName: body.branchName, + changedFileCount: body.changedFiles?.length ?? 0, + localScorerStatus, + setupGuidance: setupGuidanceForLocalScorer(localScorerStatus), + }, + analysis, + }; +} + +async function previewLocalScore(input) { + const cwd = input.cwd ?? process.cwd(); + const diff = collectLocalDiff(cwd, input.baseRef); + const branchPayload = buildBranchAnalysisPayload({ ...input, login: input.contributorLogin ?? "local", cwd, repoFullName: input.repoFullName, baseRef: input.baseRef }); + const upstreamPreview = branchPayload.localScorerStatus; + const estimatedSourceLines = input.sourceLines ?? Math.max(1, diff.changedLineCount - diff.testFiles.length); + const body = { + repoFullName: input.repoFullName, + targetType: "local_diff", + targetKey: input.targetKey ?? `${input.repoFullName}:${cwd}:${input.baseRef}`, + contributorLogin: input.contributorLogin, + labels: input.labels, + linkedIssueMode: input.linkedIssueMode, + sourceTokenScore: input.sourceTokenScore ?? estimatedSourceLines, + sourceLines: estimatedSourceLines, + totalTokenScore: input.totalTokenScore ?? diff.changedLineCount, + testTokenScore: diff.testFiles.length, + openPrCount: input.openPrCount, + credibility: input.credibility, + changesRequestedCount: input.changesRequestedCount, + metadataOnly: !upstreamPreview.ok, + }; + return { + localDiff: { + changedFiles: diff.changedFiles, + changedLineCount: diff.changedLineCount, + testFiles: diff.testFiles, + codeFiles: diff.codeFiles, + commitMessage: input.commitMessage ?? diff.commitMessage, + }, + upstreamPreview, + remotePreview: await apiPost("/v1/scoring/preview", body), + setupGuidance: upstreamPreview.ok + ? [] + : setupGuidanceForLocalScorer(upstreamPreview), + }; +} + +function toolResult(summary, data) { + return { + content: [ + { + type: "text", + text: `${summary}\n\n${JSON.stringify(data, null, 2)}`, + }, + ], + structuredContent: data, + }; +} + +function camel(value) { + return value.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); +} diff --git a/packages/gittensory-mcp/lib/local-branch.js b/packages/gittensory-mcp/lib/local-branch.js new file mode 100644 index 0000000000..f7c923b20c --- /dev/null +++ b/packages/gittensory-mcp/lib/local-branch.js @@ -0,0 +1,249 @@ +import { execFileSync } from "node:child_process"; + +export function parseGitRemote(remoteUrl) { + const trimmed = String(remoteUrl ?? "").trim(); + const patterns = [ + /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/, + /^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/, + /^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/, + ]; + for (const pattern of patterns) { + const match = trimmed.match(pattern); + if (match?.[1] && match[2]) return `${match[1]}/${match[2].replace(/\.git$/, "")}`; + } + return undefined; +} + +export function collectLocalDiff(cwd, baseRef) { + const metadata = collectLocalBranchMetadata({ cwd, baseRef, login: "local" }); + return { + title: metadata.title ?? "Local diff preflight", + commitMessage: metadata.commitMessages.join("\n\n").trim(), + changedFiles: metadata.changedFiles.map((file) => file.path), + changedLineCount: metadata.changedFiles.reduce((sum, file) => sum + (file.additions ?? 0) + (file.deletions ?? 0), 0), + testFiles: metadata.changedFiles.map((file) => file.path).filter(isTestFile), + codeFiles: metadata.changedFiles.map((file) => file.path).filter(isCodeFile), + }; +} + +export function collectLocalBranchMetadata(input) { + assertSourceUploadDisabled(); + const cwd = input.cwd ?? process.cwd(); + const baseRef = input.baseRef ?? defaultBaseRef(cwd); + const remoteUrl = gitLines(cwd, ["config", "--get", "remote.origin.url"])[0] ?? ""; + const repoFullName = input.repoFullName ?? parseGitRemote(remoteUrl); + if (!repoFullName) throw new Error("Could not infer repoFullName from git remote; pass --repo owner/repo."); + const branchName = input.branchName ?? gitLines(cwd, ["branch", "--show-current"])[0] ?? "local-branch"; + const headRef = input.headRef ?? gitLines(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])[0] ?? branchName; + const changedFiles = collectChangedFiles(cwd, baseRef); + const commitMessages = input.commitMessages ?? collectCommitMessages(cwd, baseRef); + const title = input.title ?? titleFromBranch(branchName) ?? firstCommitTitle(commitMessages); + const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssues([branchName, title, input.body, ...commitMessages].filter(Boolean).join("\n"))])].sort( + (left, right) => left - right, + ); + const payload = { + login: input.login, + repoFullName, + baseRef, + headRef, + branchName, + commitMessages, + changedFiles, + validation: input.validation, + linkedIssues, + labels: input.labels, + title, + body: input.body, + }; + return stripUndefined(payload); +} + +export function buildBranchAnalysisPayload(input) { + const metadata = collectLocalBranchMetadata(input); + const scorerCommand = input.scorePreviewCommand ?? process.env.GITTENSOR_SCORE_PREVIEW_CMD; + const externalPreview = runExternalScorePreview(metadata, scorerCommand); + const localScorer = externalPreview.ok ? normalizeScorerOutput(externalPreview.payload) : metadataOnlyScorer(externalPreview); + return { + ...metadata, + localScorer, + localScorerStatus: externalPreview, + }; +} + +export function runExternalScorePreview(metadata, scorerCommand) { + if (!scorerCommand) return { ok: false, reason: "missing_scorer_command" }; + try { + const [command, ...args] = splitCommand(scorerCommand); + if (!command) return { ok: false, reason: "empty_scorer_command" }; + const output = execFileSync(command, args, { + input: JSON.stringify({ + ...metadata, + gittensorRoot: process.env.GITTENSOR_ROOT, + }), + encoding: "utf8", + timeout: 15000, + stdio: ["pipe", "pipe", "pipe"], + }); + return { ok: true, payload: JSON.parse(output) }; + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : "external_scorer_failed" }; + } +} + +export function setupGuidanceForLocalScorer(status) { + if (status.ok) return []; + return [ + "Gittensory used metadata-only analysis because no external scorer succeeded.", + "Set GITTENSOR_SCORE_PREVIEW_CMD to a command that reads branch metadata JSON from stdin and emits scoring metrics JSON.", + "Set GITTENSOR_ROOT if your scorer needs a local entrius/gittensor checkout.", + ]; +} + +export function gitLines(cwd, args) { + try { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + } catch { + return []; + } +} + +function collectChangedFiles(cwd, baseRef) { + const statusRows = gitLines(cwd, ["diff", "--name-status", "-M", baseRef, "--"]); + const numstat = new Map(parseNumstat(cwd, baseRef).map((entry) => [entry.path, entry])); + return statusRows.map((row) => { + const fields = row.split(/\t/); + const code = fields[0] ?? ""; + const isRename = code.startsWith("R"); + const path = isRename ? fields[2] ?? fields[1] ?? "" : fields[1] ?? ""; + const previousPath = isRename ? fields[1] : undefined; + const stats = numstat.get(path) ?? { additions: 0, deletions: 0, binary: false }; + return stripUndefined({ + path, + previousPath, + additions: stats.additions, + deletions: stats.deletions, + status: statusFromCode(code), + binary: stats.binary, + }); + }); +} + +function parseNumstat(cwd, baseRef) { + return gitLines(cwd, ["diff", "--numstat", "-M", baseRef, "--"]).map((row) => { + const fields = row.split(/\t/); + const additions = fields[0] === "-" ? 0 : Number(fields[0] ?? 0); + const deletions = fields[1] === "-" ? 0 : Number(fields[1] ?? 0); + return { + path: normalizeNumstatPath(fields.slice(2).join("\t")), + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + binary: fields[0] === "-" || fields[1] === "-", + }; + }); +} + +function normalizeNumstatPath(path) { + const renamed = path.match(/\{.* => (.*)\}/); + return renamed?.[1] ? path.replace(/\{.* => (.*)\}/, renamed[1]) : path; +} + +function collectCommitMessages(cwd, baseRef) { + const rangeMessages = gitLines(cwd, ["log", "--pretty=%B%x1e", `${baseRef}..HEAD`]).join("\n"); + const messages = rangeMessages + .split("\u001e") + .map((message) => message.trim()) + .filter(Boolean); + if (messages.length > 0) return messages.slice(0, 30); + const last = gitLines(cwd, ["log", "-1", "--pretty=%B"]).join("\n").trim(); + return last ? [last] : []; +} + +function defaultBaseRef(cwd) { + const originHead = gitLines(cwd, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"])[0]; + if (originHead) return originHead; + if (gitLines(cwd, ["rev-parse", "--verify", "origin/main"]).length > 0) return "origin/main"; + if (gitLines(cwd, ["rev-parse", "--verify", "origin/master"]).length > 0) return "origin/master"; + return "HEAD"; +} + +function normalizeScorerOutput(payload) { + return stripUndefined({ + mode: "external_command", + activeModel: stringValue(payload.activeModel ?? payload.active_model), + sourceTokenScore: numberValue(payload.sourceTokenScore ?? payload.source_token_score ?? payload.source?.tokenScore), + totalTokenScore: numberValue(payload.totalTokenScore ?? payload.total_token_score ?? payload.total?.tokenScore), + sourceLines: numberValue(payload.sourceLines ?? payload.source_lines ?? payload.source?.lines), + testTokenScore: numberValue(payload.testTokenScore ?? payload.test_token_score ?? payload.tests?.tokenScore), + nonCodeTokenScore: numberValue(payload.nonCodeTokenScore ?? payload.non_code_token_score ?? payload.nonCode?.tokenScore), + warnings: Array.isArray(payload.warnings) ? payload.warnings.map(String) : undefined, + }); +} + +function metadataOnlyScorer(status) { + return { + mode: "metadata_only", + warnings: [status.reason ?? "external_scorer_unavailable"], + }; +} + +function splitCommand(command) { + return String(command).match(/(?:[^\s"]+|"[^"]*")+/g)?.map((part) => part.replace(/^"|"$/g, "")) ?? []; +} + +function assertSourceUploadDisabled() { + if (/^(1|true|yes)$/i.test(process.env.GITTENSORY_UPLOAD_SOURCE ?? "false")) { + throw new Error("GITTENSORY_UPLOAD_SOURCE=true is not supported in v1; local MCP sends metadata only."); + } +} + +function extractLinkedIssues(text) { + const issues = []; + for (const match of String(text).matchAll(/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|#)\s*#?(\d+)/gi)) issues.push(Number(match[1])); + return issues.filter((issue) => Number.isInteger(issue) && issue > 0); +} + +function statusFromCode(code) { + if (code.startsWith("A")) return "added"; + if (code.startsWith("M")) return "modified"; + if (code.startsWith("D")) return "deleted"; + if (code.startsWith("R")) return "renamed"; + if (code.startsWith("C")) return "copied"; + return "unknown"; +} + +function titleFromBranch(branchName) { + return String(branchName ?? "") + .replace(/^[-/_.\w]+\/(?=[^/]+$)/, "") + .replace(/[-_]+/g, " ") + .trim(); +} + +function firstCommitTitle(messages) { + return messages.find((message) => message.trim().length > 0)?.split("\n")[0]?.trim(); +} + +function isTestFile(file) { + return /(^|\/)(test|tests|spec|__tests__)\//i.test(file) || /\.(test|spec)\.(ts|tsx|js|jsx|py|rb|rs)$/i.test(file); +} + +function isCodeFile(file) { + return /\.(ts|tsx|js|jsx|py|rb|rs|kt|scala|java|go|sql)$/i.test(file) && !isTestFile(file); +} + +function numberValue(value) { + const number = Number(value); + return Number.isFinite(number) ? number : undefined; +} + +function stringValue(value) { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function stripUndefined(value) { + if (Array.isArray(value)) return value.map(stripUndefined); + if (!value || typeof value !== "object") return value; + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined).map(([key, entry]) => [key, stripUndefined(entry)])); +} diff --git a/packages/gittensory-mcp/package.json b/packages/gittensory-mcp/package.json new file mode 100644 index 0000000000..00344d33f3 --- /dev/null +++ b/packages/gittensory-mcp/package.json @@ -0,0 +1,44 @@ +{ + "name": "@jsonbored/gittensory-mcp", + "version": "0.1.0", + "license": "AGPL-3.0-only", + "type": "module", + "description": "Local stdio MCP wrapper for private Gittensory contributor intelligence.", + "repository": { + "type": "git", + "url": "git+https://github.com/JSONbored/gittensory.git", + "directory": "packages/gittensory-mcp" + }, + "homepage": "https://github.com/JSONbored/gittensory#readme", + "bugs": { + "url": "https://github.com/JSONbored/gittensory/issues" + }, + "keywords": [ + "gittensor", + "mcp", + "model-context-protocol", + "github", + "contributors", + "preflight" + ], + "publishConfig": { + "access": "public" + }, + "bin": { + "gittensory-mcp": "bin/gittensory-mcp.js" + }, + "files": [ + "bin", + "lib" + ], + "scripts": { + "build": "node --check bin/gittensory-mcp.js && node --check lib/local-branch.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "zod": "^3.25.76" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/scripts/check-mcp-package.mjs b/scripts/check-mcp-package.mjs new file mode 100644 index 0000000000..7ec54c84dc --- /dev/null +++ b/scripts/check-mcp-package.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const result = spawnSync("npm", ["pack", "--workspace", "@jsonbored/gittensory-mcp", "--dry-run", "--json"], { + encoding: "utf8", +}); + +if (result.status !== 0) { + process.stderr.write(result.stderr || result.stdout); + process.exit(result.status ?? 1); +} + +const [pack] = JSON.parse(result.stdout); +const files = pack.files.map((file) => file.path).sort(); +const allowed = [/^bin\/gittensory-mcp\.js$/, /^lib\/local-branch\.js$/, /^package\.json$/, /^README\.md$/, /^LICENSE$/]; +const forbiddenPath = /(^|\/)(\.dev\.vars|\.env|\.npmrc|.*\.pem|.*private.*key.*|.*secret.*)$/i; +const forbiddenContent = /(BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+|gts_[0-9a-f]{64}|[A-Z0-9_]*(TOKEN|SECRET|PRIVATE_KEY)=)/; + +for (const file of files) { + if (forbiddenPath.test(file)) throw new Error(`Forbidden file in MCP package: ${file}`); + if (!allowed.some((pattern) => pattern.test(file))) throw new Error(`Unexpected file in MCP package: ${file}`); + const fullPath = join("packages/gittensory-mcp", file); + const content = readFileSync(fullPath, "utf8"); + if (forbiddenContent.test(content)) throw new Error(`Secret-like content found in MCP package file: ${file}`); +} + +process.stdout.write(`MCP package dry-run ok: ${files.join(", ")}\n`); diff --git a/src/api/routes.ts b/src/api/routes.ts index 0eba104164..d824bce500 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1,47 +1,99 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { z } from "zod"; +import { createSessionFromGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow } from "../auth/github-oauth"; +import { enforceRateLimit, routeClassForPath } from "../auth/rate-limit"; +import { authenticateInternalToken, authenticatePrivateToken, authenticateSessionToken, extractBearerToken, revokeSession } from "../auth/security"; import { normalizeGittBountySnapshot } from "../bounties/ingest"; import { + countOpenIssues, + countOpenPullRequests, getBounty, getIssue, + getInstallationHealth, + getLatestRepoGithubTotalsSnapshot, + getLatestScoringModelSnapshot, getPullRequest, getRepository, getRepositorySettings, - listAllIssues, - listAllPullRequests, + recordAuditEvent, + getContributorEvidence, + listAllPullRequestDetailSyncStates, + listCheckSummaries, listBounties, listContributorIssues, listContributorPullRequests, + listContributorRepoStats, + listLatestGitHubRateLimitObservations, + listLatestRepoGithubTotalsSnapshots, + listInstallationHealth, + listInstallations, listIssues, - listOtherOpenPullRequests, - listOpenIssues, + listIssueSignalSample, listOpenPullRequests, + listPullRequestFiles, + listPullRequestReviews, + listRecentMergedPullRequests, + listRepoLabels, + listRepoSyncSegments, + listRepoSyncStates, + listSignalSnapshots, listPullRequests, listRepositories, - persistAdvisory, + persistScorePreview, + persistSignalSnapshot, upsertBounty, + upsertContributorEvidence, + upsertContributorScoringProfile, upsertRepositorySettings, } from "../db/repositories"; +import { + backfillOpenPullRequestDetails, + backfillRegisteredRepositories, + backfillRepositorySegment, + refreshContributorActivity, + refreshInstallationHealth, +} from "../github/backfill"; +import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; import { fetchPublicContributorProfile } from "../github/public"; import { handleGitHubWebhook } from "../github/webhook"; import { handleMcpRequest } from "../mcp/server"; import { buildOpenApiSpec } from "../openapi/spec"; -import { getLatestRegistrySnapshot, refreshRegistry } from "../registry/sync"; -import { buildIssueAdvisory, buildPullRequestAdvisory, buildRepositoryAdvisory } from "../rules/advisory"; +import { generateSignalSnapshots } from "../queue/processors"; +import { getLatestRegistrySnapshot, listLatestRegistrySnapshots, refreshRegistry } from "../registry/sync"; +import { getOrCreateScoringModelSnapshot, refreshScoringModelSnapshot } from "../scoring/model"; +import { buildScorePreview, makeScorePreviewRecord } from "../scoring/preview"; +import { + buildAndPersistContributorDecisionPack, + loadContributorDecisionPack, + loadFreshContributorDecisionPack, + repoDecisionFromPack, +} from "../services/decision-pack"; import { buildBountyAdvisory, + buildBurdenForecast, buildCollisionReport, buildConfigQuality, - buildContributorOpportunities, + buildContributorFit, + buildContributorOutcomeHistory, buildContributorProfile, - buildMaintainerPacket, + buildContributorScoringProfile, + buildContributorIntakeHealth, + buildLabelAudit, + buildLaneAdvice, + buildLocalDiffPreflightResult, + buildMaintainerCutReadiness, + buildMaintainerLaneReport, + buildPullRequestMaintainerPacket, buildPreflightResult, buildQueueHealth, + buildRegistryChangeReport, } from "../signals/engine"; -import type { JobMessage } from "../types"; +import { attachDataQuality, buildCoreSignalFidelity, buildRepoDataQuality, buildSignalFidelity } from "../signals/data-quality"; +import { buildPullRequestReviewability } from "../signals/reward-risk"; +import { buildLocalBranchAnalysis } from "../signals/local-branch"; +import type { ContributorEvidenceRecord, JobMessage, JsonValue, RepoSyncSegmentRecord } from "../types"; import { nowIso } from "../utils/json"; -import { buildWorkboard } from "./workboard"; type AppBindings = { Bindings: Env }; @@ -57,18 +109,122 @@ const preflightSchema = z.object({ authorAssociation: z.string().optional(), }); +const localDiffPreflightSchema = preflightSchema.extend({ + changedLineCount: z.number().int().min(0).optional(), + testFiles: z.array(z.string()).optional(), + commitMessage: z.string().optional(), +}); + +const localBranchChangedFileSchema = z + .object({ + path: z.string().min(1), + previousPath: z.string().min(1).optional(), + additions: z.number().int().min(0).optional(), + deletions: z.number().int().min(0).optional(), + status: z.enum(["added", "modified", "deleted", "renamed", "copied", "unknown"]).optional(), + binary: z.boolean().optional(), + }) + .strict(); + +const localBranchValidationSchema = z + .object({ + command: z.string().min(1), + status: z.enum(["passed", "failed", "not_run"]), + summary: z.string().optional(), + }) + .strict(); + +const localBranchScorerSchema = z + .object({ + mode: z.enum(["metadata_only", "external_command", "gittensor_root"]), + activeModel: z.string().optional(), + sourceTokenScore: z.number().min(0).optional(), + totalTokenScore: z.number().min(0).optional(), + sourceLines: z.number().min(0).optional(), + testTokenScore: z.number().min(0).optional(), + nonCodeTokenScore: z.number().min(0).optional(), + warnings: z.array(z.string()).optional(), + }) + .strict(); + +const localBranchAnalysisSchema = z + .object({ + login: z.string().min(1), + repoFullName: z.string().min(3), + baseRef: z.string().min(1).optional(), + headRef: z.string().min(1).optional(), + branchName: z.string().min(1).optional(), + commitMessages: z.array(z.string()).max(30).optional(), + changedFiles: z.array(localBranchChangedFileSchema).max(500).optional(), + validation: z.array(localBranchValidationSchema).max(50).optional(), + linkedIssues: z.array(z.number().int().positive()).optional(), + labels: z.array(z.string()).optional(), + title: z.string().min(1).optional(), + body: z.string().optional(), + localScorer: localBranchScorerSchema.optional(), + }) + .strict(); + +const scorePreviewSchema = z.object({ + repoFullName: z.string().min(3), + targetType: z.enum(["planned_pr", "pull_request", "local_diff", "variant"]).default("planned_pr"), + targetKey: z.string().optional(), + contributorLogin: z.string().min(1).optional(), + labels: z.array(z.string()).optional(), + linkedIssueMode: z.enum(["none", "standard", "maintainer"]).default("none"), + sourceTokenScore: z.number().min(0).optional(), + totalTokenScore: z.number().min(0).optional(), + sourceLines: z.number().min(0).optional(), + testTokenScore: z.number().min(0).optional(), + nonCodeTokenScore: z.number().min(0).optional(), + existingContributorTokenScore: z.number().min(0).optional(), + openPrCount: z.number().int().min(0).optional(), + credibility: z.number().min(0).max(1).optional(), + changesRequestedCount: z.number().int().min(0).optional(), + fixedBaseScore: z.number().min(0).optional(), + metadataOnly: z.boolean().default(false), +}); + const repositorySettingsSchema = z.object({ commentMode: z.enum(["off", "detected_contributors_only", "all_prs"]), publicSignalLevel: z.enum(["minimal", "standard"]).default("standard"), + checkRunDetailLevel: z.enum(["minimal", "standard", "deep"]).default("standard"), + backfillEnabled: z.boolean().default(true), + privateTrustEnabled: z.boolean().default(true), }); export function createApp() { const app = new Hono(); - app.use("*", cors()); + app.use( + "*", + cors({ + origin: (origin, c) => { + if (!origin) return null; + const allowed = allowedCorsOrigins(c.env); + return allowed.has(origin) ? origin : null; + }, + allowHeaders: ["authorization", "content-type", "mcp-session-id", "mcp-protocol-version"], + allowMethods: ["GET", "POST", "OPTIONS"], + exposeHeaders: ["x-ratelimit-limit", "x-ratelimit-remaining", "x-ratelimit-reset", "retry-after"], + maxAge: 600, + }), + ); + app.use("*", async (c, next) => { + if (c.req.method === "OPTIONS" || c.req.path === "/health" || c.req.path === "/v1/github/webhook") return next(); + const limited = await enforceRateLimit(c, routeClassForPath(c.req.path)); + if (limited) return limited; + return next(); + }); + app.use("/v1/internal/*", async (c, next) => { + const identity = await authenticateInternalToken(c.env, extractBearerToken(c.req.header("authorization"))); + if (!identity) return c.json({ error: "unauthorized" }, 401); + return next(); + }); app.use("*", async (c, next) => { + if (c.req.method === "OPTIONS") return next(); if (!requiresApiToken(c.req.path)) return next(); - const token = c.req.header("authorization")?.replace(/^Bearer\s+/i, ""); - if (!token || token !== c.env.GITTENSORY_API_TOKEN) return c.json({ error: "unauthorized" }, 401); + const identity = await authenticatePrivateToken(c.env, extractBearerToken(c.req.header("authorization"))); + if (!identity) return c.json({ error: "unauthorized" }, 401); return next(); }); @@ -76,57 +232,252 @@ export function createApp() { app.get("/openapi.json", (c) => c.json(buildOpenApiSpec())); app.all("/mcp", handleMcpRequest); + app.post("/v1/auth/github/device/start", async (c) => { + try { + const device = await startGitHubDeviceFlow(c.env); + await recordAuditEvent(c.env, { eventType: "auth.github_device_start", route: c.req.path, outcome: "success" }); + return c.json( + { + status: "pending", + deviceCode: device.device_code, + userCode: device.user_code, + verificationUri: device.verification_uri, + expiresIn: device.expires_in, + interval: device.interval ?? 5, + }, + 201, + ); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : "github_device_flow_start_failed" }, error instanceof Error && error.message === "github_oauth_not_configured" ? 503 : 502); + } + }); + + app.post("/v1/auth/github/device/poll", async (c) => { + const body = await c.req.json().catch(() => null); + const deviceCode = typeof body?.deviceCode === "string" ? body.deviceCode : ""; + if (!deviceCode) return c.json({ error: "device_code_required" }, 400); + try { + return c.json(await pollGitHubDeviceFlow(c.env, deviceCode)); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : "github_device_flow_poll_failed" }, error instanceof Error && error.message === "github_oauth_not_configured" ? 503 : 502); + } + }); + + app.post("/v1/auth/github/session", async (c) => { + const body = await c.req.json().catch(() => null); + const githubToken = typeof body?.githubToken === "string" ? body.githubToken : ""; + if (!githubToken) return c.json({ error: "github_token_required" }, 400); + try { + return c.json(await createSessionFromGitHubToken(c.env, githubToken, { source: "github_token_exchange" }), 201); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : "github_session_create_failed" }, 401); + } + }); + + app.get("/v1/auth/session", async (c) => { + const identity = await authenticateSessionToken(c.env, extractBearerToken(c.req.header("authorization"))); + if (!identity || identity.kind !== "session") return c.json({ error: "unauthorized" }, 401); + return c.json({ + status: "authenticated", + login: identity.session.login, + expiresAt: identity.session.expiresAt, + scopes: identity.session.scopes, + createdAt: identity.session.createdAt, + lastSeenAt: identity.session.lastSeenAt, + }); + }); + + app.post("/v1/auth/logout", async (c) => { + const identity = await authenticateSessionToken(c.env, extractBearerToken(c.req.header("authorization"))); + const revoked = await revokeSession(c.env, identity); + return c.json({ ok: true, revoked }); + }); + app.get("/v1/registry/snapshot", async (c) => { const snapshot = await getLatestRegistrySnapshot(c.env); if (!snapshot) return c.json({ error: "registry_snapshot_not_found" }, 404); return c.json(snapshot); }); - app.get("/v1/repos", async (c) => c.json(await listRepositories(c.env))); + app.get("/v1/registry/changes", async (c) => c.json(buildRegistryChangeReport(await listLatestRegistrySnapshots(c.env, 2)))); - app.get("/v1/repos/:owner/:repo", async (c) => { - const repo = await getRepository(c.env, `${c.req.param("owner")}/${c.req.param("repo")}`); - if (!repo) return c.json({ error: "repo_not_found" }, 404); - return c.json(repo); + app.get("/v1/scoring/model", async (c) => c.json(await getOrCreateScoringModelSnapshot(c.env))); + + app.post("/v1/scoring/preview", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = scorePreviewSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_scoring_preview_request", issues: parsed.error.issues }, 400); + const [repo, snapshot, evidence] = await Promise.all([ + getRepository(c.env, parsed.data.repoFullName), + getOrCreateScoringModelSnapshot(c.env), + parsed.data.contributorLogin ? getContributorEvidence(c.env, parsed.data.contributorLogin) : Promise.resolve(null), + ]); + const result = buildScorePreview({ input: parsed.data, repo, snapshot, contributorEvidence: evidence }); + const record = makeScorePreviewRecord(parsed.data, snapshot, result); + await persistScorePreview(c.env, record); + return c.json(record); }); - app.get("/v1/repos/:owner/:repo/advisory", async (c) => { - const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; - const repo = await getRepository(c.env, fullName); - const advisory = buildRepositoryAdvisory(repo, fullName); - await persistAdvisory(c.env, advisory); - return c.json(advisory); + app.get("/v1/sync/status", async (c) => { + const [snapshot, repositories, segments, totals, detailStates, installations, rateLimits] = await Promise.all([ + getLatestRegistrySnapshot(c.env), + listRepoSyncStates(c.env), + listRepoSyncSegments(c.env), + listLatestRepoGithubTotalsSnapshots(c.env), + listAllPullRequestDetailSyncStates(c.env), + listInstallationHealth(c.env), + listLatestGitHubRateLimitObservations(c.env, 20), + ]); + const repoCount = snapshot?.repoCount ?? repositories.length; + const coreSignalFidelity = buildCoreSignalFidelity(repoCount, repositories, segments, totals, detailStates); + return c.json({ + generatedAt: nowIso(), + signalFidelity: buildSignalFidelity(repoCount, repositories, segments), + coreSignalFidelity, + historyCoverage: coreSignalFidelity.historyCoverage, + refreshingRepos: coreSignalFidelity.refreshingRepos, + waitingForRateLimitRepos: coreSignalFidelity.waitingForRateLimitRepos, + repositories, + segments: segments.map(enrichSyncSegment), + githubTotals: totals, + pullRequestDetailSync: detailStates, + installations, + rateLimits, + }); }); - app.get("/v1/repos/:owner/:repo/workboard", async (c) => { - const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; - const repo = await getRepository(c.env, fullName); - const issues = await listOpenIssues(c.env, fullName); - return c.json(buildWorkboard(repo, issues)); + app.get("/v1/readiness", async (c) => { + const [snapshot, scoringSnapshot, syncStates, syncSegments, totals, detailStates, installations, installationHealth, rateLimits] = await Promise.all([ + getLatestRegistrySnapshot(c.env), + getLatestScoringModelSnapshot(c.env), + listRepoSyncStates(c.env), + listRepoSyncSegments(c.env), + listLatestRepoGithubTotalsSnapshots(c.env), + listAllPullRequestDetailSyncStates(c.env), + listInstallations(c.env), + listInstallationHealth(c.env), + listLatestGitHubRateLimitObservations(c.env, 20), + ]); + const repoCount = snapshot?.repoCount ?? syncStates.length; + const signalFidelity = buildSignalFidelity(repoCount, syncStates, syncSegments); + const coreSignalFidelity = buildCoreSignalFidelity(repoCount, syncStates, syncSegments, totals, detailStates); + const statusCounts = syncStates.reduce>((counts, state) => { + counts[state.status] = (counts[state.status] ?? 0) + 1; + return counts; + }, {}); + const failingSyncs = syncStates.filter((state) => state.status === "error").slice(0, 10); + const incompleteSyncs = syncStates.filter((state) => state.status === "never_synced" || state.status === "running" || state.status === "skipped").slice(0, 10); + const missingSyncCount = snapshot ? Math.max(snapshot.repoCount - syncStates.length, 0) : 0; + const warnings = [ + ...(!snapshot ? ["Registry snapshot is missing."] : []), + ...(!scoringSnapshot ? ["Scoring model snapshot is missing. Run refresh-scoring-model before public review."] : []), + ...(missingSyncCount > 0 ? [`${missingSyncCount} registered repo(s) do not have GitHub backfill state yet.`] : []), + ...(!c.env.GITHUB_PUBLIC_TOKEN ? ["GITHUB_PUBLIC_TOKEN is not configured; public registered-repo backfill may hit GitHub rate limits."] : []), + ...(failingSyncs.length > 0 ? [`${failingSyncs.length} recent repo sync error(s) are visible in the readiness sample.`] : []), + ...(incompleteSyncs.length > 0 ? [`${incompleteSyncs.length} repo sync(s) are incomplete or skipped in the readiness sample.`] : []), + ...(coreSignalFidelity.status !== "complete" ? [`Core open-data fidelity is ${coreSignalFidelity.status}; required open queue data is not complete.`] : []), + ...(coreSignalFidelity.refreshingRepos.length > 0 ? [`${coreSignalFidelity.refreshingRepos.length} repo(s) are refreshing while preserving prior usable data.`] : []), + ...(coreSignalFidelity.waitingForRateLimitRepos.length > 0 ? [`${coreSignalFidelity.waitingForRateLimitRepos.length} repo(s) are waiting for GitHub rate-limit recovery.`] : []), + ...(signalFidelity.cappedRepos.length > 0 ? [`${signalFidelity.cappedRepos.length} repo sync(s) hit local pagination caps; signal fidelity is degraded.`] : []), + ...(signalFidelity.rateLimitedRepos.length > 0 ? [`${signalFidelity.rateLimitedRepos.length} repo sync(s) encountered GitHub rate limiting.`] : []), + ...(signalFidelity.staleRepos.length > 0 ? [`${signalFidelity.staleRepos.length} repo sync(s) are stale.`] : []), + ...(installationHealth.some((health) => health.status !== "healthy") ? ["One or more GitHub App installations need attention."] : []), + ]; + const ready = Boolean(snapshot) && Boolean(c.env.INTERNAL_JOB_TOKEN) && Boolean(c.env.GITTENSORY_API_TOKEN); + const readyForPublicReview = snapshot + ? snapshot.repoCount > 0 && + ready && + Boolean(scoringSnapshot) && + Boolean(c.env.GITHUB_PUBLIC_TOKEN) && + missingSyncCount === 0 && + failingSyncs.length === 0 && + coreSignalFidelity.status === "complete" + : false; + return c.json({ + status: ready ? "ready" : "needs_attention", + generatedAt: nowIso(), + ready, + readyForPublicReview, + signalFidelity, + coreSignalFidelity, + historyCoverage: coreSignalFidelity.historyCoverage, + partialRepos: signalFidelity.partialRepos, + cappedRepos: signalFidelity.cappedRepos, + staleRepos: signalFidelity.staleRepos, + rateLimitedRepos: signalFidelity.rateLimitedRepos, + refreshingRepos: coreSignalFidelity.refreshingRepos, + waitingForRateLimitRepos: coreSignalFidelity.waitingForRateLimitRepos, + nextRecoverableAt: signalFidelity.nextRecoverableAt, + registry: snapshot + ? { snapshotId: snapshot.id, repoCount: snapshot.repoCount, totalEmissionShare: snapshot.totalEmissionShare, source: snapshot.source, warningCount: snapshot.warnings.length } + : null, + scoringModel: scoringSnapshot + ? { + snapshotId: scoringSnapshot.id, + activeModel: scoringSnapshot.activeModel, + sourceKind: scoringSnapshot.sourceKind, + fetchedAt: scoringSnapshot.fetchedAt, + warningCount: scoringSnapshot.warnings.length, + } + : null, + githubBackfill: { + repoSyncCount: syncStates.length, + statusCounts, + failingSyncs: failingSyncs.map((state) => ({ repoFullName: state.repoFullName, errorSummary: state.errorSummary, lastCompletedAt: state.lastCompletedAt })), + incompleteSyncs: incompleteSyncs.map((state) => ({ repoFullName: state.repoFullName, status: state.status, lastCompletedAt: state.lastCompletedAt })), + segmentCount: syncSegments.length, + segments: syncSegments.map(enrichSyncSegment), + githubTotals: totals, + pullRequestDetailSyncCount: detailStates.length, + cappedSegments: syncSegments.filter((segment) => segment.status === "capped").map((segment) => ({ repoFullName: segment.repoFullName, segment: segment.segment, nextCursor: segment.nextCursor })), + rateLimitedSegments: syncSegments + .filter((segment) => segment.status === "rate_limited" || segment.status === "waiting_rate_limit") + .map((segment) => ({ repoFullName: segment.repoFullName, segment: segment.segment, rateLimitResetAt: segment.rateLimitResetAt })), + latestRateLimits: rateLimits, + }, + installations: { + count: installations.length, + healthCount: installationHealth.length, + unhealthyCount: installationHealth.filter((health) => health.status !== "healthy").length, + }, + secrets: { + githubAppPrivateKey: Boolean(c.env.GITHUB_APP_PRIVATE_KEY), + githubWebhookSecret: Boolean(c.env.GITHUB_WEBHOOK_SECRET), + githubPublicToken: Boolean(c.env.GITHUB_PUBLIC_TOKEN), + apiToken: Boolean(c.env.GITTENSORY_API_TOKEN), + mcpToken: Boolean(c.env.GITTENSORY_MCP_TOKEN), + internalJobToken: Boolean(c.env.INTERNAL_JOB_TOKEN), + }, + warnings, + }); }); - app.get("/v1/repos/:owner/:repo/queue-health", async (c) => { - const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; - const repo = await getRepository(c.env, fullName); - const issues = await listIssues(c.env, fullName); - const pullRequests = await listPullRequests(c.env, fullName); - const collisions = buildCollisionReport(fullName, issues, pullRequests); - return c.json(buildQueueHealth(repo, issues, pullRequests, collisions)); + app.get("/v1/installations", async (c) => + c.json({ + installations: await listInstallations(c.env), + health: await listInstallationHealth(c.env), + }), + ); + + app.get("/v1/installations/:id/health", async (c) => { + const installationId = Number(c.req.param("id")); + if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400); + const health = await getInstallationHealth(c.env, installationId); + if (!health) return c.json({ error: "installation_health_not_found" }, 404); + return c.json(health); }); - app.get("/v1/repos/:owner/:repo/collisions", async (c) => { - const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; - const issues = await listIssues(c.env, fullName); - const pullRequests = await listPullRequests(c.env, fullName); - return c.json(buildCollisionReport(fullName, issues, pullRequests)); + app.get("/v1/repos", async (c) => c.json(await listRepositories(c.env))); + + app.get("/v1/repos/:owner/:repo", async (c) => { + const repo = await getRepository(c.env, `${c.req.param("owner")}/${c.req.param("repo")}`); + if (!repo) return c.json({ error: "repo_not_found" }, 404); + return c.json(repo); }); - app.get("/v1/repos/:owner/:repo/config-quality", async (c) => { + app.get("/v1/repos/:owner/:repo/intelligence", async (c) => { const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; - const repo = await getRepository(c.env, fullName); - const issues = await listIssues(c.env, fullName); - const pullRequests = await listPullRequests(c.env, fullName); - return c.json(buildConfigQuality(repo, issues, pullRequests, fullName)); + return c.json(await buildRepoIntelligenceResponse(c.env, fullName)); }); app.get("/v1/repos/:owner/:repo/settings", async (c) => { @@ -134,60 +485,117 @@ export function createApp() { return c.json(await getRepositorySettings(c.env, fullName)); }); - app.get("/v1/repos/:owner/:repo/maintainer-packet", async (c) => { - const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; - const repo = await getRepository(c.env, fullName); - const issues = await listOpenIssues(c.env, fullName); - const pullRequests = await listOpenPullRequests(c.env, fullName); - return c.json(buildMaintainerPacket(repo, issues, pullRequests, fullName)); - }); - - app.get("/v1/repos/:owner/:repo/pulls/:number/advisory", async (c) => { + app.get("/v1/repos/:owner/:repo/pulls/:number/maintainer-packet", async (c) => { const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; const number = Number(c.req.param("number")); - const repo = await getRepository(c.env, fullName); - const pr = Number.isFinite(number) ? await getPullRequest(c.env, fullName, number) : null; - const otherOpenPullRequests = Number.isFinite(number) ? await listOtherOpenPullRequests(c.env, fullName, number) : []; - const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests }); - await persistAdvisory(c.env, advisory); - return c.json(advisory); + if (!Number.isFinite(number)) return c.json({ error: "invalid_pull_number" }, 400); + const [repo, pullRequest, issues, pullRequests, files, reviews, checks, recentMergedPullRequests] = await Promise.all([ + getRepository(c.env, fullName), + getPullRequest(c.env, fullName, number), + listIssues(c.env, fullName), + listPullRequests(c.env, fullName), + listPullRequestFiles(c.env, fullName, number), + listPullRequestReviews(c.env, fullName, number), + listCheckSummaries(c.env, fullName, number), + listRecentMergedPullRequests(c.env, fullName), + ]); + return c.json( + attachDataQuality( + buildPullRequestMaintainerPacket({ repo, pullRequest, issues, pullRequests, files, reviews, checks, recentMergedPullRequests, repoFullName: fullName, pullNumber: number }) as unknown as Record, + await loadRepoDataQuality(c.env, fullName), + ), + ); }); - app.get("/v1/repos/:owner/:repo/issues/:number/advisory", async (c) => { + app.get("/v1/repos/:owner/:repo/pulls/:number/reviewability", async (c) => { const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; const number = Number(c.req.param("number")); - const repo = await getRepository(c.env, fullName); - const issue = Number.isFinite(number) ? await getIssue(c.env, fullName, number) : null; - const advisory = buildIssueAdvisory(repo, issue); - await persistAdvisory(c.env, advisory); - return c.json(advisory); + if (!Number.isFinite(number)) return c.json({ error: "invalid_pull_number" }, 400); + const [repo, pullRequest, issues, pullRequests, files, reviews, checks, recentMergedPullRequests] = await Promise.all([ + getRepository(c.env, fullName), + getPullRequest(c.env, fullName, number), + listIssues(c.env, fullName), + listPullRequests(c.env, fullName), + listPullRequestFiles(c.env, fullName, number), + listPullRequestReviews(c.env, fullName, number), + listCheckSummaries(c.env, fullName, number), + listRecentMergedPullRequests(c.env, fullName), + ]); + const contributor = pullRequest?.authorLogin; + const contributorContext = contributor ? await loadContributorFastContext(c.env, contributor) : null; + const reviewability = buildPullRequestReviewability({ + repo, + pullRequest, + issues, + pullRequests, + files, + reviews, + checks, + recentMergedPullRequests, + repoFullName: fullName, + pullNumber: number, + profile: contributorContext?.profile, + outcomeHistory: contributorContext?.outcomeHistory, + }); + await persistSignal(c.env, "pr-reviewability", `${fullName}#${number}`, fullName, reviewability as unknown as Record, reviewability.generatedAt); + return c.json(reviewability); }); app.get("/v1/contributors/:login/profile", async (c) => { const login = c.req.param("login"); - const [github, pullRequests, issues] = await Promise.all([ + const [github, pullRequests, issues, cachedRepoStats, gittensorSnapshot] = await Promise.all([ fetchPublicContributorProfile(login), listContributorPullRequests(c.env, login), listContributorIssues(c.env, login), + listContributorRepoStats(c.env, login), + fetchGittensorContributorSnapshot(login), ]); - return c.json(buildContributorProfile(login, github, pullRequests, issues)); + const repoStats = authoritativeContributorRepoStats(gittensorSnapshot, cachedRepoStats); + return c.json(buildContributorProfile(login, github, pullRequests, issues, repoStats, gittensorSnapshot)); }); - app.get("/v1/contributors/:login/opportunities", async (c) => { + app.get("/v1/contributors/:login/decision-pack", async (c) => { const login = c.req.param("login"); - const [github, contributorPullRequests, contributorIssues, repositories, allIssues, allPullRequests] = await Promise.all([ - fetchPublicContributorProfile(login), - listContributorPullRequests(c.env, login), - listContributorIssues(c.env, login), - listRepositories(c.env), - listAllIssues(c.env), - listAllPullRequests(c.env), - ]); - const profile = buildContributorProfile(login, github, contributorPullRequests, contributorIssues); - return c.json({ - profile, - opportunities: buildContributorOpportunities(profile, repositories, allIssues, allPullRequests), - }); + const pack = await loadFreshContributorDecisionPack(c.env, login); + if (pack) return c.json(pack); + const stalePack = await loadContributorDecisionPack(c.env, login); + await c.env.JOBS.send({ type: "build-contributor-decision-packs", requestedBy: "api", login }); + return c.json( + { + status: "needs_snapshot_refresh", + login, + generatedAt: nowIso(), + reason: stalePack ? "stale_snapshot" : "missing_snapshot", + enqueued: true, + ...(stalePack ? { staleSnapshot: { generatedAt: stalePack.generatedAt, ageSeconds: Math.max(0, Math.floor((Date.now() - Date.parse(stalePack.generatedAt)) / 1000)) } } : {}), + ...(stalePack?.dataQuality ? { dataQuality: stalePack.dataQuality } : {}), + }, + 202, + ); + }); + + app.get("/v1/contributors/:login/repos/:owner/:repo/decision", async (c) => { + const login = c.req.param("login"); + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const pack = await loadFreshContributorDecisionPack(c.env, login); + if (!pack) { + const stalePack = await loadContributorDecisionPack(c.env, login); + await c.env.JOBS.send({ type: "build-contributor-decision-packs", requestedBy: "api", login }); + return c.json( + { + status: "needs_snapshot_refresh", + login, + repoFullName: fullName, + generatedAt: nowIso(), + reason: stalePack ? "stale_snapshot" : "missing_snapshot", + enqueued: true, + }, + 202, + ); + } + const decision = repoDecisionFromPack(pack, fullName); + if (!decision) return c.json({ error: "repo_decision_not_found", login, repoFullName: fullName }, 404); + return c.json({ status: "ready", login, repoFullName: fullName, generatedAt: pack.generatedAt, source: pack.source, decision, dataQuality: pack.dataQuality }); }); app.post("/v1/preflight/pr", async (c) => { @@ -200,6 +608,46 @@ export function createApp() { return c.json(buildPreflightResult(parsed.data, repo, issues, pullRequests)); }); + app.post("/v1/preflight/local-diff", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = localDiffPreflightSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_local_diff_preflight_request", issues: parsed.error.issues }, 400); + const repo = await getRepository(c.env, parsed.data.repoFullName); + const issues = await listIssues(c.env, parsed.data.repoFullName); + const pullRequests = await listPullRequests(c.env, parsed.data.repoFullName); + return c.json(buildLocalDiffPreflightResult(parsed.data, repo, issues, pullRequests)); + }); + + app.post("/v1/local/branch-analysis", async (c) => { + const body = await c.req.json().catch(() => null); + const parsed = localBranchAnalysisSchema.safeParse(body); + if (!parsed.success) return c.json({ error: "invalid_local_branch_analysis_request", issues: parsed.error.issues }, 400); + const [context, repo, issues, pullRequests, recentMergedPullRequests, snapshot] = await Promise.all([ + loadContributorFastContext(c.env, parsed.data.login), + getRepository(c.env, parsed.data.repoFullName), + listIssues(c.env, parsed.data.repoFullName), + listPullRequests(c.env, parsed.data.repoFullName), + listRecentMergedPullRequests(c.env, parsed.data.repoFullName), + getOrCreateScoringModelSnapshot(c.env), + ]); + const fit = buildContributorFit(context.profile, context.repositories, [], [], context.syncStates, context.repoStats); + const scoringProfile = buildContributorScoringProfile({ login: parsed.data.login, fit, scoringSnapshot: snapshot }); + const analysis = buildLocalBranchAnalysis({ + input: parsed.data, + repo, + issues, + pullRequests, + recentMergedPullRequests, + profile: context.profile, + outcomeHistory: context.outcomeHistory, + scoringSnapshot: snapshot, + scoringProfile, + }); + const response = { ...analysis, dataQuality: await loadRepoDataQuality(c.env, parsed.data.repoFullName) }; + await persistSignal(c.env, "local-branch-analysis", `${parsed.data.login}:${parsed.data.repoFullName}:${parsed.data.branchName ?? parsed.data.headRef ?? "local"}`, parsed.data.repoFullName, response as unknown as Record, analysis.generatedAt); + return c.json(response); + }); + app.get("/v1/bounties", async (c) => c.json(await listBounties(c.env))); app.get("/v1/bounties/:id/advisory", async (c) => { @@ -215,22 +663,174 @@ export function createApp() { app.post("/v1/github/webhook", handleGitHubWebhook); app.post("/v1/internal/jobs/refresh-registry", async (c) => { - const token = c.req.header("authorization")?.replace(/^Bearer\s+/i, ""); - if (!token || token !== c.env.INTERNAL_JOB_TOKEN) return c.json({ error: "unauthorized" }, 401); const message: JobMessage = { type: "refresh-registry", requestedBy: "api" }; await c.env.JOBS.send(message); return c.json({ ok: true, status: "queued" }, 202); }); app.post("/v1/internal/jobs/refresh-registry/run", async (c) => { - const token = c.req.header("authorization")?.replace(/^Bearer\s+/i, ""); - if (!token || token !== c.env.INTERNAL_JOB_TOKEN) return c.json({ error: "unauthorized" }, 401); return c.json(await refreshRegistry(c.env)); }); + app.post("/v1/internal/jobs/backfill-registered-repos", async (c) => { + const body = await c.req.json().catch(() => ({})); + const repoFullName = typeof body?.repoFullName === "string" ? body.repoFullName : undefined; + const force = body?.force === true; + const mode = body?.mode === "full" || body?.mode === "resume" ? body.mode : "light"; + const message: JobMessage = { type: "backfill-registered-repos", requestedBy: "api", repoFullName, force, mode }; + await c.env.JOBS.send(message); + return c.json({ ok: true, status: "queued", repoFullName, force, mode }, 202); + }); + + app.post("/v1/internal/jobs/backfill-registered-repos/run", async (c) => { + const body = await c.req.json().catch(() => ({})); + const repoFullName = typeof body?.repoFullName === "string" ? body.repoFullName : undefined; + const force = body?.force === true; + const mode = body?.mode === "full" || body?.mode === "resume" ? body.mode : "light"; + return c.json(await backfillRegisteredRepositories(c.env, { repoFullName, requestedBy: "api", force, mode })); + }); + + app.post("/v1/internal/jobs/backfill-repo-segment", async (c) => { + const body = await c.req.json().catch(() => ({})); + if (typeof body?.repoFullName !== "string" || body.repoFullName.length === 0) return c.json({ error: "repo_full_name_required" }, 400); + const segment = parseBackfillSegment(body?.segment); + if (!segment) return c.json({ error: "valid_segment_required" }, 400); + const mode = body?.mode === "full" || body?.mode === "resume" ? body.mode : "light"; + const message: JobMessage = { + type: "backfill-repo-segment", + requestedBy: "api", + repoFullName: body.repoFullName, + segment, + mode, + force: body?.force === true, + ...(typeof body?.cursor === "string" ? { cursor: body.cursor } : {}), + }; + await c.env.JOBS.send(message); + return c.json({ ok: true, status: "queued", repoFullName: body.repoFullName, segment, mode }, 202); + }); + + app.post("/v1/internal/jobs/backfill-repo-segment/run", async (c) => { + const body = await c.req.json().catch(() => ({})); + if (typeof body?.repoFullName !== "string" || body.repoFullName.length === 0) return c.json({ error: "repo_full_name_required" }, 400); + const segment = parseBackfillSegment(body?.segment); + if (!segment) return c.json({ error: "valid_segment_required" }, 400); + const mode = body?.mode === "full" || body?.mode === "resume" ? body.mode : "light"; + return c.json( + await backfillRepositorySegment(c.env, { + repoFullName: body.repoFullName, + segment, + requestedBy: "api", + mode, + ...(typeof body?.cursor === "string" ? { cursor: body.cursor } : {}), + force: body?.force === true, + }), + ); + }); + + app.post("/v1/internal/jobs/backfill-pr-details", async (c) => { + const body = await c.req.json().catch(() => ({})); + if (typeof body?.repoFullName !== "string" || body.repoFullName.length === 0) return c.json({ error: "repo_full_name_required" }, 400); + const mode = body?.mode === "full" || body?.mode === "resume" ? body.mode : "light"; + const message: JobMessage = { + type: "backfill-pr-details", + requestedBy: "api", + repoFullName: body.repoFullName, + mode, + ...(Number.isFinite(Number(body?.cursor)) ? { cursor: Number(body.cursor) } : {}), + }; + await c.env.JOBS.send(message); + return c.json({ ok: true, status: "queued", repoFullName: body.repoFullName, mode }, 202); + }); + + app.post("/v1/internal/jobs/backfill-pr-details/run", async (c) => { + const body = await c.req.json().catch(() => ({})); + if (typeof body?.repoFullName !== "string" || body.repoFullName.length === 0) return c.json({ error: "repo_full_name_required" }, 400); + const mode = body?.mode === "full" || body?.mode === "resume" ? body.mode : "light"; + return c.json(await backfillOpenPullRequestDetails(c.env, { repoFullName: body.repoFullName, mode, ...(Number.isFinite(Number(body?.cursor)) ? { cursor: Number(body.cursor) } : {}) })); + }); + + app.post("/v1/internal/jobs/refresh-scoring-model", async (c) => { + const message: JobMessage = { type: "refresh-scoring-model", requestedBy: "api" }; + await c.env.JOBS.send(message); + return c.json({ ok: true, status: "queued" }, 202); + }); + + app.post("/v1/internal/jobs/refresh-scoring-model/run", async (c) => { + return c.json(await refreshScoringModelSnapshot(c.env)); + }); + + app.post("/v1/internal/jobs/build-contributor-evidence", async (c) => { + const body = await c.req.json().catch(() => ({})); + const login = typeof body?.login === "string" ? body.login : undefined; + const message: JobMessage = { type: "build-contributor-evidence", requestedBy: "api", login }; + await c.env.JOBS.send(message); + return c.json({ ok: true, status: "queued", login }, 202); + }); + + app.post("/v1/internal/jobs/build-contributor-decision-packs", async (c) => { + const body = await c.req.json().catch(() => ({})); + const login = typeof body?.login === "string" ? body.login : undefined; + const message: JobMessage = { type: "build-contributor-decision-packs", requestedBy: "api", login }; + await c.env.JOBS.send(message); + return c.json({ ok: true, status: "queued", login }, 202); + }); + + app.post("/v1/internal/jobs/build-contributor-decision-packs/run", async (c) => { + const body = await c.req.json().catch(() => ({})); + if (typeof body?.login !== "string" || body.login.length === 0) return c.json({ error: "login_required" }, 400); + return c.json(await buildAndPersistContributorDecisionPack(c.env, body.login)); + }); + + app.post("/v1/internal/jobs/refresh-contributor-activity", async (c) => { + const body = await c.req.json().catch(() => ({})); + if (typeof body?.login !== "string" || body.login.length === 0) return c.json({ error: "login_required" }, 400); + const repoFullName = typeof body?.repoFullName === "string" ? body.repoFullName : undefined; + const message: JobMessage = { type: "refresh-contributor-activity", requestedBy: "api", login: body.login, repoFullName }; + await c.env.JOBS.send(message); + return c.json({ ok: true, status: "queued", login: body.login, repoFullName }, 202); + }); + + app.post("/v1/internal/jobs/refresh-contributor-activity/run", async (c) => { + const body = await c.req.json().catch(() => ({})); + if (typeof body?.login !== "string" || body.login.length === 0) return c.json({ error: "login_required" }, 400); + const repoFullName = typeof body?.repoFullName === "string" ? body.repoFullName : undefined; + return c.json(await refreshContributorActivity(c.env, body.login, { repoFullName })); + }); + + app.post("/v1/internal/jobs/build-burden-forecasts", async (c) => { + const body = await c.req.json().catch(() => ({})); + const repoFullName = typeof body?.repoFullName === "string" ? body.repoFullName : undefined; + const message: JobMessage = { type: "build-burden-forecasts", requestedBy: "api", repoFullName }; + await c.env.JOBS.send(message); + return c.json({ ok: true, status: "queued", repoFullName }, 202); + }); + + app.post("/v1/internal/jobs/generate-signal-snapshots", async (c) => { + const body = await c.req.json().catch(() => ({})); + const repoFullName = typeof body?.repoFullName === "string" ? body.repoFullName : undefined; + const message: JobMessage = { type: "generate-signal-snapshots", requestedBy: "api", repoFullName }; + await c.env.JOBS.send(message); + return c.json({ ok: true, status: "queued", repoFullName }, 202); + }); + + app.post("/v1/internal/jobs/repair-data-fidelity", async (c) => { + const message: JobMessage = { type: "repair-data-fidelity", requestedBy: "api" }; + await c.env.JOBS.send(message); + return c.json({ ok: true, status: "queued" }, 202); + }); + + app.post("/v1/internal/jobs/generate-signal-snapshots/run", async (c) => { + const body = await c.req.json().catch(() => ({})); + const repoFullName = typeof body?.repoFullName === "string" ? body.repoFullName : undefined; + await generateSignalSnapshots(c.env, repoFullName); + return c.json({ ok: true, status: "completed", repoFullName }); + }); + + app.post("/v1/internal/jobs/refresh-installation-health/run", async (c) => { + return c.json(await refreshInstallationHealth(c.env)); + }); + app.post("/v1/internal/bounties/import", async (c) => { - const token = c.req.header("authorization")?.replace(/^Bearer\s+/i, ""); - if (!token || token !== c.env.INTERNAL_JOB_TOKEN) return c.json({ error: "unauthorized" }, 401); const body = await c.req.json().catch(() => null); const bounties = normalizeGittBountySnapshot(body); await Promise.all(bounties.map((bounty) => upsertBounty(c.env, bounty))); @@ -238,8 +838,6 @@ export function createApp() { }); app.post("/v1/internal/repos/:owner/:repo/settings", async (c) => { - const token = c.req.header("authorization")?.replace(/^Bearer\s+/i, ""); - if (!token || token !== c.env.INTERNAL_JOB_TOKEN) return c.json({ error: "unauthorized" }, 401); const body = await c.req.json().catch(() => null); const parsed = repositorySettingsSchema.safeParse(body); if (!parsed.success) return c.json({ error: "invalid_repository_settings", issues: parsed.error.issues }, 400); @@ -250,6 +848,9 @@ export function createApp() { commentMode: parsed.data.commentMode, publicSignalLevel: parsed.data.publicSignalLevel, checkRunMode: "enabled", + checkRunDetailLevel: parsed.data.checkRunDetailLevel, + backfillEnabled: parsed.data.backfillEnabled, + privateTrustEnabled: parsed.data.privateTrustEnabled, }), ); }); @@ -257,10 +858,201 @@ export function createApp() { return app; } +async function buildRepoIntelligenceResponse(env: Env, fullName: string) { + const [repo, snapshots, dataQuality] = await Promise.all([ + getRepository(env, fullName), + Promise.all( + ["queue-health", "config-quality", "label-audit", "maintainer-lane", "maintainer-cut-readiness", "contributor-intake-health"].map(async (signalType) => [ + signalType, + (await listSignalSnapshots(env, signalType, fullName))[0]?.payload ?? null, + ]), + ), + loadRepoDataQuality(env, fullName), + ]); + const snapshotMap = Object.fromEntries(snapshots); + if (snapshotMap["queue-health"] && snapshotMap["config-quality"] && snapshotMap["label-audit"]) { + return { + status: "ready", + source: "snapshot", + repoFullName: fullName, + generatedAt: nowIso(), + repo, + lane: buildLaneAdvice(repo, fullName), + queueHealth: snapshotMap["queue-health"], + configQuality: snapshotMap["config-quality"], + labelAudit: snapshotMap["label-audit"], + maintainerLane: snapshotMap["maintainer-lane"], + maintainerCutReadiness: snapshotMap["maintainer-cut-readiness"], + contributorIntakeHealth: snapshotMap["contributor-intake-health"], + dataQuality, + }; + } + const [issues, pullRequests, recentMergedPullRequests, labels, queueCounts] = await Promise.all([ + listIssueSignalSample(env, fullName), + listOpenPullRequests(env, fullName), + listRecentMergedPullRequests(env, fullName), + listRepoLabels(env, fullName), + loadOpenQueueCounts(env, fullName), + ]); + const collisions = buildCollisionReport(fullName, issues, pullRequests, recentMergedPullRequests); + const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions, queueCounts); + const configQuality = buildConfigQuality(repo, issues, pullRequests, fullName); + const labelAudit = buildLabelAudit(repo, labels, issues, pullRequests, fullName); + const maintainerLane = buildMaintainerLaneReport(repo, issues, pullRequests, fullName, collisions, queueCounts); + const maintainerCutReadiness = buildMaintainerCutReadiness(repo, issues, pullRequests, fullName, queueCounts, collisions); + const contributorIntakeHealth = buildContributorIntakeHealth(repo, issues, pullRequests, fullName, collisions, queueCounts); + return { + status: "ready", + source: "computed", + repoFullName: fullName, + generatedAt: nowIso(), + repo, + lane: buildLaneAdvice(repo, fullName), + queueHealth, + collisions, + configQuality, + labelAudit, + maintainerLane, + maintainerCutReadiness, + contributorIntakeHealth, + dataQuality, + }; +} + +async function loadOpenQueueCounts(env: Env, fullName: string): Promise<{ openIssues: number; openPullRequests: number }> { + const [totals, openIssues, openPullRequests] = await Promise.all([getLatestRepoGithubTotalsSnapshot(env, fullName), countOpenIssues(env, fullName), countOpenPullRequests(env, fullName)]); + return { + openIssues: totals?.openIssuesTotal ?? openIssues, + openPullRequests: totals?.openPullRequestsTotal ?? openPullRequests, + }; +} + +async function loadContributorFastContext(env: Env, login: string) { + const [github, contributorPullRequests, contributorIssues, repositories, syncStates, syncSegments, cachedRepoStats, gittensorSnapshot] = await Promise.all([ + fetchPublicContributorProfile(login), + listContributorPullRequests(env, login), + listContributorIssues(env, login), + listRepositories(env), + listRepoSyncStates(env), + listRepoSyncSegments(env), + listContributorRepoStats(env, login), + fetchGittensorContributorSnapshot(login), + ]); + const repoStats = authoritativeContributorRepoStats(gittensorSnapshot, cachedRepoStats); + const profile = buildContributorProfile(login, github, contributorPullRequests, contributorIssues, repoStats, gittensorSnapshot); + const outcomeHistory = buildContributorOutcomeHistory({ + login, + profile, + repositories, + pullRequests: contributorPullRequests, + issues: contributorIssues, + repoStats, + }); + return { + login, + github, + contributorPullRequests, + contributorIssues, + repositories, + syncStates, + syncSegments, + repoStats, + gittensorSnapshot, + profile, + outcomeHistory, + }; +} + +async function loadRepoDataQuality(env: Env, fullName: string) { + const [syncStates, syncSegments] = await Promise.all([listRepoSyncStates(env), listRepoSyncSegments(env, fullName)]); + return buildRepoDataQuality( + fullName, + syncStates.find((state) => state.repoFullName === fullName), + syncSegments, + ); +} + +function enrichSyncSegment(segment: RepoSyncSegmentRecord) { + const expected = segment.expectedCount ?? 0; + const coveragePercent = expected > 0 ? Math.min(100, Math.round((segment.fetchedCount / expected) * 10000) / 100) : segment.status === "complete" ? 100 : null; + return { + ...segment, + cursor: segment.nextCursor ?? segment.lastCursor, + coveragePercent, + isRequired: ["metadata", "labels", "open_issues", "open_pull_requests", "pull_request_files", "pull_request_reviews", "check_summaries"].includes(segment.segment), + }; +} + +function parseBackfillSegment(value: unknown): Extract["segment"] | null { + return value === "labels" || value === "open_issues" || value === "open_pull_requests" || value === "recent_merged_pull_requests" ? value : null; +} + +function authoritativeContributorRepoStats( + gittensorSnapshot: Awaited>, + cachedRepoStats: Awaited>, +) { + const officialRepoStats = contributorRepoStatsFromGittensor(gittensorSnapshot); + return officialRepoStats.length > 0 ? officialRepoStats : cachedRepoStats; +} + +async function persistSignal( + env: Env, + signalType: string, + targetKey: string, + repoFullName: string | null, + payload: Record, + generatedAt: string, +): Promise { + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType, + targetKey, + repoFullName, + payload, + generatedAt, + }); +} + +function contributorEvidenceFromProfile(profile: { + login: string; + generatedAt: string; + evidence: { + registeredRepoPullRequests: number; + mergedPullRequests: number; + openPullRequests: number; + stalePullRequests: number; + unlinkedPullRequests: number; + issueDiscoveryReports: number; + languageMatches: number; + credibilityAssumption: number; + }; +}): ContributorEvidenceRecord { + return { + login: profile.login, + generatedAt: profile.generatedAt, + payload: { + pullRequests: profile.evidence.registeredRepoPullRequests, + mergedPullRequests: profile.evidence.mergedPullRequests, + openPullRequests: profile.evidence.openPullRequests, + stalePullRequests: profile.evidence.stalePullRequests, + unlinkedPullRequests: profile.evidence.unlinkedPullRequests, + issueDiscoveryReports: profile.evidence.issueDiscoveryReports, + languageMatches: profile.evidence.languageMatches, + credibilityAssumption: profile.evidence.credibilityAssumption, + }, + }; +} + function requiresApiToken(path: string): boolean { if (path === "/health") return false; if (path === "/mcp") return false; + if (path.startsWith("/v1/auth/")) return false; if (path === "/v1/github/webhook") return false; if (path.startsWith("/v1/internal/")) return false; return path === "/openapi.json" || path.startsWith("/v1/"); } + +function allowedCorsOrigins(env: Env): Set { + const values = [env.PUBLIC_API_ORIGIN, "http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"]; + return new Set(values.filter((value): value is string => Boolean(value))); +} diff --git a/src/auth/github-oauth.ts b/src/auth/github-oauth.ts new file mode 100644 index 0000000000..a705be93e3 --- /dev/null +++ b/src/auth/github-oauth.ts @@ -0,0 +1,116 @@ +import { createSessionForGitHubUser } from "./security"; +import { recordAuditEvent } from "../db/repositories"; +import type { JsonValue } from "../types"; + +type GitHubDeviceCodeResponse = { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval?: number; +}; + +type GitHubAccessTokenResponse = + | { access_token: string; token_type?: string; scope?: string } + | { error: string; error_description?: string }; + +type GitHubUserResponse = { + login?: string; + id?: number; + message?: string; +}; + +export async function startGitHubDeviceFlow(env: Env): Promise { + if (!env.GITHUB_OAUTH_CLIENT_ID) throw new Error("github_oauth_not_configured"); + const response = await fetch("https://github.com/login/device/code", { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + "user-agent": "gittensory-api", + }, + body: JSON.stringify({ + client_id: env.GITHUB_OAUTH_CLIENT_ID, + scope: "read:user", + }), + }); + const payload = (await response.json().catch(() => ({}))) as Partial & { error?: string; error_description?: string }; + if (!response.ok || payload.error) throw new Error(payload.error_description ?? payload.error ?? "github_device_flow_start_failed"); + if (!payload.device_code || !payload.user_code || !payload.verification_uri || !payload.expires_in) throw new Error("github_device_flow_response_invalid"); + return { + device_code: payload.device_code, + user_code: payload.user_code, + verification_uri: payload.verification_uri, + expires_in: payload.expires_in, + ...(payload.interval === undefined ? {} : { interval: payload.interval }), + }; +} + +export async function pollGitHubDeviceFlow(env: Env, deviceCode: string) { + if (!env.GITHUB_OAUTH_CLIENT_ID) throw new Error("github_oauth_not_configured"); + const tokenResponse = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + "user-agent": "gittensory-api", + }, + body: JSON.stringify({ + client_id: env.GITHUB_OAUTH_CLIENT_ID, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + const tokenPayload = (await tokenResponse.json().catch(() => ({}))) as GitHubAccessTokenResponse; + if ("error" in tokenPayload) { + await recordAuditEvent(env, { + eventType: "auth.github_device_poll", + outcome: tokenPayload.error === "authorization_pending" || tokenPayload.error === "slow_down" ? "denied" : "error", + detail: tokenPayload.error, + }); + return { + status: tokenPayload.error, + message: tokenPayload.error_description, + }; + } + if (!tokenPayload.access_token) throw new Error("github_access_token_missing"); + return createSessionFromGitHubToken(env, tokenPayload.access_token, { + source: "github_device_flow", + scopes: parseScopes(tokenPayload.scope), + }); +} + +export async function createSessionFromGitHubToken( + env: Env, + githubToken: string, + metadata: Record = {}, +): Promise<{ token: string; login: string; expiresAt: string; scopes: string[] }> { + const response = await fetch("https://api.github.com/user", { + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${githubToken}`, + "user-agent": "gittensory-api", + "x-github-api-version": "2022-11-28", + }, + }); + const user = (await response.json().catch(() => ({}))) as GitHubUserResponse; + if (!response.ok || !user.login) { + await recordAuditEvent(env, { + eventType: "auth.github_session", + outcome: "denied", + detail: user.message ?? "github_user_validation_failed", + }); + throw new Error("github_user_validation_failed"); + } + const scopes = Array.isArray(metadata.scopes) ? metadata.scopes.filter((scope): scope is string => typeof scope === "string") : []; + const githubUser = user.id === undefined ? { login: user.login } : { login: user.login, id: user.id }; + const { token, session } = await createSessionForGitHubUser(env, githubUser, { scopes, metadata }); + return { token, login: session.login, expiresAt: session.expiresAt, scopes: session.scopes }; +} + +function parseScopes(scopeHeader: string | undefined): string[] { + return (scopeHeader ?? "") + .split(",") + .map((scope) => scope.trim()) + .filter(Boolean); +} diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts new file mode 100644 index 0000000000..8d43739e9c --- /dev/null +++ b/src/auth/rate-limit.ts @@ -0,0 +1,123 @@ +import type { Context } from "hono"; +import { DurableObject } from "cloudflare:workers"; +import { recordAuditEvent } from "../db/repositories"; +import { hashToken } from "./security"; + +export type RateLimitClass = "strict" | "normal" | "expensive"; + +type RateLimitConfig = { + limit: number; + windowSeconds: number; +}; + +type RateLimitDecision = { + allowed: boolean; + limit: number; + remaining: number; + resetAt: string; + retryAfterSeconds?: number; +}; + +const CONFIG: Record = { + strict: { limit: 10, windowSeconds: 60 }, + normal: { limit: 120, windowSeconds: 60 }, + expensive: { limit: 20, windowSeconds: 300 }, +}; + +export class RateLimiter extends DurableObject { + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + } + + override async fetch(request: Request): Promise { + const body = (await request.json().catch(() => null)) as { key?: string; limit?: number; windowSeconds?: number } | null; + if (!body?.key || !body.limit || !body.windowSeconds) return Response.json({ error: "invalid_rate_limit_request" }, { status: 400 }); + const now = Date.now(); + const storageKey = `bucket:${body.key}`; + const existing = (await this.ctx.storage.get<{ count: number; resetAt: number }>(storageKey)) ?? { + count: 0, + resetAt: now + body.windowSeconds * 1000, + }; + const bucket = existing.resetAt <= now ? { count: 0, resetAt: now + body.windowSeconds * 1000 } : existing; + bucket.count += 1; + await this.ctx.storage.put(storageKey, bucket); + const remaining = Math.max(body.limit - bucket.count, 0); + const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)); + const decision: RateLimitDecision = { + allowed: bucket.count <= body.limit, + limit: body.limit, + remaining, + resetAt: new Date(bucket.resetAt).toISOString(), + ...(bucket.count > body.limit ? { retryAfterSeconds } : {}), + }; + return Response.json(decision, { status: decision.allowed ? 200 : 429 }); + } +} + +export async function enforceRateLimit(c: Context<{ Bindings: Env }>, routeClass: RateLimitClass): Promise { + if (!c.env.RATE_LIMITER) return null; + const config = CONFIG[routeClass]; + const key = await rateLimitKey(c, routeClass); + const id = c.env.RATE_LIMITER.idFromName(key); + const decisionResponse = await c.env.RATE_LIMITER.get(id).fetch("https://rate-limit/check", { + method: "POST", + body: JSON.stringify({ key, ...config }), + }); + const decision = (await decisionResponse.json().catch(() => ({}))) as Partial; + if (decisionResponse.status !== 429) { + c.res.headers.set("x-ratelimit-limit", String(decision.limit ?? config.limit)); + c.res.headers.set("x-ratelimit-remaining", String(decision.remaining ?? config.limit)); + if (decision.resetAt) c.res.headers.set("x-ratelimit-reset", decision.resetAt); + return null; + } + await recordAuditEvent(c.env, { + eventType: "rate_limit.denied", + actor: await actorHint(c), + route: c.req.path, + outcome: "denied", + metadata: { routeClass, retryAfterSeconds: decision.retryAfterSeconds ?? null }, + }); + return c.json( + { + error: "rate_limited", + routeClass, + retryAfterSeconds: decision.retryAfterSeconds ?? 60, + resetAt: decision.resetAt, + }, + 429, + { + "retry-after": String(decision.retryAfterSeconds ?? 60), + "x-ratelimit-limit": String(decision.limit ?? config.limit), + "x-ratelimit-remaining": "0", + ...(decision.resetAt ? { "x-ratelimit-reset": decision.resetAt } : {}), + }, + ); +} + +export function routeClassForPath(path: string): RateLimitClass { + if (path.startsWith("/v1/auth/")) return "strict"; + if ( + path.includes("/branch-analysis") || + path.includes("/scoring/preview") || + path.includes("/decision-pack") || + path.includes("/internal/jobs/generate-signal-snapshots") || + path.includes("/internal/jobs/build-contributor-decision-packs") + ) { + return "expensive"; + } + return "normal"; +} + +async function rateLimitKey(c: Context<{ Bindings: Env }>, routeClass: RateLimitClass): Promise { + const token = c.req.header("authorization")?.replace(/^Bearer\s+/i, ""); + const ip = c.req.header("cf-connecting-ip") ?? c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown-ip"; + const pathGroup = c.req.path.replace(/\/\d+(?=\/|$)/g, "/:number").replace(/\/[^/]+\/[^/]+\/pulls\//, "/:owner/:repo/pulls/"); + const identity = token ? `token:${await hashToken(token)}` : `ip:${await hashToken(ip)}`; + return `${routeClass}:${pathGroup}:${identity}`; +} + +async function actorHint(c: Context<{ Bindings: Env }>): Promise { + const token = c.req.header("authorization")?.replace(/^Bearer\s+/i, ""); + if (!token) return "anonymous"; + return `token:${(await hashToken(token)).slice(0, 16)}`; +} diff --git a/src/auth/security.ts b/src/auth/security.ts new file mode 100644 index 0000000000..a5b8208e3d --- /dev/null +++ b/src/auth/security.ts @@ -0,0 +1,109 @@ +import { + createAuthSession, + getAuthSessionByTokenHash, + recordAuditEvent, + revokeAuthSession, + touchAuthSession, +} from "../db/repositories"; +import type { AuthSessionRecord, JsonValue } from "../types"; +import { nowIso } from "../utils/json"; + +export type AuthIdentity = + | { kind: "static"; actor: "api" | "mcp" | "internal" } + | { kind: "session"; actor: string; session: AuthSessionRecord }; + +const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; + +export function extractBearerToken(header: string | null | undefined): string | undefined { + const match = /^Bearer\s+(.+)$/i.exec(header ?? ""); + return match?.[1]?.trim() || undefined; +} + +export async function timingSafeEqual(actual: string | undefined, expected: string | undefined): Promise { + if (!actual || !expected) return false; + const [left, right] = await Promise.all([sha256Bytes(actual), sha256Bytes(expected)]); + let diff = left.length ^ right.length; + const length = Math.min(left.length, right.length); + for (let index = 0; index < length; index += 1) diff |= (left[index] ?? 0) ^ (right[index] ?? 0); + return diff === 0; +} + +export async function hashToken(token: string): Promise { + return bytesToHex(await sha256Bytes(token)); +} + +export function createOpaqueToken(prefix = "gts"): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return `${prefix}_${bytesToHex(bytes)}`; +} + +export async function authenticatePrivateToken(env: Env, token: string | undefined): Promise { + if (!token) return null; + if (await timingSafeEqual(token, env.GITTENSORY_API_TOKEN)) return { kind: "static", actor: "api" }; + if (await timingSafeEqual(token, env.GITTENSORY_MCP_TOKEN)) return { kind: "static", actor: "mcp" }; + return authenticateSessionToken(env, token); +} + +export async function authenticateInternalToken(env: Env, token: string | undefined): Promise { + if (await timingSafeEqual(token, env.INTERNAL_JOB_TOKEN)) return { kind: "static", actor: "internal" }; + return null; +} + +export async function authenticateSessionToken(env: Env, token: string | undefined): Promise { + if (!token) return null; + const session = await getAuthSessionByTokenHash(env, await hashToken(token)); + if (!session) return null; + if (session.revokedAt || Date.parse(session.expiresAt) <= Date.now()) return null; + await touchAuthSession(env, session.id); + return { kind: "session", actor: session.login, session }; +} + +export async function createSessionForGitHubUser( + env: Env, + user: { login: string; id?: number | null }, + options: { scopes?: string[]; metadata?: Record } = {}, +): Promise<{ token: string; session: AuthSessionRecord }> { + const token = createOpaqueToken(); + const issuedAt = nowIso(); + const expiresAt = new Date(Date.now() + SESSION_TTL_SECONDS * 1000).toISOString(); + const session: AuthSessionRecord = { + id: crypto.randomUUID(), + tokenHash: await hashToken(token), + login: user.login, + githubUserId: user.id, + scopes: options.scopes ?? [], + expiresAt, + createdAt: issuedAt, + lastSeenAt: issuedAt, + metadata: options.metadata ?? {}, + }; + await createAuthSession(env, session); + await recordAuditEvent(env, { + eventType: "auth.session_created", + actor: user.login, + outcome: "success", + metadata: { scopes: session.scopes, githubUserId: user.id ?? null }, + }); + return { token, session }; +} + +export async function revokeSession(env: Env, identity: AuthIdentity | null): Promise { + if (!identity || identity.kind !== "session") return false; + await revokeAuthSession(env, identity.session.id); + await recordAuditEvent(env, { + eventType: "auth.session_revoked", + actor: identity.actor, + outcome: "success", + }); + return true; +} + +async function sha256Bytes(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return new Uint8Array(digest); +} + +function bytesToHex(bytes: Uint8Array): string { + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 93653b638a..30205c4695 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1,21 +1,86 @@ -import { and, desc, eq, not } from "drizzle-orm"; +import { and, desc, eq, not, sql } from "drizzle-orm"; import { getDb } from "./client"; -import { advisories, bounties, installations, issues, pullRequests, repositories, repositorySettings, webhookEvents } from "./schema"; +import { + advisories, + auditEvents, + authSessions, + bounties, + bountyLifecycleEvents, + checkSummaries, + burdenForecasts, + contributorEvidence, + collisionEdges, + contributorRepoStats, + contributorScoringProfiles, + contributors, + installationHealth, + installations, + issueQualityReports, + issues, + githubRateLimitObservations, + pullRequestFiles, + pullRequestDetailSyncState, + pullRequestReviews, + pullRequests, + recentMergedPullRequests, + repositories, + repoGithubTotalsSnapshots, + registryDriftEvents, + repoLabels, + repoSnapshots, + repoSyncSegments, + repoSyncState, + repositorySettings, + scorePreviews, + scoringModelSnapshots, + signalSnapshots, + webhookEvents, +} from "./schema"; import type { Advisory, + AuditEventRecord, + AuthSessionRecord, + BountyLifecycleEventRecord, BountyRecord, + BurdenForecastRecord, + CheckSummaryRecord, + CollisionEdgeRecord, + ContributorEvidenceRecord, + ContributorRecord, + ContributorRepoStatRecord, + ContributorScoringProfileRecord, GitHubIssuePayload, GitHubPullRequestPayload, + GitHubRateLimitObservationRecord, GitHubRepositoryPayload, GitHubWebhookPayload, + InstallationHealthRecord, + InstallationRecord, IssueRecord, + IssueQualityReportRecord, + JsonValue, + PullRequestFileRecord, + PullRequestDetailSyncStateRecord, PullRequestRecord, + PullRequestReviewRecord, + RecentMergedPullRequestRecord, RegistryRepoConfig, + RegistryDriftEventRecord, + RepoLabelRecord, + RepoGithubTotalsSnapshotRecord, + RepoSnapshotRecord, + RepoSyncSegmentRecord, + RepoSyncStateRecord, RepositorySettings, RepositoryRecord, + ScorePreviewRecord, + ScoringModelSnapshotRecord, + SignalSnapshotRecord, } from "../types"; import { jsonString, nowIso, parseJson, repoParts } from "../utils/json"; +const MAX_STORED_BODY_CHARS = 4000; + export async function upsertInstallation(env: Env, payload: GitHubWebhookPayload): Promise { if (!payload.installation?.id) return; const account = payload.installation.account; @@ -57,6 +122,12 @@ export async function markInstallationDeleted(env: Env, installationId: number): .where(eq(repositories.installationId, installationId)); } +export async function listInstallations(env: Env): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(installations).orderBy(desc(installations.updatedAt)).limit(100); + return rows.map(toInstallationRecord); +} + export async function upsertRepositoryFromGitHub(env: Env, repo: GitHubRepositoryPayload, installationId?: number): Promise { const db = getDb(env.DB); const parts = repoParts(repo.full_name); @@ -92,9 +163,11 @@ export async function upsertPullRequestFromGitHub( env: Env, repoFullName: string, pr: GitHubPullRequestPayload, + options: { seenOpenAt?: string } = {}, ): Promise { const record = toPullRequestRecord(repoFullName, pr); const db = getDb(env.DB); + const lastSeenOpenAt = pr.state === "open" ? (options.seenOpenAt ?? nowIso()) : null; await db .insert(pullRequests) .values({ @@ -112,7 +185,8 @@ export async function upsertPullRequestFromGitHub( htmlUrl: pr.html_url, labelsJson: jsonString(record.labels), linkedIssuesJson: jsonString(record.linkedIssues), - payloadJson: jsonString(pr as unknown as Record), + lastSeenOpenAt, + payloadJson: jsonString(compactGitHubPayload(pr)), updatedAt: nowIso(), }) .onConflictDoUpdate({ @@ -129,16 +203,18 @@ export async function upsertPullRequestFromGitHub( htmlUrl: pr.html_url, labelsJson: jsonString(record.labels), linkedIssuesJson: jsonString(record.linkedIssues), - payloadJson: jsonString(pr as unknown as Record), + lastSeenOpenAt, + payloadJson: jsonString(compactGitHubPayload(pr)), updatedAt: nowIso(), }, }); return record; } -export async function upsertIssueFromGitHub(env: Env, repoFullName: string, issue: GitHubIssuePayload): Promise { +export async function upsertIssueFromGitHub(env: Env, repoFullName: string, issue: GitHubIssuePayload, options: { seenOpenAt?: string } = {}): Promise { const record = toIssueRecord(repoFullName, issue); const db = getDb(env.DB); + const lastSeenOpenAt = issue.state === "open" ? (options.seenOpenAt ?? nowIso()) : null; await db .insert(issues) .values({ @@ -152,7 +228,8 @@ export async function upsertIssueFromGitHub(env: Env, repoFullName: string, issu htmlUrl: issue.html_url, labelsJson: jsonString(record.labels), linkedPrsJson: jsonString(record.linkedPrs), - payloadJson: jsonString(issue as unknown as Record), + lastSeenOpenAt, + payloadJson: jsonString(compactGitHubPayload(issue)), updatedAt: nowIso(), }) .onConflictDoUpdate({ @@ -165,7 +242,8 @@ export async function upsertIssueFromGitHub(env: Env, repoFullName: string, issu htmlUrl: issue.html_url, labelsJson: jsonString(record.labels), linkedPrsJson: jsonString(record.linkedPrs), - payloadJson: jsonString(issue as unknown as Record), + lastSeenOpenAt, + payloadJson: jsonString(compactGitHubPayload(issue)), updatedAt: nowIso(), }, }); @@ -175,7 +253,13 @@ export async function upsertIssueFromGitHub(env: Env, repoFullName: string, issu export async function getRepository(env: Env, fullName: string): Promise { const db = getDb(env.DB); const [row] = await db.select().from(repositories).where(eq(repositories.fullName, fullName)).limit(1); - return row ? toRepositoryRecord(row) : null; + if (row) return toRepositoryRecord(row); + const [caseInsensitiveRow] = await db + .select() + .from(repositories) + .where(sql`lower(${repositories.fullName}) = ${fullName.toLowerCase()}`) + .limit(1); + return caseInsensitiveRow ? toRepositoryRecord(caseInsensitiveRow) : null; } export async function listRepositories(env: Env): Promise { @@ -193,6 +277,9 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise commentMode: "off", publicSignalLevel: "standard", checkRunMode: "enabled", + checkRunDetailLevel: "standard", + backfillEnabled: true, + privateTrustEnabled: true, }; } return { @@ -200,6 +287,9 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise commentMode: parseCommentMode(row.commentMode), publicSignalLevel: row.publicSignalLevel === "minimal" ? "minimal" : "standard", checkRunMode: "enabled", + checkRunDetailLevel: parseCheckRunDetailLevel(row.checkRunDetailLevel), + backfillEnabled: row.backfillEnabled, + privateTrustEnabled: row.privateTrustEnabled, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -214,6 +304,9 @@ export async function upsertRepositorySettings(env: Env, settings: RepositorySet commentMode: settings.commentMode, publicSignalLevel: settings.publicSignalLevel, checkRunMode: "enabled", + checkRunDetailLevel: settings.checkRunDetailLevel, + backfillEnabled: settings.backfillEnabled, + privateTrustEnabled: settings.privateTrustEnabled, updatedAt: nowIso(), }) .onConflictDoUpdate({ @@ -222,12 +315,516 @@ export async function upsertRepositorySettings(env: Env, settings: RepositorySet commentMode: settings.commentMode, publicSignalLevel: settings.publicSignalLevel, checkRunMode: "enabled", + checkRunDetailLevel: settings.checkRunDetailLevel, + backfillEnabled: settings.backfillEnabled, + privateTrustEnabled: settings.privateTrustEnabled, updatedAt: nowIso(), }, }); return getRepositorySettings(env, settings.repoFullName); } +export async function upsertRepoSyncState(env: Env, state: RepoSyncStateRecord): Promise { + const db = getDb(env.DB); + await db + .insert(repoSyncState) + .values({ + repoFullName: state.repoFullName, + status: state.status, + sourceKind: state.sourceKind, + primaryLanguage: state.primaryLanguage, + defaultBranch: state.defaultBranch, + isPrivate: state.isPrivate, + openIssuesCount: state.openIssuesCount, + openPullRequestsCount: state.openPullRequestsCount, + recentMergedPullRequestsCount: state.recentMergedPullRequestsCount, + labelsSyncedAt: state.labelsSyncedAt, + issuesSyncedAt: state.issuesSyncedAt, + pullRequestsSyncedAt: state.pullRequestsSyncedAt, + mergedPullRequestsSyncedAt: state.mergedPullRequestsSyncedAt, + lastStartedAt: state.lastStartedAt, + lastCompletedAt: state.lastCompletedAt, + errorSummary: state.errorSummary, + warningsJson: jsonString(state.warnings), + updatedAt: nowIso(), + }) + .onConflictDoUpdate({ + target: repoSyncState.repoFullName, + set: { + status: state.status, + sourceKind: state.sourceKind, + primaryLanguage: state.primaryLanguage, + defaultBranch: state.defaultBranch, + isPrivate: state.isPrivate, + openIssuesCount: state.openIssuesCount, + openPullRequestsCount: state.openPullRequestsCount, + recentMergedPullRequestsCount: state.recentMergedPullRequestsCount, + labelsSyncedAt: state.labelsSyncedAt, + issuesSyncedAt: state.issuesSyncedAt, + pullRequestsSyncedAt: state.pullRequestsSyncedAt, + mergedPullRequestsSyncedAt: state.mergedPullRequestsSyncedAt, + lastStartedAt: state.lastStartedAt, + lastCompletedAt: state.lastCompletedAt, + errorSummary: state.errorSummary, + warningsJson: jsonString(state.warnings), + updatedAt: nowIso(), + }, + }); +} + +export async function getRepoSyncState(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const [row] = await db.select().from(repoSyncState).where(eq(repoSyncState.repoFullName, fullName)).limit(1); + return row ? toRepoSyncStateRecord(row) : null; +} + +export async function listRepoSyncStates(env: Env): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(repoSyncState).orderBy(desc(repoSyncState.updatedAt)).limit(500); + return rows.map(toRepoSyncStateRecord); +} + +export async function upsertRepoSyncSegment(env: Env, segment: RepoSyncSegmentRecord): Promise { + const db = getDb(env.DB); + await db + .insert(repoSyncSegments) + .values({ + id: `${segment.repoFullName}#${segment.segment}`, + repoFullName: segment.repoFullName, + segment: segment.segment, + status: segment.status, + sourceKind: segment.sourceKind, + mode: segment.mode, + lastCursor: segment.lastCursor ?? null, + nextCursor: segment.nextCursor ?? null, + fetchedCount: segment.fetchedCount, + expectedCount: segment.expectedCount ?? null, + pageCount: segment.pageCount, + startedAt: segment.startedAt ?? null, + completedAt: segment.completedAt ?? null, + staleAt: segment.staleAt ?? null, + rateLimitResetAt: segment.rateLimitResetAt ?? null, + etag: segment.etag ?? null, + lastModified: segment.lastModified ?? null, + warningsJson: jsonString(segment.warnings), + errorSummary: segment.errorSummary ?? null, + updatedAt: nowIso(), + }) + .onConflictDoUpdate({ + target: [repoSyncSegments.repoFullName, repoSyncSegments.segment], + set: { + status: segment.status, + sourceKind: segment.sourceKind, + mode: segment.mode, + lastCursor: segment.lastCursor ?? null, + nextCursor: segment.nextCursor ?? null, + fetchedCount: segment.fetchedCount, + expectedCount: segment.expectedCount ?? null, + pageCount: segment.pageCount, + startedAt: segment.startedAt ?? null, + completedAt: segment.completedAt ?? null, + staleAt: segment.staleAt ?? null, + rateLimitResetAt: segment.rateLimitResetAt ?? null, + etag: segment.etag ?? null, + lastModified: segment.lastModified ?? null, + warningsJson: jsonString(segment.warnings), + errorSummary: segment.errorSummary ?? null, + updatedAt: nowIso(), + }, + }); +} + +export async function getRepoSyncSegment(env: Env, fullName: string, segment: RepoSyncSegmentRecord["segment"]): Promise { + const db = getDb(env.DB); + const [row] = await db + .select() + .from(repoSyncSegments) + .where(and(eq(repoSyncSegments.repoFullName, fullName), eq(repoSyncSegments.segment, segment))) + .limit(1); + return row ? toRepoSyncSegmentRecord(row) : null; +} + +export async function listRepoSyncSegments(env: Env, fullName?: string): Promise { + const db = getDb(env.DB); + const rows = fullName + ? await db + .select() + .from(repoSyncSegments) + .where(eq(repoSyncSegments.repoFullName, fullName)) + .orderBy(repoSyncSegments.repoFullName, repoSyncSegments.segment) + .limit(500) + : await db.select().from(repoSyncSegments).orderBy(repoSyncSegments.repoFullName, repoSyncSegments.segment).limit(2000); + return rows.map(toRepoSyncSegmentRecord); +} + +export async function recordGitHubRateLimitObservation(env: Env, observation: GitHubRateLimitObservationRecord): Promise { + const db = getDb(env.DB); + await db.insert(githubRateLimitObservations).values({ + id: observation.id ?? crypto.randomUUID(), + repoFullName: observation.repoFullName, + resource: observation.resource, + path: observation.path, + statusCode: observation.statusCode, + limitValue: observation.limitValue, + remaining: observation.remaining, + resetAt: observation.resetAt, + observedAt: observation.observedAt ?? nowIso(), + }); +} + +export async function listLatestGitHubRateLimitObservations(env: Env, limit = 50): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(githubRateLimitObservations).orderBy(desc(githubRateLimitObservations.observedAt)).limit(limit); + return rows.map(toGitHubRateLimitObservationRecord); +} + +export async function persistRepoGithubTotalsSnapshot(env: Env, snapshot: RepoGithubTotalsSnapshotRecord): Promise { + const db = getDb(env.DB); + await db.insert(repoGithubTotalsSnapshots).values({ + id: snapshot.id, + repoFullName: snapshot.repoFullName, + openIssuesTotal: snapshot.openIssuesTotal, + openPullRequestsTotal: snapshot.openPullRequestsTotal, + mergedPullRequestsTotal: snapshot.mergedPullRequestsTotal, + closedUnmergedPullRequestsTotal: snapshot.closedUnmergedPullRequestsTotal, + labelsTotal: snapshot.labelsTotal, + sourceKind: snapshot.sourceKind, + fetchedAt: snapshot.fetchedAt, + rateLimitRemaining: snapshot.rateLimitRemaining, + rateLimitResetAt: snapshot.rateLimitResetAt, + payloadJson: jsonString(snapshot.payload), + }); +} + +export async function getLatestRepoGithubTotalsSnapshot(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const [row] = await db + .select() + .from(repoGithubTotalsSnapshots) + .where(eq(repoGithubTotalsSnapshots.repoFullName, fullName)) + .orderBy(desc(repoGithubTotalsSnapshots.fetchedAt)) + .limit(1); + return row ? toRepoGithubTotalsSnapshotRecord(row) : null; +} + +export async function listLatestRepoGithubTotalsSnapshots(env: Env): Promise { + const db = getDb(env.DB); + const latestRows = await db + .select({ + repoFullName: repoGithubTotalsSnapshots.repoFullName, + fetchedAt: sql`max(${repoGithubTotalsSnapshots.fetchedAt})`, + }) + .from(repoGithubTotalsSnapshots) + .groupBy(repoGithubTotalsSnapshots.repoFullName); + const rows = []; + for (const latest of latestRows) { + const [row] = await db + .select() + .from(repoGithubTotalsSnapshots) + .where(and(eq(repoGithubTotalsSnapshots.repoFullName, latest.repoFullName), eq(repoGithubTotalsSnapshots.fetchedAt, latest.fetchedAt))) + .limit(1); + if (row) rows.push(row); + } + return rows.map(toRepoGithubTotalsSnapshotRecord).sort((left, right) => left.repoFullName.localeCompare(right.repoFullName)); +} + +export async function upsertPullRequestDetailSyncState(env: Env, state: PullRequestDetailSyncStateRecord): Promise { + const db = getDb(env.DB); + await db + .insert(pullRequestDetailSyncState) + .values({ + id: `${state.repoFullName}#${state.pullNumber}`, + repoFullName: state.repoFullName, + pullNumber: state.pullNumber, + status: state.status, + filesSyncedAt: state.filesSyncedAt, + reviewsSyncedAt: state.reviewsSyncedAt, + checksSyncedAt: state.checksSyncedAt, + lastSyncedAt: state.lastSyncedAt, + errorSummary: state.errorSummary, + updatedAt: nowIso(), + }) + .onConflictDoUpdate({ + target: [pullRequestDetailSyncState.repoFullName, pullRequestDetailSyncState.pullNumber], + set: { + status: state.status, + filesSyncedAt: state.filesSyncedAt, + reviewsSyncedAt: state.reviewsSyncedAt, + checksSyncedAt: state.checksSyncedAt, + lastSyncedAt: state.lastSyncedAt, + errorSummary: state.errorSummary, + updatedAt: nowIso(), + }, + }); +} + +export async function listPullRequestDetailSyncStates(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(pullRequestDetailSyncState).where(eq(pullRequestDetailSyncState.repoFullName, fullName)).limit(2000); + return rows.map(toPullRequestDetailSyncStateRecord).sort((left, right) => left.pullNumber - right.pullNumber); +} + +export async function listAllPullRequestDetailSyncStates(env: Env): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(pullRequestDetailSyncState).orderBy(pullRequestDetailSyncState.repoFullName, pullRequestDetailSyncState.pullNumber).limit(10000); + return rows.map(toPullRequestDetailSyncStateRecord); +} + +export async function persistScoringModelSnapshot(env: Env, snapshot: ScoringModelSnapshotRecord): Promise { + const db = getDb(env.DB); + await db.insert(scoringModelSnapshots).values({ + id: snapshot.id, + sourceKind: snapshot.sourceKind, + sourceUrl: snapshot.sourceUrl, + fetchedAt: snapshot.fetchedAt, + activeModel: snapshot.activeModel, + constantsJson: jsonString(snapshot.constants), + programmingLanguagesJson: jsonString(snapshot.programmingLanguages), + registrySnapshotId: snapshot.registrySnapshotId, + warningsJson: jsonString(snapshot.warnings), + payloadJson: jsonString(snapshot.payload), + }); +} + +export async function getLatestScoringModelSnapshot(env: Env): Promise { + const db = getDb(env.DB); + const [row] = await db.select().from(scoringModelSnapshots).orderBy(desc(scoringModelSnapshots.fetchedAt)).limit(1); + return row ? toScoringModelSnapshotRecord(row) : null; +} + +export async function persistScorePreview(env: Env, preview: ScorePreviewRecord): Promise { + const db = getDb(env.DB); + await db.insert(scorePreviews).values({ + id: preview.id, + scoringModelSnapshotId: preview.scoringModelSnapshotId, + repoFullName: preview.repoFullName, + targetType: preview.targetType, + targetKey: preview.targetKey, + contributorLogin: preview.contributorLogin, + inputJson: jsonString(preview.input), + resultJson: jsonString(preview.result), + generatedAt: preview.generatedAt, + }); +} + +export async function getLatestScorePreview(env: Env, repoFullName: string, targetKey: string): Promise { + const db = getDb(env.DB); + const [row] = await db + .select() + .from(scorePreviews) + .where(and(eq(scorePreviews.repoFullName, repoFullName), eq(scorePreviews.targetKey, targetKey))) + .orderBy(desc(scorePreviews.generatedAt)) + .limit(1); + return row ? toScorePreviewRecord(row) : null; +} + +export async function upsertContributorEvidence(env: Env, evidence: ContributorEvidenceRecord): Promise { + const db = getDb(env.DB); + await db + .insert(contributorEvidence) + .values({ login: evidence.login, payloadJson: jsonString(evidence.payload), generatedAt: evidence.generatedAt }) + .onConflictDoUpdate({ + target: contributorEvidence.login, + set: { payloadJson: jsonString(evidence.payload), generatedAt: evidence.generatedAt }, + }); +} + +export async function getContributorEvidence(env: Env, login: string): Promise { + const db = getDb(env.DB); + const [row] = await db.select().from(contributorEvidence).where(eq(contributorEvidence.login, login)).limit(1); + return row ? { login: row.login, payload: parseJson(row.payloadJson, {}), generatedAt: row.generatedAt } : null; +} + +export async function createAuthSession(env: Env, session: AuthSessionRecord): Promise { + const db = getDb(env.DB); + await db.insert(authSessions).values({ + id: session.id, + tokenHash: session.tokenHash, + login: session.login, + githubUserId: session.githubUserId, + scopesJson: jsonString(session.scopes), + expiresAt: session.expiresAt, + revokedAt: session.revokedAt, + createdAt: session.createdAt, + lastSeenAt: session.lastSeenAt, + metadataJson: jsonString(session.metadata), + }); + return session; +} + +export async function getAuthSessionByTokenHash(env: Env, tokenHash: string): Promise { + const db = getDb(env.DB); + const [row] = await db.select().from(authSessions).where(eq(authSessions.tokenHash, tokenHash)).limit(1); + return row ? toAuthSessionRecord(row) : null; +} + +export async function touchAuthSession(env: Env, sessionId: string): Promise { + const db = getDb(env.DB); + await db.update(authSessions).set({ lastSeenAt: nowIso() }).where(eq(authSessions.id, sessionId)); +} + +export async function revokeAuthSession(env: Env, sessionId: string): Promise { + const db = getDb(env.DB); + await db.update(authSessions).set({ revokedAt: nowIso(), lastSeenAt: nowIso() }).where(eq(authSessions.id, sessionId)); +} + +export async function recordAuditEvent(env: Env, event: AuditEventRecord): Promise { + const db = getDb(env.DB); + await db.insert(auditEvents).values({ + id: event.id ?? crypto.randomUUID(), + eventType: event.eventType, + actor: event.actor, + route: event.route, + targetKey: event.targetKey, + outcome: event.outcome, + detail: event.detail, + metadataJson: jsonString(event.metadata ?? {}), + createdAt: event.createdAt ?? nowIso(), + }); +} + +export async function upsertContributorScoringProfile(env: Env, profile: ContributorScoringProfileRecord): Promise { + const db = getDb(env.DB); + await db + .insert(contributorScoringProfiles) + .values({ + login: profile.login, + scoringModelSnapshotId: profile.scoringModelSnapshotId, + payloadJson: jsonString(profile.payload), + generatedAt: profile.generatedAt, + }) + .onConflictDoUpdate({ + target: contributorScoringProfiles.login, + set: { + scoringModelSnapshotId: profile.scoringModelSnapshotId, + payloadJson: jsonString(profile.payload), + generatedAt: profile.generatedAt, + }, + }); +} + +export async function getContributorScoringProfile(env: Env, login: string): Promise { + const db = getDb(env.DB); + const [row] = await db.select().from(contributorScoringProfiles).where(eq(contributorScoringProfiles.login, login)).limit(1); + return row + ? { login: row.login, scoringModelSnapshotId: row.scoringModelSnapshotId, payload: parseJson(row.payloadJson, {}), generatedAt: row.generatedAt } + : null; +} + +export async function upsertIssueQualityReport(env: Env, report: IssueQualityReportRecord): Promise { + const db = getDb(env.DB); + await db + .insert(issueQualityReports) + .values({ + id: report.id, + repoFullName: report.repoFullName, + issueNumber: report.issueNumber, + payloadJson: jsonString(report.payload), + generatedAt: report.generatedAt, + }) + .onConflictDoUpdate({ + target: [issueQualityReports.repoFullName, issueQualityReports.issueNumber], + set: { payloadJson: jsonString(report.payload), generatedAt: report.generatedAt }, + }); +} + +export async function upsertBurdenForecast(env: Env, forecast: BurdenForecastRecord): Promise { + const db = getDb(env.DB); + await db + .insert(burdenForecasts) + .values({ repoFullName: forecast.repoFullName, payloadJson: jsonString(forecast.payload), generatedAt: forecast.generatedAt }) + .onConflictDoUpdate({ + target: burdenForecasts.repoFullName, + set: { payloadJson: jsonString(forecast.payload), generatedAt: forecast.generatedAt }, + }); +} + +export async function persistRegistryDriftEvents(env: Env, events: RegistryDriftEventRecord[]): Promise { + const db = getDb(env.DB); + for (const event of events) { + await db.insert(registryDriftEvents).values({ + id: event.id, + repoFullName: event.repoFullName, + driftType: event.driftType, + detail: event.detail, + previousSnapshotId: event.previousSnapshotId, + currentSnapshotId: event.currentSnapshotId, + payloadJson: jsonString(event.payload), + generatedAt: event.generatedAt, + }); + } +} + +export async function persistBountyLifecycleEvent(env: Env, event: BountyLifecycleEventRecord): Promise { + const db = getDb(env.DB); + await db.insert(bountyLifecycleEvents).values({ + id: event.id, + bountyId: event.bountyId, + repoFullName: event.repoFullName, + issueNumber: event.issueNumber, + status: event.status, + payloadJson: jsonString(event.payload), + generatedAt: event.generatedAt, + }); +} + +export async function upsertRepoLabel(env: Env, label: RepoLabelRecord): Promise { + const db = getDb(env.DB); + await db + .insert(repoLabels) + .values({ + id: `${label.repoFullName}#${label.name.toLowerCase()}`, + repoFullName: label.repoFullName, + name: label.name, + color: label.color, + description: label.description, + isConfigured: label.isConfigured, + observedCount: label.observedCount, + payloadJson: jsonString(label.payload), + lastSeenAt: label.lastSeenAt ?? nowIso(), + }) + .onConflictDoUpdate({ + target: [repoLabels.repoFullName, repoLabels.name], + set: { + color: label.color, + description: label.description, + isConfigured: label.isConfigured, + observedCount: label.observedCount, + payloadJson: jsonString(label.payload), + lastSeenAt: label.lastSeenAt ?? nowIso(), + }, + }); +} + +export async function listRepoLabels(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(repoLabels).where(eq(repoLabels.repoFullName, fullName)).limit(500); + return rows.map(toRepoLabelRecord).sort((left, right) => left.name.localeCompare(right.name)); +} + +export async function countRepoLabels(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const [row] = await db.select({ count: sql`count(*)` }).from(repoLabels).where(eq(repoLabels.repoFullName, fullName)); + return Number(row?.count ?? 0); +} + +export async function persistRepoSnapshot(env: Env, snapshot: RepoSnapshotRecord): Promise { + const db = getDb(env.DB); + await db.insert(repoSnapshots).values({ + id: snapshot.id, + repoFullName: snapshot.repoFullName, + snapshotKind: snapshot.snapshotKind, + sourceKind: snapshot.sourceKind, + fetchedAt: snapshot.fetchedAt, + primaryLanguage: snapshot.primaryLanguage, + defaultBranch: snapshot.defaultBranch, + openIssuesCount: snapshot.openIssuesCount, + openPullRequestsCount: snapshot.openPullRequestsCount, + recentMergedPullRequestsCount: snapshot.recentMergedPullRequestsCount, + payloadJson: jsonString(snapshot.payload), + }); +} + export async function getPullRequest(env: Env, fullName: string, number: number): Promise { const db = getDb(env.DB); const [row] = await db @@ -246,10 +843,46 @@ export async function getIssue(env: Env, fullName: string, number: number): Prom export async function listOpenIssues(env: Env, fullName: string): Promise { const db = getDb(env.DB); - const rows = await db.select().from(issues).where(and(eq(issues.repoFullName, fullName), eq(issues.state, "open"))).limit(100); + const rows = await db.select().from(issues).where(and(eq(issues.repoFullName, fullName), eq(issues.state, "open"))).orderBy(desc(issues.updatedAt)).limit(10000); + return rows.map(toIssueRecordFromRow); +} + +export async function countOpenIssues(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const [row] = await db.select({ count: sql`count(*)` }).from(issues).where(and(eq(issues.repoFullName, fullName), eq(issues.state, "open"))); + return Number(row?.count ?? 0); +} + +export async function listOpenIssueNumbers(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const rows = await db + .select({ number: issues.number }) + .from(issues) + .where(and(eq(issues.repoFullName, fullName), eq(issues.state, "open"))) + .limit(10000); + return rows.map((row) => row.number); +} + +export async function listIssueSignalSample(env: Env, fullName: string, limit = 400): Promise { + const db = getDb(env.DB); + const rows = await db + .select() + .from(issues) + .where(and(eq(issues.repoFullName, fullName), eq(issues.state, "open"))) + .orderBy(desc(issues.updatedAt)) + .limit(limit); return rows.map(toIssueRecordFromRow); } +export async function markUnseenOpenIssuesClosed(env: Env, fullName: string, seenOpenAt: string): Promise { + const db = getDb(env.DB); + const result = await db + .update(issues) + .set({ state: "closed", updatedAt: nowIso() }) + .where(sql`${issues.repoFullName} = ${fullName} AND ${issues.state} = 'open' AND (${issues.lastSeenOpenAt} IS NULL OR ${issues.lastSeenOpenAt} < ${seenOpenAt})`); + return Number(result.meta.changes ?? 0); +} + export async function listIssues(env: Env, fullName: string): Promise { const db = getDb(env.DB); const rows = await db.select().from(issues).where(eq(issues.repoFullName, fullName)).limit(500); @@ -264,10 +897,27 @@ export async function listAllIssues(env: Env): Promise { export async function listOpenPullRequests(env: Env, fullName: string): Promise { const db = getDb(env.DB); - const rows = await db.select().from(pullRequests).where(and(eq(pullRequests.repoFullName, fullName), eq(pullRequests.state, "open"))).limit(500); + const rows = await db.select().from(pullRequests).where(and(eq(pullRequests.repoFullName, fullName), eq(pullRequests.state, "open"))).limit(10000); return rows.map(toPullRequestRecordFromRow); } +export async function countOpenPullRequests(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const [row] = await db.select({ count: sql`count(*)` }).from(pullRequests).where(and(eq(pullRequests.repoFullName, fullName), eq(pullRequests.state, "open"))); + return Number(row?.count ?? 0); +} + +export async function markUnseenOpenPullRequestsClosed(env: Env, fullName: string, seenOpenAt: string): Promise { + const db = getDb(env.DB); + const result = await db + .update(pullRequests) + .set({ state: "closed", updatedAt: nowIso() }) + .where( + sql`${pullRequests.repoFullName} = ${fullName} AND ${pullRequests.state} = 'open' AND (${pullRequests.lastSeenOpenAt} IS NULL OR ${pullRequests.lastSeenOpenAt} < ${seenOpenAt})`, + ); + return Number(result.meta.changes ?? 0); +} + export async function listPullRequests(env: Env, fullName: string): Promise { const db = getDb(env.DB); const rows = await db.select().from(pullRequests).where(eq(pullRequests.repoFullName, fullName)).limit(500); @@ -292,74 +942,426 @@ export async function listOtherOpenPullRequests(env: Env, fullName: string, numb export async function listContributorPullRequests(env: Env, login: string): Promise { const db = getDb(env.DB); - const rows = await db.select().from(pullRequests).where(eq(pullRequests.authorLogin, login)).limit(1000); + const rows = await db.select().from(pullRequests).where(loginMatches(pullRequests.authorLogin, login)).limit(1000); return rows.map(toPullRequestRecordFromRow); } export async function listContributorIssues(env: Env, login: string): Promise { const db = getDb(env.DB); - const rows = await db.select().from(issues).where(eq(issues.authorLogin, login)).limit(1000); + const rows = await db.select().from(issues).where(loginMatches(issues.authorLogin, login)).limit(1000); return rows.map(toIssueRecordFromRow); } -export async function listBounties(env: Env): Promise { +export async function upsertPullRequestFile(env: Env, file: PullRequestFileRecord): Promise { const db = getDb(env.DB); - const rows = await db.select().from(bounties).orderBy(desc(bounties.updatedAt)).limit(1000); - return rows.map(toBountyRecord); + await db + .insert(pullRequestFiles) + .values({ + id: `${file.repoFullName}#${file.pullNumber}#${file.path}`, + repoFullName: file.repoFullName, + pullNumber: file.pullNumber, + path: file.path, + status: file.status, + additions: file.additions, + deletions: file.deletions, + changes: file.changes, + previousFilename: file.previousFilename, + payloadJson: jsonString(file.payload), + updatedAt: nowIso(), + }) + .onConflictDoUpdate({ + target: [pullRequestFiles.repoFullName, pullRequestFiles.pullNumber, pullRequestFiles.path], + set: { + status: file.status, + additions: file.additions, + deletions: file.deletions, + changes: file.changes, + previousFilename: file.previousFilename, + payloadJson: jsonString(file.payload), + updatedAt: nowIso(), + }, + }); } -export async function getBounty(env: Env, id: string): Promise { +export async function listPullRequestFiles(env: Env, fullName: string, pullNumber: number): Promise { const db = getDb(env.DB); - const [row] = await db.select().from(bounties).where(eq(bounties.id, id)).limit(1); - return row ? toBountyRecord(row) : null; + const rows = await db + .select() + .from(pullRequestFiles) + .where(and(eq(pullRequestFiles.repoFullName, fullName), eq(pullRequestFiles.pullNumber, pullNumber))) + .limit(500); + return rows.map(toPullRequestFileRecord); } -export async function upsertBounty(env: Env, bounty: BountyRecord): Promise { +export async function upsertPullRequestReview(env: Env, review: PullRequestReviewRecord): Promise { const db = getDb(env.DB); await db - .insert(bounties) + .insert(pullRequestReviews) .values({ - id: bounty.id, - repoFullName: bounty.repoFullName, - issueNumber: bounty.issueNumber, - status: bounty.status, - amountText: bounty.amountText, - sourceUrl: bounty.sourceUrl, - payloadJson: jsonString(bounty.payload), + id: review.id, + repoFullName: review.repoFullName, + pullNumber: review.pullNumber, + reviewerLogin: review.reviewerLogin, + state: review.state, + authorAssociation: review.authorAssociation, + submittedAt: review.submittedAt, + payloadJson: jsonString(review.payload), updatedAt: nowIso(), }) .onConflictDoUpdate({ - target: bounties.id, + target: pullRequestReviews.id, set: { - repoFullName: bounty.repoFullName, - issueNumber: bounty.issueNumber, - status: bounty.status, - amountText: bounty.amountText, - sourceUrl: bounty.sourceUrl, - payloadJson: jsonString(bounty.payload), + reviewerLogin: review.reviewerLogin, + state: review.state, + authorAssociation: review.authorAssociation, + submittedAt: review.submittedAt, + payloadJson: jsonString(review.payload), updatedAt: nowIso(), }, }); } -export async function persistAdvisory(env: Env, advisory: Advisory): Promise { +export async function listPullRequestReviews(env: Env, fullName: string, pullNumber: number): Promise { const db = getDb(env.DB); - await db.insert(advisories).values({ - id: advisory.id, - targetType: advisory.targetType, - targetKey: advisory.targetKey, - repoFullName: advisory.repoFullName, - pullNumber: advisory.pullNumber, - issueNumber: advisory.issueNumber, - headSha: advisory.headSha, - conclusion: advisory.conclusion, - severity: advisory.severity, - title: advisory.title, - summary: advisory.summary, - findingsJson: jsonString(advisory.findings as unknown as Record[]), - updatedAt: nowIso(), - }); -} + const rows = await db + .select() + .from(pullRequestReviews) + .where(and(eq(pullRequestReviews.repoFullName, fullName), eq(pullRequestReviews.pullNumber, pullNumber))) + .limit(500); + return rows.map(toPullRequestReviewRecord); +} + +export async function upsertCheckSummary(env: Env, check: CheckSummaryRecord): Promise { + const db = getDb(env.DB); + await db + .insert(checkSummaries) + .values({ + id: check.id, + repoFullName: check.repoFullName, + pullNumber: check.pullNumber, + headSha: check.headSha, + name: check.name, + status: check.status, + conclusion: check.conclusion, + startedAt: check.startedAt, + completedAt: check.completedAt, + detailsUrl: check.detailsUrl, + payloadJson: jsonString(check.payload), + updatedAt: nowIso(), + }) + .onConflictDoUpdate({ + target: [checkSummaries.repoFullName, checkSummaries.headSha, checkSummaries.name], + set: { + pullNumber: check.pullNumber, + status: check.status, + conclusion: check.conclusion, + startedAt: check.startedAt, + completedAt: check.completedAt, + detailsUrl: check.detailsUrl, + payloadJson: jsonString(check.payload), + updatedAt: nowIso(), + }, + }); +} + +export async function listCheckSummaries(env: Env, fullName: string, pullNumber: number): Promise { + const db = getDb(env.DB); + const rows = await db + .select() + .from(checkSummaries) + .where(and(eq(checkSummaries.repoFullName, fullName), eq(checkSummaries.pullNumber, pullNumber))) + .limit(500); + return rows.map(toCheckSummaryRecord); +} + +export async function upsertRecentMergedPullRequest(env: Env, pr: RecentMergedPullRequestRecord): Promise { + const db = getDb(env.DB); + await db + .insert(recentMergedPullRequests) + .values({ + id: `${pr.repoFullName}#${pr.number}`, + repoFullName: pr.repoFullName, + number: pr.number, + title: pr.title, + authorLogin: pr.authorLogin, + htmlUrl: pr.htmlUrl, + mergedAt: pr.mergedAt, + labelsJson: jsonString(pr.labels), + linkedIssuesJson: jsonString(pr.linkedIssues), + changedFilesJson: jsonString(pr.changedFiles), + payloadJson: jsonString(pr.payload), + updatedAt: nowIso(), + }) + .onConflictDoUpdate({ + target: [recentMergedPullRequests.repoFullName, recentMergedPullRequests.number], + set: { + title: pr.title, + authorLogin: pr.authorLogin, + htmlUrl: pr.htmlUrl, + mergedAt: pr.mergedAt, + labelsJson: jsonString(pr.labels), + linkedIssuesJson: jsonString(pr.linkedIssues), + changedFilesJson: jsonString(pr.changedFiles), + payloadJson: jsonString(pr.payload), + updatedAt: nowIso(), + }, + }); +} + +export async function listRecentMergedPullRequests(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const rows = await db + .select() + .from(recentMergedPullRequests) + .where(eq(recentMergedPullRequests.repoFullName, fullName)) + .orderBy(desc(recentMergedPullRequests.mergedAt)) + .limit(200); + return rows.map(toRecentMergedPullRequestRecord); +} + +export async function countRecentMergedPullRequests(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const [row] = await db.select({ count: sql`count(*)` }).from(recentMergedPullRequests).where(eq(recentMergedPullRequests.repoFullName, fullName)); + return Number(row?.count ?? 0); +} + +export async function listContributorRecentMergedPullRequests(env: Env, login: string): Promise { + const db = getDb(env.DB); + const rows = await db + .select() + .from(recentMergedPullRequests) + .where(loginMatches(recentMergedPullRequests.authorLogin, login)) + .orderBy(desc(recentMergedPullRequests.mergedAt)) + .limit(1000); + return rows.map(toRecentMergedPullRequestRecord); +} + +export async function upsertContributor(env: Env, contributor: ContributorRecord): Promise { + const db = getDb(env.DB); + await db + .insert(contributors) + .values({ + login: contributor.login, + githubProfileJson: jsonString(contributor.githubProfile), + topLanguagesJson: jsonString(contributor.topLanguages), + publicRepos: contributor.publicRepos, + followers: contributor.followers, + source: contributor.source, + lastSeenAt: contributor.lastSeenAt ?? nowIso(), + updatedAt: nowIso(), + }) + .onConflictDoUpdate({ + target: contributors.login, + set: { + githubProfileJson: jsonString(contributor.githubProfile), + topLanguagesJson: jsonString(contributor.topLanguages), + publicRepos: contributor.publicRepos, + followers: contributor.followers, + source: contributor.source, + lastSeenAt: contributor.lastSeenAt ?? nowIso(), + updatedAt: nowIso(), + }, + }); +} + +export async function upsertContributorRepoStat(env: Env, stat: ContributorRepoStatRecord): Promise { + const db = getDb(env.DB); + const login = stat.login.toLowerCase(); + await db + .insert(contributorRepoStats) + .values({ + id: `${login}#${stat.repoFullName}`, + login, + repoFullName: stat.repoFullName, + pullRequests: stat.pullRequests, + mergedPullRequests: stat.mergedPullRequests, + openPullRequests: stat.openPullRequests, + issues: stat.issues, + stalePullRequests: stat.stalePullRequests, + unlinkedPullRequests: stat.unlinkedPullRequests, + dominantLabelsJson: jsonString(stat.dominantLabels), + lastActivityAt: stat.lastActivityAt, + updatedAt: nowIso(), + }) + .onConflictDoUpdate({ + target: [contributorRepoStats.login, contributorRepoStats.repoFullName], + set: { + pullRequests: stat.pullRequests, + mergedPullRequests: stat.mergedPullRequests, + openPullRequests: stat.openPullRequests, + issues: stat.issues, + stalePullRequests: stat.stalePullRequests, + unlinkedPullRequests: stat.unlinkedPullRequests, + dominantLabelsJson: jsonString(stat.dominantLabels), + lastActivityAt: stat.lastActivityAt, + updatedAt: nowIso(), + }, + }); +} + +export async function listContributorRepoStats(env: Env, login: string): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(contributorRepoStats).where(loginMatches(contributorRepoStats.login, login)).limit(500); + return mergeContributorRepoStats(rows.map(toContributorRepoStatRecord)); +} + +export async function listBounties(env: Env): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(bounties).orderBy(desc(bounties.updatedAt)).limit(1000); + return rows.map(toBountyRecord); +} + +export async function getBounty(env: Env, id: string): Promise { + const db = getDb(env.DB); + const [row] = await db.select().from(bounties).where(eq(bounties.id, id)).limit(1); + return row ? toBountyRecord(row) : null; +} + +export async function upsertBounty(env: Env, bounty: BountyRecord): Promise { + const db = getDb(env.DB); + await db + .insert(bounties) + .values({ + id: bounty.id, + repoFullName: bounty.repoFullName, + issueNumber: bounty.issueNumber, + status: bounty.status, + amountText: bounty.amountText, + sourceUrl: bounty.sourceUrl, + payloadJson: jsonString(bounty.payload), + updatedAt: nowIso(), + }) + .onConflictDoUpdate({ + target: bounties.id, + set: { + repoFullName: bounty.repoFullName, + issueNumber: bounty.issueNumber, + status: bounty.status, + amountText: bounty.amountText, + sourceUrl: bounty.sourceUrl, + payloadJson: jsonString(bounty.payload), + updatedAt: nowIso(), + }, + }); +} + +export async function persistAdvisory(env: Env, advisory: Advisory): Promise { + const db = getDb(env.DB); + await db.insert(advisories).values({ + id: advisory.id, + targetType: advisory.targetType, + targetKey: advisory.targetKey, + repoFullName: advisory.repoFullName, + pullNumber: advisory.pullNumber, + issueNumber: advisory.issueNumber, + headSha: advisory.headSha, + conclusion: advisory.conclusion, + severity: advisory.severity, + title: advisory.title, + summary: advisory.summary, + findingsJson: jsonString(advisory.findings as unknown as Record[]), + updatedAt: nowIso(), + }); +} + +export async function replaceCollisionEdges(env: Env, repoFullName: string, edges: CollisionEdgeRecord[]): Promise { + const db = getDb(env.DB); + await env.DB.prepare("DELETE FROM collision_edges WHERE repo_full_name = ?").bind(repoFullName).run(); + const limitedEdges = edges.slice(0, 40); + for (const edge of limitedEdges) { + await db.insert(collisionEdges).values({ + id: edge.id, + repoFullName: edge.repoFullName, + leftType: edge.leftType, + leftNumber: edge.leftNumber, + leftTitle: edge.leftTitle, + rightType: edge.rightType, + rightNumber: edge.rightNumber, + rightTitle: edge.rightTitle, + risk: edge.risk, + reason: edge.reason, + sharedTermsJson: jsonString(edge.sharedTerms), + generatedAt: edge.generatedAt ?? nowIso(), + }); + } +} + +export async function listCollisionEdges(env: Env, repoFullName: string): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(collisionEdges).where(eq(collisionEdges.repoFullName, repoFullName)).limit(1000); + return rows.map(toCollisionEdgeRecord); +} + +export async function persistSignalSnapshot(env: Env, snapshot: SignalSnapshotRecord): Promise { + const db = getDb(env.DB); + await db.insert(signalSnapshots).values({ + id: snapshot.id, + signalType: snapshot.signalType, + targetKey: snapshot.targetKey, + repoFullName: snapshot.repoFullName, + payloadJson: jsonString(snapshot.payload), + generatedAt: snapshot.generatedAt ?? nowIso(), + }); +} + +export async function listSignalSnapshots(env: Env, signalType: string, targetKey: string): Promise { + const db = getDb(env.DB); + const rows = await db + .select() + .from(signalSnapshots) + .where(and(eq(signalSnapshots.signalType, signalType), eq(signalSnapshots.targetKey, targetKey))) + .orderBy(desc(signalSnapshots.generatedAt)) + .limit(100); + return rows.map(toSignalSnapshotRecord); +} + +export async function upsertInstallationHealth(env: Env, health: InstallationHealthRecord): Promise { + const db = getDb(env.DB); + await db + .insert(installationHealth) + .values({ + installationId: health.installationId, + accountLogin: health.accountLogin, + repositorySelection: health.repositorySelection, + installedReposCount: health.installedReposCount, + registeredInstalledCount: health.registeredInstalledCount, + status: health.status, + missingPermissionsJson: jsonString(health.missingPermissions), + missingEventsJson: jsonString(health.missingEvents), + permissionsJson: jsonString(health.permissions), + eventsJson: jsonString(health.events), + checkedAt: health.checkedAt, + errorSummary: health.errorSummary, + }) + .onConflictDoUpdate({ + target: installationHealth.installationId, + set: { + accountLogin: health.accountLogin, + repositorySelection: health.repositorySelection, + installedReposCount: health.installedReposCount, + registeredInstalledCount: health.registeredInstalledCount, + status: health.status, + missingPermissionsJson: jsonString(health.missingPermissions), + missingEventsJson: jsonString(health.missingEvents), + permissionsJson: jsonString(health.permissions), + eventsJson: jsonString(health.events), + checkedAt: health.checkedAt, + errorSummary: health.errorSummary, + }, + }); +} + +export async function listInstallationHealth(env: Env): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(installationHealth).orderBy(desc(installationHealth.checkedAt)).limit(100); + return rows.map(toInstallationHealthRecord); +} + +export async function getInstallationHealth(env: Env, installationId: number): Promise { + const db = getDb(env.DB); + const [row] = await db.select().from(installationHealth).where(eq(installationHealth.installationId, installationId)).limit(1); + return row ? toInstallationHealthRecord(row) : null; +} export async function recordWebhookEvent( env: Env, @@ -416,6 +1418,21 @@ export async function getWebhookEvent( }; } +function toInstallationRecord(row: typeof installations.$inferSelect): InstallationRecord { + return { + id: row.id, + accountLogin: row.accountLogin, + accountId: row.accountId, + targetType: row.targetType, + repositorySelection: row.repositorySelection, + permissions: parseJson>(row.permissionsJson, {}), + events: parseJson(row.eventsJson, []), + suspendedAt: row.suspendedAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + function toRepositoryRecord(row: typeof repositories.$inferSelect): RepositoryRecord { return { fullName: row.fullName, @@ -431,6 +1448,140 @@ function toRepositoryRecord(row: typeof repositories.$inferSelect): RepositoryRe }; } +function toRepoSyncStateRecord(row: typeof repoSyncState.$inferSelect): RepoSyncStateRecord { + return { + repoFullName: row.repoFullName, + status: parseSyncStatus(row.status), + sourceKind: parseSyncSourceKind(row.sourceKind), + primaryLanguage: row.primaryLanguage, + defaultBranch: row.defaultBranch, + isPrivate: row.isPrivate, + openIssuesCount: row.openIssuesCount, + openPullRequestsCount: row.openPullRequestsCount, + recentMergedPullRequestsCount: row.recentMergedPullRequestsCount, + labelsSyncedAt: row.labelsSyncedAt, + issuesSyncedAt: row.issuesSyncedAt, + pullRequestsSyncedAt: row.pullRequestsSyncedAt, + mergedPullRequestsSyncedAt: row.mergedPullRequestsSyncedAt, + lastStartedAt: row.lastStartedAt, + lastCompletedAt: row.lastCompletedAt, + errorSummary: row.errorSummary, + warnings: parseJson(row.warningsJson, []), + updatedAt: row.updatedAt, + }; +} + +function toRepoSyncSegmentRecord(row: typeof repoSyncSegments.$inferSelect): RepoSyncSegmentRecord { + return { + repoFullName: row.repoFullName, + segment: parseRepoSyncSegment(row.segment), + status: parseRepoSyncSegmentStatus(row.status), + sourceKind: parseSyncSourceKind(row.sourceKind), + mode: parseBackfillMode(row.mode), + lastCursor: row.lastCursor, + nextCursor: row.nextCursor, + fetchedCount: row.fetchedCount, + expectedCount: row.expectedCount, + pageCount: row.pageCount, + startedAt: row.startedAt, + completedAt: row.completedAt, + staleAt: row.staleAt, + rateLimitResetAt: row.rateLimitResetAt, + etag: row.etag, + lastModified: row.lastModified, + warnings: parseJson(row.warningsJson, []), + errorSummary: row.errorSummary, + updatedAt: row.updatedAt, + }; +} + +function toGitHubRateLimitObservationRecord(row: typeof githubRateLimitObservations.$inferSelect): GitHubRateLimitObservationRecord { + return { + id: row.id, + repoFullName: row.repoFullName, + resource: row.resource === "graphql" ? "graphql" : "rest", + path: row.path, + statusCode: row.statusCode, + limitValue: row.limitValue, + remaining: row.remaining, + resetAt: row.resetAt, + observedAt: row.observedAt, + }; +} + +function toRepoGithubTotalsSnapshotRecord(row: typeof repoGithubTotalsSnapshots.$inferSelect): RepoGithubTotalsSnapshotRecord { + return { + id: row.id, + repoFullName: row.repoFullName, + openIssuesTotal: row.openIssuesTotal, + openPullRequestsTotal: row.openPullRequestsTotal, + mergedPullRequestsTotal: row.mergedPullRequestsTotal, + closedUnmergedPullRequestsTotal: row.closedUnmergedPullRequestsTotal, + labelsTotal: row.labelsTotal, + sourceKind: parseSyncSourceKind(row.sourceKind), + fetchedAt: row.fetchedAt, + rateLimitRemaining: row.rateLimitRemaining, + rateLimitResetAt: row.rateLimitResetAt, + payload: parseJson>(row.payloadJson, {}), + }; +} + +function toPullRequestDetailSyncStateRecord(row: typeof pullRequestDetailSyncState.$inferSelect): PullRequestDetailSyncStateRecord { + return { + repoFullName: row.repoFullName, + pullNumber: row.pullNumber, + status: parsePullRequestDetailSyncStatus(row.status), + filesSyncedAt: row.filesSyncedAt, + reviewsSyncedAt: row.reviewsSyncedAt, + checksSyncedAt: row.checksSyncedAt, + lastSyncedAt: row.lastSyncedAt, + errorSummary: row.errorSummary, + updatedAt: row.updatedAt, + }; +} + +function toScoringModelSnapshotRecord(row: typeof scoringModelSnapshots.$inferSelect): ScoringModelSnapshotRecord { + return { + id: row.id, + sourceKind: parseScoringSourceKind(row.sourceKind), + sourceUrl: row.sourceUrl, + fetchedAt: row.fetchedAt, + activeModel: parseActiveScoringModel(row.activeModel), + constants: parseJson>(row.constantsJson, {}), + programmingLanguages: parseJson>(row.programmingLanguagesJson, {}), + registrySnapshotId: row.registrySnapshotId, + warnings: parseJson(row.warningsJson, []), + payload: parseJson>(row.payloadJson, {}), + }; +} + +function toScorePreviewRecord(row: typeof scorePreviews.$inferSelect): ScorePreviewRecord { + return { + id: row.id, + scoringModelSnapshotId: row.scoringModelSnapshotId, + repoFullName: row.repoFullName, + targetType: parseScorePreviewTargetType(row.targetType), + targetKey: row.targetKey, + contributorLogin: row.contributorLogin, + input: parseJson>(row.inputJson, {}), + result: parseJson>(row.resultJson, {}), + generatedAt: row.generatedAt, + }; +} + +function toRepoLabelRecord(row: typeof repoLabels.$inferSelect): RepoLabelRecord { + return { + repoFullName: row.repoFullName, + name: row.name, + color: row.color, + description: row.description, + isConfigured: row.isConfigured, + observedCount: row.observedCount, + payload: parseJson>(row.payloadJson, {}), + lastSeenAt: row.lastSeenAt, + }; +} + function toPullRequestRecord(repoFullName: string, pr: GitHubPullRequestPayload): PullRequestRecord { return { repoFullName, @@ -487,6 +1638,19 @@ function toIssueRecord(repoFullName: string, issue: GitHubIssuePayload): IssueRe }; } +function compactGitHubPayload(payload: { body?: string | null; created_at?: string | null; updated_at?: string | null }): Record { + return { + body: truncateBody(payload.body), + created_at: payload.created_at ?? null, + updated_at: payload.updated_at ?? null, + }; +} + +function truncateBody(body: string | null | undefined): string | null { + if (!body) return body ?? null; + return body.length > MAX_STORED_BODY_CHARS ? body.slice(0, MAX_STORED_BODY_CHARS) : body; +} + function toIssueRecordFromRow(row: typeof issues.$inferSelect): IssueRecord { const payload = parseJson<{ body?: string | null; created_at?: string | null; updated_at?: string | null }>(row.payloadJson, {}); return { @@ -505,6 +1669,117 @@ function toIssueRecordFromRow(row: typeof issues.$inferSelect): IssueRecord { }; } +function toPullRequestFileRecord(row: typeof pullRequestFiles.$inferSelect): PullRequestFileRecord { + return { + repoFullName: row.repoFullName, + pullNumber: row.pullNumber, + path: row.path, + status: row.status, + additions: row.additions, + deletions: row.deletions, + changes: row.changes, + previousFilename: row.previousFilename, + payload: parseJson>(row.payloadJson, {}), + }; +} + +function toPullRequestReviewRecord(row: typeof pullRequestReviews.$inferSelect): PullRequestReviewRecord { + return { + id: row.id, + repoFullName: row.repoFullName, + pullNumber: row.pullNumber, + reviewerLogin: row.reviewerLogin, + state: row.state, + authorAssociation: row.authorAssociation, + submittedAt: row.submittedAt, + payload: parseJson>(row.payloadJson, {}), + }; +} + +function toCheckSummaryRecord(row: typeof checkSummaries.$inferSelect): CheckSummaryRecord { + return { + id: row.id, + repoFullName: row.repoFullName, + pullNumber: row.pullNumber, + headSha: row.headSha, + name: row.name, + status: row.status, + conclusion: row.conclusion, + startedAt: row.startedAt, + completedAt: row.completedAt, + detailsUrl: row.detailsUrl, + payload: parseJson>(row.payloadJson, {}), + }; +} + +function toRecentMergedPullRequestRecord(row: typeof recentMergedPullRequests.$inferSelect): RecentMergedPullRequestRecord { + return { + repoFullName: row.repoFullName, + number: row.number, + title: row.title, + authorLogin: row.authorLogin, + htmlUrl: row.htmlUrl, + mergedAt: row.mergedAt, + labels: parseJson(row.labelsJson, []), + linkedIssues: parseJson(row.linkedIssuesJson, []), + changedFiles: parseJson(row.changedFilesJson, []), + payload: parseJson>(row.payloadJson, {}), + }; +} + +function toContributorRepoStatRecord(row: typeof contributorRepoStats.$inferSelect): ContributorRepoStatRecord { + return { + login: row.login, + repoFullName: row.repoFullName, + pullRequests: row.pullRequests, + mergedPullRequests: row.mergedPullRequests, + openPullRequests: row.openPullRequests, + issues: row.issues, + stalePullRequests: row.stalePullRequests, + unlinkedPullRequests: row.unlinkedPullRequests, + dominantLabels: parseJson(row.dominantLabelsJson, []), + lastActivityAt: row.lastActivityAt, + }; +} + +function mergeContributorRepoStats(stats: ContributorRepoStatRecord[]): ContributorRepoStatRecord[] { + const byRepo = new Map(); + for (const stat of stats) { + const key = stat.repoFullName.toLowerCase(); + const existing = byRepo.get(key); + if (!existing) { + byRepo.set(key, stat); + continue; + } + byRepo.set(key, { + login: stat.login, + repoFullName: stat.repoFullName, + pullRequests: Math.max(existing.pullRequests, stat.pullRequests), + mergedPullRequests: Math.max(existing.mergedPullRequests, stat.mergedPullRequests), + openPullRequests: Math.max(existing.openPullRequests, stat.openPullRequests), + issues: Math.max(existing.issues, stat.issues), + stalePullRequests: Math.max(existing.stalePullRequests, stat.stalePullRequests), + unlinkedPullRequests: Math.max(existing.unlinkedPullRequests, stat.unlinkedPullRequests), + dominantLabels: topStringItems([...existing.dominantLabels, ...stat.dominantLabels], 8), + lastActivityAt: latestIso([existing.lastActivityAt, stat.lastActivityAt]), + }); + } + return [...byRepo.values()].sort((left, right) => left.repoFullName.localeCompare(right.repoFullName)); +} + +function topStringItems(values: string[], limit: number): string[] { + const counts = new Map(); + for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1); + return [...counts.entries()] + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .slice(0, limit) + .map(([value]) => value); +} + +function latestIso(values: Array): string | null | undefined { + return values.filter(Boolean).sort().at(-1); +} + function toBountyRecord(row: typeof bounties.$inferSelect): BountyRecord { return { id: row.id, @@ -519,11 +1794,177 @@ function toBountyRecord(row: typeof bounties.$inferSelect): BountyRecord { }; } +function toCollisionEdgeRecord(row: typeof collisionEdges.$inferSelect): CollisionEdgeRecord { + return { + id: row.id, + repoFullName: row.repoFullName, + leftType: parseCollisionItemType(row.leftType), + leftNumber: row.leftNumber, + leftTitle: row.leftTitle, + rightType: parseCollisionItemType(row.rightType), + rightNumber: row.rightNumber, + rightTitle: row.rightTitle, + risk: parseCollisionRisk(row.risk), + reason: row.reason, + sharedTerms: parseJson(row.sharedTermsJson, []), + generatedAt: row.generatedAt, + }; +} + +function toSignalSnapshotRecord(row: typeof signalSnapshots.$inferSelect): SignalSnapshotRecord { + return { + id: row.id, + signalType: row.signalType, + targetKey: row.targetKey, + repoFullName: row.repoFullName, + payload: parseJson>(row.payloadJson, {}), + generatedAt: row.generatedAt, + }; +} + +function toInstallationHealthRecord(row: typeof installationHealth.$inferSelect): InstallationHealthRecord { + return { + installationId: row.installationId, + accountLogin: row.accountLogin, + repositorySelection: row.repositorySelection, + installedReposCount: row.installedReposCount, + registeredInstalledCount: row.registeredInstalledCount, + status: parseInstallationHealthStatus(row.status), + missingPermissions: parseJson(row.missingPermissionsJson, []), + missingEvents: parseJson(row.missingEventsJson, []), + permissions: parseJson>(row.permissionsJson, {}), + events: parseJson(row.eventsJson, []), + checkedAt: row.checkedAt, + errorSummary: row.errorSummary, + }; +} + +function toAuthSessionRecord(row: typeof authSessions.$inferSelect): AuthSessionRecord { + return { + id: row.id, + tokenHash: row.tokenHash, + login: row.login, + githubUserId: row.githubUserId, + scopes: parseJson(row.scopesJson, []), + expiresAt: row.expiresAt, + revokedAt: row.revokedAt, + createdAt: row.createdAt, + lastSeenAt: row.lastSeenAt, + metadata: parseJson>(row.metadataJson, {}), + }; +} + function parseCommentMode(value: string): RepositorySettings["commentMode"] { if (value === "detected_contributors_only" || value === "all_prs") return value; return "off"; } +function parseCheckRunDetailLevel(value: string): RepositorySettings["checkRunDetailLevel"] { + if (value === "minimal" || value === "deep") return value; + return "standard"; +} + +function parseSyncStatus(value: string): RepoSyncStateRecord["status"] { + if ( + value === "running" || + value === "success" || + value === "partial" || + value === "error" || + value === "skipped" || + value === "capped" || + value === "rate_limited" || + value === "stale" + ) { + return value; + } + return "never_synced"; +} + +function parseSyncSourceKind(value: string): RepoSyncStateRecord["sourceKind"] { + if (value === "installation" || value === "test") return value; + return "github"; +} + +function parseRepoSyncSegment(value: string): RepoSyncSegmentRecord["segment"] { + if ( + value === "metadata" || + value === "labels" || + value === "open_issues" || + value === "open_pull_requests" || + value === "recent_merged_pull_requests" || + value === "pull_request_files" || + value === "pull_request_reviews" || + value === "check_summaries" + ) { + return value; + } + return "metadata"; +} + +function parseRepoSyncSegmentStatus(value: string): RepoSyncSegmentRecord["status"] { + if ( + value === "running" || + value === "refreshing" || + value === "complete" || + value === "partial" || + value === "capped" || + value === "sampled" || + value === "stale" || + value === "rate_limited" || + value === "waiting_rate_limit" || + value === "error" || + value === "skipped" || + value === "not_modified" + ) { + return value; + } + return "never_synced"; +} + +function parseBackfillMode(value: string): RepoSyncSegmentRecord["mode"] { + if (value === "full" || value === "resume") return value; + return "light"; +} + +function parseCollisionItemType(value: string): CollisionEdgeRecord["leftType"] { + if (value === "pull_request" || value === "recent_merged_pull_request") return value; + return "issue"; +} + +function parseCollisionRisk(value: string): CollisionEdgeRecord["risk"] { + if (value === "high" || value === "medium") return value; + return "low"; +} + +function parseInstallationHealthStatus(value: string): InstallationHealthRecord["status"] { + if (value === "healthy" || value === "broken") return value; + return "needs_attention"; +} + +function parseScoringSourceKind(value: string): ScoringModelSnapshotRecord["sourceKind"] { + if (value === "raw-github" || value === "api" || value === "test") return value; + return "fallback"; +} + +function parseActiveScoringModel(value: string): ScoringModelSnapshotRecord["activeModel"] { + if (value === "current_density_model" || value === "pending_saturation_model") return value; + return "unknown"; +} + +function parseScorePreviewTargetType(value: string): ScorePreviewRecord["targetType"] { + if (value === "pull_request" || value === "local_diff" || value === "variant") return value; + return "planned_pr"; +} + +function parsePullRequestDetailSyncStatus(value: string): PullRequestDetailSyncStateRecord["status"] { + if (value === "running" || value === "complete" || value === "partial" || value === "waiting_rate_limit" || value === "error") return value; + return "never_synced"; +} + +function loginMatches(column: unknown, login: string) { + return sql`lower(${column}) = ${login.toLowerCase()}`; +} + export function extractLinkedIssueNumbers(text: string): number[] { const matches = [...text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/gi)]; return [...new Set(matches.map((match) => Number(match[1])).filter((value) => Number.isInteger(value) && value > 0))]; diff --git a/src/db/schema.ts b/src/db/schema.ts index b8aafec3a9..6a7d614f8e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { integer, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { index, integer, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; export const installations = sqliteTable("installations", { id: integer("id").primaryKey(), @@ -38,10 +38,156 @@ export const repositorySettings = sqliteTable("repository_settings", { commentMode: text("comment_mode").notNull().default("off"), publicSignalLevel: text("public_signal_level").notNull().default("standard"), checkRunMode: text("check_run_mode").notNull().default("enabled"), + checkRunDetailLevel: text("check_run_detail_level").notNull().default("standard"), + backfillEnabled: integer("backfill_enabled", { mode: "boolean" }).notNull().default(true), + privateTrustEnabled: integer("private_trust_enabled", { mode: "boolean" }).notNull().default(true), createdAt: text("created_at").notNull().default("CURRENT_TIMESTAMP"), updatedAt: text("updated_at").notNull().default("CURRENT_TIMESTAMP"), }); +export const repoSyncState = sqliteTable("repo_sync_state", { + repoFullName: text("repo_full_name").primaryKey(), + status: text("status").notNull().default("never_synced"), + sourceKind: text("source_kind").notNull().default("github"), + primaryLanguage: text("primary_language"), + defaultBranch: text("default_branch"), + isPrivate: integer("is_private", { mode: "boolean" }), + openIssuesCount: integer("open_issues_count").notNull().default(0), + openPullRequestsCount: integer("open_pull_requests_count").notNull().default(0), + recentMergedPullRequestsCount: integer("recent_merged_pull_requests_count").notNull().default(0), + labelsSyncedAt: text("labels_synced_at"), + issuesSyncedAt: text("issues_synced_at"), + pullRequestsSyncedAt: text("pull_requests_synced_at"), + mergedPullRequestsSyncedAt: text("merged_pull_requests_synced_at"), + lastStartedAt: text("last_started_at"), + lastCompletedAt: text("last_completed_at"), + errorSummary: text("error_summary"), + warningsJson: text("warnings_json").notNull().default("[]"), + updatedAt: text("updated_at").notNull().default("CURRENT_TIMESTAMP"), +}); + +export const repoSyncSegments = sqliteTable( + "repo_sync_segments", + { + id: text("id").primaryKey(), + repoFullName: text("repo_full_name").notNull(), + segment: text("segment").notNull(), + status: text("status").notNull().default("never_synced"), + sourceKind: text("source_kind").notNull().default("github"), + mode: text("mode").notNull().default("light"), + lastCursor: text("last_cursor"), + nextCursor: text("next_cursor"), + fetchedCount: integer("fetched_count").notNull().default(0), + expectedCount: integer("expected_count"), + pageCount: integer("page_count").notNull().default(0), + startedAt: text("started_at"), + completedAt: text("completed_at"), + staleAt: text("stale_at"), + rateLimitResetAt: text("rate_limit_reset_at"), + etag: text("etag"), + lastModified: text("last_modified"), + warningsJson: text("warnings_json").notNull().default("[]"), + errorSummary: text("error_summary"), + updatedAt: text("updated_at").notNull().default("CURRENT_TIMESTAMP"), + }, + (table) => ({ + repoSegment: uniqueIndex("repo_sync_segments_repo_segment_unique").on(table.repoFullName, table.segment), + repoStatus: index("repo_sync_segments_repo_status_idx").on(table.repoFullName, table.status), + }), +); + +export const githubRateLimitObservations = sqliteTable( + "github_rate_limit_observations", + { + id: text("id").primaryKey(), + repoFullName: text("repo_full_name"), + resource: text("resource").notNull().default("rest"), + path: text("path").notNull(), + statusCode: integer("status_code").notNull(), + limitValue: integer("limit_value"), + remaining: integer("remaining"), + resetAt: text("reset_at"), + observedAt: text("observed_at").notNull().default("CURRENT_TIMESTAMP"), + }, + (table) => ({ + repoObserved: index("github_rate_limit_observations_repo_observed_idx").on(table.repoFullName, table.observedAt), + reset: index("github_rate_limit_observations_reset_idx").on(table.resetAt), + }), +); + +export const repoGithubTotalsSnapshots = sqliteTable( + "repo_github_totals_snapshots", + { + id: text("id").primaryKey(), + repoFullName: text("repo_full_name").notNull(), + openIssuesTotal: integer("open_issues_total").notNull().default(0), + openPullRequestsTotal: integer("open_pull_requests_total").notNull().default(0), + mergedPullRequestsTotal: integer("merged_pull_requests_total").notNull().default(0), + closedUnmergedPullRequestsTotal: integer("closed_unmerged_pull_requests_total").notNull().default(0), + labelsTotal: integer("labels_total").notNull().default(0), + sourceKind: text("source_kind").notNull().default("github"), + fetchedAt: text("fetched_at").notNull(), + rateLimitRemaining: integer("rate_limit_remaining"), + rateLimitResetAt: text("rate_limit_reset_at"), + payloadJson: text("payload_json").notNull().default("{}"), + }, + (table) => ({ + repoFetched: index("repo_github_totals_repo_fetched_idx").on(table.repoFullName, table.fetchedAt), + }), +); + +export const pullRequestDetailSyncState = sqliteTable( + "pull_request_detail_sync_state", + { + id: text("id").primaryKey(), + repoFullName: text("repo_full_name").notNull(), + pullNumber: integer("pull_number").notNull(), + status: text("status").notNull().default("never_synced"), + filesSyncedAt: text("files_synced_at"), + reviewsSyncedAt: text("reviews_synced_at"), + checksSyncedAt: text("checks_synced_at"), + lastSyncedAt: text("last_synced_at"), + errorSummary: text("error_summary"), + updatedAt: text("updated_at").notNull().default("CURRENT_TIMESTAMP"), + }, + (table) => ({ + repoPull: uniqueIndex("pull_request_detail_sync_repo_pull_unique").on(table.repoFullName, table.pullNumber), + repoStatus: index("pull_request_detail_sync_repo_status_idx").on(table.repoFullName, table.status), + }), +); + +export const repoLabels = sqliteTable( + "repo_labels", + { + id: text("id").primaryKey(), + repoFullName: text("repo_full_name").notNull(), + name: text("name").notNull(), + color: text("color"), + description: text("description"), + isConfigured: integer("is_configured", { mode: "boolean" }).notNull().default(false), + observedCount: integer("observed_count").notNull().default(0), + payloadJson: text("payload_json").notNull().default("{}"), + lastSeenAt: text("last_seen_at").notNull().default("CURRENT_TIMESTAMP"), + }, + (table) => ({ + repoLabel: uniqueIndex("repo_labels_repo_name_unique").on(table.repoFullName, table.name), + }), +); + +export const repoSnapshots = sqliteTable("repo_snapshots", { + id: text("id").primaryKey(), + repoFullName: text("repo_full_name").notNull(), + snapshotKind: text("snapshot_kind").notNull(), + sourceKind: text("source_kind").notNull().default("github"), + fetchedAt: text("fetched_at").notNull(), + primaryLanguage: text("primary_language"), + defaultBranch: text("default_branch"), + openIssuesCount: integer("open_issues_count").notNull().default(0), + openPullRequestsCount: integer("open_pull_requests_count").notNull().default(0), + recentMergedPullRequestsCount: integer("recent_merged_pull_requests_count").notNull().default(0), + payloadJson: text("payload_json").notNull().default("{}"), +}); + export const registrySnapshots = sqliteTable("registry_snapshots", { id: text("id").primaryKey(), sourceKind: text("source_kind").notNull(), @@ -71,6 +217,7 @@ export const pullRequests = sqliteTable( htmlUrl: text("html_url"), labelsJson: text("labels_json").notNull().default("[]"), linkedIssuesJson: text("linked_issues_json").notNull().default("[]"), + lastSeenOpenAt: text("last_seen_open_at"), payloadJson: text("payload_json").notNull().default("{}"), createdAt: text("created_at").notNull().default("CURRENT_TIMESTAMP"), updatedAt: text("updated_at").notNull().default("CURRENT_TIMESTAMP"), @@ -80,6 +227,80 @@ export const pullRequests = sqliteTable( }), ); +export const pullRequestFiles = sqliteTable( + "pull_request_files", + { + id: text("id").primaryKey(), + repoFullName: text("repo_full_name").notNull(), + pullNumber: integer("pull_number").notNull(), + path: text("path").notNull(), + status: text("status"), + additions: integer("additions").notNull().default(0), + deletions: integer("deletions").notNull().default(0), + changes: integer("changes").notNull().default(0), + previousFilename: text("previous_filename"), + payloadJson: text("payload_json").notNull().default("{}"), + updatedAt: text("updated_at").notNull().default("CURRENT_TIMESTAMP"), + }, + (table) => ({ + repoPullPath: uniqueIndex("pull_request_files_repo_pull_path_unique").on(table.repoFullName, table.pullNumber, table.path), + }), +); + +export const pullRequestReviews = sqliteTable("pull_request_reviews", { + id: text("id").primaryKey(), + repoFullName: text("repo_full_name").notNull(), + pullNumber: integer("pull_number").notNull(), + reviewerLogin: text("reviewer_login"), + state: text("state").notNull(), + authorAssociation: text("author_association"), + submittedAt: text("submitted_at"), + payloadJson: text("payload_json").notNull().default("{}"), + updatedAt: text("updated_at").notNull().default("CURRENT_TIMESTAMP"), +}); + +export const checkSummaries = sqliteTable( + "check_summaries", + { + id: text("id").primaryKey(), + repoFullName: text("repo_full_name").notNull(), + pullNumber: integer("pull_number"), + headSha: text("head_sha"), + name: text("name").notNull(), + status: text("status").notNull(), + conclusion: text("conclusion"), + startedAt: text("started_at"), + completedAt: text("completed_at"), + detailsUrl: text("details_url"), + payloadJson: text("payload_json").notNull().default("{}"), + updatedAt: text("updated_at").notNull().default("CURRENT_TIMESTAMP"), + }, + (table) => ({ + repoShaName: uniqueIndex("check_summaries_repo_sha_name_unique").on(table.repoFullName, table.headSha, table.name), + }), +); + +export const recentMergedPullRequests = sqliteTable( + "recent_merged_pull_requests", + { + id: text("id").primaryKey(), + repoFullName: text("repo_full_name").notNull(), + number: integer("number").notNull(), + title: text("title").notNull(), + authorLogin: text("author_login"), + htmlUrl: text("html_url"), + mergedAt: text("merged_at"), + labelsJson: text("labels_json").notNull().default("[]"), + linkedIssuesJson: text("linked_issues_json").notNull().default("[]"), + changedFilesJson: text("changed_files_json").notNull().default("[]"), + payloadJson: text("payload_json").notNull().default("{}"), + updatedAt: text("updated_at").notNull().default("CURRENT_TIMESTAMP"), + }, + (table) => ({ + repoNumber: uniqueIndex("recent_merged_pull_requests_repo_number_unique").on(table.repoFullName, table.number), + }), +); + export const issues = sqliteTable( "issues", { @@ -93,6 +314,7 @@ export const issues = sqliteTable( htmlUrl: text("html_url"), labelsJson: text("labels_json").notNull().default("[]"), linkedPrsJson: text("linked_prs_json").notNull().default("[]"), + lastSeenOpenAt: text("last_seen_open_at"), payloadJson: text("payload_json").notNull().default("{}"), createdAt: text("created_at").notNull().default("CURRENT_TIMESTAMP"), updatedAt: text("updated_at").notNull().default("CURRENT_TIMESTAMP"), @@ -120,6 +342,78 @@ export const bounties = sqliteTable( }), ); +export const contributors = sqliteTable("contributors", { + login: text("login").primaryKey(), + githubProfileJson: text("github_profile_json").notNull().default("{}"), + topLanguagesJson: text("top_languages_json").notNull().default("[]"), + publicRepos: integer("public_repos"), + followers: integer("followers"), + source: text("source").notNull().default("github"), + firstSeenAt: text("first_seen_at").notNull().default("CURRENT_TIMESTAMP"), + lastSeenAt: text("last_seen_at").notNull().default("CURRENT_TIMESTAMP"), + updatedAt: text("updated_at").notNull().default("CURRENT_TIMESTAMP"), +}); + +export const contributorRepoStats = sqliteTable( + "contributor_repo_stats", + { + id: text("id").primaryKey(), + login: text("login").notNull(), + repoFullName: text("repo_full_name").notNull(), + pullRequests: integer("pull_requests").notNull().default(0), + mergedPullRequests: integer("merged_pull_requests").notNull().default(0), + openPullRequests: integer("open_pull_requests").notNull().default(0), + issues: integer("issues").notNull().default(0), + stalePullRequests: integer("stale_pull_requests").notNull().default(0), + unlinkedPullRequests: integer("unlinked_pull_requests").notNull().default(0), + dominantLabelsJson: text("dominant_labels_json").notNull().default("[]"), + lastActivityAt: text("last_activity_at"), + updatedAt: text("updated_at").notNull().default("CURRENT_TIMESTAMP"), + }, + (table) => ({ + loginRepo: uniqueIndex("contributor_repo_stats_login_repo_unique").on(table.login, table.repoFullName), + }), +); + +export const collisionEdges = sqliteTable("collision_edges", { + id: text("id").primaryKey(), + repoFullName: text("repo_full_name").notNull(), + leftType: text("left_type").notNull(), + leftNumber: integer("left_number").notNull(), + leftTitle: text("left_title").notNull(), + rightType: text("right_type").notNull(), + rightNumber: integer("right_number").notNull(), + rightTitle: text("right_title").notNull(), + risk: text("risk").notNull(), + reason: text("reason").notNull(), + sharedTermsJson: text("shared_terms_json").notNull().default("[]"), + generatedAt: text("generated_at").notNull().default("CURRENT_TIMESTAMP"), +}); + +export const signalSnapshots = sqliteTable("signal_snapshots", { + id: text("id").primaryKey(), + signalType: text("signal_type").notNull(), + targetKey: text("target_key").notNull(), + repoFullName: text("repo_full_name"), + payloadJson: text("payload_json").notNull().default("{}"), + generatedAt: text("generated_at").notNull().default("CURRENT_TIMESTAMP"), +}); + +export const installationHealth = sqliteTable("installation_health", { + installationId: integer("installation_id").primaryKey(), + accountLogin: text("account_login").notNull(), + repositorySelection: text("repository_selection"), + installedReposCount: integer("installed_repos_count").notNull().default(0), + registeredInstalledCount: integer("registered_installed_count").notNull().default(0), + status: text("status").notNull(), + missingPermissionsJson: text("missing_permissions_json").notNull().default("[]"), + missingEventsJson: text("missing_events_json").notNull().default("[]"), + permissionsJson: text("permissions_json").notNull().default("{}"), + eventsJson: text("events_json").notNull().default("[]"), + checkedAt: text("checked_at").notNull(), + errorSummary: text("error_summary"), +}); + export const advisories = sqliteTable("advisories", { id: text("id").primaryKey(), targetType: text("target_type").notNull(), @@ -163,3 +457,124 @@ export const syncRuns = sqliteTable("sync_runs", { startedAt: text("started_at").notNull().default("CURRENT_TIMESTAMP"), completedAt: text("completed_at"), }); + +export const scoringModelSnapshots = sqliteTable("scoring_model_snapshots", { + id: text("id").primaryKey(), + sourceKind: text("source_kind").notNull(), + sourceUrl: text("source_url").notNull(), + fetchedAt: text("fetched_at").notNull(), + activeModel: text("active_model").notNull(), + constantsJson: text("constants_json").notNull().default("{}"), + programmingLanguagesJson: text("programming_languages_json").notNull().default("{}"), + registrySnapshotId: text("registry_snapshot_id"), + warningsJson: text("warnings_json").notNull().default("[]"), + payloadJson: text("payload_json").notNull().default("{}"), +}); + +export const scorePreviews = sqliteTable("score_previews", { + id: text("id").primaryKey(), + scoringModelSnapshotId: text("scoring_model_snapshot_id").notNull(), + repoFullName: text("repo_full_name").notNull(), + targetType: text("target_type").notNull(), + targetKey: text("target_key").notNull(), + contributorLogin: text("contributor_login"), + inputJson: text("input_json").notNull().default("{}"), + resultJson: text("result_json").notNull().default("{}"), + generatedAt: text("generated_at").notNull().default("CURRENT_TIMESTAMP"), +}); + +export const contributorEvidence = sqliteTable("contributor_evidence", { + login: text("login").primaryKey(), + payloadJson: text("payload_json").notNull().default("{}"), + generatedAt: text("generated_at").notNull().default("CURRENT_TIMESTAMP"), +}); + +export const contributorScoringProfiles = sqliteTable("contributor_scoring_profiles", { + login: text("login").primaryKey(), + scoringModelSnapshotId: text("scoring_model_snapshot_id").notNull(), + payloadJson: text("payload_json").notNull().default("{}"), + generatedAt: text("generated_at").notNull().default("CURRENT_TIMESTAMP"), +}); + +export const issueQualityReports = sqliteTable( + "issue_quality_reports", + { + id: text("id").primaryKey(), + repoFullName: text("repo_full_name").notNull(), + issueNumber: integer("issue_number").notNull(), + payloadJson: text("payload_json").notNull().default("{}"), + generatedAt: text("generated_at").notNull().default("CURRENT_TIMESTAMP"), + }, + (table) => ({ + repoIssue: uniqueIndex("issue_quality_reports_repo_issue_unique").on(table.repoFullName, table.issueNumber), + }), +); + +export const burdenForecasts = sqliteTable("burden_forecasts", { + repoFullName: text("repo_full_name").primaryKey(), + payloadJson: text("payload_json").notNull().default("{}"), + generatedAt: text("generated_at").notNull().default("CURRENT_TIMESTAMP"), +}); + +export const registryDriftEvents = sqliteTable("registry_drift_events", { + id: text("id").primaryKey(), + repoFullName: text("repo_full_name").notNull(), + driftType: text("drift_type").notNull(), + detail: text("detail").notNull(), + previousSnapshotId: text("previous_snapshot_id"), + currentSnapshotId: text("current_snapshot_id"), + payloadJson: text("payload_json").notNull().default("{}"), + generatedAt: text("generated_at").notNull().default("CURRENT_TIMESTAMP"), +}); + +export const bountyLifecycleEvents = sqliteTable("bounty_lifecycle_events", { + id: text("id").primaryKey(), + bountyId: text("bounty_id").notNull(), + repoFullName: text("repo_full_name").notNull(), + issueNumber: integer("issue_number").notNull(), + status: text("status").notNull(), + payloadJson: text("payload_json").notNull().default("{}"), + generatedAt: text("generated_at").notNull().default("CURRENT_TIMESTAMP"), +}); + +export const authSessions = sqliteTable( + "auth_sessions", + { + id: text("id").primaryKey(), + tokenHash: text("token_hash").notNull(), + login: text("login").notNull(), + githubUserId: integer("github_user_id"), + scopesJson: text("scopes_json").notNull().default("[]"), + expiresAt: text("expires_at").notNull(), + revokedAt: text("revoked_at"), + createdAt: text("created_at").notNull().default("CURRENT_TIMESTAMP"), + lastSeenAt: text("last_seen_at"), + metadataJson: text("metadata_json").notNull().default("{}"), + }, + (table) => ({ + tokenHash: uniqueIndex("auth_sessions_token_hash_unique").on(table.tokenHash), + login: index("auth_sessions_login_idx").on(table.login), + expires: index("auth_sessions_expires_idx").on(table.expiresAt), + revoked: index("auth_sessions_revoked_idx").on(table.revokedAt), + }), +); + +export const auditEvents = sqliteTable( + "audit_events", + { + id: text("id").primaryKey(), + eventType: text("event_type").notNull(), + actor: text("actor"), + route: text("route"), + targetKey: text("target_key"), + outcome: text("outcome").notNull(), + detail: text("detail"), + metadataJson: text("metadata_json").notNull().default("{}"), + createdAt: text("created_at").notNull().default("CURRENT_TIMESTAMP"), + }, + (table) => ({ + typeCreated: index("audit_events_type_created_idx").on(table.eventType, table.createdAt), + actorCreated: index("audit_events_actor_created_idx").on(table.actor, table.createdAt), + routeCreated: index("audit_events_route_created_idx").on(table.route, table.createdAt), + }), +); diff --git a/src/env.d.ts b/src/env.d.ts index 6a21c50b08..f7804cde42 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,7 +1,16 @@ declare global { interface Env { + DB: D1Database; + JOBS: Queue; + RATE_LIMITER?: DurableObjectNamespace; + PUBLIC_API_ORIGIN?: string; GITHUB_WEBHOOK_SECRET: string; GITHUB_APP_PRIVATE_KEY: string; + GITHUB_APP_ID: string; + GITHUB_APP_SLUG: string; + GITHUB_OAUTH_CLIENT_ID?: string; + GITTENSOR_REGISTRY_URL: string; + GITHUB_PUBLIC_TOKEN?: string; GITTENSORY_API_TOKEN: string; GITTENSORY_MCP_TOKEN: string; INTERNAL_JOB_TOKEN: string; diff --git a/src/github/backfill.ts b/src/github/backfill.ts new file mode 100644 index 0000000000..df1059f8fb --- /dev/null +++ b/src/github/backfill.ts @@ -0,0 +1,1818 @@ +import { + getRepositorySettings, + getRepository, + countOpenIssues, + countOpenPullRequests, + countRecentMergedPullRequests, + countRepoLabels, + getLatestRepoGithubTotalsSnapshot, + getRepoSyncSegment, + getRepoSyncState, + listLatestGitHubRateLimitObservations, + listOpenIssueNumbers, + listOpenPullRequests, + listInstallations, + listPullRequestDetailSyncStates, + listRepositories, + markUnseenOpenIssuesClosed, + markUnseenOpenPullRequestsClosed, + persistRepoGithubTotalsSnapshot, + recordGitHubRateLimitObservation, + upsertCheckSummary, + upsertContributor, + upsertContributorRepoStat, + upsertInstallationHealth, + upsertIssueFromGitHub, + upsertPullRequestFile, + upsertPullRequestDetailSyncState, + upsertPullRequestFromGitHub, + upsertPullRequestReview, + upsertRecentMergedPullRequest, + upsertRepoLabel, + upsertRepoSyncSegment, + upsertRepoSyncState, + upsertRepositoryFromGitHub, + persistRepoSnapshot, +} from "../db/repositories"; +import type { + ContributorRepoStatRecord, + GitHubRateLimitObservationRecord, + GitHubIssuePayload, + GitHubPullRequestPayload, + GitHubRepositoryPayload, + JsonValue, + PullRequestRecord, + RecentMergedPullRequestRecord, + RepoGithubTotalsSnapshotRecord, + RepoSyncSegmentRecord, + RepoSyncStateRecord, + RepositoryRecord, +} from "../types"; +import { nowIso, repoParts } from "../utils/json"; +import { createInstallationToken } from "./app"; + +type GitHubLabelPayload = { + name: string; + color?: string; + description?: string | null; +}; + +type GitHubFilePayload = { + filename: string; + status?: string; + additions?: number; + deletions?: number; + changes?: number; + previous_filename?: string; +}; + +type GitHubReviewPayload = { + id: number; + user?: { login?: string }; + state?: string; + author_association?: string; + submitted_at?: string | null; +}; + +type GitHubCheckRunPayload = { + id: number; + name: string; + status: string; + conclusion?: string | null; + started_at?: string | null; + completed_at?: string | null; + details_url?: string | null; + html_url?: string | null; +}; + +type BackfillLimits = { + issues: number; + pullRequests: number; + recentMergedPullRequests: number; + pullRequestDetails: number; + repoConcurrency: number; + detailConcurrency: number; +}; + +type BackfillMode = "light" | "full" | "resume"; +type BackfillSegmentName = "labels" | "open_issues" | "open_pull_requests" | "recent_merged_pull_requests"; + +export type BackfillRegisteredReposResult = { + ok: true; + repoCount: number; + repos: RepoBackfillResult[]; +}; + +export type RepoBackfillResult = { + repoFullName: string; + status: "success" | "partial" | "capped" | "rate_limited" | "error" | "skipped"; + openIssues: number; + openPullRequests: number; + recentMergedPullRequests: number; + warnings: string[]; + dataQuality?: { + capped: boolean; + rateLimited: boolean; + partial: boolean; + segmentStatuses: Record; + }; + errorSummary?: string; +}; + +export type RefreshContributorActivityResult = { + ok: true; + login: string; + repoCount: number; + updatedRepoStats: number; + warnings: string[]; +}; + +type GitHubGraphQlSearchNode = { + __typename?: "PullRequest" | "Issue"; + number?: number; + title?: string; + url?: string; + state?: string; + body?: string | null; + updatedAt?: string | null; + mergedAt?: string | null; + labels?: { nodes?: Array<{ name?: string | null } | null> | null } | null; +}; + +type GitHubGraphQlSearchBucket = { + issueCount?: number; + nodes?: Array | null; +}; + +type GitHubGraphQlContributorSearchResponse = { + data?: Record; + errors?: Array<{ message?: string }>; +}; + +type GitHubRepoTotalsResponse = { + data?: { + rateLimit?: { remaining?: number; resetAt?: string }; + repository?: { + issues?: { totalCount?: number }; + openPullRequests?: { totalCount?: number }; + mergedPullRequests?: { totalCount?: number }; + closedPullRequests?: { totalCount?: number }; + labels?: { totalCount?: number }; + } | null; + }; + errors?: Array<{ message?: string }>; +}; + +type GitHubOpenIssuesResponse = { + data?: { + repository?: { + issues?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + nodes?: Array<{ + number?: number; + title?: string; + state?: string; + url?: string; + body?: string | null; + createdAt?: string | null; + updatedAt?: string | null; + authorAssociation?: string | null; + author?: { login?: string | null } | null; + labels?: { nodes?: Array<{ name?: string | null } | null> | null } | null; + } | null>; + }; + } | null; + rateLimit?: { remaining?: number; resetAt?: string }; + }; + errors?: Array<{ message?: string }>; +}; + +const MODE_LIMITS: Record = { + light: { + issues: 100, + pullRequests: 100, + recentMergedPullRequests: 200, + pullRequestDetails: 12, + repoConcurrency: 2, + detailConcurrency: 4, + }, + full: { + issues: 1000, + pullRequests: 1000, + recentMergedPullRequests: 1000, + pullRequestDetails: 50, + repoConcurrency: 2, + detailConcurrency: 4, + }, + resume: { + issues: 1000, + pullRequests: 1000, + recentMergedPullRequests: 1000, + pullRequestDetails: 50, + repoConcurrency: 2, + detailConcurrency: 4, + }, +}; + +const DEFAULT_LIMITS: BackfillLimits = { + issues: 100, + pullRequests: 100, + recentMergedPullRequests: 200, + pullRequestDetails: 12, + repoConcurrency: 2, + detailConcurrency: 4, +}; + +const FRESH_SYNC_MS = 6 * 60 * 60 * 1000; +const ERROR_BACKOFF_MS = 60 * 60 * 1000; +const LOW_REST_RATE_LIMIT_REMAINING = 75; +const SEGMENT_PAGE_BUDGET: Record = { light: 2, full: 10, resume: 10 }; +const PR_DETAIL_BATCH_SIZE: Record = { light: 12, full: 40, resume: 40 }; +const CURRENT_OPEN_SCAN_MARKER = "gittensory-current-open-scan-v1"; + +export async function backfillRegisteredRepositories( + env: Env, + options: { repoFullName?: string; limits?: Partial; requestedBy?: string; force?: boolean; mode?: BackfillMode } = {}, +): Promise { + const repositories = (await listRepositories(env)).filter((repo) => repo.isRegistered && (!options.repoFullName || repo.fullName === options.repoFullName)); + const mode = options.mode ?? "light"; + const limits = { ...DEFAULT_LIMITS, ...MODE_LIMITS[mode], ...(options.limits ?? {}) }; + const repoResults = await mapWithConcurrency(repositories, limits.repoConcurrency, async (repo): Promise => { + const settings = await getRepositorySettings(env, repo.fullName); + if (!settings.backfillEnabled) { + const completedAt = nowIso(); + await upsertSkippedSegments(env, repo, mode, completedAt, ["Backfill is disabled for this repository."]); + return { + repoFullName: repo.fullName, + status: "skipped", + openIssues: 0, + openPullRequests: 0, + recentMergedPullRequests: 0, + warnings: ["Backfill is disabled for this repository."], + }; + } + if (!repo.installationId && !env.GITHUB_PUBLIC_TOKEN) { + const completedAt = nowIso(); + const warnings = ["GITHUB_PUBLIC_TOKEN is not configured; public GitHub backfill was skipped to avoid unauthenticated rate limits."]; + await upsertRepoSyncState(env, { + repoFullName: repo.fullName, + status: "skipped", + sourceKind: "github", + primaryLanguage: undefined, + defaultBranch: repo.defaultBranch, + isPrivate: repo.isPrivate, + openIssuesCount: 0, + openPullRequestsCount: 0, + recentMergedPullRequestsCount: 0, + lastStartedAt: completedAt, + lastCompletedAt: completedAt, + warnings, + }); + await upsertSkippedSegments(env, repo, mode, completedAt, warnings); + return { + repoFullName: repo.fullName, + status: "skipped", + openIssues: 0, + openPullRequests: 0, + recentMergedPullRequests: 0, + warnings, + }; + } + const syncState = await getRepoSyncState(env, repo.fullName); + if (!options.force && syncState?.lastCompletedAt && syncState.status !== "never_synced") { + const ageMs = Date.now() - Date.parse(syncState.lastCompletedAt); + const freshSuccess = + (syncState.status === "success" || syncState.status === "partial" || syncState.status === "capped") && Number.isFinite(ageMs) && ageMs < FRESH_SYNC_MS; + const recentError = syncState.status === "error" && Number.isFinite(ageMs) && ageMs < ERROR_BACKOFF_MS; + if (freshSuccess || recentError) { + return { + repoFullName: repo.fullName, + status: "skipped", + openIssues: syncState.openIssuesCount, + openPullRequests: syncState.openPullRequestsCount, + recentMergedPullRequests: syncState.recentMergedPullRequestsCount, + warnings: [ + freshSuccess + ? `Recent GitHub sync completed at ${syncState.lastCompletedAt}; use force=true for a manual refresh.` + : `Recent GitHub sync error recorded at ${syncState.lastCompletedAt}; backing off unless force=true.`, + ], + ...(recentError && syncState.errorSummary ? { errorSummary: syncState.errorSummary } : {}), + }; + } + } + return backfillRepository(env, repo, limits, mode); + }); + return { ok: true, repoCount: repoResults.length, repos: repoResults.sort((left, right) => left.repoFullName.localeCompare(right.repoFullName)) }; +} + +export async function enqueueRepositoryOpenDataBackfill( + env: Env, + options: { repoFullName: string; requestedBy: "schedule" | "api" | "test"; mode?: BackfillMode; force?: boolean }, +): Promise<{ ok: true; repoFullName: string; status: "queued" | "skipped"; totals?: RepoGithubTotalsSnapshotRecord; warnings: string[] }> { + const repo = await getRepository(env, options.repoFullName); + if (!repo?.isRegistered) return { ok: true, repoFullName: options.repoFullName, status: "skipped", warnings: ["Repository is not registered for Gittensory backfill."] }; + const mode = options.mode ?? "light"; + const settings = await getRepositorySettings(env, repo.fullName); + if (!settings.backfillEnabled) return { ok: true, repoFullName: repo.fullName, status: "skipped", warnings: ["Backfill is disabled for this repository."] }; + const token = await tokenForRepo(env, repo); + const sourceKind: RepoSyncSegmentRecord["sourceKind"] = repo.installationId && token !== env.GITHUB_PUBLIC_TOKEN ? "installation" : "github"; + const totals = token ? await refreshRepoGithubTotals(env, repo, token, sourceKind).catch(() => undefined) : undefined; + const startedAt = nowIso(); + const previous = await getRepoSyncState(env, repo.fullName); + await upsertRepoSyncState(env, { + repoFullName: repo.fullName, + status: "running", + sourceKind, + primaryLanguage: previous?.primaryLanguage, + defaultBranch: previous?.defaultBranch ?? repo.defaultBranch, + isPrivate: previous?.isPrivate ?? repo.isPrivate, + openIssuesCount: previous?.openIssuesCount ?? totals?.openIssuesTotal ?? 0, + openPullRequestsCount: previous?.openPullRequestsCount ?? totals?.openPullRequestsTotal ?? 0, + recentMergedPullRequestsCount: previous?.recentMergedPullRequestsCount ?? 0, + labelsSyncedAt: previous?.labelsSyncedAt, + issuesSyncedAt: previous?.issuesSyncedAt, + pullRequestsSyncedAt: previous?.pullRequestsSyncedAt, + mergedPullRequestsSyncedAt: previous?.mergedPullRequestsSyncedAt, + lastStartedAt: startedAt, + lastCompletedAt: previous?.lastCompletedAt, + warnings: previous?.warnings ?? [], + }); + const segments: BackfillSegmentName[] = ["labels", "open_issues", "open_pull_requests", "recent_merged_pull_requests"]; + await Promise.all( + segments.map((segment, index) => + env.JOBS.send( + { type: "backfill-repo-segment", requestedBy: options.requestedBy, repoFullName: repo.fullName, segment, mode, ...(options.force === undefined ? {} : { force: options.force }) }, + { delaySeconds: index * 15 }, + ), + ), + ); + return { + ok: true, + repoFullName: repo.fullName, + status: "queued", + ...(totals ? { totals } : {}), + warnings: totals ? [] : ["GitHub totals snapshot could not be refreshed before segment queueing."], + }; +} + +export async function backfillRepositorySegment( + env: Env, + options: { repoFullName: string; segment: BackfillSegmentName; requestedBy?: string; mode?: BackfillMode; cursor?: string; force?: boolean }, +): Promise<{ ok: true; repoFullName: string; segment: BackfillSegmentName; status: RepoSyncSegmentRecord["status"]; fetchedCount: number; expectedCount?: number | null; nextCursor?: string | null; warnings: string[] }> { + const repo = await getRepository(env, options.repoFullName); + if (!repo) return { ok: true, repoFullName: options.repoFullName, segment: options.segment, status: "skipped", fetchedCount: 0, warnings: ["Repository was not found."] }; + const mode = options.mode ?? "light"; + const token = await tokenForRepo(env, repo); + const sourceKind: RepoSyncSegmentRecord["sourceKind"] = repo.installationId && token !== env.GITHUB_PUBLIC_TOKEN ? "installation" : "github"; + const resetAt = await shouldWaitForGitHubRateLimit(env); + if (resetAt) { + const previous = await getRepoSyncSegment(env, repo.fullName, options.segment); + const segment = await completeSegment(env, repo, options.segment, sourceKind, mode, nowIso(), { + status: "waiting_rate_limit", + fetchedCount: previous?.fetchedCount ?? 0, + expectedCount: previous?.expectedCount, + pageCount: previous?.pageCount ?? 0, + lastCursor: previous?.lastCursor, + nextCursor: previous?.nextCursor ?? options.cursor, + warnings: [`GitHub REST rate limit is low; retry after ${resetAt}.`], + rateLimitResetAt: resetAt, + errorSummary: `Waiting for GitHub rate limit reset at ${resetAt}.`, + }); + await env.JOBS.send( + { type: "backfill-repo-segment", requestedBy: options.requestedBy === "schedule" || options.requestedBy === "test" ? options.requestedBy : "api", repoFullName: repo.fullName, segment: options.segment, mode, force: true }, + { delaySeconds: delayUntil(resetAt) }, + ); + return segmentJobResult(repo.fullName, options.segment, segment); + } + const totals = (token ? await refreshRepoGithubTotals(env, repo, token, sourceKind).catch(() => undefined) : undefined) ?? (await getLatestRepoGithubTotalsSnapshot(env, repo.fullName)); + const result = + options.segment === "labels" + ? await backfillLabelsSegment(env, repo, token, sourceKind, mode, options.cursor, totals) + : options.segment === "open_issues" + ? await backfillOpenIssuesSegment(env, repo, token, sourceKind, mode, options.cursor, totals) + : options.segment === "open_pull_requests" + ? await backfillOpenPullRequestsSegment(env, repo, token, sourceKind, mode, options.cursor, totals) + : await backfillRecentMergedSegment(env, repo, token, sourceKind, mode, options.cursor, totals); + if ((result.status === "running" || result.status === "waiting_rate_limit") && (options.segment === "labels" || options.segment === "open_issues" || options.segment === "open_pull_requests")) { + const delaySeconds = result.status === "waiting_rate_limit" && result.segment.rateLimitResetAt ? delayUntil(result.segment.rateLimitResetAt) : 20; + await env.JOBS.send( + { type: "backfill-repo-segment", requestedBy: options.requestedBy === "schedule" || options.requestedBy === "test" ? options.requestedBy : "api", repoFullName: repo.fullName, segment: options.segment, mode: "resume", force: true }, + { delaySeconds }, + ); + } + if (options.segment === "open_pull_requests" && result.status === "complete") { + await env.JOBS.send({ type: "backfill-pr-details", requestedBy: "api", repoFullName: repo.fullName, mode: "resume", cursor: 0 }, { delaySeconds: 10 }); + } + await refreshRepoSyncStateFromSegments(env, repo, sourceKind); + return segmentJobResult(repo.fullName, options.segment, result.segment); +} + +export async function backfillOpenPullRequestDetails( + env: Env, + options: { repoFullName: string; mode?: BackfillMode; cursor?: number }, +): Promise<{ ok: true; repoFullName: string; status: RepoSyncSegmentRecord["status"]; processed: number; nextCursor?: number; warnings: string[] }> { + const repo = await getRepository(env, options.repoFullName); + if (!repo) return { ok: true, repoFullName: options.repoFullName, status: "skipped", processed: 0, warnings: ["Repository was not found."] }; + const mode = options.mode ?? "light"; + const token = await tokenForRepo(env, repo); + const sourceKind: RepoSyncSegmentRecord["sourceKind"] = repo.installationId && token !== env.GITHUB_PUBLIC_TOKEN ? "installation" : "github"; + const resetAt = await shouldWaitForGitHubRateLimit(env); + if (resetAt) { + const previous = await getRepoSyncSegment(env, repo.fullName, "pull_request_files"); + await env.JOBS.send({ type: "backfill-pr-details", requestedBy: "api", repoFullName: repo.fullName, mode, cursor: options.cursor ?? 0 }, { delaySeconds: delayUntil(resetAt) }); + await completeSegment(env, repo, "pull_request_files", sourceKind, mode, nowIso(), { + status: "waiting_rate_limit", + fetchedCount: previous?.fetchedCount ?? 0, + expectedCount: previous?.expectedCount, + pageCount: previous?.pageCount ?? 0, + warnings: [`GitHub REST rate limit is low; retry PR detail sync after ${resetAt}.`], + rateLimitResetAt: resetAt, + errorSummary: `Waiting for GitHub rate limit reset at ${resetAt}.`, + }); + return { ok: true, repoFullName: repo.fullName, status: "waiting_rate_limit", processed: 0, warnings: [`GitHub REST rate limit is low; retry after ${resetAt}.`] }; + } + const openPullRequests = (await listOpenPullRequests(env, repo.fullName)).sort((left, right) => left.number - right.number); + const detailStates = await listPullRequestDetailSyncStates(env, repo.fullName); + const detailStateByPull = new Map(detailStates.map((state) => [state.pullNumber, state.status])); + const openPullNumbers = new Set(openPullRequests.map((pr) => pr.number)); + const incompleteOpenPullRequests = openPullRequests.filter((pr) => detailStateByPull.get(pr.number) !== "complete"); + // Incomplete-target lists shrink after every batch, so cursoring over the + // filtered list can skip newly retriable partial rows. Always take the next + // oldest incomplete open PRs. + const cursor = 0; + const batch = incompleteOpenPullRequests.slice(cursor, cursor + PR_DETAIL_BATCH_SIZE[mode]); + const warnings: string[] = []; + await mapWithConcurrency(batch, 2, async (pr) => { + await upsertPullRequestDetailSyncState(env, { repoFullName: repo.fullName, pullNumber: pr.number, status: "running" }); + const before = warnings.length; + await fetchAndStorePullRequestDetails(env, repo.fullName, pr, token, warnings); + const syncedAt = nowIso(); + const newWarnings = warnings.slice(before); + await upsertPullRequestDetailSyncState(env, { + repoFullName: repo.fullName, + pullNumber: pr.number, + status: newWarnings.length > 0 ? "partial" : "complete", + filesSyncedAt: syncedAt, + reviewsSyncedAt: syncedAt, + checksSyncedAt: syncedAt, + lastSyncedAt: syncedAt, + errorSummary: newWarnings.at(-1), + }); + }); + const refreshedDetailStates = await listPullRequestDetailSyncStates(env, repo.fullName); + const completedCount = refreshedDetailStates.filter((state) => openPullNumbers.has(state.pullNumber) && state.status === "complete").length; + const nextCursor = batch.length < incompleteOpenPullRequests.length ? 0 : undefined; + const status: RepoSyncSegmentRecord["status"] = nextCursor !== undefined ? "running" : completedCount >= openPullRequests.length ? "complete" : "partial"; + await Promise.all( + (["pull_request_files", "pull_request_reviews", "check_summaries"] as const).map((segment) => + completeSegment(env, repo, segment, sourceKind, mode, nowIso(), { + status, + fetchedCount: completedCount, + expectedCount: openPullRequests.length, + pageCount: 0, + nextCursor: nextCursor === undefined ? undefined : String(nextCursor), + warnings, + }), + ), + ); + if (nextCursor !== undefined) { + await env.JOBS.send({ type: "backfill-pr-details", requestedBy: "api", repoFullName: repo.fullName, mode: "resume", cursor: nextCursor }, { delaySeconds: 20 }); + } + await refreshRepoSyncStateFromSegments(env, repo, sourceKind); + return { + ok: true, + repoFullName: repo.fullName, + status, + processed: batch.length, + ...(nextCursor === undefined ? {} : { nextCursor }), + warnings, + }; +} + +export async function refreshContributorActivity( + env: Env, + login: string, + options: { repoFullName?: string } = {}, +): Promise { + const warnings: string[] = []; + const token = env.GITHUB_PUBLIC_TOKEN; + if (!token) { + return { + ok: true, + login, + repoCount: 0, + updatedRepoStats: 0, + warnings: ["GITHUB_PUBLIC_TOKEN is not configured; contributor activity refresh was skipped."], + }; + } + + const repositories = (await listRepositories(env)).filter((repo) => repo.isRegistered && (!options.repoFullName || repo.fullName === options.repoFullName)); + let updatedRepoStats = 0; + for (const chunk of chunkArray(repositories, 4)) { + const aliases = buildContributorActivityAliases(login, chunk); + if (aliases.length === 0) continue; + const query = buildContributorActivityQuery(aliases); + let payload: GitHubGraphQlContributorSearchResponse; + try { + payload = await githubGraphQl(env, query, token); + } catch (error) { + warnings.push(`Contributor activity refresh failed for ${chunk.map((repo) => repo.fullName).join(", ")}: ${error instanceof Error ? error.message : "unknown error"}`); + continue; + } + if (payload.errors?.length) { + warnings.push(...payload.errors.flatMap((error) => (error.message ? [error.message] : []))); + } + const data = payload.data ?? {}; + for (const repo of chunk) { + const allPullRequests = data[activityAlias(repo.fullName, "all")]; + const mergedPullRequests = data[activityAlias(repo.fullName, "merged")]; + const openPullRequests = data[activityAlias(repo.fullName, "open")]; + const authoredIssues = data[activityAlias(repo.fullName, "issues")]; + const pullRequestCount = allPullRequests?.issueCount ?? 0; + const mergedPullRequestCount = mergedPullRequests?.issueCount ?? 0; + const openPullRequestCount = openPullRequests?.issueCount ?? 0; + const issueCount = authoredIssues?.issueCount ?? 0; + if (pullRequestCount + issueCount === 0) continue; + + const openNodes = compactNodes(openPullRequests); + const labelNames = [ + ...labelsFromBucket(allPullRequests), + ...labelsFromBucket(mergedPullRequests), + ...labelsFromBucket(openPullRequests), + ...labelsFromBucket(authoredIssues), + ]; + await upsertContributorRepoStat(env, { + login, + repoFullName: repo.fullName, + pullRequests: pullRequestCount, + mergedPullRequests: mergedPullRequestCount, + openPullRequests: openPullRequestCount, + issues: issueCount, + stalePullRequests: openNodes.filter((node) => node.updatedAt && daysSince(node.updatedAt) >= 14).length, + unlinkedPullRequests: openNodes.filter((node) => extractLinkedIssueNumbers(node.body ?? "").length === 0).length, + dominantLabels: topItems(labelNames, 8), + lastActivityAt: latestDate([ + ...compactNodes(allPullRequests).map((node) => node.updatedAt ?? node.mergedAt), + ...compactNodes(mergedPullRequests).map((node) => node.mergedAt ?? node.updatedAt), + ...compactNodes(openPullRequests).map((node) => node.updatedAt), + ...compactNodes(authoredIssues).map((node) => node.updatedAt), + ]), + }); + updatedRepoStats += 1; + } + } + + await upsertContributor(env, { + login, + githubProfile: { login }, + topLanguages: [], + source: "github", + lastSeenAt: nowIso(), + }); + + return { ok: true, login, repoCount: repositories.length, updatedRepoStats, warnings }; +} + +export async function refreshInstallationHealth(env: Env) { + const [installations, repositories] = await Promise.all([listInstallations(env), listRepositories(env)]); + const requiredPermissions: Record = { + checks: "write", + metadata: "read", + pull_requests: "read", + }; + const requiredEvents = ["issues", "pull_request", "repository"]; + const health = []; + for (const installation of installations) { + const installedRepos = repositories.filter((repo) => repo.installationId === installation.id && repo.isInstalled); + const registeredInstalled = installedRepos.filter((repo) => repo.isRegistered); + const missingPermissions = Object.entries(requiredPermissions) + .filter(([permission, expected]) => installation.permissions[permission] !== expected) + .map(([permission]) => permission); + const missingEvents = requiredEvents.filter((event) => !installation.events.includes(event)); + const status = missingPermissions.length > 0 || missingEvents.length > 0 ? "needs_attention" : "healthy"; + const record = { + installationId: installation.id, + accountLogin: installation.accountLogin, + repositorySelection: installation.repositorySelection, + installedReposCount: installedRepos.length, + registeredInstalledCount: registeredInstalled.length, + status, + missingPermissions, + missingEvents, + permissions: installation.permissions, + events: installation.events, + checkedAt: nowIso(), + } as const; + await upsertInstallationHealth(env, record); + health.push(record); + } + return { ok: true, installations: health }; +} + +async function tokenForRepo(env: Env, repo: RepositoryRecord): Promise { + const installationToken = repo.installationId ? await createInstallationToken(env, repo.installationId).catch(() => undefined) : undefined; + return installationToken ?? env.GITHUB_PUBLIC_TOKEN; +} + +async function refreshRepoGithubTotals( + env: Env, + repo: RepositoryRecord, + token: string, + sourceKind: RepoSyncSegmentRecord["sourceKind"], +): Promise { + const { owner, name } = repoParts(repo.fullName); + const query = `query GittensoryRepoTotals { + rateLimit { remaining resetAt } + repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { + issues(states: OPEN) { totalCount } + openPullRequests: pullRequests(states: OPEN) { totalCount } + mergedPullRequests: pullRequests(states: MERGED) { totalCount } + closedPullRequests: pullRequests(states: CLOSED) { totalCount } + labels { totalCount } + } + }`; + const response = await githubGraphQl(env, query, token); + const repository = response.data?.repository; + if (!repository) throw new Error(`GitHub totals query did not return repository data for ${repo.fullName}.`); + const snapshot: RepoGithubTotalsSnapshotRecord = { + id: crypto.randomUUID(), + repoFullName: repo.fullName, + openIssuesTotal: repository.issues?.totalCount ?? 0, + openPullRequestsTotal: repository.openPullRequests?.totalCount ?? 0, + mergedPullRequestsTotal: repository.mergedPullRequests?.totalCount ?? 0, + closedUnmergedPullRequestsTotal: repository.closedPullRequests?.totalCount ?? 0, + labelsTotal: repository.labels?.totalCount ?? 0, + sourceKind, + fetchedAt: nowIso(), + rateLimitRemaining: response.data?.rateLimit?.remaining, + rateLimitResetAt: response.data?.rateLimit?.resetAt, + payload: response as unknown as Record, + }; + await persistRepoGithubTotalsSnapshot(env, snapshot); + return snapshot; +} + +async function backfillLabelsSegment( + env: Env, + repo: RepositoryRecord, + token: string | undefined, + sourceKind: RepoSyncSegmentRecord["sourceKind"], + mode: BackfillMode, + cursor: string | undefined, + totals: RepoGithubTotalsSnapshotRecord | null | undefined, +): Promise<{ status: RepoSyncSegmentRecord["status"]; segment: RepoSyncSegmentRecord }> { + const configuredLabels = new Set(Object.keys(repo.registryConfig?.labelMultipliers ?? {})); + return fetchPagedSegment( + env, + repo, + "labels", + "/labels", + token, + sourceKind, + mode, + cursor, + totals?.labelsTotal, + async (labels) => { + await mapWithConcurrency(labels, 8, async (label) => + upsertRepoLabel(env, { + repoFullName: repo.fullName, + name: label.name, + color: label.color, + description: label.description, + isConfigured: configuredLabels.has(label.name), + observedCount: 0, + payload: label as unknown as Record, + lastSeenAt: nowIso(), + }), + ); + return labels.length; + }, + { countPersisted: () => countRepoLabels(env, repo.fullName) }, + ); +} + +async function backfillOpenIssuesSegment( + env: Env, + repo: RepositoryRecord, + token: string | undefined, + sourceKind: RepoSyncSegmentRecord["sourceKind"], + mode: BackfillMode, + cursor: string | undefined, + totals: RepoGithubTotalsSnapshotRecord | null | undefined, +): Promise<{ status: RepoSyncSegmentRecord["status"]; segment: RepoSyncSegmentRecord }> { + const result = await fetchPagedSegment( + env, + repo, + "open_issues", + "/issues?state=open&sort=created&direction=asc", + token, + sourceKind, + mode, + cursor, + totals?.openIssuesTotal, + async (payloads, scanStartedAt) => { + const issuePayloads = payloads.filter((issue) => !issue.pull_request); + await mapWithConcurrency(issuePayloads, 8, async (issue) => upsertIssueFromGitHub(env, repo.fullName, issue, { seenOpenAt: scanStartedAt })); + return issuePayloads.length; + }, + { + countPersisted: () => countOpenIssues(env, repo.fullName), + reconcileOnComplete: (scanStartedAt) => markUnseenOpenIssuesClosed(env, repo.fullName, scanStartedAt), + ...(token ? { supplementOnUnderCount: (scanStartedAt: string) => supplementOpenIssuesFromGraphQl(env, repo, token, scanStartedAt) } : {}), + }, + ); + return result; +} + +async function backfillOpenPullRequestsSegment( + env: Env, + repo: RepositoryRecord, + token: string | undefined, + sourceKind: RepoSyncSegmentRecord["sourceKind"], + mode: BackfillMode, + cursor: string | undefined, + totals: RepoGithubTotalsSnapshotRecord | null | undefined, +): Promise<{ status: RepoSyncSegmentRecord["status"]; segment: RepoSyncSegmentRecord }> { + return fetchPagedSegment( + env, + repo, + "open_pull_requests", + "/pulls?state=open&sort=created&direction=asc", + token, + sourceKind, + mode, + cursor, + totals?.openPullRequestsTotal, + async (payloads, scanStartedAt) => { + await mapWithConcurrency(payloads, 8, async (pr) => upsertPullRequestFromGitHub(env, repo.fullName, pr, { seenOpenAt: scanStartedAt })); + return payloads.length; + }, + { + countPersisted: () => countOpenPullRequests(env, repo.fullName), + reconcileOnComplete: (scanStartedAt) => markUnseenOpenPullRequestsClosed(env, repo.fullName, scanStartedAt), + }, + ); +} + +async function backfillRecentMergedSegment( + env: Env, + repo: RepositoryRecord, + token: string | undefined, + sourceKind: RepoSyncSegmentRecord["sourceKind"], + mode: BackfillMode, + cursor: string | undefined, + totals: RepoGithubTotalsSnapshotRecord | null | undefined, +): Promise<{ status: RepoSyncSegmentRecord["status"]; segment: RepoSyncSegmentRecord }> { + return fetchPagedSegment( + env, + repo, + "recent_merged_pull_requests", + "/pulls?state=closed&sort=updated&direction=desc", + token, + sourceKind, + mode, + cursor, + totals?.mergedPullRequestsTotal, + async (payloads) => { + const merged = payloads.filter((pr) => Boolean(pr.merged_at)); + await mapWithConcurrency(merged, 8, async (pr) => { + await upsertRecentMergedPullRequest(env, toRecentMergedPullRequest(repo.fullName, pr, [])); + }); + return merged.length; + }, + { progressiveHistory: true, countPersisted: () => countRecentMergedPullRequests(env, repo.fullName) }, + ); +} + +async function fetchPagedSegment( + env: Env, + repo: RepositoryRecord, + segmentName: BackfillSegmentName, + path: string, + token: string | undefined, + sourceKind: RepoSyncSegmentRecord["sourceKind"], + mode: BackfillMode, + cursor: string | undefined, + expectedCount: number | undefined, + persistPage: (payloads: T[], scanStartedAt: string) => Promise, + options: { + progressiveHistory?: boolean; + countPersisted?: () => Promise; + reconcileOnComplete?: (scanStartedAt: string) => Promise; + supplementOnUnderCount?: (scanStartedAt: string) => Promise; + } = {}, +): Promise<{ status: RepoSyncSegmentRecord["status"]; segment: RepoSyncSegmentRecord }> { + const previous = mode === "resume" ? await getRepoSyncSegment(env, repo.fullName, segmentName) : null; + const requiresCurrentOpenScan = Boolean(options.reconcileOnComplete); + const canResumePreviousScan = + mode === "resume" && + (!requiresCurrentOpenScan || previous?.etag === CURRENT_OPEN_SCAN_MARKER) && + Boolean(previous?.startedAt) && + (previous?.status === "running" || previous?.status === "partial" || previous?.status === "waiting_rate_limit"); + const startedAt = canResumePreviousScan ? previous?.startedAt ?? nowIso() : nowIso(); + await markSegmentRunning(env, repo, segmentName, sourceKind, mode, startedAt); + const startPage = + canResumePreviousScan && cursor && Number.isFinite(Number(cursor)) + ? Number(cursor) + : canResumePreviousScan && previous?.nextCursor && Number.isFinite(Number(previous.nextCursor)) + ? Number(previous.nextCursor) + : 1; + const priorFetched = canResumePreviousScan ? (previous?.fetchedCount ?? 0) : 0; + let fetchedThisRun = 0; + let lastCursor: string | undefined; + let nextCursor: string | undefined; + let pageCount = 0; + let hasMore = false; + let rateLimitResetAt: string | undefined; + const warnings: string[] = []; + let status: RepoSyncSegmentRecord["status"] = "complete"; + try { + for (let page = startPage; page < startPage + SEGMENT_PAGE_BUDGET[mode]; page += 1) { + const separator = path.includes("?") ? "&" : "?"; + const pagePath = `${path}${separator}per_page=100&page=${page}`; + const result = await githubJsonWithHeaders(env, repo.fullName, pagePath, token); + lastCursor = String(page); + pageCount += 1; + fetchedThisRun += await persistPage(result.data, startedAt); + hasMore = hasNextPage(result.link); + if (!hasMore) break; + nextCursor = String(page + 1); + } + } catch (error) { + if (error instanceof GitHubApiError && error.rateLimited) { + status = "waiting_rate_limit"; + rateLimitResetAt = error.rateLimitResetAt ?? undefined; + warnings.push(`GitHub sync is waiting for rate-limit recovery for ${path}: ${error.message}`); + } else { + status = fetchedThisRun > 0 ? "partial" : "error"; + warnings.push(`GitHub sync failed for ${path}: ${error instanceof Error ? error.message : "unknown error"}`); + } + } + let fetchedCount = options.countPersisted ? await options.countPersisted() : priorFetched + fetchedThisRun; + if (status === "complete") { + if (hasMore && options.progressiveHistory) { + status = "sampled"; + } else if (hasMore) { + status = "running"; + } else { + fetchedCount = await supplementUnderCountIfNeeded(options, startedAt, fetchedCount, expectedCount, warnings); + if (expectedCount !== undefined && fetchedCount < expectedCount) { + status = "partial"; + warnings.push(`GitHub segment ${segmentName} fetched ${fetchedCount} item(s), below expected total ${expectedCount}.`); + } + } + } + if (status === "complete" && !hasMore && options.reconcileOnComplete) { + const reconciled = await options.reconcileOnComplete(startedAt); + if (reconciled > 0) warnings.push(`Marked ${reconciled} stale open ${segmentName === "open_issues" ? "issue" : "pull request"} row(s) closed after a complete GitHub open-data crawl.`); + fetchedCount = options.countPersisted ? await options.countPersisted() : fetchedCount; + fetchedCount = await supplementUnderCountIfNeeded(options, startedAt, fetchedCount, expectedCount, warnings); + if (expectedCount !== undefined && fetchedCount < expectedCount) { + status = "partial"; + warnings.push(`GitHub segment ${segmentName} fetched ${fetchedCount} item(s), below expected total ${expectedCount}.`); + } + } + const segment = await completeSegment(env, repo, segmentName, sourceKind, mode, startedAt, { + status, + fetchedCount, + expectedCount, + pageCount, + lastCursor, + nextCursor, + etag: requiresCurrentOpenScan ? CURRENT_OPEN_SCAN_MARKER : undefined, + warnings, + errorSummary: status === "error" || status === "waiting_rate_limit" || status === "partial" ? warnings.at(-1) : undefined, + rateLimitResetAt, + }); + return { status, segment }; +} + +async function supplementUnderCountIfNeeded( + options: { + countPersisted?: () => Promise; + supplementOnUnderCount?: (scanStartedAt: string) => Promise; + }, + scanStartedAt: string, + fetchedCount: number, + expectedCount: number | undefined, + warnings: string[], +): Promise { + if (expectedCount === undefined || fetchedCount >= expectedCount || !options.supplementOnUnderCount) return fetchedCount; + try { + const supplemented = await options.supplementOnUnderCount(scanStartedAt); + if (supplemented > 0) warnings.push(`Supplemented ${supplemented} open issue row(s) from GitHub GraphQL because REST open issue pagination undercounted the authoritative total.`); + return options.countPersisted ? await options.countPersisted() : fetchedCount + supplemented; + } catch (error) { + warnings.push(`GitHub GraphQL supplement failed after REST undercount: ${error instanceof Error ? error.message : "unknown error"}`); + return fetchedCount; + } +} + +async function supplementOpenIssuesFromGraphQl(env: Env, repo: RepositoryRecord, token: string, seenOpenAt: string): Promise { + const existingNumbers = new Set(await listOpenIssueNumbers(env, repo.fullName)); + const { owner, name } = repoParts(repo.fullName); + let after = ""; + let supplemented = 0; + for (;;) { + const query = `query GittensoryOpenIssuesSupplement { + repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { + issues(states: OPEN, first: 100${after}) { + pageInfo { hasNextPage endCursor } + nodes { + number + title + state + url + body + createdAt + updatedAt + authorAssociation + author { login } + labels(first: 30) { nodes { name } } + } + } + } + rateLimit { remaining resetAt } + }`; + const response = await githubGraphQl(env, query, token); + const issues = response.data?.repository?.issues; + for (const issue of issues?.nodes ?? []) { + if (!issue?.number || existingNumbers.has(issue.number)) continue; + const payload: GitHubIssuePayload = { + number: issue.number, + title: issue.title ?? `Issue #${issue.number}`, + state: String(issue.state ?? "OPEN").toLowerCase(), + labels: (issue.labels?.nodes ?? []).flatMap((label) => (label?.name ? [{ name: label.name }] : [])), + ...(issue.url ? { html_url: issue.url } : {}), + ...(issue.createdAt === undefined ? {} : { created_at: issue.createdAt }), + ...(issue.updatedAt === undefined ? {} : { updated_at: issue.updatedAt }), + ...(issue.author?.login ? { user: { login: issue.author.login } } : {}), + ...(issue.authorAssociation ? { author_association: issue.authorAssociation } : {}), + ...(issue.body === undefined ? {} : { body: issue.body }), + }; + await upsertIssueFromGitHub(env, repo.fullName, payload, { seenOpenAt }); + existingNumbers.add(issue.number); + supplemented += 1; + } + if (!issues?.pageInfo?.hasNextPage) break; + after = `, after: ${JSON.stringify(issues.pageInfo.endCursor)}`; + } + return supplemented; +} + +async function refreshRepoSyncStateFromSegments(env: Env, repo: RepositoryRecord, sourceKind: RepoSyncSegmentRecord["sourceKind"]): Promise { + const [previous, totals, metadata, labels, openIssues, openPullRequests, recentMerged, files, reviews, checks] = await Promise.all([ + getRepoSyncState(env, repo.fullName), + getLatestRepoGithubTotalsSnapshot(env, repo.fullName), + getRepoSyncSegment(env, repo.fullName, "metadata"), + getRepoSyncSegment(env, repo.fullName, "labels"), + getRepoSyncSegment(env, repo.fullName, "open_issues"), + getRepoSyncSegment(env, repo.fullName, "open_pull_requests"), + getRepoSyncSegment(env, repo.fullName, "recent_merged_pull_requests"), + getRepoSyncSegment(env, repo.fullName, "pull_request_files"), + getRepoSyncSegment(env, repo.fullName, "pull_request_reviews"), + getRepoSyncSegment(env, repo.fullName, "check_summaries"), + ]); + const required = [metadata, labels, openIssues, openPullRequests, files, reviews, checks].filter(Boolean) as RepoSyncSegmentRecord[]; + const waiting = required.some((segment) => segment.status === "waiting_rate_limit" || segment.status === "rate_limited"); + const running = required.some((segment) => segment.status === "running" || segment.status === "refreshing"); + const errored = required.some((segment) => segment.status === "error"); + const incomplete = required.some((segment) => segment.status !== "complete" && segment.status !== "not_modified"); + const status: RepoSyncStateRecord["status"] = waiting ? "rate_limited" : errored ? "error" : running ? "running" : incomplete ? "partial" : "success"; + const warnings = [...new Set(required.flatMap((segment) => segment.warnings))]; + const completedAt = running || waiting ? previous?.lastCompletedAt : nowIso(); + await upsertRepoSyncState(env, { + repoFullName: repo.fullName, + status, + sourceKind, + primaryLanguage: previous?.primaryLanguage, + defaultBranch: previous?.defaultBranch ?? repo.defaultBranch, + isPrivate: previous?.isPrivate ?? repo.isPrivate, + openIssuesCount: openIssues?.fetchedCount ?? previous?.openIssuesCount ?? totals?.openIssuesTotal ?? 0, + openPullRequestsCount: openPullRequests?.fetchedCount ?? previous?.openPullRequestsCount ?? totals?.openPullRequestsTotal ?? 0, + recentMergedPullRequestsCount: recentMerged?.fetchedCount ?? previous?.recentMergedPullRequestsCount ?? 0, + labelsSyncedAt: labels?.status === "complete" ? labels.completedAt : previous?.labelsSyncedAt, + issuesSyncedAt: openIssues?.status === "complete" ? openIssues.completedAt : previous?.issuesSyncedAt, + pullRequestsSyncedAt: openPullRequests?.status === "complete" ? openPullRequests.completedAt : previous?.pullRequestsSyncedAt, + mergedPullRequestsSyncedAt: recentMerged?.status === "complete" || recentMerged?.status === "sampled" ? recentMerged.completedAt : previous?.mergedPullRequestsSyncedAt, + lastStartedAt: previous?.lastStartedAt, + lastCompletedAt: completedAt, + errorSummary: warnings.at(-1), + warnings, + }); +} + +async function shouldWaitForGitHubRateLimit(env: Env): Promise { + const observations = await listLatestGitHubRateLimitObservations(env, 10); + const rest = observations.find((observation) => observation.resource === "rest" && observation.remaining !== null && observation.remaining !== undefined); + if (!rest?.resetAt || rest.remaining === null || rest.remaining === undefined || rest.remaining > LOW_REST_RATE_LIMIT_REMAINING) return undefined; + return Date.parse(rest.resetAt) > Date.now() ? rest.resetAt : undefined; +} + +function segmentJobResult( + repoFullName: string, + segmentName: BackfillSegmentName, + segment: RepoSyncSegmentRecord, +): { ok: true; repoFullName: string; segment: BackfillSegmentName; status: RepoSyncSegmentRecord["status"]; fetchedCount: number; expectedCount?: number | null; nextCursor?: string | null; warnings: string[] } { + return { + ok: true, + repoFullName, + segment: segmentName, + status: segment.status, + fetchedCount: segment.fetchedCount, + ...(segment.expectedCount === undefined ? {} : { expectedCount: segment.expectedCount }), + ...(segment.nextCursor === undefined ? {} : { nextCursor: segment.nextCursor }), + warnings: segment.warnings, + }; +} + +function delayUntil(iso: string): number { + const ms = Date.parse(iso) - Date.now(); + if (!Number.isFinite(ms)) return 60; + return Math.max(30, Math.min(900, Math.ceil(ms / 1000) + 15)); +} + +async function backfillRepository(env: Env, repo: RepositoryRecord, limits: BackfillLimits, mode: BackfillMode): Promise { + const startedAt = nowIso(); + const warnings: string[] = []; + const segmentResults: RepoSyncSegmentRecord[] = []; + await upsertRepoSyncState(env, { + repoFullName: repo.fullName, + status: "running", + sourceKind: repo.installationId ? "installation" : "github", + primaryLanguage: undefined, + defaultBranch: repo.defaultBranch, + isPrivate: repo.isPrivate, + openIssuesCount: 0, + openPullRequestsCount: 0, + recentMergedPullRequestsCount: 0, + lastStartedAt: startedAt, + warnings, + }); + + try { + const installationToken = repo.installationId ? await createInstallationToken(env, repo.installationId).catch(() => undefined) : undefined; + const token = installationToken ?? env.GITHUB_PUBLIC_TOKEN; + const sourceKind = installationToken ? "installation" : "github"; + await markSegmentRunning(env, repo, "metadata", sourceKind, mode, startedAt); + const metadata = await githubJson(env, repo.fullName, "", token); + segmentResults.push( + await completeSegment(env, repo, "metadata", sourceKind, mode, startedAt, { + status: "complete", + fetchedCount: 1, + expectedCount: 1, + warnings: [], + }), + ); + await upsertRepositoryFromGitHub(env, metadata, repo.installationId ?? undefined); + + const [labels, issuePage, pullRequestPage, recentMergedPage] = await Promise.all([ + syncLabels(env, repo, token, sourceKind, mode, warnings), + githubPaged(env, repo, "open_issues", "/issues?state=open&sort=created&direction=asc", limits.issues, token, mode), + githubPaged(env, repo, "open_pull_requests", "/pulls?state=open&sort=created&direction=asc", limits.pullRequests, token, mode), + githubPaged( + env, + repo, + "recent_merged_pull_requests", + "/pulls?state=closed&sort=updated&direction=desc", + limits.recentMergedPullRequests, + token, + mode, + ), + ]); + const labelItems = labels.items; + segmentResults.push(labels.segment, issuePage.segment, pullRequestPage.segment, recentMergedPage.segment); + warnings.push(...labels.warnings, ...issuePage.warnings, ...pullRequestPage.warnings, ...recentMergedPage.warnings); + + const issues = issuePage.items.filter((issue) => !issue.pull_request); + const pullRequests = pullRequestPage.items; + const recentMerged = recentMergedPage.items.filter((pr) => Boolean(pr.merged_at)); + + await mapWithConcurrency(issues, 16, async (issue) => upsertIssueFromGitHub(env, repo.fullName, issue, { seenOpenAt: startedAt })); + const normalizedPullRequests = await mapWithConcurrency(pullRequests, 16, async (pr) => upsertPullRequestFromGitHub(env, repo.fullName, pr, { seenOpenAt: startedAt })); + + const mergedFileWarningStart = warnings.length; + await mapWithConcurrency(recentMerged, limits.detailConcurrency, async (pr) => { + const changedFiles = await fetchPullRequestFiles(env, repo.fullName, pr.number, token, warnings).catch(() => []); + await upsertRecentMergedPullRequest(env, toRecentMergedPullRequest(repo.fullName, pr, changedFiles)); + }); + + const detailTargets = normalizedPullRequests.slice(0, limits.pullRequestDetails); + const detailWarningStart = warnings.length; + await mapWithConcurrency(detailTargets, limits.detailConcurrency, async (pr) => { + await fetchAndStorePullRequestDetails(env, repo.fullName, pr, token, warnings); + }); + const fileWarnings = warnings.slice(mergedFileWarningStart).filter((warning) => /File sync failed/i.test(warning)); + const reviewWarnings = warnings.slice(detailWarningStart).filter((warning) => /Review sync failed/i.test(warning)); + const checkWarnings = warnings.slice(detailWarningStart).filter((warning) => /Check sync failed/i.test(warning)); + segmentResults.push( + await completeSegment(env, repo, "pull_request_files", sourceKind, mode, startedAt, { + status: fileWarnings.length > 0 ? "partial" : "complete", + fetchedCount: recentMerged.length + detailTargets.length, + expectedCount: recentMerged.length + detailTargets.length, + warnings: fileWarnings, + }), + await completeSegment(env, repo, "pull_request_reviews", sourceKind, mode, startedAt, { + status: reviewWarnings.length > 0 ? "partial" : "complete", + fetchedCount: detailTargets.length, + expectedCount: detailTargets.length, + warnings: reviewWarnings, + }), + await completeSegment(env, repo, "check_summaries", sourceKind, mode, startedAt, { + status: checkWarnings.length > 0 ? "partial" : "complete", + fetchedCount: detailTargets.length, + expectedCount: detailTargets.length, + warnings: checkWarnings, + }), + ); + + const configuredLabels = new Set(Object.keys(repo.registryConfig?.labelMultipliers ?? {})); + const observedCounts = countObservedLabels([...issues, ...pullRequests, ...recentMerged]); + for (const label of labelItems) { + await upsertRepoLabel(env, { + repoFullName: repo.fullName, + name: label.name, + color: label.color, + description: label.description, + isConfigured: configuredLabels.has(label.name), + observedCount: observedCounts.get(label.name) ?? 0, + payload: label as unknown as Record, + lastSeenAt: nowIso(), + }); + } + for (const configured of configuredLabels) { + if (labelItems.some((label) => label.name === configured)) continue; + await upsertRepoLabel(env, { + repoFullName: repo.fullName, + name: configured, + isConfigured: true, + observedCount: observedCounts.get(configured) ?? 0, + payload: {}, + lastSeenAt: nowIso(), + }); + } + + await upsertContributorStats(env, repo.fullName, normalizedPullRequests, issues, recentMerged); + const completedAt = nowIso(); + const dataQuality = summarizeSegments(segmentResults, warnings); + const status = dataQuality.rateLimited ? "rate_limited" : dataQuality.capped ? "capped" : dataQuality.partial || warnings.length > 0 ? "partial" : "success"; + await upsertRepoSyncState(env, { + repoFullName: repo.fullName, + status, + sourceKind, + primaryLanguage: metadata.language, + defaultBranch: metadata.default_branch, + isPrivate: metadata.private, + openIssuesCount: issuePage.fetchedCount, + openPullRequestsCount: pullRequestPage.fetchedCount, + recentMergedPullRequestsCount: recentMergedPage.fetchedCount, + labelsSyncedAt: completedAt, + issuesSyncedAt: completedAt, + pullRequestsSyncedAt: completedAt, + mergedPullRequestsSyncedAt: completedAt, + lastStartedAt: startedAt, + lastCompletedAt: completedAt, + warnings, + }); + await persistRepoSnapshot(env, { + id: crypto.randomUUID(), + repoFullName: repo.fullName, + snapshotKind: "github-backfill", + sourceKind, + fetchedAt: completedAt, + primaryLanguage: metadata.language, + defaultBranch: metadata.default_branch, + openIssuesCount: issuePage.fetchedCount, + openPullRequestsCount: pullRequestPage.fetchedCount, + recentMergedPullRequestsCount: recentMergedPage.fetchedCount, + payload: { + open_issues_count: metadata.open_issues_count ?? null, + limits, + mode, + warnings, + dataQuality, + }, + }); + return { + repoFullName: repo.fullName, + status, + openIssues: issuePage.fetchedCount, + openPullRequests: pullRequestPage.fetchedCount, + recentMergedPullRequests: recentMergedPage.fetchedCount, + warnings, + dataQuality, + }; + } catch (error) { + const errorSummary = error instanceof Error ? error.message : "unknown error"; + const rateLimitResetAt = error instanceof GitHubApiError ? error.rateLimitResetAt : undefined; + const status = error instanceof GitHubApiError && error.rateLimited ? "rate_limited" : "error"; + await completeSegment(env, repo, "metadata", repo.installationId ? "installation" : "github", mode, startedAt, { + status, + fetchedCount: 0, + expectedCount: 1, + warnings, + errorSummary, + rateLimitResetAt, + }); + await upsertRepoSyncState(env, { + repoFullName: repo.fullName, + status, + sourceKind: repo.installationId ? "installation" : "github", + primaryLanguage: undefined, + defaultBranch: repo.defaultBranch, + isPrivate: repo.isPrivate, + openIssuesCount: 0, + openPullRequestsCount: 0, + recentMergedPullRequestsCount: 0, + lastStartedAt: startedAt, + lastCompletedAt: nowIso(), + errorSummary, + warnings, + }); + return { + repoFullName: repo.fullName, + status, + openIssues: 0, + openPullRequests: 0, + recentMergedPullRequests: 0, + warnings, + dataQuality: { capped: false, partial: false, rateLimited: status === "rate_limited", segmentStatuses: { metadata: status } }, + errorSummary, + }; + } +} + +async function fetchAndStorePullRequestDetails( + env: Env, + repoFullName: string, + pr: PullRequestRecord, + token: string | undefined, + warnings: string[], +): Promise { + const [files, reviews, checks] = await Promise.all([ + fetchPullRequestFiles(env, repoFullName, pr.number, token, warnings), + githubJson(env, repoFullName, `/pulls/${pr.number}/reviews?per_page=100`, token).catch((error) => { + warnings.push(`Review sync failed for #${pr.number}: ${error instanceof Error ? error.message : "unknown error"}`); + return []; + }), + pr.headSha + ? githubJson<{ check_runs?: GitHubCheckRunPayload[] }>(env, repoFullName, `/commits/${pr.headSha}/check-runs?per_page=100`, token).catch((error) => { + warnings.push(`Check sync failed for #${pr.number}: ${error instanceof Error ? error.message : "unknown error"}`); + return { check_runs: [] }; + }) + : Promise.resolve({ check_runs: [] }), + ]); + + for (const file of files) { + await upsertPullRequestFile(env, { + repoFullName, + pullNumber: pr.number, + path: file.filename, + status: file.status, + additions: file.additions ?? 0, + deletions: file.deletions ?? 0, + changes: file.changes ?? 0, + previousFilename: file.previous_filename, + payload: file as unknown as Record, + }); + } + for (const review of reviews) { + await upsertPullRequestReview(env, { + id: `${repoFullName}#${pr.number}#${review.id}`, + repoFullName, + pullNumber: pr.number, + reviewerLogin: review.user?.login, + state: review.state ?? "UNKNOWN", + authorAssociation: review.author_association, + submittedAt: review.submitted_at, + payload: review as unknown as Record, + }); + } + for (const check of checks.check_runs ?? []) { + await upsertCheckSummary(env, { + id: `${repoFullName}#${pr.headSha ?? "unknown"}#${check.name}`, + repoFullName, + pullNumber: pr.number, + headSha: pr.headSha, + name: check.name, + status: check.status, + conclusion: check.conclusion, + startedAt: check.started_at, + completedAt: check.completed_at, + detailsUrl: check.details_url ?? check.html_url, + payload: check as unknown as Record, + }); + } +} + +async function fetchPullRequestFiles( + env: Env, + repoFullName: string, + pullNumber: number, + token: string | undefined, + warnings: string[], +): Promise { + return githubJson(env, repoFullName, `/pulls/${pullNumber}/files?per_page=100`, token).catch((error) => { + warnings.push(`File sync failed for #${pullNumber}: ${error instanceof Error ? error.message : "unknown error"}`); + return []; + }); +} + +async function upsertContributorStats( + env: Env, + repoFullName: string, + pullRequests: PullRequestRecord[], + issues: GitHubIssuePayload[], + recentMerged: GitHubPullRequestPayload[], +): Promise { + const logins = new Set(); + for (const pr of pullRequests) if (pr.authorLogin) logins.add(pr.authorLogin); + for (const pr of recentMerged) if (pr.user?.login) logins.add(pr.user.login); + for (const issue of issues) if (issue.user?.login) logins.add(issue.user.login); + + for (const login of logins) { + const authoredPullRequests = pullRequests.filter((pr) => pr.authorLogin === login); + const authoredMerged = recentMerged.filter((pr) => pr.user?.login === login); + const authoredIssues = issues.filter((issue) => issue.user?.login === login); + const labels = [...authoredPullRequests.flatMap((pr) => pr.labels), ...authoredIssues.flatMap((issue) => (issue.labels ?? []).flatMap((label) => (label.name ? [label.name] : [])))]; + const stat: ContributorRepoStatRecord = { + login, + repoFullName, + pullRequests: authoredPullRequests.length + authoredMerged.length, + mergedPullRequests: authoredMerged.length, + openPullRequests: authoredPullRequests.filter((pr) => pr.state === "open").length, + issues: authoredIssues.length, + stalePullRequests: authoredPullRequests.filter((pr) => pr.updatedAt && daysSince(pr.updatedAt) >= 14).length, + unlinkedPullRequests: authoredPullRequests.filter((pr) => pr.linkedIssues.length === 0).length, + dominantLabels: topItems(labels, 8), + lastActivityAt: latestDate([ + ...authoredPullRequests.map((pr) => pr.updatedAt ?? pr.createdAt), + ...authoredMerged.map((pr) => pr.merged_at ?? undefined), + ...authoredIssues.map((issue) => issue.updated_at ?? issue.created_at), + ]), + }; + await upsertContributor(env, { + login, + githubProfile: { login }, + topLanguages: [], + source: "github", + lastSeenAt: nowIso(), + }); + await upsertContributorRepoStat(env, stat); + } +} + +function toRecentMergedPullRequest(repoFullName: string, pr: GitHubPullRequestPayload, files: GitHubFilePayload[]): RecentMergedPullRequestRecord { + return { + repoFullName, + number: pr.number, + title: pr.title, + authorLogin: pr.user?.login, + htmlUrl: pr.html_url, + mergedAt: pr.merged_at, + labels: (pr.labels ?? []).flatMap((label) => (label.name ? [label.name] : [])), + linkedIssues: extractLinkedIssueNumbers(pr.body ?? ""), + changedFiles: files.map((file) => file.filename), + payload: pr as unknown as Record, + }; +} + +async function syncLabels( + env: Env, + repo: RepositoryRecord, + token: string | undefined, + sourceKind: RepoSyncSegmentRecord["sourceKind"], + mode: BackfillMode, + _warnings: string[], +): Promise<{ items: GitHubLabelPayload[]; warnings: string[]; segment: RepoSyncSegmentRecord }> { + const startedAt = nowIso(); + await markSegmentRunning(env, repo, "labels", sourceKind, mode, startedAt); + try { + const items = await githubJson(env, repo.fullName, "/labels?per_page=100", token); + const segment = await completeSegment(env, repo, "labels", sourceKind, mode, startedAt, { + status: "complete", + fetchedCount: items.length, + expectedCount: items.length, + warnings: [], + }); + return { items, warnings: [], segment }; + } catch (error) { + const warning = `Label sync failed: ${error instanceof Error ? error.message : "unknown error"}`; + const segment = await completeSegment(env, repo, "labels", sourceKind, mode, startedAt, { + status: error instanceof GitHubApiError && error.rateLimited ? "rate_limited" : "partial", + fetchedCount: 0, + warnings: [warning], + errorSummary: warning, + rateLimitResetAt: error instanceof GitHubApiError ? error.rateLimitResetAt : undefined, + }); + return { items: [], warnings: [warning], segment }; + } +} + +async function githubPaged( + env: Env, + repo: RepositoryRecord, + segmentName: RepoSyncSegmentRecord["segment"], + path: string, + limit: number, + token: string | undefined, + mode: BackfillMode, +): Promise<{ items: T[]; warnings: string[]; segment: RepoSyncSegmentRecord; fetchedCount: number }> { + const startedAt = nowIso(); + const sourceKind: RepoSyncSegmentRecord["sourceKind"] = repo.installationId ? "installation" : "github"; + const previous = mode === "resume" ? await getRepoSyncSegment(env, repo.fullName, segmentName) : null; + await markSegmentRunning(env, repo, segmentName, sourceKind, mode, startedAt); + const startPage = mode === "resume" && previous?.nextCursor && Number.isFinite(Number(previous.nextCursor)) ? Number(previous.nextCursor) : 1; + const priorFetched = mode === "resume" ? (previous?.fetchedCount ?? 0) : 0; + const items: T[] = []; + const warnings: string[] = []; + let pageCount = 0; + let nextCursor: string | undefined; + let lastCursor: string | undefined; + let etag: string | null | undefined; + let lastModified: string | null | undefined; + let rateLimitResetAt: string | null | undefined; + let status: RepoSyncSegmentRecord["status"] = "complete"; + + try { + for (let page = startPage; items.length < limit; page += 1) { + const pageLimit = Math.min(100, limit - items.length); + const separator = path.includes("?") ? "&" : "?"; + const pagePath = `${path}${separator}per_page=${pageLimit}&page=${page}`; + const result = await githubJsonWithHeaders(env, repo.fullName, pagePath, token); + etag = result.etag ?? etag; + lastModified = result.lastModified ?? lastModified; + lastCursor = String(page); + pageCount += 1; + items.push(...result.data); + const hasNext = hasNextPage(result.link); + if (result.data.length < pageLimit || !hasNext) break; + nextCursor = String(page + 1); + if (items.length >= limit) { + status = "capped"; + warnings.push(`GitHub sync reached local cap of ${limit} item(s) for ${path}; next page cursor is ${nextCursor}.`); + } + } + } catch (error) { + status = error instanceof GitHubApiError && error.rateLimited ? "rate_limited" : items.length > 0 ? "partial" : "error"; + rateLimitResetAt = error instanceof GitHubApiError ? error.rateLimitResetAt : undefined; + warnings.push(`GitHub sync failed for ${path}: ${error instanceof Error ? error.message : "unknown error"}`); + } + + if (status === "complete" && items.length >= limit && limit > 0) { + status = "capped"; + nextCursor = nextCursor ?? String(startPage + Math.max(pageCount, 1)); + warnings.push(`GitHub sync reached local cap of ${limit} item(s) for ${path}.`); + } + const fetchedCount = priorFetched + items.length; + const segment = await completeSegment(env, repo, segmentName, sourceKind, mode, startedAt, { + status, + fetchedCount, + expectedCount: status === "complete" ? fetchedCount : undefined, + pageCount, + lastCursor, + nextCursor, + etag, + lastModified, + warnings, + errorSummary: status === "error" || status === "rate_limited" ? warnings.at(-1) : undefined, + rateLimitResetAt, + }); + return { items, warnings, segment, fetchedCount }; +} + +async function githubJson(env: Env, repoFullName: string, path: string, token?: string): Promise { + return (await githubJsonWithHeaders(env, repoFullName, path, token)).data; +} + +async function githubJsonWithHeaders( + env: Env, + repoFullName: string, + path: string, + token?: string, +): Promise<{ data: T; link: string | null; etag: string | null; lastModified: string | null }> { + const { owner, name } = repoParts(repoFullName); + const response = await fetch(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}${path}`, { + headers: { + accept: "application/vnd.github+json", + "user-agent": "gittensory/0.1", + "x-github-api-version": "2022-11-28", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + }); + await recordGitHubResponse(env, repoFullName, path, response, "rest"); + if (!response.ok) { + const body = await response.text(); + throw new GitHubApiError( + `GitHub API failed for ${repoFullName}${path} (${response.status}): ${body.slice(0, 180)}`, + response.status, + response.headers.get("x-ratelimit-reset"), + response.headers.get("x-ratelimit-remaining"), + ); + } + return { + data: (await response.json()) as T, + link: response.headers.get("link"), + etag: response.headers.get("etag"), + lastModified: response.headers.get("last-modified"), + }; +} + +async function githubGraphQl(env: Env, query: string, token: string): Promise { + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + accept: "application/vnd.github+json", + "content-type": "application/json", + "user-agent": "gittensory/0.1", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ query }), + }); + await recordGitHubResponse(env, null, "/graphql", response, "graphql"); + if (!response.ok) { + const body = await response.text(); + throw new GitHubApiError( + `GitHub GraphQL failed (${response.status}): ${body.slice(0, 180)}`, + response.status, + response.headers.get("x-ratelimit-reset"), + response.headers.get("x-ratelimit-remaining"), + ); + } + return (await response.json()) as T; +} + +async function markSegmentRunning( + env: Env, + repo: RepositoryRecord, + segment: RepoSyncSegmentRecord["segment"], + sourceKind: RepoSyncSegmentRecord["sourceKind"], + mode: BackfillMode, + startedAt: string, +): Promise { + const previous = await getRepoSyncSegment(env, repo.fullName, segment); + await upsertRepoSyncSegment(env, { + repoFullName: repo.fullName, + segment, + status: "running", + sourceKind, + mode, + fetchedCount: previous?.fetchedCount ?? 0, + expectedCount: previous?.expectedCount, + pageCount: previous?.pageCount ?? 0, + lastCursor: previous?.lastCursor, + nextCursor: previous?.nextCursor, + startedAt, + completedAt: previous?.completedAt, + staleAt: previous?.staleAt, + rateLimitResetAt: previous?.rateLimitResetAt, + etag: previous?.etag, + lastModified: previous?.lastModified, + warnings: [], + }); +} + +async function completeSegment( + env: Env, + repo: RepositoryRecord, + segment: RepoSyncSegmentRecord["segment"], + sourceKind: RepoSyncSegmentRecord["sourceKind"], + mode: BackfillMode, + startedAt: string, + result: { + status: RepoSyncSegmentRecord["status"]; + fetchedCount: number; + expectedCount?: number | null | undefined; + pageCount?: number | undefined; + lastCursor?: string | null | undefined; + nextCursor?: string | null | undefined; + etag?: string | null | undefined; + lastModified?: string | null | undefined; + warnings: string[]; + errorSummary?: string | null | undefined; + rateLimitResetAt?: string | null | undefined; + }, +): Promise { + const record: RepoSyncSegmentRecord = { + repoFullName: repo.fullName, + segment, + status: result.status, + sourceKind, + mode, + lastCursor: result.lastCursor, + nextCursor: result.nextCursor, + fetchedCount: result.fetchedCount, + expectedCount: result.expectedCount, + pageCount: result.pageCount ?? 0, + startedAt, + completedAt: nowIso(), + staleAt: result.status === "stale" ? nowIso() : undefined, + rateLimitResetAt: result.rateLimitResetAt, + etag: result.etag, + lastModified: result.lastModified, + warnings: result.warnings, + errorSummary: result.errorSummary, + }; + await upsertRepoSyncSegment(env, record); + return record; +} + +async function upsertSkippedSegments(env: Env, repo: RepositoryRecord, mode: BackfillMode, completedAt: string, warnings: string[]): Promise { + const sourceKind: RepoSyncSegmentRecord["sourceKind"] = repo.installationId ? "installation" : "github"; + await Promise.all( + (["metadata", "labels", "open_issues", "open_pull_requests", "recent_merged_pull_requests", "pull_request_files", "pull_request_reviews", "check_summaries"] as const).map( + (segment) => + upsertRepoSyncSegment(env, { + repoFullName: repo.fullName, + segment, + status: "skipped", + sourceKind, + mode, + fetchedCount: 0, + pageCount: 0, + startedAt: completedAt, + completedAt, + warnings, + }), + ), + ); +} + +function summarizeSegments( + segments: RepoSyncSegmentRecord[], + warnings: string[], +): NonNullable { + const segmentStatuses = Object.fromEntries(segments.map((segment) => [segment.segment, segment.status])); + return { + capped: segments.some((segment) => segment.status === "capped") || warnings.some((warning) => /cap|capped/i.test(warning)), + rateLimited: segments.some((segment) => segment.status === "rate_limited") || warnings.some((warning) => /rate.?limit/i.test(warning)), + partial: segments.some((segment) => segment.status !== "complete" && segment.status !== "not_modified") || warnings.length > 0, + segmentStatuses, + }; +} + +async function recordGitHubResponse( + env: Env, + repoFullName: string | null, + path: string, + response: Response, + resource: "rest" | "graphql", +): Promise { + const resetHeader = response.headers.get("x-ratelimit-reset"); + const resetAt = resetHeader && Number.isFinite(Number(resetHeader)) ? new Date(Number(resetHeader) * 1000).toISOString() : undefined; + await recordGitHubRateLimitObservation(env, { + repoFullName, + resource, + path, + statusCode: response.status, + limitValue: parseNullableInt(response.headers.get("x-ratelimit-limit")), + remaining: parseNullableInt(response.headers.get("x-ratelimit-remaining")), + resetAt, + }); +} + +function hasNextPage(link: string | null): boolean { + return Boolean(link?.split(",").some((part) => /rel="next"/.test(part))); +} + +function parseNullableInt(value: string | null): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +async function mapWithConcurrency(items: T[], concurrency: number, mapper: (item: T, index: number) => Promise): Promise { + const results: R[] = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.max(1, Math.min(concurrency, items.length || 1)); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapper(items[index] as T, index); + } + }), + ); + return results; +} + +class GitHubApiError extends Error { + readonly rateLimitResetAt: string | undefined; + readonly rateLimited: boolean; + + constructor(message: string, readonly statusCode: number, resetHeader: string | null, remainingHeader: string | null) { + super(message); + this.name = "GitHubApiError"; + this.rateLimited = statusCode === 403 || statusCode === 429 || remainingHeader === "0"; + this.rateLimitResetAt = resetHeader && Number.isFinite(Number(resetHeader)) ? new Date(Number(resetHeader) * 1000).toISOString() : undefined; + } +} + +function buildContributorActivityAliases(login: string, repositories: RepositoryRecord[]): Array<{ alias: string; query: string }> { + return repositories.flatMap((repo) => [ + { + alias: activityAlias(repo.fullName, "all"), + query: `repo:${repo.fullName} author:${login} type:pr sort:updated-desc`, + }, + { + alias: activityAlias(repo.fullName, "merged"), + query: `repo:${repo.fullName} author:${login} type:pr is:merged sort:updated-desc`, + }, + { + alias: activityAlias(repo.fullName, "open"), + query: `repo:${repo.fullName} author:${login} type:pr is:open sort:updated-desc`, + }, + { + alias: activityAlias(repo.fullName, "issues"), + query: `repo:${repo.fullName} author:${login} type:issue sort:updated-desc`, + }, + ]); +} + +function buildContributorActivityQuery(aliases: Array<{ alias: string; query: string }>): string { + const fields = aliases + .map( + ({ alias, query }) => ` + ${alias}: search(query: ${JSON.stringify(query)}, type: ISSUE, first: 20) { + issueCount + nodes { + __typename + ... on PullRequest { + number + title + url + state + body + updatedAt + mergedAt + labels(first: 10) { nodes { name } } + } + ... on Issue { + number + title + url + state + body + updatedAt + labels(first: 10) { nodes { name } } + } + } + }`, + ) + .join("\n"); + return `query GittensoryContributorActivity {${fields}\n}`; +} + +function activityAlias(repoFullName: string, kind: "all" | "merged" | "open" | "issues"): string { + return `r_${repoFullName.replace(/[^A-Za-z0-9_]/g, "_")}_${kind}`; +} + +function compactNodes(bucket: GitHubGraphQlSearchBucket | undefined): GitHubGraphQlSearchNode[] { + return (bucket?.nodes ?? []).filter((node): node is GitHubGraphQlSearchNode => Boolean(node)); +} + +function labelsFromBucket(bucket: GitHubGraphQlSearchBucket | undefined): string[] { + return compactNodes(bucket).flatMap((node) => (node.labels?.nodes ?? []).flatMap((label) => (label?.name ? [label.name] : []))); +} + +function chunkArray(items: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let index = 0; index < items.length; index += size) chunks.push(items.slice(index, index + size)); + return chunks; +} + +function countObservedLabels(records: Array<{ labels?: Array<{ name?: string }> }>): Map { + const counts = new Map(); + for (const record of records) { + for (const label of record.labels ?? []) { + if (!label.name) continue; + counts.set(label.name, (counts.get(label.name) ?? 0) + 1); + } + } + return counts; +} + +function extractLinkedIssueNumbers(text: string): number[] { + const matches = [...text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/gi)]; + return [...new Set(matches.map((match) => Number(match[1])).filter((value) => Number.isInteger(value) && value > 0))]; +} + +function topItems(values: string[], limit: number): string[] { + const counts = new Map(); + for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1); + return [...counts.entries()] + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .slice(0, limit) + .map(([value]) => value); +} + +function latestDate(values: Array): string | undefined { + return values.filter(Boolean).sort().at(-1) ?? undefined; +} + +function daysSince(value: string): number { + const time = Date.parse(value); + if (!Number.isFinite(time)) return 0; + return Math.floor((Date.now() - time) / 86_400_000); +} diff --git a/src/gittensor/api.ts b/src/gittensor/api.ts new file mode 100644 index 0000000000..041c5f3af2 --- /dev/null +++ b/src/gittensor/api.ts @@ -0,0 +1,272 @@ +import type { ContributorRepoStatRecord } from "../types"; + +const GITTENSOR_API_BASE = "https://api.gittensor.io"; +const GITTENSOR_MIRROR_API_BASE = "https://mirror.gittensor.io/api/v1"; + +type GittensorMinerSummaryResponse = { + uid?: number; + hotkey?: string; + githubUsername?: string; + githubId?: string; + failedReason?: string | null; + totalOpenPrs?: number; + totalClosedPrs?: number; + totalMergedPrs?: number; + totalPrs?: number; + uniqueReposCount?: number; + isEligible?: boolean; + credibility?: number; + eligibleRepoCount?: number; + issueDiscoveryScore?: number; + issueTokenScore?: number; + issueCredibility?: number; + isIssueEligible?: boolean; + issueEligibleRepoCount?: number; + totalSolvedIssues?: number; + totalValidSolvedIssues?: number; + totalClosedIssues?: number; + totalOpenIssues?: number; + evaluatedAt?: string; + updatedAt?: string; + alphaPerDay?: number; + taoPerDay?: number; + usdPerDay?: number; +}; + +type GittensorMinerDetailResponse = GittensorMinerSummaryResponse & { + repositories?: GittensorRepositoryEvaluationResponse[]; +}; + +type GittensorRepositoryEvaluationResponse = { + repositoryFullName?: string; + totalOpenPrs?: number | string; + totalClosedPrs?: number | string; + totalMergedPrs?: number | string; + totalPrs?: number | string; + totalOpenIssues?: number | string; + totalClosedIssues?: number | string; + totalSolvedIssues?: number | string; + totalValidSolvedIssues?: number | string; + isEligible?: boolean; + isIssueEligible?: boolean; + credibility?: number | string; + issueCredibility?: number | string; + totalScore?: number | string; + baseTotalScore?: number | string; +}; + +type GittensorPullRequestResponse = { + pullRequestNumber?: number; + pullRequestTitle?: string; + repository?: string; + prState?: string; + mergedAt?: string | null; + author?: string; + githubId?: string; + label?: string | null; + score?: string | number; + baseScore?: string | number; + collateralScore?: string | number; + tokenScore?: string | number; + reviewQualityMultiplier?: string | number; + labelMultiplier?: string | number; + codeDensity?: string | number; +}; + +type GittensorMinerIssuesResponse = { + issues?: Array<{ + repo_full_name?: string; + issue_number?: number; + state?: string; + author_association?: string | null; + labels?: Array<{ name?: string | null }>; + solved_by_pr?: number | null; + }>; +}; + +export type GittensorContributorSnapshot = { + source: "gittensor_api"; + githubId: string; + githubUsername: string; + uid?: number | undefined; + hotkey?: string | undefined; + failedReason?: string | null | undefined; + evaluatedAt?: string | undefined; + updatedAt?: string | undefined; + isEligible: boolean; + credibility: number; + eligibleRepoCount: number; + issueDiscoveryScore: number; + issueTokenScore: number; + issueCredibility: number; + isIssueEligible: boolean; + issueEligibleRepoCount: number; + alphaPerDay: number; + taoPerDay: number; + usdPerDay: number; + totals: { + pullRequests: number; + mergedPullRequests: number; + openPullRequests: number; + closedPullRequests: number; + openIssues: number; + closedIssues: number; + solvedIssues: number; + validSolvedIssues: number; + }; + repositories: Array<{ + repoFullName: string; + pullRequests: number; + mergedPullRequests: number; + openPullRequests: number; + closedPullRequests: number; + openIssues: number; + closedIssues: number; + solvedIssues: number; + validSolvedIssues: number; + isEligible: boolean; + isIssueEligible: boolean; + credibility: number; + issueCredibility: number; + totalScore: number; + baseTotalScore: number; + }>; + pullRequests: Array<{ + repoFullName: string; + number: number; + title: string; + state: string; + mergedAt?: string | null | undefined; + label?: string | null | undefined; + score: number; + baseScore: number; + tokenScore: number; + }>; + issueLabels: string[]; +}; + +export async function fetchGittensorContributorSnapshot(login: string): Promise { + try { + const miners = await fetchJson(`${GITTENSOR_API_BASE}/miners`); + const normalizedLogin = login.toLowerCase(); + const miner = miners.find((candidate) => candidate.githubUsername?.toLowerCase() === normalizedLogin || candidate.githubId === login); + if (!miner?.githubId || !miner.githubUsername) return null; + + const [detailResult, pullRequestsResult, issuesResult] = await Promise.allSettled([ + fetchJson(`${GITTENSOR_API_BASE}/miners/${encodeURIComponent(miner.githubId)}`), + fetchJson(`${GITTENSOR_API_BASE}/miners/${encodeURIComponent(miner.githubId)}/prs`), + fetchJson(`${GITTENSOR_MIRROR_API_BASE}/miners/${encodeURIComponent(miner.githubId)}/issues`), + ]); + const detail = detailResult.status === "fulfilled" ? detailResult.value : {}; + const pullRequests = pullRequestsResult.status === "fulfilled" ? pullRequestsResult.value : []; + const issues = issuesResult.status === "fulfilled" ? issuesResult.value.issues ?? [] : []; + const source = { ...miner, ...detail }; + + return { + source: "gittensor_api", + githubId: miner.githubId, + githubUsername: miner.githubUsername, + uid: source.uid, + hotkey: source.hotkey, + failedReason: source.failedReason, + evaluatedAt: source.evaluatedAt, + updatedAt: source.updatedAt, + isEligible: Boolean(source.isEligible), + credibility: asNumber(source.credibility), + eligibleRepoCount: asNumber(source.eligibleRepoCount), + issueDiscoveryScore: asNumber(source.issueDiscoveryScore), + issueTokenScore: asNumber(source.issueTokenScore), + issueCredibility: asNumber(source.issueCredibility, 1), + isIssueEligible: Boolean(source.isIssueEligible), + issueEligibleRepoCount: asNumber(source.issueEligibleRepoCount), + alphaPerDay: asNumber(source.alphaPerDay), + taoPerDay: asNumber(source.taoPerDay), + usdPerDay: asNumber(source.usdPerDay), + totals: { + pullRequests: asNumber(source.totalPrs), + mergedPullRequests: asNumber(source.totalMergedPrs), + openPullRequests: asNumber(source.totalOpenPrs), + closedPullRequests: asNumber(source.totalClosedPrs), + openIssues: asNumber(source.totalOpenIssues), + closedIssues: asNumber(source.totalClosedIssues), + solvedIssues: asNumber(source.totalSolvedIssues), + validSolvedIssues: asNumber(source.totalValidSolvedIssues), + }, + repositories: (detail.repositories ?? []).map(toRepositoryEvaluation).filter((repo) => repo.pullRequests + repo.openIssues + repo.closedIssues > 0), + pullRequests: pullRequests.map(toPullRequest).filter((pr) => pr.repoFullName && pr.number > 0), + issueLabels: issues.flatMap((issue) => (issue.labels ?? []).flatMap((label) => (label.name ? [label.name] : []))), + }; + } catch { + return null; + } +} + +export function contributorRepoStatsFromGittensor(snapshot: GittensorContributorSnapshot | null): ContributorRepoStatRecord[] { + if (!snapshot) return []; + return snapshot.repositories.map((repo) => ({ + login: snapshot.githubUsername.toLowerCase(), + repoFullName: repo.repoFullName, + pullRequests: repo.pullRequests, + mergedPullRequests: repo.mergedPullRequests, + openPullRequests: repo.openPullRequests, + issues: repo.openIssues + repo.closedIssues, + stalePullRequests: 0, + unlinkedPullRequests: 0, + dominantLabels: [], + lastActivityAt: snapshot.updatedAt ?? snapshot.evaluatedAt, + })); +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url, { + headers: { + accept: "application/json", + "user-agent": "gittensory/0.1", + }, + }); + if (!response.ok) throw new Error(`Gittensor API failed for ${url} (${response.status})`); + return (await response.json()) as T; +} + +function toRepositoryEvaluation(repo: GittensorRepositoryEvaluationResponse): GittensorContributorSnapshot["repositories"][number] { + return { + repoFullName: repo.repositoryFullName ?? "", + pullRequests: asNumber(repo.totalPrs), + mergedPullRequests: asNumber(repo.totalMergedPrs), + openPullRequests: asNumber(repo.totalOpenPrs), + closedPullRequests: asNumber(repo.totalClosedPrs), + openIssues: asNumber(repo.totalOpenIssues), + closedIssues: asNumber(repo.totalClosedIssues), + solvedIssues: asNumber(repo.totalSolvedIssues), + validSolvedIssues: asNumber(repo.totalValidSolvedIssues), + isEligible: Boolean(repo.isEligible), + isIssueEligible: Boolean(repo.isIssueEligible), + credibility: asNumber(repo.credibility), + issueCredibility: asNumber(repo.issueCredibility), + totalScore: asNumber(repo.totalScore), + baseTotalScore: asNumber(repo.baseTotalScore), + }; +} + +function toPullRequest(pr: GittensorPullRequestResponse): GittensorContributorSnapshot["pullRequests"][number] { + return { + repoFullName: pr.repository ?? "", + number: asNumber(pr.pullRequestNumber), + title: pr.pullRequestTitle ?? "", + state: pr.prState ?? "UNKNOWN", + mergedAt: pr.mergedAt, + label: pr.label, + score: asNumber(pr.score), + baseScore: asNumber(pr.baseScore), + tokenScore: asNumber(pr.tokenScore), + }; +} + +function asNumber(value: unknown, fallback = 0): number { + if (typeof value === "number") return Number.isFinite(value) ? value : fallback; + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; + } + return fallback; +} diff --git a/src/index.ts b/src/index.ts index f25e045b6b..1a2c401e36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,12 @@ import { createApp } from "./api/routes"; +import { RateLimiter } from "./auth/rate-limit"; import { processJob } from "./queue/processors"; import type { JobMessage } from "./types"; const app = createApp(); +export { RateLimiter }; + export default { fetch: app.fetch, async queue(batch: MessageBatch, env: Env): Promise { @@ -17,6 +20,7 @@ export default { level: "error", event: "queue_message_failed", messageId: message.id, + /* v8 ignore next -- JavaScript can throw non-Error values, but queue processors throw Error instances in practice. */ error: error instanceof Error ? error.message : "unknown error", }), ); @@ -24,7 +28,31 @@ export default { } } }, - async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise { - ctx.waitUntil(processJob(env, { type: "refresh-registry", requestedBy: "schedule" })); + async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise { + ctx.waitUntil(enqueueScheduledJobs(env, controller)); }, }; + +async function enqueueScheduledJobs(env: Env, controller: ScheduledController): Promise { + const scheduledAt = new Date(controller.scheduledTime ?? Date.now()); + const minute = scheduledAt.getUTCMinutes(); + const hour = scheduledAt.getUTCHours(); + const isHourly = minute === 0; + const isFullSyncWindow = isHourly && hour % 6 === 0; + const jobs: JobMessage[] = [ + { type: "backfill-registered-repos", requestedBy: "schedule", mode: isFullSyncWindow ? "full" : "light" }, + { type: "repair-data-fidelity", requestedBy: "schedule" }, + { type: "refresh-installation-health", requestedBy: "schedule" }, + ]; + if (isHourly) { + jobs.push({ type: "refresh-registry", requestedBy: "schedule" }); + jobs.push({ type: "refresh-scoring-model", requestedBy: "schedule" }); + } + if (isFullSyncWindow) { + jobs.push({ type: "generate-signal-snapshots", requestedBy: "schedule" }); + jobs.push({ type: "build-burden-forecasts", requestedBy: "schedule" }); + jobs.push({ type: "build-contributor-evidence", requestedBy: "schedule" }); + jobs.push({ type: "build-contributor-decision-packs", requestedBy: "schedule" }); + } + await Promise.all(jobs.map((job) => env.JOBS.send(job))); +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d187823231..fb493d1ea1 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -2,28 +2,50 @@ import { createMcpHandler } from "agents/mcp"; import type { Context } from "hono"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import { authenticatePrivateToken, extractBearerToken } from "../auth/security"; import { + countOpenIssues, + countOpenPullRequests, getBounty, + getContributorEvidence, + getLatestRepoGithubTotalsSnapshot, getIssue, getRepository, - listAllIssues, - listAllPullRequests, + listContributorRepoStats, listContributorIssues, listContributorPullRequests, + listIssueSignalSample, listIssues, + listOpenPullRequests, listPullRequests, + listRecentMergedPullRequests, + listRepoSyncSegments, + listRepoSyncStates, listRepositories, } from "../db/repositories"; +import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; import { fetchPublicContributorProfile } from "../github/public"; +import { listLatestRegistrySnapshots } from "../registry/sync"; +import { getOrCreateScoringModelSnapshot } from "../scoring/model"; +import { buildScorePreview, makeScorePreviewRecord } from "../scoring/preview"; +import { loadFreshContributorDecisionPack, repoDecisionFromPack } from "../services/decision-pack"; import { buildBountyAdvisory, buildCollisionReport, buildConfigQuality, - buildContributorOpportunities, + buildContributorFit, + buildContributorOutcomeHistory, buildContributorProfile, + buildContributorScoringProfile, + buildLaneAdvice, + buildLocalDiffPreflightResult, buildPreflightResult, buildQueueHealth, + buildRegistryChangeReport, + buildRoleContext, } from "../signals/engine"; +import { buildLocalBranchAnalysis } from "../signals/local-branch"; +import { buildRepoDataQuality } from "../signals/data-quality"; type AppContext = Context<{ Bindings: Env }>; type ToolPayload = { @@ -40,6 +62,12 @@ const loginShape = { login: z.string().min(1), }; +const loginRepoShape = { + login: z.string().min(1), + owner: z.string().min(1), + repo: z.string().min(1), +}; + const bountyShape = { id: z.string().min(1), }; @@ -56,6 +84,93 @@ const preflightShape = { authorAssociation: z.string().optional(), }; +const localDiffPreflightShape = { + ...preflightShape, + changedLineCount: z.number().int().min(0).optional(), + testFiles: z.array(z.string()).optional(), + commitMessage: z.string().optional(), +}; + +const localBranchAnalysisShape = { + login: z.string().min(1), + repoFullName: z.string().min(3), + baseRef: z.string().min(1).optional(), + headRef: z.string().min(1).optional(), + branchName: z.string().min(1).optional(), + commitMessages: z.array(z.string()).max(30).optional(), + changedFiles: z + .array( + z + .object({ + path: z.string().min(1), + previousPath: z.string().min(1).optional(), + additions: z.number().int().min(0).optional(), + deletions: z.number().int().min(0).optional(), + status: z.enum(["added", "modified", "deleted", "renamed", "copied", "unknown"]).optional(), + binary: z.boolean().optional(), + }) + .strict(), + ) + .max(500) + .optional(), + validation: z + .array( + z + .object({ + command: z.string().min(1), + status: z.enum(["passed", "failed", "not_run"]), + summary: z.string().optional(), + }) + .strict(), + ) + .max(50) + .optional(), + linkedIssues: z.array(z.number().int().positive()).optional(), + labels: z.array(z.string()).optional(), + title: z.string().min(1).optional(), + body: z.string().optional(), + localScorer: z + .object({ + mode: z.enum(["metadata_only", "external_command", "gittensor_root"]), + activeModel: z.string().optional(), + sourceTokenScore: z.number().min(0).optional(), + totalTokenScore: z.number().min(0).optional(), + sourceLines: z.number().min(0).optional(), + testTokenScore: z.number().min(0).optional(), + nonCodeTokenScore: z.number().min(0).optional(), + warnings: z.array(z.string()).optional(), + }) + .strict() + .optional(), +}; + +const localBranchVariantsShape = { + variants: z.array(z.object(localBranchAnalysisShape).strict()).min(1).max(10), +}; + +const scorePreviewShape = { + repoFullName: z.string().min(3), + targetType: z.enum(["planned_pr", "pull_request", "local_diff", "variant"]).default("local_diff"), + targetKey: z.string().optional(), + contributorLogin: z.string().min(1).optional(), + labels: z.array(z.string()).optional(), + linkedIssueMode: z.enum(["none", "standard", "maintainer"]).default("none"), + sourceTokenScore: z.number().min(0).optional(), + totalTokenScore: z.number().min(0).optional(), + sourceLines: z.number().min(0).optional(), + testTokenScore: z.number().min(0).optional(), + nonCodeTokenScore: z.number().min(0).optional(), + existingContributorTokenScore: z.number().min(0).optional(), + openPrCount: z.number().int().min(0).optional(), + credibility: z.number().min(0).max(1).optional(), + changesRequestedCount: z.number().int().min(0).optional(), + metadataOnly: z.boolean().default(true), +}; + +const variantsShape = { + variants: z.array(z.object(scorePreviewShape)).min(1).max(10), +}; + export async function handleMcpRequest(c: AppContext): Promise { if (c.req.method === "OPTIONS") return new Response(null, { status: 204 }); if (!(await isAuthorizedMcpRequest(c))) return c.json({ error: "unauthorized" }, 401); @@ -92,12 +207,21 @@ export class GittensoryMcp { ); server.registerTool( - "gittensory_find_opportunities", + "gittensory_get_decision_pack", { - description: "Return ranked registered-repo opportunities for a GitHub login using cached Gittensory signals.", + description: "Return the canonical private contributor decision pack for a GitHub login.", inputSchema: loginShape, }, - async (input) => this.toolResult(await this.findOpportunities(input.login)), + async (input) => this.toolResult(await this.getDecisionPack(input.login)), + ); + + server.registerTool( + "gittensory_explain_repo_decision", + { + description: "Return the contributor/repo decision from the canonical decision pack.", + inputSchema: loginRepoShape, + }, + async (input) => this.toolResult(await this.explainRepoDecision(input)), ); server.registerTool( @@ -110,30 +234,138 @@ export class GittensoryMcp { ); server.registerTool( - "gittensory_get_queue_health", + "gittensory_get_bounty_advisory", { - description: "Return maintainer burden and queue-health signals for a registered repository.", - inputSchema: ownerRepoShape, + description: "Return lifecycle, funding, and consensus-risk context for a cached Gittensor bounty.", + inputSchema: bountyShape, + }, + async (input) => this.toolResult(await this.getBountyAdvisory(input.id)), + ); + + server.registerTool( + "gittensory_get_registry_changes", + { + description: "Return the diff between the latest cached Gittensor registry snapshots.", + inputSchema: {}, }, - async (input) => this.toolResult(await this.getQueueHealth(input)), + async () => this.toolResult(await this.getRegistryChanges()), ); server.registerTool( - "gittensory_get_collisions", + "gittensory_preflight_local_diff", { - description: "Return duplicate and WIP collision clusters for a registered repository.", - inputSchema: ownerRepoShape, + description: "Preflight local git-diff metadata without uploading code content.", + inputSchema: localDiffPreflightShape, }, - async (input) => this.toolResult(await this.getCollisions(input)), + async (input) => this.toolResult(await this.preflightLocalDiff(input)), ); server.registerTool( - "gittensory_get_bounty_advisory", + "gittensory_preview_local_pr_score", { - description: "Return lifecycle, funding, and consensus-risk context for a cached Gittensor bounty.", - inputSchema: bountyShape, + description: "Return a private scoring preview from local diff metrics or supplied metadata. Source contents are not required.", + inputSchema: scorePreviewShape, }, - async (input) => this.toolResult(await this.getBountyAdvisory(input.id)), + async (input) => this.toolResult(await this.previewScore(input)), + ); + + server.registerTool( + "gittensory_explain_review_risk", + { + description: "Explain review risk for a planned PR using preflight, lane, duplicate, and role context.", + inputSchema: preflightShape, + }, + async (input) => this.toolResult(await this.explainReviewRisk(input)), + ); + + server.registerTool( + "gittensory_compare_pr_variants", + { + description: "Compare private scoring previews for multiple PR variants.", + inputSchema: variantsShape, + }, + async (input) => this.toolResult(await this.comparePrVariants(input.variants)), + ); + + server.registerTool( + "gittensory_local_status", + { + description: "Return Gittensory local-MCP contract status and privacy defaults.", + inputSchema: {}, + }, + async () => + this.toolResult({ + summary: "Gittensory local MCP status.", + data: { + apiAvailable: true, + sourceUploadDefault: false, + supportedEndpoint: "/v1/local/branch-analysis", + supportedTools: [ + "gittensory_get_decision_pack", + "gittensory_explain_repo_decision", + "gittensory_preflight_current_branch", + "gittensory_preview_current_branch_score", + "gittensory_rank_local_next_actions", + "gittensory_compare_local_variants", + "gittensory_explain_local_blockers", + "gittensory_prepare_pr_packet", + ], + }, + }), + ); + + server.registerTool( + "gittensory_preflight_current_branch", + { + description: "Analyze current-branch metadata supplied by a local MCP wrapper and return PR readiness.", + inputSchema: localBranchAnalysisShape, + }, + async (input) => this.toolResult(await this.localBranchSlice(input, "preflight")), + ); + + server.registerTool( + "gittensory_preview_current_branch_score", + { + description: "Analyze current-branch metadata and return private scoreability context.", + inputSchema: localBranchAnalysisShape, + }, + async (input) => this.toolResult(await this.localBranchSlice(input, "scorePreview")), + ); + + server.registerTool( + "gittensory_rank_local_next_actions", + { + description: "Analyze current-branch metadata and rank local next actions by private reward/risk signals.", + inputSchema: localBranchAnalysisShape, + }, + async (input) => this.toolResult(await this.localBranchSlice(input, "nextActions")), + ); + + server.registerTool( + "gittensory_explain_local_blockers", + { + description: "Analyze current-branch metadata and explain private scoreability and review blockers.", + inputSchema: localBranchAnalysisShape, + }, + async (input) => this.toolResult(await this.localBranchSlice(input, "scoreBlockers")), + ); + + server.registerTool( + "gittensory_prepare_pr_packet", + { + description: "Analyze current-branch metadata and return a public-safe PR packet for coding agents.", + inputSchema: localBranchAnalysisShape, + }, + async (input) => this.toolResult(await this.localBranchSlice(input, "prPacket")), + ); + + server.registerTool( + "gittensory_compare_local_variants", + { + description: "Compare private local-branch analysis variants without source uploads.", + inputSchema: localBranchVariantsShape, + }, + async (input) => this.toolResult(await this.compareLocalVariants(input.variants)), ); return server; @@ -141,50 +373,116 @@ export class GittensoryMcp { private async getRepoContext(input: { owner: string; repo: string }): Promise { const fullName = `${input.owner}/${input.repo}`; - const [repo, issues, pullRequests] = await Promise.all([getRepository(this.env, fullName), listIssues(this.env, fullName), listPullRequests(this.env, fullName)]); - const collisions = buildCollisionReport(fullName, issues, pullRequests); + const [repo, issues, pullRequests, recentMergedPullRequests, queueCounts] = await Promise.all([ + getRepository(this.env, fullName), + listIssueSignalSample(this.env, fullName), + listOpenPullRequests(this.env, fullName), + listRecentMergedPullRequests(this.env, fullName), + this.loadOpenQueueCounts(fullName), + ]); + const collisions = buildCollisionReport(fullName, issues, pullRequests, recentMergedPullRequests); return { summary: `Gittensory repo context for ${fullName}.`, data: { + repoFullName: fullName, repo, - queueHealth: buildQueueHealth(repo, issues, pullRequests, collisions), + lane: buildLaneAdvice(repo, fullName), + queueHealth: buildQueueHealth(repo, issues, pullRequests, collisions, queueCounts), collisions, configQuality: buildConfigQuality(repo, issues, pullRequests, fullName), + dataQuality: await this.loadRepoDataQuality(fullName), }, }; } + private async loadOpenQueueCounts(fullName: string): Promise<{ openIssues: number; openPullRequests: number }> { + const [totals, openIssues, openPullRequests] = await Promise.all([ + getLatestRepoGithubTotalsSnapshot(this.env, fullName), + countOpenIssues(this.env, fullName), + countOpenPullRequests(this.env, fullName), + ]); + return { + openIssues: totals?.openIssuesTotal ?? openIssues, + openPullRequests: totals?.openPullRequestsTotal ?? openPullRequests, + }; + } + private async getContributorProfile(login: string): Promise { - const [github, pullRequests, issues] = await Promise.all([ + const [github, pullRequests, issues, cachedRepoStats, gittensorSnapshot] = await Promise.all([ fetchPublicContributorProfile(login), listContributorPullRequests(this.env, login), listContributorIssues(this.env, login), + listContributorRepoStats(this.env, login), + fetchGittensorContributorSnapshot(login), ]); + const repoStats = authoritativeContributorRepoStats(gittensorSnapshot, cachedRepoStats); return { summary: `Gittensory contributor profile for ${login}.`, - data: buildContributorProfile(login, github, pullRequests, issues) as unknown as Record, + data: buildContributorProfile(login, github, pullRequests, issues, repoStats, gittensorSnapshot) as unknown as Record, }; } - private async findOpportunities(login: string): Promise { - const [github, contributorPullRequests, contributorIssues, repositories, allIssues, allPullRequests] = await Promise.all([ - fetchPublicContributorProfile(login), - listContributorPullRequests(this.env, login), - listContributorIssues(this.env, login), - listRepositories(this.env), - listAllIssues(this.env), - listAllPullRequests(this.env), - ]); - const profile = buildContributorProfile(login, github, contributorPullRequests, contributorIssues); + private async getDecisionPack(login: string): Promise { + const pack = await loadFreshContributorDecisionPack(this.env, login); + if (pack) { + return { + summary: `Gittensory decision pack for ${login}.`, + data: pack as unknown as Record, + }; + } + await this.env.JOBS.send({ type: "build-contributor-decision-packs", requestedBy: "api", login }); return { - summary: `Gittensory opportunities for ${login}.`, + summary: `Gittensory decision pack for ${login} needs a snapshot refresh.`, data: { - profile, - opportunities: buildContributorOpportunities(profile, repositories, allIssues, allPullRequests), + status: "needs_snapshot_refresh", + login, + generatedAt: new Date().toISOString(), + reason: "missing_snapshot", + enqueued: true, }, }; } + private async explainRepoDecision(input: { login: string; owner: string; repo: string }): Promise { + const fullName = `${input.owner}/${input.repo}`; + const pack = await loadFreshContributorDecisionPack(this.env, input.login); + if (!pack) { + await this.env.JOBS.send({ type: "build-contributor-decision-packs", requestedBy: "api", login: input.login }); + return { + summary: `Gittensory repo decision for ${input.login} in ${fullName} needs a snapshot refresh.`, + data: { + status: "needs_snapshot_refresh", + login: input.login, + repoFullName: fullName, + generatedAt: new Date().toISOString(), + reason: "missing_snapshot", + enqueued: true, + }, + }; + } + const decision = repoDecisionFromPack(pack, fullName); + return { + summary: `Gittensory repo decision for ${input.login} in ${fullName}.`, + data: { + status: decision ? "ready" : "not_found", + login: input.login, + repoFullName: fullName, + generatedAt: pack.generatedAt, + source: pack.source, + decision, + dataQuality: pack.dataQuality, + }, + }; + } + + private async getRegistryChanges(): Promise { + const report = buildRegistryChangeReport(await listLatestRegistrySnapshots(this.env, 2)); + return { + summary: "Gittensory registry changes from latest cached snapshots.", + data: report as unknown as Record, + }; + } + private async preflightPr(input: z.infer>): Promise { const [repo, issues, pullRequests] = await Promise.all([ getRepository(this.env, input.repoFullName), @@ -197,22 +495,137 @@ export class GittensoryMcp { }; } - private async getQueueHealth(input: { owner: string; repo: string }): Promise { - const fullName = `${input.owner}/${input.repo}`; - const [repo, issues, pullRequests] = await Promise.all([getRepository(this.env, fullName), listIssues(this.env, fullName), listPullRequests(this.env, fullName)]); - const collisions = buildCollisionReport(fullName, issues, pullRequests); + private async preflightLocalDiff(input: z.infer>): Promise { + const [repo, issues, pullRequests] = await Promise.all([ + getRepository(this.env, input.repoFullName), + listIssues(this.env, input.repoFullName), + listPullRequests(this.env, input.repoFullName), + ]); return { - summary: `Gittensory queue health for ${fullName}.`, - data: buildQueueHealth(repo, issues, pullRequests, collisions) as unknown as Record, + summary: `Gittensory local diff preflight for ${input.repoFullName}.`, + data: buildLocalDiffPreflightResult(input, repo, issues, pullRequests) as unknown as Record, }; } - private async getCollisions(input: { owner: string; repo: string }): Promise { - const fullName = `${input.owner}/${input.repo}`; - const [issues, pullRequests] = await Promise.all([listIssues(this.env, fullName), listPullRequests(this.env, fullName)]); + private async previewScore(input: z.infer>): Promise { + const [repo, snapshot, evidence] = await Promise.all([ + getRepository(this.env, input.repoFullName), + getOrCreateScoringModelSnapshot(this.env), + input.contributorLogin ? getContributorEvidence(this.env, input.contributorLogin) : Promise.resolve(null), + ]); + const result = buildScorePreview({ input, repo, snapshot, contributorEvidence: evidence }); return { - summary: `Gittensory collision report for ${fullName}.`, - data: buildCollisionReport(fullName, issues, pullRequests) as unknown as Record, + summary: `Private Gittensory scoring preview for ${input.repoFullName}.`, + data: makeScorePreviewRecord(input, snapshot, result) as unknown as Record, + }; + } + + private async explainReviewRisk(input: z.infer>): Promise { + const [repo, issues, pullRequests] = await Promise.all([ + getRepository(this.env, input.repoFullName), + listIssues(this.env, input.repoFullName), + listPullRequests(this.env, input.repoFullName), + ]); + const preflight = buildPreflightResult(input, repo, issues, pullRequests); + const roleContext = input.contributorLogin + ? buildRoleContext({ login: input.contributorLogin, repo, repoFullName: input.repoFullName, pullRequests, issues }) + : null; + return { + summary: `Gittensory review-risk explanation for ${input.repoFullName}.`, + data: { + preflight, + roleContext, + recommendation: preflight.collisions.some((cluster) => cluster.risk === "high") + ? "likely_duplicate" + : roleContext?.maintainerLane + ? "maintainer_lane" + : preflight.status === "needs_work" + ? "needs_author" + : preflight.status === "ready" + ? "review" + : "watch", + }, + }; + } + + private async comparePrVariants(variants: Array>>): Promise { + const previews = []; + for (const variant of variants) previews.push((await this.previewScore({ ...variant, targetType: "variant" })).data); + previews.sort((left, right) => { + const leftScore = Number((left as { result: { scoreEstimate: { estimatedMergedScore: number } } }).result.scoreEstimate.estimatedMergedScore); + const rightScore = Number((right as { result: { scoreEstimate: { estimatedMergedScore: number } } }).result.scoreEstimate.estimatedMergedScore); + return rightScore - leftScore; + }); + return { + summary: "Private Gittensory PR variant comparison.", + data: { variants: previews }, + }; + } + + private async localBranchSlice(input: z.infer>, slice: "preflight" | "scorePreview" | "nextActions" | "scoreBlockers" | "prPacket"): Promise { + const analysis = await this.analyzeLocalBranch(input); + return { + summary: `${analysis.summary} (${slice}).`, + data: { + login: analysis.login, + repoFullName: analysis.repoFullName, + generatedAt: analysis.generatedAt, + [slice]: analysis[slice], + dataQuality: analysis.dataQuality, + } as Record, + }; + } + + private async compareLocalVariants(variants: Array>>): Promise { + const analyses = []; + for (const variant of variants) analyses.push(await this.analyzeLocalBranch(variant)); + analyses.sort( + (left, right) => + (right.nextActions[0]?.priorityScore ?? 0) - (left.nextActions[0]?.priorityScore ?? 0) || + right.scorePreview.scoreEstimate.estimatedMergedScore - left.scorePreview.scoreEstimate.estimatedMergedScore || + left.repoFullName.localeCompare(right.repoFullName), + ); + return { + summary: "Gittensory local branch variant comparison.", + data: { + variants: analyses.map((analysis) => ({ + repoFullName: analysis.repoFullName, + branchName: analysis.branchName, + preflightStatus: analysis.preflight.status, + scoreBlockers: analysis.scoreBlockers, + scorePreview: analysis.scorePreview, + topAction: analysis.nextActions[0] ?? null, + prPacket: analysis.prPacket, + dataQuality: analysis.dataQuality, + })), + }, + }; + } + + private async analyzeLocalBranch(input: z.infer>) { + const [context, repo, issues, pullRequests, recentMergedPullRequests, snapshot] = await Promise.all([ + this.loadContributorFastContext(input.login), + getRepository(this.env, input.repoFullName), + listIssues(this.env, input.repoFullName), + listPullRequests(this.env, input.repoFullName), + listRecentMergedPullRequests(this.env, input.repoFullName), + getOrCreateScoringModelSnapshot(this.env), + ]); + const fit = buildContributorFit(context.profile, context.repositories, [], [], context.syncStates, context.repoStats); + const scoringProfile = buildContributorScoringProfile({ login: input.login, fit, scoringSnapshot: snapshot }); + return { + ...buildLocalBranchAnalysis({ + input, + repo, + issues, + pullRequests, + recentMergedPullRequests, + profile: context.profile, + outcomeHistory: context.outcomeHistory, + scoringSnapshot: snapshot, + scoringProfile, + }), + dataQuality: await this.loadRepoDataQuality(input.repoFullName), }; } @@ -226,25 +639,78 @@ export class GittensoryMcp { }; } + private async loadContributorFastContext(login: string) { + const [github, contributorPullRequests, contributorIssues, repositories, syncStates, cachedRepoStats, gittensorSnapshot] = await Promise.all([ + fetchPublicContributorProfile(login), + listContributorPullRequests(this.env, login), + listContributorIssues(this.env, login), + listRepositories(this.env), + listRepoSyncStates(this.env), + listContributorRepoStats(this.env, login), + fetchGittensorContributorSnapshot(login), + ]); + const repoStats = authoritativeContributorRepoStats(gittensorSnapshot, cachedRepoStats); + const profile = buildContributorProfile(login, github, contributorPullRequests, contributorIssues, repoStats, gittensorSnapshot); + const outcomeHistory = buildContributorOutcomeHistory({ + login, + profile, + repositories, + pullRequests: contributorPullRequests, + issues: contributorIssues, + repoStats, + }); + return { + profile, + repositories, + syncStates, + repoStats, + outcomeHistory, + }; + } + + private async loadRepoDataQuality(fullName: string) { + const [syncStates, syncSegments] = await Promise.all([listRepoSyncStates(this.env), listRepoSyncSegments(this.env, fullName)]); + return buildRepoDataQuality( + fullName, + syncStates.find((state) => state.repoFullName === fullName), + syncSegments, + ); + } + private toolResult(payload: ToolPayload) { + const data = redactSensitiveForMcp(payload.data) as Record; return { content: [ { type: "text" as const, - text: `${payload.summary}\n\n${JSON.stringify(payload.data, null, 2)}`, + text: `${payload.summary}\n\n${JSON.stringify(data, null, 2)}`, }, ], - structuredContent: payload.data, + structuredContent: data, }; } } +function redactSensitiveForMcp(value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => redactSensitiveForMcp(item)); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .filter(([key]) => !/hotkey|coldkey|wallet|private_key|privateKey|mnemonic/i.test(key)) + .map(([key, entry]) => [key, redactSensitiveForMcp(entry)]), + ); +} + +function authoritativeContributorRepoStats( + gittensorSnapshot: Awaited>, + cachedRepoStats: Awaited>, +) { + const officialRepoStats = contributorRepoStatsFromGittensor(gittensorSnapshot); + return officialRepoStats.length > 0 ? officialRepoStats : cachedRepoStats; +} + async function isAuthorizedMcpRequest(c: AppContext): Promise { - const expected = c.env.GITTENSORY_MCP_TOKEN; - if (!expected) return false; - const actual = c.req.header("authorization")?.replace(/^Bearer\s+/i, ""); - if (!actual) return false; - return actual === expected; + return Boolean(await authenticatePrivateToken(c.env, extractBearerToken(c.req.header("authorization")))); } function getExecutionContext(c: AppContext): ExecutionContext { diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 70bf51cd2e..c5db6fafe9 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -148,6 +148,12 @@ export const QueueHealthSchema = z stalePullRequests: z.number(), maintainerAuthoredPullRequests: z.number(), collisionClusters: z.number(), + ageBuckets: z.object({ + under7Days: z.number(), + days7To30: z.number(), + over30Days: z.number(), + }), + likelyReviewablePullRequests: z.number(), }), findings: z.array(FindingSchema), }) @@ -167,6 +173,27 @@ export const ConfigQualitySchema = z }) .openapi("ConfigQuality"); +export const LabelAuditSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + configuredLabels: z.array(z.string()), + liveLabels: z.array(z.string()), + observedLabels: z.array( + z.object({ + name: z.string(), + count: z.number(), + configured: z.boolean(), + existsOnGitHub: z.boolean(), + }), + ), + missingConfiguredLabels: z.array(z.string()), + suspiciousConfiguredLabels: z.array(z.string()), + trustedPipelineReady: z.boolean(), + findings: z.array(FindingSchema), + }) + .openapi("LabelAudit"); + export const ContributorProfileSchema = z .object({ login: z.string(), @@ -183,6 +210,57 @@ export const ContributorProfileSchema = z topLanguages: z.array(z.string()), source: z.enum(["github", "unavailable"]), }), + source: z.enum(["gittensor_api", "github_cache"]), + gittensor: z + .object({ + githubId: z.string(), + githubUsername: z.string(), + uid: z.number().optional(), + hotkey: z.string().optional(), + evaluatedAt: z.string().optional(), + updatedAt: z.string().optional(), + isEligible: z.boolean(), + credibility: z.number(), + eligibleRepoCount: z.number(), + issueDiscoveryScore: z.number(), + issueTokenScore: z.number(), + issueCredibility: z.number(), + isIssueEligible: z.boolean(), + issueEligibleRepoCount: z.number(), + alphaPerDay: z.number(), + taoPerDay: z.number(), + usdPerDay: z.number(), + totals: z.object({ + pullRequests: z.number(), + mergedPullRequests: z.number(), + openPullRequests: z.number(), + closedPullRequests: z.number(), + openIssues: z.number(), + closedIssues: z.number(), + solvedIssues: z.number(), + validSolvedIssues: z.number(), + }), + repositories: z.array( + z.object({ + repoFullName: z.string(), + pullRequests: z.number(), + mergedPullRequests: z.number(), + openPullRequests: z.number(), + closedPullRequests: z.number(), + openIssues: z.number(), + closedIssues: z.number(), + solvedIssues: z.number(), + validSolvedIssues: z.number(), + isEligible: z.boolean(), + isIssueEligible: z.boolean(), + credibility: z.number(), + issueCredibility: z.number(), + totalScore: z.number(), + baseTotalScore: z.number(), + }), + ), + }) + .optional(), registeredRepoActivity: z.object({ pullRequests: z.number(), mergedPullRequests: z.number(), @@ -219,6 +297,38 @@ export const ContributorOpportunitiesResponseSchema = z }) .openapi("ContributorOpportunitiesResponse"); +export const ContributorFitSchema = z + .object({ + login: z.string(), + generatedAt: z.string(), + profile: ContributorProfileSchema, + summary: z.string(), + languageFit: z.array( + z.object({ + repoFullName: z.string(), + language: z.string().nullable().optional(), + match: z.boolean(), + }), + ), + repoStats: z.array( + z.object({ + login: z.string(), + repoFullName: z.string(), + pullRequests: z.number(), + mergedPullRequests: z.number(), + openPullRequests: z.number(), + issues: z.number(), + stalePullRequests: z.number(), + unlinkedPullRequests: z.number(), + dominantLabels: z.array(z.string()), + lastActivityAt: z.string().nullable().optional(), + }), + ), + opportunities: z.array(ContributorOpportunitySchema), + findings: z.array(FindingSchema), + }) + .openapi("ContributorFit"); + export const PreflightResultSchema = z .object({ repoFullName: z.string(), @@ -232,6 +342,17 @@ export const PreflightResultSchema = z }) .openapi("PreflightResult"); +export const LocalDiffPreflightResultSchema = PreflightResultSchema.extend({ + localDiff: z.object({ + changedFileCount: z.number(), + changedLineCount: z.number(), + testFileCount: z.number(), + codeFileCount: z.number(), + inferredLinkedIssues: z.array(z.number()), + summary: z.string(), + }), +}).openapi("LocalDiffPreflightResult"); + export const MaintainerPacketSchema = z .object({ repoFullName: z.string(), @@ -252,6 +373,35 @@ export const MaintainerPacketSchema = z }) .openapi("MaintainerPacket"); +export const PullRequestMaintainerPacketSchema = z + .object({ + repoFullName: z.string(), + pullNumber: z.number(), + generatedAt: z.string(), + reviewPriority: z.enum(["review", "needs_author", "watch"]), + summary: z.string(), + changeSummary: z.object({ + fileCount: z.number(), + codeFileCount: z.number(), + testFileCount: z.number(), + additions: z.number(), + deletions: z.number(), + topPaths: z.array(z.string()), + }), + reviewSignals: z.object({ + reviewCount: z.number(), + approvalCount: z.number(), + changeRequestCount: z.number(), + checkFailureCount: z.number(), + linkedIssues: z.array(z.number()), + collisionClusters: z.number(), + }), + findings: z.array(FindingSchema), + contributorNextSteps: z.array(z.string()), + maintainerNotes: z.array(z.string()), + }) + .openapi("PullRequestMaintainerPacket"); + export const BountySchema = z .object({ id: z.string(), @@ -285,11 +435,673 @@ export const RepositorySettingsSchema = z commentMode: z.enum(["off", "detected_contributors_only", "all_prs"]), publicSignalLevel: z.enum(["minimal", "standard"]), checkRunMode: z.enum(["enabled"]), + checkRunDetailLevel: z.enum(["minimal", "standard", "deep"]), + backfillEnabled: z.boolean(), + privateTrustEnabled: z.boolean(), createdAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(), }) .openapi("RepositorySettings"); +export const RepoSyncStateSchema = z + .object({ + repoFullName: z.string(), + status: z.enum(["never_synced", "running", "success", "partial", "error", "skipped", "capped", "rate_limited", "stale"]), + sourceKind: z.enum(["github", "installation", "test"]), + primaryLanguage: z.string().nullable().optional(), + defaultBranch: z.string().nullable().optional(), + isPrivate: z.boolean().nullable().optional(), + openIssuesCount: z.number(), + openPullRequestsCount: z.number(), + recentMergedPullRequestsCount: z.number(), + labelsSyncedAt: z.string().nullable().optional(), + issuesSyncedAt: z.string().nullable().optional(), + pullRequestsSyncedAt: z.string().nullable().optional(), + mergedPullRequestsSyncedAt: z.string().nullable().optional(), + lastStartedAt: z.string().nullable().optional(), + lastCompletedAt: z.string().nullable().optional(), + errorSummary: z.string().nullable().optional(), + warnings: z.array(z.string()), + updatedAt: z.string().nullable().optional(), + }) + .openapi("RepoSyncState"); + +export const RepoSyncSegmentSchema = z + .object({ + repoFullName: z.string(), + segment: z.enum(["metadata", "labels", "open_issues", "open_pull_requests", "recent_merged_pull_requests", "pull_request_files", "pull_request_reviews", "check_summaries"]), + status: z.enum([ + "never_synced", + "running", + "refreshing", + "complete", + "partial", + "capped", + "sampled", + "stale", + "rate_limited", + "waiting_rate_limit", + "error", + "skipped", + "not_modified", + ]), + sourceKind: z.enum(["github", "installation", "test"]), + mode: z.enum(["light", "full", "resume"]), + lastCursor: z.string().nullable().optional(), + nextCursor: z.string().nullable().optional(), + fetchedCount: z.number(), + expectedCount: z.number().nullable().optional(), + pageCount: z.number(), + startedAt: z.string().nullable().optional(), + completedAt: z.string().nullable().optional(), + staleAt: z.string().nullable().optional(), + rateLimitResetAt: z.string().nullable().optional(), + warnings: z.array(z.string()), + errorSummary: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), + cursor: z.string().nullable().optional(), + coveragePercent: z.number().nullable().optional(), + isRequired: z.boolean().optional(), + }) + .openapi("RepoSyncSegment"); + +export const GitHubRateLimitObservationSchema = z + .object({ + id: z.string().optional(), + repoFullName: z.string().nullable().optional(), + resource: z.enum(["rest", "graphql"]), + path: z.string(), + statusCode: z.number(), + limitValue: z.number().nullable().optional(), + remaining: z.number().nullable().optional(), + resetAt: z.string().nullable().optional(), + observedAt: z.string().nullable().optional(), + }) + .openapi("GitHubRateLimitObservation"); + +export const SignalFidelitySchema = z + .object({ + status: z.enum(["complete", "degraded", "blocked", "unknown"]), + repoCount: z.number(), + completeRepos: z.number(), + degradedRepos: z.number(), + blockedRepos: z.number(), + partialRepos: z.array(z.string()), + cappedRepos: z.array(z.string()), + staleRepos: z.array(z.string()), + rateLimitedRepos: z.array(z.string()), + nextRecoverableAt: z.string().nullable().optional(), + }) + .openapi("SignalFidelity"); + +export const CoreSignalFidelitySchema = z + .object({ + status: z.enum(["complete", "degraded", "blocked", "unknown"]), + repoCount: z.number(), + completeRepos: z.number(), + degradedRepos: z.number(), + blockedRepos: z.number(), + incompleteRepos: z.array(z.string()), + refreshingRepos: z.array(z.string()), + waitingForRateLimitRepos: z.array(z.string()), + historyCoverage: z.enum(["sampled", "counts_only", "full"]), + }) + .openapi("CoreSignalFidelity"); + +export const RepoGithubTotalsSnapshotSchema = z + .object({ + id: z.string(), + repoFullName: z.string(), + openIssuesTotal: z.number(), + openPullRequestsTotal: z.number(), + mergedPullRequestsTotal: z.number(), + closedUnmergedPullRequestsTotal: z.number(), + labelsTotal: z.number(), + sourceKind: z.enum(["github", "installation", "test"]), + fetchedAt: z.string(), + rateLimitRemaining: z.number().nullable().optional(), + rateLimitResetAt: z.string().nullable().optional(), + payload: z.record(z.unknown()).optional(), + }) + .openapi("RepoGithubTotalsSnapshot"); + +export const InstallationHealthSchema = z + .object({ + installationId: z.number(), + accountLogin: z.string(), + repositorySelection: z.string().nullable().optional(), + installedReposCount: z.number(), + registeredInstalledCount: z.number(), + status: z.enum(["healthy", "needs_attention", "broken"]), + missingPermissions: z.array(z.string()), + missingEvents: z.array(z.string()), + permissions: z.record(z.string()), + events: z.array(z.string()), + checkedAt: z.string(), + errorSummary: z.string().nullable().optional(), + }) + .openapi("InstallationHealth"); + +export const SyncStatusSchema = z + .object({ + generatedAt: z.string(), + signalFidelity: SignalFidelitySchema, + coreSignalFidelity: CoreSignalFidelitySchema, + historyCoverage: z.enum(["sampled", "counts_only", "full"]), + refreshingRepos: z.array(z.string()), + waitingForRateLimitRepos: z.array(z.string()), + repositories: z.array(RepoSyncStateSchema), + segments: z.array(RepoSyncSegmentSchema), + githubTotals: z.array(RepoGithubTotalsSnapshotSchema), + pullRequestDetailSync: z.array(z.record(z.unknown())), + installations: z.array(InstallationHealthSchema), + rateLimits: z.array(GitHubRateLimitObservationSchema), + }) + .openapi("SyncStatus"); + +export const ReadinessSchema = z + .object({ + status: z.enum(["ready", "needs_attention"]), + generatedAt: z.string(), + ready: z.boolean(), + readyForPublicReview: z.boolean(), + signalFidelity: SignalFidelitySchema, + coreSignalFidelity: CoreSignalFidelitySchema, + historyCoverage: z.enum(["sampled", "counts_only", "full"]), + partialRepos: z.array(z.string()), + cappedRepos: z.array(z.string()), + staleRepos: z.array(z.string()), + rateLimitedRepos: z.array(z.string()), + refreshingRepos: z.array(z.string()), + waitingForRateLimitRepos: z.array(z.string()), + nextRecoverableAt: z.string().nullable().optional(), + registry: z + .object({ + snapshotId: z.string(), + repoCount: z.number(), + totalEmissionShare: z.number(), + source: z.object({ kind: z.string(), url: z.string() }), + warningCount: z.number(), + }) + .nullable(), + scoringModel: z + .object({ + snapshotId: z.string(), + activeModel: z.enum(["current_density_model", "pending_saturation_model", "unknown"]), + sourceKind: z.string(), + fetchedAt: z.string(), + warningCount: z.number(), + }) + .nullable(), + githubBackfill: z.object({ + repoSyncCount: z.number(), + statusCounts: z.record(z.number()), + failingSyncs: z.array( + z.object({ + repoFullName: z.string(), + errorSummary: z.string().nullable().optional(), + lastCompletedAt: z.string().nullable().optional(), + }), + ), + incompleteSyncs: z.array( + z.object({ + repoFullName: z.string(), + status: z.enum(["never_synced", "running", "skipped"]), + lastCompletedAt: z.string().nullable().optional(), + }), + ), + segmentCount: z.number(), + segments: z.array(RepoSyncSegmentSchema), + githubTotals: z.array(RepoGithubTotalsSnapshotSchema), + pullRequestDetailSyncCount: z.number(), + cappedSegments: z.array(z.object({ repoFullName: z.string(), segment: z.string(), nextCursor: z.string().nullable().optional() })), + rateLimitedSegments: z.array(z.object({ repoFullName: z.string(), segment: z.string(), rateLimitResetAt: z.string().nullable().optional() })), + latestRateLimits: z.array(GitHubRateLimitObservationSchema), + }), + installations: z.object({ + count: z.number(), + healthCount: z.number(), + unhealthyCount: z.number(), + }), + secrets: z.object({ + githubAppPrivateKey: z.boolean(), + githubWebhookSecret: z.boolean(), + githubPublicToken: z.boolean(), + apiToken: z.boolean(), + mcpToken: z.boolean(), + internalJobToken: z.boolean(), + }), + warnings: z.array(z.string()), + }) + .openapi("Readiness"); + +export const ScoringModelSnapshotSchema = z + .object({ + id: z.string(), + sourceKind: z.enum(["raw-github", "api", "fallback", "test"]), + sourceUrl: z.string(), + fetchedAt: z.string(), + activeModel: z.enum(["current_density_model", "pending_saturation_model", "unknown"]), + constants: z.record(z.number()), + programmingLanguages: z.record(z.unknown()), + registrySnapshotId: z.string().nullable().optional(), + warnings: z.array(z.string()), + payload: z.record(z.unknown()), + }) + .openapi("ScoringModelSnapshot"); + +export const ScorePreviewSchema = z + .object({ + id: z.string(), + scoringModelSnapshotId: z.string(), + repoFullName: z.string(), + targetType: z.enum(["planned_pr", "pull_request", "local_diff", "variant"]), + targetKey: z.string(), + contributorLogin: z.string().nullable().optional(), + input: z.record(z.unknown()), + result: z.record(z.unknown()), + generatedAt: z.string(), + }) + .openapi("ScorePreview"); + +export const IssueQualityReportSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + lane: LaneAdviceSchema, + issues: z.array( + z.object({ + number: z.number(), + title: z.string(), + status: z.enum(["ready", "needs_proof", "hold", "do_not_use"]), + score: z.number(), + reasons: z.array(z.string()), + warnings: z.array(z.string()), + }), + ), + summary: z.string(), + }) + .openapi("IssueQualityReport"); + +export const BurdenForecastSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + horizonDays: z.union([z.literal(7), z.literal(30)]), + level: z.enum(["low", "medium", "high", "critical"]), + forecast: z.record(z.number()), + findings: z.array(FindingSchema), + summary: z.string(), + }) + .openapi("BurdenForecast"); + +export const ContributorScoringProfileSchema = z + .object({ + login: z.string(), + generatedAt: z.string(), + scoringModelSnapshotId: z.string(), + evidence: z.record(z.number()), + privateSignals: z.array(z.string()), + }) + .openapi("ContributorScoringProfile"); + +export const RoleContextSchema = z + .object({ + login: z.string(), + repoFullName: z.string(), + generatedAt: z.string(), + role: z.enum(["outside_contributor", "repo_maintainer", "org_member", "collaborator", "owner", "unknown"]), + maintainerLane: z.boolean(), + normalContributorEvidenceAllowed: z.boolean(), + source: z.enum(["github_association", "repo_owner_match", "gittensor_api", "cache", "unknown"]), + association: z.string().nullable().optional(), + reasons: z.array(z.string()), + guidance: z.string(), + }) + .openapi("RoleContext"); + +export const ContributorOutcomeHistorySchema = z + .object({ + login: z.string(), + generatedAt: z.string(), + source: z.enum(["gittensor_api", "github_cache"]), + totals: z.record(z.number()), + repoOutcomes: z.array(z.record(z.unknown())), + successPatterns: z.array(z.record(z.unknown())), + failurePatterns: z.array(z.record(z.unknown())), + summary: z.string(), + }) + .openapi("ContributorOutcomeHistory"); + +export const ContributorPatternReportSchema = z + .object({ + login: z.string(), + generatedAt: z.string(), + patternType: z.enum(["success", "failure"]), + patterns: z.array(z.record(z.unknown())), + summary: z.string(), + }) + .openapi("ContributorPatternReport"); + +export const RepoFitRecommendationSchema = z + .object({ + login: z.string(), + repoFullName: z.string(), + generatedAt: z.string(), + roleContext: RoleContextSchema, + lane: LaneAdviceSchema, + recommendation: z.enum(["pursue", "cleanup_first", "maintainer_lane", "avoid_for_now", "unknown"]), + confidence: z.enum(["high", "medium", "low"]), + reasons: z.array(z.string()), + risks: z.array(z.string()), + nextActions: z.array(z.string()), + rewardRisk: z.record(z.unknown()).optional(), + reasoning: z.array(z.string()).optional(), + actionImpact: z.record(z.unknown()).optional(), + }) + .openapi("RepoFitRecommendation"); + +export const ContributorIntakeHealthSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + level: z.enum(["healthy", "watch", "strained", "blocked"]), + score: z.number(), + queueHealth: z.record(z.unknown()), + configLevel: z.enum(["excellent", "good", "needs_attention", "fragile"]), + duplicateClusters: z.number(), + reviewablePullRequests: z.number(), + summary: z.string(), + findings: z.array(FindingSchema), + }) + .openapi("ContributorIntakeHealth"); + +export const MaintainerCutReadinessSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + ready: z.boolean(), + maintainerCut: z.number(), + recommendedAction: z.enum(["leave_disabled", "consider_small_cut", "review_existing_cut", "fix_config_first"]), + reasons: z.array(z.string()), + warnings: z.array(z.string()), + }) + .openapi("MaintainerCutReadiness"); + +export const MaintainerLaneReportSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + lane: LaneAdviceSchema, + maintainerCut: z.number(), + maintainerCutConfigured: z.boolean(), + queueHealth: QueueHealthSchema, + configQuality: ConfigQualitySchema, + contributorIntakeHealth: ContributorIntakeHealthSchema, + summary: z.string(), + findings: z.array(FindingSchema), + }) + .openapi("MaintainerLaneReport"); + +export const PullRequestReviewIntelligenceSchema = PullRequestMaintainerPacketSchema.extend({ + roleContext: RoleContextSchema, + outcomeContext: z.record(z.unknown()).optional(), + recommendation: z.enum(["review", "needs_author", "watch", "likely_duplicate", "maintainer_lane"]), + privateSummary: z.string(), + reviewability: z.record(z.unknown()).optional(), +}).openapi("PullRequestReviewIntelligence"); + +export const ContributorStrategySchema = z + .object({ + login: z.string(), + generatedAt: z.string(), + scoringModelSnapshotId: z.string(), + summary: z.string(), + bestFitRepos: z.array(z.record(z.unknown())), + avoidRepos: z.array(z.record(z.unknown())), + cleanupFirst: z.array(z.record(z.unknown())), + maintainerLaneRepos: z.array(z.record(z.unknown())), + successPatterns: z.array(z.record(z.unknown())), + failurePatterns: z.array(z.record(z.unknown())), + laneWarnings: z.array(z.string()), + nextActions: z.array(z.string()), + rewardRisk: z.record(z.unknown()).optional(), + reasoning: z.array(z.string()).optional(), + actionImpact: z.array(z.string()).optional(), + }) + .openapi("ContributorStrategy"); + +export const ContributorDecisionPackSchema = z + .object({ + status: z.enum(["ready"]), + source: z.enum(["computed", "snapshot"]), + login: z.string(), + generatedAt: z.string(), + snapshotAgeSeconds: z.number().optional(), + stale: z.boolean(), + scoringModelSnapshotId: z.string(), + profile: z.record(z.unknown()), + outcomeHistory: ContributorOutcomeHistorySchema, + roleContexts: z.array(RoleContextSchema), + repoDecisions: z.array(z.record(z.unknown())), + topActions: z.array(z.record(z.unknown())), + cleanupFirst: z.array(z.record(z.unknown())), + pursueRepos: z.array(z.record(z.unknown())), + avoidRepos: z.array(z.record(z.unknown())), + maintainerLaneRepos: z.array(z.record(z.unknown())), + scoreBlockers: z.array(z.record(z.unknown())), + dataQuality: z.record(z.unknown()), + summary: z.string(), + nextActions: z.array(z.string()), + }) + .openapi("ContributorDecisionPack"); + +export const DecisionPackRefreshNeededSchema = z + .object({ + status: z.enum(["needs_snapshot_refresh"]), + login: z.string(), + repoFullName: z.string().optional(), + generatedAt: z.string(), + reason: z.enum(["missing_snapshot", "stale_snapshot"]), + enqueued: z.boolean(), + staleSnapshot: z.object({ generatedAt: z.string(), ageSeconds: z.number() }).optional(), + dataQuality: z.record(z.unknown()).optional(), + }) + .openapi("DecisionPackRefreshNeeded"); + +export const RepoDecisionResponseSchema = z + .object({ + status: z.enum(["ready"]), + login: z.string(), + repoFullName: z.string(), + generatedAt: z.string(), + source: z.enum(["computed", "snapshot"]), + decision: z.record(z.unknown()), + dataQuality: z.record(z.unknown()), + }) + .openapi("RepoDecisionResponse"); + +export const RepoIntelligenceSchema = z + .object({ + status: z.enum(["ready"]), + source: z.enum(["computed", "snapshot"]), + repoFullName: z.string(), + generatedAt: z.string(), + repo: RepositorySchema.nullable(), + lane: LaneAdviceSchema, + queueHealth: z.record(z.unknown()).nullable().optional(), + collisions: z.record(z.unknown()).optional(), + configQuality: z.record(z.unknown()).nullable().optional(), + labelAudit: z.record(z.unknown()).nullable().optional(), + maintainerLane: z.record(z.unknown()).nullable().optional(), + maintainerCutReadiness: z.record(z.unknown()).nullable().optional(), + contributorIntakeHealth: z.record(z.unknown()).nullable().optional(), + dataQuality: z.record(z.unknown()), + }) + .openapi("RepoIntelligence"); + +export const RewardRiskActionSchema = z + .object({ + actionKind: z.enum([ + "cleanup_existing_prs", + "land_existing_prs", + "close_or_withdraw_low_fit_prs", + "open_new_direct_pr", + "file_issue_discovery", + "maintainer_lane_improve_repo", + "maintainer_cut_readiness", + ]), + repoFullName: z.string(), + priorityScore: z.number(), + laneValueScore: z.number(), + scoreabilityScore: z.number(), + personalFitScore: z.number(), + riskPenalty: z.number(), + maintainerFrictionPenalty: z.number(), + actionLeverageScore: z.number(), + whyThisHelps: z.array(z.string()), + nextActions: z.array(z.string()), + }) + .openapi("RewardRiskAction"); + +export const RepoRewardRiskSchema = z + .object({ + login: z.string(), + repoFullName: z.string(), + generatedAt: z.string(), + roleContext: RoleContextSchema, + lane: LaneAdviceSchema, + recommendation: z.enum(["pursue", "cleanup_first", "maintainer_lane", "avoid_for_now", "unknown"]), + rewardUpside: z.object({ + relevantLane: z.enum(["direct_pr", "issue_discovery", "maintainer_lane", "none"]), + repoSlice: z.number(), + directPrSlice: z.number(), + issueDiscoverySlice: z.number(), + maintainerCutSlice: z.number(), + labelMultiplier: z.number(), + issueMultiplier: z.number(), + estimatedScoreIfClean: z.number(), + currentEstimatedScore: z.number(), + }), + scoreBlockers: z.array(z.string()), + riskBreakdown: z.object({ + queueBurden: z.enum(["low", "medium", "high", "critical"]), + queueBurdenScore: z.number(), + duplicateClusters: z.number(), + highRiskDuplicateClusters: z.number(), + closedPullRequestRate: z.number(), + openPullRequests: z.number(), + credibility: z.number(), + reviewChurnRisk: z.enum(["low", "medium", "high"]), + }), + actionImpact: z.record(z.unknown()), + currentPreview: z.record(z.unknown()), + afterCleanupPreview: z.record(z.unknown()), + actions: z.array(RewardRiskActionSchema), + whyThisHelps: z.array(z.string()), + nextActions: z.array(z.string()), + summary: z.string(), + }) + .openapi("RepoRewardRisk"); + +export const LocalBranchAnalysisSchema = z + .object({ + login: z.string(), + repoFullName: z.string(), + generatedAt: z.string(), + baseRef: z.string().optional(), + headRef: z.string().optional(), + branchName: z.string().optional(), + lane: LaneAdviceSchema, + roleContext: RoleContextSchema, + preflight: LocalDiffPreflightResultSchema, + scorePreview: ScorePreviewSchema, + rewardRisk: RepoRewardRiskSchema, + scoreBlockers: z.array(z.string()), + localFindings: z.array(FindingSchema), + maintainerFit: z.object({ + recommendation: z.enum(["pursue", "cleanup_first", "maintainer_lane", "avoid_for_now", "unknown"]), + reviewBurden: z.enum(["low", "medium", "high"]), + role: z.enum(["outside_contributor", "repo_maintainer", "org_member", "collaborator", "owner", "unknown"]), + maintainerLane: z.boolean(), + reasons: z.array(z.string()), + risks: z.array(z.string()), + }), + prPacket: z.object({ + titleSuggestion: z.string(), + bodySections: z.array(z.object({ heading: z.string(), lines: z.array(z.string()) })), + reviewerNotes: z.array(z.string()), + validationSummary: z.object({ + passed: z.number(), + failed: z.number(), + notRun: z.number(), + commands: z.array(z.object({ command: z.string(), status: z.enum(["passed", "failed", "not_run"]), summary: z.string().optional() })), + }), + publicSafeWarnings: z.array(z.string()), + }), + nextActions: z.array(RewardRiskActionSchema), + summary: z.string(), + }) + .openapi("LocalBranchAnalysis"); + +export const ContributorRewardRiskStrategySchema = z + .object({ + login: z.string(), + generatedAt: z.string(), + scoringModelSnapshotId: z.string(), + summary: z.string(), + topActions: z.array(RewardRiskActionSchema), + repoAnalyses: z.array(RepoRewardRiskSchema), + reasoning: z.array(z.string()), + actionImpact: z.array(z.string()), + nextActions: z.array(z.string()), + }) + .openapi("ContributorRewardRiskStrategy"); + +export const MaintainerNoiseReportSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + score: z.number(), + level: z.enum(["low", "medium", "high", "critical"]), + noiseSources: z.array(z.string()), + maintainerActions: z.array(z.enum(["review_now", "needs_author", "likely_duplicate", "close_or_redirect", "watch", "maintainer_lane"])), + queueHealth: QueueHealthSchema, + summary: z.string(), + }) + .openapi("MaintainerNoiseReport"); + +export const PullRequestReviewabilitySchema = z + .object({ + repoFullName: z.string(), + pullNumber: z.number(), + generatedAt: z.string(), + score: z.number(), + action: z.enum(["review_now", "needs_author", "likely_duplicate", "close_or_redirect", "watch", "maintainer_lane"]), + noiseSources: z.array(z.string()), + whyThisHelps: z.array(z.string()), + maintainerNextSteps: z.array(z.string()), + privateSummary: z.string(), + }) + .openapi("PullRequestReviewability"); + +export const RegistryChangeReportSchema = z + .object({ + generatedAt: z.string(), + currentSnapshotId: z.string().optional(), + previousSnapshotId: z.string().optional(), + addedRepos: z.array(z.string()), + removedRepos: z.array(z.string()), + changedRepos: z.array( + z.object({ + repoFullName: z.string(), + changes: z.array(z.string()), + }), + ), + summary: z.string(), + }) + .openapi("RegistryChangeReport"); + export const HealthSchema = z .object({ status: z.literal("ok"), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index d09b92b133..37a30d64d4 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -1,20 +1,58 @@ import { OpenApiGeneratorV3, OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; +import { z } from "zod"; import { AdvisorySchema, BountyAdvisorySchema, BountySchema, + BurdenForecastSchema, CollisionReportSchema, ConfigQualitySchema, + ContributorFitSchema, + ContributorIntakeHealthSchema, + ContributorOutcomeHistorySchema, ContributorOpportunitiesResponseSchema, ContributorOpportunitySchema, + ContributorPatternReportSchema, + ContributorDecisionPackSchema, + ContributorRewardRiskStrategySchema, ContributorProfileSchema, + ContributorScoringProfileSchema, + ContributorStrategySchema, HealthSchema, + InstallationHealthSchema, + IssueQualityReportSchema, + LabelAuditSchema, + LaneAdviceSchema, + LocalBranchAnalysisSchema, + LocalDiffPreflightResultSchema, MaintainerPacketSchema, + MaintainerCutReadinessSchema, + MaintainerLaneReportSchema, + MaintainerNoiseReportSchema, + PullRequestMaintainerPacketSchema, + PullRequestReviewIntelligenceSchema, + PullRequestReviewabilitySchema, PreflightResultSchema, QueueHealthSchema, + ReadinessSchema, + RegistryChangeReportSchema, + DecisionPackRefreshNeededSchema, + RepoFitRecommendationSchema, + RepoDecisionResponseSchema, + RepoIntelligenceSchema, + RepoRewardRiskSchema, RegistrySnapshotSchema, + GitHubRateLimitObservationSchema, + RepoSyncSegmentSchema, + RepoSyncStateSchema, RepositorySchema, RepositorySettingsSchema, + RoleContextSchema, + RewardRiskActionSchema, + ScorePreviewSchema, + ScoringModelSnapshotSchema, + SignalFidelitySchema, + SyncStatusSchema, WorkboardItemSchema, } from "./schemas"; @@ -28,14 +66,51 @@ export function buildOpenApiSpec() { registry.register("QueueHealth", QueueHealthSchema); registry.register("CollisionReport", CollisionReportSchema); registry.register("ConfigQuality", ConfigQualitySchema); + registry.register("LabelAudit", LabelAuditSchema); registry.register("ContributorProfile", ContributorProfileSchema); registry.register("ContributorOpportunity", ContributorOpportunitySchema); registry.register("ContributorOpportunitiesResponse", ContributorOpportunitiesResponseSchema); + registry.register("ContributorFit", ContributorFitSchema); + registry.register("RoleContext", RoleContextSchema); + registry.register("ContributorOutcomeHistory", ContributorOutcomeHistorySchema); + registry.register("ContributorPatternReport", ContributorPatternReportSchema); + registry.register("ContributorDecisionPack", ContributorDecisionPackSchema); + registry.register("DecisionPackRefreshNeeded", DecisionPackRefreshNeededSchema); + registry.register("RepoDecisionResponse", RepoDecisionResponseSchema); + registry.register("RepoIntelligence", RepoIntelligenceSchema); + registry.register("RepoFitRecommendation", RepoFitRecommendationSchema); registry.register("PreflightResult", PreflightResultSchema); + registry.register("LocalDiffPreflightResult", LocalDiffPreflightResultSchema); + registry.register("LocalBranchAnalysis", LocalBranchAnalysisSchema); registry.register("MaintainerPacket", MaintainerPacketSchema); + registry.register("MaintainerLaneReport", MaintainerLaneReportSchema); + registry.register("MaintainerCutReadiness", MaintainerCutReadinessSchema); + registry.register("ContributorIntakeHealth", ContributorIntakeHealthSchema); + registry.register("PullRequestMaintainerPacket", PullRequestMaintainerPacketSchema); + registry.register("PullRequestReviewIntelligence", PullRequestReviewIntelligenceSchema); registry.register("Bounty", BountySchema); registry.register("BountyAdvisory", BountyAdvisorySchema); registry.register("RepositorySettings", RepositorySettingsSchema); + registry.register("RepoSyncState", RepoSyncStateSchema); + registry.register("RepoSyncSegment", RepoSyncSegmentSchema); + registry.register("GitHubRateLimitObservation", GitHubRateLimitObservationSchema); + registry.register("SignalFidelity", SignalFidelitySchema); + registry.register("InstallationHealth", InstallationHealthSchema); + registry.register("SyncStatus", SyncStatusSchema); + registry.register("Readiness", ReadinessSchema); + registry.register("RegistryChangeReport", RegistryChangeReportSchema); + registry.register("LaneAdvice", LaneAdviceSchema); + registry.register("ScoringModelSnapshot", ScoringModelSnapshotSchema); + registry.register("ScorePreview", ScorePreviewSchema); + registry.register("IssueQualityReport", IssueQualityReportSchema); + registry.register("BurdenForecast", BurdenForecastSchema); + registry.register("ContributorScoringProfile", ContributorScoringProfileSchema); + registry.register("ContributorStrategy", ContributorStrategySchema); + registry.register("RewardRiskAction", RewardRiskActionSchema); + registry.register("RepoRewardRisk", RepoRewardRiskSchema); + registry.register("ContributorRewardRiskStrategy", ContributorRewardRiskStrategySchema); + registry.register("MaintainerNoiseReport", MaintainerNoiseReportSchema); + registry.register("PullRequestReviewability", PullRequestReviewabilitySchema); registry.registerPath({ method: "get", @@ -53,58 +128,85 @@ export function buildOpenApiSpec() { }); registry.registerPath({ method: "get", - path: "/v1/repos", + path: "/v1/registry/changes", responses: { - 200: { description: "Known repositories", content: { "application/json": { schema: RepositorySchema.array() } } }, + 200: { description: "Diff between latest registry snapshots", content: { "application/json": { schema: RegistryChangeReportSchema } } }, }, }); registry.registerPath({ method: "get", - path: "/v1/repos/{owner}/{repo}", + path: "/v1/scoring/model", responses: { - 200: { description: "Repository detail", content: { "application/json": { schema: RepositorySchema } } }, - 404: { description: "Repository not found" }, + 200: { description: "Latest private scoring model snapshot", content: { "application/json": { schema: ScoringModelSnapshotSchema } } }, }, }); - for (const path of [ - "/v1/repos/{owner}/{repo}/advisory", - "/v1/repos/{owner}/{repo}/pulls/{number}/advisory", - "/v1/repos/{owner}/{repo}/issues/{number}/advisory", - ]) { - registry.registerPath({ - method: "get", - path, - responses: { - 200: { description: "Generated advisory", content: { "application/json": { schema: AdvisorySchema } } }, + registry.registerPath({ + method: "post", + path: "/v1/scoring/preview", + responses: { + 200: { description: "Private scoring preview artifact", content: { "application/json": { schema: ScorePreviewSchema } } }, + 400: { description: "Invalid scoring preview input" }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/sync/status", + responses: { + 200: { description: "Repository and installation sync status", content: { "application/json": { schema: SyncStatusSchema } } }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/readiness", + responses: { + 200: { description: "Operational readiness summary for private beta and public-review preparation", content: { "application/json": { schema: ReadinessSchema } } }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/installations", + responses: { + 200: { + description: "GitHub App installations and health", + content: { + "application/json": { + schema: z.object({ + installations: z.array(z.record(z.unknown())), + health: z.array(InstallationHealthSchema), + }), + }, + }, }, - }); - } + }, + }); registry.registerPath({ method: "get", - path: "/v1/repos/{owner}/{repo}/workboard", + path: "/v1/installations/{id}/health", responses: { - 200: { description: "Contributor workboard", content: { "application/json": { schema: WorkboardItemSchema.array() } } }, + 200: { description: "GitHub App installation health", content: { "application/json": { schema: InstallationHealthSchema } } }, + 404: { description: "Installation health not found" }, }, }); registry.registerPath({ method: "get", - path: "/v1/repos/{owner}/{repo}/queue-health", + path: "/v1/repos", responses: { - 200: { description: "Maintainer burden and queue health signals", content: { "application/json": { schema: QueueHealthSchema } } }, + 200: { description: "Known repositories", content: { "application/json": { schema: RepositorySchema.array() } } }, }, }); registry.registerPath({ method: "get", - path: "/v1/repos/{owner}/{repo}/collisions", + path: "/v1/repos/{owner}/{repo}", responses: { - 200: { description: "Duplicate and WIP collision clusters", content: { "application/json": { schema: CollisionReportSchema } } }, + 200: { description: "Repository detail", content: { "application/json": { schema: RepositorySchema } } }, + 404: { description: "Repository not found" }, }, }); registry.registerPath({ method: "get", - path: "/v1/repos/{owner}/{repo}/config-quality", + path: "/v1/repos/{owner}/{repo}/intelligence", responses: { - 200: { description: "Gittensor repository config quality signals", content: { "application/json": { schema: ConfigQualitySchema } } }, + 200: { description: "Canonical repository intelligence bundle", content: { "application/json": { schema: RepoIntelligenceSchema } } }, }, }); registry.registerPath({ @@ -116,9 +218,16 @@ export function buildOpenApiSpec() { }); registry.registerPath({ method: "get", - path: "/v1/repos/{owner}/{repo}/maintainer-packet", + path: "/v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet", responses: { - 200: { description: "Maintainer-friendly repo review packet", content: { "application/json": { schema: MaintainerPacketSchema } } }, + 200: { description: "PR-specific maintainer review packet", content: { "application/json": { schema: PullRequestMaintainerPacketSchema } } }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/pulls/{number}/reviewability", + responses: { + 200: { description: "Private PR reviewability score and maintainer action", content: { "application/json": { schema: PullRequestReviewabilitySchema } } }, }, }); registry.registerPath({ @@ -130,16 +239,21 @@ export function buildOpenApiSpec() { }); registry.registerPath({ method: "get", - path: "/v1/contributors/{login}/opportunities", + path: "/v1/contributors/{login}/decision-pack", responses: { 200: { - description: "Contributor profile and ranked opportunities", - content: { - "application/json": { - schema: ContributorOpportunitiesResponseSchema, - }, - }, + description: "Canonical private contributor decision pack", + content: { "application/json": { schema: ContributorDecisionPackSchema } }, }, + 202: { description: "Decision pack snapshot is missing or stale", content: { "application/json": { schema: DecisionPackRefreshNeededSchema } } }, + }, + }); + registry.registerPath({ + method: "get", + path: "/v1/contributors/{login}/repos/{owner}/{repo}/decision", + responses: { + 200: { description: "Repo-specific contributor decision from decision pack", content: { "application/json": { schema: RepoDecisionResponseSchema } } }, + 202: { description: "Decision pack snapshot is missing or stale", content: { "application/json": { schema: DecisionPackRefreshNeededSchema } } }, }, }); registry.registerPath({ @@ -150,6 +264,23 @@ export function buildOpenApiSpec() { 400: { description: "Invalid preflight input" }, }, }); + registry.registerPath({ + method: "post", + path: "/v1/preflight/local-diff", + responses: { + 200: { description: "Local diff preflight result", content: { "application/json": { schema: LocalDiffPreflightResultSchema } } }, + 400: { description: "Invalid local diff preflight input" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/local/branch-analysis", + responses: { + 200: { description: "Private local branch analysis for MCP clients", content: { "application/json": { schema: LocalBranchAnalysisSchema } } }, + 400: { description: "Invalid local branch analysis input" }, + 401: { description: "Unauthorized" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/bounties", @@ -173,6 +304,27 @@ export function buildOpenApiSpec() { 401: { description: "Invalid webhook signature" }, }, }); + for (const path of ["/v1/auth/github/device/start", "/v1/auth/github/device/poll", "/v1/auth/github/session", "/v1/auth/logout"]) { + registry.registerPath({ + method: "post", + path, + responses: { + 200: { description: "Auth request completed" }, + 201: { description: "Auth session created" }, + 400: { description: "Invalid auth request" }, + 401: { description: "Unauthorized" }, + 429: { description: "Rate limited" }, + }, + }); + } + registry.registerPath({ + method: "get", + path: "/v1/auth/session", + responses: { + 200: { description: "Current auth session" }, + 401: { description: "Unauthorized" }, + }, + }); registry.registerPath({ method: "post", path: "/v1/internal/jobs/refresh-registry", @@ -181,6 +333,49 @@ export function buildOpenApiSpec() { 401: { description: "Invalid internal token" }, }, }); + registry.registerPath({ + method: "post", + path: "/v1/internal/jobs/backfill-registered-repos", + responses: { + 202: { description: "Registered repo backfill queued" }, + 401: { description: "Invalid internal token" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/internal/jobs/backfill-repo-segment", + responses: { + 202: { description: "Repository segment backfill queued" }, + 400: { description: "Invalid segment request" }, + 401: { description: "Invalid internal token" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/internal/jobs/backfill-pr-details", + responses: { + 202: { description: "Open PR detail backfill queued" }, + 400: { description: "Invalid PR detail backfill request" }, + 401: { description: "Invalid internal token" }, + }, + }); + for (const path of [ + "/v1/internal/jobs/refresh-scoring-model", + "/v1/internal/jobs/build-contributor-evidence", + "/v1/internal/jobs/build-contributor-decision-packs", + "/v1/internal/jobs/build-burden-forecasts", + "/v1/internal/jobs/generate-signal-snapshots", + "/v1/internal/jobs/repair-data-fidelity", + ]) { + registry.registerPath({ + method: "post", + path, + responses: { + 202: { description: "Internal job queued" }, + 401: { description: "Invalid internal token" }, + }, + }); + } registry.registerPath({ method: "post", path: "/v1/internal/bounties/import", diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 348320ddac..d42f5e78d4 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -1,46 +1,411 @@ import { + countOpenIssues, + countOpenPullRequests, + getLatestRepoGithubTotalsSnapshot, getRepository, getRepositorySettings, + listCheckSummaries, + listAllIssues, + listAllPullRequests, listContributorIssues, listContributorPullRequests, + listContributorRepoStats, listIssues, + listIssueSignalSample, listOtherOpenPullRequests, + listOpenPullRequests, + listPullRequestFiles, + listPullRequestReviews, listPullRequests, + listRecentMergedPullRequests, + listRepoLabels, + listRepoSyncStates, + listRepoSyncSegments, + listRepositories, markInstallationDeleted, persistAdvisory, + recordAuditEvent, + persistSignalSnapshot, recordWebhookEvent, + replaceCollisionEdges, + upsertBurdenForecast, + upsertContributorEvidence, + upsertContributorScoringProfile, upsertInstallation, upsertIssueFromGitHub, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, } from "../db/repositories"; +import { + backfillOpenPullRequestDetails, + backfillRegisteredRepositories, + backfillRepositorySegment, + enqueueRepositoryOpenDataBackfill, + refreshContributorActivity, + refreshInstallationHealth, +} from "../github/backfill"; +import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; import { createOrUpdateCheckRun, getInstallationId } from "../github/app"; import { createOrUpdatePrIntelligenceComment } from "../github/comments"; import { fetchPublicContributorProfile } from "../github/public"; import { refreshRegistry } from "../registry/sync"; import { buildIssueAdvisory, buildPullRequestAdvisory } from "../rules/advisory"; +import { getOrCreateScoringModelSnapshot, refreshScoringModelSnapshot } from "../scoring/model"; +import { buildAndPersistContributorDecisionPack } from "../services/decision-pack"; import { + buildBurdenForecast, + buildCollisionEdges, buildCollisionReport, + buildConfigQuality, + buildContributorFit, + buildContributorOutcomeHistory, buildContributorProfile, + buildContributorScoringProfile, + buildContributorStrategy, + buildContributorIntakeHealth, + buildLabelAudit, + buildMaintainerCutReadiness, + buildMaintainerLaneReport, buildPreflightResult, buildPublicPrIntelligenceComment, buildQueueHealth, detectGittensorContributor, shouldPublishPrIntelligenceComment, } from "../signals/engine"; -import type { GitHubWebhookPayload, JobMessage } from "../types"; +import { buildPullRequestReviewability } from "../signals/reward-risk"; +import type { ContributorEvidenceRecord, GitHubWebhookPayload, JobMessage, JsonValue } from "../types"; export async function processJob(env: Env, message: JobMessage): Promise { switch (message.type) { case "refresh-registry": await refreshRegistry(env); return; + case "backfill-registered-repos": + if (!message.repoFullName && message.requestedBy !== "test") { + const repositories = (await listRepositories(env)).filter((repo) => repo.isRegistered); + if (repositories.length > 0) { + const delayStepSeconds = message.mode === "full" || message.mode === "resume" ? 45 : 15; + await Promise.all( + repositories.map((repo, index) => { + const repoMessage: JobMessage = { + type: "backfill-registered-repos", + requestedBy: message.requestedBy, + repoFullName: repo.fullName, + ...(message.force === undefined ? {} : { force: message.force }), + ...(message.mode === undefined ? {} : { mode: message.mode }), + }; + const delaySeconds = Math.min(index * delayStepSeconds, 900); + return delaySeconds > 0 ? env.JOBS.send(repoMessage, { delaySeconds }) : env.JOBS.send(repoMessage); + }), + ); + return; + } + } + if (message.repoFullName && message.requestedBy !== "test") { + await enqueueRepositoryOpenDataBackfill(env, { + repoFullName: message.repoFullName, + requestedBy: message.requestedBy, + ...(message.force === undefined ? {} : { force: message.force }), + ...(message.mode === undefined ? {} : { mode: message.mode }), + }); + return; + } + await backfillRegisteredRepositories(env, { + ...(message.repoFullName ? { repoFullName: message.repoFullName } : {}), + requestedBy: message.requestedBy, + ...(message.force === undefined ? {} : { force: message.force }), + ...(message.mode === undefined ? {} : { mode: message.mode }), + }); + return; + case "backfill-repo-segment": + await backfillRepositorySegment(env, { + repoFullName: message.repoFullName, + segment: message.segment, + requestedBy: message.requestedBy, + ...(message.mode === undefined ? {} : { mode: message.mode }), + ...(message.cursor === undefined ? {} : { cursor: message.cursor }), + ...(message.force === undefined ? {} : { force: message.force }), + }); + return; + case "backfill-pr-details": + await backfillOpenPullRequestDetails(env, { + repoFullName: message.repoFullName, + ...(message.mode === undefined ? {} : { mode: message.mode }), + ...(message.cursor === undefined ? {} : { cursor: message.cursor }), + }); + return; + case "refresh-installation-health": + await refreshInstallationHealth(env); + return; + case "generate-signal-snapshots": + if (!message.repoFullName && message.requestedBy !== "test") { + await fanOutRepoSignalSnapshotJobs(env, message.requestedBy); + return; + } + await generateSignalSnapshots(env, message.repoFullName); + return; + case "refresh-scoring-model": + await refreshScoringModelSnapshot(env); + return; + case "build-contributor-evidence": + await buildContributorEvidence(env, message.login); + return; + case "build-contributor-decision-packs": + await buildContributorDecisionPacks(env, message.login); + return; + case "refresh-contributor-activity": + await refreshContributorActivity(env, message.login, message.repoFullName ? { repoFullName: message.repoFullName } : {}); + return; + case "build-burden-forecasts": + await buildBurdenForecasts(env, message.repoFullName); + return; + case "repair-data-fidelity": + await repairDataFidelity(env, message.requestedBy); + return; case "github-webhook": await processGitHubWebhook(env, message.deliveryId, message.eventName, message.payload); return; } } +async function buildContributorDecisionPacks(env: Env, login?: string): Promise { + const logins = login ? [login] : await discoverContributorLogins(env); + for (const contributorLogin of logins) await buildAndPersistContributorDecisionPack(env, contributorLogin); +} + +async function fanOutRepoSignalSnapshotJobs(env: Env, requestedBy: "schedule" | "api" | "test"): Promise { + const repositories = (await listRepositories(env)).filter((repo) => repo.isRegistered); + await Promise.all( + repositories.map((repo, index) => { + const message: JobMessage = { + type: "generate-signal-snapshots", + requestedBy, + repoFullName: repo.fullName, + }; + const delaySeconds = Math.min(index * 10, 600); + return delaySeconds > 0 ? env.JOBS.send(message, { delaySeconds }) : env.JOBS.send(message); + }), + ); + await recordAuditEvent(env, { + eventType: "signals.snapshot_fanout", + outcome: "queued", + metadata: { repoCount: repositories.length, requestedBy }, + }); +} + +async function repairDataFidelity(env: Env, requestedBy: "schedule" | "api" | "test"): Promise { + const [repositories, segments] = await Promise.all([listRepositories(env), listRepoSyncSegments(env)]); + const requiredSegments = new Set(["labels", "open_issues", "open_pull_requests"]); + const segmentsByRepo = new Map>(); + for (const segment of segments) { + if (requiredSegments.has(segment.segment) && segment.status === "complete") { + const complete = segmentsByRepo.get(segment.repoFullName) ?? new Set(); + complete.add(segment.segment); + segmentsByRepo.set(segment.repoFullName, complete); + } + } + const registeredRepos = repositories.filter((repo) => repo.isRegistered); + const repairs = []; + const signalRefreshes = []; + for (const repo of registeredRepos) { + const complete = segmentsByRepo.get(repo.fullName) ?? new Set(); + const missing = [...requiredSegments].filter((segment) => !complete.has(segment)); + if (missing.length > 0) { + repairs.push({ repoFullName: repo.fullName, missing }); + continue; + } + signalRefreshes.push(repo.fullName); + } + await Promise.all([ + ...repairs.map((repair, index) => { + const message: JobMessage = { + type: "backfill-registered-repos", + requestedBy, + repoFullName: repair.repoFullName, + mode: "resume", + }; + const delaySeconds = Math.min(index * 30, 900); + return delaySeconds > 0 ? env.JOBS.send(message, { delaySeconds }) : env.JOBS.send(message); + }), + ...signalRefreshes.slice(0, 50).map((repoFullName, index) => { + const message: JobMessage = { + type: "generate-signal-snapshots", + requestedBy, + repoFullName, + }; + const delaySeconds = repairs.length > 0 || index > 0 ? Math.min(60 + index * 10, 900) : 0; + return delaySeconds > 0 ? env.JOBS.send(message, { delaySeconds }) : env.JOBS.send(message); + }), + ]); + await recordAuditEvent(env, { + eventType: "sync.fidelity_repair", + outcome: repairs.length > 0 ? "queued" : "completed", + metadata: { requestedBy, repairCount: repairs.length, signalRefreshCount: signalRefreshes.length, repairs: repairs.slice(0, 25) }, + }); +} + +async function discoverContributorLogins(env: Env): Promise { + const [pullRequests, issues] = await Promise.all([listAllPullRequests(env), listAllIssues(env)]); + return [...new Set([...pullRequests, ...issues].flatMap((record) => (record.authorLogin ? [record.authorLogin] : [])))].slice(0, 200); +} + +async function buildContributorEvidence(env: Env, login?: string): Promise { + const [allPullRequests, allIssues, repositories, syncStates, snapshot] = await Promise.all([ + listAllPullRequests(env), + listAllIssues(env), + listRepositories(env), + listRepoSyncStates(env), + getOrCreateScoringModelSnapshot(env), + ]); + const logins = login ? [login] : [...new Set([...allPullRequests, ...allIssues].flatMap((record) => (record.authorLogin ? [record.authorLogin] : [])))].slice(0, 500); + for (const contributorLogin of logins) { + const [github, contributorPullRequests, contributorIssues, cachedRepoStats, gittensorSnapshot] = await Promise.all([ + fetchPublicContributorProfile(contributorLogin), + listContributorPullRequests(env, contributorLogin), + listContributorIssues(env, contributorLogin), + listContributorRepoStats(env, contributorLogin), + fetchGittensorContributorSnapshot(contributorLogin), + ]); + const repoStats = authoritativeContributorRepoStats(gittensorSnapshot, cachedRepoStats); + const profile = buildContributorProfile(contributorLogin, github, contributorPullRequests, contributorIssues, repoStats, gittensorSnapshot); + const fit = buildContributorFit(profile, repositories, allIssues, allPullRequests, syncStates, repoStats); + const scoringProfile = buildContributorScoringProfile({ login: contributorLogin, fit, scoringSnapshot: snapshot }); + const outcomeHistory = buildContributorOutcomeHistory({ login: contributorLogin, profile, repositories, pullRequests: allPullRequests, issues: allIssues, repoStats }); + const strategy = buildContributorStrategy({ login: contributorLogin, fit, scoringProfile, scoringSnapshot: snapshot, outcomeHistory }); + const evidence: ContributorEvidenceRecord = { + login: contributorLogin, + generatedAt: scoringProfile.generatedAt, + payload: { + pullRequests: scoringProfile.evidence.registeredRepoPullRequests, + mergedPullRequests: scoringProfile.evidence.mergedPullRequests, + openPullRequests: scoringProfile.evidence.openPullRequests, + stalePullRequests: scoringProfile.evidence.stalePullRequests, + unlinkedPullRequests: scoringProfile.evidence.unlinkedPullRequests, + issueDiscoveryReports: scoringProfile.evidence.issueDiscoveryReports, + languageMatches: scoringProfile.evidence.languageMatches, + credibilityAssumption: scoringProfile.evidence.credibilityAssumption, + }, + }; + await upsertContributorEvidence(env, evidence); + await upsertContributorScoringProfile(env, { + login: contributorLogin, + scoringModelSnapshotId: snapshot.id, + payload: scoringProfile as unknown as Record, + generatedAt: scoringProfile.generatedAt, + }); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: "contributor-outcome-history", + targetKey: contributorLogin, + payload: outcomeHistory as unknown as Record, + generatedAt: outcomeHistory.generatedAt, + }); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: "contributor-strategy", + targetKey: contributorLogin, + payload: strategy as unknown as Record, + generatedAt: strategy.generatedAt, + }); + } +} + +async function buildBurdenForecasts(env: Env, repoFullName?: string): Promise { + const repositories = (await listRepositories(env)).filter((repo) => repo.isRegistered && (!repoFullName || repo.fullName === repoFullName)); + for (const repo of repositories) { + const [issues, pullRequests, recentMergedPullRequests, queueCounts] = await Promise.all([ + listIssueSignalSample(env, repo.fullName), + listOpenPullRequests(env, repo.fullName), + listRecentMergedPullRequests(env, repo.fullName), + loadOpenQueueCounts(env, repo.fullName), + ]); + const forecast = buildBurdenForecast(repo, issues, pullRequests, buildCollisionReport(repo.fullName, issues, pullRequests, recentMergedPullRequests), 30, queueCounts); + await upsertBurdenForecast(env, { + repoFullName: repo.fullName, + payload: forecast as unknown as Record, + generatedAt: forecast.generatedAt, + }); + } +} + +export async function generateSignalSnapshots(env: Env, repoFullName?: string): Promise { + const repositories = (await listRepositories(env)).filter((repo) => repo.isRegistered && (!repoFullName || repo.fullName === repoFullName)); + for (const repo of repositories) { + const [issues, pullRequests, recentMergedPullRequests, labels, queueCounts] = await Promise.all([ + listIssueSignalSample(env, repo.fullName), + listOpenPullRequests(env, repo.fullName), + listRecentMergedPullRequests(env, repo.fullName), + listRepoLabels(env, repo.fullName), + loadOpenQueueCounts(env, repo.fullName), + ]); + const collisions = buildCollisionReport(repo.fullName, issues, pullRequests, recentMergedPullRequests); + const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions, queueCounts); + const configQuality = buildConfigQuality(repo, issues, pullRequests, repo.fullName); + const labelAudit = buildLabelAudit(repo, labels, issues, pullRequests, repo.fullName); + const maintainerLane = buildMaintainerLaneReport(repo, issues, pullRequests, repo.fullName, collisions, queueCounts); + const maintainerCutReadiness = buildMaintainerCutReadiness(repo, issues, pullRequests, repo.fullName, queueCounts, collisions); + const contributorIntakeHealth = buildContributorIntakeHealth(repo, issues, pullRequests, repo.fullName, collisions, queueCounts); + await replaceCollisionEdges(env, repo.fullName, buildCollisionEdges(collisions)); + const generatedAt = new Date().toISOString(); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: "queue-health", + targetKey: repo.fullName, + repoFullName: repo.fullName, + payload: queueHealth as unknown as Record, + generatedAt, + }); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: "config-quality", + targetKey: repo.fullName, + repoFullName: repo.fullName, + payload: configQuality as unknown as Record, + generatedAt, + }); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: "label-audit", + targetKey: repo.fullName, + repoFullName: repo.fullName, + payload: labelAudit as unknown as Record, + generatedAt, + }); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: "maintainer-lane", + targetKey: repo.fullName, + repoFullName: repo.fullName, + payload: maintainerLane as unknown as Record, + generatedAt, + }); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: "maintainer-cut-readiness", + targetKey: repo.fullName, + repoFullName: repo.fullName, + payload: maintainerCutReadiness as unknown as Record, + generatedAt, + }); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: "contributor-intake-health", + targetKey: repo.fullName, + repoFullName: repo.fullName, + payload: contributorIntakeHealth as unknown as Record, + generatedAt, + }); + } +} + +async function loadOpenQueueCounts(env: Env, repoFullName: string): Promise<{ openIssues: number; openPullRequests: number }> { + const [totals, openIssues, openPullRequests] = await Promise.all([getLatestRepoGithubTotalsSnapshot(env, repoFullName), countOpenIssues(env, repoFullName), countOpenPullRequests(env, repoFullName)]); + return { + openIssues: totals?.openIssuesTotal ?? openIssues, + openPullRequests: totals?.openPullRequestsTotal ?? openPullRequests, + }; +} + async function processGitHubWebhook(env: Env, deliveryId: string, eventName: string, payload: GitHubWebhookPayload): Promise { try { if (eventName === "installation" && payload.action === "deleted" && payload.installation?.id) { @@ -68,8 +433,28 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str if (payload.repository?.full_name && payload.pull_request) { const pr = await upsertPullRequestFromGitHub(env, payload.repository.full_name, payload.pull_request); const repo = await getRepository(env, payload.repository.full_name); - const otherOpenPullRequests = await listOtherOpenPullRequests(env, payload.repository.full_name, pr.number); - const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests }); + const [otherOpenPullRequests, issues, pullRequests, files, reviews, checks, recentMergedPullRequests] = await Promise.all([ + listOtherOpenPullRequests(env, payload.repository.full_name, pr.number), + listIssues(env, payload.repository.full_name), + listPullRequests(env, payload.repository.full_name), + listPullRequestFiles(env, payload.repository.full_name, pr.number), + listPullRequestReviews(env, payload.repository.full_name, pr.number), + listCheckSummaries(env, payload.repository.full_name, pr.number), + listRecentMergedPullRequests(env, payload.repository.full_name), + ]); + const reviewability = buildPullRequestReviewability({ + repo, + pullRequest: pr, + issues, + pullRequests, + files, + reviews, + checks, + recentMergedPullRequests, + repoFullName: payload.repository.full_name, + pullNumber: pr.number, + }); + const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests, reviewabilityText: reviewability.privateSummary }); await persistAdvisory(env, advisory); if (installationId && advisory.headSha) await createOrUpdateCheckRun(env, installationId, payload.repository.full_name, advisory); if (installationId) { @@ -131,17 +516,20 @@ async function maybePublishPrIntelligenceComment( const author = pr.authorLogin; if (!author) return; - const [contributorPullRequests, contributorIssues, repoIssues, repoPullRequests, github] = await Promise.all([ + const [contributorPullRequests, contributorIssues, repoIssues, repoPullRequests, github, cachedRepoStats, gittensorSnapshot] = await Promise.all([ listContributorPullRequests(env, author), listContributorIssues(env, author), listIssues(env, repoFullName), listPullRequests(env, repoFullName), fetchPublicContributorProfile(author), + listContributorRepoStats(env, author), + fetchGittensorContributorSnapshot(author), ]); - const detection = detectGittensorContributor(author, pr, contributorPullRequests, contributorIssues); + const repoStats = authoritativeContributorRepoStats(gittensorSnapshot, cachedRepoStats); + const detection = detectGittensorContributor(author, pr, contributorPullRequests, contributorIssues, repoStats); if (!shouldPublishPrIntelligenceComment(settings, detection)) return; - const profile = buildContributorProfile(author, github, contributorPullRequests, contributorIssues); + const profile = buildContributorProfile(author, github, contributorPullRequests, contributorIssues, repoStats, gittensorSnapshot); const collisions = buildCollisionReport(repoFullName, repoIssues, repoPullRequests); const queueHealth = buildQueueHealth(repo, repoIssues, repoPullRequests, collisions); const preflight = buildPreflightResult( @@ -170,3 +558,11 @@ async function maybePublishPrIntelligenceComment( }); await createOrUpdatePrIntelligenceComment(env, installationId, repoFullName, pr.number, body); } + +function authoritativeContributorRepoStats( + gittensorSnapshot: Awaited>, + cachedRepoStats: Awaited>, +) { + const officialRepoStats = contributorRepoStatsFromGittensor(gittensorSnapshot); + return officialRepoStats.length > 0 ? officialRepoStats : cachedRepoStats; +} diff --git a/src/registry/sync.ts b/src/registry/sync.ts index 29cf7b107a..06b07fa5ff 100644 --- a/src/registry/sync.ts +++ b/src/registry/sync.ts @@ -1,4 +1,4 @@ -import { desc, eq } from "drizzle-orm"; +import { and, desc, eq, notInArray } from "drizzle-orm"; import { getDb } from "../db/client"; import { registrySnapshots, repositories, syncRuns } from "../db/schema"; import type { RegistrySnapshot } from "../types"; @@ -122,6 +122,22 @@ export async function persistRegistrySnapshot(env: Env, snapshot: RegistrySnapsh }, }); } + + const registeredFullNames = snapshot.repositories.map((repo) => repo.repo); + if (registeredFullNames.length > 0) { + await db + .update(repositories) + .set({ + isRegistered: false, + registryConfigJson: null, + emissionShare: null, + issueDiscoveryShare: null, + maintainerCut: 0, + labelMultipliersJson: "{}", + updatedAt: nowIso(), + }) + .where(and(eq(repositories.isRegistered, true), notInArray(repositories.fullName, registeredFullNames))); + } } export async function getLatestRegistrySnapshot(env: Env): Promise { @@ -130,3 +146,9 @@ export async function getLatestRegistrySnapshot(env: Env): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(registrySnapshots).orderBy(desc(registrySnapshots.fetchedAt)).limit(limit); + return rows.map((row) => JSON.parse(row.payloadJson) as RegistrySnapshot); +} diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index 0470d63298..c7a529610f 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -28,7 +28,7 @@ export function buildRepositoryAdvisory(repo: RepositoryRecord | null, fullName: export function buildPullRequestAdvisory( repo: RepositoryRecord | null, pr: PullRequestRecord | null, - context: { otherOpenPullRequests?: PullRequestRecord[] } = {}, + context: { otherOpenPullRequests?: PullRequestRecord[]; reviewabilityText?: string } = {}, ): Advisory { const repoFullName = pr?.repoFullName ?? repo?.fullName ?? "unknown/unknown"; const targetKey = pr ? `${repoFullName}#${pr.number}` : `${repoFullName}#unknown`; @@ -55,6 +55,14 @@ export function buildPullRequestAdvisory( } else { addPullRequestFindings(repo, pr, findings, context.otherOpenPullRequests ?? []); } + if (context.reviewabilityText) { + findings.push({ + code: "private_reviewability_context", + severity: "info", + title: "Private reviewability context", + detail: context.reviewabilityText, + }); + } return advisory("pull_request", targetKey, repoFullName, findings, "Pull request advisory generated.", pr?.number, undefined, pr?.headSha ?? undefined); } diff --git a/src/scoring/model.ts b/src/scoring/model.ts new file mode 100644 index 0000000000..36406d92de --- /dev/null +++ b/src/scoring/model.ts @@ -0,0 +1,140 @@ +import { + getLatestScoringModelSnapshot, + persistScoringModelSnapshot, +} from "../db/repositories"; +import { getLatestRegistrySnapshot } from "../registry/sync"; +import type { JsonValue, ScoringModelSnapshotRecord } from "../types"; +import { nowIso } from "../utils/json"; + +export const DEFAULT_SCORING_CONSTANTS: Record = { + OSS_EMISSION_SHARE: 0.9, + ISSUE_TREASURY_EMISSION_SHARE: 0.1, + PR_LOOKBACK_DAYS: 30, + MERGED_PR_BASE_SCORE: 25, + MIN_TOKEN_SCORE_FOR_BASE_SCORE: 5, + MAX_CODE_DENSITY_MULTIPLIER: 1.15, + MAX_CONTRIBUTION_BONUS: 25, + CONTRIBUTION_SCORE_FOR_FULL_BONUS: 1500, + TEST_FILE_CONTRIBUTION_WEIGHT: 0.05, + MIN_VALID_MERGED_PRS: 3, + MIN_CREDIBILITY: 0.8, + MIN_VALID_SOLVED_ISSUES: 3, + MIN_ISSUE_CREDIBILITY: 0.8, + MIN_TOKEN_SCORE_FOR_VALID_ISSUE: 5, + OPEN_ISSUE_SPAM_BASE_THRESHOLD: 2, + OPEN_ISSUE_SPAM_TOKEN_SCORE_PER_SLOT: 300, + MAX_OPEN_ISSUE_THRESHOLD: 30, + OPEN_PR_COLLATERAL_PERCENT: 0.2, + REVIEW_PENALTY_RATE: 0.15, + STANDARD_ISSUE_MULTIPLIER: 1.33, + MAINTAINER_ISSUE_MULTIPLIER: 1.66, + EXCESSIVE_PR_PENALTY_BASE_THRESHOLD: 2, + OPEN_PR_THRESHOLD_TOKEN_SCORE: 300, + MAX_OPEN_PR_THRESHOLD: 30, + SRC_TOK_SATURATION_SCALE: 58, +}; + +export const SCORING_CONSTANTS_URL = + "https://raw.githubusercontent.com/entrius/gittensor/test/gittensor/constants.py"; +export const PROGRAMMING_LANGUAGES_URL = + "https://raw.githubusercontent.com/entrius/gittensor/test/gittensor/validator/weights/programming_languages.json"; + +const SCORING_CONSTANT_NAMES = new Set(Object.keys(DEFAULT_SCORING_CONSTANTS)); + +export async function refreshScoringModelSnapshot(env: Env): Promise { + const warnings: string[] = []; + const fetchedAt = nowIso(); + const [registrySnapshot, constantsResult, languagesResult] = await Promise.all([ + getLatestRegistrySnapshot(env), + fetchText(SCORING_CONSTANTS_URL, env.GITHUB_PUBLIC_TOKEN), + fetchJson(PROGRAMMING_LANGUAGES_URL, env.GITHUB_PUBLIC_TOKEN), + ]); + + let sourceKind: ScoringModelSnapshotRecord["sourceKind"] = "raw-github"; + let constants = { ...DEFAULT_SCORING_CONSTANTS }; + let constantsPayload: Record = {}; + + if (constantsResult.ok) { + const parsed = parsePythonNumberConstants(constantsResult.value); + constants = { ...constants, ...parsed }; + constantsPayload = { parsedConstantCount: Object.keys(parsed).length, sourceBytes: constantsResult.value.length }; + } else { + sourceKind = "fallback"; + warnings.push(`Scoring constants fetch failed: ${constantsResult.error}`); + } + + const programmingLanguages = languagesResult.ok ? languagesResult.value : {}; + if (!languagesResult.ok) warnings.push(`Programming language weights fetch failed: ${languagesResult.error}`); + + const snapshot: ScoringModelSnapshotRecord = { + id: crypto.randomUUID(), + sourceKind, + sourceUrl: SCORING_CONSTANTS_URL, + fetchedAt, + activeModel: detectActiveModel(constants), + constants, + programmingLanguages: programmingLanguages as Record, + registrySnapshotId: registrySnapshot?.id, + warnings, + payload: { + constants: constantsPayload, + programmingLanguagesSourceUrl: PROGRAMMING_LANGUAGES_URL, + registryRepoCount: registrySnapshot?.repoCount ?? 0, + }, + }; + await persistScoringModelSnapshot(env, snapshot); + return snapshot; +} + +export async function getOrCreateScoringModelSnapshot(env: Env): Promise { + return (await getLatestScoringModelSnapshot(env)) ?? refreshScoringModelSnapshot(env); +} + +export function parsePythonNumberConstants(source: string): Record { + const constants: Record = {}; + for (const line of source.split("\n")) { + const match = line.match(/^([A-Z][A-Z0-9_]+)\s*=\s*([-+]?\d+(?:\.\d+)?)/); + if (!match) continue; + const [, name, raw] = match; + if (!name || !raw || !SCORING_CONSTANT_NAMES.has(name)) continue; + const value = Number(raw); + if (Number.isFinite(value)) constants[name] = value; + } + return constants; +} + +export function detectActiveModel(constants: Record): ScoringModelSnapshotRecord["activeModel"] { + if (Number.isFinite(constants.MAX_CODE_DENSITY_MULTIPLIER) && Number.isFinite(constants.MIN_TOKEN_SCORE_FOR_BASE_SCORE)) { + return "current_density_model"; + } + if (Number.isFinite(constants.SRC_TOK_SATURATION_SCALE)) return "pending_saturation_model"; + return "unknown"; +} + +async function fetchText(url: string, token?: string): Promise<{ ok: true; value: string } | { ok: false; error: string }> { + try { + const response = await fetch(url, { headers: githubHeaders(token, "text/plain") }); + if (!response.ok) return { ok: false, error: `${response.status} ${response.statusText}` }; + return { ok: true, value: await response.text() }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "unknown error" }; + } +} + +async function fetchJson(url: string, token?: string): Promise<{ ok: true; value: Record } | { ok: false; error: string }> { + try { + const response = await fetch(url, { headers: githubHeaders(token, "application/json") }); + if (!response.ok) return { ok: false, error: `${response.status} ${response.statusText}` }; + return { ok: true, value: (await response.json()) as Record }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "unknown error" }; + } +} + +function githubHeaders(token: string | undefined, accept: string): Record { + return { + accept, + "user-agent": "gittensory/0.1", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }; +} diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts new file mode 100644 index 0000000000..3dde43be11 --- /dev/null +++ b/src/scoring/preview.ts @@ -0,0 +1,236 @@ +import type { ContributorEvidenceRecord, JsonValue, RepositoryRecord, ScoringModelSnapshotRecord, ScorePreviewRecord } from "../types"; +import { nowIso } from "../utils/json"; + +export type ScorePreviewInput = { + repoFullName: string; + targetType?: ScorePreviewRecord["targetType"]; + targetKey?: string | undefined; + contributorLogin?: string | undefined; + labels?: string[] | undefined; + linkedIssueMode?: "none" | "standard" | "maintainer" | undefined; + sourceTokenScore?: number | undefined; + totalTokenScore?: number | undefined; + sourceLines?: number | undefined; + testTokenScore?: number | undefined; + nonCodeTokenScore?: number | undefined; + existingContributorTokenScore?: number | undefined; + openPrCount?: number | undefined; + credibility?: number | undefined; + changesRequestedCount?: number | undefined; + fixedBaseScore?: number | undefined; + metadataOnly?: boolean | undefined; +}; + +export type ScorePreviewResult = { + repoFullName: string; + generatedAt: string; + scoringModelSnapshotId: string; + activeModel: ScoringModelSnapshotRecord["activeModel"]; + privateOnly: true; + laneMath: { + repoEmissionShare: number; + ossEmissionShare: number; + repoSlice: number; + directPrSlice: number; + issueDiscoverySlice: number; + issueDiscoveryShare: number; + }; + scoreEstimate: { + baseScore: number; + densityMultiplier: number; + contributionBonus: number; + labelMultiplier: number; + issueMultiplier: number; + credibilityMultiplier: number; + reviewPenaltyMultiplier: number; + openPrMultiplier: number; + estimatedMergedScore: number; + pendingSaturationScore: number; + }; + gates: { + baseTokenGatePassed: boolean; + openPrThreshold: number; + openPrCount: number; + collateralFraction: number; + credibilityFloor: number; + credibilityObserved: number; + }; + warnings: string[]; + assumptions: string[]; + recommendation: { + level: "strong_fit" | "reasonable_fit" | "needs_work" | "hold"; + actions: string[]; + }; +}; + +export function buildScorePreview(args: { + input: ScorePreviewInput; + repo: RepositoryRecord | null; + snapshot: ScoringModelSnapshotRecord; + contributorEvidence?: ContributorEvidenceRecord | null | undefined; +}): ScorePreviewResult { + const constants = { ...args.snapshot.constants }; + const config = args.repo?.registryConfig; + const emissionShare = clamp(config?.emissionShare ?? 0, 0, 1); + const issueDiscoveryShare = clamp(config?.issueDiscoveryShare ?? 0, 0, 1); + const ossEmissionShare = constant(constants, "OSS_EMISSION_SHARE", 0.9); + const repoSlice = emissionShare * ossEmissionShare; + const directPrSlice = repoSlice * (1 - issueDiscoveryShare); + const issueDiscoverySlice = repoSlice * issueDiscoveryShare; + + const sourceTokenScore = nonNegative(args.input.sourceTokenScore); + const totalTokenScore = nonNegative(args.input.totalTokenScore ?? sourceTokenScore + nonNegative(args.input.testTokenScore) + nonNegative(args.input.nonCodeTokenScore)); + const sourceLines = Math.max(1, nonNegative(args.input.sourceLines ?? sourceTokenScore)); + const fixedBaseScore = args.input.fixedBaseScore ?? config?.fixedBaseScore ?? undefined; + const rawDensity = sourceTokenScore / sourceLines; + const densityMultiplier = clamp(rawDensity || 0, 0, constant(constants, "MAX_CODE_DENSITY_MULTIPLIER", 1.15)); + const baseTokenGatePassed = sourceTokenScore >= constant(constants, "MIN_TOKEN_SCORE_FOR_BASE_SCORE", 5); + const contributionBonus = + clamp(totalTokenScore / constant(constants, "CONTRIBUTION_SCORE_FOR_FULL_BONUS", 1500), 0, 1) * + constant(constants, "MAX_CONTRIBUTION_BONUS", 25); + const baseScore = + fixedBaseScore !== undefined + ? fixedBaseScore + : (baseTokenGatePassed ? constant(constants, "MERGED_PR_BASE_SCORE", 25) * densityMultiplier : 0) + contributionBonus; + const labelMultiplier = selectLabelMultiplier(args.input.labels ?? [], config?.labelMultipliers ?? {}, config?.defaultLabelMultiplier ?? 1); + const issueMultiplier = selectIssueMultiplier(args.input.linkedIssueMode ?? "none", constants); + const credibilityObserved = clamp(args.input.credibility ?? inferCredibility(args.contributorEvidence), 0, 1); + const credibilityFloor = constant(constants, "MIN_CREDIBILITY", 0.8); + const credibilityMultiplier = credibilityObserved >= credibilityFloor ? 1 : credibilityObserved / credibilityFloor; + const changesRequestedCount = nonNegative(args.input.changesRequestedCount); + const reviewPenaltyMultiplier = clamp(1 - changesRequestedCount * constant(constants, "REVIEW_PENALTY_RATE", 0.15), 0, 1); + const openPrCount = nonNegative(args.input.openPrCount); + const openPrThreshold = Math.min( + constant(constants, "MAX_OPEN_PR_THRESHOLD", 30), + constant(constants, "EXCESSIVE_PR_PENALTY_BASE_THRESHOLD", 2) + + Math.floor((nonNegative(args.input.existingContributorTokenScore) + totalTokenScore) / constant(constants, "OPEN_PR_THRESHOLD_TOKEN_SCORE", 300)), + ); + const openPrMultiplier = openPrCount <= openPrThreshold ? 1 : 0; + const estimatedMergedScore = roundScore(baseScore * labelMultiplier * issueMultiplier * credibilityMultiplier * reviewPenaltyMultiplier * openPrMultiplier); + const pendingSaturationScore = roundScore( + constant(constants, "MERGED_PR_BASE_SCORE", 25) * (1 - Math.exp(-sourceTokenScore / constant(constants, "SRC_TOK_SATURATION_SCALE", 58))) + + clamp(totalTokenScore / constant(constants, "CONTRIBUTION_SCORE_FOR_FULL_BONUS", 1500), 0, 1) * 5, + ); + + const warnings = [ + ...(!args.repo?.isRegistered ? ["Repository is not registered in the local Gittensory cache."] : []), + ...(emissionShare <= 0 ? ["Repository has no active allocation in the current registry snapshot."] : []), + ...(args.input.metadataOnly ? ["Preview used metadata-only inputs, so token and density estimates are rough."] : []), + ...(!baseTokenGatePassed ? ["Source token score does not pass the current base-score token gate."] : []), + ...(openPrMultiplier === 0 ? ["Open PR count exceeds the current threshold assumption."] : []), + ...(credibilityMultiplier < 1 ? ["Credibility assumption is below the current floor."] : []), + ...(reviewPenaltyMultiplier < 1 ? ["Change-request history reduces the estimate."] : []), + ]; + const actions = [ + ...(!baseTokenGatePassed ? ["Increase meaningful source change size or scope clarity before relying on this preview."] : []), + ...(openPrMultiplier === 0 ? ["Land or close existing open PRs before opening more concurrent work."] : []), + ...(reviewPenaltyMultiplier < 1 ? ["Reduce review churn with tighter tests and clearer evidence."] : []), + ...(labelMultiplier <= 1 && Object.keys(config?.labelMultipliers ?? {}).length > 0 ? ["Check whether the change legitimately matches one of the repo's configured trusted labels."] : []), + ]; + + return { + repoFullName: args.input.repoFullName, + generatedAt: nowIso(), + scoringModelSnapshotId: args.snapshot.id, + activeModel: args.snapshot.activeModel, + privateOnly: true, + laneMath: { + repoEmissionShare: emissionShare, + ossEmissionShare, + repoSlice: roundScore(repoSlice), + directPrSlice: roundScore(directPrSlice), + issueDiscoverySlice: roundScore(issueDiscoverySlice), + issueDiscoveryShare, + }, + scoreEstimate: { + baseScore: roundScore(baseScore), + densityMultiplier: roundScore(densityMultiplier), + contributionBonus: roundScore(contributionBonus), + labelMultiplier, + issueMultiplier, + credibilityMultiplier: roundScore(credibilityMultiplier), + reviewPenaltyMultiplier: roundScore(reviewPenaltyMultiplier), + openPrMultiplier, + estimatedMergedScore, + pendingSaturationScore, + }, + gates: { + baseTokenGatePassed, + openPrThreshold, + openPrCount, + collateralFraction: constant(constants, "OPEN_PR_COLLATERAL_PERCENT", 0.2), + credibilityFloor, + credibilityObserved, + }, + warnings, + assumptions: [ + "Advisory preview only; tied to the recorded scoring model snapshot and cached Gittensory data.", + "No future outcome or exact payout is guaranteed.", + "Private API/MCP output only; public comments intentionally omit these details.", + ], + recommendation: { + level: warnings.some((warning) => /not registered|no active|exceeds/i.test(warning)) + ? "hold" + : estimatedMergedScore >= 30 && warnings.length === 0 + ? "strong_fit" + : estimatedMergedScore >= 15 + ? "reasonable_fit" + : "needs_work", + actions: actions.length > 0 ? actions : ["Keep the PR focused, linked, tested, and easy for maintainers to review."], + }, + }; +} + +export function makeScorePreviewRecord(input: ScorePreviewInput, snapshot: ScoringModelSnapshotRecord, result: ScorePreviewResult): ScorePreviewRecord { + return { + id: crypto.randomUUID(), + scoringModelSnapshotId: snapshot.id, + repoFullName: input.repoFullName, + targetType: input.targetType ?? "planned_pr", + targetKey: input.targetKey ?? `${input.repoFullName}:${input.targetType ?? "planned_pr"}:${Date.now()}`, + contributorLogin: input.contributorLogin, + input: input as unknown as Record, + result: result as unknown as Record, + generatedAt: result.generatedAt, + }; +} + +function selectLabelMultiplier(labels: string[], multipliers: Record, fallback: number): number { + const normalized = new Set(labels.map((label) => label.toLowerCase())); + return Math.max( + fallback || 1, + ...Object.entries(multipliers).flatMap(([label, multiplier]) => (normalized.has(label.toLowerCase()) ? [multiplier] : [])), + ); +} + +function selectIssueMultiplier(mode: "none" | "standard" | "maintainer", constants: Record): number { + if (mode === "maintainer") return constant(constants, "MAINTAINER_ISSUE_MULTIPLIER", 1.66); + if (mode === "standard") return constant(constants, "STANDARD_ISSUE_MULTIPLIER", 1.33); + return 1; +} + +function inferCredibility(evidence?: ContributorEvidenceRecord | null): number { + const payload = evidence?.payload; + const merged = Number(payload?.mergedPullRequests ?? 0); + const stale = Number(payload?.stalePullRequests ?? 0); + const unlinked = Number(payload?.unlinkedPullRequests ?? 0); + if (!Number.isFinite(merged)) return 0.8; + return clamp(0.75 + merged * 0.04 - stale * 0.03 - unlinked * 0.02, 0.25, 1); +} + +function constant(constants: Record, key: string, fallback: number): number { + const value = constants[key]; + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function nonNegative(value: number | undefined): number { + return Number.isFinite(value) ? Math.max(0, value ?? 0) : 0; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function roundScore(value: number): number { + return Math.round(value * 10000) / 10000; +} diff --git a/src/services/decision-pack.ts b/src/services/decision-pack.ts new file mode 100644 index 0000000000..64e8aa625f --- /dev/null +++ b/src/services/decision-pack.ts @@ -0,0 +1,489 @@ +import { + listContributorIssues, + listContributorPullRequests, + listContributorRepoStats, + listLatestRepoGithubTotalsSnapshots, + listRepositories, + listRepoSyncSegments, + listRepoSyncStates, + listSignalSnapshots, + persistSignalSnapshot, + upsertContributorEvidence, + upsertContributorScoringProfile, +} from "../db/repositories"; +import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; +import { fetchPublicContributorProfile } from "../github/public"; +import { getOrCreateScoringModelSnapshot } from "../scoring/model"; +import { + buildContributorFit, + buildContributorOutcomeHistory, + buildContributorProfile, + buildContributorScoringProfile, + buildLaneAdvice, + buildRoleContext, + type ContributorOutcomeHistory, + type ContributorProfile, + type RoleContext, +} from "../signals/engine"; +import { buildSignalFidelity } from "../signals/data-quality"; +import type { ContributorRepoStatRecord, JsonValue, RepositoryRecord, RepoGithubTotalsSnapshotRecord, RepoSyncSegmentRecord, RepoSyncStateRecord, SignalSnapshotRecord } from "../types"; +import { nowIso } from "../utils/json"; + +export const CONTRIBUTOR_DECISION_PACK_SIGNAL = "contributor-decision-pack"; +export const DECISION_PACK_MAX_AGE_MS = 6 * 60 * 60 * 1000; + +export type DecisionRecommendation = "pursue" | "cleanup_first" | "maintainer_lane" | "avoid_for_now" | "watch"; +export type DecisionActionKind = "cleanup_existing_prs" | "land_existing_prs" | "open_new_direct_pr" | "file_issue_discovery" | "maintainer_lane_improve_repo" | "maintainer_cut_readiness"; + +export type ContributorDecisionPack = { + status: "ready"; + source: "computed" | "snapshot"; + login: string; + generatedAt: string; + snapshotAgeSeconds?: number | undefined; + stale: boolean; + scoringModelSnapshotId: string; + profile: { + login: string; + github: ContributorProfile["github"]; + source: ContributorProfile["source"]; + officialStats: Omit, "hotkey"> | null; + registeredRepoActivity: ContributorProfile["registeredRepoActivity"]; + trustSignals: ContributorProfile["trustSignals"]; + }; + outcomeHistory: ContributorOutcomeHistory; + roleContexts: RoleContext[]; + repoDecisions: RepoDecision[]; + topActions: DecisionAction[]; + cleanupFirst: RepoDecision[]; + pursueRepos: RepoDecision[]; + avoidRepos: RepoDecision[]; + maintainerLaneRepos: RepoDecision[]; + scoreBlockers: ScoreBlocker[]; + dataQuality: { + signalFidelity: ReturnType; + }; + summary: string; + nextActions: string[]; +}; + +export type DecisionPackRefreshNeeded = { + status: "needs_snapshot_refresh"; + login: string; + generatedAt: string; + reason: "missing_snapshot" | "stale_snapshot"; + enqueued: boolean; + staleSnapshot?: { + generatedAt: string; + ageSeconds: number; + }; + dataQuality?: ContributorDecisionPack["dataQuality"] | undefined; +}; + +export type RepoDecision = { + repoFullName: string; + recommendation: DecisionRecommendation; + priorityScore: number; + lane: ReturnType; + roleContext: RoleContext; + outcome?: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; + queue: { + openIssues: number; + openPullRequests: number; + mergedPullRequests: number; + closedUnmergedPullRequests: number; + }; + rewardUpside: { + emissionShare: number; + directPrShare: number; + issueDiscoveryShare: number; + maintainerCut: number; + }; + scoreBlockers: ScoreBlocker[]; + riskReasons: string[]; + whyThisHelps: string[]; + nextActions: string[]; +}; + +export type DecisionAction = { + actionKind: DecisionActionKind; + repoFullName: string; + priorityScore: number; + recommendation: DecisionRecommendation; + whyThisHelps: string[]; + nextActions: string[]; +}; + +export type ScoreBlocker = { + code: "open_pr_pressure" | "maintainer_lane" | "inactive_or_unknown_lane" | "closed_pr_credibility" | "issue_discovery_only" | "low_credibility"; + repoFullName?: string | undefined; + severity: "info" | "warning" | "critical"; + detail: string; +}; + +export async function loadContributorDecisionPack(env: Env, login: string): Promise { + const latest = (await listSignalSnapshots(env, CONTRIBUTOR_DECISION_PACK_SIGNAL, login))[0]; + if (!latest) return null; + return withSnapshotMetadata(latest); +} + +export async function loadFreshContributorDecisionPack(env: Env, login: string, maxAgeMs = DECISION_PACK_MAX_AGE_MS): Promise { + const pack = await loadContributorDecisionPack(env, login); + if (!pack) return null; + return pack.stale || snapshotAgeMs(pack.generatedAt) > maxAgeMs ? null : pack; +} + +export async function buildAndPersistContributorDecisionPack(env: Env, login: string): Promise { + const [ + github, + contributorPullRequests, + contributorIssues, + repositories, + syncStates, + syncSegments, + totals, + cachedRepoStats, + gittensorSnapshot, + scoringSnapshot, + ] = await Promise.all([ + fetchPublicContributorProfile(login), + listContributorPullRequests(env, login), + listContributorIssues(env, login), + listRepositories(env), + listRepoSyncStates(env), + listRepoSyncSegments(env), + listLatestRepoGithubTotalsSnapshots(env), + listContributorRepoStats(env, login), + fetchGittensorContributorSnapshot(login), + getOrCreateScoringModelSnapshot(env), + ]); + const repoStats = authoritativeContributorRepoStats(gittensorSnapshot, cachedRepoStats); + const profile = buildContributorProfile(login, github, contributorPullRequests, contributorIssues, repoStats, gittensorSnapshot); + const outcomeHistory = buildContributorOutcomeHistory({ + login, + profile, + repositories, + pullRequests: contributorPullRequests, + issues: contributorIssues, + repoStats, + }); + const fit = buildContributorFit(profile, repositories, [], [], syncStates, repoStats); + const scoringProfile = buildContributorScoringProfile({ login, fit, scoringSnapshot }); + const pack = buildContributorDecisionPack({ + login, + profile, + outcomeHistory, + repositories, + syncStates, + syncSegments, + totals, + scoringModelSnapshotId: scoringSnapshot.id, + contributorPullRequests, + contributorIssues, + }); + + await upsertContributorEvidence(env, { + login, + generatedAt: scoringProfile.generatedAt, + payload: { + pullRequests: scoringProfile.evidence.registeredRepoPullRequests, + mergedPullRequests: scoringProfile.evidence.mergedPullRequests, + openPullRequests: scoringProfile.evidence.openPullRequests, + stalePullRequests: scoringProfile.evidence.stalePullRequests, + unlinkedPullRequests: scoringProfile.evidence.unlinkedPullRequests, + issueDiscoveryReports: scoringProfile.evidence.issueDiscoveryReports, + languageMatches: scoringProfile.evidence.languageMatches, + credibilityAssumption: scoringProfile.evidence.credibilityAssumption, + }, + }); + await upsertContributorScoringProfile(env, { + login, + scoringModelSnapshotId: scoringSnapshot.id, + payload: scoringProfile as unknown as Record, + generatedAt: scoringProfile.generatedAt, + }); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: CONTRIBUTOR_DECISION_PACK_SIGNAL, + targetKey: login, + payload: pack as unknown as Record, + generatedAt: pack.generatedAt, + }); + return pack; +} + +export function repoDecisionFromPack(pack: ContributorDecisionPack, repoFullName: string): RepoDecision | null { + const key = repoFullName.toLowerCase(); + return pack.repoDecisions.find((decision) => decision.repoFullName.toLowerCase() === key) ?? null; +} + +function buildContributorDecisionPack(args: { + login: string; + profile: ContributorProfile; + outcomeHistory: ContributorOutcomeHistory; + repositories: RepositoryRecord[]; + syncStates: RepoSyncStateRecord[]; + syncSegments: RepoSyncSegmentRecord[]; + totals: RepoGithubTotalsSnapshotRecord[]; + scoringModelSnapshotId: string; + contributorPullRequests: Parameters[0]["pullRequests"]; + contributorIssues: Parameters[0]["issues"]; +}): ContributorDecisionPack { + const registeredRepositories = args.repositories.filter((repo) => repo.isRegistered); + const syncByRepo = new Map(args.syncStates.map((state) => [state.repoFullName.toLowerCase(), state])); + const totalsByRepo = new Map(args.totals.map((total) => [total.repoFullName.toLowerCase(), total])); + const outcomeByRepo = new Map(args.outcomeHistory.repoOutcomes.map((outcome) => [outcome.repoFullName.toLowerCase(), outcome])); + const roleContexts = registeredRepositories.map((repo) => + buildRoleContext({ + login: args.login, + repo, + repoFullName: repo.fullName, + pullRequests: args.contributorPullRequests, + issues: args.contributorIssues, + profile: args.profile, + }), + ); + const roleByRepo = new Map(roleContexts.map((role) => [role.repoFullName.toLowerCase(), role])); + const repoDecisions = registeredRepositories + .map((repo) => { + const key = repo.fullName.toLowerCase(); + return buildRepoDecision({ + repo, + roleContext: roleByRepo.get(key) ?? buildRoleContext({ login: args.login, repo, repoFullName: repo.fullName, profile: args.profile }), + outcome: outcomeByRepo.get(key), + syncState: syncByRepo.get(key), + totals: totalsByRepo.get(key), + }); + }) + .sort((left, right) => right.priorityScore - left.priorityScore || left.repoFullName.localeCompare(right.repoFullName)); + const topActions = repoDecisions.flatMap(actionsForDecision).sort((left, right) => right.priorityScore - left.priorityScore || left.repoFullName.localeCompare(right.repoFullName)).slice(0, 12); + const scoreBlockers = repoDecisions.flatMap((decision) => decision.scoreBlockers).sort((left, right) => severityRank(right.severity) - severityRank(left.severity) || (left.repoFullName ?? "").localeCompare(right.repoFullName ?? "")); + const dataQuality = { + signalFidelity: buildSignalFidelity(registeredRepositories.length, args.syncStates, args.syncSegments), + }; + return { + status: "ready", + source: "computed", + login: args.login, + generatedAt: nowIso(), + stale: false, + scoringModelSnapshotId: args.scoringModelSnapshotId, + profile: { + login: args.profile.login, + github: args.profile.github, + source: args.profile.source, + officialStats: sanitizeOfficialStats(args.profile), + registeredRepoActivity: args.profile.registeredRepoActivity, + trustSignals: args.profile.trustSignals, + }, + outcomeHistory: args.outcomeHistory, + roleContexts: roleContexts.filter((role) => role.role !== "unknown" || role.maintainerLane), + repoDecisions, + topActions, + cleanupFirst: repoDecisions.filter((decision) => decision.recommendation === "cleanup_first").slice(0, 8), + pursueRepos: repoDecisions.filter((decision) => decision.recommendation === "pursue").slice(0, 8), + avoidRepos: repoDecisions.filter((decision) => decision.recommendation === "avoid_for_now").slice(0, 8), + maintainerLaneRepos: repoDecisions.filter((decision) => decision.recommendation === "maintainer_lane").slice(0, 8), + scoreBlockers, + dataQuality, + summary: `${args.login} has ${topActions.length} ranked action(s), ${scoreBlockers.length} scoreability blocker(s), and ${repoDecisions.length} registered repo decision(s).`, + nextActions: [...new Set(topActions.flatMap((action) => action.nextActions))].slice(0, 10), + }; +} + +function buildRepoDecision(args: { + repo: RepositoryRecord; + roleContext: RoleContext; + outcome?: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; + syncState?: RepoSyncStateRecord | undefined; + totals?: RepoGithubTotalsSnapshotRecord | undefined; +}): RepoDecision { + const lane = buildLaneAdvice(args.repo, args.repo.fullName); + const config = args.repo.registryConfig; + const openPullRequests = args.totals?.openPullRequestsTotal ?? args.syncState?.openPullRequestsCount ?? 0; + const openIssues = args.totals?.openIssuesTotal ?? args.syncState?.openIssuesCount ?? 0; + const queue = { + openIssues, + openPullRequests, + mergedPullRequests: args.totals?.mergedPullRequestsTotal ?? args.syncState?.recentMergedPullRequestsCount ?? 0, + closedUnmergedPullRequests: args.totals?.closedUnmergedPullRequestsTotal ?? 0, + }; + const rewardUpside = { + emissionShare: round(config?.emissionShare ?? 0), + directPrShare: round((config?.emissionShare ?? 0) * (1 - (config?.issueDiscoveryShare ?? 0))), + issueDiscoveryShare: round((config?.emissionShare ?? 0) * (config?.issueDiscoveryShare ?? 0)), + maintainerCut: round(config?.maintainerCut ?? 0), + }; + const blockers = scoreBlockersFor(args.repo.fullName, lane.lane, args.roleContext, args.outcome); + const riskReasons = [ + ...(queue.openPullRequests >= 25 ? [`Repo queue is busy with ${queue.openPullRequests} open PR(s).`] : []), + ...(queue.openIssues >= 100 ? [`Repo issue queue is large with ${queue.openIssues} open issue(s).`] : []), + ...(args.outcome && args.outcome.closedPullRequestRate >= 0.35 ? [`Repo-specific closed PR rate is ${Math.round(args.outcome.closedPullRequestRate * 100)}%.`] : []), + ...(args.outcome && args.outcome.openPullRequests >= 3 ? [`Contributor has ${args.outcome.openPullRequests} open PR(s) in this repo.`] : []), + ...(lane.lane === "issue_discovery" ? ["Direct PRs are not the useful lane here; use issue-discovery behavior only."] : []), + ]; + const recommendation = recommendationFor(lane.lane, args.roleContext, args.outcome, blockers); + const priorityScore = priorityFor(recommendation, rewardUpside, args.outcome, queue, blockers); + return { + repoFullName: args.repo.fullName, + recommendation, + priorityScore, + lane, + roleContext: args.roleContext, + outcome: args.outcome, + queue, + rewardUpside, + scoreBlockers: blockers, + riskReasons, + whyThisHelps: whyThisHelpsFor(recommendation, args.repo.fullName, args.outcome, rewardUpside), + nextActions: nextActionsFor(recommendation, lane.lane), + }; +} + +function scoreBlockersFor(repoFullName: string, lane: string, roleContext: RoleContext, outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined): ScoreBlocker[] { + const blockers: ScoreBlocker[] = []; + if (roleContext.maintainerLane) blockers.push({ code: "maintainer_lane", repoFullName, severity: "info", detail: "Maintainer-lane activity is separate from normal outside-contributor reward evidence." }); + if (lane === "inactive" || lane === "unknown") blockers.push({ code: "inactive_or_unknown_lane", repoFullName, severity: "critical", detail: "The repo lane is inactive or unknown in the current registry snapshot." }); + if (lane === "issue_discovery") blockers.push({ code: "issue_discovery_only", repoFullName, severity: "warning", detail: "This repo is issue-discovery-only; direct PR reward/risk reasoning is not applicable." }); + if ((outcome?.openPullRequests ?? 0) >= 5) blockers.push({ code: "open_pr_pressure", repoFullName, severity: "critical", detail: `${outcome?.openPullRequests ?? 0} open PR(s) create scoreability and review-pressure risk.` }); + if ((outcome?.closedPullRequestRate ?? 0) >= 0.35) blockers.push({ code: "closed_pr_credibility", repoFullName, severity: "warning", detail: `Closed PR rate is ${Math.round((outcome?.closedPullRequestRate ?? 0) * 100)}%.` }); + if (outcome && !outcome.maintainerLane && outcome.credibility > 0 && outcome.credibility < 0.8) blockers.push({ code: "low_credibility", repoFullName, severity: "warning", detail: `Official repo credibility is ${round(outcome.credibility)}.` }); + return blockers; +} + +function recommendationFor(lane: string, roleContext: RoleContext, outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined, blockers: ScoreBlocker[]): DecisionRecommendation { + if (roleContext.maintainerLane) return "maintainer_lane"; + if (blockers.some((blocker) => blocker.code === "open_pr_pressure")) return "cleanup_first"; + if (blockers.some((blocker) => blocker.severity === "critical")) return "avoid_for_now"; + if ((outcome?.openPullRequests ?? 0) >= 3) return "cleanup_first"; + if (lane === "direct_pr" || lane === "split") return "pursue"; + if (lane === "issue_discovery") return "watch"; + return "avoid_for_now"; +} + +function priorityFor( + recommendation: DecisionRecommendation, + rewardUpside: RepoDecision["rewardUpside"], + outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined, + queue: RepoDecision["queue"], + blockers: ScoreBlocker[], +): number { + const upside = Math.max(rewardUpside.directPrShare, rewardUpside.issueDiscoveryShare, rewardUpside.emissionShare * 0.35) * 1000; + const history = (outcome?.mergedPullRequests ?? 0) * 2 + (outcome?.validSolvedIssues ?? 0) * 3 - (outcome?.closedPullRequests ?? 0) * 1.5; + const queuePenalty = Math.min(30, queue.openPullRequests * 0.25); + const blockerPenalty = blockers.reduce((sum, blocker) => sum + (blocker.severity === "critical" ? 35 : blocker.severity === "warning" ? 15 : 5), 0); + const base = recommendation === "cleanup_first" ? 75 : recommendation === "pursue" ? 65 : recommendation === "maintainer_lane" ? 55 : recommendation === "watch" ? 35 : 20; + return clamp(round(base + upside + history - queuePenalty - blockerPenalty), 0, 100); +} + +function actionsForDecision(decision: RepoDecision): DecisionAction[] { + if (decision.recommendation === "maintainer_lane") { + return [ + action("maintainer_lane_improve_repo", decision, decision.priorityScore), + action("maintainer_cut_readiness", decision, Math.max(0, decision.priorityScore - 10)), + ]; + } + if (decision.recommendation === "cleanup_first") { + return [action("cleanup_existing_prs", decision, decision.priorityScore), action("land_existing_prs", decision, Math.max(0, decision.priorityScore - 8))]; + } + if (decision.recommendation === "pursue") return [action("open_new_direct_pr", decision, decision.priorityScore)]; + if (decision.lane.lane === "issue_discovery" || decision.lane.lane === "split") return [action("file_issue_discovery", decision, decision.priorityScore)]; + return []; +} + +function action(kind: DecisionActionKind, decision: RepoDecision, priorityScore: number): DecisionAction { + return { + actionKind: kind, + repoFullName: decision.repoFullName, + priorityScore, + recommendation: decision.recommendation, + whyThisHelps: decision.whyThisHelps, + nextActions: decision.nextActions, + }; +} + +function whyThisHelpsFor( + recommendation: DecisionRecommendation, + repoFullName: string, + outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined, + rewardUpside: RepoDecision["rewardUpside"], +): string[] { + if (recommendation === "cleanup_first") return [`${repoFullName}: cleaning up active PR pressure protects scoreability and reduces maintainer friction.`]; + if (recommendation === "maintainer_lane") return [`${repoFullName}: maintainer-owned work should improve repo health, intake quality, labels, and queue clarity.`]; + if (recommendation === "pursue") return [`${repoFullName}: direct PR lane has ${round(rewardUpside.directPrShare)} lane share and no hard personal blocker in current signals.`]; + if (recommendation === "watch") return [`${repoFullName}: issue-discovery context is useful only when the report is actionable, non-duplicate, and likely solvable.`]; + return [`${repoFullName}: risk-adjusted priority is low until blockers improve.`]; +} + +function nextActionsFor(recommendation: DecisionRecommendation, lane: string): string[] { + if (recommendation === "cleanup_first") return ["Close, update, or land existing open PRs before opening more work.", "Use local branch preflight on each active PR to reduce review friction."]; + if (recommendation === "maintainer_lane") return ["Improve contributor intake health, label clarity, and queue hygiene.", "Review maintainer_cut readiness separately from outside-contributor strategy."]; + if (recommendation === "pursue") return ["Pick one narrow change, link context clearly, run tests, and use local branch analysis before opening the PR."]; + if (lane === "issue_discovery") return ["File only high-confidence, actionable, non-duplicate issue-discovery reports."]; + return ["Choose a different repo or wait for cleaner lane/credibility conditions."]; +} + +function sanitizeOfficialStats(profile: ContributorProfile): ContributorDecisionPack["profile"]["officialStats"] { + if (!profile.gittensor) return null; + const { hotkey: _hotkey, ...safe } = profile.gittensor; + return safe; +} + +function withSnapshotMetadata(snapshot: SignalSnapshotRecord): ContributorDecisionPack { + const payload = snapshot.payload as unknown as ContributorDecisionPack; + const generatedAt = snapshot.generatedAt ?? payload.generatedAt ?? nowIso(); + const ageSeconds = Math.max(0, Math.floor(snapshotAgeMs(generatedAt) / 1000)); + return { + ...payload, + status: "ready", + source: "snapshot", + generatedAt, + snapshotAgeSeconds: ageSeconds, + stale: snapshotAgeMs(generatedAt) > DECISION_PACK_MAX_AGE_MS, + }; +} + +function snapshotAgeMs(generatedAt: string): number { + const parsed = Date.parse(generatedAt); + return Number.isFinite(parsed) ? Date.now() - parsed : Number.POSITIVE_INFINITY; +} + +function authoritativeContributorRepoStats( + gittensorSnapshot: Awaited>, + cachedRepoStats: ContributorRepoStatRecord[], +) { + const officialRepoStats = contributorRepoStatsFromGittensor(gittensorSnapshot); + return officialRepoStats.length > 0 ? officialRepoStats : cachedRepoStats; +} + +function severityRank(severity: ScoreBlocker["severity"]): number { + if (severity === "critical") return 3; + if (severity === "warning") return 2; + return 1; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function round(value: number): number { + return Math.round(value * 10000) / 10000; +} + +export const __decisionPackInternals = { + buildRepoDecision, + buildContributorDecisionPack, + scoreBlockersFor, + recommendationFor, + priorityFor, + actionsForDecision, + whyThisHelpsFor, + nextActionsFor, + sanitizeOfficialStats, + withSnapshotMetadata, + snapshotAgeMs, + authoritativeContributorRepoStats, + severityRank, + clamp, + round, +}; diff --git a/src/signals/data-quality.ts b/src/signals/data-quality.ts new file mode 100644 index 0000000000..0102288ed0 --- /dev/null +++ b/src/signals/data-quality.ts @@ -0,0 +1,298 @@ +import type { DataQuality, PullRequestDetailSyncStateRecord, RepoGithubTotalsSnapshotRecord, RepoSyncSegmentRecord, RepoSyncStateRecord } from "../types"; +import { nowIso } from "../utils/json"; + +const DEFAULT_STALE_MS = 7 * 24 * 60 * 60 * 1000; +const COMPLETE_SEGMENT_STATUSES = new Set(["complete", "not_modified", "sampled"]); +const BLOCKING_SEGMENT_STATUSES = new Set(["error", "rate_limited", "waiting_rate_limit", "skipped"]); +const REQUIRED_OPEN_SEGMENTS = new Set(["metadata", "labels", "open_issues", "open_pull_requests", "pull_request_files", "pull_request_reviews", "check_summaries"]); + +export type SignalFidelity = { + status: "complete" | "degraded" | "blocked" | "unknown"; + repoCount: number; + completeRepos: number; + degradedRepos: number; + blockedRepos: number; + partialRepos: string[]; + cappedRepos: string[]; + staleRepos: string[]; + rateLimitedRepos: string[]; + nextRecoverableAt?: string | null | undefined; +}; + +export type CoreSignalFidelity = { + status: "complete" | "degraded" | "blocked" | "unknown"; + repoCount: number; + completeRepos: number; + degradedRepos: number; + blockedRepos: number; + incompleteRepos: string[]; + refreshingRepos: string[]; + waitingForRateLimitRepos: string[]; + historyCoverage: "sampled" | "counts_only" | "full"; +}; + +export function buildRepoDataQuality( + repoFullName: string, + syncState: RepoSyncStateRecord | null | undefined, + segments: RepoSyncSegmentRecord[], + options: { staleMs?: number; nowMs?: number } = {}, +): DataQuality { + const staleMs = options.staleMs ?? DEFAULT_STALE_MS; + const nowMs = options.nowMs ?? Date.now(); + const scopedSegments = segments.filter((segment) => segment.repoFullName === repoFullName); + const incompleteSegments = scopedSegments + .filter((segment) => !hasEffectiveSegmentCoverage(segment)) + .map((segment) => segment.segment) + .sort(); + const cappedSegments = scopedSegments.filter((segment) => segment.status === "capped").map((segment) => segment.segment).sort(); + const rateLimitedSegments = scopedSegments + .filter((segment) => segment.status === "rate_limited" && !hasEffectiveSegmentCoverage(segment)) + .map((segment) => segment.segment) + .sort(); + const waitingRateLimitSegments = scopedSegments + .filter((segment) => segment.status === "waiting_rate_limit" && !hasEffectiveSegmentCoverage(segment)) + .map((segment) => segment.segment) + .sort(); + const staleSegments = scopedSegments + .filter((segment) => segment.status === "stale" || isStale(segment.completedAt ?? syncState?.lastCompletedAt, staleMs, nowMs)) + .map((segment) => segment.segment) + .sort(); + const stateStatus = syncState?.status; + const hasEffectiveCoverage = scopedSegments.length > 0 && scopedSegments.every((segment) => hasEffectiveSegmentCoverage(segment)); + const activeStateWarnings = stateStatus === "success" && hasEffectiveCoverage ? [] : (syncState?.warnings ?? []); + const allBlockingSegmentsRecovered = + scopedSegments.length > 0 && scopedSegments.every((segment) => !BLOCKING_SEGMENT_STATUSES.has(segment.status) || hasEffectiveSegmentCoverage(segment)); + const stateBlocked = stateStatus === "error" || stateStatus === "skipped" || (stateStatus === "rate_limited" && !allBlockingSegmentsRecovered); + const statePartial = stateStatus === "partial" || stateStatus === "capped"; + const segmentBlocked = scopedSegments.some((segment) => BLOCKING_SEGMENT_STATUSES.has(segment.status) && !hasEffectiveSegmentCoverage(segment)); + const blocked = stateBlocked || segmentBlocked; + const partial = statePartial || incompleteSegments.length > 0; + const stale = stateStatus === "stale" || isStale(syncState?.lastCompletedAt ?? syncState?.updatedAt, staleMs, nowMs) || staleSegments.length > 0; + const capped = cappedSegments.length > 0 || stateStatus === "capped" || Boolean(activeStateWarnings.some((warning) => /cap|capped/i.test(warning))); + const rateLimited = + rateLimitedSegments.length > 0 || + waitingRateLimitSegments.length > 0 || + (stateStatus === "rate_limited" && !allBlockingSegmentsRecovered) || + Boolean(activeStateWarnings.some((warning) => /rate.?limit/i.test(warning))); + const status: DataQuality["status"] = !syncState + ? "unknown" + : blocked + ? "blocked" + : partial || stale || capped || rateLimited + ? "degraded" + : "complete"; + const activeSyncWarnings = status === "complete" ? [] : (syncState?.warnings ?? []); + const warnings = [ + ...(!syncState ? [`No repository sync state is available for ${repoFullName}.`] : []), + ...(partial ? [`Repository sync for ${repoFullName} is incomplete or partial.`] : []), + ...(capped ? [`Repository sync for ${repoFullName} hit a local pagination cap; large-queue signals may be undercounted.`] : []), + ...(stale ? [`Repository sync for ${repoFullName} is stale; recommendations should be treated as lower confidence.`] : []), + ...(rateLimited ? [`Repository sync for ${repoFullName} encountered GitHub rate limiting.`] : []), + ...(status !== "complete" && syncState?.errorSummary ? [`Latest sync error for ${repoFullName}: ${syncState.errorSummary}`] : []), + ]; + return { + status, + generatedAt: nowIso(), + repoFullName, + stale, + partial, + capped, + rateLimited, + segmentCount: scopedSegments.length, + incompleteSegments, + cappedSegments, + staleSegments, + rateLimitedSegments: [...new Set([...rateLimitedSegments, ...waitingRateLimitSegments])], + warnings: [...new Set([...warnings, ...activeSyncWarnings])], + syncState: syncState + ? { + status: syncState.status, + lastCompletedAt: syncState.lastCompletedAt, + updatedAt: syncState.updatedAt, + warnings: syncState.warnings, + } + : undefined, + }; +} + +export function buildCoreSignalFidelity( + repoCount: number, + states: RepoSyncStateRecord[], + segments: RepoSyncSegmentRecord[], + totals: RepoGithubTotalsSnapshotRecord[], + detailStates: PullRequestDetailSyncStateRecord[] = [], +): CoreSignalFidelity { + const repoNames = [...new Set([...states.map((state) => state.repoFullName), ...segments.map((segment) => segment.repoFullName), ...totals.map((total) => total.repoFullName)])].sort(); + const totalsByRepo = new Map(totals.map((total) => [total.repoFullName, total])); + const segmentsByRepo = groupByRepo(segments); + const detailsByRepo = groupByRepo(detailStates); + const incompleteRepos: string[] = []; + const refreshingRepos: string[] = []; + const waitingForRateLimitRepos: string[] = []; + const blockedRepos: string[] = []; + let completeRepos = 0; + let hasHistoricalSample = false; + let hasFullHistory = repoNames.length > 0; + + for (const repoFullName of repoNames) { + const state = states.find((record) => record.repoFullName === repoFullName); + const repoTotals = totalsByRepo.get(repoFullName); + const repoSegments = segmentsByRepo.get(repoFullName) ?? []; + const repoDetails = detailsByRepo.get(repoFullName) ?? []; + const requiredSegments = repoSegments.filter((segment) => REQUIRED_OPEN_SEGMENTS.has(segment.segment)); + const historySegment = repoSegments.find((segment) => segment.segment === "recent_merged_pull_requests"); + if ((historySegment?.fetchedCount ?? 0) > 0) hasHistoricalSample = true; + if (!historySegment || !repoTotals || historySegment.status !== "complete" || historySegment.fetchedCount < repoTotals.mergedPullRequestsTotal) hasFullHistory = false; + + const repoWaiting = requiredSegments.some((segment) => { + const expected = expectedForRequiredSegment(segment, repoTotals); + return (segment.status === "waiting_rate_limit" || segment.status === "rate_limited") && !hasCompleteCountCoverage(segment, expected); + }); + const repoRefreshing = requiredSegments.some((segment) => segment.status === "running" || segment.status === "refreshing"); + const repoHardBlocked = state?.status === "error" || state?.status === "skipped"; + const repoStateRateLimited = state?.status === "rate_limited"; + const missingRequired = !state || !repoTotals || REQUIRED_OPEN_SEGMENTS.size > requiredSegments.length; + const openIssues = repoSegments.find((segment) => segment.segment === "open_issues"); + const openPullRequests = repoSegments.find((segment) => segment.segment === "open_pull_requests"); + const labels = repoSegments.find((segment) => segment.segment === "labels"); + const detailCompleteCount = repoDetails.filter((detail) => detail.status === "complete").length; + const requiredIncomplete = + missingRequired || + !isCompleteCount(openIssues, repoTotals?.openIssuesTotal) || + !isCompleteCount(openPullRequests, repoTotals?.openPullRequestsTotal) || + !isCompleteCount(labels, repoTotals?.labelsTotal) || + detailCompleteCount < (repoTotals?.openPullRequestsTotal ?? 0) || + requiredSegments.some((segment) => !hasUsableRequiredSegmentCoverage(segment, expectedForRequiredSegment(segment, repoTotals))); + const repoBlocked = repoWaiting || repoHardBlocked || (repoStateRateLimited && requiredIncomplete); + + if (repoBlocked) blockedRepos.push(repoFullName); + if (repoRefreshing) refreshingRepos.push(repoFullName); + if (repoWaiting) waitingForRateLimitRepos.push(repoFullName); + if (requiredIncomplete) incompleteRepos.push(repoFullName); + if (!repoBlocked && !requiredIncomplete) completeRepos += 1; + } + + const missingRepoCount = Math.max(repoCount - repoNames.length, 0); + const status: CoreSignalFidelity["status"] = + repoCount === 0 || repoNames.length === 0 + ? "unknown" + : blockedRepos.length > 0 + ? "blocked" + : incompleteRepos.length > 0 || missingRepoCount > 0 + ? "degraded" + : "complete"; + return { + status, + repoCount, + completeRepos, + degradedRepos: incompleteRepos.filter((repo) => !blockedRepos.includes(repo)).length + missingRepoCount, + blockedRepos: blockedRepos.length, + incompleteRepos, + refreshingRepos, + waitingForRateLimitRepos, + historyCoverage: hasFullHistory ? "full" : hasHistoricalSample ? "sampled" : "counts_only", + }; +} + +export function attachDataQuality>(payload: T, dataQuality: DataQuality): T & { dataQuality: DataQuality } { + return { ...payload, dataQuality }; +} + +export function buildSignalFidelity(repoCount: number, states: RepoSyncStateRecord[], segments: RepoSyncSegmentRecord[]): SignalFidelity { + const segmentRepos = new Map(); + for (const segment of segments) { + const existing = segmentRepos.get(segment.repoFullName) ?? []; + existing.push(segment); + segmentRepos.set(segment.repoFullName, existing); + } + const repoNames = [...new Set([...states.map((state) => state.repoFullName), ...segments.map((segment) => segment.repoFullName)])].sort(); + const qualities = repoNames.map((repoFullName) => + buildRepoDataQuality( + repoFullName, + states.find((state) => state.repoFullName === repoFullName), + segmentRepos.get(repoFullName) ?? [], + ), + ); + const partialRepos = qualities.filter((quality) => quality.partial || quality.status === "unknown").map((quality) => quality.repoFullName ?? ""); + const cappedRepos = qualities.filter((quality) => quality.capped).map((quality) => quality.repoFullName ?? ""); + const staleRepos = qualities.filter((quality) => quality.stale).map((quality) => quality.repoFullName ?? ""); + const rateLimitedRepos = qualities.filter((quality) => quality.rateLimited).map((quality) => quality.repoFullName ?? ""); + const blockedRepos = qualities.filter((quality) => quality.status === "blocked").map((quality) => quality.repoFullName ?? ""); + const rateLimitResetValues = segments.flatMap((segment) => + (segment.status === "rate_limited" || segment.status === "waiting_rate_limit") && segment.rateLimitResetAt && !hasEffectiveSegmentCoverage(segment) ? [segment.rateLimitResetAt] : [], + ); + const missingRepoCount = Math.max(repoCount - states.length, 0); + const status: SignalFidelity["status"] = + repoCount === 0 || qualities.length === 0 + ? "unknown" + : blockedRepos.length > 0 + ? "blocked" + : missingRepoCount > 0 || partialRepos.length > 0 || cappedRepos.length > 0 || staleRepos.length > 0 || rateLimitedRepos.length > 0 + ? "degraded" + : "complete"; + return { + status, + repoCount, + completeRepos: qualities.filter((quality) => quality.status === "complete").length, + degradedRepos: qualities.filter((quality) => quality.status === "degraded" || quality.status === "unknown").length + missingRepoCount, + blockedRepos: blockedRepos.length, + partialRepos, + cappedRepos, + staleRepos, + rateLimitedRepos, + nextRecoverableAt: rateLimitResetValues.sort()[0], + }; +} + +function isStale(value: string | null | undefined, staleMs: number, nowMs: number): boolean { + if (!value) return false; + const parsed = Date.parse(value); + return Number.isFinite(parsed) && nowMs - parsed > staleMs; +} + +function groupByRepo(records: T[]): Map { + const grouped = new Map(); + for (const record of records) { + const existing = grouped.get(record.repoFullName) ?? []; + existing.push(record); + grouped.set(record.repoFullName, existing); + } + return grouped; +} + +function isCompleteCount(segment: RepoSyncSegmentRecord | undefined, expected: number | null | undefined): boolean { + return Boolean(segment && hasCompleteCountCoverage(segment, expected) && hasUsableRequiredSegmentCoverage(segment, expected)); +} + +function hasUsableRequiredSegmentCoverage(segment: RepoSyncSegmentRecord, expected?: number | null): boolean { + if (segment.status === "complete" || segment.status === "not_modified") return true; + if ((segment.status === "waiting_rate_limit" || segment.status === "rate_limited") && hasCompleteCountCoverage(segment, expected)) return true; + return (segment.status === "running" || segment.status === "refreshing") && Boolean(segment.completedAt); +} + +function hasEffectiveSegmentCoverage(segment: RepoSyncSegmentRecord): boolean { + return COMPLETE_SEGMENT_STATUSES.has(segment.status) || hasCompleteCountCoverage(segment, segment.expectedCount); +} + +function hasCompleteCountCoverage(segment: RepoSyncSegmentRecord, expected: number | null | undefined): boolean { + return Boolean(segment.completedAt && expected !== null && expected !== undefined && segment.fetchedCount >= expected); +} + +function expectedForRequiredSegment(segment: RepoSyncSegmentRecord, repoTotals: RepoGithubTotalsSnapshotRecord | undefined): number | null | undefined { + if (!repoTotals) return segment.expectedCount; + switch (segment.segment) { + case "metadata": + return 1; + case "labels": + return repoTotals.labelsTotal; + case "open_issues": + return repoTotals.openIssuesTotal; + case "open_pull_requests": + case "pull_request_files": + case "pull_request_reviews": + case "check_summaries": + return repoTotals.openPullRequestsTotal; + default: + return segment.expectedCount; + } +} diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 741ebc7a66..b989dc5d89 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -1,12 +1,23 @@ import type { AdvisoryFinding, BountyRecord, + CheckSummaryRecord, + CollisionEdgeRecord, + ContributorRepoStatRecord, IssueRecord, + PullRequestFileRecord, PullRequestRecord, + PullRequestReviewRecord, + RecentMergedPullRequestRecord, + RegistrySnapshot, + RepoLabelRecord, + RepoSyncStateRecord, RepositoryRecord, RepositorySettings, + ScoringModelSnapshotRecord, } from "../types"; import type { PublicContributorProfile } from "../github/public"; +import type { GittensorContributorSnapshot } from "../gittensor/api"; import { nowIso } from "../utils/json"; export type ParticipationLane = "direct_pr" | "issue_discovery" | "split" | "inactive" | "unknown"; @@ -23,11 +34,15 @@ export type LaneAdvice = { }; export type CollisionItem = { - type: "issue" | "pull_request"; + type: "issue" | "pull_request" | "recent_merged_pull_request"; number: number; title: string; authorLogin?: string | null | undefined; htmlUrl?: string | null | undefined; + labels?: string[] | undefined; + linkedIssues?: number[] | undefined; + changedFiles?: string[] | undefined; + body?: string | null | undefined; }; export type CollisionCluster = { @@ -61,10 +76,21 @@ export type QueueHealth = { stalePullRequests: number; maintainerAuthoredPullRequests: number; collisionClusters: number; + ageBuckets: { + under7Days: number; + days7To30: number; + over30Days: number; + }; + likelyReviewablePullRequests: number; }; findings: SignalFinding[]; }; +export type QueueSignalCounts = { + openIssues?: number | undefined; + openPullRequests?: number | undefined; +}; + export type ConfigQuality = { repoFullName: string; generatedAt: string; @@ -77,10 +103,44 @@ export type ConfigQuality = { findings: SignalFinding[]; }; +export type LabelAudit = { + repoFullName: string; + generatedAt: string; + configuredLabels: string[]; + liveLabels: string[]; + observedLabels: Array<{ name: string; count: number; configured: boolean; existsOnGitHub: boolean }>; + missingConfiguredLabels: string[]; + suspiciousConfiguredLabels: string[]; + trustedPipelineReady: boolean; + findings: SignalFinding[]; +}; + export type ContributorProfile = { login: string; generatedAt: string; github: PublicContributorProfile; + source: "gittensor_api" | "github_cache"; + gittensor?: { + githubId: string; + githubUsername: string; + uid?: number | undefined; + hotkey?: string | undefined; + evaluatedAt?: string | undefined; + updatedAt?: string | undefined; + isEligible: boolean; + credibility: number; + eligibleRepoCount: number; + issueDiscoveryScore: number; + issueTokenScore: number; + issueCredibility: number; + isIssueEligible: boolean; + issueEligibleRepoCount: number; + alphaPerDay: number; + taoPerDay: number; + usdPerDay: number; + totals: GittensorContributorSnapshot["totals"]; + repositories: GittensorContributorSnapshot["repositories"]; + } | undefined; registeredRepoActivity: { pullRequests: number; mergedPullRequests: number; @@ -107,6 +167,152 @@ export type ContributorOpportunity = { warnings: string[]; }; +export type ContributorFit = { + login: string; + generatedAt: string; + profile: ContributorProfile; + summary: string; + languageFit: Array<{ repoFullName: string; language?: string | null; match: boolean }>; + repoStats: ContributorRepoStatRecord[]; + opportunities: ContributorOpportunity[]; + findings: SignalFinding[]; +}; + +export type ContributorRole = "outside_contributor" | "repo_maintainer" | "org_member" | "collaborator" | "owner" | "unknown"; + +export type RoleContext = { + login: string; + repoFullName: string; + generatedAt: string; + role: ContributorRole; + maintainerLane: boolean; + normalContributorEvidenceAllowed: boolean; + source: "github_association" | "repo_owner_match" | "gittensor_api" | "cache" | "unknown"; + association?: string | null | undefined; + reasons: string[]; + guidance: string; +}; + +export type ContributorOutcomeHistory = { + login: string; + generatedAt: string; + source: ContributorProfile["source"]; + totals: { + pullRequests: number; + mergedPullRequests: number; + openPullRequests: number; + closedPullRequests: number; + closedPullRequestRate: number; + issues: number; + openIssues: number; + closedIssues: number; + solvedIssues: number; + validSolvedIssues: number; + credibility: number; + issueCredibility: number; + }; + repoOutcomes: Array<{ + repoFullName: string; + role: ContributorRole; + lane: ParticipationLane; + maintainerLane: boolean; + pullRequests: number; + mergedPullRequests: number; + openPullRequests: number; + closedPullRequests: number; + closedPullRequestRate: number; + issues: number; + openIssues: number; + closedIssues: number; + solvedIssues: number; + validSolvedIssues: number; + credibility: number; + issueCredibility: number; + isEligible: boolean; + successLevel: "strong" | "emerging" | "weak" | "maintainer_context"; + strengths: string[]; + risks: string[]; + }>; + successPatterns: OutcomePattern[]; + failurePatterns: OutcomePattern[]; + summary: string; +}; + +export type OutcomePattern = { + repoFullName?: string | undefined; + title: string; + detail: string; + confidence: "high" | "medium" | "low"; +}; + +export type ContributorPatternReport = { + login: string; + generatedAt: string; + patternType: "success" | "failure"; + patterns: OutcomePattern[]; + summary: string; +}; + +export type RepoFitRecommendation = { + login: string; + repoFullName: string; + generatedAt: string; + roleContext: RoleContext; + lane: LaneAdvice; + recommendation: "pursue" | "cleanup_first" | "maintainer_lane" | "avoid_for_now" | "unknown"; + confidence: "high" | "medium" | "low"; + reasons: string[]; + risks: string[]; + nextActions: string[]; + rewardRisk?: Record | undefined; + reasoning?: string[] | undefined; + actionImpact?: Record | undefined; +}; + +export type MaintainerLaneReport = { + repoFullName: string; + generatedAt: string; + lane: LaneAdvice; + maintainerCut: number; + maintainerCutConfigured: boolean; + queueHealth: QueueHealth; + configQuality: ConfigQuality; + contributorIntakeHealth: ContributorIntakeHealth; + summary: string; + findings: SignalFinding[]; +}; + +export type MaintainerCutReadiness = { + repoFullName: string; + generatedAt: string; + ready: boolean; + maintainerCut: number; + recommendedAction: "leave_disabled" | "consider_small_cut" | "review_existing_cut" | "fix_config_first"; + reasons: string[]; + warnings: string[]; +}; + +export type ContributorIntakeHealth = { + repoFullName: string; + generatedAt: string; + level: "healthy" | "watch" | "strained" | "blocked"; + score: number; + queueHealth: Pick; + configLevel: ConfigQuality["level"]; + duplicateClusters: number; + reviewablePullRequests: number; + summary: string; + findings: SignalFinding[]; +}; + +export type PullRequestReviewIntelligence = PullRequestMaintainerPacket & { + roleContext: RoleContext; + outcomeContext?: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; + recommendation: RepoFitRecommendation["recommendation"] | "review" | "needs_author" | "watch" | "likely_duplicate" | "maintainer_lane"; + privateSummary: string; + reviewability?: Record | undefined; +}; + export type PreflightInput = { repoFullName: string; contributorLogin?: string | undefined; @@ -130,6 +336,23 @@ export type PreflightResult = { collisions: CollisionCluster[]; }; +export type LocalDiffPreflightInput = PreflightInput & { + changedLineCount?: number | undefined; + testFiles?: string[] | undefined; + commitMessage?: string | undefined; +}; + +export type LocalDiffPreflightResult = PreflightResult & { + localDiff: { + changedFileCount: number; + changedLineCount: number; + testFileCount: number; + codeFileCount: number; + inferredLinkedIssues: number[]; + summary: string; + }; +}; + export type MaintainerPacket = { repoFullName: string; generatedAt: string; @@ -146,6 +369,33 @@ export type MaintainerPacket = { suggestedActions: string[]; }; +export type PullRequestMaintainerPacket = { + repoFullName: string; + pullNumber: number; + generatedAt: string; + reviewPriority: "review" | "needs_author" | "watch"; + summary: string; + changeSummary: { + fileCount: number; + codeFileCount: number; + testFileCount: number; + additions: number; + deletions: number; + topPaths: string[]; + }; + reviewSignals: { + reviewCount: number; + approvalCount: number; + changeRequestCount: number; + checkFailureCount: number; + linkedIssues: number[]; + collisionClusters: number; + }; + findings: SignalFinding[]; + contributorNextSteps: string[]; + maintainerNotes: string[]; +}; + export type BountyAdvisory = { id: string; repoFullName: string; @@ -165,6 +415,93 @@ export type ContributorDetection = { priorIssues: number; }; +export type RegistryChangeReport = { + generatedAt: string; + currentSnapshotId?: string | undefined; + previousSnapshotId?: string | undefined; + addedRepos: string[]; + removedRepos: string[]; + changedRepos: Array<{ + repoFullName: string; + changes: string[]; + }>; + summary: string; +}; + +export type IssueQualityReport = { + repoFullName: string; + generatedAt: string; + lane: LaneAdvice; + issues: Array<{ + number: number; + title: string; + status: "ready" | "needs_proof" | "hold" | "do_not_use"; + score: number; + reasons: string[]; + warnings: string[]; + }>; + summary: string; +}; + +export type BurdenForecast = { + repoFullName: string; + generatedAt: string; + horizonDays: 7 | 30; + level: "low" | "medium" | "high" | "critical"; + forecast: { + projectedReviewLoad: number; + reviewablePullRequests: number; + stalePullRequests: number; + duplicateTrend: number; + queueGrowthRisk: number; + }; + findings: SignalFinding[]; + summary: string; +}; + +export type ContributorScoringProfile = { + login: string; + generatedAt: string; + scoringModelSnapshotId: string; + evidence: { + registeredRepoPullRequests: number; + mergedPullRequests: number; + openPullRequests: number; + stalePullRequests: number; + unlinkedPullRequests: number; + issueDiscoveryReports: number; + languageMatches: number; + credibilityAssumption: number; + }; + privateSignals: string[]; +}; + +export type ContributorStrategy = { + login: string; + generatedAt: string; + scoringModelSnapshotId: string; + summary: string; + bestFitRepos: Array<{ + repoFullName: string; + lane: ParticipationLane; + fit: ContributorOpportunity["fit"]; + opportunityScore: number; + privateScoringReadiness: "good" | "caution" | "hold"; + reasons: string[]; + warnings: string[]; + }>; + avoidRepos: Array<{ repoFullName: string; reason: string }>; + cleanupFirst: Array<{ repoFullName: string; reason: string }>; + maintainerLaneRepos: Array<{ repoFullName: string; reason: string }>; + successPatterns: OutcomePattern[]; + failurePatterns: OutcomePattern[]; + laneWarnings: string[]; + nextActions: string[]; + rewardRisk?: Record | undefined; + reasoning?: string[] | undefined; + actionImpact?: string[] | undefined; +}; + const STOPWORDS = new Set([ "the", "and", @@ -183,6 +520,9 @@ const STOPWORDS = new Set([ "update", "improve", ]); +const MAX_COLLISION_PAIRWISE_ISSUES = 80; +const MAX_COLLISION_PAIRWISE_PULL_REQUESTS = 120; +const MAX_COLLISION_PAIRWISE_RECENT_MERGES = 40; export function buildLaneAdvice(repo: RepositoryRecord | null, fullName: string): LaneAdvice { const config = repo?.registryConfig; @@ -241,13 +581,27 @@ export function buildLaneAdvice(repo: RepositoryRecord | null, fullName: string) }; } -export function buildCollisionReport(repoFullName: string, issues: IssueRecord[], pullRequests: PullRequestRecord[]): CollisionReport { +export function buildCollisionReport( + repoFullName: string, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + recentMergedPullRequests: RecentMergedPullRequestRecord[] = [], +): CollisionReport { const openIssues = issues.filter((issue) => issue.state === "open"); const openPullRequests = pullRequests.filter((pr) => pr.state === "open"); const clusters = new Map(); + const pullRequestsByLinkedIssue = new Map(); + + for (const pr of openPullRequests) { + for (const issueNumber of pr.linkedIssues) { + const linkedPrs = pullRequestsByLinkedIssue.get(issueNumber) ?? []; + linkedPrs.push(pr); + pullRequestsByLinkedIssue.set(issueNumber, linkedPrs); + } + } for (const issue of openIssues) { - const linkedPrs = openPullRequests.filter((pr) => pr.linkedIssues.includes(issue.number)); + const linkedPrs = pullRequestsByLinkedIssue.get(issue.number) ?? []; if (linkedPrs.length === 0) continue; const items = [issueItem(issue), ...linkedPrs.map(prItem)]; clusters.set(`issue-${issue.number}`, { @@ -258,13 +612,31 @@ export function buildCollisionReport(repoFullName: string, issues: IssueRecord[] }); } - const items = [...openIssues.map(issueItem), ...openPullRequests.map(prItem)]; + const pairwiseIssues = boundedCollisionIssues(openIssues, openPullRequests); + const pairwisePullRequests = openPullRequests.slice(0, MAX_COLLISION_PAIRWISE_PULL_REQUESTS); + const pairwiseRecentMergedPullRequests = recentMergedPullRequests.slice(0, MAX_COLLISION_PAIRWISE_RECENT_MERGES); + const items = [...pairwiseIssues.map(issueItem), ...pairwisePullRequests.map(prItem), ...pairwiseRecentMergedPullRequests.map(recentMergedItem)]; + const itemTerms = new Map(); + for (const item of items) itemTerms.set(itemKey(item), collisionTerms(item)); for (let leftIndex = 0; leftIndex < items.length; leftIndex += 1) { for (let rightIndex = leftIndex + 1; rightIndex < items.length; rightIndex += 1) { const left = items[leftIndex]; const right = items[rightIndex]; if (!left || !right) continue; - const overlap = titleOverlap(left.title, right.title); + const sharedIssue = (left.linkedIssues ?? []).find((issue) => (right.linkedIssues ?? []).includes(issue)); + if (sharedIssue) { + const key = [itemKey(left), itemKey(right)].sort().join("--"); + if (!clusters.has(key)) { + clusters.set(key, { + id: key, + risk: right.type === "recent_merged_pull_request" || left.type === "recent_merged_pull_request" ? "medium" : "high", + reason: `Items reference the same linked issue #${sharedIssue}.`, + items: [left, right], + }); + } + continue; + } + const overlap = termOverlap(itemTerms.get(itemKey(left)) ?? collisionTerms(left), itemTerms.get(itemKey(right)) ?? collisionTerms(right)); if (overlap.score < 0.58 || overlap.shared < 2) continue; const key = [itemKey(left), itemKey(right)].sort().join("--"); if (clusters.has(key)) continue; @@ -284,7 +656,7 @@ export function buildCollisionReport(repoFullName: string, issues: IssueRecord[] summary: { clusterCount: clusterList.length, highRiskCount: clusterList.filter((cluster) => cluster.risk === "high").length, - itemsReviewed: openIssues.length + openPullRequests.length, + itemsReviewed: openIssues.length + openPullRequests.length + recentMergedPullRequests.length, }, clusters: clusterList, }; @@ -295,19 +667,33 @@ export function buildQueueHealth( issues: IssueRecord[], pullRequests: PullRequestRecord[], collisions: CollisionReport, + countOverrides: QueueSignalCounts = {}, ): QueueHealth { const repoFullName = repo?.fullName ?? collisions.repoFullName; const openIssues = issues.filter((issue) => issue.state === "open"); const openPullRequests = pullRequests.filter((pr) => pr.state === "open"); + const openIssueCount = Math.max(openIssues.length, countOverrides.openIssues ?? 0); + const openPullRequestCount = Math.max(openPullRequests.length, countOverrides.openPullRequests ?? 0); const unlinkedPullRequests = openPullRequests.filter((pr) => pr.linkedIssues.length === 0); const stalePullRequests = openPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) >= 14); const maintainerAuthoredPullRequests = openPullRequests.filter((pr) => isMaintainerAssociation(pr.authorAssociation)); + const likelyReviewablePullRequests = openPullRequests.filter((pr) => pr.linkedIssues.length > 0 && daysSince(pr.updatedAt ?? pr.createdAt) < 30).length; + const ageBuckets = { + under7Days: openPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) < 7).length, + days7To30: openPullRequests.filter((pr) => { + const age = daysSince(pr.updatedAt ?? pr.createdAt); + return age >= 7 && age <= 30; + }).length, + over30Days: openPullRequests.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) > 30).length, + }; const burdenScore = clamp( - openPullRequests.length * 6 + - openIssues.length + + openPullRequestCount * 6 + + openIssueCount + unlinkedPullRequests.length * 8 + stalePullRequests.length * 6 + - collisions.summary.clusterCount * 10, + ageBuckets.over30Days * 4 + + collisions.summary.clusterCount * 10 - + likelyReviewablePullRequests * 2, 0, 100, ); @@ -344,14 +730,16 @@ export function buildQueueHealth( generatedAt: nowIso(), burdenScore, level, - summary: `Queue burden is ${level} with ${openPullRequests.length} open PR(s), ${openIssues.length} open issue(s), and ${collisions.summary.clusterCount} overlap cluster(s).`, + summary: `Queue burden is ${level} with ${openPullRequestCount} open PR(s), ${openIssueCount} open issue(s), and ${collisions.summary.clusterCount} overlap cluster(s).`, signals: { - openIssues: openIssues.length, - openPullRequests: openPullRequests.length, + openIssues: openIssueCount, + openPullRequests: openPullRequestCount, unlinkedPullRequests: unlinkedPullRequests.length, stalePullRequests: stalePullRequests.length, maintainerAuthoredPullRequests: maintainerAuthoredPullRequests.length, collisionClusters: collisions.summary.clusterCount, + ageBuckets, + likelyReviewablePullRequests, }, findings, }; @@ -422,28 +810,189 @@ export function buildConfigQuality( }; } +export function buildLabelAudit(repo: RepositoryRecord | null, repoLabels: RepoLabelRecord[], issues: IssueRecord[], pullRequests: PullRequestRecord[], fullName: string): LabelAudit { + const configuredLabels = Object.keys(repo?.registryConfig?.labelMultipliers ?? {}).sort(); + const liveLabels = repoLabels.map((label) => label.name).sort(); + const observedCountMap = new Map(); + for (const label of repoLabels) observedCountMap.set(label.name, Math.max(observedCountMap.get(label.name) ?? 0, label.observedCount)); + for (const label of [...issues, ...pullRequests].flatMap((record) => record.labels)) { + observedCountMap.set(label, (observedCountMap.get(label) ?? 0) + 1); + } + const observedLabels = [...observedCountMap.entries()] + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .map(([name, count]) => ({ + name, + count, + configured: configuredLabels.includes(name), + existsOnGitHub: liveLabels.includes(name), + })); + const missingConfiguredLabels = configuredLabels.filter((label) => !liveLabels.includes(label)); + const suspiciousConfiguredLabels = configuredLabels.filter((label) => /^(status|state|source|bot|codex|gittensory|reward|score|miner|verified|risk)[:/-]?/i.test(label)); + const findings: SignalFinding[] = []; + if (repo?.registryConfig?.trustedLabelPipeline && missingConfiguredLabels.length > 0) { + findings.push({ + code: "trusted_labels_missing", + severity: "warning", + title: "Trusted label config references missing labels", + detail: `Configured label(s) not found in live GitHub labels: ${missingConfiguredLabels.join(", ")}.`, + action: "Create those labels or remove them from the registry config.", + }); + } + if (suspiciousConfiguredLabels.length > 0) { + findings.push({ + code: "suspicious_configured_labels", + severity: "warning", + title: "Configured labels look like status or source labels", + detail: `Potentially weak work-value labels: ${suspiciousConfiguredLabels.join(", ")}.`, + action: "Prefer labels that describe work type or user impact.", + }); + } + if (configuredLabels.length > 0 && observedLabels.filter((label) => label.configured).length === 0) { + findings.push({ + code: "configured_labels_unused", + severity: "info", + title: "Configured labels are not visible in cached work", + detail: "No configured label has been observed on cached issues or pull requests.", + }); + } + return { + repoFullName: fullName, + generatedAt: nowIso(), + configuredLabels, + liveLabels, + observedLabels, + missingConfiguredLabels, + suspiciousConfiguredLabels, + trustedPipelineReady: Boolean(repo?.registryConfig?.trustedLabelPipeline) && missingConfiguredLabels.length === 0 && suspiciousConfiguredLabels.length === 0, + findings, + }; +} + export function buildContributorProfile( login: string, github: PublicContributorProfile, pullRequests: PullRequestRecord[], issues: IssueRecord[], + repoStats: ContributorRepoStatRecord[] = [], + gittensorSnapshot: GittensorContributorSnapshot | null = null, ): ContributorProfile { + if (gittensorSnapshot) return buildGittensorContributorProfile(login, github, pullRequests, repoStats, gittensorSnapshot); + const authoredPullRequests = pullRequests.filter((pr) => sameLogin(pr.authorLogin, login)); const authoredIssues = issues.filter((issue) => sameLogin(issue.authorLogin, login)); const mergedPullRequests = authoredPullRequests.filter((pr) => pr.mergedAt || pr.state === "merged"); - const reposTouched = [...new Set([...authoredPullRequests, ...authoredIssues].map((record) => record.repoFullName))].sort(); - const dominantLabels = topItems([...authoredPullRequests, ...authoredIssues].flatMap((record) => record.labels), 8); - const unlinkedOpenPullRequests = authoredPullRequests.filter((pr) => pr.state === "open" && pr.linkedIssues.length === 0).length; + const matchingStats = repoStats.filter((stat) => sameLogin(stat.login, login)); + const statPullRequests = matchingStats.reduce((sum, stat) => sum + stat.pullRequests, 0); + const statMergedPullRequests = matchingStats.reduce((sum, stat) => sum + stat.mergedPullRequests, 0); + const statIssues = matchingStats.reduce((sum, stat) => sum + stat.issues, 0); + const reposTouched = [ + ...new Set([ + ...authoredPullRequests.map((record) => record.repoFullName), + ...authoredIssues.map((record) => record.repoFullName), + ...matchingStats.filter((stat) => stat.pullRequests > 0 || stat.issues > 0).map((stat) => stat.repoFullName), + ]), + ].sort(); + const dominantLabels = topItems( + [ + ...authoredPullRequests.flatMap((record) => record.labels), + ...authoredIssues.flatMap((record) => record.labels), + ...matchingStats.flatMap((stat) => stat.dominantLabels), + ], + 8, + ); + const unlinkedOpenPullRequests = Math.max( + authoredPullRequests.filter((pr) => pr.state === "open" && pr.linkedIssues.length === 0).length, + matchingStats.reduce((sum, stat) => sum + stat.unlinkedPullRequests, 0), + ); const maintainerAssociatedPullRequests = authoredPullRequests.filter((pr) => isMaintainerAssociation(pr.authorAssociation)).length; - const evidenceScore = clamp(mergedPullRequests.length * 15 + reposTouched.length * 10 + authoredIssues.length * 2 - unlinkedOpenPullRequests * 8, 0, 100); + const pullRequestCount = Math.max(authoredPullRequests.length, statPullRequests); + const mergedPullRequestCount = Math.max(mergedPullRequests.length, statMergedPullRequests); + const issueCount = Math.max(authoredIssues.length, statIssues); + const evidenceScore = clamp(mergedPullRequestCount * 15 + reposTouched.length * 10 + issueCount * 2 - unlinkedOpenPullRequests * 8, 0, 100); return { login, generatedAt: nowIso(), github, + source: "github_cache", registeredRepoActivity: { - pullRequests: authoredPullRequests.length, - mergedPullRequests: mergedPullRequests.length, - issues: authoredIssues.length, + pullRequests: pullRequestCount, + mergedPullRequests: mergedPullRequestCount, + issues: issueCount, + reposTouched, + dominantLabels, + }, + trustSignals: { + evidenceScore, + level: evidenceScore >= 60 ? "established" : evidenceScore >= 25 ? "emerging" : "new", + unlinkedOpenPullRequests, + maintainerAssociatedPullRequests, + }, + }; +} + +function buildGittensorContributorProfile( + login: string, + github: PublicContributorProfile, + pullRequests: PullRequestRecord[], + repoStats: ContributorRepoStatRecord[], + snapshot: GittensorContributorSnapshot, +): ContributorProfile { + const matchingStats = repoStats.filter((stat) => sameLogin(stat.login, snapshot.githubUsername) || sameLogin(stat.login, login)); + const unlinkedOpenPullRequests = matchingStats.reduce((sum, stat) => sum + stat.unlinkedPullRequests, 0); + const maintainerAssociatedPullRequests = pullRequests.filter((pr) => sameLogin(pr.authorLogin, login) && isMaintainerAssociation(pr.authorAssociation)).length; + const reposTouched = snapshot.repositories + .filter((repo) => repo.pullRequests + repo.openIssues + repo.closedIssues > 0) + .map((repo) => repo.repoFullName) + .sort(); + const dominantLabels = topItems( + [ + ...snapshot.pullRequests.flatMap((pr) => (pr.label ? [pr.label] : [])), + ...snapshot.issueLabels, + ...matchingStats.flatMap((stat) => stat.dominantLabels), + ], + 8, + ); + const issues = snapshot.totals.openIssues + snapshot.totals.closedIssues; + const evidenceScore = clamp( + snapshot.totals.mergedPullRequests * 15 + + reposTouched.length * 10 + + issues * 2 + + snapshot.totals.validSolvedIssues * 10 - + snapshot.totals.closedPullRequests * 4 - + unlinkedOpenPullRequests * 8, + 0, + 100, + ); + return { + login, + generatedAt: nowIso(), + github, + source: "gittensor_api", + gittensor: { + githubId: snapshot.githubId, + githubUsername: snapshot.githubUsername, + uid: snapshot.uid, + hotkey: snapshot.hotkey, + evaluatedAt: snapshot.evaluatedAt, + updatedAt: snapshot.updatedAt, + isEligible: snapshot.isEligible, + credibility: snapshot.credibility, + eligibleRepoCount: snapshot.eligibleRepoCount, + issueDiscoveryScore: snapshot.issueDiscoveryScore, + issueTokenScore: snapshot.issueTokenScore, + issueCredibility: snapshot.issueCredibility, + isIssueEligible: snapshot.isIssueEligible, + issueEligibleRepoCount: snapshot.issueEligibleRepoCount, + alphaPerDay: snapshot.alphaPerDay, + taoPerDay: snapshot.taoPerDay, + usdPerDay: snapshot.usdPerDay, + totals: snapshot.totals, + repositories: snapshot.repositories, + }, + registeredRepoActivity: { + pullRequests: snapshot.totals.pullRequests, + mergedPullRequests: snapshot.totals.mergedPullRequests, + issues, reposTouched, dominantLabels, }, @@ -461,28 +1010,36 @@ export function detectGittensorContributor( currentPr: PullRequestRecord, pullRequests: PullRequestRecord[], issues: IssueRecord[], + repoStats: ContributorRepoStatRecord[] = [], ): ContributorDetection { const priorPullRequests = pullRequests.filter( (pr) => sameLogin(pr.authorLogin, login) && !(pr.repoFullName === currentPr.repoFullName && pr.number === currentPr.number), ); const priorIssues = issues.filter((issue) => sameLogin(issue.authorLogin, login)); const priorMergedPullRequests = priorPullRequests.filter((pr) => pr.mergedAt || pr.state === "merged"); - if (priorMergedPullRequests.length > 0) { + const matchingStats = repoStats.filter((stat) => sameLogin(stat.login, login)); + const statPullRequests = matchingStats.reduce((sum, stat) => sum + stat.pullRequests, 0); + const statMergedPullRequests = matchingStats.reduce((sum, stat) => sum + stat.mergedPullRequests, 0); + const statIssues = matchingStats.reduce((sum, stat) => sum + stat.issues, 0); + const priorPullRequestCount = Math.max(priorPullRequests.length, statPullRequests); + const priorMergedPullRequestCount = Math.max(priorMergedPullRequests.length, statMergedPullRequests); + const priorIssueCount = Math.max(priorIssues.length, statIssues); + if (priorMergedPullRequestCount > 0) { return { detected: true, reason: "Contributor has prior merged PR activity in registered repos cached by Gittensory.", - priorPullRequests: priorPullRequests.length, - priorMergedPullRequests: priorMergedPullRequests.length, - priorIssues: priorIssues.length, + priorPullRequests: priorPullRequestCount, + priorMergedPullRequests: priorMergedPullRequestCount, + priorIssues: priorIssueCount, }; } - if (priorPullRequests.length > 0 || priorIssues.length > 0) { + if (priorPullRequestCount > 0 || priorIssueCount > 0) { return { detected: true, reason: "Contributor has prior registered-repo activity cached by Gittensory.", - priorPullRequests: priorPullRequests.length, - priorMergedPullRequests: priorMergedPullRequests.length, - priorIssues: priorIssues.length, + priorPullRequests: priorPullRequestCount, + priorMergedPullRequests: priorMergedPullRequestCount, + priorIssues: priorIssueCount, }; } return { @@ -554,6 +1111,432 @@ export function buildContributorOpportunities( return opportunities.sort((left, right) => right.score - left.score || left.repoFullName.localeCompare(right.repoFullName)).slice(0, 25); } +export function buildContributorFit( + profile: ContributorProfile, + repositories: RepositoryRecord[], + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + repoSyncStates: RepoSyncStateRecord[], + repoStats: ContributorRepoStatRecord[], +): ContributorFit { + const opportunities = buildContributorOpportunities(profile, repositories, issues, pullRequests); + const languageSet = new Set(profile.github.topLanguages.map((language) => language.toLowerCase())); + const syncByRepo = new Map(repoSyncStates.map((state) => [state.repoFullName, state])); + const languageFit = repositories + .filter((repo) => repo.isRegistered) + .map((repo) => { + const language = syncByRepo.get(repo.fullName)?.primaryLanguage ?? null; + return { + repoFullName: repo.fullName, + language, + match: Boolean(language && languageSet.has(language.toLowerCase())), + }; + }) + .sort((left, right) => Number(right.match) - Number(left.match) || left.repoFullName.localeCompare(right.repoFullName)); + const findings: SignalFinding[] = []; + const matchedLanguages = languageFit.filter((fit) => fit.match).length; + if (matchedLanguages === 0 && profile.github.topLanguages.length > 0) { + findings.push({ + code: "no_language_fit", + severity: "info", + title: "No strong language fit found in cached repo metadata", + detail: "The contributor's public GitHub languages do not match cached primary languages for registered repos.", + }); + } + const highQueueMatches = opportunities.filter((opportunity) => opportunity.warnings.some((warning) => /busy|queue/i.test(warning))); + if (highQueueMatches.length > 0) { + findings.push({ + code: "busy_queue_matches", + severity: "info", + title: "Some apparent fits have busy queues", + detail: `${highQueueMatches.length} ranked opportunity/opportunities carry queue-pressure warnings.`, + }); + } + return { + login: profile.login, + generatedAt: nowIso(), + profile, + summary: `${profile.login} has ${profile.registeredRepoActivity.pullRequests} ${profile.source === "gittensor_api" ? "Gittensor API" : "cached"} registered-repo PR(s), ${profile.registeredRepoActivity.mergedPullRequests} merged PR(s), and ${opportunities.length} ranked opportunity/opportunities.`, + languageFit, + repoStats, + opportunities, + findings, + }; +} + +export function buildRoleContext(args: { + login: string; + repo: RepositoryRecord | null; + repoFullName: string; + pullRequests?: PullRequestRecord[] | undefined; + issues?: IssueRecord[] | undefined; + profile?: ContributorProfile | null | undefined; +}): RoleContext { + const normalizedLogin = args.login.toLowerCase(); + const [owner] = args.repoFullName.split("/"); + const authoredAssociations = [ + ...(args.pullRequests ?? []).filter((pr) => pr.repoFullName === args.repoFullName && sameLogin(pr.authorLogin, args.login)).map((pr) => pr.authorAssociation), + ...(args.issues ?? []).filter((issue) => issue.repoFullName === args.repoFullName && sameLogin(issue.authorLogin, args.login)).map((issue) => issue.authorAssociation), + ].filter(Boolean) as string[]; + const officialRepo = args.profile?.gittensor?.repositories.find((repo) => repo.repoFullName.toLowerCase() === args.repoFullName.toLowerCase()); + const touchedByOfficial = Boolean(officialRepo && officialRepo.pullRequests + officialRepo.openIssues + officialRepo.closedIssues > 0); + const touchedByCache = Boolean( + args.profile?.registeredRepoActivity.reposTouched.some((repo) => repo.toLowerCase() === args.repoFullName.toLowerCase()) || + (args.pullRequests ?? []).some((pr) => pr.repoFullName === args.repoFullName && sameLogin(pr.authorLogin, args.login)) || + (args.issues ?? []).some((issue) => issue.repoFullName === args.repoFullName && sameLogin(issue.authorLogin, args.login)), + ); + + let role: ContributorRole = "unknown"; + let source: RoleContext["source"] = "unknown"; + const association = strongestAssociation(authoredAssociations); + if (owner?.toLowerCase() === normalizedLogin || args.repo?.owner.toLowerCase() === normalizedLogin) { + role = "owner"; + source = "repo_owner_match"; + } else if (association === "OWNER") { + role = "owner"; + source = "github_association"; + } else if (association === "MEMBER") { + role = "org_member"; + source = "github_association"; + } else if (association === "COLLABORATOR") { + role = "collaborator"; + source = "github_association"; + } else if (authoredAssociations.some(isMaintainerAssociation)) { + role = "repo_maintainer"; + source = "github_association"; + } else if (touchedByOfficial) { + role = "outside_contributor"; + source = "gittensor_api"; + } else if (touchedByCache) { + role = "outside_contributor"; + source = "cache"; + } + + const maintainerLane = role === "owner" || role === "org_member" || role === "collaborator" || role === "repo_maintainer"; + const reasons = [ + ...(source === "repo_owner_match" ? [`${args.login} appears to own ${args.repoFullName}.`] : []), + ...(source === "github_association" && association ? [`GitHub association for cached activity is ${association}.`] : []), + ...(source === "gittensor_api" ? ["Official Gittensor API shows activity on this repo."] : []), + ...(source === "cache" ? ["Cached GitHub activity shows activity on this repo."] : []), + ...(maintainerLane ? ["Maintainer-associated repo activity should be treated separately from normal contributor evidence."] : []), + ]; + return { + login: args.login, + repoFullName: args.repoFullName, + generatedAt: nowIso(), + role, + maintainerLane, + normalContributorEvidenceAllowed: !maintainerLane, + source, + association, + reasons: reasons.length > 0 ? reasons : ["No maintainer or contributor relationship is visible in current Gittensory data."], + guidance: maintainerLane + ? "Use maintainer-lane guidance for repo health, queue quality, labels, contributor triage, and maintainer_cut readiness; do not count this repo as normal contributor evidence for this user." + : role === "outside_contributor" + ? "Use contributor-lane guidance: fit, duplicate risk, open/closed pressure, linked issue quality, and review hygiene." + : "Relationship is unknown; rely on public preflight signals until more GitHub or Gittensor data is available.", + }; +} + +export function buildContributorOutcomeHistory(args: { + login: string; + profile: ContributorProfile; + repositories: RepositoryRecord[]; + pullRequests: PullRequestRecord[]; + issues: IssueRecord[]; + repoStats: ContributorRepoStatRecord[]; +}): ContributorOutcomeHistory { + const repoByName = new Map(args.repositories.map((repo) => [repo.fullName.toLowerCase(), repo])); + const repoNamesByKey = new Map(); + const addRepoName = (repoFullName: string, priority: number) => { + const key = repoFullName.toLowerCase(); + const current = repoNamesByKey.get(key); + if (!current || priority >= current.priority) repoNamesByKey.set(key, { repoFullName, priority }); + }; + for (const repo of args.repositories) addRepoName(repo.fullName, 1); + for (const repoFullName of args.profile.registeredRepoActivity.reposTouched) addRepoName(repoFullName, 2); + for (const stat of args.repoStats.filter((stat) => sameLogin(stat.login, args.login))) addRepoName(stat.repoFullName, 2); + for (const pr of args.pullRequests.filter((pr) => sameLogin(pr.authorLogin, args.login))) addRepoName(pr.repoFullName, 3); + for (const issue of args.issues.filter((issue) => sameLogin(issue.authorLogin, args.login))) addRepoName(issue.repoFullName, 3); + for (const repo of args.profile.gittensor?.repositories ?? []) addRepoName(repo.repoFullName, 4); + const repoNames = new Set([...repoNamesByKey.values()].map((entry) => entry.repoFullName)); + const officialByRepo = new Map(args.profile.gittensor?.repositories.map((repo) => [repo.repoFullName.toLowerCase(), repo]) ?? []); + const statsByRepo = new Map(args.repoStats.filter((stat) => sameLogin(stat.login, args.login)).map((stat) => [stat.repoFullName.toLowerCase(), stat])); + const repoOutcomes = [...repoNames] + .sort() + .map((repoFullName) => { + const repo = repoByName.get(repoFullName.toLowerCase()) ?? null; + const official = officialByRepo.get(repoFullName.toLowerCase()); + const cachedStat = statsByRepo.get(repoFullName.toLowerCase()); + const cachedPrs = args.pullRequests.filter((pr) => pr.repoFullName === repoFullName && sameLogin(pr.authorLogin, args.login)); + const cachedIssues = args.issues.filter((issue) => issue.repoFullName === repoFullName && sameLogin(issue.authorLogin, args.login)); + const pullRequests = official?.pullRequests ?? Math.max(cachedPrs.length, cachedStat?.pullRequests ?? 0); + const mergedPullRequests = official?.mergedPullRequests ?? Math.max(cachedPrs.filter((pr) => pr.mergedAt || pr.state === "merged").length, cachedStat?.mergedPullRequests ?? 0); + const openPullRequests = official?.openPullRequests ?? Math.max(cachedPrs.filter((pr) => pr.state === "open").length, cachedStat?.openPullRequests ?? 0); + const closedPullRequests = official?.closedPullRequests ?? Math.max(cachedPrs.filter((pr) => pr.state === "closed").length, pullRequests - mergedPullRequests - openPullRequests, 0); + const openIssues = official?.openIssues ?? cachedIssues.filter((issue) => issue.state === "open").length; + const closedIssues = official?.closedIssues ?? cachedIssues.filter((issue) => issue.state !== "open").length; + const solvedIssues = official?.solvedIssues ?? 0; + const validSolvedIssues = official?.validSolvedIssues ?? 0; + const roleContext = buildRoleContext({ login: args.login, repo, repoFullName, pullRequests: args.pullRequests, issues: args.issues, profile: args.profile }); + const closedPullRequestRate = rate(closedPullRequests, pullRequests); + const lane = buildLaneAdvice(repo, repoFullName).lane; + const risks = [ + ...(roleContext.maintainerLane ? ["Maintainer-lane repo; do not treat this as normal contributor evidence."] : []), + ...(closedPullRequestRate >= 0.3 ? [`Closed PR rate is ${percent(closedPullRequestRate)}.`] : []), + ...(openPullRequests >= 5 ? [`${openPullRequests} open PR(s) create review and threshold pressure.`] : []), + ...(openIssues >= 10 && validSolvedIssues === 0 ? ["Issue activity is mostly open/raw, not valid solved issue-discovery evidence."] : []), + ...((official?.credibility ?? 1) < 0.8 ? [`Repo credibility is ${round(official?.credibility ?? 0)}.`] : []), + ]; + const strengths = [ + ...(mergedPullRequests >= 5 ? [`${mergedPullRequests} merged PR(s) show strong repo-specific history.`] : []), + ...(mergedPullRequests > 0 && closedPullRequestRate < 0.25 ? ["Merged history is stronger than closed-PR pressure."] : []), + ...(validSolvedIssues > 0 ? [`${validSolvedIssues} valid solved issue-discovery report(s).`] : []), + ...((official?.credibility ?? 0) >= 0.9 ? ["Official repo credibility is strong."] : []), + ]; + const successLevel: ContributorOutcomeHistory["repoOutcomes"][number]["successLevel"] = roleContext.maintainerLane + ? "maintainer_context" + : mergedPullRequests >= 5 && closedPullRequestRate < 0.3 + ? "strong" + : mergedPullRequests > 0 + ? "emerging" + : "weak"; + return { + repoFullName, + role: roleContext.role, + lane, + maintainerLane: roleContext.maintainerLane, + pullRequests, + mergedPullRequests, + openPullRequests, + closedPullRequests, + closedPullRequestRate, + issues: openIssues + closedIssues, + openIssues, + closedIssues, + solvedIssues, + validSolvedIssues, + credibility: official?.credibility ?? 0, + issueCredibility: official?.issueCredibility ?? 0, + isEligible: Boolean(official?.isEligible), + successLevel, + strengths: strengths.length > 0 ? strengths : ["No strong success pattern detected yet."], + risks: risks.length > 0 ? risks : ["No major repo-specific risk detected from current signals."], + }; + }) + .filter((outcome) => outcome.pullRequests + outcome.issues > 0 || outcome.maintainerLane); + const totals = { + pullRequests: args.profile.gittensor?.totals.pullRequests ?? args.profile.registeredRepoActivity.pullRequests, + mergedPullRequests: args.profile.gittensor?.totals.mergedPullRequests ?? args.profile.registeredRepoActivity.mergedPullRequests, + openPullRequests: args.profile.gittensor?.totals.openPullRequests ?? args.repoStats.reduce((sum, stat) => sum + stat.openPullRequests, 0), + closedPullRequests: args.profile.gittensor?.totals.closedPullRequests ?? repoOutcomes.reduce((sum, outcome) => sum + outcome.closedPullRequests, 0), + closedPullRequestRate: 0, + issues: args.profile.registeredRepoActivity.issues, + openIssues: args.profile.gittensor?.totals.openIssues ?? repoOutcomes.reduce((sum, outcome) => sum + outcome.openIssues, 0), + closedIssues: args.profile.gittensor?.totals.closedIssues ?? repoOutcomes.reduce((sum, outcome) => sum + outcome.closedIssues, 0), + solvedIssues: args.profile.gittensor?.totals.solvedIssues ?? 0, + validSolvedIssues: args.profile.gittensor?.totals.validSolvedIssues ?? 0, + credibility: args.profile.gittensor?.credibility ?? 0, + issueCredibility: args.profile.gittensor?.issueCredibility ?? 0, + }; + totals.closedPullRequestRate = rate(totals.closedPullRequests, totals.pullRequests); + const history = { + login: args.login, + generatedAt: nowIso(), + source: args.profile.source, + totals, + repoOutcomes, + successPatterns: [] as OutcomePattern[], + failurePatterns: [] as OutcomePattern[], + summary: "", + }; + history.successPatterns = outcomeSuccessPatterns(history); + history.failurePatterns = outcomeFailurePatterns(history); + history.summary = `${args.login} has ${totals.pullRequests} official/cached PR(s), ${totals.mergedPullRequests} merged, ${totals.closedPullRequests} closed, ${totals.openPullRequests} open, and ${history.repoOutcomes.length} repo-specific outcome profile(s).`; + return history; +} + +export function buildContributorPatternReport(history: ContributorOutcomeHistory, patternType: "success" | "failure"): ContributorPatternReport { + const patterns = patternType === "success" ? history.successPatterns : history.failurePatterns; + return { + login: history.login, + generatedAt: nowIso(), + patternType, + patterns, + summary: `${patterns.length} ${patternType} pattern(s) generated from ${history.source === "gittensor_api" ? "official Gittensor API plus cached GitHub" : "cached GitHub"} evidence.`, + }; +} + +export function buildRepoFitRecommendation(args: { + login: string; + repo: RepositoryRecord | null; + repoFullName: string; + profile: ContributorProfile; + outcomeHistory: ContributorOutcomeHistory; + issues: IssueRecord[]; + pullRequests: PullRequestRecord[]; +}): RepoFitRecommendation { + const roleContext = buildRoleContext({ login: args.login, repo: args.repo, repoFullName: args.repoFullName, pullRequests: args.pullRequests, issues: args.issues, profile: args.profile }); + const lane = buildLaneAdvice(args.repo, args.repoFullName); + const repoOutcome = args.outcomeHistory.repoOutcomes.find((outcome) => outcome.repoFullName.toLowerCase() === args.repoFullName.toLowerCase()); + const collisions = buildCollisionReport(args.repoFullName, args.issues, args.pullRequests); + const queueHealth = buildQueueHealth(args.repo, args.issues, args.pullRequests, collisions); + const risks = [ + ...(repoOutcome?.risks ?? []), + ...(lane.lane === "inactive" || lane.lane === "unknown" ? [lane.summary] : []), + ...(queueHealth.level === "high" || queueHealth.level === "critical" ? [`Queue burden is ${queueHealth.level}.`] : []), + ...(collisions.summary.highRiskCount > 0 ? [`${collisions.summary.highRiskCount} high-risk collision cluster(s).`] : []), + ]; + const reasons = [ + lane.summary, + ...(repoOutcome?.strengths ?? []), + ...(roleContext.reasons ?? []), + ]; + const recommendation: RepoFitRecommendation["recommendation"] = roleContext.maintainerLane + ? "maintainer_lane" + : lane.lane === "unknown" || lane.lane === "inactive" + ? "unknown" + : (repoOutcome?.openPullRequests ?? 0) >= 5 || (repoOutcome?.closedPullRequestRate ?? 0) >= 0.35 || queueHealth.level === "critical" + ? "cleanup_first" + : risks.some((risk) => /collision|Queue burden is high|direct-PR first/i.test(risk)) + ? "avoid_for_now" + : "pursue"; + const nextActions = [ + ...(recommendation === "maintainer_lane" ? ["Use repo-health and contributor-triage actions instead of normal contributor work for this repo."] : []), + ...(recommendation === "cleanup_first" ? ["Close, land, or update existing open work before opening another PR."] : []), + ...(recommendation === "avoid_for_now" ? ["Pick a lower-collision or lower-burden repo unless the work is already well proven."] : []), + ...(recommendation === "pursue" ? ["Run local diff preflight, check collisions, and keep the submission tightly scoped."] : []), + ...(lane.lane === "issue_discovery" ? ["Use issue-discovery quality gates; do not file issues you plan to solve yourself."] : []), + ]; + return { + login: args.login, + repoFullName: args.repoFullName, + generatedAt: nowIso(), + roleContext, + lane, + recommendation, + confidence: args.profile.source === "gittensor_api" || repoOutcome ? "high" : args.repo ? "medium" : "low", + reasons: [...new Set(reasons)], + risks: [...new Set(risks)], + nextActions: [...new Set(nextActions.length > 0 ? nextActions : ["Gather more repo-specific evidence before acting."])], + }; +} + +export function buildContributorIntakeHealth( + repo: RepositoryRecord | null, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + fullName: string, + collisions = buildCollisionReport(fullName, issues, pullRequests), + countOverrides: QueueSignalCounts = {}, +): ContributorIntakeHealth { + const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions, countOverrides); + const configQuality = buildConfigQuality(repo, issues, pullRequests, fullName); + const configPenalty = configQuality.level === "fragile" ? 30 : configQuality.level === "needs_attention" ? 18 : configQuality.level === "good" ? 6 : 0; + const score = clamp(100 - queueHealth.burdenScore * 0.55 - collisions.summary.clusterCount * 8 - configPenalty, 0, 100); + const level: ContributorIntakeHealth["level"] = score >= 75 ? "healthy" : score >= 50 ? "watch" : score >= 25 ? "strained" : "blocked"; + const findings: SignalFinding[] = [ + ...(queueHealth.findings ?? []), + ...(configQuality.findings ?? []), + ...(collisions.summary.highRiskCount > 0 + ? [ + { + code: "high_risk_collisions", + severity: "warning" as const, + title: "High-risk duplicate clusters are present", + detail: `${collisions.summary.highRiskCount} high-risk collision cluster(s) should be triaged before inviting more contributor work.`, + }, + ] + : []), + ]; + return { + repoFullName: fullName, + generatedAt: nowIso(), + level, + score, + queueHealth: { + burdenScore: queueHealth.burdenScore, + level: queueHealth.level, + signals: queueHealth.signals, + }, + configLevel: configQuality.level, + duplicateClusters: collisions.summary.clusterCount, + reviewablePullRequests: queueHealth.signals.likelyReviewablePullRequests, + summary: `Contributor intake is ${level}; queue burden ${queueHealth.burdenScore}/100, config ${configQuality.level}, duplicate clusters ${collisions.summary.clusterCount}.`, + findings, + }; +} + +export function buildMaintainerCutReadiness( + repo: RepositoryRecord | null, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + fullName: string, + countOverrides: QueueSignalCounts = {}, + collisions = buildCollisionReport(fullName, issues, pullRequests), +): MaintainerCutReadiness { + const configQuality = buildConfigQuality(repo, issues, pullRequests, fullName); + const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions, countOverrides); + const maintainerCut = repo?.registryConfig?.maintainerCut ?? 0; + const warnings = [ + ...(!repo?.isRegistered ? ["Repository is not registered in the local snapshot."] : []), + ...(configQuality.level === "fragile" || configQuality.level === "needs_attention" ? [`Config quality is ${configQuality.level}.`] : []), + ...(queueHealth.level === "high" || queueHealth.level === "critical" ? [`Queue burden is ${queueHealth.level}.`] : []), + ]; + const ready = Boolean(repo?.isRegistered) && warnings.length === 0; + return { + repoFullName: fullName, + generatedAt: nowIso(), + ready, + maintainerCut, + recommendedAction: maintainerCut > 0 ? "review_existing_cut" : ready ? "consider_small_cut" : repo?.isRegistered ? "fix_config_first" : "leave_disabled", + reasons: [ + ...(maintainerCut > 0 ? [`Current maintainer_cut is ${maintainerCut}.`] : ["No maintainer_cut is configured."]), + ...(ready ? ["Repo config and queue signals are clean enough to discuss maintainer-lane economics privately."] : []), + ], + warnings, + }; +} + +export function buildMaintainerLaneReport( + repo: RepositoryRecord | null, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + fullName: string, + collisions = buildCollisionReport(fullName, issues, pullRequests), + countOverrides: QueueSignalCounts = {}, +): MaintainerLaneReport { + const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions, countOverrides); + const configQuality = buildConfigQuality(repo, issues, pullRequests, fullName); + const contributorIntakeHealth = buildContributorIntakeHealth(repo, issues, pullRequests, fullName, collisions, countOverrides); + const maintainerCut = repo?.registryConfig?.maintainerCut ?? 0; + const findings: SignalFinding[] = [ + ...(maintainerCut === 0 + ? [ + { + code: "maintainer_cut_not_configured", + severity: "info" as const, + title: "Maintainer cut is not configured", + detail: "Maintainer-associated work is separate from normal contributor evidence; maintainer_cut is the explicit maintainer lane when configured.", + }, + ] + : []), + ...contributorIntakeHealth.findings, + ]; + return { + repoFullName: fullName, + generatedAt: nowIso(), + lane: buildLaneAdvice(repo, fullName), + maintainerCut, + maintainerCutConfigured: maintainerCut > 0, + queueHealth, + configQuality, + contributorIntakeHealth, + summary: `Maintainer lane for ${fullName}: maintainer_cut ${maintainerCut > 0 ? "configured" : "not configured"}, contributor intake ${contributorIntakeHealth.level}.`, + findings, + }; +} + export function buildPreflightResult( input: PreflightInput, repo: RepositoryRecord | null, @@ -618,6 +1601,62 @@ export function buildPreflightResult( }; } +export function buildLocalDiffPreflightResult( + input: LocalDiffPreflightInput, + repo: RepositoryRecord | null, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], +): LocalDiffPreflightResult { + const changedFiles = [...new Set([...(input.changedFiles ?? []), ...(input.testFiles ?? [])])]; + const linkedFromCommit = extractLinkedIssueNumbers([input.commitMessage, input.body, input.title].filter(Boolean).join("\n")); + const base = buildPreflightResult( + { + ...input, + changedFiles, + linkedIssues: [...new Set([...(input.linkedIssues ?? []), ...linkedFromCommit])], + tests: [...(input.tests ?? []), ...(input.testFiles ?? [])], + }, + repo, + issues, + pullRequests, + ); + const codeFileCount = changedFiles.filter(isCodeFile).length; + const testFileCount = changedFiles.filter(isTestFile).length; + const changedLineCount = input.changedLineCount ?? 0; + const findings = [...base.findings]; + if (changedLineCount > 800) { + findings.push({ + code: "large_local_diff", + severity: "warning", + title: "Local diff is large", + detail: "The planned change is large enough to create avoidable review burden.", + action: "Split unrelated work or clearly explain why the scope needs to stay together.", + }); + } + if (codeFileCount > 0 && testFileCount === 0) { + findings.push({ + code: "local_diff_missing_tests", + severity: "warning", + title: "Local diff has code changes without test files", + detail: "Changed paths include code files but no test paths.", + action: "Add regression coverage or include concrete validation evidence.", + }); + } + return { + ...base, + findings, + status: base.status === "hold" ? "hold" : findings.some((finding) => finding.severity === "warning" || finding.severity === "critical") ? "needs_work" : "ready", + localDiff: { + changedFileCount: changedFiles.length, + changedLineCount, + testFileCount, + codeFileCount, + inferredLinkedIssues: linkedFromCommit, + summary: `${changedFiles.length} file(s), ${changedLineCount} changed line(s), ${testFileCount} test file(s), ${codeFileCount} code file(s).`, + }, + }; +} + export function buildMaintainerPacket( repo: RepositoryRecord | null, issues: IssueRecord[], @@ -664,6 +1703,450 @@ export function buildMaintainerPacket( }; } +export function buildPullRequestMaintainerPacket(args: { + repo: RepositoryRecord | null; + pullRequest: PullRequestRecord | null; + issues: IssueRecord[]; + pullRequests: PullRequestRecord[]; + files: PullRequestFileRecord[]; + reviews: PullRequestReviewRecord[]; + checks: CheckSummaryRecord[]; + recentMergedPullRequests: RecentMergedPullRequestRecord[]; + repoFullName: string; + pullNumber: number; +}): PullRequestMaintainerPacket { + const pr = args.pullRequest; + const collisions = buildCollisionReport(args.repoFullName, args.issues, args.pullRequests, args.recentMergedPullRequests); + const prCollisionCount = pr + ? collisions.clusters.filter((cluster) => cluster.items.some((item) => item.type === "pull_request" && item.number === pr.number)).length + : 0; + const codeFiles = args.files.filter((file) => isCodeFile(file.path)); + const testFiles = args.files.filter((file) => isTestFile(file.path)); + const additions = args.files.reduce((sum, file) => sum + file.additions, 0); + const deletions = args.files.reduce((sum, file) => sum + file.deletions, 0); + const approvalCount = args.reviews.filter((review) => review.state.toUpperCase() === "APPROVED").length; + const changeRequestCount = args.reviews.filter((review) => review.state.toUpperCase() === "CHANGES_REQUESTED").length; + const checkFailureCount = args.checks.filter((check) => check.conclusion === "failure" || check.conclusion === "timed_out" || check.conclusion === "cancelled").length; + const findings: SignalFinding[] = []; + if (!pr) { + findings.push({ + code: "pr_not_cached", + severity: "warning", + title: "PR is not cached", + detail: "Gittensory does not have this pull request in the local cache.", + }); + } else { + if (pr.linkedIssues.length === 0) { + findings.push({ + code: "missing_linked_issue", + severity: "warning", + title: "No linked issue detected", + detail: "The PR body does not include a closing issue reference in cached metadata.", + action: "Ask for issue context or a no-issue rationale before deep review.", + }); + } + if (prCollisionCount > 0) { + findings.push({ + code: "pr_collision_context", + severity: "warning", + title: "PR overlaps active or recent work", + detail: `${prCollisionCount} collision cluster(s) include this PR.`, + action: "Review overlap before spending detailed review time.", + }); + } + if (codeFiles.length > 0 && testFiles.length === 0) { + findings.push({ + code: "missing_test_files", + severity: "warning", + title: "Code changes do not include cached test files", + detail: "Cached file metadata includes code paths but no obvious test paths.", + action: "Ask for test evidence or a clear validation note.", + }); + } + if (checkFailureCount > 0) { + findings.push({ + code: "checks_need_attention", + severity: "warning", + title: "Checks need attention", + detail: `${checkFailureCount} cached check(s) ended with a non-success conclusion.`, + }); + } + } + const reviewPriority = findings.some((finding) => finding.severity === "warning" || finding.severity === "critical") + ? "needs_author" + : approvalCount > 0 && checkFailureCount === 0 + ? "review" + : "watch"; + return { + repoFullName: args.repoFullName, + pullNumber: args.pullNumber, + generatedAt: nowIso(), + reviewPriority, + summary: pr + ? `PR #${pr.number} has ${args.files.length} cached file(s), ${args.reviews.length} review(s), ${args.checks.length} check summary/summaries, and ${prCollisionCount} collision cluster(s).` + : `PR #${args.pullNumber} is not cached yet.`, + changeSummary: { + fileCount: args.files.length, + codeFileCount: codeFiles.length, + testFileCount: testFiles.length, + additions, + deletions, + topPaths: args.files.map((file) => file.path).slice(0, 12), + }, + reviewSignals: { + reviewCount: args.reviews.length, + approvalCount, + changeRequestCount, + checkFailureCount, + linkedIssues: pr?.linkedIssues ?? [], + collisionClusters: prCollisionCount, + }, + findings, + contributorNextSteps: findings.flatMap((finding) => (finding.action ? [finding.action] : [])), + maintainerNotes: findings.length > 0 ? findings.map((finding) => finding.title) : ["No obvious maintainer-blocking signal in cached metadata."], + }; +} + +export function buildPullRequestReviewIntelligence(args: { + repo: RepositoryRecord | null; + pullRequest: PullRequestRecord | null; + issues: IssueRecord[]; + pullRequests: PullRequestRecord[]; + files: PullRequestFileRecord[]; + reviews: PullRequestReviewRecord[]; + checks: CheckSummaryRecord[]; + recentMergedPullRequests: RecentMergedPullRequestRecord[]; + repoFullName: string; + pullNumber: number; + profile?: ContributorProfile | null | undefined; + outcomeHistory?: ContributorOutcomeHistory | null | undefined; +}): PullRequestReviewIntelligence { + const packet = buildPullRequestMaintainerPacket(args); + const login = args.pullRequest?.authorLogin ?? args.profile?.login ?? "unknown"; + const roleContext = buildRoleContext({ + login, + repo: args.repo, + repoFullName: args.repoFullName, + pullRequests: args.pullRequests, + issues: args.issues, + profile: args.profile, + }); + const outcomeContext = args.outcomeHistory?.repoOutcomes.find((outcome) => outcome.repoFullName.toLowerCase() === args.repoFullName.toLowerCase()); + const recommendation: PullRequestReviewIntelligence["recommendation"] = roleContext.maintainerLane + ? "maintainer_lane" + : packet.reviewSignals.collisionClusters > 0 + ? "likely_duplicate" + : packet.reviewPriority === "needs_author" + ? "needs_author" + : packet.reviewPriority === "review" + ? "review" + : "watch"; + return { + ...packet, + roleContext, + outcomeContext, + recommendation, + privateSummary: [ + `Role: ${roleContext.role}${roleContext.maintainerLane ? " (maintainer lane)" : ""}.`, + ...(outcomeContext ? [`Repo history: ${outcomeContext.mergedPullRequests} merged, ${outcomeContext.closedPullRequests} closed, ${outcomeContext.openPullRequests} open PR(s).`] : []), + `Recommended maintainer action: ${recommendation}.`, + ].join(" "), + }; +} + +export function buildIssueQualityReport( + repo: RepositoryRecord | null, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + fullName: string, +): IssueQualityReport { + const lane = buildLaneAdvice(repo, fullName); + const collisions = buildCollisionReport(fullName, issues, pullRequests); + const reports = issues + .filter((issue) => issue.state === "open") + .slice(0, 100) + .map((issue) => { + const linkedPrs = pullRequests.filter((pr) => pr.linkedIssues.includes(issue.number)); + const issueCollisions = collisions.clusters.filter((cluster) => cluster.items.some((item) => item.type === "issue" && item.number === issue.number)); + const age = daysSince(issue.updatedAt ?? issue.createdAt); + const bodyLength = issue.body?.trim().length ?? 0; + const reasons = [ + ...(bodyLength >= 200 ? ["Issue has enough body detail to evaluate."] : []), + ...(issue.labels.length > 0 ? [`Labels: ${issue.labels.join(", ")}.`] : []), + ...(linkedPrs.length === 0 ? ["No active PR is linked in cached metadata."] : []), + ]; + const warnings = [ + ...(bodyLength < 80 ? ["Issue body is thin; contributor may need more proof before acting."] : []), + ...(linkedPrs.length > 0 ? [`${linkedPrs.length} active PR(s) already reference this issue.`] : []), + ...(issueCollisions.length > 0 ? ["Potential duplicate or overlapping issue/PR context exists."] : []), + ...(age > 90 ? ["Issue is stale in cached metadata."] : []), + ...(lane.lane === "direct_pr" ? ["Repo is direct-PR first; issue filing is not the primary Gittensor lane."] : []), + ]; + const score = clamp(100 - warnings.length * 18 + reasons.length * 5 - (age > 180 ? 15 : 0), 0, 100); + const status: IssueQualityReport["issues"][number]["status"] = + linkedPrs.length > 0 || issueCollisions.some((cluster) => cluster.risk === "high") + ? "do_not_use" + : warnings.some((warning) => /thin|stale|direct-PR/i.test(warning)) + ? "needs_proof" + : score < 45 + ? "hold" + : "ready"; + return { number: issue.number, title: issue.title, status, score, reasons, warnings }; + }) + .sort((left, right) => right.score - left.score || left.number - right.number); + return { + repoFullName: fullName, + generatedAt: nowIso(), + lane, + issues: reports, + summary: `${reports.length} open issue(s) evaluated; ${reports.filter((report) => report.status === "ready").length} look ready from cached metadata.`, + }; +} + +export function buildBurdenForecast( + repo: RepositoryRecord | null, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + collisions: CollisionReport, + horizonDays: 7 | 30 = 30, + countOverrides: QueueSignalCounts = {}, +): BurdenForecast { + const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions, countOverrides); + const openPrs = pullRequests.filter((pr) => pr.state === "open"); + const updatedRecently = openPrs.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) <= horizonDays).length; + const stalePrs = openPrs.filter((pr) => daysSince(pr.updatedAt ?? pr.createdAt) > 30).length; + const projectedReviewLoad = clamp(openPrs.length * 3 + updatedRecently * 2 + collisions.summary.highRiskCount * 4 + stalePrs, 0, 100); + const queueGrowthRisk = clamp((openPrs.length - queueHealth.signals.likelyReviewablePullRequests) * 5 + collisions.summary.clusterCount * 7, 0, 100); + const level = projectedReviewLoad >= 80 || queueGrowthRisk >= 80 ? "critical" : projectedReviewLoad >= 55 || queueGrowthRisk >= 55 ? "high" : projectedReviewLoad >= 25 ? "medium" : "low"; + const findings: SignalFinding[] = [ + ...(queueGrowthRisk >= 55 + ? [ + { + code: "queue_growth_risk", + severity: "warning" as const, + title: "Queue growth risk is elevated", + detail: "Cached PR volume, reviewable count, and collision signals suggest maintainers may see avoidable triage load.", + action: "Prefer smaller, linked, lower-collision submissions until the queue clears.", + }, + ] + : []), + ...(stalePrs > 0 + ? [ + { + code: "stale_review_load", + severity: "info" as const, + title: "Stale PRs affect maintainer load", + detail: `${stalePrs} open PR(s) appear stale in cached metadata.`, + }, + ] + : []), + ]; + return { + repoFullName: repo?.fullName ?? collisions.repoFullName, + generatedAt: nowIso(), + horizonDays, + level, + forecast: { + projectedReviewLoad, + reviewablePullRequests: queueHealth.signals.likelyReviewablePullRequests, + stalePullRequests: stalePrs, + duplicateTrend: collisions.summary.clusterCount, + queueGrowthRisk, + }, + findings, + summary: `${horizonDays}-day maintainer load forecast is ${level}; projected review load ${projectedReviewLoad}/100 and queue growth risk ${queueGrowthRisk}/100.`, + }; +} + +export function buildContributorScoringProfile(args: { + login: string; + fit: ContributorFit; + scoringSnapshot: ScoringModelSnapshotRecord; +}): ContributorScoringProfile { + const stats = args.fit.repoStats; + const mergedPullRequests = stats.reduce((sum, stat) => sum + stat.mergedPullRequests, 0); + const openPullRequests = stats.reduce((sum, stat) => sum + stat.openPullRequests, 0); + const stalePullRequests = stats.reduce((sum, stat) => sum + stat.stalePullRequests, 0); + const unlinkedPullRequests = stats.reduce((sum, stat) => sum + stat.unlinkedPullRequests, 0); + const languageMatches = args.fit.languageFit.filter((fit) => fit.match).length; + const credibilityAssumption = clamp(0.75 + mergedPullRequests * 0.04 + languageMatches * 0.02 - stalePullRequests * 0.03 - unlinkedPullRequests * 0.02, 0.25, 1); + const officialTotals = args.fit.profile.gittensor?.totals; + const officialSource = args.fit.profile.source === "gittensor_api"; + const issueDiscoveryReports = officialTotals + ? Math.max(officialTotals.validSolvedIssues, officialTotals.solvedIssues) + : args.fit.profile.registeredRepoActivity.issues; + const sourceLabel = officialSource ? "Gittensor API" : "cached"; + const privateSignals = [ + `${mergedPullRequests} ${sourceLabel} merged registered-repo PR(s).`, + `${openPullRequests} ${sourceLabel} open registered-repo PR(s).`, + `${issueDiscoveryReports} ${sourceLabel} valid/solved issue-discovery report(s).`, + `${languageMatches} cached registered repo language match(es).`, + ...(unlinkedPullRequests > 0 ? [`${unlinkedPullRequests} ${sourceLabel} unlinked PR pattern(s).`] : []), + ]; + return { + login: args.login, + generatedAt: nowIso(), + scoringModelSnapshotId: args.scoringSnapshot.id, + evidence: { + registeredRepoPullRequests: args.fit.profile.registeredRepoActivity.pullRequests, + mergedPullRequests, + openPullRequests, + stalePullRequests, + unlinkedPullRequests, + issueDiscoveryReports, + languageMatches, + credibilityAssumption, + }, + privateSignals, + }; +} + +export function buildContributorStrategy(args: { + login: string; + fit: ContributorFit; + scoringProfile: ContributorScoringProfile; + scoringSnapshot: ScoringModelSnapshotRecord; + outcomeHistory?: ContributorOutcomeHistory | null | undefined; +}): ContributorStrategy { + const outcomeByRepo = new Map((args.outcomeHistory?.repoOutcomes ?? []).map((outcome) => [outcome.repoFullName, outcome])); + const bestFitRepos = args.fit.opportunities.slice(0, 10).map((opportunity) => { + const outcome = outcomeByRepo.get(opportunity.repoFullName); + const privateScoringReadiness: ContributorStrategy["bestFitRepos"][number]["privateScoringReadiness"] = + outcome?.maintainerLane + ? "hold" + : opportunity.fit === "hold" || opportunity.warnings.some((warning) => /busy|duplicate|inactive|unknown/i.test(warning)) || (outcome?.closedPullRequestRate ?? 0) >= 0.35 + ? "hold" + : args.scoringProfile.evidence.credibilityAssumption >= 0.8 && opportunity.fit === "good" && (outcome?.openPullRequests ?? 0) < 5 + ? "good" + : "caution"; + return { + repoFullName: opportunity.repoFullName, + lane: opportunity.lane, + fit: opportunity.fit, + opportunityScore: opportunity.score, + privateScoringReadiness, + reasons: [...opportunity.reasons, ...(outcome?.strengths ?? [])], + warnings: [...opportunity.warnings, ...(outcome?.risks.filter((risk) => !/No major/i.test(risk)) ?? [])], + }; + }); + const avoidRepos = (args.outcomeHistory?.repoOutcomes ?? []) + .filter((outcome) => !outcome.maintainerLane && (outcome.closedPullRequestRate >= 0.35 || outcome.credibility > 0 && outcome.credibility < 0.8)) + .map((outcome) => ({ + repoFullName: outcome.repoFullName, + reason: outcome.closedPullRequestRate >= 0.35 ? `Closed PR rate is ${percent(outcome.closedPullRequestRate)}.` : `Official repo credibility is ${round(outcome.credibility)}.`, + })) + .slice(0, 8); + const cleanupFirst = (args.outcomeHistory?.repoOutcomes ?? []) + .filter((outcome) => !outcome.maintainerLane && outcome.openPullRequests >= 3) + .map((outcome) => ({ repoFullName: outcome.repoFullName, reason: `${outcome.openPullRequests} open PR(s) are still active.` })) + .slice(0, 8); + const maintainerLaneRepos = (args.outcomeHistory?.repoOutcomes ?? []) + .filter((outcome) => outcome.maintainerLane) + .map((outcome) => ({ repoFullName: outcome.repoFullName, reason: "Maintainer-associated repo; use repo-health guidance instead of contributor-lane guidance." })) + .slice(0, 8); + const laneWarnings = [ + ...bestFitRepos.filter((repo) => repo.lane === "direct_pr").map((repo) => `${repo.repoFullName}: direct PR lane; prioritize tested implementation work.`), + ...bestFitRepos.filter((repo) => repo.lane === "issue_discovery").map((repo) => `${repo.repoFullName}: issue-discovery lane; prioritize actionable reports and avoid duplicate reports.`), + ...maintainerLaneRepos.map((repo) => `${repo.repoFullName}: maintainer lane; treat as repo health and contributor triage.`), + ]; + const nextActions = [ + ...(bestFitRepos.some((repo) => repo.privateScoringReadiness === "good") ? ["Start with the highest-fit repo that has low duplicate and queue pressure."] : []), + ...(args.scoringProfile.evidence.unlinkedPullRequests > 0 ? ["Clean up linked issue/context patterns before adding more open PRs."] : []), + ...(cleanupFirst.length > 0 ? ["Clean up active open PR pressure before adding more work in those repos."] : []), + ...(maintainerLaneRepos.length > 0 ? ["For maintainer-owned repos, focus on config quality, labels, queue health, and contributor intake rather than contributor-lane submissions."] : []), + ...(args.scoringProfile.evidence.languageMatches === 0 ? ["Prefer repos where the changed files match prior language evidence, or keep first submissions small."] : []), + "Use local diff preflight before opening the PR so maintainers get a cleaner submission.", + ]; + return { + login: args.login, + generatedAt: nowIso(), + scoringModelSnapshotId: args.scoringSnapshot.id, + summary: `${args.login} has ${bestFitRepos.length} ranked private strategy candidate(s), ${cleanupFirst.length} cleanup-first repo(s), and ${maintainerLaneRepos.length} maintainer-lane repo(s).`, + bestFitRepos, + avoidRepos, + cleanupFirst, + maintainerLaneRepos, + successPatterns: args.outcomeHistory?.successPatterns ?? [], + failurePatterns: args.outcomeHistory?.failurePatterns ?? [], + laneWarnings: [...new Set(laneWarnings)], + nextActions: [...new Set(nextActions)], + }; +} + +export function buildCollisionEdges(report: CollisionReport): CollisionEdgeRecord[] { + return report.clusters.flatMap((cluster) => { + const [left, right] = cluster.items; + if (!left || !right) return []; + const rightTerms = new Set(tokenize(collisionItemText(right))); + return [ + { + id: `${report.repoFullName}#${cluster.id}`, + repoFullName: report.repoFullName, + leftType: left.type, + leftNumber: left.number, + leftTitle: left.title, + rightType: right.type, + rightNumber: right.number, + rightTitle: right.title, + risk: cluster.risk, + reason: cluster.reason, + sharedTerms: [...new Set(tokenize(collisionItemText(left)).filter((term) => rightTerms.has(term)))], + generatedAt: report.generatedAt, + }, + ]; + }); +} + +export function buildRegistryChangeReport(snapshots: RegistrySnapshot[]): RegistryChangeReport { + const [current, previous] = snapshots; + if (!current) { + return { + generatedAt: nowIso(), + addedRepos: [], + removedRepos: [], + changedRepos: [], + summary: "No registry snapshots are available.", + }; + } + if (!previous) { + return { + generatedAt: nowIso(), + currentSnapshotId: current.id, + addedRepos: current.repositories.map((repo) => repo.repo).sort(), + removedRepos: [], + changedRepos: [], + summary: "Only one registry snapshot is available; every current repo is treated as newly observed.", + }; + } + const currentByRepo = new Map(current.repositories.map((repo) => [repo.repo, repo])); + const previousByRepo = new Map(previous.repositories.map((repo) => [repo.repo, repo])); + const addedRepos = [...currentByRepo.keys()].filter((repo) => !previousByRepo.has(repo)).sort(); + const removedRepos = [...previousByRepo.keys()].filter((repo) => !currentByRepo.has(repo)).sort(); + const changedRepos = [...currentByRepo.entries()] + .flatMap(([repoFullName, repo]) => { + const old = previousByRepo.get(repoFullName); + if (!old) return []; + const changes = [ + ...(repo.emissionShare !== old.emissionShare ? [`emission_share ${old.emissionShare} -> ${repo.emissionShare}`] : []), + ...(repo.issueDiscoveryShare !== old.issueDiscoveryShare ? [`issue_discovery_share ${old.issueDiscoveryShare} -> ${repo.issueDiscoveryShare}`] : []), + ...(repo.maintainerCut !== old.maintainerCut ? [`maintainer_cut ${old.maintainerCut} -> ${repo.maintainerCut}`] : []), + ...(JSON.stringify(repo.labelMultipliers) !== JSON.stringify(old.labelMultipliers) ? ["label_multipliers changed"] : []), + ...(repo.trustedLabelPipeline !== old.trustedLabelPipeline ? [`trusted_label_pipeline ${old.trustedLabelPipeline ?? false} -> ${repo.trustedLabelPipeline ?? false}`] : []), + ]; + return changes.length > 0 ? [{ repoFullName, changes }] : []; + }) + .sort((left, right) => left.repoFullName.localeCompare(right.repoFullName)); + return { + generatedAt: nowIso(), + currentSnapshotId: current.id, + previousSnapshotId: previous.id, + addedRepos, + removedRepos, + changedRepos, + summary: `${addedRepos.length} added, ${removedRepos.length} removed, ${changedRepos.length} changed repo(s) between the latest registry snapshots.`, + }; +} + export function buildBountyAdvisory(bounty: BountyRecord, repo: RepositoryRecord | null, issue: IssueRecord | null): BountyAdvisory { const status = bounty.status.toLowerCase(); const lifecycle = status.includes("complete") || status.includes("cancel") || status.includes("closed") ? "historical" : status ? "active" : "unknown"; @@ -722,7 +2205,16 @@ export function buildPublicPrIntelligenceComment(args: { .slice(0, args.settings.publicSignalLevel === "minimal" ? 2 : 5); const collisionCount = args.collisions.clusters.length; const linkedIssues = args.pr.linkedIssues.length > 0 ? args.pr.linkedIssues.map((issue) => `#${issue}`).join(", ") : "None detected"; + const roleContext = buildRoleContext({ + login: args.pr.authorLogin ?? args.profile.login, + repo: args.repo, + repoFullName: args.pr.repoFullName, + pullRequests: [args.pr], + issues: [], + profile: args.profile, + }); const nextSteps = [ + ...(roleContext.maintainerLane ? ["Treat this as maintainer-lane context rather than normal contributor-lane activity."] : []), ...(args.pr.linkedIssues.length === 0 ? ["Link the issue being solved, or explain why this is a no-issue PR."] : []), ...(collisionCount > 0 ? ["Check overlapping issues/PRs before review continues."] : []), ...(publicFindings.length > 0 ? publicFindings.flatMap((finding) => (finding.action ? [finding.action] : [])) : []), @@ -735,6 +2227,7 @@ export function buildPublicPrIntelligenceComment(args: { "", "### Contributor context", `- Author: \`${args.pr.authorLogin ?? "unknown"}\``, + `- Role context: ${roleContext.role}${roleContext.maintainerLane ? " (maintainer lane)" : ""}`, `- Registered-repo signal: ${args.detection.detected ? args.detection.reason : "No prior cached registered-repo activity detected."}`, `- Prior cached PRs/issues: ${args.detection.priorPullRequests} PR(s), ${args.detection.priorIssues} issue(s)`, `- Public profile languages: ${args.profile.github.topLanguages.length > 0 ? args.profile.github.topLanguages.join(", ") : "not available"}`, @@ -765,6 +2258,9 @@ function issueItem(issue: IssueRecord): CollisionItem { title: issue.title, authorLogin: issue.authorLogin, htmlUrl: issue.htmlUrl, + labels: issue.labels, + linkedIssues: [issue.number], + body: issue.body, }; } @@ -775,20 +2271,66 @@ function prItem(pr: PullRequestRecord): CollisionItem { title: pr.title, authorLogin: pr.authorLogin, htmlUrl: pr.htmlUrl, + labels: pr.labels, + linkedIssues: pr.linkedIssues, + body: pr.body, }; } +function recentMergedItem(pr: RecentMergedPullRequestRecord): CollisionItem { + return { + type: "recent_merged_pull_request", + number: pr.number, + title: pr.title, + authorLogin: pr.authorLogin, + htmlUrl: pr.htmlUrl, + labels: pr.labels, + linkedIssues: pr.linkedIssues, + changedFiles: pr.changedFiles, + }; +} + +function boundedCollisionIssues(openIssues: IssueRecord[], openPullRequests: PullRequestRecord[]): IssueRecord[] { + if (openIssues.length <= MAX_COLLISION_PAIRWISE_ISSUES) return openIssues; + const linkedIssueNumbers = new Set(openPullRequests.flatMap((pr) => pr.linkedIssues)); + const selected = new Map(); + for (const issue of openIssues) { + if (linkedIssueNumbers.has(issue.number)) selected.set(issue.number, issue); + if (selected.size >= MAX_COLLISION_PAIRWISE_ISSUES) return [...selected.values()]; + } + for (const issue of openIssues) { + selected.set(issue.number, issue); + if (selected.size >= MAX_COLLISION_PAIRWISE_ISSUES) break; + } + return [...selected.values()]; +} + function itemKey(item: CollisionItem): string { return `${item.type}-${item.number}`; } -function titleOverlap(left: string, right: string): { score: number; shared: number } { - const leftTerms = tokenize(left); - const rightTerms = tokenize(right); - if (leftTerms.length === 0 || rightTerms.length === 0) return { score: 0, shared: 0 }; - const rightSet = new Set(rightTerms); - const shared = new Set(leftTerms.filter((term) => rightSet.has(term))).size; - return { score: shared / Math.min(new Set(leftTerms).size, new Set(rightTerms).size), shared }; +type CollisionTerms = { + terms: Set; + size: number; +}; + +function collisionTerms(item: CollisionItem): CollisionTerms { + const terms = new Set(tokenize(collisionItemText(item))); + return { terms, size: terms.size }; +} + +function termOverlap(left: CollisionTerms, right: CollisionTerms): { score: number; shared: number } { + if (left.size === 0 || right.size === 0) return { score: 0, shared: 0 }; + let shared = 0; + const [smaller, larger] = left.size <= right.size ? [left.terms, right.terms] : [right.terms, left.terms]; + for (const term of smaller) { + if (larger.has(term)) shared += 1; + } + return { score: shared / Math.min(left.size, right.size), shared }; +} + +function collisionItemText(item: CollisionItem): string { + return [item.title, item.body, ...(item.labels ?? []), ...(item.changedFiles ?? [])].filter(Boolean).join(" "); } function tokenize(value: string): string[] { @@ -803,6 +2345,104 @@ function extractLinkedIssueNumbers(text: string): number[] { return [...new Set(matches.map((match) => Number(match[1])).filter((value) => Number.isInteger(value) && value > 0))]; } +function outcomeSuccessPatterns(history: ContributorOutcomeHistory): OutcomePattern[] { + const patterns: OutcomePattern[] = []; + for (const outcome of history.repoOutcomes) { + if (outcome.maintainerLane) { + patterns.push({ + repoFullName: outcome.repoFullName, + title: "Maintainer-side repo context", + detail: `${outcome.repoFullName} is maintainer-lane for this user; use it for repo health and contributor triage, not normal contributor fit.`, + confidence: "high", + }); + continue; + } + if (outcome.mergedPullRequests >= 5 && outcome.closedPullRequestRate < 0.3) { + patterns.push({ + repoFullName: outcome.repoFullName, + title: "Strong merge history", + detail: `${outcome.mergedPullRequests} merged PR(s) with ${percent(outcome.closedPullRequestRate)} closed PR rate.`, + confidence: outcome.credibility >= 0.9 || outcome.mergedPullRequests >= 10 ? "high" : "medium", + }); + } else if (outcome.mergedPullRequests > 0) { + patterns.push({ + repoFullName: outcome.repoFullName, + title: "Emerging repo fit", + detail: `${outcome.mergedPullRequests} merged PR(s) show usable repo familiarity.`, + confidence: "medium", + }); + } + if (outcome.validSolvedIssues > 0) { + patterns.push({ + repoFullName: outcome.repoFullName, + title: "Valid issue-discovery evidence", + detail: `${outcome.validSolvedIssues} valid solved issue-discovery report(s) are visible in official data.`, + confidence: "high", + }); + } + } + return patterns.sort((left, right) => patternRank(right) - patternRank(left) || (left.repoFullName ?? "").localeCompare(right.repoFullName ?? "")).slice(0, 12); +} + +function outcomeFailurePatterns(history: ContributorOutcomeHistory): OutcomePattern[] { + const patterns: OutcomePattern[] = []; + if (history.totals.openPullRequests >= 5) { + patterns.push({ + title: "Open PR pressure", + detail: `${history.totals.openPullRequests} open PR(s) are visible; clean up active work before adding more.`, + confidence: "high", + }); + } + if (history.totals.closedPullRequestRate >= 0.25) { + patterns.push({ + title: "Closed PR credibility pressure", + detail: `Overall closed PR rate is ${percent(history.totals.closedPullRequestRate)}.`, + confidence: "medium", + }); + } + if (history.totals.openIssues > 0 && history.totals.validSolvedIssues === 0) { + patterns.push({ + title: "Raw issue activity is not solved discovery evidence", + detail: `${history.totals.openIssues} open issue(s) are visible, but no valid solved issue-discovery evidence is visible in official totals.`, + confidence: "medium", + }); + } + for (const outcome of history.repoOutcomes) { + if (outcome.openIssues >= 10 && outcome.validSolvedIssues === 0) { + patterns.push({ + repoFullName: outcome.repoFullName, + title: "Raw issue activity is not solved discovery evidence", + detail: `${outcome.repoFullName} has ${outcome.openIssues} open issue(s), but no valid solved issue-discovery evidence for that repo.`, + confidence: outcome.maintainerLane ? "high" : "medium", + }); + } + if (!outcome.maintainerLane && outcome.closedPullRequestRate >= 0.35) { + patterns.push({ + repoFullName: outcome.repoFullName, + title: "Repo-specific closed PR risk", + detail: `${outcome.repoFullName} has ${outcome.closedPullRequests} closed PR(s) and ${percent(outcome.closedPullRequestRate)} closed PR rate.`, + confidence: "high", + }); + } + if (!outcome.maintainerLane && outcome.openPullRequests >= 3) { + patterns.push({ + repoFullName: outcome.repoFullName, + title: "Repo-specific open PR pressure", + detail: `${outcome.repoFullName} has ${outcome.openPullRequests} open PR(s).`, + confidence: "medium", + }); + } + } + return patterns.sort((left, right) => patternRank(right) - patternRank(left) || (left.repoFullName ?? "").localeCompare(right.repoFullName ?? "")).slice(0, 12); +} + +function strongestAssociation(values: string[]): string | undefined { + for (const association of ["OWNER", "MEMBER", "COLLABORATOR"]) { + if (values.includes(association)) return association; + } + return values[0]; +} + function isMaintainerAssociation(value: string | null | undefined): boolean { return value === "OWNER" || value === "MEMBER" || value === "COLLABORATOR"; } @@ -820,6 +2460,22 @@ function topItems(items: string[], limit: number): string[] { .map(([item]) => item); } +function rate(numerator: number, denominator: number): number { + return denominator > 0 ? round(numerator / denominator) : 0; +} + +function percent(value: number): string { + return `${Math.round(value * 100)}%`; +} + +function round(value: number): number { + return Math.round(value * 10000) / 10000; +} + +function patternRank(pattern: OutcomePattern): number { + return pattern.confidence === "high" ? 3 : pattern.confidence === "medium" ? 2 : 1; +} + function daysSince(value: string | null | undefined): number { if (!value) return 0; const parsed = Date.parse(value); diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts new file mode 100644 index 0000000000..a9efa801d1 --- /dev/null +++ b/src/signals/local-branch.ts @@ -0,0 +1,420 @@ +import type { ScorePreviewInput, ScorePreviewResult } from "../scoring/preview"; +import { buildScorePreview } from "../scoring/preview"; +import type { IssueRecord, PullRequestRecord, RecentMergedPullRequestRecord, RepositoryRecord, ScoringModelSnapshotRecord } from "../types"; +import { nowIso } from "../utils/json"; +import { + buildLaneAdvice, + buildLocalDiffPreflightResult, + buildRepoFitRecommendation, + buildRoleContext, + type ContributorOutcomeHistory, + type ContributorProfile, + type ContributorScoringProfile, + type LocalDiffPreflightResult, + type RoleContext, +} from "./engine"; +import { buildRepoRewardRisk, type RepoRewardRisk, type RewardRiskAction } from "./reward-risk"; + +export type LocalBranchChangedFile = { + path: string; + previousPath?: string | undefined; + additions?: number | undefined; + deletions?: number | undefined; + status?: "added" | "modified" | "deleted" | "renamed" | "copied" | "unknown" | undefined; + binary?: boolean | undefined; +}; + +export type LocalBranchValidation = { + command: string; + status: "passed" | "failed" | "not_run"; + summary?: string | undefined; +}; + +export type LocalBranchScorer = { + mode: "metadata_only" | "external_command" | "gittensor_root"; + activeModel?: string | undefined; + sourceTokenScore?: number | undefined; + totalTokenScore?: number | undefined; + sourceLines?: number | undefined; + testTokenScore?: number | undefined; + nonCodeTokenScore?: number | undefined; + warnings?: string[] | undefined; +}; + +export type LocalBranchAnalysisInput = { + login: string; + repoFullName: string; + baseRef?: string | undefined; + headRef?: string | undefined; + branchName?: string | undefined; + commitMessages?: string[] | undefined; + changedFiles?: LocalBranchChangedFile[] | undefined; + validation?: LocalBranchValidation[] | undefined; + linkedIssues?: number[] | undefined; + labels?: string[] | undefined; + title?: string | undefined; + body?: string | undefined; + localScorer?: LocalBranchScorer | undefined; +}; + +export type LocalBranchAnalysis = { + login: string; + repoFullName: string; + generatedAt: string; + baseRef?: string | undefined; + headRef?: string | undefined; + branchName?: string | undefined; + lane: ReturnType; + roleContext: RoleContext; + preflight: LocalDiffPreflightResult; + scorePreview: ScorePreviewResult; + rewardRisk: RepoRewardRisk; + scoreBlockers: string[]; + localFindings: Array<{ + code: string; + severity: "info" | "warning" | "critical"; + title: string; + detail: string; + action?: string | undefined; + }>; + maintainerFit: { + recommendation: ReturnType["recommendation"]; + reviewBurden: LocalDiffPreflightResult["reviewBurden"]; + role: RoleContext["role"]; + maintainerLane: boolean; + reasons: string[]; + risks: string[]; + }; + prPacket: { + titleSuggestion: string; + bodySections: Array<{ heading: string; lines: string[] }>; + reviewerNotes: string[]; + validationSummary: { + passed: number; + failed: number; + notRun: number; + commands: LocalBranchValidation[]; + }; + publicSafeWarnings: string[]; + }; + nextActions: RewardRiskAction[]; + summary: string; +}; + +export function buildLocalBranchAnalysis(args: { + input: LocalBranchAnalysisInput; + repo: RepositoryRecord | null; + issues: IssueRecord[]; + pullRequests: PullRequestRecord[]; + recentMergedPullRequests?: RecentMergedPullRequestRecord[] | undefined; + profile: ContributorProfile; + outcomeHistory: ContributorOutcomeHistory; + scoringSnapshot: ScoringModelSnapshotRecord; + scoringProfile?: ContributorScoringProfile | null | undefined; +}): LocalBranchAnalysis { + const changedFiles = args.input.changedFiles ?? []; + const changedPaths = changedFiles.map((file) => file.path); + const testFiles = changedPaths.filter(isTestFile); + const changedLineCount = changedFiles.reduce((sum, file) => sum + nonNegative(file.additions) + nonNegative(file.deletions), 0); + const commitMessage = (args.input.commitMessages ?? []).join("\n\n").trim(); + const title = args.input.title?.trim() || titleFromBranch(args.input.branchName) || firstCommitTitle(args.input.commitMessages) || "Local branch preflight"; + const preflight = buildLocalDiffPreflightResult( + { + repoFullName: args.input.repoFullName, + contributorLogin: args.input.login, + title, + body: args.input.body, + labels: args.input.labels, + changedFiles: changedPaths, + linkedIssues: args.input.linkedIssues, + tests: validationEvidence(args.input.validation), + commitMessage, + changedLineCount, + testFiles, + }, + args.repo, + args.issues, + args.pullRequests, + ); + const roleContext = buildRoleContext({ + login: args.input.login, + repo: args.repo, + repoFullName: args.input.repoFullName, + pullRequests: args.pullRequests, + issues: args.issues, + profile: args.profile, + }); + const lane = buildLaneAdvice(args.repo, args.input.repoFullName); + const repoOutcome = args.outcomeHistory.repoOutcomes.find((outcome) => sameRepo(outcome.repoFullName, args.input.repoFullName)); + const scoreInput = buildLocalScoreInput({ + input: args.input, + changedFiles, + changedLineCount, + testFiles, + linkedIssueCount: preflight.linkedIssues.length, + roleContext, + outcomeHistory: args.outcomeHistory, + repoOutcome, + }); + const scorePreview = buildScorePreview({ + input: scoreInput, + repo: args.repo, + snapshot: args.scoringSnapshot, + }); + const rewardRisk = buildRepoRewardRisk({ + login: args.input.login, + repo: args.repo, + repoFullName: args.input.repoFullName, + profile: args.profile, + outcomeHistory: args.outcomeHistory, + scoringSnapshot: args.scoringSnapshot, + scoringProfile: args.scoringProfile, + issues: args.issues, + pullRequests: args.pullRequests, + recentMergedPullRequests: args.recentMergedPullRequests ?? [], + }); + const recommendation = buildRepoFitRecommendation({ + login: args.input.login, + repo: args.repo, + repoFullName: args.input.repoFullName, + profile: args.profile, + outcomeHistory: args.outcomeHistory, + issues: args.issues, + pullRequests: args.pullRequests, + }); + const localFindings = buildLocalFindings(args.input, changedFiles, preflight, scorePreview); + const validationSummary = summarizeValidation(args.input.validation ?? []); + const prPacket = buildPublicSafePrPacket({ + title, + preflight, + changedFiles, + validationSummary, + roleContext, + laneSummary: lane.summary, + localFindings, + }); + const scoreBlockers = [ + ...rewardRisk.scoreBlockers, + ...scorePreview.warnings.filter((warning) => /not registered|no active|exceeds|credibility|token gate/i.test(warning)), + ...preflight.findings.filter((finding) => finding.severity !== "info").map((finding) => finding.title), + ]; + return { + login: args.input.login, + repoFullName: args.input.repoFullName, + generatedAt: nowIso(), + baseRef: args.input.baseRef, + headRef: args.input.headRef, + branchName: args.input.branchName, + lane, + roleContext, + preflight, + scorePreview, + rewardRisk, + scoreBlockers: [...new Set(scoreBlockers)], + localFindings, + maintainerFit: { + recommendation: recommendation.recommendation, + reviewBurden: preflight.reviewBurden, + role: roleContext.role, + maintainerLane: roleContext.maintainerLane, + reasons: recommendation.reasons, + risks: recommendation.risks, + }, + prPacket, + nextActions: rewardRisk.actions.slice(0, 6), + summary: `${args.input.repoFullName}: local branch analysis is ${preflight.status}; ${rewardRisk.actions[0]?.actionKind ?? "no ranked action"} is the top private next action.`, + }; +} + +function buildLocalScoreInput(args: { + input: LocalBranchAnalysisInput; + changedFiles: LocalBranchChangedFile[]; + changedLineCount: number; + testFiles: string[]; + linkedIssueCount: number; + roleContext: RoleContext; + outcomeHistory: ContributorOutcomeHistory; + repoOutcome?: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; +}): ScorePreviewInput { + const scorer = args.input.localScorer; + const testLineCount = args.changedFiles.filter((file) => isTestFile(file.path)).reduce((sum, file) => sum + nonNegative(file.additions) + nonNegative(file.deletions), 0); + const sourceLineCount = args.changedFiles + .filter((file) => isCodeFile(file.path)) + .reduce((sum, file) => sum + nonNegative(file.additions) + nonNegative(file.deletions), 0); + const nonCodeLineCount = Math.max(0, args.changedLineCount - sourceLineCount - testLineCount); + return { + repoFullName: args.input.repoFullName, + targetType: "local_diff", + targetKey: `${args.input.login}:${args.input.repoFullName}:${args.input.branchName ?? args.input.headRef ?? "local-branch"}`, + contributorLogin: args.input.login, + labels: args.input.labels ?? [], + linkedIssueMode: args.roleContext.maintainerLane ? "maintainer" : args.linkedIssueCount > 0 ? "standard" : "none", + sourceTokenScore: scorer?.sourceTokenScore ?? Math.max(0, sourceLineCount), + totalTokenScore: scorer?.totalTokenScore ?? Math.max(0, args.changedLineCount), + sourceLines: scorer?.sourceLines ?? Math.max(1, sourceLineCount || args.changedLineCount || 1), + testTokenScore: scorer?.testTokenScore ?? testLineCount, + nonCodeTokenScore: scorer?.nonCodeTokenScore ?? nonCodeLineCount, + openPrCount: args.outcomeHistory.totals.openPullRequests, + credibility: args.repoOutcome?.credibility ?? args.outcomeHistory.totals.credibility, + metadataOnly: scorer?.mode !== "gittensor_root" && scorer?.mode !== "external_command", + }; +} + +function buildLocalFindings( + input: LocalBranchAnalysisInput, + changedFiles: LocalBranchChangedFile[], + preflight: LocalDiffPreflightResult, + scorePreview: ScorePreviewResult, +): LocalBranchAnalysis["localFindings"] { + const failedValidation = (input.validation ?? []).filter((entry) => entry.status === "failed"); + return [ + { + code: "source_upload_disabled", + severity: "info" as const, + title: "Source upload disabled", + detail: "Local MCP branch analysis used structured git metadata only; source contents were not uploaded.", + }, + ...(input.repoFullName.toLowerCase() === "jsonbored/gittensory" + ? [ + { + code: "gittensory_not_registered", + severity: "warning" as const, + title: "Gittensory is not registered", + detail: "Treat this project as product/maintainer work until it appears in the official registry snapshot.", + action: "Do not treat this repo as a miner target yet.", + }, + ] + : []), + ...(failedValidation.length > 0 + ? [ + { + code: "failed_local_validation", + severity: "warning" as const, + title: "Local validation failed", + detail: `${failedValidation.length} validation command(s) were reported as failed.`, + action: "Fix validation before asking maintainers to review.", + }, + ] + : []), + ...(changedFiles.some((file) => file.binary) + ? [ + { + code: "binary_diff_present", + severity: "info" as const, + title: "Binary changes detected", + detail: "Binary file changes cannot be scored or reviewed from line metadata alone.", + }, + ] + : []), + ...scorePreview.warnings.map((warning) => ({ + code: "score_preview_warning", + severity: /not registered|no active|exceeds|credibility/i.test(warning) ? ("warning" as const) : ("info" as const), + title: "Private preview warning", + detail: warning, + })), + ...preflight.findings.map((finding) => ({ + code: `preflight_${finding.code}`, + severity: finding.severity, + title: finding.title, + detail: finding.detail, + action: finding.action, + })), + ]; +} + +function buildPublicSafePrPacket(args: { + title: string; + preflight: LocalDiffPreflightResult; + changedFiles: LocalBranchChangedFile[]; + validationSummary: LocalBranchAnalysis["prPacket"]["validationSummary"]; + roleContext: RoleContext; + laneSummary: string; + localFindings: LocalBranchAnalysis["localFindings"]; +}): LocalBranchAnalysis["prPacket"] { + const topPaths = args.changedFiles.slice(0, 8).map((file) => file.path); + const publicSafeWarnings = [ + ...(args.roleContext.maintainerLane ? ["This is maintainer-lane context; present it as repo stewardship work."] : []), + ...args.preflight.findings + .filter((finding) => finding.severity !== "info") + .map((finding) => finding.publicText ?? finding.action ?? finding.title), + ...args.localFindings + .filter((finding) => finding.code !== "score_preview_warning" && finding.severity === "warning") + .flatMap((finding) => (finding.action ? [finding.action] : [finding.title])), + ].filter(isPublicSafeText); + const validationLines = + args.validationSummary.commands.length > 0 + ? args.validationSummary.commands.map((entry) => `- ${entry.status}: ${entry.command}${entry.summary ? ` (${entry.summary})` : ""}`) + : ["- Not supplied yet."]; + return { + titleSuggestion: args.title, + bodySections: [ + { + heading: "Summary", + lines: ["Describe the user-visible problem or maintainer-facing improvement this branch addresses."], + }, + { + heading: "Linked Context", + lines: args.preflight.linkedIssues.length > 0 ? args.preflight.linkedIssues.map((issue) => `- Closes #${issue}`) : ["- No linked issue detected; explain why this is a no-issue PR."], + }, + { + heading: "Changed Paths", + lines: topPaths.length > 0 ? topPaths.map((path) => `- ${path}`) : ["- No changed paths were detected from local metadata."], + }, + { + heading: "Validation", + lines: validationLines, + }, + ], + reviewerNotes: [ + `Lane context: ${args.laneSummary}`, + `Review burden: ${args.preflight.reviewBurden}`, + `Role context: ${args.roleContext.role}${args.roleContext.maintainerLane ? " (maintainer lane)" : ""}`, + ], + validationSummary: args.validationSummary, + publicSafeWarnings: [...new Set(publicSafeWarnings)], + }; +} + +function summarizeValidation(validation: LocalBranchValidation[]): LocalBranchAnalysis["prPacket"]["validationSummary"] { + return { + passed: validation.filter((entry) => entry.status === "passed").length, + failed: validation.filter((entry) => entry.status === "failed").length, + notRun: validation.filter((entry) => entry.status === "not_run").length, + commands: validation, + }; +} + +function validationEvidence(validation: LocalBranchValidation[] | undefined): string[] { + return (validation ?? []) + .filter((entry) => entry.status === "passed") + .map((entry) => entry.command); +} + +function titleFromBranch(branchName: string | undefined): string | undefined { + const cleaned = branchName?.replace(/^[-/_.\w]+\/(?=[^/]+$)/, "").replace(/[-_]+/g, " ").trim(); + return cleaned || undefined; +} + +function firstCommitTitle(messages: string[] | undefined): string | undefined { + return messages?.find((message) => message.trim().length > 0)?.split("\n")[0]?.trim() || undefined; +} + +function isPublicSafeText(text: string): boolean { + return !/\b(reward|score|wallet|hotkey|coldkey|mnemonic|farming|payout|ranking|trust score)\b/i.test(text); +} + +function isTestFile(file: string): boolean { + return /(^|\/)(test|tests|spec|__tests__)\//i.test(file) || /\.(test|spec)\.(ts|tsx|js|jsx|py|rb|rs)$/i.test(file); +} + +function isCodeFile(file: string): boolean { + return /\.(ts|tsx|js|jsx|py|rb|rs|kt|scala|java|go|sql)$/i.test(file) && !isTestFile(file); +} + +function sameRepo(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); +} + +function nonNegative(value: number | undefined): number { + return Number.isFinite(value) ? Math.max(0, value ?? 0) : 0; +} diff --git a/src/signals/reward-risk.ts b/src/signals/reward-risk.ts new file mode 100644 index 0000000000..2cd2884657 --- /dev/null +++ b/src/signals/reward-risk.ts @@ -0,0 +1,790 @@ +import type { ScorePreviewResult } from "../scoring/preview"; +import { buildScorePreview } from "../scoring/preview"; +import type { + CheckSummaryRecord, + IssueRecord, + PullRequestFileRecord, + PullRequestRecord, + PullRequestReviewRecord, + RecentMergedPullRequestRecord, + RepositoryRecord, + ScoringModelSnapshotRecord, +} from "../types"; +import { nowIso } from "../utils/json"; +import { + buildCollisionReport, + buildContributorIntakeHealth, + buildLaneAdvice, + buildPullRequestReviewIntelligence, + buildQueueHealth, + buildRepoFitRecommendation, + buildRoleContext, + type ContributorFit, + type ContributorOutcomeHistory, + type ContributorProfile, + type ContributorScoringProfile, + type LaneAdvice, + type ParticipationLane, + type QueueHealth, + type RepoFitRecommendation, + type RoleContext, +} from "./engine"; + +export type RewardRiskActionKind = + | "cleanup_existing_prs" + | "land_existing_prs" + | "close_or_withdraw_low_fit_prs" + | "open_new_direct_pr" + | "file_issue_discovery" + | "maintainer_lane_improve_repo" + | "maintainer_cut_readiness"; + +const ACTION_RANK: Record = { + cleanup_existing_prs: 0, + land_existing_prs: 1, + close_or_withdraw_low_fit_prs: 2, + open_new_direct_pr: 3, + file_issue_discovery: 4, + maintainer_lane_improve_repo: 5, + maintainer_cut_readiness: 6, +}; + +export type RewardRiskAction = { + actionKind: RewardRiskActionKind; + repoFullName: string; + priorityScore: number; + laneValueScore: number; + scoreabilityScore: number; + personalFitScore: number; + riskPenalty: number; + maintainerFrictionPenalty: number; + actionLeverageScore: number; + whyThisHelps: string[]; + nextActions: string[]; +}; + +export type RepoRewardRisk = { + login: string; + repoFullName: string; + generatedAt: string; + roleContext: RoleContext; + lane: LaneAdvice; + recommendation: RepoFitRecommendation["recommendation"]; + rewardUpside: { + relevantLane: "direct_pr" | "issue_discovery" | "maintainer_lane" | "none"; + repoSlice: number; + directPrSlice: number; + issueDiscoverySlice: number; + maintainerCutSlice: number; + labelMultiplier: number; + issueMultiplier: number; + estimatedScoreIfClean: number; + currentEstimatedScore: number; + }; + scoreBlockers: string[]; + riskBreakdown: { + queueBurden: QueueHealth["level"]; + queueBurdenScore: number; + duplicateClusters: number; + highRiskDuplicateClusters: number; + closedPullRequestRate: number; + openPullRequests: number; + credibility: number; + reviewChurnRisk: "low" | "medium" | "high"; + }; + actionImpact: { + currentOpenPrCount: number; + openPrThreshold: number; + openPrMultiplierDelta: string; + estimatedScoreDelta: string; + cleanupNeeded: number; + explanation: string; + }; + currentPreview: ScorePreviewResult; + afterCleanupPreview: ScorePreviewResult; + actions: RewardRiskAction[]; + whyThisHelps: string[]; + nextActions: string[]; + summary: string; +}; + +export type ContributorRewardRiskStrategy = { + login: string; + generatedAt: string; + scoringModelSnapshotId: string; + summary: string; + topActions: RewardRiskAction[]; + repoAnalyses: RepoRewardRisk[]; + reasoning: string[]; + actionImpact: string[]; + nextActions: string[]; +}; + +export type MaintainerNoiseReport = { + repoFullName: string; + generatedAt: string; + score: number; + level: "low" | "medium" | "high" | "critical"; + noiseSources: string[]; + maintainerActions: Array<"review_now" | "needs_author" | "likely_duplicate" | "close_or_redirect" | "watch" | "maintainer_lane">; + queueHealth: QueueHealth; + summary: string; +}; + +export type PullRequestReviewability = { + repoFullName: string; + pullNumber: number; + generatedAt: string; + score: number; + action: "review_now" | "needs_author" | "likely_duplicate" | "close_or_redirect" | "watch" | "maintainer_lane"; + noiseSources: string[]; + whyThisHelps: string[]; + maintainerNextSteps: string[]; + privateSummary: string; +}; + +export function buildRepoRewardRisk(args: { + login: string; + repo: RepositoryRecord | null; + repoFullName: string; + profile: ContributorProfile; + outcomeHistory: ContributorOutcomeHistory; + scoringSnapshot: ScoringModelSnapshotRecord; + scoringProfile?: ContributorScoringProfile | null | undefined; + issues: IssueRecord[]; + pullRequests: PullRequestRecord[]; + recentMergedPullRequests?: RecentMergedPullRequestRecord[] | undefined; +}): RepoRewardRisk { + const roleContext = buildRoleContext({ + login: args.login, + repo: args.repo, + repoFullName: args.repoFullName, + pullRequests: args.pullRequests, + issues: args.issues, + profile: args.profile, + }); + const lane = buildLaneAdvice(args.repo, args.repoFullName); + const repoOutcome = args.outcomeHistory.repoOutcomes.find((outcome) => sameRepo(outcome.repoFullName, args.repoFullName)); + const collisions = buildCollisionReport(args.repoFullName, args.issues, args.pullRequests, args.recentMergedPullRequests ?? []); + const queueHealth = buildQueueHealth(args.repo, args.issues, args.pullRequests, collisions); + const recommendation = buildRepoFitRecommendation({ + login: args.login, + repo: args.repo, + repoFullName: args.repoFullName, + profile: args.profile, + outcomeHistory: args.outcomeHistory, + issues: args.issues, + pullRequests: args.pullRequests, + }).recommendation; + + const labels = bestFitLabels(args.repo); + const currentOpenPrCount = nonNegative(args.outcomeHistory.totals.openPullRequests); + const credibility = repoOutcome?.credibility && repoOutcome.credibility > 0 ? repoOutcome.credibility : args.scoringProfile?.evidence.credibilityAssumption ?? args.outcomeHistory.totals.credibility ?? 0.8; + const commonPreviewInput = { + repoFullName: args.repoFullName, + targetType: "planned_pr" as const, + targetKey: `${args.login}:${args.repoFullName}:reward-risk`, + contributorLogin: args.login, + labels, + linkedIssueMode: lane.lane === "issue_discovery" ? ("none" as const) : ("standard" as const), + sourceTokenScore: estimatedSourceTokenScore(repoOutcome), + totalTokenScore: estimatedTotalTokenScore(repoOutcome), + sourceLines: estimatedSourceLines(repoOutcome), + existingContributorTokenScore: 0, + credibility, + metadataOnly: true, + }; + const currentPreview = buildScorePreview({ + input: { ...commonPreviewInput, openPrCount: currentOpenPrCount }, + repo: args.repo, + snapshot: args.scoringSnapshot, + }); + const cleanupOpenPrCount = Math.min(currentOpenPrCount, currentPreview.gates.openPrThreshold); + const afterCleanupPreview = buildScorePreview({ + input: { ...commonPreviewInput, openPrCount: cleanupOpenPrCount }, + repo: args.repo, + snapshot: args.scoringSnapshot, + }); + + const relevantLane = relevantLaneFor(lane, roleContext); + const laneValueScore = laneValue(lane, currentPreview, relevantLane); + const personalFitScore = personalFit(repoOutcome, args.scoringProfile, roleContext, args.profile, args.repo); + const riskPenalty = riskScore(repoOutcome, queueHealth, collisions.summary.clusterCount, collisions.summary.highRiskCount, currentOpenPrCount, currentPreview.gates.openPrThreshold); + const maintainerFrictionPenalty = maintainerFriction(queueHealth, collisions.summary.clusterCount, args.pullRequests); + const scoreBlockers = scoreBlockersFor({ + lane, + roleContext, + currentPreview, + repo: args.repo, + repoOutcome, + currentOpenPrCount, + }); + const scoreabilityScore = scoreBlockers.length > 0 ? 0 : clamp((currentPreview.scoreEstimate.estimatedMergedScore / 50) * 100, 0, 100); + const actionLeverageScore = cleanupOpenPrCount < currentOpenPrCount ? clamp((currentOpenPrCount - cleanupOpenPrCount) * 18, 30, 100) : 0; + const baseActionInput = { + repoFullName: args.repoFullName, + laneValueScore, + scoreabilityScore, + personalFitScore, + riskPenalty, + maintainerFrictionPenalty, + actionLeverageScore, + }; + const cleanupNeeded = Math.max(0, currentOpenPrCount - currentPreview.gates.openPrThreshold); + const actions = buildActions({ + ...baseActionInput, + lane, + roleContext, + repoOutcome, + currentPreview, + afterCleanupPreview, + cleanupNeeded, + scoreBlockers, + queueHealth, + collisionsHighRiskCount: collisions.summary.highRiskCount, + }); + const actionImpact = { + currentOpenPrCount, + openPrThreshold: currentPreview.gates.openPrThreshold, + openPrMultiplierDelta: `${currentPreview.scoreEstimate.openPrMultiplier} -> ${afterCleanupPreview.scoreEstimate.openPrMultiplier}`, + estimatedScoreDelta: `${currentPreview.scoreEstimate.estimatedMergedScore} -> ${afterCleanupPreview.scoreEstimate.estimatedMergedScore}`, + cleanupNeeded, + explanation: + cleanupNeeded > 0 + ? `Landing, closing, or withdrawing ${cleanupNeeded} open PR(s) moves the current open-PR gate from blocked toward scoreable future work.` + : "Open PR pressure is not the primary scoreability blocker for this repo right now.", + }; + const whyThisHelps = whyThisHelpsFor({ + repoFullName: args.repoFullName, + lane, + roleContext, + repoOutcome, + currentPreview, + afterCleanupPreview, + cleanupNeeded, + scoreBlockers, + queueHealth, + collisionsHighRiskCount: collisions.summary.highRiskCount, + }); + const nextActions = [...new Set(actions.flatMap((action) => action.nextActions).slice(0, 8))]; + + return { + login: args.login, + repoFullName: args.repoFullName, + generatedAt: nowIso(), + roleContext, + lane, + recommendation, + rewardUpside: { + relevantLane, + repoSlice: currentPreview.laneMath.repoSlice, + directPrSlice: currentPreview.laneMath.directPrSlice, + issueDiscoverySlice: currentPreview.laneMath.issueDiscoverySlice, + maintainerCutSlice: round((args.repo?.registryConfig?.maintainerCut ?? 0) * currentPreview.laneMath.repoSlice), + labelMultiplier: currentPreview.scoreEstimate.labelMultiplier, + issueMultiplier: currentPreview.scoreEstimate.issueMultiplier, + estimatedScoreIfClean: afterCleanupPreview.scoreEstimate.estimatedMergedScore, + currentEstimatedScore: currentPreview.scoreEstimate.estimatedMergedScore, + }, + scoreBlockers, + riskBreakdown: { + queueBurden: queueHealth.level, + queueBurdenScore: queueHealth.burdenScore, + duplicateClusters: collisions.summary.clusterCount, + highRiskDuplicateClusters: collisions.summary.highRiskCount, + closedPullRequestRate: repoOutcome?.closedPullRequestRate ?? args.outcomeHistory.totals.closedPullRequestRate, + openPullRequests: currentOpenPrCount, + credibility, + reviewChurnRisk: reviewChurnRisk(repoOutcome, queueHealth, collisions.summary.highRiskCount), + }, + actionImpact, + currentPreview, + afterCleanupPreview, + actions, + whyThisHelps, + nextActions: nextActions.length > 0 ? nextActions : ["Gather fresher repo and contributor evidence before acting."], + summary: `${args.repoFullName}: ${scoreBlockers.length > 0 ? "blocked or cautionary" : "scoreable"} private reward/risk context; top action ${actions[0]?.actionKind ?? "none"}.`, + }; +} + +export function buildContributorRewardRiskStrategy(args: { + login: string; + fit: ContributorFit; + scoringProfile: ContributorScoringProfile; + scoringSnapshot: ScoringModelSnapshotRecord; + outcomeHistory: ContributorOutcomeHistory; + repositories: RepositoryRecord[]; + allIssues: IssueRecord[]; + allPullRequests: PullRequestRecord[]; + recentMergedPullRequests?: RecentMergedPullRequestRecord[] | undefined; +}): ContributorRewardRiskStrategy { + const registeredRepoNames = new Map(args.repositories.filter((repo) => repo.isRegistered).map((repo) => [repo.fullName.toLowerCase(), repo.fullName])); + const candidateRepoNames = uniqueRegisteredRepoNames( + [ + ...args.fit.opportunities.map((opportunity) => opportunity.repoFullName), + ...args.outcomeHistory.repoOutcomes.filter((outcome) => registeredRepoNames.has(outcome.repoFullName.toLowerCase())).map((outcome) => outcome.repoFullName), + ...args.repositories.filter((repo) => repo.isRegistered).map((repo) => repo.fullName), + ], + registeredRepoNames, + ); + const repoAnalyses = candidateRepoNames + .map((repoFullName) => { + const repo = args.repositories.find((candidate) => sameRepo(candidate.fullName, repoFullName)) ?? null; + return buildRepoRewardRisk({ + login: args.login, + repo, + repoFullName, + profile: args.fit.profile, + outcomeHistory: args.outcomeHistory, + scoringSnapshot: args.scoringSnapshot, + scoringProfile: args.scoringProfile, + issues: args.allIssues.filter((issue) => sameRepo(issue.repoFullName, repoFullName)), + pullRequests: args.allPullRequests.filter((pr) => sameRepo(pr.repoFullName, repoFullName)), + recentMergedPullRequests: (args.recentMergedPullRequests ?? []).filter((pr) => sameRepo(pr.repoFullName, repoFullName)), + }); + }) + .sort((left, right) => analysisRank(right) - analysisRank(left) || left.repoFullName.localeCompare(right.repoFullName)) + .slice(0, 20); + const topActions = repoAnalyses + .flatMap((analysis) => analysis.actions) + .sort((left, right) => right.priorityScore - left.priorityScore || ACTION_RANK[left.actionKind] - ACTION_RANK[right.actionKind] || left.repoFullName.localeCompare(right.repoFullName)) + .slice(0, 12); + const reasoning = [ + ...topActions.slice(0, 5).flatMap((action) => action.whyThisHelps.map((reason) => `${action.repoFullName}: ${reason}`)), + ...repoAnalyses + .filter((analysis) => analysis.roleContext.maintainerLane) + .slice(0, 4) + .map((analysis) => `${analysis.repoFullName}: maintainer-lane economics are separate from normal contributor rewards.`), + ]; + const actionImpact = repoAnalyses + .filter((analysis) => analysis.actionImpact.cleanupNeeded > 0 || analysis.currentPreview.scoreEstimate.estimatedMergedScore !== analysis.afterCleanupPreview.scoreEstimate.estimatedMergedScore) + .slice(0, 8) + .map((analysis) => `${analysis.repoFullName}: ${analysis.actionImpact.explanation} Score preview ${analysis.actionImpact.estimatedScoreDelta}; openPrMultiplier ${analysis.actionImpact.openPrMultiplierDelta}.`); + const nextActions = [...new Set(topActions.flatMap((action) => action.nextActions).slice(0, 10))]; + return { + login: args.login, + generatedAt: nowIso(), + scoringModelSnapshotId: args.scoringSnapshot.id, + summary: `${args.login} has ${topActions.length} ranked reward/risk action(s) from ${repoAnalyses.length} repo analysis record(s).`, + topActions, + repoAnalyses, + reasoning: [...new Set(reasoning)], + actionImpact, + nextActions: nextActions.length > 0 ? nextActions : ["Refresh official Gittensor and GitHub backfill data, then rerun strategy."], + }; +} + +export function buildMaintainerNoiseReport( + repo: RepositoryRecord | null, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + recentMergedPullRequests: RecentMergedPullRequestRecord[], + fullName: string, +): MaintainerNoiseReport { + const collisions = buildCollisionReport(fullName, issues, pullRequests, recentMergedPullRequests); + const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions); + const intake = buildContributorIntakeHealth(repo, issues, pullRequests, fullName, collisions); + const unlinked = pullRequests.filter((pr) => pr.state === "open" && pr.linkedIssues.length === 0).length; + const broadDiffSignals = pullRequests.filter((pr) => pr.title.length > 120 || /refactor|cleanup|misc|various/i.test(pr.title)).length; + const noiseSources = [ + ...(unlinked > 0 ? [`${unlinked} open PR(s) lack linked issue context.`] : []), + ...(collisions.summary.highRiskCount > 0 ? [`${collisions.summary.highRiskCount} high-risk duplicate/WIP cluster(s).`] : []), + ...(queueHealth.signals.stalePullRequests > 0 ? [`${queueHealth.signals.stalePullRequests} stale PR(s) add queue drag.`] : []), + ...(broadDiffSignals > 0 ? [`${broadDiffSignals} PR(s) look broad or hard to triage from title metadata.`] : []), + ...(intake.level === "strained" || intake.level === "blocked" ? [`Contributor intake is ${intake.level}.`] : []), + ]; + const score = clamp(100 - queueHealth.burdenScore * 0.55 - collisions.summary.highRiskCount * 12 - unlinked * 6 - broadDiffSignals * 4, 0, 100); + const level: MaintainerNoiseReport["level"] = score < 25 ? "critical" : score < 50 ? "high" : score < 75 ? "medium" : "low"; + const maintainerActions: MaintainerNoiseReport["maintainerActions"] = [ + ...(collisions.summary.highRiskCount > 0 ? ["likely_duplicate" as const] : []), + ...(unlinked > 0 || queueHealth.signals.stalePullRequests > 0 ? ["needs_author" as const] : []), + ...(queueHealth.signals.likelyReviewablePullRequests > 0 ? ["review_now" as const] : []), + ...(noiseSources.length === 0 ? ["watch" as const] : []), + ]; + return { + repoFullName: fullName, + generatedAt: nowIso(), + score: round(score), + level, + noiseSources: noiseSources.length > 0 ? noiseSources : ["No major maintainer-noise source detected in cached metadata."], + maintainerActions: [...new Set(maintainerActions)], + queueHealth, + summary: `${fullName} maintainer noise is ${level}; queue ${queueHealth.level}, ${collisions.summary.highRiskCount} high-risk collision cluster(s), ${unlinked} unlinked open PR(s).`, + }; +} + +export function buildPullRequestReviewability(args: { + repo: RepositoryRecord | null; + pullRequest: PullRequestRecord | null; + issues: IssueRecord[]; + pullRequests: PullRequestRecord[]; + files: PullRequestFileRecord[]; + reviews: PullRequestReviewRecord[]; + checks: CheckSummaryRecord[]; + recentMergedPullRequests: RecentMergedPullRequestRecord[]; + repoFullName: string; + pullNumber: number; + profile?: ContributorProfile | null | undefined; + outcomeHistory?: ContributorOutcomeHistory | null | undefined; +}): PullRequestReviewability { + const intelligence = buildPullRequestReviewIntelligence(args); + const pr = args.pullRequest; + const failingChecks = args.checks.filter((check) => ["failure", "timed_out", "cancelled"].includes(check.conclusion ?? "")).length; + const broadDiff = intelligence.changeSummary.fileCount >= 12 || intelligence.changeSummary.additions + intelligence.changeSummary.deletions >= 800; + const noiseSources = [ + ...(pr?.state && pr.state !== "open" ? [`PR is ${pr.state}.`] : []), + ...(intelligence.reviewSignals.linkedIssues.length === 0 ? ["Missing linked issue or no-issue rationale."] : []), + ...(intelligence.reviewSignals.collisionClusters > 0 ? [`${intelligence.reviewSignals.collisionClusters} duplicate/WIP collision cluster(s).`] : []), + ...(intelligence.changeSummary.codeFileCount > 0 && intelligence.changeSummary.testFileCount === 0 ? ["Code changes do not include cached test files."] : []), + ...(failingChecks > 0 ? [`${failingChecks} failing or cancelled check(s).`] : []), + ...(broadDiff ? ["Diff is broad enough to create avoidable review friction."] : []), + ...(intelligence.outcomeContext && !intelligence.roleContext.maintainerLane && intelligence.outcomeContext.closedPullRequestRate >= 0.35 + ? [`Contributor repo-specific closed PR rate is ${percent(intelligence.outcomeContext.closedPullRequestRate)}.`] + : []), + ]; + const score = clamp( + 100 - + noiseSources.length * 14 - + intelligence.reviewSignals.collisionClusters * 12 - + failingChecks * 18 - + (broadDiff ? 18 : 0) + + (intelligence.reviewSignals.approvalCount > 0 ? 12 : 0), + 0, + 100, + ); + const action: PullRequestReviewability["action"] = intelligence.roleContext.maintainerLane + ? "maintainer_lane" + : pr?.state && pr.state !== "open" + ? "close_or_redirect" + : intelligence.reviewSignals.collisionClusters > 0 + ? "likely_duplicate" + : score >= 75 + ? "review_now" + : score >= 45 + ? "needs_author" + : "watch"; + const whyThisHelps = [ + ...(action === "review_now" ? ["Reviewing now is efficient because cached signals show linked context and manageable friction."] : []), + ...(action === "needs_author" ? ["Asking for author cleanup first reduces maintainer review time before deep technical review."] : []), + ...(action === "likely_duplicate" ? ["Checking overlap first prevents maintainers from reviewing duplicate or soon-obsolete work."] : []), + ...(action === "maintainer_lane" ? ["Maintainer-authored work should be reviewed as repo stewardship, not outside-contributor triage."] : []), + ...(action === "close_or_redirect" ? ["Closed or non-open PRs should be redirected before consuming review time."] : []), + ...(action === "watch" ? ["Watching is lower-cost until checks, tests, issue links, or overlap signals improve."] : []), + ]; + return { + repoFullName: args.repoFullName, + pullNumber: args.pullNumber, + generatedAt: nowIso(), + score: round(score), + action, + noiseSources: noiseSources.length > 0 ? noiseSources : ["No major reviewability blocker detected in cached metadata."], + whyThisHelps, + maintainerNextSteps: maintainerNextStepsFor(action, noiseSources), + privateSummary: `Reviewability ${round(score)}/100; action ${action}; ${noiseSources.length} noise source(s) from cached metadata.`, + }; +} + +function buildActions(args: { + repoFullName: string; + lane: LaneAdvice; + roleContext: RoleContext; + repoOutcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; + currentPreview: ScorePreviewResult; + afterCleanupPreview: ScorePreviewResult; + cleanupNeeded: number; + scoreBlockers: string[]; + queueHealth: QueueHealth; + collisionsHighRiskCount: number; + laneValueScore: number; + scoreabilityScore: number; + personalFitScore: number; + riskPenalty: number; + maintainerFrictionPenalty: number; + actionLeverageScore: number; +}): RewardRiskAction[] { + const actions: RewardRiskAction[] = []; + const openRepoPrs = args.repoOutcome?.openPullRequests ?? 0; + if (args.roleContext.maintainerLane) { + actions.push( + action("maintainer_lane_improve_repo", args, 55 + (100 - args.maintainerFrictionPenalty) * 0.25, [ + "Improves the repo's contributor intake, label/config quality, and review flow instead of treating owner work as normal contributor evidence.", + ]), + action("maintainer_cut_readiness", args, 45 + (args.queueHealth.level === "low" ? 20 : 0), [ + "Checks whether maintainer-lane economics are configured clearly enough for repo owners without inflating outside-contributor history.", + ]), + ); + } + if (!args.roleContext.maintainerLane && openRepoPrs > 0) { + actions.push( + action("cleanup_existing_prs", args, 30 + args.actionLeverageScore * 0.55 + args.personalFitScore * 0.22 + args.laneValueScore * 0.12 - args.maintainerFrictionPenalty * 0.04, [ + args.cleanupNeeded > 0 + ? `Reduces open PR pressure; current openPrMultiplier ${args.currentPreview.scoreEstimate.openPrMultiplier} can move toward ${args.afterCleanupPreview.scoreEstimate.openPrMultiplier}.` + : "Keeps repo-specific queue pressure lower before adding more work.", + ]), + ); + if (args.lane.lane !== "issue_discovery") { + actions.push( + action("land_existing_prs", args, 25 + args.personalFitScore * 0.28 + args.laneValueScore * 0.18 + args.actionLeverageScore * 0.35 - args.riskPenalty * 0.08, [ + "Landing already-open work preserves successful repo-specific evidence and avoids adding new maintainer load.", + ]), + ); + } + } + if (!args.roleContext.maintainerLane && openRepoPrs > 0 && (args.scoreBlockers.length > 0 || args.riskPenalty >= 55)) { + actions.push( + action("close_or_withdraw_low_fit_prs", args, 20 + args.actionLeverageScore * 0.35 + args.riskPenalty * 0.08, [ + "Withdrawing stale or low-fit work can reduce collateral pressure faster than opening new submissions.", + ]), + ); + } + if (!args.roleContext.maintainerLane && (args.lane.lane === "direct_pr" || args.lane.lane === "split")) { + actions.push( + action( + "open_new_direct_pr", + args, + 18 + args.laneValueScore * 0.22 + args.scoreabilityScore * 0.3 + args.personalFitScore * 0.25 - args.riskPenalty * 0.18 - args.maintainerFrictionPenalty * 0.08, + args.scoreBlockers.length > 0 + ? ["New PR expected value is low until hard scoreability blockers and maintainer-friction signals are cleared."] + : ["A tightly scoped, linked, tested direct PR has scoreability and maintainer-fit upside in this lane."], + ), + ); + } + if (!args.roleContext.maintainerLane && (args.lane.lane === "issue_discovery" || args.lane.lane === "split")) { + actions.push( + action("file_issue_discovery", args, 18 + args.laneValueScore * 0.28 + (args.lane.lane === "issue_discovery" ? 20 : 0) - args.riskPenalty * 0.16, [ + args.lane.lane === "issue_discovery" + ? "This repo routes value through issue discovery; direct PR-side work has little or no lane value under current config." + : "Issue discovery can be viable only for high-proof reports that someone else can solve.", + ]), + ); + } + return actions + .map((candidate) => ({ ...candidate, priorityScore: round(clamp(candidate.priorityScore, 0, 100)) })) + .sort((left, right) => right.priorityScore - left.priorityScore || ACTION_RANK[left.actionKind] - ACTION_RANK[right.actionKind]); +} + +function action(kind: RewardRiskActionKind, args: { + repoFullName: string; + laneValueScore: number; + scoreabilityScore: number; + personalFitScore: number; + riskPenalty: number; + maintainerFrictionPenalty: number; + actionLeverageScore: number; +}, priorityScore: number, whyThisHelps: string[]): RewardRiskAction { + return { + actionKind: kind, + repoFullName: args.repoFullName, + priorityScore, + laneValueScore: round(args.laneValueScore), + scoreabilityScore: round(args.scoreabilityScore), + personalFitScore: round(args.personalFitScore), + riskPenalty: round(args.riskPenalty), + maintainerFrictionPenalty: round(args.maintainerFrictionPenalty), + actionLeverageScore: round(args.actionLeverageScore), + whyThisHelps, + nextActions: nextActionsFor(kind), + }; +} + +function scoreBlockersFor(args: { + lane: LaneAdvice; + roleContext: RoleContext; + currentPreview: ScorePreviewResult; + repo: RepositoryRecord | null; + repoOutcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; + currentOpenPrCount: number; +}): string[] { + return [ + ...(!args.repo?.isRegistered ? ["Repository is not registered in the local snapshot."] : []), + ...(args.lane.lane === "inactive" ? ["Repository allocation is inactive."] : []), + ...(args.lane.lane === "unknown" ? ["Repository lane is unknown."] : []), + ...(args.roleContext.maintainerLane ? ["Maintainer-lane work is not normal outside-contributor reward evidence."] : []), + ...(args.currentPreview.laneMath.directPrSlice <= 0 && args.lane.lane === "issue_discovery" ? ["Direct PR-side lane value is disabled for this repo."] : []), + ...(args.currentOpenPrCount > args.currentPreview.gates.openPrThreshold ? ["Open PR count exceeds the current threshold assumption."] : []), + ...(args.currentPreview.gates.credibilityObserved < args.currentPreview.gates.credibilityFloor ? ["Credibility assumption is below the current floor."] : []), + ...((args.repoOutcome?.closedPullRequestRate ?? 0) >= 0.35 ? ["Repo-specific closed PR rate is high enough to create credibility risk."] : []), + ]; +} + +function whyThisHelpsFor(args: { + repoFullName: string; + lane: LaneAdvice; + roleContext: RoleContext; + repoOutcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; + currentPreview: ScorePreviewResult; + afterCleanupPreview: ScorePreviewResult; + cleanupNeeded: number; + scoreBlockers: string[]; + queueHealth: QueueHealth; + collisionsHighRiskCount: number; +}): string[] { + return [ + ...(args.cleanupNeeded > 0 + ? [`Cleanup is high leverage because it changes openPrMultiplier ${args.currentPreview.scoreEstimate.openPrMultiplier} -> ${args.afterCleanupPreview.scoreEstimate.openPrMultiplier} and estimated score ${args.currentPreview.scoreEstimate.estimatedMergedScore} -> ${args.afterCleanupPreview.scoreEstimate.estimatedMergedScore}.`] + : []), + ...(args.repoOutcome && args.repoOutcome.mergedPullRequests > 0 + ? [`Protects repo-specific credibility where ${args.repoOutcome.mergedPullRequests} merged PR(s) already show fit.`] + : []), + ...(args.roleContext.maintainerLane + ? [`${args.repoFullName} is maintainer lane for this user, so repo-health and maintainer_cut readiness matter more than normal contributor submissions.`] + : []), + ...(args.lane.lane === "issue_discovery" ? ["Direct PRs have no PR-side lane value here; issue-discovery quality and closure risk dominate."] : []), + ...(args.scoreBlockers.length > 0 ? [`Hard blockers: ${args.scoreBlockers.join(" ")}`] : []), + ...(args.queueHealth.level === "high" || args.queueHealth.level === "critical" ? [`Maintainer queue is ${args.queueHealth.level}; review friction lowers risk-adjusted priority.`] : []), + ...(args.collisionsHighRiskCount > 0 ? [`${args.collisionsHighRiskCount} high-risk collision cluster(s) must be cleared before new work has good expected value.`] : []), + ]; +} + +function relevantLaneFor(lane: LaneAdvice, roleContext: RoleContext): RepoRewardRisk["rewardUpside"]["relevantLane"] { + if (roleContext.maintainerLane) return "maintainer_lane"; + if (lane.lane === "direct_pr") return "direct_pr"; + if (lane.lane === "issue_discovery") return "issue_discovery"; + if (lane.lane === "split") return "direct_pr"; + return "none"; +} + +function laneValue(lane: LaneAdvice, preview: ScorePreviewResult, relevantLane: RepoRewardRisk["rewardUpside"]["relevantLane"]): number { + if (lane.lane === "inactive" || lane.lane === "unknown" || relevantLane === "none") return 0; + if (relevantLane === "issue_discovery") return clamp(preview.laneMath.issueDiscoverySlice * 1000, 0, 100); + if (relevantLane === "maintainer_lane") return clamp(preview.laneMath.repoSlice * 800, 0, 100); + return clamp(preview.laneMath.directPrSlice * 1000, 0, 100); +} + +function personalFit( + outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined, + scoringProfile: ContributorScoringProfile | null | undefined, + roleContext: RoleContext, + profile: ContributorProfile, + repo: RepositoryRecord | null, +): number { + if (roleContext.maintainerLane) return 80; + const languageMatch = repo?.fullName && profile.github.topLanguages.length > 0 ? 10 : 0; + return clamp( + (outcome?.mergedPullRequests ?? 0) * 2.2 + + (outcome?.credibility ?? scoringProfile?.evidence.credibilityAssumption ?? 0.8) * 35 + + (outcome?.validSolvedIssues ?? 0) * 3 + + languageMatch - + (outcome?.closedPullRequestRate ?? 0) * 45 - + Math.max(0, (outcome?.openPullRequests ?? 0) - 2) * 4, + 0, + 100, + ); +} + +function riskScore( + outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined, + queueHealth: QueueHealth, + duplicateClusters: number, + highRiskDuplicateClusters: number, + openPrCount: number, + openPrThreshold: number, +): number { + const queuePenalty = queueHealth.level === "critical" ? 35 : queueHealth.level === "high" ? 24 : queueHealth.level === "medium" ? 12 : 0; + return clamp( + queuePenalty + + duplicateClusters * 4 + + highRiskDuplicateClusters * 14 + + Math.max(0, openPrCount - openPrThreshold) * 12 + + (outcome?.closedPullRequestRate ?? 0) * 55 + + Math.max(0, (outcome?.openPullRequests ?? 0) - 2) * 5, + 0, + 100, + ); +} + +function maintainerFriction(queueHealth: QueueHealth, duplicateClusters: number, pullRequests: PullRequestRecord[]): number { + const unlinked = pullRequests.filter((pr) => pr.state === "open" && pr.linkedIssues.length === 0).length; + return clamp(queueHealth.burdenScore * 0.55 + duplicateClusters * 8 + unlinked * 5, 0, 100); +} + +function reviewChurnRisk(outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined, queueHealth: QueueHealth, highRiskDuplicateClusters: number): "low" | "medium" | "high" { + const risk = (outcome?.closedPullRequestRate ?? 0) * 100 + highRiskDuplicateClusters * 18 + (queueHealth.level === "critical" ? 25 : queueHealth.level === "high" ? 15 : 0); + return risk >= 45 ? "high" : risk >= 20 ? "medium" : "low"; +} + +function analysisRank(analysis: RepoRewardRisk): number { + return (analysis.actions[0]?.priorityScore ?? 0) + analysis.rewardUpside.directPrSlice * 100 + analysis.rewardUpside.issueDiscoverySlice * 100; +} + +function bestFitLabels(repo: RepositoryRecord | null): string[] { + const multipliers = repo?.registryConfig?.labelMultipliers ?? {}; + const labels = Object.entries(multipliers) + .filter(([label]) => !/status|source|contributor|verified|risk|codex/i.test(label)) + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) + .map(([label]) => label); + return labels.slice(0, 1); +} + +function estimatedSourceTokenScore(outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined): number { + return clamp(42 + (outcome?.mergedPullRequests ?? 0) * 2, 30, 120); +} + +function estimatedTotalTokenScore(outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined): number { + return clamp(70 + (outcome?.mergedPullRequests ?? 0) * 4, 60, 220); +} + +function estimatedSourceLines(outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined): number { + return Math.max(12, estimatedSourceTokenScore(outcome)); +} + +function nextActionsFor(kind: RewardRiskActionKind): string[] { + switch (kind) { + case "cleanup_existing_prs": + return ["Land, close, or withdraw stale open PRs before opening additional direct-PR work.", "Prioritize the repo where existing successful evidence is strongest."]; + case "land_existing_prs": + return ["Tighten validation, update PR bodies, and resolve review/check blockers on already-open work."]; + case "close_or_withdraw_low_fit_prs": + return ["Withdraw stale or low-fit PRs that are unlikely to merge cleanly and are adding open PR pressure."]; + case "open_new_direct_pr": + return ["Only open a new PR after duplicate checks, local score preview, tests, and linked/no-issue rationale are clean."]; + case "file_issue_discovery": + return ["File only high-proof issues that someone else can solve and that are unlikely to be closed as duplicate or unclear."]; + case "maintainer_lane_improve_repo": + return ["Improve labels, contribution docs, queue hygiene, and contributor intake for the maintained repo."]; + case "maintainer_cut_readiness": + return ["Check config quality and maintainer_cut readiness before expecting maintainer-lane economics to work cleanly."]; + } +} + +function maintainerNextStepsFor(action: PullRequestReviewability["action"], noiseSources: string[]): string[] { + if (action === "review_now") return ["Review the technical diff now; cached hygiene signals look clean enough."]; + if (action === "maintainer_lane") return ["Treat as maintainer stewardship and verify repo-health impact separately."]; + if (action === "likely_duplicate") return ["Compare against linked issues, active PRs, and recent merges before detailed review."]; + if (action === "close_or_redirect") return ["Redirect or close non-open/stale context before spending review time."]; + if (action === "needs_author") return ["Ask the author to address the concrete missing context before deep review.", ...noiseSources.slice(0, 3)]; + return ["Watch for tests, checks, linked context, or duplicate-risk changes before prioritizing review."]; +} + +function sameRepo(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); +} + +function uniqueRegisteredRepoNames(repoFullNames: string[], registeredRepoNames: Map): string[] { + const seen = new Set(); + const unique: string[] = []; + for (const repoFullName of repoFullNames) { + const key = repoFullName.toLowerCase(); + const canonical = registeredRepoNames.get(key); + if (!canonical || seen.has(key)) continue; + seen.add(key); + unique.push(canonical); + } + return unique; +} + +function nonNegative(value: number | undefined): number { + return Number.isFinite(value) ? Math.max(0, value ?? 0) : 0; +} + +function percent(value: number): string { + return `${Math.round(value * 100)}%`; +} + +function round(value: number): number { + return Math.round(value * 10000) / 10000; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} diff --git a/src/types.ts b/src/types.ts index 516d95b779..39029a4f40 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,67 @@ export type JobMessage = | { type: "refresh-registry"; requestedBy: "schedule" | "api" | "test"; + } + | { + type: "backfill-registered-repos"; + requestedBy: "schedule" | "api" | "test"; + repoFullName?: string; + force?: boolean; + mode?: "light" | "full" | "resume"; + } + | { + type: "backfill-repo-segment"; + requestedBy: "schedule" | "api" | "test"; + repoFullName: string; + segment: "labels" | "open_issues" | "open_pull_requests" | "recent_merged_pull_requests"; + mode?: "light" | "full" | "resume"; + force?: boolean; + cursor?: string; + } + | { + type: "backfill-pr-details"; + requestedBy: "schedule" | "api" | "test"; + repoFullName: string; + mode?: "light" | "full" | "resume"; + cursor?: number; + } + | { + type: "refresh-installation-health"; + requestedBy: "schedule" | "api" | "test"; + } + | { + type: "generate-signal-snapshots"; + requestedBy: "schedule" | "api" | "test"; + repoFullName?: string; + } + | { + type: "refresh-scoring-model"; + requestedBy: "schedule" | "api" | "test"; + } + | { + type: "build-contributor-evidence"; + requestedBy: "schedule" | "api" | "test"; + login?: string; + } + | { + type: "build-contributor-decision-packs"; + requestedBy: "schedule" | "api" | "test"; + login?: string; + } + | { + type: "refresh-contributor-activity"; + requestedBy: "schedule" | "api" | "test"; + login: string; + repoFullName?: string; + } + | { + type: "build-burden-forecasts"; + requestedBy: "schedule" | "api" | "test"; + repoFullName?: string; + } + | { + type: "repair-data-fidelity"; + requestedBy: "schedule" | "api" | "test"; }; export type GitHubWebhookPayload = { @@ -55,6 +116,8 @@ export type GitHubPullRequestPayload = { state: string; html_url?: string; merged_at?: string | null; + created_at?: string | null; + updated_at?: string | null; user?: { login?: string; }; @@ -75,6 +138,8 @@ export type GitHubIssuePayload = { title: string; state: string; html_url?: string; + created_at?: string | null; + updated_at?: string | null; user?: { login?: string; }; @@ -203,6 +268,379 @@ export type RepositorySettings = { commentMode: "off" | "detected_contributors_only" | "all_prs"; publicSignalLevel: "minimal" | "standard"; checkRunMode: "enabled"; + checkRunDetailLevel: "minimal" | "standard" | "deep"; + backfillEnabled: boolean; + privateTrustEnabled: boolean; + createdAt?: string | null | undefined; + updatedAt?: string | null | undefined; +}; + +export type RepoSyncStateRecord = { + repoFullName: string; + status: "never_synced" | "running" | "success" | "partial" | "error" | "skipped" | "capped" | "rate_limited" | "stale"; + sourceKind: "github" | "installation" | "test"; + primaryLanguage?: string | null | undefined; + defaultBranch?: string | null | undefined; + isPrivate?: boolean | null | undefined; + openIssuesCount: number; + openPullRequestsCount: number; + recentMergedPullRequestsCount: number; + labelsSyncedAt?: string | null | undefined; + issuesSyncedAt?: string | null | undefined; + pullRequestsSyncedAt?: string | null | undefined; + mergedPullRequestsSyncedAt?: string | null | undefined; + lastStartedAt?: string | null | undefined; + lastCompletedAt?: string | null | undefined; + errorSummary?: string | null | undefined; + warnings: string[]; + updatedAt?: string | null | undefined; +}; + +export type RepoSyncSegmentRecord = { + repoFullName: string; + segment: + | "metadata" + | "labels" + | "open_issues" + | "open_pull_requests" + | "recent_merged_pull_requests" + | "pull_request_files" + | "pull_request_reviews" + | "check_summaries"; + status: + | "never_synced" + | "running" + | "refreshing" + | "complete" + | "partial" + | "capped" + | "sampled" + | "stale" + | "rate_limited" + | "waiting_rate_limit" + | "error" + | "skipped" + | "not_modified"; + sourceKind: "github" | "installation" | "test"; + mode: "light" | "full" | "resume"; + lastCursor?: string | null | undefined; + nextCursor?: string | null | undefined; + fetchedCount: number; + expectedCount?: number | null | undefined; + pageCount: number; + startedAt?: string | null | undefined; + completedAt?: string | null | undefined; + staleAt?: string | null | undefined; + rateLimitResetAt?: string | null | undefined; + etag?: string | null | undefined; + lastModified?: string | null | undefined; + warnings: string[]; + errorSummary?: string | null | undefined; + updatedAt?: string | null | undefined; +}; + +export type RepoGithubTotalsSnapshotRecord = { + id: string; + repoFullName: string; + openIssuesTotal: number; + openPullRequestsTotal: number; + mergedPullRequestsTotal: number; + closedUnmergedPullRequestsTotal: number; + labelsTotal: number; + sourceKind: "github" | "installation" | "test"; + fetchedAt: string; + rateLimitRemaining?: number | null | undefined; + rateLimitResetAt?: string | null | undefined; + payload: Record; +}; + +export type PullRequestDetailSyncStateRecord = { + repoFullName: string; + pullNumber: number; + status: "never_synced" | "running" | "complete" | "partial" | "waiting_rate_limit" | "error"; + filesSyncedAt?: string | null | undefined; + reviewsSyncedAt?: string | null | undefined; + checksSyncedAt?: string | null | undefined; + lastSyncedAt?: string | null | undefined; + errorSummary?: string | null | undefined; + updatedAt?: string | null | undefined; +}; + +export type GitHubRateLimitObservationRecord = { + id?: string | undefined; + repoFullName?: string | null | undefined; + resource: "rest" | "graphql"; + path: string; + statusCode: number; + limitValue?: number | null | undefined; + remaining?: number | null | undefined; + resetAt?: string | null | undefined; + observedAt?: string | null | undefined; +}; + +export type DataQuality = { + status: "complete" | "degraded" | "blocked" | "unknown"; + generatedAt: string; + repoFullName?: string | null | undefined; + stale: boolean; + partial: boolean; + capped: boolean; + rateLimited: boolean; + segmentCount: number; + incompleteSegments: string[]; + cappedSegments: string[]; + staleSegments: string[]; + rateLimitedSegments: string[]; + warnings: string[]; + syncState?: Pick | undefined; +}; + +export type RepoLabelRecord = { + repoFullName: string; + name: string; + color?: string | null | undefined; + description?: string | null | undefined; + isConfigured: boolean; + observedCount: number; + payload: Record; + lastSeenAt?: string | null | undefined; +}; + +export type RepoSnapshotRecord = { + id: string; + repoFullName: string; + snapshotKind: string; + sourceKind: string; + fetchedAt: string; + primaryLanguage?: string | null | undefined; + defaultBranch?: string | null | undefined; + openIssuesCount: number; + openPullRequestsCount: number; + recentMergedPullRequestsCount: number; + payload: Record; +}; + +export type PullRequestFileRecord = { + repoFullName: string; + pullNumber: number; + path: string; + status?: string | null | undefined; + additions: number; + deletions: number; + changes: number; + previousFilename?: string | null | undefined; + payload: Record; +}; + +export type PullRequestReviewRecord = { + id: string; + repoFullName: string; + pullNumber: number; + reviewerLogin?: string | null | undefined; + state: string; + authorAssociation?: string | null | undefined; + submittedAt?: string | null | undefined; + payload: Record; +}; + +export type CheckSummaryRecord = { + id: string; + repoFullName: string; + pullNumber?: number | null | undefined; + headSha?: string | null | undefined; + name: string; + status: string; + conclusion?: string | null | undefined; + startedAt?: string | null | undefined; + completedAt?: string | null | undefined; + detailsUrl?: string | null | undefined; + payload: Record; +}; + +export type RecentMergedPullRequestRecord = { + repoFullName: string; + number: number; + title: string; + authorLogin?: string | null | undefined; + htmlUrl?: string | null | undefined; + mergedAt?: string | null | undefined; + labels: string[]; + linkedIssues: number[]; + changedFiles: string[]; + payload: Record; +}; + +export type ContributorRecord = { + login: string; + githubProfile: Record; + topLanguages: string[]; + publicRepos?: number | null | undefined; + followers?: number | null | undefined; + source: "github" | "unavailable"; + firstSeenAt?: string | null | undefined; + lastSeenAt?: string | null | undefined; + updatedAt?: string | null | undefined; +}; + +export type ContributorRepoStatRecord = { + login: string; + repoFullName: string; + pullRequests: number; + mergedPullRequests: number; + openPullRequests: number; + issues: number; + stalePullRequests: number; + unlinkedPullRequests: number; + dominantLabels: string[]; + lastActivityAt?: string | null | undefined; +}; + +export type CollisionEdgeRecord = { + id: string; + repoFullName: string; + leftType: "issue" | "pull_request" | "recent_merged_pull_request"; + leftNumber: number; + leftTitle: string; + rightType: "issue" | "pull_request" | "recent_merged_pull_request"; + rightNumber: number; + rightTitle: string; + risk: "low" | "medium" | "high"; + reason: string; + sharedTerms: string[]; + generatedAt?: string | null | undefined; +}; + +export type SignalSnapshotRecord = { + id: string; + signalType: string; + targetKey: string; + repoFullName?: string | null | undefined; + payload: Record; + generatedAt?: string | null | undefined; +}; + +export type InstallationRecord = { + id: number; + accountLogin: string; + accountId: number; + targetType: string; + repositorySelection?: string | null | undefined; + permissions: Record; + events: string[]; + suspendedAt?: string | null | undefined; createdAt?: string | null | undefined; updatedAt?: string | null | undefined; }; + +export type InstallationHealthRecord = { + installationId: number; + accountLogin: string; + repositorySelection?: string | null | undefined; + installedReposCount: number; + registeredInstalledCount: number; + status: "healthy" | "needs_attention" | "broken"; + missingPermissions: string[]; + missingEvents: string[]; + permissions: Record; + events: string[]; + checkedAt: string; + errorSummary?: string | null | undefined; +}; + +export type ScoringModelSnapshotRecord = { + id: string; + sourceKind: "raw-github" | "api" | "fallback" | "test"; + sourceUrl: string; + fetchedAt: string; + activeModel: "current_density_model" | "pending_saturation_model" | "unknown"; + constants: Record; + programmingLanguages: Record; + registrySnapshotId?: string | null | undefined; + warnings: string[]; + payload: Record; +}; + +export type ScorePreviewRecord = { + id: string; + scoringModelSnapshotId: string; + repoFullName: string; + targetType: "planned_pr" | "pull_request" | "local_diff" | "variant"; + targetKey: string; + contributorLogin?: string | null | undefined; + input: Record; + result: Record; + generatedAt: string; +}; + +export type ContributorEvidenceRecord = { + login: string; + payload: Record; + generatedAt: string; +}; + +export type ContributorScoringProfileRecord = { + login: string; + scoringModelSnapshotId: string; + payload: Record; + generatedAt: string; +}; + +export type IssueQualityReportRecord = { + id: string; + repoFullName: string; + issueNumber: number; + payload: Record; + generatedAt: string; +}; + +export type BurdenForecastRecord = { + repoFullName: string; + payload: Record; + generatedAt: string; +}; + +export type RegistryDriftEventRecord = { + id: string; + repoFullName: string; + driftType: string; + detail: string; + previousSnapshotId?: string | null | undefined; + currentSnapshotId?: string | null | undefined; + payload: Record; + generatedAt: string; +}; + +export type BountyLifecycleEventRecord = { + id: string; + bountyId: string; + repoFullName: string; + issueNumber: number; + status: string; + payload: Record; + generatedAt: string; +}; + +export type AuthSessionRecord = { + id: string; + tokenHash: string; + login: string; + githubUserId?: number | null | undefined; + scopes: string[]; + expiresAt: string; + revokedAt?: string | null | undefined; + createdAt: string; + lastSeenAt?: string | null | undefined; + metadata: Record; +}; + +export type AuditEventRecord = { + id?: string | undefined; + eventType: string; + actor?: string | null | undefined; + route?: string | null | undefined; + targetKey?: string | null | undefined; + outcome: "success" | "denied" | "error" | "queued" | "completed"; + detail?: string | null | undefined; + metadata?: Record | undefined; + createdAt?: string | null | undefined; +}; diff --git a/src/utils/json.ts b/src/utils/json.ts index 73be9c4eba..0e0e1216a2 100644 --- a/src/utils/json.ts +++ b/src/utils/json.ts @@ -20,9 +20,10 @@ export function normalizeRepoFullName(value: string): string { } export function repoParts(fullName: string): { owner: string; name: string } { - const [owner, ...rest] = fullName.split("/"); + if (fullName.length === 0) return { owner: "", name: "" }; + const [owner, ...rest] = fullName.split("/") as [string, ...string[]]; return { - owner: owner ?? "", + owner, name: rest.join("/"), }; } diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index cec146a7aa..79e2d39d4f 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1,5 +1,23 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { upsertBounty, upsertIssueFromGitHub, upsertPullRequestFromGitHub } from "../../src/db/repositories"; +import { + upsertBounty, + upsertCheckSummary, + upsertInstallation, + upsertInstallationHealth, + upsertPullRequestFile, + upsertPullRequestReview, + upsertPullRequestDetailSyncState, + upsertRecentMergedPullRequest, + persistRepoGithubTotalsSnapshot, + persistSignalSnapshot, + upsertRepoLabel, + upsertRepoSyncSegment, + upsertRepoSyncState, + upsertIssueFromGitHub, + upsertPullRequestFromGitHub, + persistScoringModelSnapshot, + upsertRepositoryFromGitHub, +} from "../../src/db/repositories"; import { createApp } from "../../src/api/routes"; import { normalizeRegistryPayload } from "../../src/registry/normalize"; import { persistRegistrySnapshot } from "../../src/registry/sync"; @@ -26,6 +44,47 @@ describe("api routes", () => { await expect(spec.json()).resolves.toMatchObject({ info: { title: "Gittensory API" } }); }); + it("serves registry drift through the canonical registry change endpoint", async () => { + const app = createApp(); + const env = createTestEnv(); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "owner/removed": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + "owner/changed": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + "owner/stable": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + }, + { kind: "raw-github", url: "fixture://old-registry" }, + "2026-05-24T00:00:00.000Z", + ), + ); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "owner/added": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + "owner/changed": { emission_share: 0.02, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + "owner/stable": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + }, + { kind: "raw-github", url: "fixture://current-registry" }, + "2026-05-25T00:00:00.000Z", + ), + ); + + const changes = await app.request("/v1/registry/changes", { headers: apiHeaders(env) }, env); + expect(changes.status).toBe(200); + await expect(changes.json()).resolves.toMatchObject({ + summary: expect.stringContaining("added"), + addedRepos: ["owner/added"], + removedRepos: ["owner/removed"], + changedRepos: [expect.objectContaining({ repoFullName: "owner/changed" })], + }); + + const legacyPerRepoDrift = await app.request("/v1/repos/owner/changed/registry-drift", { headers: apiHeaders(env) }, env); + expect(legacyPerRepoDrift.status).toBe(404); + }); + it("queues signed GitHub webhooks and rejects invalid signatures", async () => { const app = createApp(); const queued: unknown[] = []; @@ -60,6 +119,9 @@ describe("api routes", () => { expect(accepted.status).toBe(202); expect(queued).toHaveLength(1); + const missingHeaders = await app.request("/v1/github/webhook", { method: "POST", body }, env); + expect(missingHeaders.status).toBe(400); + const duplicate = await app.request( "/v1/github/webhook", { @@ -101,6 +163,50 @@ describe("api routes", () => { await seedSignalData(env); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); + if (url === "https://api.gittensor.io/miners") { + return Response.json([ + { + uid: 7, + hotkey: "hotkey", + githubUsername: "oktofeesh1", + githubId: "12345", + totalPrs: 2, + totalMergedPrs: 1, + totalOpenPrs: 1, + totalClosedPrs: 0, + totalOpenIssues: 1, + totalClosedIssues: 0, + totalSolvedIssues: 0, + totalValidSolvedIssues: 0, + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + }, + ]); + } + if (url === "https://api.gittensor.io/miners/12345") { + return Response.json({ + repositories: [ + { + repositoryFullName: "entrius/allways-ui", + totalPrs: "2", + totalMergedPrs: "1", + totalOpenPrs: "1", + totalClosedPrs: "0", + totalOpenIssues: "1", + totalClosedIssues: "0", + isEligible: true, + credibility: "1.000000", + }, + ], + }); + } + if (url === "https://api.gittensor.io/miners/12345/prs") { + return Response.json([{ repository: "entrius/allways-ui", pullRequestNumber: 12, pullRequestTitle: "Fix dashboard cache", prState: "OPEN", label: "bug" }]); + } + if (url === "https://mirror.gittensor.io/api/v1/miners/12345/issues") { + return Response.json({ issues: [{ labels: [{ name: "bug" }] }] }); + } if (url.endsWith("/users/oktofeesh1")) { return Response.json({ login: "oktofeesh1", public_repos: 42, followers: 7 }); } @@ -110,16 +216,49 @@ describe("api routes", () => { return new Response("not found", { status: 404 }); }); - const unauthenticated = await app.request("/v1/repos/entrius/allways-ui/queue-health", {}, env); + const unauthenticated = await app.request("/v1/repos/entrius/allways-ui/intelligence", {}, env); expect(unauthenticated.status).toBe(401); - const queueHealth = await app.request("/v1/repos/entrius/allways-ui/queue-health", { headers: apiHeaders(env) }, env); - expect(queueHealth.status).toBe(200); - await expect(queueHealth.json()).resolves.toMatchObject({ repoFullName: "entrius/allways-ui", signals: { openPullRequests: 2 } }); + const intelligence = await app.request("/v1/repos/entrius/allways-ui/intelligence", { headers: apiHeaders(env) }, env); + expect(intelligence.status).toBe(200); + await expect(intelligence.json()).resolves.toMatchObject({ + status: "ready", + repoFullName: "entrius/allways-ui", + lane: { lane: "direct_pr" }, + queueHealth: { signals: { openPullRequests: 2 } }, + collisions: { summary: { clusterCount: expect.any(Number) } }, + configQuality: { notObservedConfiguredLabels: expect.arrayContaining(["refactor"]) }, + labelAudit: { missingConfiguredLabels: expect.arrayContaining(["refactor"]) }, + dataQuality: expect.any(Object), + }); + + for (const path of [ + "/v1/repos/entrius/allways-ui/queue-health", + "/v1/repos/entrius/allways-ui/collisions", + "/v1/repos/entrius/allways-ui/config-quality", + "/v1/repos/entrius/allways-ui/lane", + "/v1/repos/entrius/allways-ui/labels/audit", + "/v1/repos/entrius/allways-ui/workboard", + "/v1/repos/entrius/allways-ui/maintainer-packet", + "/v1/repos/entrius/allways-ui/maintainer-lane", + "/v1/repos/entrius/allways-ui/maintainer-cut-readiness", + "/v1/repos/entrius/allways-ui/contributor-intake-health", + "/v1/repos/entrius/allways-ui/maintainer-noise", + ]) { + const legacy = await app.request(path, { headers: apiHeaders(env) }, env); + expect(legacy.status).toBe(404); + } + + const maintainerPacket = await app.request("/v1/repos/entrius/allways-ui/pulls/12/maintainer-packet", { headers: apiHeaders(env) }, env); + expect(maintainerPacket.status).toBe(200); + await expect(maintainerPacket.json()).resolves.toMatchObject({ pullNumber: 12, reviewSignals: { linkedIssues: [7] } }); - const configQuality = await app.request("/v1/repos/entrius/allways-ui/config-quality", { headers: apiHeaders(env) }, env); - expect(configQuality.status).toBe(200); - await expect(configQuality.json()).resolves.toMatchObject({ notObservedConfiguredLabels: expect.arrayContaining(["refactor"]) }); + const reviewIntelligence = await app.request("/v1/repos/entrius/allways-ui/pulls/12/review-intelligence", { headers: apiHeaders(env) }, env); + expect(reviewIntelligence.status).toBe(404); + + const reviewability = await app.request("/v1/repos/entrius/allways-ui/pulls/12/reviewability", { headers: apiHeaders(env) }, env); + expect(reviewability.status).toBe(200); + await expect(reviewability.json()).resolves.toMatchObject({ repoFullName: "entrius/allways-ui", pullNumber: 12, action: expect.any(String), privateSummary: expect.any(String) }); const preflight = await app.request( "/v1/preflight/pr", @@ -138,14 +277,162 @@ describe("api routes", () => { expect(preflight.status).toBe(200); await expect(preflight.json()).resolves.toMatchObject({ status: "needs_work" }); - const opportunities = await app.request("/v1/contributors/oktofeesh1/opportunities", { headers: apiHeaders(env) }, env); - expect(opportunities.status).toBe(200); - const opportunityPayload = (await opportunities.json()) as { - profile: { github: { topLanguages: string[] } }; - opportunities: Array<{ repoFullName: string }>; + const contributorProfile = await app.request("/v1/contributors/oktofeesh1/profile", { headers: apiHeaders(env) }, env); + expect(contributorProfile.status).toBe(200); + await expect(contributorProfile.json()).resolves.toMatchObject({ login: "oktofeesh1", github: { topLanguages: ["TypeScript", "Python"] } }); + + const missingDecisionPack = await app.request("/v1/contributors/oktofeesh1/decision-pack", { headers: apiHeaders(env) }, env); + expect(missingDecisionPack.status).toBe(202); + await expect(missingDecisionPack.json()).resolves.toMatchObject({ status: "needs_snapshot_refresh", login: "oktofeesh1", enqueued: true }); + + const builtDecisionPack = await app.request( + "/v1/internal/jobs/build-contributor-decision-packs/run", + { + method: "POST", + headers: { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify({ login: "oktofeesh1" }), + }, + env, + ); + expect(builtDecisionPack.status).toBe(200); + const builtDecisionPayload = (await builtDecisionPack.json()) as { + profile: { github: { topLanguages: string[] }; officialStats?: Record | null }; + outcomeHistory: { totals: Record }; + topActions: unknown[]; + }; + expect(builtDecisionPayload.profile.github.topLanguages).toEqual(["TypeScript", "Python"]); + expect(builtDecisionPayload.profile.officialStats).not.toHaveProperty("hotkey"); + expect(builtDecisionPayload.outcomeHistory.totals).toMatchObject({ pullRequests: 2, mergedPullRequests: 1, openPullRequests: 1 }); + expect(builtDecisionPayload.topActions.length).toBeGreaterThan(0); + + const decisionPack = await app.request("/v1/contributors/oktofeesh1/decision-pack", { headers: apiHeaders(env) }, env); + expect(decisionPack.status).toBe(200); + await expect(decisionPack.json()).resolves.toMatchObject({ status: "ready", login: "oktofeesh1", profile: { github: { topLanguages: ["TypeScript", "Python"] } } }); + + const repoDecision = await app.request("/v1/contributors/oktofeesh1/repos/entrius/allways-ui/decision", { headers: apiHeaders(env) }, env); + expect(repoDecision.status).toBe(200); + await expect(repoDecision.json()).resolves.toMatchObject({ + status: "ready", + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + decision: { repoFullName: "entrius/allways-ui", rewardUpside: expect.any(Object), roleContext: { role: "outside_contributor" } }, + }); + + const missingRepoDecisionSnapshot = await app.request("/v1/contributors/new-user/repos/entrius/allways-ui/decision", { headers: apiHeaders(env) }, env); + expect(missingRepoDecisionSnapshot.status).toBe(202); + await expect(missingRepoDecisionSnapshot.json()).resolves.toMatchObject({ status: "needs_snapshot_refresh", repoFullName: "entrius/allways-ui" }); + + for (const path of [ + "/v1/contributors/oktofeesh1/opportunities", + "/v1/contributors/oktofeesh1/fit", + "/v1/contributors/oktofeesh1/scoring-profile", + "/v1/contributors/oktofeesh1/strategy", + "/v1/contributors/oktofeesh1/reward-risk-strategy", + "/v1/contributors/oktofeesh1/actions/recommendations", + "/v1/contributors/oktofeesh1/role-context", + "/v1/contributors/oktofeesh1/outcome-history", + "/v1/contributors/oktofeesh1/success-patterns", + "/v1/contributors/oktofeesh1/failure-patterns", + "/v1/contributors/oktofeesh1/repos/entrius/allways-ui/role-context", + "/v1/contributors/oktofeesh1/repos/entrius/allways-ui/recommendation", + "/v1/contributors/oktofeesh1/repos/entrius/allways-ui/reward-risk", + ]) { + const legacy = await app.request(path, { headers: apiHeaders(env) }, env); + expect(legacy.status).toBe(404); + } + + const localDiff = await app.request( + "/v1/preflight/local-diff", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ + repoFullName: "entrius/allways-ui", + title: "Fix dashboard cache refresh after reconnect", + commitMessage: "Fixes #7", + changedFiles: ["src/cache.ts", "test/cache.test.ts"], + changedLineCount: 42, + }), + }, + env, + ); + expect(localDiff.status).toBe(200); + await expect(localDiff.json()).resolves.toMatchObject({ localDiff: { testFileCount: 1, inferredLinkedIssues: [7] } }); + + const invalidPreflight = await app.request("/v1/preflight/pr", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({}) }, env); + expect(invalidPreflight.status).toBe(400); + + const invalidLocalDiff = await app.request("/v1/preflight/local-diff", { method: "POST", headers: apiHeaders(env), body: JSON.stringify({}) }, env); + expect(invalidLocalDiff.status).toBe(400); + + const localBranchAnalysis = await app.request( + "/v1/local/branch-analysis", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + baseRef: "origin/test", + headRef: "fix-cache", + branchName: "fix-cache-reconnect", + title: "Fix dashboard cache refresh after reconnect", + body: "Fixes #7", + labels: ["bug"], + changedFiles: [ + { path: "src/cache.ts", additions: 42, deletions: 4, status: "modified" }, + { path: "test/cache.test.ts", additions: 20, deletions: 0, status: "added" }, + ], + validation: [{ command: "npm test -- cache", status: "passed", summary: "cache regression passed" }], + localScorer: { mode: "external_command", sourceTokenScore: 42, totalTokenScore: 66, sourceLines: 44, testTokenScore: 20 }, + }), + }, + env, + ); + expect(localBranchAnalysis.status).toBe(200); + const localBranchPayload = (await localBranchAnalysis.json()) as { + prPacket: unknown; }; - expect(opportunityPayload.profile.github.topLanguages).toEqual(["TypeScript", "Python"]); - expect(opportunityPayload.opportunities[0]).toMatchObject({ repoFullName: "entrius/allways-ui" }); + expect(localBranchPayload).toMatchObject({ + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + preflight: { localDiff: { testFileCount: 1, inferredLinkedIssues: [7] } }, + scorePreview: { privateOnly: true }, + rewardRisk: { rewardUpside: { relevantLane: "direct_pr" } }, + prPacket: { titleSuggestion: "Fix dashboard cache refresh after reconnect" }, + }); + expect(JSON.stringify(localBranchPayload.prPacket)).not.toMatch(/reward|score|wallet|hotkey|farming|payout|ranking|trust score/i); + + const localBranchWithMcpToken = await app.request( + "/v1/local/branch-analysis", + { + method: "POST", + headers: { authorization: `Bearer ${env.GITTENSORY_MCP_TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify({ + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + branchName: "fix-cache-reconnect", + changedFiles: [{ path: "src/cache.ts", additions: 1, deletions: 0 }], + }), + }, + env, + ); + expect(localBranchWithMcpToken.status).toBe(200); + + const sourceContentRejected = await app.request( + "/v1/local/branch-analysis", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + changedFiles: [{ path: "src/cache.ts", additions: 1, deletions: 0, content: "source should not be accepted" }], + }), + }, + env, + ); + expect(sourceContentRejected.status).toBe(400); const imported = await app.request( "/v1/internal/bounties/import", @@ -179,12 +466,311 @@ describe("api routes", () => { const bountyAdvisory = await app.request("/v1/bounties/bounty-1/advisory", { headers: apiHeaders(env) }, env); expect(bountyAdvisory.status).toBe(200); await expect(bountyAdvisory.json()).resolves.toMatchObject({ lifecycle: "historical", fundingStatus: "target_only" }); + + const missingBountyAdvisory = await app.request("/v1/bounties/missing/advisory", { headers: apiHeaders(env) }, env); + expect(missingBountyAdvisory.status).toBe(404); + + const syncStatus = await app.request("/v1/sync/status", { headers: apiHeaders(env) }, env); + expect(syncStatus.status).toBe(200); + await expect(syncStatus.json()).resolves.toMatchObject({ repositories: expect.any(Array), installations: expect.any(Array) }); + + const readiness = await app.request("/v1/readiness", { headers: apiHeaders(env) }, env); + expect(readiness.status).toBe(200); + await expect(readiness.json()).resolves.toMatchObject({ status: expect.any(String), secrets: { githubPublicToken: false } }); + + const installations = await app.request("/v1/installations", { headers: apiHeaders(env) }, env); + expect(installations.status).toBe(200); + await expect(installations.json()).resolves.toMatchObject({ health: expect.arrayContaining([expect.objectContaining({ status: "healthy" })]) }); + + const installationHealth = await app.request("/v1/installations/123/health", { headers: apiHeaders(env) }, env); + expect(installationHealth.status).toBe(200); + await expect(installationHealth.json()).resolves.toMatchObject({ installationId: 123 }); + + const invalidInstallationHealth = await app.request("/v1/installations/not-a-number/health", { headers: apiHeaders(env) }, env); + expect(invalidInstallationHealth.status).toBe(400); + + const missingInstallationHealth = await app.request("/v1/installations/999/health", { headers: apiHeaders(env) }, env); + expect(missingInstallationHealth.status).toBe(404); + + const missingRepo = await app.request("/v1/repos/missing/repo", { headers: apiHeaders(env) }, env); + expect(missingRepo.status).toBe(404); + + const registryChanges = await app.request("/v1/registry/changes", { headers: apiHeaders(env) }, env); + expect(registryChanges.status).toBe(200); + await expect(registryChanges.json()).resolves.toMatchObject({ addedRepos: expect.any(Array), summary: expect.any(String) }); + + const scoringModel = await app.request("/v1/scoring/model", { headers: apiHeaders(env) }, env); + expect(scoringModel.status).toBe(200); + await expect(scoringModel.json()).resolves.toMatchObject({ activeModel: "current_density_model", id: "scoring-1" }); + + const scorePreview = await app.request( + "/v1/scoring/preview", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ + repoFullName: "entrius/allways-ui", + targetKey: "planned-fixture", + contributorLogin: "oktofeesh1", + labels: ["bug"], + linkedIssueMode: "standard", + sourceTokenScore: 42, + totalTokenScore: 60, + sourceLines: 40, + openPrCount: 1, + }), + }, + env, + ); + expect(scorePreview.status).toBe(200); + await expect(scorePreview.json()).resolves.toMatchObject({ + repoFullName: "entrius/allways-ui", + targetType: "planned_pr", + result: { privateOnly: true, scoringModelSnapshotId: "scoring-1" }, + }); + const noContributorScorePreview = await app.request( + "/v1/scoring/preview", + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify({ repoFullName: "entrius/allways-ui", targetKey: "no-contributor", sourceTokenScore: 3 }), + }, + env, + ); + expect(noContributorScorePreview.status).toBe(200); + + for (const [signalType, payload] of [ + ["queue-health", { repoFullName: "entrius/allways-ui", signals: { openPullRequests: 2 } }], + ["config-quality", { repoFullName: "entrius/allways-ui", notObservedConfiguredLabels: ["refactor"] }], + ["label-audit", { repoFullName: "entrius/allways-ui", missingConfiguredLabels: ["refactor"] }], + ["maintainer-lane", { repoFullName: "entrius/allways-ui" }], + ["maintainer-cut-readiness", { repoFullName: "entrius/allways-ui" }], + ["contributor-intake-health", { repoFullName: "entrius/allways-ui" }], + ] as const) { + await persistSignalSnapshot(env, { + id: `snapshot-${signalType}`, + signalType, + targetKey: "entrius/allways-ui", + repoFullName: "entrius/allways-ui", + payload: payload as unknown as Record, + generatedAt: "2026-05-25T00:00:00.000Z", + }); + } + const snapshotIntelligence = await app.request("/v1/repos/entrius/allways-ui/intelligence", { headers: apiHeaders(env) }, env); + expect(snapshotIntelligence.status).toBe(200); + await expect(snapshotIntelligence.json()).resolves.toMatchObject({ source: "snapshot", queueHealth: { signals: { openPullRequests: 2 } } }); + + for (const path of [ + "/v1/repos/entrius/allways-ui/issue-quality", + "/v1/repos/entrius/allways-ui/burden-forecast", + "/v1/repos/entrius/allways-ui/pulls/12/scoring-preview", + "/v1/contributors/oktofeesh1/scoring-profile", + "/v1/contributors/oktofeesh1/strategy", + "/v1/contributors/oktofeesh1/reward-risk-strategy", + "/v1/contributors/oktofeesh1/actions/recommendations", + ]) { + const legacy = await app.request(path, { headers: apiHeaders(env) }, env); + expect(legacy.status).toBe(404); + } + }); + + it("reports ready status when required public-review dependencies are present", async () => { + const app = createApp(); + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedSignalData(env); + + const readiness = await app.request("/v1/readiness", { headers: apiHeaders(env) }, env); + expect(readiness.status).toBe(200); + await expect(readiness.json()).resolves.toMatchObject({ + status: "ready", + readyForPublicReview: true, + secrets: { githubPublicToken: true }, + githubBackfill: { failingSyncs: [] }, + warnings: [], + }); + + const failingEnv = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedSignalData(failingEnv); + await upsertRepoSyncState(failingEnv, { + repoFullName: "entrius/allways-ui", + status: "error", + sourceKind: "github", + primaryLanguage: "TypeScript", + defaultBranch: "main", + isPrivate: false, + openIssuesCount: 0, + openPullRequestsCount: 0, + recentMergedPullRequestsCount: 0, + lastCompletedAt: "2026-05-23T00:00:00.000Z", + errorSummary: "rate limited", + warnings: [], + }); + const failingReadiness = await app.request("/v1/readiness", { headers: apiHeaders(failingEnv) }, failingEnv); + expect(failingReadiness.status).toBe(200); + await expect(failingReadiness.json()).resolves.toMatchObject({ + status: "ready", + ready: true, + readyForPublicReview: false, + signalFidelity: { status: "blocked" }, + githubBackfill: { failingSyncs: [expect.objectContaining({ errorSummary: "rate limited" })] }, + warnings: expect.arrayContaining([expect.stringContaining("repo sync error"), expect.stringContaining("Core open-data fidelity")]), + }); + + const skippedEnv = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedSignalData(skippedEnv); + await upsertRepoSyncState(skippedEnv, { + repoFullName: "entrius/allways-ui", + status: "skipped", + sourceKind: "github", + primaryLanguage: "TypeScript", + defaultBranch: "main", + isPrivate: false, + openIssuesCount: 0, + openPullRequestsCount: 0, + recentMergedPullRequestsCount: 0, + lastCompletedAt: "2026-05-23T00:00:00.000Z", + warnings: ["missing token"], + }); + const skippedReadiness = await app.request("/v1/readiness", { headers: apiHeaders(skippedEnv) }, skippedEnv); + expect(skippedReadiness.status).toBe(200); + await expect(skippedReadiness.json()).resolves.toMatchObject({ + status: "ready", + ready: true, + readyForPublicReview: false, + signalFidelity: { status: "blocked" }, + githubBackfill: { incompleteSyncs: [expect.objectContaining({ status: "skipped" })] }, + warnings: expect.arrayContaining([expect.stringContaining("incomplete or skipped"), expect.stringContaining("Core open-data fidelity")]), + }); + + const missingSnapshotEnv = createTestEnv(); + const missingSnapshotReadiness = await app.request("/v1/readiness", { headers: apiHeaders(missingSnapshotEnv) }, missingSnapshotEnv); + expect(missingSnapshotReadiness.status).toBe(200); + await expect(missingSnapshotReadiness.json()).resolves.toMatchObject({ + readyForPublicReview: false, + warnings: expect.arrayContaining([ + "Registry snapshot is missing.", + "Scoring model snapshot is missing. Run refresh-scoring-model before public review.", + "GITHUB_PUBLIC_TOKEN is not configured; public registered-repo backfill may hit GitHub rate limits.", + ]), + }); + + const missingSyncEnv = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await persistRegistrySnapshot( + missingSyncEnv, + normalizeRegistryPayload( + { "entrius/allways-ui": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false } }, + { kind: "raw-github", url: "fixture://registry" }, + "2026-05-25T00:00:00.000Z", + ), + ); + const missingSyncReadiness = await app.request("/v1/readiness", { headers: apiHeaders(missingSyncEnv) }, missingSyncEnv); + expect(missingSyncReadiness.status).toBe(200); + await expect(missingSyncReadiness.json()).resolves.toMatchObject({ + readyForPublicReview: false, + warnings: expect.arrayContaining([expect.stringContaining("registered repo(s) do not have GitHub backfill state yet")]), + }); + }); + + it("exposes capped and rate-limited sync segments in readiness and sync status", async () => { + const app = createApp(); + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedSignalData(env); + await upsertRepoSyncSegment(env, { + repoFullName: "entrius/allways-ui", + segment: "open_pull_requests", + status: "capped", + sourceKind: "github", + mode: "full", + fetchedCount: 100, + pageCount: 1, + nextCursor: "2", + completedAt: "2026-05-23T00:00:00.000Z", + warnings: ["local cap"], + }); + await upsertRepoSyncSegment(env, { + repoFullName: "entrius/allways-ui", + segment: "open_issues", + status: "rate_limited", + sourceKind: "github", + mode: "full", + fetchedCount: 0, + pageCount: 0, + rateLimitResetAt: "2026-05-27T00:00:00.000Z", + completedAt: "2026-05-23T00:00:00.000Z", + warnings: ["secondary rate limit"], + }); + + const readiness = await app.request("/v1/readiness", { headers: apiHeaders(env) }, env); + expect(readiness.status).toBe(200); + await expect(readiness.json()).resolves.toMatchObject({ + status: "ready", + ready: true, + readyForPublicReview: false, + signalFidelity: { + status: "blocked", + cappedRepos: ["entrius/allways-ui"], + rateLimitedRepos: ["entrius/allways-ui"], + nextRecoverableAt: "2026-05-27T00:00:00.000Z", + }, + cappedRepos: ["entrius/allways-ui"], + rateLimitedRepos: ["entrius/allways-ui"], + nextRecoverableAt: "2026-05-27T00:00:00.000Z", + githubBackfill: { + cappedSegments: [expect.objectContaining({ repoFullName: "entrius/allways-ui", segment: "open_pull_requests", nextCursor: "2" })], + rateLimitedSegments: [expect.objectContaining({ repoFullName: "entrius/allways-ui", segment: "open_issues", rateLimitResetAt: "2026-05-27T00:00:00.000Z" })], + }, + }); + + const syncStatus = await app.request("/v1/sync/status", { headers: apiHeaders(env) }, env); + expect(syncStatus.status).toBe(200); + await expect(syncStatus.json()).resolves.toMatchObject({ + signalFidelity: { status: "blocked" }, + segments: expect.arrayContaining([ + expect.objectContaining({ repoFullName: "entrius/allways-ui", segment: "open_pull_requests", status: "capped" }), + expect.objectContaining({ repoFullName: "entrius/allways-ui", segment: "open_issues", status: "rate_limited" }), + ]), + }); }); it("serves private MCP tool listing and tool calls", async () => { const app = createApp(); const env = createTestEnv(); await seedSignalData(env); + stubOktofeeshFetch(); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "owner/removed": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + "owner/stable": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + "entrius/allways-ui": { emission_share: 0.01107, issue_discovery_share: 0, label_multipliers: { bug: 1.1 }, trusted_label_pipeline: true }, + }, + { kind: "raw-github", url: "fixture://mcp-old-registry" }, + "2026-05-24T00:00:00.000Z", + ), + ); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "owner/added": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + "owner/stable": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + "entrius/allways-ui": { emission_share: 0.01107, issue_discovery_share: 0, label_multipliers: { bug: 1.1 }, trusted_label_pipeline: true }, + }, + { kind: "raw-github", url: "fixture://mcp-current-registry" }, + "2026-05-25T00:00:00.000Z", + ), + ); + const decisionBuild = await app.request( + "/v1/internal/jobs/build-contributor-decision-packs/run", + { + method: "POST", + headers: { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}`, "content-type": "application/json" }, + body: JSON.stringify({ login: "oktofeesh1" }), + }, + env, + ); + expect(decisionBuild.status).toBe(200); const unauthorized = await app.request( "/mcp", @@ -229,7 +815,39 @@ describe("api routes", () => { ); expect(toolsList.status).toBe(200); const toolsPayload = (await mcpJson(toolsList)) as { result: { tools: Array<{ name: string }> } }; - expect(toolsPayload.result.tools.map((tool) => tool.name)).toContain("gittensory_preflight_pr"); + const toolNames = toolsPayload.result.tools.map((tool) => tool.name); + expect(toolNames).toContain("gittensory_get_repo_context"); + expect(toolNames).toContain("gittensory_get_contributor_profile"); + expect(toolNames).toContain("gittensory_get_decision_pack"); + expect(toolNames).toContain("gittensory_explain_repo_decision"); + expect(toolNames).toContain("gittensory_preflight_pr"); + expect(toolNames).toContain("gittensory_preflight_local_diff"); + expect(toolNames).toContain("gittensory_preview_local_pr_score"); + expect(toolNames).toContain("gittensory_get_registry_changes"); + expect(toolNames).toContain("gittensory_explain_review_risk"); + expect(toolNames).toContain("gittensory_compare_pr_variants"); + expect(toolNames).toContain("gittensory_local_status"); + expect(toolNames).toContain("gittensory_preflight_current_branch"); + expect(toolNames).toContain("gittensory_preview_current_branch_score"); + expect(toolNames).toContain("gittensory_rank_local_next_actions"); + expect(toolNames).toContain("gittensory_compare_local_variants"); + expect(toolNames).toContain("gittensory_explain_local_blockers"); + expect(toolNames).toContain("gittensory_prepare_pr_packet"); + for (const removed of [ + "gittensory_get_contributor_fit", + "gittensory_find_opportunities", + "gittensory_get_contribution_strategy", + "gittensory_explain_reward_risk", + "gittensory_rank_next_actions", + "gittensory_explain_score_blockers", + "gittensory_explain_maintainer_noise", + "gittensory_get_role_context", + "gittensory_get_outcome_history", + "gittensory_explain_repo_fit", + "gittensory_explain_maintainer_lane", + ]) { + expect(toolNames).not.toContain(removed); + } const call = await app.request( "/mcp", @@ -241,7 +859,7 @@ describe("api routes", () => { id: 3, method: "tools/call", params: { - name: "gittensory_get_queue_health", + name: "gittensory_get_repo_context", arguments: { owner: "entrius", repo: "allways-ui" }, }, }), @@ -252,7 +870,357 @@ describe("api routes", () => { const callPayload = (await mcpJson(call)) as { result: { structuredContent: { repoFullName: string }; content: Array<{ text: string }> } }; expect(callPayload.result.structuredContent.repoFullName).toBe("entrius/allways-ui"); expect(callPayload.result.content[0]?.text).not.toMatch(/reward|farming/i); - }); + + const noTotalsContext = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(env), + body: JSON.stringify({ + jsonrpc: "2.0", + id: "repo-context-no-totals", + method: "tools/call", + params: { + name: "gittensory_get_repo_context", + arguments: { owner: "owner", repo: "stable" }, + }, + }), + }, + env, + ); + expect(noTotalsContext.status).toBe(200); + const noTotalsPayload = (await mcpJson(noTotalsContext)) as { result: { structuredContent: { queueHealth: { signals: { openIssues: number; openPullRequests: number } } } } }; + expect(noTotalsPayload.result.structuredContent.queueHealth.signals).toMatchObject({ openIssues: 0, openPullRequests: 0 }); + + for (const [name, args] of [ + ["gittensory_get_decision_pack", { login: "needs-snapshot" }], + ["gittensory_explain_repo_decision", { login: "needs-snapshot", owner: "entrius", repo: "allways-ui" }], + ["gittensory_get_contributor_profile", { login: "unknown-user" }], + ] as const) { + const response = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(env), + body: JSON.stringify({ jsonrpc: "2.0", id: `refresh-${name}`, method: "tools/call", params: { name, arguments: args } }), + }, + env, + ); + expect(response.status).toBe(200); + const payload = (await mcpJson(response)) as { result: { structuredContent: Record } }; + if (name === "gittensory_get_contributor_profile") expect(payload.result.structuredContent).toMatchObject({ login: "unknown-user" }); + else expect(payload.result.structuredContent).toMatchObject({ status: "needs_snapshot_refresh", enqueued: true }); + } + + const missingRepoDecision = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(env), + body: JSON.stringify({ + jsonrpc: "2.0", + id: "missing-repo-decision", + method: "tools/call", + params: { name: "gittensory_explain_repo_decision", arguments: { login: "oktofeesh1", owner: "missing", repo: "repo" } }, + }), + }, + env, + ); + expect(missingRepoDecision.status).toBe(200); + await expect(mcpJson(missingRepoDecision)).resolves.toMatchObject({ result: { structuredContent: { status: "not_found", decision: null } } }); + + for (const [name, args] of [ + ["gittensory_get_repo_context", { owner: "entrius", repo: "allways-ui" }], + ["gittensory_get_contributor_profile", { login: "oktofeesh1" }], + ["gittensory_get_decision_pack", { login: "oktofeesh1" }], + ["gittensory_explain_repo_decision", { login: "oktofeesh1", owner: "entrius", repo: "allways-ui" }], + [ + "gittensory_preflight_pr", + { + repoFullName: "entrius/allways-ui", + title: "Fix dashboard cache refresh after reconnect", + body: "Fixes #7", + changedFiles: ["src/cache.ts", "test/cache.test.ts"], + }, + ], + ["gittensory_get_registry_changes", {}], + [ + "gittensory_preview_local_pr_score", + { + repoFullName: "entrius/allways-ui", + targetKey: "mcp-local-fixture", + contributorLogin: "oktofeesh1", + labels: ["bug"], + linkedIssueMode: "standard", + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 42, + }, + ], + [ + "gittensory_explain_review_risk", + { + repoFullName: "entrius/allways-ui", + contributorLogin: "oktofeesh1", + title: "Fix dashboard cache refresh after reconnect", + body: "Fixes #7", + changedFiles: ["src/cache.ts"], + }, + ], + [ + "gittensory_compare_pr_variants", + { + variants: [ + { repoFullName: "entrius/allways-ui", targetKey: "small", sourceTokenScore: 10, totalTokenScore: 12, sourceLines: 10 }, + { repoFullName: "entrius/allways-ui", targetKey: "larger", sourceTokenScore: 40, totalTokenScore: 60, sourceLines: 42, labels: ["bug"] }, + ], + }, + ], + [ + "gittensory_preflight_local_diff", + { + repoFullName: "entrius/allways-ui", + title: "Fix dashboard cache refresh after reconnect", + changedFiles: ["src/cache.ts", "test/cache.test.ts"], + changedLineCount: 42, + }, + ], + ["gittensory_local_status", {}], + [ + "gittensory_preflight_current_branch", + { + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + branchName: "fix-cache-reconnect", + title: "Fix dashboard cache refresh after reconnect", + body: "Fixes #7", + labels: ["bug"], + changedFiles: [ + { path: "src/cache.ts", additions: 42, deletions: 4, status: "modified" }, + { path: "test/cache.test.ts", additions: 20, deletions: 0, status: "added" }, + ], + validation: [{ command: "npm test -- cache", status: "passed" }], + localScorer: { mode: "external_command", sourceTokenScore: 42, totalTokenScore: 66, sourceLines: 44 }, + }, + ], + [ + "gittensory_preview_current_branch_score", + { + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + branchName: "fix-cache-reconnect", + changedFiles: [{ path: "src/cache.ts", additions: 42, deletions: 4, status: "modified" }], + }, + ], + [ + "gittensory_rank_local_next_actions", + { + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + branchName: "fix-cache-reconnect", + changedFiles: [{ path: "src/cache.ts", additions: 42, deletions: 4, status: "modified" }], + }, + ], + [ + "gittensory_explain_local_blockers", + { + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + branchName: "fix-cache-reconnect", + changedFiles: [{ path: "src/cache.ts", additions: 42, deletions: 4, status: "modified" }], + }, + ], + [ + "gittensory_prepare_pr_packet", + { + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + branchName: "fix-cache-reconnect", + body: "Fixes #7", + changedFiles: [ + { path: "src/cache.ts", additions: 42, deletions: 4, status: "modified" }, + { path: "test/cache.test.ts", additions: 20, deletions: 0, status: "added" }, + ], + }, + ], + [ + "gittensory_compare_local_variants", + { + variants: [ + { + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + branchName: "small-cache-fix", + changedFiles: [{ path: "src/cache.ts", additions: 8, deletions: 1, status: "modified" }], + }, + { + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + branchName: "tested-cache-fix", + changedFiles: [ + { path: "src/cache.ts", additions: 42, deletions: 4, status: "modified" }, + { path: "test/cache.test.ts", additions: 20, deletions: 0, status: "added" }, + ], + }, + ], + }, + ], + ["gittensory_get_bounty_advisory", { id: "bounty-1" }], + ] as const) { + const response = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(env), + body: JSON.stringify({ jsonrpc: "2.0", id: `tool-${name}`, method: "tools/call", params: { name, arguments: args } }), + }, + env, + ); + expect(response.status).toBe(200); + const payload = (await mcpJson(response)) as { result?: { content?: Array<{ text: string }> } }; + const text = payload.result?.content?.[0]?.text ?? ""; + const privateRewardTools = new Set([ + "gittensory_get_decision_pack", + "gittensory_explain_repo_decision", + "gittensory_preview_local_pr_score", + "gittensory_compare_pr_variants", + "gittensory_preview_current_branch_score", + "gittensory_rank_local_next_actions", + "gittensory_explain_local_blockers", + "gittensory_compare_local_variants", + ]); + expect(text).not.toMatch(/farming|wallet|hotkey|guaranteed payout/i); + if (!privateRewardTools.has(name)) expect(text).not.toMatch(/reward/i); + } + + for (const [args, recommendation] of [ + [ + { + repoFullName: "entrius/allways-ui", + title: "Fix dashboard cache refresh after reconnect", + body: "Fixes #7", + changedFiles: ["src/cache.ts"], + }, + "likely_duplicate", + ], + [ + { + repoFullName: "entrius/allways-ui", + contributorLogin: "entrius", + title: "Maintainer config cleanup", + body: "Maintenance follow-up", + changedFiles: ["README.md"], + }, + "maintainer_lane", + ], + [ + { + repoFullName: "entrius/allways-ui", + contributorLogin: "oktofeesh1", + title: "Focused parser guard without validation evidence", + body: "Fixes #999", + changedFiles: ["src/parser.ts"], + }, + "needs_author", + ], + [ + { + repoFullName: "entrius/allways-ui", + contributorLogin: "oktofeesh1", + title: "Documentation note for isolated setup", + body: "Fixes #999", + changedFiles: ["docs/setup.md"], + }, + "review", + ], + [ + { + repoFullName: "missing/repo", + title: "Unknown repo preflight", + body: "Fixes #999", + changedFiles: ["docs/setup.md"], + }, + "watch", + ], + ] as const) { + const response = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(env), + body: JSON.stringify({ jsonrpc: "2.0", id: `review-risk-${args.title}`, method: "tools/call", params: { name: "gittensory_explain_review_risk", arguments: args } }), + }, + env, + ); + expect(response.status).toBe(200); + const payload = (await mcpJson(response)) as { result: { structuredContent: { recommendation: string } } }; + expect(payload.result.structuredContent.recommendation).toBe(recommendation); + } + + const sparseVariantComparison = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(env), + body: JSON.stringify({ + jsonrpc: "2.0", + id: "sparse-variant-comparison", + method: "tools/call", + params: { + name: "gittensory_compare_pr_variants", + arguments: { + variants: [ + { repoFullName: "entrius/allways-ui", targetKey: "metadata-only" }, + { repoFullName: "entrius/allways-ui", targetKey: "label-only", labels: ["feature"] }, + ], + }, + }, + }), + }, + env, + ); + expect(sparseVariantComparison.status).toBe(200); + + const tiedLocalVariantComparison = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(env), + body: JSON.stringify({ + jsonrpc: "2.0", + id: "tied-local-variant-comparison", + method: "tools/call", + params: { + name: "gittensory_compare_local_variants", + arguments: { + variants: [ + { login: "oktofeesh1", repoFullName: "missing/b", branchName: "same", changedFiles: [] }, + { login: "oktofeesh1", repoFullName: "missing/a", branchName: "same", changedFiles: [] }, + ], + }, + }, + }), + }, + env, + ); + expect(tiedLocalVariantComparison.status).toBe(200); + await expect(mcpJson(tiedLocalVariantComparison)).resolves.toMatchObject({ + result: { structuredContent: { variants: [expect.objectContaining({ repoFullName: "missing/a" }), expect.objectContaining({ repoFullName: "missing/b" })] } }, + }); + + const missingBounty = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(env), + body: JSON.stringify({ jsonrpc: "2.0", id: "missing-bounty", method: "tools/call", params: { name: "gittensory_get_bounty_advisory", arguments: { id: "missing" } } }), + }, + env, + ); + expect(missingBounty.status).toBe(200); + const missingBountyPayload = await mcpJson(missingBounty); + expect(JSON.stringify(missingBountyPayload)).toMatch(/Bounty not found|error|isError/i); + }, 15_000); it("updates repository settings through protected internal API", async () => { const app = createApp(); @@ -310,6 +1278,63 @@ function apiHeaders(env: Env): Record { }; } +function stubOktofeeshFetch(): void { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") { + return Response.json([ + { + uid: 7, + hotkey: "hotkey", + githubUsername: "oktofeesh1", + githubId: "12345", + totalPrs: 2, + totalMergedPrs: 1, + totalOpenPrs: 1, + totalClosedPrs: 0, + totalOpenIssues: 1, + totalClosedIssues: 0, + totalSolvedIssues: 0, + totalValidSolvedIssues: 0, + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + }, + ]); + } + if (url === "https://api.gittensor.io/miners/12345") { + return Response.json({ + repositories: [ + { + repositoryFullName: "entrius/allways-ui", + totalPrs: "2", + totalMergedPrs: "1", + totalOpenPrs: "1", + totalClosedPrs: "0", + totalOpenIssues: "1", + totalClosedIssues: "0", + isEligible: true, + credibility: "1.000000", + }, + ], + }); + } + if (url === "https://api.gittensor.io/miners/12345/prs") { + return Response.json([{ repository: "entrius/allways-ui", pullRequestNumber: 12, pullRequestTitle: "Fix dashboard cache", prState: "OPEN", label: "bug" }]); + } + if (url === "https://mirror.gittensor.io/api/v1/miners/12345/issues") { + return Response.json({ issues: [{ labels: [{ name: "bug" }] }] }); + } + if (url.endsWith("/users/oktofeesh1")) { + return Response.json({ login: "oktofeesh1", public_repos: 42, followers: 7 }); + } + if (url.includes("/users/oktofeesh1/repos")) { + return Response.json([{ language: "TypeScript" }, { language: "Python" }, { language: "TypeScript" }]); + } + return new Response("not found", { status: 404 }); + }); +} + async function mcpJson(response: Response): Promise { const text = await response.text(); if (response.headers.get("content-type")?.includes("application/json")) return JSON.parse(text); @@ -322,6 +1347,15 @@ async function mcpJson(response: Response): Promise { } async function seedSignalData(env: Env): Promise { + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "entrius", id: 1, type: "Organization" }, + repository_selection: "selected", + permissions: { checks: "write", metadata: "read", pull_requests: "read" }, + events: ["issues", "pull_request", "repository"], + }, + }); const snapshot = normalizeRegistryPayload( { "entrius/allways-ui": { @@ -335,7 +1369,138 @@ async function seedSignalData(env: Env): Promise { { kind: "raw-github", url: "https://example.test/master_repositories.json" }, "2026-05-23T00:00:00.000Z", ); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "entrius/allways-ui": { + emission_share: 0.005, + issue_discovery_share: 0, + label_multipliers: {}, + trusted_label_pipeline: true, + maintainer_cut: 0, + }, + }, + { kind: "raw-github", url: "https://example.test/old_master_repositories.json" }, + "2026-05-22T00:00:00.000Z", + ), + ); await persistRegistrySnapshot(env, snapshot); + await upsertRepositoryFromGitHub(env, { + name: "allways-ui", + full_name: "entrius/allways-ui", + private: false, + default_branch: "test", + owner: { login: "entrius" }, + }); + await persistScoringModelSnapshot(env, { + id: "scoring-1", + sourceKind: "test", + sourceUrl: "fixture://scoring", + fetchedAt: "2026-05-23T00:00:00.000Z", + activeModel: "current_density_model", + constants: { + OSS_EMISSION_SHARE: 0.9, + MERGED_PR_BASE_SCORE: 25, + MIN_TOKEN_SCORE_FOR_BASE_SCORE: 5, + MAX_CODE_DENSITY_MULTIPLIER: 1.15, + MAX_CONTRIBUTION_BONUS: 25, + CONTRIBUTION_SCORE_FOR_FULL_BONUS: 1500, + STANDARD_ISSUE_MULTIPLIER: 1.33, + MAINTAINER_ISSUE_MULTIPLIER: 1.66, + MIN_CREDIBILITY: 0.8, + REVIEW_PENALTY_RATE: 0.15, + EXCESSIVE_PR_PENALTY_BASE_THRESHOLD: 2, + OPEN_PR_THRESHOLD_TOKEN_SCORE: 300, + MAX_OPEN_PR_THRESHOLD: 30, + OPEN_PR_COLLATERAL_PERCENT: 0.2, + SRC_TOK_SATURATION_SCALE: 58, + }, + programmingLanguages: { TypeScript: 1 }, + registrySnapshotId: snapshot.id, + warnings: [], + payload: {}, + }); + await upsertRepoSyncState(env, { + repoFullName: "entrius/allways-ui", + status: "success", + sourceKind: "github", + primaryLanguage: "TypeScript", + defaultBranch: "main", + isPrivate: false, + openIssuesCount: 2, + openPullRequestsCount: 2, + recentMergedPullRequestsCount: 1, + warnings: [], + }); + await persistRepoGithubTotalsSnapshot(env, { + id: "totals-entrius-allways-ui", + repoFullName: "entrius/allways-ui", + openIssuesTotal: 2, + openPullRequestsTotal: 2, + mergedPullRequestsTotal: 1, + closedUnmergedPullRequestsTotal: 0, + labelsTotal: 2, + sourceKind: "github", + fetchedAt: "2026-05-23T00:00:00.000Z", + payload: {}, + }); + await Promise.all( + [ + { segment: "metadata", fetchedCount: 1, expectedCount: 1 }, + { segment: "labels", fetchedCount: 2, expectedCount: 2 }, + { segment: "open_issues", fetchedCount: 2, expectedCount: 2 }, + { segment: "open_pull_requests", fetchedCount: 2, expectedCount: 2 }, + { segment: "pull_request_files", fetchedCount: 2, expectedCount: 2 }, + { segment: "pull_request_reviews", fetchedCount: 2, expectedCount: 2 }, + { segment: "check_summaries", fetchedCount: 2, expectedCount: 2 }, + { segment: "recent_merged_pull_requests", fetchedCount: 1, expectedCount: 1 }, + ].map((record) => + upsertRepoSyncSegment(env, { + repoFullName: "entrius/allways-ui", + segment: record.segment as never, + status: "complete", + sourceKind: "github", + mode: "full", + fetchedCount: record.fetchedCount, + expectedCount: record.expectedCount, + pageCount: 1, + completedAt: "2026-05-23T00:00:00.000Z", + warnings: [], + }), + ), + ); + await upsertRepoLabel(env, { + repoFullName: "entrius/allways-ui", + name: "bug", + color: "cc0000", + description: "Bug", + isConfigured: true, + observedCount: 3, + payload: {}, + }); + await upsertRepoLabel(env, { + repoFullName: "entrius/allways-ui", + name: "feature", + color: "00cc00", + description: "Feature", + isConfigured: true, + observedCount: 1, + payload: {}, + }); + await upsertInstallationHealth(env, { + installationId: 123, + accountLogin: "entrius", + repositorySelection: "selected", + installedReposCount: 1, + registeredInstalledCount: 1, + status: "healthy", + missingPermissions: [], + missingEvents: [], + permissions: { checks: "write", metadata: "read", pull_requests: "read" }, + events: ["issues", "pull_request", "repository"], + checkedAt: "2026-05-23T00:00:00.000Z", + }); await upsertIssueFromGitHub(env, "entrius/allways-ui", { number: 7, title: "Dashboard cache refresh fails after reconnect", @@ -366,6 +1531,42 @@ async function seedSignalData(env: Env): Promise { labels: [{ name: "bug" }], body: "Fixes #7", }); + await upsertPullRequestDetailSyncState(env, { + repoFullName: "entrius/allways-ui", + pullNumber: 12, + status: "complete", + filesSyncedAt: "2026-05-23T00:00:00.000Z", + reviewsSyncedAt: "2026-05-23T00:00:00.000Z", + checksSyncedAt: "2026-05-23T00:00:00.000Z", + lastSyncedAt: "2026-05-23T00:00:00.000Z", + }); + await upsertPullRequestFile(env, { + repoFullName: "entrius/allways-ui", + pullNumber: 12, + path: "src/cache.ts", + additions: 20, + deletions: 2, + changes: 22, + payload: {}, + }); + await upsertPullRequestReview(env, { + id: "entrius/allways-ui#12#1", + repoFullName: "entrius/allways-ui", + pullNumber: 12, + reviewerLogin: "maintainer", + state: "APPROVED", + payload: {}, + }); + await upsertCheckSummary(env, { + id: "entrius/allways-ui#abc123#test", + repoFullName: "entrius/allways-ui", + pullNumber: 12, + headSha: "abc123", + name: "test", + status: "completed", + conclusion: "success", + payload: {}, + }); await upsertPullRequestFromGitHub(env, "entrius/allways-ui", { number: 13, title: "Alternative cache reconnect fix", @@ -378,6 +1579,26 @@ async function seedSignalData(env: Env): Promise { labels: [{ name: "bug" }], body: "Fixes #7", }); + await upsertPullRequestDetailSyncState(env, { + repoFullName: "entrius/allways-ui", + pullNumber: 13, + status: "complete", + filesSyncedAt: "2026-05-23T00:00:00.000Z", + reviewsSyncedAt: "2026-05-23T00:00:00.000Z", + checksSyncedAt: "2026-05-23T00:00:00.000Z", + lastSyncedAt: "2026-05-23T00:00:00.000Z", + }); + await upsertRecentMergedPullRequest(env, { + repoFullName: "entrius/allways-ui", + number: 3, + title: "Fix dashboard cache refresh after reconnect", + authorLogin: "oktofeesh1", + mergedAt: "2026-05-01T00:00:00.000Z", + labels: ["bug"], + linkedIssues: [7], + changedFiles: ["src/cache.ts"], + payload: {}, + }); await upsertBounty(env, { id: "bounty-1", repoFullName: "entrius/allways-ui", diff --git a/test/integration/routes-errors.test.ts b/test/integration/routes-errors.test.ts new file mode 100644 index 0000000000..e1a810bb9f --- /dev/null +++ b/test/integration/routes-errors.test.ts @@ -0,0 +1,547 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { handleMcpRequest } from "../../src/mcp/server"; +import { normalizeRegistryPayload } from "../../src/registry/normalize"; +import { persistRegistrySnapshot } from "../../src/registry/sync"; +import { createTestEnv } from "../helpers/d1"; + +describe("api route guards and error branches", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("creates, verifies, and revokes GitHub-backed API sessions", async () => { + const app = createApp(); + const env = createTestEnv(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/user") return Response.json({ login: "jsonbored", id: 42 }); + return Response.json({}); + }); + + const login = await app.request( + "/v1/auth/github/session", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ githubToken: "github-token" }), + }, + env, + ); + expect(login.status).toBe(201); + const session = (await login.json()) as { token: string; login: string; expiresAt: string }; + expect(session).toMatchObject({ login: "jsonbored" }); + expect(session.token).toMatch(/^gts_/); + + const authHeaders = { authorization: `Bearer ${session.token}` }; + expect((await app.request("/v1/auth/session", { headers: authHeaders }, env)).status).toBe(200); + expect((await app.request("/v1/repos", { headers: authHeaders }, env)).status).toBe(200); + + const logout = await app.request("/v1/auth/logout", { method: "POST", headers: authHeaders }, env); + expect(logout.status).toBe(200); + expect((await app.request("/v1/auth/session", { headers: authHeaders }, env)).status).toBe(401); + }); + + it("keeps OAuth setup, CORS, and rate limits explicit", async () => { + const app = createApp(); + const env = createTestEnv(); + expect((await app.request("/v1/auth/github/device/start", { method: "POST" }, env)).status).toBe(503); + expect((await app.request("/v1/auth/github/device/poll", { method: "POST", body: "{}" }, env)).status).toBe(400); + expect((await app.request("/v1/auth/github/device/poll", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ deviceCode: "device-code" }) }, env)).status).toBe(503); + expect((await app.request("/v1/auth/github/session", { method: "POST", body: "{}" }, env)).status).toBe(400); + + const blockedPreflight = await app.request( + "/v1/repos", + { + method: "OPTIONS", + headers: { origin: "https://evil.example", "access-control-request-method": "GET" }, + }, + env, + ); + expect(blockedPreflight.headers.get("access-control-allow-origin")).toBeNull(); + + const allowedPreflight = await app.request( + "/v1/repos", + { + method: "OPTIONS", + headers: { origin: "https://gittensory-api.zeronode.workers.dev", "access-control-request-method": "GET" }, + }, + env, + ); + expect(allowedPreflight.headers.get("access-control-allow-origin")).toBe("https://gittensory-api.zeronode.workers.dev"); + + const limitedEnv = createTestEnv({ RATE_LIMITER: denyAllRateLimiter() as unknown as DurableObjectNamespace }); + const limited = await app.request("/v1/auth/github/device/start", { method: "POST" }, limitedEnv); + expect(limited.status).toBe(429); + await expect(limited.json()).resolves.toMatchObject({ error: "rate_limited", routeClass: "strict" }); + expect((await app.request("/v1/repos", { headers: apiHeaders(limitedEnv) }, limitedEnv)).status).toBe(429); + + const allowedRateEnv = createTestEnv({ RATE_LIMITER: allowRateLimiter() as unknown as DurableObjectNamespace }); + const allowedRate = await app.request("/v1/repos", { headers: apiHeaders(allowedRateEnv) }, allowedRateEnv); + expect(allowedRate.status).toBe(200); + expect(allowedRate.headers.get("x-ratelimit-limit")).toBe("99"); + expect(allowedRate.headers.get("x-ratelimit-reset")).toBe("2026-05-25T00:01:00.000Z"); + }); + + it("exposes the GitHub device OAuth route flow without requiring a static token", async () => { + const app = createApp(); + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id" }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("device/code")) { + return Response.json({ + device_code: "device-code", + user_code: "USER-CODE", + verification_uri: "https://github.com/login/device", + expires_in: 900, + }); + } + if (url.includes("access_token")) return Response.json({ error: "authorization_pending", error_description: "waiting" }); + return Response.json({}); + }); + + const started = await app.request("/v1/auth/github/device/start", { method: "POST" }, env); + expect(started.status).toBe(201); + await expect(started.json()).resolves.toMatchObject({ status: "pending", deviceCode: "device-code", userCode: "USER-CODE", interval: 5 }); + + const polled = await app.request("/v1/auth/github/device/poll", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ deviceCode: "device-code" }), + }, env); + expect(polled.status).toBe(200); + await expect(polled.json()).resolves.toMatchObject({ status: "authorization_pending" }); + + vi.stubGlobal("fetch", async () => Response.json({ message: "bad credentials" }, { status: 401 })); + expect( + ( + await app.request("/v1/auth/github/session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ githubToken: "bad-token" }), + }, env) + ).status, + ).toBe(401); + }); + + it("covers private route errors, internal guards, and manual job runners", async () => { + const app = createApp(); + const queued: unknown[] = []; + const env = createTestEnv({ + JOBS: { + async send(message: unknown) { + queued.push(message); + }, + } as unknown as Queue, + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("api.gittensor.io") || url.includes("mirror.gittensor.io")) return new Response("missing", { status: 404 }); + if (url.includes("master_repositories.json")) return Response.json({}); + if (url.includes("constants.py")) return new Response("OSS_EMISSION_SHARE = 0.90\nMIN_TOKEN_SCORE_FOR_BASE_SCORE = 5\nMAX_CODE_DENSITY_MULTIPLIER = 1.15\n"); + if (url.includes("programming_languages.json")) return Response.json({ TypeScript: 1 }); + return new Response("not found", { status: 404 }); + }); + + expect((await app.request("/v1/repos", {}, env)).status).toBe(401); + expect((await app.request("/v1/repos", { headers: { authorization: `Bearer ${env.GITTENSORY_MCP_TOKEN}` } }, env)).status).toBe(200); + expect((await app.request("/v1/registry/snapshot", { headers: apiHeaders(env) }, env)).status).toBe(404); + expect((await app.request("/v1/repos/nope/missing", { headers: apiHeaders(env) }, env)).status).toBe(404); + expect((await app.request("/v1/installations/not-a-number/health", { headers: apiHeaders(env) }, env)).status).toBe(400); + expect((await app.request("/v1/installations/999/health", { headers: apiHeaders(env) }, env)).status).toBe(404); + const emptyReadiness = await app.request("/v1/readiness", { headers: apiHeaders(env) }, env); + expect(emptyReadiness.status).toBe(200); + await expect(emptyReadiness.json()).resolves.toMatchObject({ registry: null, scoringModel: null, readyForPublicReview: false }); + + for (const removedPath of [ + "/v1/repos/nope/missing/advisory", + "/v1/repos/nope/missing/pulls/not-a-number/advisory", + "/v1/repos/nope/missing/issues/not-a-number/advisory", + "/v1/repos/nope/missing/pulls/1/advisory", + "/v1/repos/nope/missing/issues/1/advisory", + ]) { + expect((await app.request(removedPath, { headers: apiHeaders(env) }, env)).status).toBe(404); + } + + const invalidMaintainerPacket = await app.request("/v1/repos/nope/missing/pulls/nope/maintainer-packet", { headers: apiHeaders(env) }, env); + expect(invalidMaintainerPacket.status).toBe(400); + + expect((await app.request("/v1/bounties/missing/advisory", { headers: apiHeaders(env) }, env)).status).toBe(404); + expect((await app.request("/v1/preflight/pr", { method: "POST", headers: apiHeaders(env), body: "{}" }, env)).status).toBe(400); + expect((await app.request("/v1/preflight/local-diff", { method: "POST", headers: apiHeaders(env), body: "{}" }, env)).status).toBe(400); + expect((await app.request("/v1/local/branch-analysis", { method: "POST", headers: apiHeaders(env), body: "{}" }, env)).status).toBe(400); + expect((await app.request("/v1/scoring/preview", { method: "POST", headers: apiHeaders(env), body: "{}" }, env)).status).toBe(400); + expect((await app.request("/v1/scoring/model", { headers: apiHeaders(env) }, env)).status).toBe(200); + expect((await app.request("/v1/repos/nope/missing/issue-quality", { headers: apiHeaders(env) }, env)).status).toBe(404); + expect((await app.request("/v1/repos/nope/missing/burden-forecast", { headers: apiHeaders(env) }, env)).status).toBe(404); + expect((await app.request("/v1/repos/nope/missing/registry-drift", { headers: apiHeaders(env) }, env)).status).toBe(404); + expect((await app.request("/v1/repos/nope/missing/pulls/not-a-number/scoring-preview", { headers: apiHeaders(env) }, env)).status).toBe(404); + + expect((await app.request("/v1/internal/jobs/refresh-registry", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/refresh-registry/run", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/backfill-registered-repos", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/backfill-registered-repos/run", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/backfill-repo-segment", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/backfill-pr-details", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/refresh-installation-health/run", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/generate-signal-snapshots", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/refresh-scoring-model", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/refresh-scoring-model/run", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/build-contributor-evidence", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/build-contributor-decision-packs", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/build-contributor-decision-packs/run", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/refresh-contributor-activity", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/refresh-contributor-activity/run", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/build-burden-forecasts", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/repair-data-fidelity", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/jobs/generate-signal-snapshots/run", { method: "POST" }, env)).status).toBe(401); + expect((await app.request("/v1/internal/bounties/import", { method: "POST" }, env)).status).toBe(401); + expect( + ( + await app.request("/v1/internal/jobs/refresh-registry", { + method: "POST", + headers: internalHeaders(env), + }, env) + ).status, + ).toBe(202); + expect(queued).toEqual(expect.arrayContaining([expect.objectContaining({ type: "refresh-registry" })])); + + expect((await app.request("/v1/internal/jobs/repair-data-fidelity", { method: "POST", headers: internalHeaders(env) }, env)).status).toBe(202); + expect(queued).toEqual(expect.arrayContaining([expect.objectContaining({ type: "repair-data-fidelity" })])); + + expect( + ( + await app.request("/v1/internal/jobs/backfill-registered-repos", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ repoFullName: "JSONbored/gittensory" }), + }, env) + ).status, + ).toBe(202); + expect(queued).toEqual(expect.arrayContaining([expect.objectContaining({ type: "backfill-registered-repos", repoFullName: "JSONbored/gittensory" })])); + + const queuedAllBackfill = await app.request("/v1/internal/jobs/backfill-registered-repos", { + method: "POST", + headers: internalHeaders(env), + body: "{bad-json", + }, env); + expect(queuedAllBackfill.status).toBe(202); + expect(await queuedAllBackfill.json()).toMatchObject({ ok: true, status: "queued" }); + const queuedFullBackfill = await app.request("/v1/internal/jobs/backfill-registered-repos", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ mode: "full" }), + }, env); + expect(queuedFullBackfill.status).toBe(202); + + const queuedSegment = await app.request("/v1/internal/jobs/backfill-repo-segment", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ repoFullName: "infiniflow/ragflow", segment: "open_issues", mode: "resume", force: true, cursor: "12" }), + }, env); + expect(queuedSegment.status).toBe(202); + expect( + ( + await app.request("/v1/internal/jobs/backfill-repo-segment", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ segment: "labels" }), + }, env) + ).status, + ).toBe(400); + expect( + ( + await app.request("/v1/internal/jobs/backfill-repo-segment", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ repoFullName: "infiniflow/ragflow", segment: "bad" }), + }, env) + ).status, + ).toBe(400); + for (const segment of ["labels", "open_pull_requests", "recent_merged_pull_requests"]) { + const response = await app.request("/v1/internal/jobs/backfill-repo-segment", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ repoFullName: "infiniflow/ragflow", segment }), + }, env); + expect(response.status).toBe(202); + } + const queuedDetails = await app.request("/v1/internal/jobs/backfill-pr-details", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ repoFullName: "infiniflow/ragflow", mode: "resume", cursor: 80 }), + }, env); + expect(queuedDetails.status).toBe(202); + const queuedDetailsWithoutCursor = await app.request("/v1/internal/jobs/backfill-pr-details", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ repoFullName: "infiniflow/ragflow" }), + }, env); + expect(queuedDetailsWithoutCursor.status).toBe(202); + expect( + ( + await app.request("/v1/internal/jobs/backfill-pr-details", { + method: "POST", + headers: internalHeaders(env), + body: "{}", + }, env) + ).status, + ).toBe(400); + expect(queued).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "backfill-repo-segment", repoFullName: "infiniflow/ragflow", segment: "open_issues", mode: "resume", force: true, cursor: "12" }), + expect.objectContaining({ type: "backfill-pr-details", repoFullName: "infiniflow/ragflow", mode: "resume", cursor: 80 }), + ]), + ); + + const queuedSignals = await app.request("/v1/internal/jobs/generate-signal-snapshots", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ repoFullName: "JSONbored/gittensory" }), + }, env); + expect(queuedSignals.status).toBe(202); + expect(queued).toEqual(expect.arrayContaining([expect.objectContaining({ type: "generate-signal-snapshots", repoFullName: "JSONbored/gittensory" })])); + + const queuedScoring = await app.request("/v1/internal/jobs/refresh-scoring-model", { method: "POST", headers: internalHeaders(env) }, env); + expect(queuedScoring.status).toBe(202); + const queuedEvidence = await app.request("/v1/internal/jobs/build-contributor-evidence", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ login: "oktofeesh1" }), + }, env); + expect(queuedEvidence.status).toBe(202); + const queuedDecisionPack = await app.request("/v1/internal/jobs/build-contributor-decision-packs", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ login: "oktofeesh1" }), + }, env); + expect(queuedDecisionPack.status).toBe(202); + expect( + ( + await app.request("/v1/internal/jobs/refresh-contributor-activity", { + method: "POST", + headers: internalHeaders(env), + body: "{}", + }, env) + ).status, + ).toBe(400); + const queuedContributorRefresh = await app.request("/v1/internal/jobs/refresh-contributor-activity", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ login: "jsonbored", repoFullName: "JSONbored/gittensory" }), + }, env); + expect(queuedContributorRefresh.status).toBe(202); + const queuedForecasts = await app.request("/v1/internal/jobs/build-burden-forecasts", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ repoFullName: "JSONbored/gittensory" }), + }, env); + expect(queuedForecasts.status).toBe(202); + expect(queued).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "refresh-scoring-model" }), + expect.objectContaining({ type: "build-contributor-evidence", login: "oktofeesh1" }), + expect.objectContaining({ type: "build-contributor-decision-packs", login: "oktofeesh1" }), + expect.objectContaining({ type: "refresh-contributor-activity", login: "jsonbored", repoFullName: "JSONbored/gittensory" }), + expect.objectContaining({ type: "build-burden-forecasts", repoFullName: "JSONbored/gittensory" }), + ]), + ); + + expect((await app.request("/v1/internal/jobs/refresh-registry/run", { method: "POST", headers: internalHeaders(env) }, env)).status).toBe(200); + expect( + ( + await app.request("/v1/internal/jobs/backfill-registered-repos/run", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ repoFullName: "JSONbored/gittensory" }), + }, env) + ).status, + ).toBe(200); + expect( + ( + await app.request("/v1/internal/jobs/backfill-repo-segment/run", { + method: "POST", + headers: internalHeaders(env), + body: "{}", + }, env) + ).status, + ).toBe(400); + expect( + ( + await app.request("/v1/internal/jobs/backfill-repo-segment/run", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ repoFullName: "missing/repo", segment: "bad" }), + }, env) + ).status, + ).toBe(400); + expect( + ( + await app.request("/v1/internal/jobs/backfill-repo-segment/run", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ repoFullName: "missing/repo", segment: "labels", mode: "full", cursor: "2" }), + }, env) + ).status, + ).toBe(200); + expect( + ( + await app.request("/v1/internal/jobs/backfill-pr-details/run", { + method: "POST", + headers: internalHeaders(env), + body: "{}", + }, env) + ).status, + ).toBe(400); + expect( + ( + await app.request("/v1/internal/jobs/backfill-pr-details/run", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ repoFullName: "missing/repo", mode: "full", cursor: 2 }), + }, env) + ).status, + ).toBe(200); + expect( + ( + await app.request("/v1/internal/jobs/backfill-registered-repos/run", { + method: "POST", + headers: internalHeaders(env), + body: "{bad-json", + }, env) + ).status, + ).toBe(200); + expect((await app.request("/v1/internal/jobs/refresh-installation-health/run", { method: "POST", headers: internalHeaders(env) }, env)).status).toBe(200); + expect((await app.request("/v1/internal/jobs/refresh-scoring-model/run", { method: "POST", headers: internalHeaders(env) }, env)).status).toBe(200); + expect((await app.request("/v1/internal/jobs/generate-signal-snapshots/run", { method: "POST", headers: internalHeaders(env), body: JSON.stringify({ repoFullName: "missing/repo" }) }, env)).status).toBe(200); + expect((await app.request("/v1/internal/jobs/build-contributor-decision-packs/run", { method: "POST", headers: internalHeaders(env), body: "{}" }, env)).status).toBe(400); + expect((await app.request("/v1/internal/jobs/refresh-contributor-activity/run", { method: "POST", headers: internalHeaders(env), body: "{}" }, env)).status).toBe(400); + expect( + ( + await app.request("/v1/internal/jobs/refresh-contributor-activity/run", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ login: "jsonbored" }), + }, env) + ).status, + ).toBe(200); + + expect( + ( + await app.request("/v1/internal/repos/JSONbored/gittensory/settings", { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ commentMode: "bad" }), + }, env) + ).status, + ).toBe(400); + }); + + it("covers public MCP preflight and successful repo/settings routes", async () => { + const app = createApp(); + const env = createTestEnv({ GITTENSORY_MCP_TOKEN: "" }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + + expect((await app.request("/mcp", { method: "OPTIONS" }, env)).status).toBe(204); + expect(await handleMcpRequest({ req: { method: "OPTIONS" } } as never)).toMatchObject({ status: 204 }); + expect( + ( + await app.request( + "/mcp", + { method: "POST", headers: { authorization: "Bearer anything", "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }) }, + env, + ) + ).status, + ).toBe(401); + + const snapshot = await app.request("/v1/registry/snapshot", { headers: apiHeaders(env) }, env); + expect(snapshot.status).toBe(200); + + const repo = await app.request("/v1/repos/JSONbored/gittensory", { headers: apiHeaders(env) }, env); + expect(repo.status).toBe(200); + await expect(repo.json()).resolves.toMatchObject({ fullName: "JSONbored/gittensory" }); + + const updated = await app.request( + "/v1/internal/repos/JSONbored/gittensory/settings", + { + method: "POST", + headers: internalHeaders(env), + body: JSON.stringify({ + commentMode: "all_prs", + publicSignalLevel: "minimal", + checkRunDetailLevel: "deep", + backfillEnabled: false, + privateTrustEnabled: false, + }), + }, + env, + ); + expect(updated.status).toBe(200); + await expect(updated.json()).resolves.toMatchObject({ commentMode: "all_prs", checkRunDetailLevel: "deep", backfillEnabled: false, privateTrustEnabled: false }); + }); +}); + +function apiHeaders(env: Env): Record { + return { + authorization: `Bearer ${env.GITTENSORY_API_TOKEN}`, + "content-type": "application/json", + }; +} + +function internalHeaders(env: Env): Record { + return { + authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}`, + "content-type": "application/json", + }; +} + +function denyAllRateLimiter() { + return { + idFromName() { + return {}; + }, + get() { + return { + async fetch() { + return Response.json( + { + allowed: false, + limit: 1, + remaining: 0, + retryAfterSeconds: 30, + resetAt: "2026-05-25T00:01:00.000Z", + }, + { status: 429 }, + ); + }, + }; + }, + }; +} + +function allowRateLimiter() { + return { + idFromName() { + return {}; + }, + get() { + return { + async fetch() { + return Response.json({ + allowed: true, + limit: 99, + remaining: 98, + resetAt: "2026-05-25T00:01:00.000Z", + }); + }, + }; + }, + }; +} diff --git a/test/unit/adapters.test.ts b/test/unit/adapters.test.ts new file mode 100644 index 0000000000..4cef6a5c5e --- /dev/null +++ b/test/unit/adapters.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildWorkboard } from "../../src/api/workboard"; +import { normalizeGittBountySnapshot } from "../../src/bounties/ingest"; +import { fetchPublicContributorProfile } from "../../src/github/public"; +import { jsonString, normalizeRepoFullName, parseJson, repoParts } from "../../src/utils/json"; +import type { IssueRecord, RepositoryRecord } from "../../src/types"; + +describe("small adapters and normalizers", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps JSON helpers predictable on missing and malformed values", () => { + expect(parseJson(undefined, { ok: true })).toEqual({ ok: true }); + expect(parseJson("{bad", ["fallback"])).toEqual(["fallback"]); + expect(parseJson('{"ok":true}', { ok: false })).toEqual({ ok: true }); + expect(jsonString(undefined)).toBe("null"); + expect(normalizeRepoFullName(" owner/repo ")).toBe("owner/repo"); + expect(repoParts("owner/name/with/slash")).toEqual({ owner: "owner", name: "name/with/slash" }); + expect(repoParts("")).toEqual({ owner: "", name: "" }); + }); + + it("normalizes gitt bounty snapshots and drops incomplete rows", () => { + expect(normalizeGittBountySnapshot({})).toEqual([]); + const records = normalizeGittBountySnapshot({ + success: true, + issues: [ + {}, + { + id: 33, + repository_full_name: "JSONbored/gittensory", + issue_number: 12, + status: "Completed", + bounty_amount: 0.5, + target_bounty: 1, + active: false, + note: null, + nested: { ignored: true }, + }, + { + id: "35", + repository_full_name: "JSONbored/gittensory", + issue_number: 13, + status: "Active", + bounty_alpha: "1.2500", + }, + { id: 34, repository_full_name: "JSONbored/gittensory", status: "Active" }, + { id: 36, repository_full_name: "JSONbored/gittensory", issue_number: 14, status: "Cancelled", active: undefined }, + ], + }); + + expect(records).toHaveLength(3); + expect(records[0]).toMatchObject({ id: "33", amountText: "0.5", sourceUrl: "gitt://issues/33" }); + expect(records[0]?.payload).toMatchObject({ active: false, note: null, target_bounty: 1 }); + expect(records[0]?.payload).not.toHaveProperty("nested"); + expect(records[1]).toMatchObject({ id: "35", amountText: "1.2500", sourceUrl: "gitt://issues/35" }); + expect(records[2]).toMatchObject({ id: "36", amountText: undefined, sourceUrl: "gitt://issues/36" }); + expect(records[2]?.payload).not.toHaveProperty("active"); + }); + + it("builds workboard holds and maintainer-authored context", () => { + const repo: RepositoryRecord = { + fullName: "JSONbored/gittensory", + owner: "JSONbored", + name: "gittensory", + isInstalled: true, + isRegistered: false, + isPrivate: true, + }; + const issues: IssueRecord[] = [ + { + repoFullName: repo.fullName, + number: 1, + title: "Add queue health endpoint", + state: "open", + authorLogin: "maintainer", + authorAssociation: "OWNER", + labels: [], + linkedPrs: [7], + }, + ]; + + expect(buildWorkboard(null, issues)).toEqual([]); + const item = buildWorkboard(repo, issues)[0]; + expect(item).toMatchObject({ fit: "hold", issueNumber: 1 }); + expect(item?.reasons).toEqual(expect.arrayContaining(["Repository is not present in the latest registry snapshot.", "Issue already has linked pull requests.", "Issue was opened by a maintainer-associated account."])); + + const registeredRepo = { ...repo, isRegistered: true, isPrivate: false }; + const baseIssue = issues[0]!; + expect( + buildWorkboard(registeredRepo, [ + { ...baseIssue, number: 2, linkedPrs: [], authorAssociation: "CONTRIBUTOR" }, + { ...baseIssue, number: 3, linkedPrs: [9], authorAssociation: "CONTRIBUTOR" }, + ]), + ).toEqual([ + expect.objectContaining({ fit: "good", reasons: ["Open issue with no linked pull request detected by Gittensory."] }), + expect.objectContaining({ fit: "caution", reasons: ["Issue already has linked pull requests."] }), + ]); + }); + + it("fetches public contributor profile languages and handles unavailable GitHub responses", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/users/oktofeesh1")) { + return Response.json({ login: "oktofeesh1", name: "Okto", public_repos: 12, followers: 3, created_at: "2026-01-01T00:00:00Z" }); + } + if (url.endsWith("/users/norepos")) { + return Response.json({ login: "norepos", public_repos: 0, followers: 0 }); + } + if (url.includes("/users/norepos/repos?")) { + return new Response("repos unavailable", { status: 503 }); + } + if (url.includes("/repos?")) { + return Response.json([{ language: "TypeScript" }, { language: "Python" }, { language: "TypeScript" }, { language: "Python" }, { language: "Ruby" }, { language: null }]); + } + return new Response("not found", { status: 404 }); + }); + + const profile = await fetchPublicContributorProfile("oktofeesh1"); + expect(profile).toMatchObject({ login: "oktofeesh1", source: "github", topLanguages: ["Python", "TypeScript", "Ruby"] }); + await expect(fetchPublicContributorProfile("norepos")).resolves.toMatchObject({ login: "norepos", source: "github", topLanguages: [] }); + + vi.stubGlobal("fetch", async () => new Response("nope", { status: 500 })); + await expect(fetchPublicContributorProfile("missing")).resolves.toMatchObject({ login: "missing", source: "unavailable", topLanguages: [] }); + }); +}); diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts new file mode 100644 index 0000000000..102b6a9163 --- /dev/null +++ b/test/unit/auth.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSessionFromGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow } from "../../src/auth/github-oauth"; +import { RateLimiter } from "../../src/auth/rate-limit"; +import { authenticatePrivateToken, createSessionForGitHubUser, revokeSession } from "../../src/auth/security"; +import { createTestEnv } from "../helpers/d1"; + +describe("private-beta auth and rate limiting", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("authenticates static tokens and hashed session tokens without accepting revoked sessions", async () => { + const env = createTestEnv(); + await expect(authenticatePrivateToken(env, env.GITTENSORY_API_TOKEN)).resolves.toMatchObject({ kind: "static", actor: "api" }); + await expect(authenticatePrivateToken(env, env.GITTENSORY_MCP_TOKEN)).resolves.toMatchObject({ kind: "static", actor: "mcp" }); + await expect(authenticatePrivateToken(env, "wrong-token")).resolves.toBeNull(); + + const { token } = await createSessionForGitHubUser(env, { login: "jsonbored", id: 42 }, { scopes: ["read:user"] }); + const identity = await authenticatePrivateToken(env, token); + expect(identity).toMatchObject({ kind: "session", actor: "jsonbored" }); + await revokeSession(env, identity); + await expect(authenticatePrivateToken(env, token)).resolves.toBeNull(); + await expect(revokeSession(env, null)).resolves.toBe(false); + + const expired = await createSessionForGitHubUser(env, { login: "expired-user" }); + await env.DB.prepare("update auth_sessions set expires_at = ? where login = ?").bind("2020-01-01T00:00:00.000Z", "expired-user").run(); + await expect(authenticatePrivateToken(env, expired.token)).resolves.toBeNull(); + }); + + it("enforces burst limits inside the Durable Object bucket", async () => { + const state = memoryDurableObjectState(); + const limiter = new RateLimiter(state as unknown as DurableObjectState, createTestEnv()); + const first = await limiter.fetch(new Request("https://rate-limit/check", { method: "POST", body: JSON.stringify({ key: "session:one", limit: 1, windowSeconds: 60 }) })); + expect(first.status).toBe(200); + + const second = await limiter.fetch(new Request("https://rate-limit/check", { method: "POST", body: JSON.stringify({ key: "session:one", limit: 1, windowSeconds: 60 }) })); + expect(second.status).toBe(429); + await expect(second.json()).resolves.toMatchObject({ allowed: false, remaining: 0 }); + + const invalid = await limiter.fetch(new Request("https://rate-limit/check", { method: "POST", body: "{}" })); + expect(invalid.status).toBe(400); + }); + + it("starts GitHub device flow and rejects malformed provider responses", async () => { + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id" }); + vi.stubGlobal("fetch", async () => + Response.json({ + device_code: "device-code", + user_code: "USER-CODE", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 5, + }), + ); + + await expect(startGitHubDeviceFlow(env)).resolves.toMatchObject({ device_code: "device-code", user_code: "USER-CODE" }); + + vi.stubGlobal("fetch", async () => Response.json({ error: "bad_verification_code", error_description: "bad" })); + await expect(startGitHubDeviceFlow(env)).rejects.toThrow(/bad/); + + vi.stubGlobal("fetch", async () => Response.json({ device_code: "missing" })); + await expect(startGitHubDeviceFlow(env)).rejects.toThrow(/response_invalid/); + await expect(startGitHubDeviceFlow(createTestEnv())).rejects.toThrow(/not_configured/); + + vi.stubGlobal("fetch", async () => + Response.json({ + device_code: "device-code", + user_code: "USER-CODE", + verification_uri: "https://github.com/login/device", + expires_in: 900, + }), + ); + await expect(startGitHubDeviceFlow(env)).resolves.not.toHaveProperty("interval"); + }); + + it("polls GitHub device flow and creates a session only after authorization", async () => { + const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id" }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("access_token")) return Response.json({ error: "authorization_pending", error_description: "waiting" }); + return Response.json({}); + }); + await expect(pollGitHubDeviceFlow(env, "device-code")).resolves.toMatchObject({ status: "authorization_pending" }); + + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("access_token")) return Response.json({ access_token: "gh-token", scope: "read:user" }); + if (url === "https://api.github.com/user") return Response.json({ login: "jsonbored", id: 42 }); + return Response.json({}); + }); + await expect(pollGitHubDeviceFlow(env, "device-code")).resolves.toMatchObject({ login: "jsonbored", scopes: ["read:user"] }); + + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("access_token")) return Response.json({ error: "slow_down", error_description: "slow down" }); + return Response.json({}); + }); + await expect(pollGitHubDeviceFlow(env, "device-code")).resolves.toMatchObject({ status: "slow_down" }); + + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("access_token")) return Response.json({}); + return Response.json({}); + }); + await expect(pollGitHubDeviceFlow(env, "device-code")).rejects.toThrow(/access_token_missing/); + await expect(pollGitHubDeviceFlow(createTestEnv(), "device-code")).rejects.toThrow(/not_configured/); + }); + + it("rejects invalid GitHub tokens when creating sessions", async () => { + const env = createTestEnv(); + vi.stubGlobal("fetch", async () => Response.json({ message: "bad credentials" }, { status: 401 })); + await expect(createSessionFromGitHubToken(env, "bad-token")).rejects.toThrow(/github_user_validation_failed/); + + vi.stubGlobal("fetch", async () => Response.json({ login: "no-id-user" })); + await expect(createSessionFromGitHubToken(env, "valid-token")).resolves.toMatchObject({ login: "no-id-user", scopes: [] }); + }); +}); + +function memoryDurableObjectState() { + const storage = new Map(); + return { + storage: { + async get(key: string) { + return storage.get(key); + }, + async put(key: string, value: unknown) { + storage.set(key, value); + }, + }, + }; +} diff --git a/test/unit/backfill.test.ts b/test/unit/backfill.test.ts new file mode 100644 index 0000000000..61f82440a1 --- /dev/null +++ b/test/unit/backfill.test.ts @@ -0,0 +1,1186 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + listCheckSummaries, + listContributorRepoStats, + listIssues, + listLatestRepoGithubTotalsSnapshots, + listPullRequestFiles, + listPullRequestReviews, + listPullRequests, + listPullRequestDetailSyncStates, + listRecentMergedPullRequests, + listLatestGitHubRateLimitObservations, + listRepoLabels, + listRepoSyncSegments, + listRepoSyncStates, + recordGitHubRateLimitObservation, + upsertInstallation, + upsertRepoSyncSegment, + upsertRepoSyncState, + upsertPullRequestFromGitHub, + upsertIssueFromGitHub, + upsertRepositoryFromGitHub, + upsertRepositorySettings, +} from "../../src/db/repositories"; +import { + backfillOpenPullRequestDetails, + backfillRegisteredRepositories, + backfillRepositorySegment, + enqueueRepositoryOpenDataBackfill, + refreshContributorActivity, + refreshInstallationHealth, +} from "../../src/github/backfill"; +import { normalizeRegistryPayload } from "../../src/registry/normalize"; +import { persistRegistrySnapshot } from "../../src/registry/sync"; +import { createTestEnv } from "../helpers/d1"; + +describe("GitHub backfill", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("stores bounded repo metadata, labels, issues, PR details, recent merges, and contributor stats", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + const authHeaders: Array = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + authHeaders.push(new Headers(init?.headers).get("authorization")); + if (url.endsWith("/repos/JSONbored/gittensory")) { + return Response.json({ + name: "gittensory", + full_name: "JSONbored/gittensory", + private: true, + html_url: "https://github.com/JSONbored/gittensory", + default_branch: "main", + language: "TypeScript", + open_issues_count: 3, + owner: { login: "JSONbored" }, + }); + } + if (url.includes("/labels?")) { + return Response.json([{ name: "bug", color: "cc0000", description: "Bug" }]); + } + if (url.includes("/issues?")) { + return Response.json([ + { + number: 1, + title: "Fix webhook processing", + state: "open", + user: { login: "reporter" }, + labels: [{ name: "bug" }], + body: "Webhook processing should be stable.", + created_at: "2026-05-20T00:00:00.000Z", + updated_at: "2026-05-21T00:00:00.000Z", + }, + ]); + } + if (url.includes("/pulls?state=open")) { + return Response.json([ + { + number: 10, + title: "Fix webhook processing", + state: "open", + user: { login: "oktofeesh1" }, + author_association: "NONE", + head: { sha: "abc", ref: "fix-webhook" }, + base: { ref: "main" }, + labels: [{ name: "bug" }], + body: "Fixes #1", + created_at: "2026-05-22T00:00:00.000Z", + updated_at: "2026-05-23T00:00:00.000Z", + }, + ]); + } + if (url.includes("/pulls?state=closed")) { + return Response.json([ + { + number: 9, + title: "Fix webhook processing", + state: "closed", + merged_at: "2026-05-22T00:00:00.000Z", + user: { login: "oktofeesh1" }, + labels: [{ name: "bug" }], + body: "Fixes #1", + }, + ]); + } + if (url.includes("/pulls/10/files") || url.includes("/pulls/9/files")) { + return Response.json([ + { filename: "src/github/webhook.ts", status: "modified", additions: 12, deletions: 3, changes: 15 }, + { filename: "README.md" }, + ]); + } + if (url.includes("/pulls/10/reviews")) { + return Response.json([ + { id: 1, user: { login: "maintainer" }, state: "APPROVED", submitted_at: "2026-05-23T00:00:00.000Z" }, + { id: 2 }, + ]); + } + if (url.includes("/commits/abc/check-runs")) { + return Response.json({ + check_runs: [ + { id: 2, name: "test", status: "completed", conclusion: "success" }, + { id: 3, name: "lint", status: "completed", conclusion: null, html_url: "https://github.com/checks/3" }, + ], + }); + } + return new Response("not found", { status: 404 }); + }); + + const result = await backfillRegisteredRepositories(env, { limits: { issues: 10, pullRequests: 10, recentMergedPullRequests: 10 } }); + expect(result).toMatchObject({ repoCount: 1, repos: [{ status: "success", openIssues: 1, openPullRequests: 1 }] }); + expect(await listIssues(env, "JSONbored/gittensory")).toMatchObject([{ number: 1, labels: ["bug"] }]); + expect(await listPullRequests(env, "JSONbored/gittensory")).toMatchObject([{ number: 10, linkedIssues: [1] }]); + expect(await listRepoLabels(env, "JSONbored/gittensory")).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "bug", isConfigured: true, observedCount: 3 })]), + ); + expect(await listPullRequestFiles(env, "JSONbored/gittensory", 10)).toEqual(expect.arrayContaining([expect.objectContaining({ path: "src/github/webhook.ts" })])); + expect(await listPullRequestReviews(env, "JSONbored/gittensory", 10)).toEqual(expect.arrayContaining([expect.objectContaining({ reviewerLogin: "maintainer" })])); + expect(await listCheckSummaries(env, "JSONbored/gittensory", 10)).toEqual(expect.arrayContaining([expect.objectContaining({ name: "test", conclusion: "success" })])); + expect(await listRecentMergedPullRequests(env, "JSONbored/gittensory")).toEqual( + expect.arrayContaining([expect.objectContaining({ number: 9, changedFiles: expect.arrayContaining(["src/github/webhook.ts"]) })]), + ); + expect(await listContributorRepoStats(env, "oktofeesh1")).toMatchObject([{ mergedPullRequests: 1, pullRequests: 2 }]); + expect(await listRepoSyncStates(env)).toMatchObject([{ repoFullName: "JSONbored/gittensory", status: "success", primaryLanguage: "TypeScript" }]); + expect(authHeaders).toContain("Bearer public-token"); + }); + + it("refreshes contributor activity from GitHub search counts for registered repos", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + const authHeaders: Array = []; + vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => { + authHeaders.push(new Headers(init?.headers).get("authorization")); + const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string }; + expect(body.query).toContain("repo:JSONbored/gittensory author:jsonbored type:pr"); + return Response.json({ + data: { + r_JSONbored_gittensory_all: { + issueCount: 50, + nodes: [{ __typename: "PullRequest", updatedAt: "2026-05-25T00:00:00Z", labels: { nodes: [{ name: "bug" }] }, body: "Fixes #1" }], + }, + r_JSONbored_gittensory_merged: { + issueCount: 47, + nodes: [{ __typename: "PullRequest", mergedAt: "2026-05-24T00:00:00Z", labels: { nodes: [{ name: "bug" }] }, body: "Fixes #1" }], + }, + r_JSONbored_gittensory_open: { + issueCount: 2, + nodes: [{ __typename: "PullRequest", updatedAt: "2026-04-01T00:00:00Z", labels: { nodes: [{ name: "ci" }] }, body: "" }], + }, + r_JSONbored_gittensory_issues: { + issueCount: 12, + nodes: [{ __typename: "Issue", updatedAt: "2026-05-20T00:00:00Z", labels: { nodes: [{ name: "bug" }] }, body: "Report" }], + }, + }, + }); + }); + + const result = await refreshContributorActivity(env, "jsonbored"); + + expect(result).toMatchObject({ repoCount: 1, updatedRepoStats: 1, warnings: [] }); + expect(authHeaders).toContain("Bearer public-token"); + expect(await listContributorRepoStats(env, "JSONbored")).toMatchObject([ + { repoFullName: "JSONbored/gittensory", pullRequests: 50, mergedPullRequests: 47, openPullRequests: 2, issues: 12, unlinkedPullRequests: 1 }, + ]); + }); + + it("skips contributor activity refresh without a public GitHub token", async () => { + const env = createTestEnv(); + await seedRegisteredRepo(env); + const result = await refreshContributorActivity(env, "jsonbored"); + expect(result).toMatchObject({ + repoCount: 0, + updatedRepoStats: 0, + warnings: ["GITHUB_PUBLIC_TOKEN is not configured; contributor activity refresh was skipped."], + }); + }); + + it("records contributor activity refresh GraphQL errors without mutating stats", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async () => new Response("rate limited", { status: 403 })); + + const result = await refreshContributorActivity(env, "jsonbored"); + + expect(result.updatedRepoStats).toBe(0); + expect(result.warnings[0]).toContain("GitHub GraphQL failed (403)"); + expect(await listContributorRepoStats(env, "jsonbored")).toEqual([]); + }); + + it("carries GraphQL warnings and ignores repos with no contributor activity", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async () => + Response.json({ + errors: [{ message: "partial search warning" }, {}], + data: { + r_JSONbored_gittensory_all: { issueCount: 0, nodes: null }, + r_JSONbored_gittensory_merged: { issueCount: 0, nodes: null }, + r_JSONbored_gittensory_open: { issueCount: 0, nodes: null }, + r_JSONbored_gittensory_issues: { issueCount: 0, nodes: null }, + }, + }), + ); + + const result = await refreshContributorActivity(env, "jsonbored"); + + expect(result).toMatchObject({ updatedRepoStats: 0, warnings: ["partial search warning"] }); + expect(await listContributorRepoStats(env, "jsonbored")).toEqual([]); + }); + + it("reports installation health from stored permissions and events", async () => { + const env = createTestEnv(); + await seedRegisteredRepo(env); + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { checks: "write", metadata: "read" }, + events: ["pull_request"], + }, + }); + + const result = await refreshInstallationHealth(env); + expect(result.installations[0]).toMatchObject({ + status: "needs_attention", + missingPermissions: ["pull_requests"], + missingEvents: ["issues", "repository"], + }); + + await upsertInstallation(env, { + installation: { + id: 124, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { checks: "write", metadata: "read", pull_requests: "read" }, + events: ["issues", "pull_request", "repository"], + }, + }); + const refreshed = await refreshInstallationHealth(env); + expect(refreshed.installations).toEqual(expect.arrayContaining([expect.objectContaining({ installationId: 124, status: "healthy" })])); + }); + + it("skips repositories with backfill disabled", async () => { + const env = createTestEnv(); + await seedRegisteredRepo(env); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSignalLevel: "standard", + checkRunMode: "enabled", + checkRunDetailLevel: "standard", + backfillEnabled: false, + privateTrustEnabled: true, + }); + + const result = await backfillRegisteredRepositories(env); + + expect(result.repos[0]).toMatchObject({ status: "skipped", warnings: ["Backfill is disabled for this repository."] }); + }); + + it("skips public repo backfill without a service token and backs off fresh sync states", async () => { + const missingTokenEnv = createTestEnv(); + await seedRegisteredRepo(missingTokenEnv); + const missingToken = await backfillRegisteredRepositories(missingTokenEnv); + expect(missingToken.repos[0]).toMatchObject({ + status: "skipped", + warnings: [expect.stringContaining("GITHUB_PUBLIC_TOKEN")], + }); + expect(await listRepoSyncStates(missingTokenEnv)).toMatchObject([{ repoFullName: "JSONbored/gittensory", status: "skipped" }]); + + const freshEnv = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(freshEnv); + await upsertRepoSyncState(freshEnv, { + repoFullName: "JSONbored/gittensory", + status: "success", + sourceKind: "github", + openIssuesCount: 2, + openPullRequestsCount: 1, + recentMergedPullRequestsCount: 0, + lastCompletedAt: new Date().toISOString(), + warnings: [], + }); + const fresh = await backfillRegisteredRepositories(freshEnv); + expect(fresh.repos[0]).toMatchObject({ status: "skipped", openIssues: 2, warnings: [expect.stringContaining("Recent GitHub sync completed")] }); + + await upsertRepoSyncState(freshEnv, { + repoFullName: "JSONbored/gittensory", + status: "error", + sourceKind: "github", + openIssuesCount: 0, + openPullRequestsCount: 0, + recentMergedPullRequestsCount: 0, + lastCompletedAt: new Date().toISOString(), + errorSummary: "rate limited", + warnings: [], + }); + const backedOff = await backfillRegisteredRepositories(freshEnv); + expect(backedOff.repos[0]).toMatchObject({ status: "skipped", errorSummary: "rate limited", warnings: [expect.stringContaining("backing off")] }); + }); + + it("records partial sync warnings from caps and GitHub detail failures", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/repos/JSONbored/gittensory")) { + return Response.json({ + name: "gittensory", + full_name: "JSONbored/gittensory", + private: false, + default_branch: "main", + language: null, + owner: { login: "JSONbored" }, + }); + } + if (url.includes("/labels?")) return new Response("label failure", { status: 500 }); + if (url.includes("/issues?")) { + return Response.json([ + { number: 1, title: "Open issue", state: "open", user: {}, labels: [{}], body: "body" }, + ]); + } + if (url.includes("/pulls?state=open")) { + return Response.json([ + { number: 10, title: "No head sha PR", state: "open", user: {}, labels: [{}], body: "", head: { sha: "badsha" }, updated_at: "not-a-date" }, + ]); + } + if (url.includes("/pulls?state=closed")) { + return Response.json([ + { number: 9, title: "Merged PR", state: "closed", merged_at: "2026-05-22T00:00:00.000Z", user: {}, labels: [{}], body: "" }, + ]); + } + if (url.includes("/pulls/")) return new Response("detail failure", { status: 503 }); + return new Response("not found", { status: 404 }); + }); + + const result = await backfillRegisteredRepositories(env, { limits: { issues: 1, pullRequests: 1, recentMergedPullRequests: 1, pullRequestDetails: 1 } }); + + expect(result.repos[0]?.status).toBe("capped"); + expect(result.repos[0]?.dataQuality).toMatchObject({ capped: true, partial: true }); + expect(result.repos[0]?.warnings.join("\n")).toMatch(/Label sync failed|local cap|File sync failed|Review sync failed/); + expect(await listRepoSyncStates(env)).toMatchObject([{ status: "capped", openIssuesCount: 1, openPullRequestsCount: 1 }]); + expect(await listRepoSyncSegments(env, "JSONbored/gittensory")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ segment: "open_issues", status: "capped", nextCursor: expect.any(String) }), + expect.objectContaining({ segment: "open_pull_requests", status: "capped", nextCursor: expect.any(String) }), + expect.objectContaining({ segment: "labels", status: "partial" }), + ]), + ); + }); + + it("uses installation tokens when available and records hard sync errors", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await seedRegisteredRepo(env); + await upsertRepositoryFromGitHub( + env, + { + name: "gittensory", + full_name: "JSONbored/gittensory", + private: true, + default_branch: "main", + owner: { login: "JSONbored" }, + }, + 123, + ); + const authHeaders: Array = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + authHeaders.push(new Headers(init?.headers).get("authorization")); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.endsWith("/repos/JSONbored/gittensory")) { + return Response.json({ + name: "gittensory", + full_name: "JSONbored/gittensory", + private: true, + default_branch: "main", + language: "TypeScript", + owner: { login: "JSONbored" }, + }); + } + if (url.includes("/labels?") || url.includes("/issues?") || url.includes("/pulls?")) return Response.json([]); + return new Response("not found", { status: 404 }); + }); + + const installed = await backfillRegisteredRepositories(env); + expect(installed.repos[0]).toMatchObject({ status: "success" }); + expect(authHeaders).toContain("Bearer installation-token"); + + vi.stubGlobal("fetch", async () => new Response("repo missing", { status: 404 })); + const failed = await backfillRegisteredRepositories(env, { repoFullName: "JSONbored/gittensory", force: true }); + expect(failed.repos[0]).toMatchObject({ status: "error", errorSummary: expect.stringContaining("GitHub API failed") }); + }); + + it("paginates beyond the first GitHub page and stores complete segment fidelity", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/repos/JSONbored/gittensory")) { + return Response.json({ + name: "gittensory", + full_name: "JSONbored/gittensory", + private: false, + default_branch: "main", + language: "TypeScript", + owner: { login: "JSONbored" }, + }); + } + if (url.includes("/labels?")) return Response.json([]); + if (url.includes("/issues?") && url.includes("page=1")) { + return Response.json( + Array.from({ length: 100 }, (_, index) => ({ number: index + 1, title: `Issue ${index + 1}`, state: "open", user: { login: "reporter" }, labels: [], body: "" })), + { headers: { link: '; rel="next"' } }, + ); + } + if (url.includes("/issues?") && url.includes("page=2")) { + return Response.json([{ number: 101, title: "Issue 101", state: "open", user: { login: "reporter" }, labels: [], body: "" }]); + } + if (url.includes("/pulls?")) return Response.json([]); + return new Response("not found", { status: 404 }); + }); + + const result = await backfillRegisteredRepositories(env, { + mode: "full", + limits: { issues: 150, pullRequests: 0, recentMergedPullRequests: 0, pullRequestDetails: 0 }, + }); + + expect(result.repos[0]).toMatchObject({ status: "success", openIssues: 101 }); + expect(await listRepoSyncSegments(env, "JSONbored/gittensory")).toEqual( + expect.arrayContaining([expect.objectContaining({ segment: "open_issues", status: "complete", fetchedCount: 101, pageCount: 2 })]), + ); + }); + + it("resumes paginated segments from stored cursors instead of restarting from page one", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + await upsertRepoSyncSegment(env, { + repoFullName: "JSONbored/gittensory", + segment: "open_issues", + status: "capped", + sourceKind: "github", + mode: "full", + nextCursor: "3", + fetchedCount: 200, + pageCount: 2, + completedAt: "2026-05-24T00:00:00.000Z", + warnings: ["previous cap"], + }); + const requestedIssuePages: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/repos/JSONbored/gittensory")) { + return Response.json({ + name: "gittensory", + full_name: "JSONbored/gittensory", + private: false, + default_branch: "main", + language: "TypeScript", + owner: { login: "JSONbored" }, + }); + } + if (url.includes("/labels?") || url.includes("/pulls?")) return Response.json([]); + if (url.includes("/issues?")) { + requestedIssuePages.push(url); + if (url.includes("page=3")) return Response.json([{ number: 201, title: "Issue 201", state: "open", user: { login: "reporter" }, labels: [], body: "" }]); + return new Response("unexpected page", { status: 500 }); + } + return new Response("not found", { status: 404 }); + }); + + const result = await backfillRegisteredRepositories(env, { + mode: "resume", + force: true, + limits: { issues: 300, pullRequests: 0, recentMergedPullRequests: 0, pullRequestDetails: 0 }, + }); + + expect(result.repos[0]).toMatchObject({ status: "success", openIssues: 201 }); + expect(requestedIssuePages).toHaveLength(1); + expect(requestedIssuePages[0]).toContain("page=3"); + expect(await listRepoSyncSegments(env, "JSONbored/gittensory")).toEqual( + expect.arrayContaining([expect.objectContaining({ segment: "open_issues", status: "complete", fetchedCount: 201, lastCursor: "3" })]), + ); + }); + + it("records rate-limited segments and sanitized rate-limit observations", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/repos/JSONbored/gittensory")) { + return Response.json({ + name: "gittensory", + full_name: "JSONbored/gittensory", + private: false, + default_branch: "main", + language: "TypeScript", + owner: { login: "JSONbored" }, + }); + } + if (url.includes("/labels?") || url.includes("/pulls?")) return Response.json([]); + if (url.includes("/issues?")) { + return new Response("secondary rate limit", { + status: 403, + headers: { + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": "1780000000", + }, + }); + } + return new Response("not found", { status: 404 }); + }); + + const result = await backfillRegisteredRepositories(env, { force: true }); + + expect(result.repos[0]).toMatchObject({ status: "rate_limited", dataQuality: { rateLimited: true } }); + expect(await listRepoSyncStates(env)).toMatchObject([{ status: "rate_limited" }]); + expect(await listRepoSyncSegments(env, "JSONbored/gittensory")).toEqual( + expect.arrayContaining([expect.objectContaining({ segment: "open_issues", status: "rate_limited", rateLimitResetAt: "2026-05-28T20:26:40.000Z" })]), + ); + expect(await listLatestGitHubRateLimitObservations(env)).toEqual( + expect.arrayContaining([expect.objectContaining({ repoFullName: "JSONbored/gittensory", resource: "rest", remaining: 0, statusCode: 403 })]), + ); + }); + + it("queues resumable repo segments without wiping previous usable counts", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITHUB_PUBLIC_TOKEN: "public-token", + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + await seedRegisteredRepo(env); + await upsertRepoSyncState(env, { + repoFullName: "JSONbored/gittensory", + status: "success", + sourceKind: "github", + openIssuesCount: 1100, + openPullRequestsCount: 167, + recentMergedPullRequestsCount: 200, + lastCompletedAt: "2026-05-24T00:00:00.000Z", + warnings: [], + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString() === "https://api.github.com/graphql") return githubTotalsResponse({ openIssues: 2911, openPullRequests: 167, mergedPullRequests: 6411, closedPullRequests: 776, labels: 2 }); + return new Response("unexpected", { status: 500 }); + }); + + const result = await enqueueRepositoryOpenDataBackfill(env, { repoFullName: "JSONbored/gittensory", requestedBy: "api", mode: "resume", force: true }); + + expect(result).toMatchObject({ status: "queued", totals: { openIssuesTotal: 2911, openPullRequestsTotal: 167 } }); + expect(await listRepoSyncStates(env)).toMatchObject([{ status: "running", openIssuesCount: 1100, openPullRequestsCount: 167, lastCompletedAt: "2026-05-24T00:00:00.000Z" }]); + expect(sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "backfill-repo-segment", repoFullName: "JSONbored/gittensory", segment: "open_issues", mode: "resume", force: true }), + expect.objectContaining({ type: "backfill-repo-segment", repoFullName: "JSONbored/gittensory", segment: "open_pull_requests", mode: "resume", force: true }), + ]), + ); + }); + + it("drains open issue segments against GitHub totals without counting PR rows from /issues", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") return githubTotalsResponse({ openIssues: 2, openPullRequests: 0, mergedPullRequests: 0, closedPullRequests: 0, labels: 0 }); + if (url.includes("/issues?") && new URL(url).searchParams.get("page") === "1") { + return Response.json( + [ + { number: 1, title: "Real issue", state: "open", user: { login: "reporter" }, labels: [], body: "" }, + { number: 10, title: "PR surfaced through issues API", state: "open", user: { login: "contributor" }, labels: [], body: "", pull_request: {} }, + ], + { headers: { link: '; rel="next"' } }, + ); + } + if (url.includes("/issues?") && new URL(url).searchParams.get("page") === "2") { + return Response.json([{ number: 2, title: "Second real issue", state: "open", user: { login: "reporter" }, labels: [], body: "" }]); + } + return new Response("not found", { status: 404 }); + }); + + const result = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_issues", mode: "full", force: true }); + + expect(result).toMatchObject({ status: "complete", fetchedCount: 2, expectedCount: 2 }); + expect((await listIssues(env, "JSONbored/gittensory")).map((issue) => issue.number)).toEqual([1, 2]); + expect(await listLatestRepoGithubTotalsSnapshots(env)).toMatchObject([{ repoFullName: "JSONbored/gittensory", openIssuesTotal: 2 }]); + expect(await listRepoSyncSegments(env, "JSONbored/gittensory")).toEqual( + expect.arrayContaining([expect.objectContaining({ segment: "open_issues", status: "complete", fetchedCount: 2, expectedCount: 2 })]), + ); + }); + + it("supplements REST open issue undercounts from GitHub GraphQL before marking completeness", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") { + const query = JSON.parse(String(init?.body ?? "{}")).query as string; + if (query.includes("GittensoryOpenIssuesSupplement")) { + if (query.includes("after:")) { + return Response.json({ + data: { + repository: { + issues: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [{ number: 3, state: "OPEN", labels: { nodes: [null] } }, null], + }, + }, + }, + }); + } + return Response.json({ + data: { + repository: { + issues: { + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + nodes: [ + { number: 1, title: "REST issue", state: "OPEN", url: "https://github.com/owner/repo/issues/1", labels: { nodes: [] } }, + { title: "No number", state: "OPEN", labels: { nodes: [] } }, + { + number: 2, + title: "GraphQL-only issue", + state: "OPEN", + url: "https://github.com/owner/repo/issues/2", + body: "GraphQL supplement", + author: { login: "reporter" }, + authorAssociation: "NONE", + labels: { nodes: [{ name: "bug" }] }, + }, + ], + }, + }, + rateLimit: { remaining: 4999, resetAt: "2026-05-25T16:00:00Z" }, + }, + }); + } + return githubTotalsResponse({ openIssues: 3, openPullRequests: 0, mergedPullRequests: 0, closedPullRequests: 0, labels: 0 }); + } + if (url.includes("/issues?")) return Response.json([{ number: 1, title: "REST issue", state: "open", user: { login: "reporter" }, labels: [], body: "" }]); + return Response.json([]); + }); + + const result = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_issues", mode: "full", force: true }); + + expect(result).toMatchObject({ status: "complete", fetchedCount: 3, expectedCount: 3 }); + expect(result.warnings).toEqual(expect.arrayContaining([expect.stringContaining("Supplemented 2 open issue")])); + expect(await listIssues(env, "JSONbored/gittensory")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ number: 2, title: "GraphQL-only issue", labels: ["bug"] }), + expect.objectContaining({ number: 3, title: "Issue #3", labels: [] }), + ]), + ); + }); + + it("keeps open issue segment partial when the GraphQL supplement fails", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") { + const query = JSON.parse(String(init?.body ?? "{}")).query as string; + if (query.includes("GittensoryOpenIssuesSupplement")) return new Response("graphql down", { status: 502 }); + return githubTotalsResponse({ openIssues: 2, openPullRequests: 0, mergedPullRequests: 0, closedPullRequests: 0, labels: 0 }); + } + if (url.includes("/issues?")) return Response.json([{ number: 1, title: "REST issue", state: "open", user: { login: "reporter" }, labels: [], body: "" }]); + return Response.json([]); + }); + + const result = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_issues", mode: "full", force: true }); + + expect(result).toMatchObject({ status: "partial", fetchedCount: 1, expectedCount: 2 }); + expect(result.warnings).toEqual(expect.arrayContaining([expect.stringContaining("GitHub GraphQL supplement failed")])); + }); + + it("keeps open issue segment partial when GraphQL supplement has no missing nodes", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") { + const query = JSON.parse(String(init?.body ?? "{}")).query as string; + if (query.includes("GittensoryOpenIssuesSupplement")) { + return Response.json({ + data: { + repository: { + issues: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [{ number: 1, title: "REST issue", state: "OPEN", labels: { nodes: [] } }], + }, + }, + }, + }); + } + return githubTotalsResponse({ openIssues: 2, openPullRequests: 0, mergedPullRequests: 0, closedPullRequests: 0, labels: 0 }); + } + if (url.includes("/issues?")) return Response.json([{ number: 1, title: "REST issue", state: "open", user: { login: "reporter" }, labels: [], body: "" }]); + return Response.json([]); + }); + + const result = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_issues", mode: "full", force: true }); + + expect(result).toMatchObject({ status: "partial", fetchedCount: 1, expectedCount: 2 }); + expect(result.warnings.join("\n")).not.toContain("Supplemented"); + }); + + it("reports fetched open issue count from persisted rows so repeated segment jobs cannot double count", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") return githubTotalsResponse({ openIssues: 1, openPullRequests: 0, mergedPullRequests: 0, closedPullRequests: 0, labels: 0 }); + if (url.includes("/issues?")) return Response.json([{ number: 1, title: "Stable issue", state: "open", user: { login: "reporter" }, labels: [], body: "" }]); + return Response.json([]); + }); + + await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_issues", mode: "full", force: true }); + const repeated = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_issues", mode: "resume", cursor: "1", force: true }); + + expect(repeated).toMatchObject({ status: "complete", fetchedCount: 1, expectedCount: 1 }); + expect(await listIssues(env, "JSONbored/gittensory")).toHaveLength(1); + }); + + it("reconciles stale open rows after a complete current open-data crawl", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + await upsertPullRequestFromGitHub( + env, + "JSONbored/gittensory", + { + number: 99, + title: "Stale open PR", + state: "open", + user: { login: "oktofeesh1" }, + head: { sha: "stale" }, + labels: [], + body: "", + }, + { seenOpenAt: "2026-01-01T00:00:00.000Z" }, + ); + await upsertIssueFromGitHub( + env, + "JSONbored/gittensory", + { + number: 88, + title: "Stale open issue", + state: "open", + user: { login: "reporter" }, + labels: [], + body: "", + }, + { seenOpenAt: "2026-01-01T00:00:00.000Z" }, + ); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") return githubTotalsResponse({ openIssues: 1, openPullRequests: 1, mergedPullRequests: 0, closedPullRequests: 0, labels: 0 }); + if (url.includes("/pulls?state=open")) { + expect(new URL(url).searchParams.get("sort")).toBe("created"); + return Response.json([{ number: 1, title: "Current PR", state: "open", user: { login: "oktofeesh1" }, head: { sha: "current" }, labels: [], body: "" }]); + } + if (url.includes("/issues?")) { + expect(new URL(url).searchParams.get("sort")).toBe("created"); + return Response.json([{ number: 2, title: "Current issue", state: "open", user: { login: "reporter" }, labels: [], body: "" }]); + } + return Response.json([]); + }); + + const prs = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_pull_requests", mode: "full", force: true }); + const issues = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_issues", mode: "full", force: true }); + + expect(prs).toMatchObject({ status: "complete", fetchedCount: 1, expectedCount: 1 }); + expect(issues).toMatchObject({ status: "complete", fetchedCount: 1, expectedCount: 1 }); + expect(await listPullRequests(env, "JSONbored/gittensory")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ number: 1, state: "open" }), + expect.objectContaining({ number: 99, state: "closed" }), + ]), + ); + expect(await listIssues(env, "JSONbored/gittensory")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ number: 2, state: "open" }), + expect.objectContaining({ number: 88, state: "closed" }), + ]), + ); + }); + + it("restarts old unmarked open-data resumes before current-open reconciliation", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + await upsertRepoSyncSegment(env, { + repoFullName: "JSONbored/gittensory", + segment: "open_issues", + status: "waiting_rate_limit", + sourceKind: "github", + mode: "resume", + fetchedCount: 2911, + expectedCount: 2912, + pageCount: 10, + lastCursor: "20", + nextCursor: "21", + startedAt: "2026-05-25T14:00:00.000Z", + warnings: [], + }); + const seenPages: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") return githubTotalsResponse({ openIssues: 1, openPullRequests: 0, mergedPullRequests: 0, closedPullRequests: 0, labels: 0 }); + if (url.includes("/issues?")) { + seenPages.push(new URL(url).searchParams.get("page") ?? ""); + return Response.json([{ number: 1, title: "Current issue", state: "open", user: { login: "reporter" }, labels: [], body: "" }]); + } + return Response.json([]); + }); + + const result = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_issues", mode: "resume", force: true }); + + expect(seenPages).toEqual(["1"]); + expect(result).toMatchObject({ status: "complete", fetchedCount: 1, expectedCount: 1 }); + }); + + it("resumes marked current-open scans from the stored or explicit cursor", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + await upsertRepoSyncSegment(env, { + repoFullName: "JSONbored/gittensory", + segment: "open_issues", + status: "running", + sourceKind: "github", + mode: "resume", + fetchedCount: 0, + expectedCount: 1, + pageCount: 1, + lastCursor: "1", + nextCursor: "2", + startedAt: "2026-05-25T14:00:00.000Z", + etag: "gittensory-current-open-scan-v1", + warnings: [], + }); + const seenPages: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") return githubTotalsResponse({ openIssues: 1, openPullRequests: 0, mergedPullRequests: 0, closedPullRequests: 0, labels: 0 }); + if (url.includes("/issues?")) { + seenPages.push(new URL(url).searchParams.get("page") ?? ""); + return Response.json([{ number: 2, title: "Current issue", state: "open", user: { login: "reporter" }, labels: [], body: "" }]); + } + return Response.json([]); + }); + + const storedCursor = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_issues", mode: "resume", force: true }); + await upsertRepoSyncSegment(env, { + repoFullName: "JSONbored/gittensory", + segment: "open_issues", + status: "running", + sourceKind: "github", + mode: "resume", + fetchedCount: 0, + expectedCount: 1, + pageCount: 1, + nextCursor: "2", + startedAt: "2026-05-25T14:00:00.000Z", + etag: "gittensory-current-open-scan-v1", + warnings: [], + }); + const explicitCursor = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_issues", mode: "resume", cursor: "3", force: true }); + + expect(seenPages).toEqual(["2", "3"]); + expect(storedCursor).toMatchObject({ status: "complete", fetchedCount: 1, expectedCount: 1 }); + expect(explicitCursor).toMatchObject({ status: "complete", fetchedCount: 1, expectedCount: 1 }); + }); + + it("handles segment skips, disabled repo settings, and low rate-limit requeue without discarding prior cursors", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITHUB_PUBLIC_TOKEN: "public-token", + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + await seedRegisteredRepo(env); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSignalLevel: "standard", + checkRunMode: "enabled", + checkRunDetailLevel: "standard", + backfillEnabled: false, + privateTrustEnabled: true, + }); + + await expect(enqueueRepositoryOpenDataBackfill(env, { repoFullName: "missing/repo", requestedBy: "api" })).resolves.toMatchObject({ status: "skipped" }); + await expect(enqueueRepositoryOpenDataBackfill(env, { repoFullName: "JSONbored/gittensory", requestedBy: "api" })).resolves.toMatchObject({ status: "skipped" }); + + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSignalLevel: "standard", + checkRunMode: "enabled", + checkRunDetailLevel: "standard", + backfillEnabled: true, + privateTrustEnabled: true, + }); + await upsertRepoSyncSegment(env, { + repoFullName: "JSONbored/gittensory", + segment: "open_issues", + status: "complete", + sourceKind: "github", + mode: "full", + fetchedCount: 2911, + expectedCount: 2911, + pageCount: 30, + lastCursor: "30", + completedAt: "2026-05-24T00:00:00.000Z", + warnings: [], + }); + await recordGitHubRateLimitObservation(env, { + repoFullName: "JSONbored/gittensory", + resource: "rest", + path: "/issues", + statusCode: 200, + remaining: 1, + resetAt: "2999-01-01T00:00:00.000Z", + }); + + const waiting = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_issues", requestedBy: "schedule", mode: "resume", cursor: "31" }); + + expect(waiting).toMatchObject({ status: "waiting_rate_limit", fetchedCount: 2911, expectedCount: 2911, nextCursor: "31" }); + expect(sent).toEqual(expect.arrayContaining([expect.objectContaining({ type: "backfill-repo-segment", requestedBy: "schedule", segment: "open_issues", mode: "resume" })])); + }); + + it("requeues incomplete required segments and starts PR detail hydration after open PR coverage completes", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITHUB_PUBLIC_TOKEN: "public-token", + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") return githubTotalsResponse({ openIssues: 0, openPullRequests: 1, mergedPullRequests: 0, closedPullRequests: 0, labels: 300 }); + if (url.includes("/labels?") && ["1", "2"].includes(new URL(url).searchParams.get("page") ?? "")) { + const page = Number(new URL(url).searchParams.get("page") ?? "1"); + return Response.json( + Array.from({ length: 100 }, (_, index) => ({ name: `label-${page}-${index}`, color: "cccccc" })), + { headers: { link: `; rel="next"` } }, + ); + } + if (url.includes("/pulls?state=open")) { + return Response.json([{ number: 10, title: "Open PR", state: "open", user: { login: "oktofeesh1" }, head: { sha: "abc" }, labels: [], body: "" }]); + } + return Response.json([]); + }); + + const labels = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "labels", requestedBy: "api", mode: "light" }); + const openPrs = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_pull_requests", requestedBy: "api", mode: "full" }); + + expect(labels).toMatchObject({ status: "running", fetchedCount: 200, expectedCount: 300 }); + expect(openPrs).toMatchObject({ status: "complete", fetchedCount: 1, expectedCount: 1 }); + expect(sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "backfill-repo-segment", segment: "labels", mode: "resume" }), + expect.objectContaining({ type: "backfill-pr-details", repoFullName: "JSONbored/gittensory", mode: "resume", cursor: 0 }), + ]), + ); + }); + + it("treats large historical merged PR segments as sampled instead of blocking open-data readiness", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") return githubTotalsResponse({ openIssues: 0, openPullRequests: 0, mergedPullRequests: 2000, closedPullRequests: 0, labels: 0 }); + if (/\/pulls\/\d+\/files/.test(url)) return Response.json([]); + if (url.includes("/pulls?state=closed")) { + const page = Number(new URL(url).searchParams.get("page") ?? "1"); + return Response.json( + [{ number: page, title: `Merged ${page}`, state: "closed", merged_at: "2026-05-20T00:00:00.000Z", user: { login: "oktofeesh1" }, labels: [], body: "" }], + { headers: { link: `; rel="next"` } }, + ); + } + return Response.json([]); + }); + + const result = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "recent_merged_pull_requests", mode: "full" }); + + expect(result).toMatchObject({ status: "sampled", fetchedCount: 10, expectedCount: 2000 }); + expect(await listRecentMergedPullRequests(env, "JSONbored/gittensory")).toHaveLength(10); + }); + + it("hydrates open PR details in small batches and records partial detail failures", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITHUB_PUBLIC_TOKEN: "public-token", + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + await seedRegisteredRepo(env); + for (let number = 1; number <= 13; number += 1) { + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + number, + title: `PR ${number}`, + state: "open", + user: { login: "oktofeesh1" }, + head: { sha: `sha-${number}` }, + labels: [], + body: "", + }); + } + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/pulls/5/reviews")) return new Response("review failure", { status: 503 }); + if (url.includes("/pulls/") && url.includes("/files")) return Response.json([{ filename: "src/file.ts", status: "modified", additions: 1, deletions: 0, changes: 1 }]); + if (url.includes("/pulls/") && url.includes("/reviews")) return Response.json([]); + if (url.includes("/commits/") && url.includes("/check-runs")) return Response.json({ check_runs: [] }); + return new Response("not found", { status: 404 }); + }); + + const firstBatch = await backfillOpenPullRequestDetails(env, { repoFullName: "JSONbored/gittensory", mode: "light", cursor: 0 }); + const secondBatch = await backfillOpenPullRequestDetails(env, { repoFullName: "JSONbored/gittensory", mode: "light", cursor: 12 }); + + expect(firstBatch).toMatchObject({ status: "running", processed: 12, nextCursor: 0 }); + expect(secondBatch.status).toBe("partial"); + expect(secondBatch.processed).toBeGreaterThanOrEqual(2); + expect(sent).toEqual(expect.arrayContaining([expect.objectContaining({ type: "backfill-pr-details", cursor: 0 })])); + expect(await listPullRequestDetailSyncStates(env, "JSONbored/gittensory")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ pullNumber: 1, status: "complete" }), + expect.objectContaining({ pullNumber: 5, status: "partial", errorSummary: expect.stringContaining("Review sync failed") }), + expect.objectContaining({ pullNumber: 13, status: "complete" }), + ]), + ); + expect(await listRepoSyncSegments(env, "JSONbored/gittensory")).toEqual( + expect.arrayContaining([expect.objectContaining({ segment: "pull_request_files", status: "partial", expectedCount: 13 })]), + ); + }); + + it("records segment partial, hard error, and GitHub rate-limit states from paged fetches", async () => { + for (const [mode, responseStatus] of [ + ["partial-after-page", 500], + ["hard-error", 500], + ["github-rate-limit", 403], + ] as const) { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") return githubTotalsResponse({ openIssues: 2, openPullRequests: 0, mergedPullRequests: 0, closedPullRequests: 0, labels: 0 }); + if (url.includes("/issues?") && mode === "partial-after-page" && new URL(url).searchParams.get("page") === "1") { + return Response.json([{ number: 1, title: "Issue 1", state: "open", user: { login: "reporter" }, labels: [], body: "" }], { + headers: { link: '; rel="next"' }, + }); + } + if (url.includes("/issues?")) { + return new Response(mode, { + status: responseStatus, + headers: + responseStatus === 403 + ? { + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": "1780000000", + } + : {}, + }); + } + return new Response("not found", { status: 404 }); + }); + + const result = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "open_issues", mode: "full", force: true }); + + if (mode === "partial-after-page") expect(result).toMatchObject({ status: "partial", fetchedCount: 1 }); + if (mode === "hard-error") expect(result).toMatchObject({ status: "error", fetchedCount: 0 }); + if (mode === "github-rate-limit") expect(result).toMatchObject({ status: "waiting_rate_limit", fetchedCount: 0 }); + } + }); + + it("backs off PR detail hydration under low REST rate limit", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITHUB_PUBLIC_TOKEN: "public-token", + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + await seedRegisteredRepo(env); + await recordGitHubRateLimitObservation(env, { + repoFullName: "JSONbored/gittensory", + resource: "rest", + path: "/pulls/1/files", + statusCode: 200, + remaining: 0, + resetAt: "2999-01-01T00:00:00.000Z", + }); + + const result = await backfillOpenPullRequestDetails(env, { repoFullName: "JSONbored/gittensory", mode: "resume", cursor: 4 }); + + expect(result).toMatchObject({ status: "waiting_rate_limit", processed: 0 }); + expect(sent).toEqual(expect.arrayContaining([expect.objectContaining({ type: "backfill-pr-details", mode: "resume", cursor: 4 })])); + expect(await listRepoSyncSegments(env, "JSONbored/gittensory")).toEqual( + expect.arrayContaining([expect.objectContaining({ segment: "pull_request_files", status: "waiting_rate_limit" })]), + ); + }); +}); + +async function seedRegisteredRepo(env: Env) { + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "JSONbored/gittensory": { + emission_share: 0.01, + issue_discovery_share: 0, + trusted_label_pipeline: true, + label_multipliers: { bug: 1.1, refactor: 0.5 }, + }, + }, + { kind: "raw-github", url: "https://example.test/master_repositories.json" }, + "2026-05-23T00:00:00.000Z", + ), + ); +} + +async function generatePrivateKeyPem(): Promise { + const key = (await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + const exported = await crypto.subtle.exportKey("pkcs8", key.privateKey); + const base64 = Buffer.from(exported as ArrayBuffer).toString("base64").replace(/(.{64})/g, "$1\n"); + return `-----BEGIN PRIVATE KEY-----\n${base64}\n-----END PRIVATE KEY-----`; +} + +function githubTotalsResponse(counts: { openIssues: number; openPullRequests: number; mergedPullRequests: number; closedPullRequests: number; labels: number }) { + return Response.json({ + data: { + rateLimit: { remaining: 4999, resetAt: "2026-05-25T01:00:00.000Z" }, + repository: { + issues: { totalCount: counts.openIssues }, + openPullRequests: { totalCount: counts.openPullRequests }, + mergedPullRequests: { totalCount: counts.mergedPullRequests }, + closedPullRequests: { totalCount: counts.closedPullRequests }, + labels: { totalCount: counts.labels }, + }, + }, + }); +} diff --git a/test/unit/crypto.test.ts b/test/unit/crypto.test.ts index ee687ab412..76ea5d8129 100644 --- a/test/unit/crypto.test.ts +++ b/test/unit/crypto.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { createOpaqueToken, hashToken, timingSafeEqual } from "../../src/auth/security"; import { verifyGitHubSignature } from "../../src/utils/crypto"; describe("webhook signature verification", () => { @@ -13,5 +14,19 @@ describe("webhook signature verification", () => { await expect(verifyGitHubSignature(body, `sha256=${signature}`, secret)).resolves.toBe(true); await expect(verifyGitHubSignature(`${body}x`, `sha256=${signature}`, secret)).resolves.toBe(false); + await expect(verifyGitHubSignature(body, null, secret)).resolves.toBe(false); + await expect(verifyGitHubSignature(body, "bad-prefix", secret)).resolves.toBe(false); + await expect(verifyGitHubSignature(body, `sha256=${signature}`, "")).resolves.toBe(false); + }); + + it("uses timing-safe token comparisons and one-way token hashes", async () => { + await expect(timingSafeEqual("token-a", "token-a")).resolves.toBe(true); + await expect(timingSafeEqual("token-a", "token-b")).resolves.toBe(false); + await expect(timingSafeEqual("token-a", undefined)).resolves.toBe(false); + await expect(hashToken("token-a")).resolves.toMatch(/^[0-9a-f]{64}$/); + + const token = createOpaqueToken(); + expect(token).toMatch(/^gts_[0-9a-f]{64}$/); + expect(token).not.toContain("token-a"); }); }); diff --git a/test/unit/data-quality.test.ts b/test/unit/data-quality.test.ts new file mode 100644 index 0000000000..d3b6d045e3 --- /dev/null +++ b/test/unit/data-quality.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, it } from "vitest"; +import { buildCoreSignalFidelity, buildRepoDataQuality, buildSignalFidelity } from "../../src/signals/data-quality"; +import type { PullRequestDetailSyncStateRecord, RepoGithubTotalsSnapshotRecord, RepoSyncSegmentRecord, RepoSyncStateRecord } from "../../src/types"; + +describe("sync data quality", () => { + it("marks capped and partial segments as degraded instead of complete", () => { + const state = repoState({ status: "capped", warnings: ["GitHub sync reached local cap of 100 item(s)."] }); + const quality = buildRepoDataQuality("owner/repo", state, [ + segment({ segment: "open_pull_requests", status: "capped", fetchedCount: 100, nextCursor: "2" }), + segment({ segment: "labels", status: "complete", fetchedCount: 12 }), + ]); + + expect(quality).toMatchObject({ + status: "degraded", + capped: true, + partial: true, + cappedSegments: ["open_pull_requests"], + warnings: expect.arrayContaining([expect.stringContaining("pagination cap")]), + }); + }); + + it("distinguishes blocked rate-limited repo fidelity from global service readiness", () => { + const states = [repoState({ repoFullName: "owner/repo", status: "rate_limited" })]; + const segments = [segment({ repoFullName: "owner/repo", segment: "open_issues", status: "rate_limited", rateLimitResetAt: "2026-05-27T00:00:00.000Z" })]; + + expect(buildSignalFidelity(1, states, segments)).toMatchObject({ + status: "blocked", + repoCount: 1, + blockedRepos: 1, + rateLimitedRepos: ["owner/repo"], + nextRecoverableAt: "2026-05-27T00:00:00.000Z", + }); + }); + + it("reports missing registered repo sync state as degraded fidelity", () => { + expect(buildSignalFidelity(2, [repoState({ repoFullName: "owner/synced", status: "success" })], [])).toMatchObject({ + status: "degraded", + completeRepos: 1, + degradedRepos: 1, + }); + }); + + it("marks missing repo sync state as unknown at repo level", () => { + expect(buildRepoDataQuality("owner/missing", null, [])).toMatchObject({ + status: "unknown", + partial: false, + capped: false, + rateLimited: false, + warnings: ["No repository sync state is available for owner/missing."], + }); + }); + + it("keeps complete and not-modified segments as complete freshness", () => { + const quality = buildRepoDataQuality("owner/repo", repoState(), [ + segment({ segment: "metadata", status: "not_modified", fetchedCount: 1 }), + segment({ segment: "labels", status: "complete", fetchedCount: 8 }), + ]); + + expect(quality).toMatchObject({ + status: "complete", + partial: false, + stale: false, + incompleteSegments: [], + segmentCount: 2, + }); + }); + + it("does not carry historical sync errors into complete repo warnings", () => { + const quality = buildRepoDataQuality("owner/repo", repoState({ status: "success", errorSummary: "old rate limit", warnings: ["old rate limit warning"] }), [ + segment({ segment: "metadata", status: "complete", fetchedCount: 1, expectedCount: 1 }), + segment({ segment: "open_issues", status: "complete", fetchedCount: 10, expectedCount: 10 }), + ]); + + expect(quality.status).toBe("complete"); + expect(quality.warnings).toEqual([]); + }); + + it("marks old sync completion timestamps as stale", () => { + const quality = buildRepoDataQuality( + "owner/repo", + repoState({ lastCompletedAt: "2026-05-01T00:00:00.000Z" }), + [segment({ segment: "open_issues", completedAt: "2026-05-01T00:00:00.000Z" })], + { nowMs: Date.parse("2026-05-25T00:00:00.000Z") }, + ); + + expect(quality).toMatchObject({ + status: "degraded", + stale: true, + staleSegments: ["open_issues"], + warnings: expect.arrayContaining([expect.stringContaining("stale")]), + }); + }); + + it("treats explicit stale segment status as stale even without an old timestamp", () => { + const quality = buildRepoDataQuality( + "owner/repo", + repoState(), + [segment({ segment: "open_pull_requests", status: "stale", completedAt: "2026-05-25T00:00:00.000Z" })], + { nowMs: Date.parse("2026-05-25T00:01:00.000Z") }, + ); + + expect(quality).toMatchObject({ + status: "degraded", + stale: true, + staleSegments: ["open_pull_requests"], + }); + }); + + it("uses state warnings to expose cap and rate-limit risk even without segment rows", () => { + const quality = buildRepoDataQuality( + "owner/repo", + repoState({ warnings: ["GitHub sync reached local cap.", "GitHub secondary rate limit observed."] }), + [], + ); + + expect(quality).toMatchObject({ + status: "degraded", + capped: true, + rateLimited: true, + warnings: expect.arrayContaining([ + "GitHub sync reached local cap.", + "GitHub secondary rate limit observed.", + expect.stringContaining("GitHub rate limiting"), + ]), + }); + }); + + it("returns unknown signal fidelity when no registered repo data exists yet", () => { + expect(buildSignalFidelity(0, [], [])).toMatchObject({ + status: "unknown", + repoCount: 0, + completeRepos: 0, + degradedRepos: 0, + blockedRepos: 0, + }); + }); + + it("uses the earliest recoverable rate-limit reset across segments", () => { + expect( + buildSignalFidelity( + 2, + [repoState({ repoFullName: "owner/a", status: "rate_limited" }), repoState({ repoFullName: "owner/b", status: "rate_limited" })], + [ + segment({ repoFullName: "owner/a", status: "rate_limited", rateLimitResetAt: "2026-05-27T12:00:00.000Z" }), + segment({ repoFullName: "owner/b", status: "rate_limited", rateLimitResetAt: "2026-05-27T06:00:00.000Z" }), + ], + ), + ).toMatchObject({ + status: "blocked", + nextRecoverableAt: "2026-05-27T06:00:00.000Z", + rateLimitedRepos: ["owner/a", "owner/b"], + }); + }); + + it("does not report stale recoverable times from completed segments", () => { + const fidelity = buildSignalFidelity( + 1, + [repoState({ repoFullName: "owner/recovered", status: "success" })], + [segment({ repoFullName: "owner/recovered", status: "complete", rateLimitResetAt: "2026-05-27T00:00:00.000Z" })], + ); + + expect(fidelity.status).toBe("complete"); + expect(fidelity.nextRecoverableAt).toBeUndefined(); + }); + + it("does not block repo fidelity when a rate-limited segment already has complete stored coverage", () => { + const recoveredSegment = segment({ + repoFullName: "owner/recovered", + segment: "recent_merged_pull_requests", + status: "waiting_rate_limit", + fetchedCount: 33, + expectedCount: 33, + rateLimitResetAt: "2026-05-27T00:00:00.000Z", + }); + + expect(buildRepoDataQuality("owner/recovered", repoState({ repoFullName: "owner/recovered" }), [recoveredSegment])).toMatchObject({ + status: "complete", + partial: false, + rateLimited: false, + incompleteSegments: [], + rateLimitedSegments: [], + }); + expect(buildSignalFidelity(1, [repoState({ repoFullName: "owner/recovered" })], [recoveredSegment])).toMatchObject({ + status: "complete", + blockedRepos: 0, + rateLimitedRepos: [], + nextRecoverableAt: undefined, + }); + }); + + it("keeps incomplete waiting-rate-limit segments blocked until coverage catches up", () => { + const waitingSegment = segment({ + repoFullName: "owner/waiting", + segment: "open_issues", + status: "waiting_rate_limit", + fetchedCount: 9, + expectedCount: 10, + rateLimitResetAt: "2026-05-27T00:00:00.000Z", + }); + + expect(buildRepoDataQuality("owner/waiting", repoState({ repoFullName: "owner/waiting" }), [waitingSegment])).toMatchObject({ + status: "blocked", + partial: true, + rateLimited: true, + incompleteSegments: ["open_issues"], + rateLimitedSegments: ["open_issues"], + }); + expect(buildSignalFidelity(1, [repoState({ repoFullName: "owner/waiting" })], [waitingSegment])).toMatchObject({ + status: "blocked", + blockedRepos: 1, + rateLimitedRepos: ["owner/waiting"], + nextRecoverableAt: "2026-05-27T00:00:00.000Z", + }); + }); + + it("requires authoritative open-data totals for core fidelity and treats history as sampled", () => { + const segments = [ + segment({ segment: "metadata", fetchedCount: 1, expectedCount: 1 }), + segment({ segment: "labels", fetchedCount: 2, expectedCount: 2 }), + segment({ segment: "open_issues", fetchedCount: 2911, expectedCount: 2911 }), + segment({ segment: "open_pull_requests", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "pull_request_files", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "pull_request_reviews", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "check_summaries", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "recent_merged_pull_requests", status: "sampled", fetchedCount: 200, expectedCount: 6411 }), + ]; + const detailStates = Array.from({ length: 167 }, (_, index) => detailState(index + 1)); + + expect(buildCoreSignalFidelity(1, [repoState()], segments, [totals()], detailStates)).toMatchObject({ + status: "complete", + completeRepos: 1, + incompleteRepos: [], + historyCoverage: "sampled", + }); + }); + + it("does not count a refreshing segment as degraded when last complete coverage is still usable", () => { + const segments = [ + segment({ segment: "metadata", fetchedCount: 1, expectedCount: 1 }), + segment({ segment: "labels", fetchedCount: 2, expectedCount: 2 }), + segment({ segment: "open_issues", status: "running", fetchedCount: 2911, expectedCount: 2911, completedAt: "2026-05-25T00:00:00.000Z" }), + segment({ segment: "open_pull_requests", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "pull_request_files", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "pull_request_reviews", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "check_summaries", fetchedCount: 167, expectedCount: 167 }), + ]; + const detailStates = Array.from({ length: 167 }, (_, index) => detailState(index + 1)); + + expect(buildCoreSignalFidelity(1, [repoState({ status: "running" })], segments, [totals()], detailStates)).toMatchObject({ + status: "complete", + completeRepos: 1, + refreshingRepos: ["owner/repo"], + incompleteRepos: [], + }); + }); + + it("marks core fidelity degraded when open issue fetch count is below GitHub totals", () => { + const segments = [ + segment({ segment: "metadata", fetchedCount: 1, expectedCount: 1 }), + segment({ segment: "labels", fetchedCount: 2, expectedCount: 2 }), + segment({ segment: "open_issues", fetchedCount: 1100, expectedCount: 2911 }), + segment({ segment: "open_pull_requests", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "pull_request_files", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "pull_request_reviews", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "check_summaries", fetchedCount: 167, expectedCount: 167 }), + ]; + const detailStates = Array.from({ length: 167 }, (_, index) => detailState(index + 1)); + + expect(buildCoreSignalFidelity(1, [repoState()], segments, [totals()], detailStates)).toMatchObject({ + status: "degraded", + incompleteRepos: ["owner/repo"], + degradedRepos: 1, + }); + }); + + it("separates blocked core fidelity from full historical coverage", () => { + const segments = [ + segment({ segment: "metadata", fetchedCount: 1, expectedCount: 1 }), + segment({ segment: "labels", fetchedCount: 2, expectedCount: 2 }), + segment({ segment: "open_issues", status: "waiting_rate_limit", fetchedCount: 2900, expectedCount: 2911, rateLimitResetAt: "2026-05-25T14:25:55.000Z" }), + segment({ segment: "open_pull_requests", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "pull_request_files", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "pull_request_reviews", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "check_summaries", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "recent_merged_pull_requests", fetchedCount: 6411, expectedCount: 6411 }), + ]; + + expect(buildCoreSignalFidelity(1, [repoState({ status: "rate_limited" })], segments, [totals()], [])).toMatchObject({ + status: "blocked", + blockedRepos: 1, + waitingForRateLimitRepos: ["owner/repo"], + incompleteRepos: ["owner/repo"], + historyCoverage: "full", + }); + }); + + it("keeps core fidelity complete when rate-limited required segments have last complete coverage", () => { + const segments = [ + segment({ segment: "metadata", fetchedCount: 1, expectedCount: 1 }), + segment({ segment: "labels", fetchedCount: 2, expectedCount: 2 }), + segment({ + segment: "open_issues", + status: "waiting_rate_limit", + fetchedCount: 2911, + expectedCount: 2911, + completedAt: "2026-05-25T00:00:00.000Z", + rateLimitResetAt: "2026-05-25T14:25:55.000Z", + }), + segment({ segment: "open_pull_requests", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "pull_request_files", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "pull_request_reviews", fetchedCount: 167, expectedCount: 167 }), + segment({ segment: "check_summaries", fetchedCount: 167, expectedCount: 167 }), + ]; + const detailStates = Array.from({ length: 167 }, (_, index) => detailState(index + 1)); + + expect(buildCoreSignalFidelity(1, [repoState({ status: "rate_limited" })], segments, [totals()], detailStates)).toMatchObject({ + status: "complete", + completeRepos: 1, + blockedRepos: 0, + incompleteRepos: [], + waitingForRateLimitRepos: [], + }); + }); + + it("returns unknown core fidelity before any repo signal exists", () => { + expect(buildCoreSignalFidelity(0, [], [], [], [])).toMatchObject({ + status: "unknown", + repoCount: 0, + completeRepos: 0, + historyCoverage: "counts_only", + }); + }); +}); + +function repoState(overrides: Partial = {}): RepoSyncStateRecord { + return { + repoFullName: "owner/repo", + status: "success", + sourceKind: "github", + openIssuesCount: 0, + openPullRequestsCount: 0, + recentMergedPullRequestsCount: 0, + lastCompletedAt: "2026-05-25T00:00:00.000Z", + warnings: [], + ...overrides, + }; +} + +function segment(overrides: Partial = {}): RepoSyncSegmentRecord { + return { + repoFullName: "owner/repo", + segment: "metadata", + status: "complete", + sourceKind: "github", + mode: "light", + fetchedCount: 1, + pageCount: 1, + completedAt: "2026-05-25T00:00:00.000Z", + warnings: [], + ...overrides, + }; +} + +function totals(overrides: Partial = {}): RepoGithubTotalsSnapshotRecord { + return { + id: "totals-owner-repo", + repoFullName: "owner/repo", + openIssuesTotal: 2911, + openPullRequestsTotal: 167, + mergedPullRequestsTotal: 6411, + closedUnmergedPullRequestsTotal: 776, + labelsTotal: 2, + sourceKind: "github", + fetchedAt: "2026-05-25T00:00:00.000Z", + payload: {}, + ...overrides, + }; +} + +function detailState(pullNumber: number, overrides: Partial = {}): PullRequestDetailSyncStateRecord { + return { + repoFullName: "owner/repo", + pullNumber, + status: "complete", + lastSyncedAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2026-05-25T00:00:00.000Z", + ...overrides, + }; +} diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts new file mode 100644 index 0000000000..147e99e67a --- /dev/null +++ b/test/unit/data-spine.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it } from "vitest"; +import { + getInstallationHealth, + getIssue, + getPullRequest, + getRepository, + getRepositorySettings, + getRepoSyncState, + listCheckSummaries, + listCollisionEdges, + listContributorIssues, + listContributorPullRequests, + listContributorRecentMergedPullRequests, + listContributorRepoStats, + listInstallationHealth, + listInstallations, + listIssueSignalSample, + listOpenIssues, + listPullRequestFiles, + listPullRequestReviews, + listRecentMergedPullRequests, + listRepoLabels, + listRepoSyncStates, + listSignalSnapshots, + countOpenIssues, + persistRepoSnapshot, + persistSignalSnapshot, + replaceCollisionEdges, + upsertCheckSummary, + upsertContributor, + upsertContributorRepoStat, + upsertInstallation, + upsertInstallationHealth, + upsertIssueFromGitHub, + upsertPullRequestFromGitHub, + upsertPullRequestFile, + upsertPullRequestReview, + upsertRecentMergedPullRequest, + upsertRepoLabel, + upsertRepoSyncState, + upsertRepositoryFromGitHub, +} from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +describe("data spine repositories", () => { + it("persists and reads sync, label, contributor, PR detail, collision, and installation records", async () => { + const env = createTestEnv(); + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + target_type: "User", + repository_selection: "selected", + permissions: { checks: "write", metadata: "read", pull_requests: "read" }, + events: ["issues", "pull_request", "repository"], + }, + }); + expect(await listInstallations(env)).toMatchObject([{ id: 123, accountLogin: "JSONbored" }]); + + await upsertRepoSyncState(env, { + repoFullName: "JSONbored/gittensory", + status: "partial", + sourceKind: "github", + primaryLanguage: "TypeScript", + defaultBranch: "main", + isPrivate: true, + openIssuesCount: 3, + openPullRequestsCount: 2, + recentMergedPullRequestsCount: 1, + labelsSyncedAt: "2026-05-23T00:00:00.000Z", + warnings: ["truncated"], + }); + expect(await getRepoSyncState(env, "JSONbored/gittensory")).toMatchObject({ status: "partial", warnings: ["truncated"] }); + expect(await listRepoSyncStates(env)).toHaveLength(1); + + await upsertRepoLabel(env, { + repoFullName: "JSONbored/gittensory", + name: "bug", + color: "cc0000", + description: "Bug", + isConfigured: true, + observedCount: 4, + payload: { name: "bug" }, + }); + expect(await listRepoLabels(env, "JSONbored/gittensory")).toMatchObject([{ name: "bug", isConfigured: true, observedCount: 4 }]); + + await persistRepoSnapshot(env, { + id: "snapshot-1", + repoFullName: "JSONbored/gittensory", + snapshotKind: "github-backfill", + sourceKind: "github", + fetchedAt: "2026-05-23T00:00:00.000Z", + primaryLanguage: "TypeScript", + defaultBranch: "main", + openIssuesCount: 3, + openPullRequestsCount: 2, + recentMergedPullRequestsCount: 1, + payload: { ok: true }, + }); + + await upsertPullRequestFile(env, { + repoFullName: "JSONbored/gittensory", + pullNumber: 5, + path: "src/index.ts", + status: "modified", + additions: 10, + deletions: 2, + changes: 12, + payload: { filename: "src/index.ts" }, + }); + await upsertPullRequestReview(env, { + id: "review-1", + repoFullName: "JSONbored/gittensory", + pullNumber: 5, + reviewerLogin: "maintainer", + state: "APPROVED", + submittedAt: "2026-05-23T00:00:00.000Z", + payload: { id: 1 }, + }); + await upsertCheckSummary(env, { + id: "check-1", + repoFullName: "JSONbored/gittensory", + pullNumber: 5, + headSha: "abc", + name: "test", + status: "completed", + conclusion: "success", + payload: { name: "test" }, + }); + expect(await listPullRequestFiles(env, "JSONbored/gittensory", 5)).toMatchObject([{ path: "src/index.ts", changes: 12 }]); + expect(await listPullRequestReviews(env, "JSONbored/gittensory", 5)).toMatchObject([{ reviewerLogin: "maintainer" }]); + expect(await listCheckSummaries(env, "JSONbored/gittensory", 5)).toMatchObject([{ name: "test", conclusion: "success" }]); + + await upsertRecentMergedPullRequest(env, { + repoFullName: "JSONbored/gittensory", + number: 4, + title: "Fix index handler", + authorLogin: "oktofeesh1", + mergedAt: "2026-05-22T00:00:00.000Z", + labels: ["bug"], + linkedIssues: [2], + changedFiles: ["src/index.ts"], + payload: { number: 4 }, + }); + expect(await listRecentMergedPullRequests(env, "JSONbored/gittensory")).toMatchObject([{ number: 4, linkedIssues: [2] }]); + + await upsertContributor(env, { + login: "oktofeesh1", + githubProfile: { login: "oktofeesh1" }, + topLanguages: ["TypeScript"], + publicRepos: 10, + followers: 2, + source: "github", + }); + await upsertContributorRepoStat(env, { + login: "oktofeesh1", + repoFullName: "JSONbored/gittensory", + pullRequests: 2, + mergedPullRequests: 1, + openPullRequests: 1, + issues: 3, + stalePullRequests: 0, + unlinkedPullRequests: 0, + dominantLabels: ["bug"], + lastActivityAt: "2026-05-23T00:00:00.000Z", + }); + expect(await listContributorRepoStats(env, "oktofeesh1")).toMatchObject([{ repoFullName: "JSONbored/gittensory", dominantLabels: ["bug"] }]); + expect(await listContributorRepoStats(env, "OKTOFEESH1")).toMatchObject([{ repoFullName: "JSONbored/gittensory", dominantLabels: ["bug"] }]); + expect(await listContributorRecentMergedPullRequests(env, "OKTOFEESH1")).toMatchObject([{ repoFullName: "JSONbored/gittensory", number: 4 }]); + await env.DB.prepare( + "insert into contributor_repo_stats (id, login, repo_full_name, pull_requests, merged_pull_requests, open_pull_requests, issues, stale_pull_requests, unlinked_pull_requests, dominant_labels_json, last_activity_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind("legacy-case-stat", "OKTOFEESH1", "JSONbored/gittensory", 9, 8, 1, 7, 0, 0, '["ci"]', "2026-05-24T00:00:00.000Z") + .run(); + expect(await listContributorRepoStats(env, "oktofeesh1")).toMatchObject([{ repoFullName: "JSONbored/gittensory", pullRequests: 9, mergedPullRequests: 8, issues: 7 }]); + + await replaceCollisionEdges(env, "JSONbored/gittensory", [ + { + id: "edge-1", + repoFullName: "JSONbored/gittensory", + leftType: "issue", + leftNumber: 2, + leftTitle: "Fix index handler", + rightType: "pull_request", + rightNumber: 5, + rightTitle: "Fix index handler", + risk: "high", + reason: "Same issue.", + sharedTerms: ["index", "handler"], + }, + ]); + expect(await listCollisionEdges(env, "JSONbored/gittensory")).toMatchObject([{ risk: "high", sharedTerms: ["index", "handler"] }]); + + await persistSignalSnapshot(env, { + id: "signal-1", + signalType: "queue-health", + targetKey: "JSONbored/gittensory", + repoFullName: "JSONbored/gittensory", + payload: { ok: true }, + }); + expect(await listSignalSnapshots(env, "queue-health", "JSONbored/gittensory")).toMatchObject([{ signalType: "queue-health" }]); + + await upsertInstallationHealth(env, { + installationId: 123, + accountLogin: "JSONbored", + repositorySelection: "selected", + installedReposCount: 1, + registeredInstalledCount: 1, + status: "healthy", + missingPermissions: [], + missingEvents: [], + permissions: { checks: "write" }, + events: ["issues", "pull_request", "repository"], + checkedAt: "2026-05-23T00:00:00.000Z", + }); + expect(await getInstallationHealth(env, 123)).toMatchObject({ status: "healthy" }); + expect(await listInstallationHealth(env)).toHaveLength(1); + }); + + it("keeps repository readers defensive around missing rows and unknown stored enum values", async () => { + const env = createTestEnv(); + await upsertInstallation(env, {}); + expect(await listInstallations(env)).toEqual([]); + + await upsertInstallation(env, { installation: { id: 999 } }); + expect(await listInstallations(env)).toMatchObject([{ id: 999, accountLogin: "unknown", accountId: 0, targetType: "unknown", permissions: {}, events: [] }]); + + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo" }); + expect(await getRepository(env, "owner/repo")).toMatchObject({ owner: "owner", isInstalled: false, isPrivate: false }); + expect(await getRepository(env, "OWNER/REPO")).toMatchObject({ fullName: "owner/repo" }); + expect(await getRepository(env, "missing/repo")).toBeNull(); + expect(await getRepositorySettings(env, "missing/repo")).toMatchObject({ commentMode: "off", checkRunDetailLevel: "standard" }); + expect(await getRepoSyncState(env, "missing/repo")).toBeNull(); + expect(await getPullRequest(env, "owner/repo", 404)).toBeNull(); + expect(await getIssue(env, "owner/repo", 404)).toBeNull(); + + await upsertRepoSyncState(env, { + repoFullName: "owner/repo", + status: "success", + sourceKind: "test", + openIssuesCount: 0, + openPullRequestsCount: 0, + recentMergedPullRequestsCount: 0, + warnings: [], + }); + await env.DB.prepare("update repo_sync_state set status = ?, source_kind = ? where repo_full_name = ?").bind("weird", "weird", "owner/repo").run(); + expect(await getRepoSyncState(env, "owner/repo")).toMatchObject({ status: "never_synced", sourceKind: "github" }); + + await replaceCollisionEdges(env, "owner/repo", [ + { + id: "edge-weird", + repoFullName: "owner/repo", + leftType: "pull_request", + leftNumber: 1, + leftTitle: "Left", + rightType: "recent_merged_pull_request", + rightNumber: 2, + rightTitle: "Right", + risk: "medium", + reason: "Medium overlap.", + sharedTerms: [], + }, + ]); + await env.DB.prepare("update collision_edges set left_type = ?, risk = ? where id = ?").bind("not-real", "not-real", "edge-weird").run(); + expect(await listCollisionEdges(env, "owner/repo")).toMatchObject([{ leftType: "issue", rightType: "recent_merged_pull_request", risk: "low" }]); + + await upsertInstallationHealth(env, { + installationId: 999, + accountLogin: "unknown", + repositorySelection: undefined, + installedReposCount: 0, + registeredInstalledCount: 0, + status: "broken", + missingPermissions: ["checks"], + missingEvents: ["pull_request"], + permissions: {}, + events: [], + checkedAt: "2026-05-23T00:00:00.000Z", + }); + await env.DB.prepare("update installation_health set status = ? where installation_id = ?").bind("weird", 999).run(); + expect(await getInstallationHealth(env, 999)).toMatchObject({ status: "needs_attention" }); + }); + + it("extracts linked issue and PR references while ignoring malformed labels", async () => { + const env = createTestEnv(); + await upsertPullRequestFromGitHub(env, "owner/repo", { + number: 1, + title: "Fix issue references", + state: "open", + user: { login: "JSONbored" }, + labels: [{}, { name: "bug" }], + body: "Fixes #10 and closes #bad and resolves #11", + }); + await upsertIssueFromGitHub(env, "owner/repo", { + number: 10, + title: "Issue with PR references", + state: "open", + user: { login: "JSONbored" }, + labels: [{}, { name: "bug" }], + body: "Related PR #1 and pull request #2.", + }); + await upsertIssueFromGitHub(env, "owner/repo", { + number: 11, + title: "Long issue body", + state: "open", + user: { login: "JSONbored" }, + labels: [], + body: "a".repeat(5000), + created_at: "2026-05-20T00:00:00.000Z", + updated_at: "2026-05-21T00:00:00.000Z", + }); + + expect(await getPullRequest(env, "owner/repo", 1)).toMatchObject({ labels: ["bug"], linkedIssues: [10, 11] }); + expect(await getIssue(env, "owner/repo", 10)).toMatchObject({ labels: ["bug"], linkedPrs: [1, 2] }); + expect((await getIssue(env, "owner/repo", 11))?.body).toHaveLength(4000); + expect(await countOpenIssues(env, "owner/repo")).toBe(2); + expect(await listOpenIssues(env, "owner/repo")).toEqual(expect.arrayContaining([expect.objectContaining({ number: 10 }), expect.objectContaining({ number: 11 })])); + expect(await listIssueSignalSample(env, "owner/repo", 1)).toHaveLength(1); + expect(await listContributorPullRequests(env, "jsonbored")).toMatchObject([{ repoFullName: "owner/repo", number: 1 }]); + expect(await listContributorIssues(env, "JSONBORED")).toEqual(expect.arrayContaining([expect.objectContaining({ repoFullName: "owner/repo", number: 10 }), expect.objectContaining({ repoFullName: "owner/repo", number: 11 })])); + }); +}); diff --git a/test/unit/decision-pack.test.ts b/test/unit/decision-pack.test.ts new file mode 100644 index 0000000000..a3f1dc3de6 --- /dev/null +++ b/test/unit/decision-pack.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; +import { persistSignalSnapshot } from "../../src/db/repositories"; +import { + __decisionPackInternals, + loadContributorDecisionPack, + loadFreshContributorDecisionPack, + repoDecisionFromPack, + type ContributorDecisionPack, + type RepoDecision, +} from "../../src/services/decision-pack"; +import { createTestEnv } from "../helpers/d1"; + +describe("decision-pack service", () => { + it("classifies score blockers, recommendations, actions, and explanations deterministically", () => { + const maintainerRole = { maintainerLane: true } as any; + const outsideRole = { maintainerLane: false } as any; + const pressureOutcome = { openPullRequests: 6, closedPullRequestRate: 0.4, credibility: 0.5, maintainerLane: false, mergedPullRequests: 2, closedPullRequests: 3, validSolvedIssues: 1 } as any; + const moderateOutcome = { openPullRequests: 3, closedPullRequestRate: 0.1, credibility: 1, maintainerLane: false, mergedPullRequests: 1, closedPullRequests: 0, validSolvedIssues: 0 } as any; + + expect(__decisionPackInternals.scoreBlockersFor("owner/repo", "inactive", maintainerRole, pressureOutcome).map((blocker) => blocker.code)).toEqual([ + "maintainer_lane", + "inactive_or_unknown_lane", + "open_pr_pressure", + "closed_pr_credibility", + "low_credibility", + ]); + expect(__decisionPackInternals.scoreBlockersFor("owner/issues", "issue_discovery", outsideRole, undefined).map((blocker) => blocker.code)).toEqual(["issue_discovery_only"]); + + expect(__decisionPackInternals.recommendationFor("direct_pr", maintainerRole, undefined, [])).toBe("maintainer_lane"); + expect(__decisionPackInternals.recommendationFor("direct_pr", outsideRole, pressureOutcome, [{ code: "open_pr_pressure", severity: "critical" } as any])).toBe("cleanup_first"); + expect(__decisionPackInternals.recommendationFor("inactive", outsideRole, undefined, [{ code: "inactive_or_unknown_lane", severity: "critical" } as any])).toBe("avoid_for_now"); + expect(__decisionPackInternals.recommendationFor("direct_pr", outsideRole, moderateOutcome, [])).toBe("cleanup_first"); + expect(__decisionPackInternals.recommendationFor("split", outsideRole, undefined, [])).toBe("pursue"); + expect(__decisionPackInternals.recommendationFor("issue_discovery", outsideRole, undefined, [])).toBe("watch"); + expect(__decisionPackInternals.recommendationFor("unknown", outsideRole, undefined, [])).toBe("avoid_for_now"); + + const baseDecision = (recommendation: RepoDecision["recommendation"], lane = "direct_pr", priorityScore = 42): RepoDecision => + ({ + repoFullName: "owner/repo", + recommendation, + priorityScore, + lane: { lane }, + whyThisHelps: [`${recommendation} helps`], + nextActions: [`${recommendation} next`], + }) as RepoDecision; + expect(__decisionPackInternals.actionsForDecision(baseDecision("maintainer_lane")).map((action) => action.actionKind)).toEqual(["maintainer_lane_improve_repo", "maintainer_cut_readiness"]); + expect(__decisionPackInternals.actionsForDecision(baseDecision("cleanup_first")).map((action) => action.actionKind)).toEqual(["cleanup_existing_prs", "land_existing_prs"]); + expect(__decisionPackInternals.actionsForDecision(baseDecision("pursue")).map((action) => action.actionKind)).toEqual(["open_new_direct_pr"]); + expect(__decisionPackInternals.actionsForDecision(baseDecision("watch", "issue_discovery")).map((action) => action.actionKind)).toEqual(["file_issue_discovery"]); + expect(__decisionPackInternals.actionsForDecision(baseDecision("avoid_for_now"))).toEqual([]); + + expect(__decisionPackInternals.whyThisHelpsFor("cleanup_first", "owner/repo", undefined, { directPrShare: 0.01 } as any)[0]).toMatch(/cleaning up/); + expect(__decisionPackInternals.whyThisHelpsFor("maintainer_lane", "owner/repo", undefined, { directPrShare: 0.01 } as any)[0]).toMatch(/maintainer-owned/); + expect(__decisionPackInternals.whyThisHelpsFor("pursue", "owner/repo", undefined, { directPrShare: 0.01234 } as any)[0]).toMatch(/0.0123/); + expect(__decisionPackInternals.whyThisHelpsFor("watch", "owner/repo", undefined, { directPrShare: 0.01 } as any)[0]).toMatch(/issue-discovery/); + expect(__decisionPackInternals.whyThisHelpsFor("avoid_for_now", "owner/repo", undefined, { directPrShare: 0.01 } as any)[0]).toMatch(/low/); + + expect(__decisionPackInternals.nextActionsFor("cleanup_first", "direct_pr")[0]).toMatch(/Close/); + expect(__decisionPackInternals.nextActionsFor("maintainer_lane", "direct_pr")[0]).toMatch(/intake/); + expect(__decisionPackInternals.nextActionsFor("pursue", "direct_pr")[0]).toMatch(/narrow/); + expect(__decisionPackInternals.nextActionsFor("watch", "issue_discovery")[0]).toMatch(/high-confidence/); + expect(__decisionPackInternals.nextActionsFor("avoid_for_now", "inactive")[0]).toMatch(/different repo/); + + expect(__decisionPackInternals.priorityFor("pursue", { directPrShare: 0.02, issueDiscoveryShare: 0, emissionShare: 0.02 } as any, moderateOutcome, { openPullRequests: 2 } as any, [])).toBeGreaterThan(0); + expect(__decisionPackInternals.priorityFor("avoid_for_now", { directPrShare: 0, issueDiscoveryShare: 0, emissionShare: 0 } as any, pressureOutcome, { openPullRequests: 500 } as any, [{ severity: "critical" } as any])).toBe(0); + expect( + __decisionPackInternals.buildRepoDecision({ + repo: repo("owner/direct", 0.03, 0), + roleContext: outsideRole, + outcome: moderateOutcome, + totals: { openPullRequestsTotal: 30, openIssuesTotal: 150, mergedPullRequestsTotal: 10, closedUnmergedPullRequestsTotal: 4 } as any, + }).riskReasons, + ).toEqual(expect.arrayContaining([expect.stringContaining("busy"), expect.stringContaining("large"), expect.stringContaining("open PR")])); + expect( + __decisionPackInternals.buildRepoDecision({ + repo: repo("owner/issues", 0.02, 1), + roleContext: outsideRole, + outcome: undefined, + syncState: { openPullRequestsCount: 1, openIssuesCount: 2, recentMergedPullRequestsCount: 3 } as any, + }), + ).toMatchObject({ recommendation: "watch", queue: { openPullRequests: 1, openIssues: 2, mergedPullRequests: 3 }, rewardUpside: { issueDiscoveryShare: 0.02 } }); + expect( + __decisionPackInternals.buildRepoDecision({ + repo: repo("owner/inactive", 0, 0), + roleContext: outsideRole, + outcome: undefined, + }), + ).toMatchObject({ recommendation: "avoid_for_now", scoreBlockers: [expect.objectContaining({ code: "inactive_or_unknown_lane" })] }); + expect(__decisionPackInternals.severityRank("critical")).toBe(3); + expect(__decisionPackInternals.severityRank("warning")).toBe(2); + expect(__decisionPackInternals.severityRank("info")).toBe(1); + expect(__decisionPackInternals.clamp(10, 0, 5)).toBe(5); + expect(__decisionPackInternals.round(1.23456)).toBe(1.2346); + }); + + it("redacts official hotkeys, loads stale snapshots, and resolves repo decisions case-insensitively", async () => { + const env = createTestEnv(); + const pack = { + status: "ready", + source: "computed", + login: "jsonbored", + generatedAt: "2026-05-24T00:00:00.000Z", + stale: false, + scoringModelSnapshotId: "scoring-1", + profile: { login: "jsonbored", github: {}, source: {}, officialStats: null, registeredRepoActivity: {}, trustSignals: {} }, + outcomeHistory: { login: "jsonbored", generatedAt: "2026-05-24T00:00:00.000Z", totals: {}, repoOutcomes: [] }, + roleContexts: [], + repoDecisions: [{ repoFullName: "JSONbored/awesome-claude", recommendation: "maintainer_lane" }], + topActions: [], + cleanupFirst: [], + pursueRepos: [], + avoidRepos: [], + maintainerLaneRepos: [], + scoreBlockers: [], + dataQuality: { signalFidelity: { status: "complete" } }, + summary: "fixture", + nextActions: [], + } as unknown as ContributorDecisionPack; + + await persistSignalSnapshot(env, { + id: "decision-pack-1", + signalType: "contributor-decision-pack", + targetKey: "jsonbored", + payload: pack as unknown as Record, + generatedAt: "2026-05-24T00:00:00.000Z", + }); + + const loaded = await loadContributorDecisionPack(env, "jsonbored"); + expect(loaded).toMatchObject({ source: "snapshot", snapshotAgeSeconds: expect.any(Number), stale: expect.any(Boolean) }); + expect(repoDecisionFromPack(loaded!, "jsonbored/AWESOME-CLAUDE")).toMatchObject({ recommendation: "maintainer_lane" }); + expect(repoDecisionFromPack(loaded!, "missing/repo")).toBeNull(); + await expect(loadFreshContributorDecisionPack(env, "jsonbored", 1)).resolves.toBeNull(); + await expect(loadFreshContributorDecisionPack(env, "missing", 1)).resolves.toBeNull(); + + expect(__decisionPackInternals.sanitizeOfficialStats({ gittensor: null } as any)).toBeNull(); + expect(__decisionPackInternals.sanitizeOfficialStats({ gittensor: { hotkey: "secret", totalMergedPrs: 5 } } as any)).toEqual({ totalMergedPrs: 5 }); + expect( + __decisionPackInternals.authoritativeContributorRepoStats( + { + githubUsername: "JsonBored", + repositories: [ + { + repoFullName: "official/repo", + pullRequests: 2, + mergedPullRequests: 1, + openPullRequests: 1, + openIssues: 0, + closedIssues: 0, + }, + ], + } as any, + [{ repoFullName: "cached/repo" }] as any, + ), + ).toEqual([expect.objectContaining({ login: "jsonbored", repoFullName: "official/repo" })]); + expect(__decisionPackInternals.authoritativeContributorRepoStats(null as any, [{ repoFullName: "cached/repo" }] as any)).toEqual([{ repoFullName: "cached/repo" }]); + expect( + __decisionPackInternals.withSnapshotMetadata({ + id: "snapshot-with-payload-date", + signalType: "contributor-decision-pack", + targetKey: "jsonbored", + generatedAt: null, + payload: { ...pack, generatedAt: "2026-05-25T00:00:00.000Z" } as any, + }), + ).toMatchObject({ generatedAt: "2026-05-25T00:00:00.000Z", source: "snapshot" }); + expect(__decisionPackInternals.snapshotAgeMs("not-a-date")).toBe(Number.POSITIVE_INFINITY); + }); + + it("builds a snapshot-style decision pack with maintainer, cleanup, pursue, watch, and avoid lanes", () => { + const profile = { + login: "jsonbored", + generatedAt: "2026-05-25T00:00:00.000Z", + github: {}, + source: {}, + gittensor: null, + registeredRepoActivity: { reposTouched: ["owner/cleanup", "owner/pursue", "owner/issues"] }, + trustSignals: {}, + } as any; + const outcomeHistory = { + login: "jsonbored", + generatedAt: "2026-05-25T00:00:00.000Z", + source: {}, + totals: {}, + repoOutcomes: [ + { repoFullName: "owner/cleanup", role: "outside_contributor", lane: "direct_pr", maintainerLane: false, openPullRequests: 6, closedPullRequestRate: 0.4, credibility: 0.5, mergedPullRequests: 1, closedPullRequests: 2, validSolvedIssues: 0 }, + { repoFullName: "owner/pursue", role: "outside_contributor", lane: "split", maintainerLane: false, openPullRequests: 0, closedPullRequestRate: 0, credibility: 1, mergedPullRequests: 3, closedPullRequests: 0, validSolvedIssues: 1 }, + ], + successPatterns: [], + failurePatterns: [], + summary: "fixture", + } as any; + + const pack = __decisionPackInternals.buildContributorDecisionPack({ + login: "jsonbored", + profile, + outcomeHistory, + repositories: [ + repo("jsonbored/owned", 0.02, 0), + repo("owner/cleanup", 0.03, 0), + repo("owner/pursue", 0.04, 0.5), + repo("owner/issues", 0.01, 1), + repo("owner/inactive", 0, 0), + { ...repo("owner/unconfigured", 0.01, 0), registryConfig: null }, + { ...repo("owner/unregistered", 0.01, 0), isRegistered: false }, + ], + syncStates: [ + { repoFullName: "owner/cleanup", status: "complete", openPullRequestsCount: 30, openIssuesCount: 150, recentMergedPullRequestsCount: 5, warnings: [], lastCompletedAt: "2026-05-25T00:00:00.000Z" }, + { repoFullName: "owner/inactive", status: "complete", openPullRequestsCount: 0, openIssuesCount: 0, recentMergedPullRequestsCount: 0, warnings: [], lastCompletedAt: "2026-05-25T00:00:00.000Z" }, + ] as any, + syncSegments: [], + totals: [{ repoFullName: "owner/pursue", openPullRequestsTotal: 2, openIssuesTotal: 3, mergedPullRequestsTotal: 4, closedUnmergedPullRequestsTotal: 1 }] as any, + scoringModelSnapshotId: "scoring-1", + contributorPullRequests: [{ repoFullName: "owner/cleanup", authorLogin: "jsonbored", authorAssociation: "CONTRIBUTOR" }] as any, + contributorIssues: [], + }); + + expect(pack.repoDecisions).toHaveLength(6); + expect(pack.maintainerLaneRepos.map((decision) => decision.repoFullName)).toContain("jsonbored/owned"); + expect(pack.cleanupFirst.map((decision) => decision.repoFullName)).toContain("owner/cleanup"); + expect(pack.pursueRepos.map((decision) => decision.repoFullName)).toContain("owner/pursue"); + expect(pack.avoidRepos.map((decision) => decision.repoFullName)).toEqual(expect.arrayContaining(["owner/inactive", "owner/unconfigured"])); + expect(pack.topActions.map((action) => action.actionKind)).toEqual(expect.arrayContaining(["maintainer_lane_improve_repo", "cleanup_existing_prs", "open_new_direct_pr", "file_issue_discovery"])); + expect(pack.roleContexts.map((role) => role.repoFullName)).not.toContain("owner/unconfigured"); + expect(pack.nextActions.length).toBeGreaterThan(0); + }); +}); + +function repo(fullName: string, emissionShare: number, issueDiscoveryShare: number) { + const [owner, name] = fullName.split("/"); + return { + fullName, + owner, + name, + isInstalled: false, + isRegistered: true, + isPrivate: false, + registryConfig: { + repo: fullName, + emissionShare, + issueDiscoveryShare, + maintainerCut: 0, + labelMultipliers: {}, + raw: {}, + }, + } as any; +} diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 11fec091b8..335ffd1e6e 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createOrUpdateCheckRun } from "../../src/github/app"; +import { createInstallationToken, createOrUpdateCheckRun, getInstallationId } from "../../src/github/app"; import type { Advisory } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; @@ -102,6 +102,56 @@ describe("GitHub check runs", () => { expect(result?.id).toBe(42); expect(methods.some((call) => call.startsWith("PATCH ") && call.includes("/check-runs/42"))).toBe(true); }); + + it("skips check creation when no head SHA is available", async () => { + const result = await createOrUpdateCheckRun(createTestEnv(), 123, "JSONbored/gittensory", { + id: "advisory-3", + targetType: "pull_request", + targetKey: "JSONbored/gittensory#1", + repoFullName: "JSONbored/gittensory", + pullNumber: 1, + conclusion: "success", + severity: "info", + title: "Gittensory advisory passed", + summary: "Pull request advisory generated.", + findings: [], + generatedAt: "2026-05-22T00:00:00.000Z", + }); + + expect(result).toBeNull(); + }); + + it("rejects invalid repo names and missing app credentials", async () => { + await expect( + createOrUpdateCheckRun(createTestEnv(), 123, "invalid", { + id: "advisory-4", + targetType: "pull_request", + targetKey: "invalid#1", + repoFullName: "invalid", + pullNumber: 1, + headSha: "abc123", + conclusion: "success", + severity: "info", + title: "Gittensory advisory passed", + summary: "Pull request advisory generated.", + findings: [], + generatedAt: "2026-05-22T00:00:00.000Z", + }), + ).rejects.toThrow(/Invalid repository full name/); + + await expect(createInstallationToken(createTestEnv({ GITHUB_APP_PRIVATE_KEY: "" }), 123)).rejects.toThrow(/not configured/); + expect(getInstallationId({ action: "created", installation: { id: 123 } })).toBe(123); + expect(getInstallationId({ action: "created" })).toBeNull(); + }); + + it("surfaces GitHub token response failures", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async () => new Response("bad credentials", { status: 401 })); + await expect(createInstallationToken(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123)).rejects.toThrow(/Failed to create GitHub installation token/); + + vi.stubGlobal("fetch", async () => Response.json({})); + await expect(createInstallationToken(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123)).rejects.toThrow(/did not include a token/); + }); }); async function generatePrivateKeyPem(): Promise { diff --git a/test/unit/github-comments.test.ts b/test/unit/github-comments.test.ts index 08420f3fbe..af6d2091ee 100644 --- a/test/unit/github-comments.test.ts +++ b/test/unit/github-comments.test.ts @@ -64,6 +64,10 @@ describe("GitHub PR intelligence comments", () => { expect(result?.id).toBe(101); expect(calls.some((call) => call.startsWith("PATCH ") && call.includes("/issues/comments/101"))).toBe(true); }); + + it("rejects invalid repository names before calling GitHub", async () => { + await expect(createOrUpdatePrIntelligenceComment(createTestEnv(), 123, "invalid", 12, "body")).rejects.toThrow(/Invalid repository full name/); + }); }); async function generatePrivateKeyPem(): Promise { diff --git a/test/unit/gittensor-api.test.ts b/test/unit/gittensor-api.test.ts new file mode 100644 index 0000000000..376f5bc8a1 --- /dev/null +++ b/test/unit/gittensor-api.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../../src/gittensor/api"; + +describe("Gittensor API contributor snapshots", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses Gittensor API miner data as the authoritative registered contribution source", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/miners")) { + return Response.json([ + { + uid: 29, + hotkey: "hotkey", + githubUsername: "JSONbored", + githubId: "49853598", + totalPrs: 63, + totalMergedPrs: 46, + totalOpenPrs: 9, + totalClosedPrs: 8, + totalOpenIssues: 44, + totalClosedIssues: 4, + totalSolvedIssues: 1, + totalValidSolvedIssues: 1, + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + evaluatedAt: "2026-05-21T14:56:20.782Z", + }, + ]); + } + if (url.endsWith("/miners/49853598")) { + return Response.json({ + repositories: [ + { + repositoryFullName: "we-promise/sure", + totalPrs: "47", + totalMergedPrs: "37", + totalOpenPrs: "6", + totalClosedPrs: "4", + totalOpenIssues: "0", + totalClosedIssues: "0", + isEligible: true, + credibility: "0.902439", + totalScore: "43.094808", + }, + { + repositoryFullName: "jsonbored/awesome-claude", + totalPrs: "0", + totalMergedPrs: "0", + totalOpenPrs: "0", + totalClosedPrs: "0", + totalOpenIssues: "42", + totalClosedIssues: "0", + isEligible: false, + }, + ], + }); + } + if (url.endsWith("/miners/49853598/prs")) { + return Response.json([ + { repository: "we-promise/sure", pullRequestNumber: 1869, pullRequestTitle: "feat(imports): verify Sure NDJSON import readback", prState: "MERGED", label: null, score: "13.551300" }, + ]); + } + if (url.endsWith("/miners/49853598/issues")) { + return Response.json({ issues: [{ labels: [{ name: "feature" }, { name: "help wanted" }] }] }); + } + return new Response("not found", { status: 404 }); + }); + + const snapshot = await fetchGittensorContributorSnapshot("jsonbored"); + + expect(snapshot).toMatchObject({ + githubId: "49853598", + githubUsername: "JSONbored", + totals: { pullRequests: 63, mergedPullRequests: 46, openPullRequests: 9, closedPullRequests: 8, openIssues: 44, closedIssues: 4 }, + repositories: [ + expect.objectContaining({ repoFullName: "we-promise/sure", pullRequests: 47, mergedPullRequests: 37, openPullRequests: 6 }), + expect.objectContaining({ repoFullName: "jsonbored/awesome-claude", pullRequests: 0, openIssues: 42 }), + ], + }); + expect(contributorRepoStatsFromGittensor(snapshot)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ repoFullName: "we-promise/sure", pullRequests: 47, mergedPullRequests: 37, issues: 0 }), + expect.objectContaining({ repoFullName: "jsonbored/awesome-claude", pullRequests: 0, mergedPullRequests: 0, issues: 42 }), + ]), + ); + }); + + it("falls back cleanly when Gittensor API is unavailable or has no matching miner", async () => { + vi.stubGlobal("fetch", async () => new Response("missing", { status: 404 })); + await expect(fetchGittensorContributorSnapshot("jsonbored")).resolves.toBeNull(); + expect(contributorRepoStatsFromGittensor(null)).toEqual([]); + }); + + it("keeps the official miner summary when detail endpoints are temporarily unavailable", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/miners")) { + return Response.json([ + { + githubUsername: "JSONbored", + githubId: "49853598", + totalPrs: 63, + totalMergedPrs: 46, + totalOpenPrs: 9, + totalClosedPrs: 8, + totalOpenIssues: 44, + totalClosedIssues: 4, + totalSolvedIssues: 1, + totalValidSolvedIssues: 1, + }, + ]); + } + return new Response("temporarily unavailable", { status: 503 }); + }); + + const snapshot = await fetchGittensorContributorSnapshot("jsonbored"); + + expect(snapshot).toMatchObject({ + source: "gittensor_api", + githubId: "49853598", + totals: { pullRequests: 63, mergedPullRequests: 46, openPullRequests: 9, closedPullRequests: 8 }, + repositories: [], + pullRequests: [], + issueLabels: [], + }); + }); + + it("handles partial Gittensor payloads and malformed numeric fields", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/miners")) { + return Response.json([ + { + githubUsername: "partial", + githubId: "123", + totalPrs: "not-a-number", + totalMergedPrs: 2, + totalOpenPrs: Number.NaN, + issueCredibility: undefined, + }, + ]); + } + if (url.endsWith("/miners/123")) { + return Response.json({ + repositories: [ + { repositoryFullName: undefined, totalPrs: "bad", totalOpenIssues: "1", totalClosedIssues: null, isEligible: true }, + { repositoryFullName: "owner/repo", totalPrs: "3", totalMergedPrs: "2", totalOpenIssues: "0", totalClosedIssues: "0", totalScore: "bad" }, + ], + }); + } + if (url.endsWith("/miners/123/prs")) { + return Response.json([ + { repository: undefined, pullRequestNumber: "bad", pullRequestTitle: undefined, prState: undefined }, + { repository: "owner/repo", pullRequestNumber: 7, pullRequestTitle: "Fix it", prState: "MERGED", score: "bad", baseScore: 1 }, + ]); + } + if (url.endsWith("/miners/123/issues")) { + return Response.json({ issues: [{ labels: [{}, { name: null }, { name: "bug" }] }] }); + } + return Response.json({}); + }); + + const snapshot = await fetchGittensorContributorSnapshot("partial"); + + expect(snapshot).toMatchObject({ + totals: { pullRequests: 0, mergedPullRequests: 2, openPullRequests: 0 }, + issueCredibility: 1, + issueLabels: ["bug"], + pullRequests: [expect.objectContaining({ repoFullName: "owner/repo", number: 7, title: "Fix it", score: 0, baseScore: 1 })], + repositories: [ + expect.objectContaining({ repoFullName: "", pullRequests: 0, openIssues: 1, closedIssues: 0 }), + expect.objectContaining({ repoFullName: "owner/repo", pullRequests: 3, mergedPullRequests: 2, totalScore: 0 }), + ], + }); + }); + + it("returns null when the miner list has no matching GitHub identity", async () => { + vi.stubGlobal("fetch", async () => Response.json([{ githubUsername: "someone-else", githubId: "999" }])); + await expect(fetchGittensorContributorSnapshot("jsonbored")).resolves.toBeNull(); + }); +}); diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts new file mode 100644 index 0000000000..19e447fb12 --- /dev/null +++ b/test/unit/index.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import worker from "../../src/index"; +import { createTestEnv } from "../helpers/d1"; + +describe("worker entrypoint", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("delegates fetch requests to the Hono app", async () => { + const env = createTestEnv(); + const response = await worker.fetch(new Request("https://gittensory.test/health"), env); + expect(response.status).toBe(200); + }); + + it("acks successful queue messages and retries failed messages", async () => { + const env = createTestEnv(); + vi.stubGlobal("fetch", async () => new Response("missing", { status: 404 })); + const acked: string[] = []; + const retried: string[] = []; + const batch = { + messages: [ + { + id: "ok", + body: { type: "refresh-installation-health", requestedBy: "test" }, + ack: () => acked.push("ok"), + retry: () => retried.push("ok"), + }, + { + id: "bad", + body: { type: "refresh-registry", requestedBy: "test" }, + ack: () => acked.push("bad"), + retry: () => retried.push("bad"), + }, + ], + } as unknown as MessageBatch; + + await worker.queue(batch, env); + expect(acked).toEqual(["ok"]); + expect(retried).toEqual(["bad"]); + }); + + it("runs scheduled jobs through waitUntil", async () => { + const env = createTestEnv(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("master_repositories.json")) return Response.json({}); + if (url.includes("api.gittensor.io") || url.includes("mirror.gittensor.io")) return new Response("missing", { status: 404 }); + return Response.json([]); + }); + const waitUntil: Promise[] = []; + await worker.scheduled( + {} as ScheduledController, + env, + { + waitUntil: (promise: Promise) => { + waitUntil.push(promise); + }, + passThroughOnException: () => {}, + exports: {}, + props: {}, + } as unknown as ExecutionContext, + ); + await Promise.allSettled(waitUntil); + expect(waitUntil).toHaveLength(1); + }); + + it("enqueues light scheduled work outside hourly and full-sync windows", async () => { + const sent: Array = []; + const env = createTestEnv({ + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + const waitUntil: Promise[] = []; + + await worker.scheduled(controllerFor("2026-05-25T05:15:00.000Z"), env, executionContext(waitUntil)); + await Promise.all(waitUntil); + + expect(sent).toEqual([ + { type: "backfill-registered-repos", requestedBy: "schedule", mode: "light" }, + { type: "repair-data-fidelity", requestedBy: "schedule" }, + { type: "refresh-installation-health", requestedBy: "schedule" }, + ]); + }); + + it("enqueues hourly refreshes without full detail work outside the six-hour window", async () => { + const sent: Array = []; + const env = createTestEnv({ + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + const waitUntil: Promise[] = []; + + await worker.scheduled(controllerFor("2026-05-25T05:00:00.000Z"), env, executionContext(waitUntil)); + await Promise.all(waitUntil); + + expect(sent).toEqual([ + { type: "backfill-registered-repos", requestedBy: "schedule", mode: "light" }, + { type: "repair-data-fidelity", requestedBy: "schedule" }, + { type: "refresh-installation-health", requestedBy: "schedule" }, + { type: "refresh-registry", requestedBy: "schedule" }, + { type: "refresh-scoring-model", requestedBy: "schedule" }, + ]); + }); + + it("enqueues full-sync scheduled work every six hours", async () => { + const sent: Array = []; + const env = createTestEnv({ + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + const waitUntil: Promise[] = []; + + await worker.scheduled(controllerFor("2026-05-25T06:00:00.000Z"), env, executionContext(waitUntil)); + await Promise.all(waitUntil); + + expect(sent).toEqual([ + { type: "backfill-registered-repos", requestedBy: "schedule", mode: "full" }, + { type: "repair-data-fidelity", requestedBy: "schedule" }, + { type: "refresh-installation-health", requestedBy: "schedule" }, + { type: "refresh-registry", requestedBy: "schedule" }, + { type: "refresh-scoring-model", requestedBy: "schedule" }, + { type: "generate-signal-snapshots", requestedBy: "schedule" }, + { type: "build-burden-forecasts", requestedBy: "schedule" }, + { type: "build-contributor-evidence", requestedBy: "schedule" }, + { type: "build-contributor-decision-packs", requestedBy: "schedule" }, + ]); + }); +}); + +function controllerFor(iso: string): ScheduledController { + return { scheduledTime: Date.parse(iso) } as ScheduledController; +} + +function executionContext(waitUntil: Promise[]): ExecutionContext { + return { + waitUntil: (promise: Promise) => { + waitUntil.push(promise); + }, + passThroughOnException: () => {}, + exports: {}, + props: {}, + } as unknown as ExecutionContext; +} diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts new file mode 100644 index 0000000000..3596fd4d94 --- /dev/null +++ b/test/unit/local-branch.test.ts @@ -0,0 +1,322 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildLocalBranchAnalysis } from "../../src/signals/local-branch"; +import type { ContributorOutcomeHistory, ContributorProfile, ContributorScoringProfile } from "../../src/signals/engine"; +import type { RepositoryRecord, ScoringModelSnapshotRecord } from "../../src/types"; + +describe("local branch analysis", () => { + it("combines local preflight, private score preview, reward/risk, and a public-safe PR packet", () => { + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + baseRef: "origin/main", + headRef: "fix-cache", + branchName: "fix-cache-reconnect", + title: "Fix dashboard cache refresh after reconnect", + body: "Fixes #7", + labels: ["bug"], + changedFiles: [ + { path: "src/cache.ts", additions: 42, deletions: 4, status: "modified" }, + { path: "test/cache.test.ts", additions: 30, deletions: 0, status: "added" }, + ], + validation: [{ command: "npm test -- cache", status: "passed", summary: "cache regression passed" }], + localScorer: { + mode: "external_command", + sourceTokenScore: 48, + totalTokenScore: 80, + sourceLines: 46, + testTokenScore: 30, + }, + }, + repo, + issues: [{ repoFullName: repo.fullName, number: 7, title: "Dashboard cache refresh fails after reconnect", state: "open", labels: ["bug"], linkedPrs: [] }], + pullRequests: [], + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + }); + + expect(analysis.preflight.status).toBe("ready"); + expect(analysis.preflight.localDiff).toMatchObject({ changedFileCount: 2, codeFileCount: 1, testFileCount: 1, inferredLinkedIssues: [7] }); + expect(analysis.scorePreview.privateOnly).toBe(true); + expect(analysis.rewardRisk.rewardUpside.relevantLane).toBe("direct_pr"); + expect(analysis.nextActions.map((action) => action.actionKind)).toContain("open_new_direct_pr"); + expect(analysis.localFindings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "source_upload_disabled" })])); + expect(JSON.stringify(analysis.prPacket)).not.toMatch(/reward|score|wallet|hotkey|farming|payout|ranking|trust score/i); + }); + + it("keeps unregistered gittensory work in product/maintainer context instead of miner target context", () => { + const analysis = buildLocalBranchAnalysis({ + input: { + login: "jsonbored", + repoFullName: "JSONbored/gittensory", + branchName: "miner-mcp-upgrade", + changedFiles: [{ path: "src/api/routes.ts", additions: 90, deletions: 2, status: "modified" }], + validation: [{ command: "npm run test:ci", status: "not_run" }], + }, + repo: null, + issues: [], + pullRequests: [], + profile: { ...profile, login: "jsonbored" }, + outcomeHistory: { ...outcomeHistory, login: "jsonbored", repoOutcomes: [] }, + scoringSnapshot, + }); + + expect(analysis.lane.lane).toBe("unknown"); + expect(analysis.scoreBlockers).toEqual(expect.arrayContaining(["Repository is not registered in the local snapshot."])); + expect(analysis.localFindings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "gittensory_not_registered" })])); + expect(analysis.rewardRisk.rewardUpside.relevantLane).toBe("maintainer_lane"); + expect(analysis.rewardRisk.scoreBlockers).toEqual(expect.arrayContaining(["Maintainer-lane work is not normal outside-contributor reward evidence."])); + }); + + it("handles sparse metadata, failed validation, binary changes, and commit-title fallback", () => { + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + commitMessages: ["Fix reconnect binary asset handling\n\nNo public scoring text."], + changedFiles: [{ path: "assets/cache.bin", additions: 0, deletions: 0, binary: true, status: "modified" }], + validation: [{ command: "npm test -- cache", status: "failed", summary: "regression failed" }], + }, + repo, + issues: [], + pullRequests: [], + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + }); + + expect(analysis.prPacket.titleSuggestion).toBe("Fix reconnect binary asset handling"); + expect(analysis.localFindings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "failed_local_validation" }), + expect.objectContaining({ code: "binary_diff_present" }), + ]), + ); + expect(analysis.prPacket.bodySections).toEqual( + expect.arrayContaining([ + expect.objectContaining({ heading: "Linked Context", lines: ["- No linked issue detected; explain why this is a no-issue PR."] }), + expect.objectContaining({ heading: "Validation", lines: [expect.stringContaining("failed: npm test -- cache")] }), + ]), + ); + expect(JSON.stringify(analysis.prPacket)).not.toMatch(/reward|score|wallet|hotkey|farming|payout|ranking|trust score/i); + }); + + it("uses safe defaults when local metadata has no title, files, or validation", () => { + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + }, + repo, + issues: [], + pullRequests: [], + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + }); + + expect(analysis.prPacket.titleSuggestion).toBe("Local branch preflight"); + expect(analysis.prPacket.bodySections).toEqual( + expect.arrayContaining([ + expect.objectContaining({ heading: "Changed Paths", lines: ["- No changed paths were detected from local metadata."] }), + expect.objectContaining({ heading: "Validation", lines: ["- Not supplied yet."] }), + ]), + ); + expect(analysis.summary).toContain("is the top private next action"); + expect(JSON.stringify(analysis.prPacket)).not.toMatch(/reward|score|wallet|hotkey|farming|payout|ranking|trust score/i); + }); +}); + +describe("local MCP git metadata collection", () => { + let tempDir: string | null = null; + + afterEach(() => { + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + delete process.env.GITTENSORY_UPLOAD_SOURCE; + }); + + it("parses remotes, changed-file stats, linked issues, and refuses source upload mode", async () => { + // @ts-expect-error package helper is plain JS because the local wrapper ships as a Node bin package. + const { collectLocalBranchMetadata, parseGitRemote } = await import("../../packages/gittensory-mcp/lib/local-branch.js"); + expect(parseGitRemote("git@github.com:entrius/allways-ui.git")).toBe("entrius/allways-ui"); + expect(parseGitRemote("https://github.com/JSONbored/gittensory.git")).toBe("JSONbored/gittensory"); + + tempDir = mkdtempSync(join(tmpdir(), "gittensory-local-")); + git(tempDir, "init"); + git(tempDir, "config", "user.email", "test@example.com"); + git(tempDir, "config", "user.name", "Gittensory Test"); + git(tempDir, "config", "commit.gpgsign", "false"); + git(tempDir, "remote", "add", "origin", "git@github.com:entrius/allways-ui.git"); + writeFileSync(join(tempDir, "README.md"), "fixture\n"); + git(tempDir, "add", "README.md"); + git(tempDir, "commit", "-m", "initial commit"); + git(tempDir, "checkout", "-b", "fix-cache-7"); + mkdirSync(join(tempDir, "src")); + mkdirSync(join(tempDir, "test")); + writeFileSync(join(tempDir, "src/cache.ts"), "export const cache = 1;\n"); + writeFileSync(join(tempDir, "test/cache.test.ts"), "expect(1).toBe(1);\n"); + git(tempDir, "add", "src/cache.ts", "test/cache.test.ts"); + + const metadata = collectLocalBranchMetadata({ cwd: tempDir, baseRef: "HEAD", login: "oktofeesh1", body: "Fixes #7" }); + expect(metadata).toMatchObject({ + login: "oktofeesh1", + repoFullName: "entrius/allways-ui", + branchName: "fix-cache-7", + linkedIssues: [7], + }); + expect(metadata.changedFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "src/cache.ts", additions: 1, status: "added" }), + expect.objectContaining({ path: "test/cache.test.ts", additions: 1, status: "added" }), + ]), + ); + expect(JSON.stringify(metadata)).not.toMatch(/export const cache/); + + process.env.GITTENSORY_UPLOAD_SOURCE = "true"; + expect(() => collectLocalBranchMetadata({ cwd: tempDir, baseRef: "HEAD", login: "oktofeesh1" })).toThrow(/not supported/); + }); +}); + +const repo: RepositoryRecord = { + fullName: "entrius/allways-ui", + owner: "entrius", + name: "allways-ui", + isInstalled: true, + isRegistered: true, + isPrivate: false, + defaultBranch: "test", + registryConfig: { + repo: "entrius/allways-ui", + emissionShare: 0.01107, + issueDiscoveryShare: 0, + labelMultipliers: { bug: 1.1 }, + trustedLabelPipeline: true, + maintainerCut: 0, + raw: {}, + }, +}; + +const profile: ContributorProfile = { + login: "oktofeesh1", + generatedAt: "2026-05-25T00:00:00.000Z", + github: { login: "oktofeesh1", topLanguages: ["TypeScript"], source: "github" }, + source: "gittensor_api", + registeredRepoActivity: { + pullRequests: 2, + mergedPullRequests: 1, + issues: 0, + reposTouched: [repo.fullName], + dominantLabels: ["bug"], + }, + trustSignals: { + evidenceScore: 80, + level: "emerging", + unlinkedOpenPullRequests: 0, + maintainerAssociatedPullRequests: 0, + }, +}; + +const outcomeHistory: ContributorOutcomeHistory = { + login: "oktofeesh1", + generatedAt: "2026-05-25T00:00:00.000Z", + source: "gittensor_api", + totals: { + pullRequests: 2, + mergedPullRequests: 1, + openPullRequests: 0, + closedPullRequests: 1, + closedPullRequestRate: 0.5, + issues: 0, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + credibility: 0.92, + issueCredibility: 1, + }, + repoOutcomes: [ + { + repoFullName: repo.fullName, + role: "outside_contributor", + lane: "direct_pr", + maintainerLane: false, + pullRequests: 2, + mergedPullRequests: 1, + openPullRequests: 0, + closedPullRequests: 1, + closedPullRequestRate: 0.5, + issues: 0, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + credibility: 0.92, + issueCredibility: 1, + isEligible: true, + successLevel: "emerging", + strengths: ["Merged prior PRs."], + risks: ["Closed PR risk exists."], + }, + ], + successPatterns: [], + failurePatterns: [], + summary: "fixture history", +}; + +const scoringSnapshot: ScoringModelSnapshotRecord = { + id: "scoring-test", + sourceKind: "test", + sourceUrl: "fixture://scoring", + fetchedAt: "2026-05-25T00:00:00.000Z", + activeModel: "current_density_model", + constants: { + OSS_EMISSION_SHARE: 0.9, + MERGED_PR_BASE_SCORE: 25, + MIN_TOKEN_SCORE_FOR_BASE_SCORE: 5, + MAX_CODE_DENSITY_MULTIPLIER: 1.15, + MAX_CONTRIBUTION_BONUS: 25, + CONTRIBUTION_SCORE_FOR_FULL_BONUS: 1500, + STANDARD_ISSUE_MULTIPLIER: 1.33, + MAINTAINER_ISSUE_MULTIPLIER: 1.66, + MIN_CREDIBILITY: 0.8, + REVIEW_PENALTY_RATE: 0.15, + EXCESSIVE_PR_PENALTY_BASE_THRESHOLD: 2, + OPEN_PR_THRESHOLD_TOKEN_SCORE: 300, + MAX_OPEN_PR_THRESHOLD: 30, + OPEN_PR_COLLATERAL_PERCENT: 0.2, + SRC_TOK_SATURATION_SCALE: 58, + }, + programmingLanguages: { TypeScript: 1 }, + warnings: [], + payload: {}, +}; + +const scoringProfile: ContributorScoringProfile = { + login: "oktofeesh1", + generatedAt: "2026-05-25T00:00:00.000Z", + scoringModelSnapshotId: "scoring-test", + evidence: { + registeredRepoPullRequests: 2, + mergedPullRequests: 1, + openPullRequests: 0, + stalePullRequests: 0, + unlinkedPullRequests: 0, + issueDiscoveryReports: 0, + languageMatches: 1, + credibilityAssumption: 0.92, + }, + privateSignals: ["fixture scoring profile"], +}; + +function git(cwd: string, ...args: string[]) { + execFileSync("git", args, { cwd, stdio: "ignore" }); +} diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index a8c1c419fc..d80ef4610d 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -2,17 +2,61 @@ import { describe, expect, it } from "vitest"; import { buildOpenApiSpec } from "../../src/openapi/spec"; describe("OpenAPI contract", () => { - it("exports Lovable-facing backend paths", () => { + it("exports the modern private-beta backend contract only", () => { const spec = buildOpenApiSpec(); expect(spec.paths["/health"]).toBeDefined(); expect(spec.paths["/v1/registry/snapshot"]).toBeDefined(); - expect(spec.paths["/v1/repos/{owner}/{repo}/pulls/{number}/advisory"]).toBeDefined(); - expect(spec.paths["/v1/contributors/{login}/opportunities"]).toBeDefined(); + expect(spec.paths["/v1/registry/changes"]).toBeDefined(); + expect(spec.paths["/v1/readiness"]).toBeDefined(); + expect(spec.paths["/v1/sync/status"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/intelligence"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/pulls/{number}/reviewability"]).toBeDefined(); + expect(spec.paths["/v1/contributors/{login}/profile"]).toBeDefined(); + expect(spec.paths["/v1/contributors/{login}/decision-pack"]).toBeDefined(); + expect(spec.paths["/v1/contributors/{login}/repos/{owner}/{repo}/decision"]).toBeDefined(); expect(spec.paths["/v1/preflight/pr"]).toBeDefined(); - expect(spec.paths["/v1/repos/{owner}/{repo}/queue-health"]).toBeDefined(); + expect(spec.paths["/v1/preflight/local-diff"]).toBeDefined(); + expect(spec.paths["/v1/local/branch-analysis"]).toBeDefined(); + expect(spec.paths["/v1/scoring/model"]).toBeDefined(); + expect(spec.paths["/v1/scoring/preview"]).toBeDefined(); expect(spec.paths["/v1/bounties/{id}/advisory"]).toBeDefined(); - expect(spec.components?.schemas?.Advisory).toBeDefined(); + expect(spec.paths["/v1/auth/github/device/start"]).toBeDefined(); + expect(spec.paths["/v1/auth/session"]).toBeDefined(); + expect(spec.paths["/v1/internal/jobs/repair-data-fidelity"]).toBeDefined(); + + for (const removedPath of [ + "/v1/contributors/{login}/opportunities", + "/v1/contributors/{login}/fit", + "/v1/contributors/{login}/strategy", + "/v1/contributors/{login}/reward-risk-strategy", + "/v1/contributors/{login}/actions/recommendations", + "/v1/contributors/{login}/outcome-history", + "/v1/contributors/{login}/repos/{owner}/{repo}/recommendation", + "/v1/contributors/{login}/repos/{owner}/{repo}/reward-risk", + "/v1/repos/{owner}/{repo}/queue-health", + "/v1/repos/{owner}/{repo}/collisions", + "/v1/repos/{owner}/{repo}/config-quality", + "/v1/repos/{owner}/{repo}/labels/audit", + "/v1/repos/{owner}/{repo}/issue-quality", + "/v1/repos/{owner}/{repo}/burden-forecast", + "/v1/repos/{owner}/{repo}/registry-drift", + "/v1/repos/{owner}/{repo}/maintainer-lane", + "/v1/repos/{owner}/{repo}/maintainer-noise", + "/v1/repos/{owner}/{repo}/pulls/{number}/review-intelligence", + "/v1/repos/{owner}/{repo}/pulls/{number}/scoring-preview", + "/v1/internal/jobs/generate-signal-snapshots/run", + ]) { + expect(spec.paths[removedPath]).toBeUndefined(); + } + expect(spec.components?.schemas?.ContributorProfile).toBeDefined(); - expect(spec.components?.schemas?.MaintainerPacket).toBeDefined(); + expect(spec.components?.schemas?.ContributorDecisionPack).toBeDefined(); + expect(spec.components?.schemas?.DecisionPackRefreshNeeded).toBeDefined(); + expect(spec.components?.schemas?.RepoDecisionResponse).toBeDefined(); + expect(spec.components?.schemas?.RepoIntelligence).toBeDefined(); + expect(spec.components?.schemas?.PullRequestMaintainerPacket).toBeDefined(); + expect(spec.components?.schemas?.PullRequestReviewability).toBeDefined(); + expect(spec.components?.schemas?.LocalBranchAnalysis).toBeDefined(); }); }); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts new file mode 100644 index 0000000000..d519b57b9d --- /dev/null +++ b/test/unit/queue.test.ts @@ -0,0 +1,545 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + listCollisionEdges, + getContributorEvidence, + getContributorScoringProfile, + listInstallationHealth, + listPullRequests, + listRepoSyncStates, + listSignalSnapshots, + upsertRepoSyncSegment, + upsertInstallation, + upsertPullRequestFromGitHub, + upsertRepositorySettings, + upsertRepositoryFromGitHub, +} from "../../src/db/repositories"; +import { processJob } from "../../src/queue/processors"; +import { normalizeRegistryPayload } from "../../src/registry/normalize"; +import { persistRegistrySnapshot } from "../../src/registry/sync"; +import { createTestEnv } from "../helpers/d1"; + +describe("queue processors", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("processes registry, backfill, installation health, and signal snapshot jobs", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("api.gittensor.io") || url.includes("mirror.gittensor.io")) { + return new Response("missing", { status: 404 }); + } + if (url.includes("master_repositories.json")) { + return Response.json({ + "JSONbored/gittensory": { + emission_share: 0.01, + issue_discovery_share: 0, + label_multipliers: { bug: 1.1 }, + trusted_label_pipeline: true, + }, + }); + } + if (url.includes("constants.py")) { + return new Response("OSS_EMISSION_SHARE = 0.90\nMIN_TOKEN_SCORE_FOR_BASE_SCORE = 5\nMAX_CODE_DENSITY_MULTIPLIER = 1.15\n"); + } + if (url.includes("programming_languages.json")) return Response.json({ TypeScript: 1 }); + if (url.endsWith("/repos/JSONbored/gittensory")) { + return Response.json({ + name: "gittensory", + full_name: "JSONbored/gittensory", + private: true, + default_branch: "main", + language: "TypeScript", + owner: { login: "JSONbored" }, + }); + } + if (url.includes("/labels?")) return Response.json([{ name: "bug" }]); + if (url.includes("/issues?")) { + return Response.json([{ number: 1, title: "Webhook duplicate delivery", state: "open", user: { login: "reporter" }, labels: [{ name: "bug" }], body: "Bug." }]); + } + if (url.includes("/pulls?state=open")) { + return Response.json([{ number: 2, title: "Fix webhook duplicate delivery", state: "open", user: { login: "oktofeesh1" }, labels: [{ name: "bug" }], body: "Fixes #1" }]); + } + if (url.includes("/pulls?state=closed")) return Response.json([]); + if (url.includes("/pulls/2/files")) return Response.json([]); + if (url.includes("/pulls/2/reviews")) return Response.json([]); + if (url.endsWith("/users/oktofeesh1")) return Response.json({ login: "oktofeesh1", public_repos: 2, followers: 1 }); + if (url.includes("/users/oktofeesh1/repos")) return Response.json([{ language: "TypeScript" }]); + return Response.json({ check_runs: [] }); + }); + + await processJob(env, { type: "refresh-registry", requestedBy: "test" }); + await processJob(env, { type: "refresh-scoring-model", requestedBy: "test" }); + await processJob(env, { type: "backfill-registered-repos", requestedBy: "test", repoFullName: "JSONbored/gittensory", force: true }); + await processJob(env, { type: "generate-signal-snapshots", requestedBy: "test", repoFullName: "JSONbored/gittensory" }); + await processJob(env, { type: "build-contributor-evidence", requestedBy: "test", login: "oktofeesh1" }); + await processJob(env, { type: "build-contributor-decision-packs", requestedBy: "test", login: "oktofeesh1" }); + await processJob(env, { type: "refresh-contributor-activity", requestedBy: "test", login: "oktofeesh1", repoFullName: "JSONbored/gittensory" }); + await processJob(env, { type: "build-contributor-evidence", requestedBy: "test" }); + await processJob(env, { type: "build-contributor-decision-packs", requestedBy: "test" }); + await processJob(env, { type: "build-burden-forecasts", requestedBy: "test", repoFullName: "JSONbored/gittensory" }); + await processJob(env, { type: "refresh-contributor-activity", requestedBy: "test", login: "oktofeesh1" }); + await processJob(env, { + type: "github-webhook", + deliveryId: "installation-created", + eventName: "installation", + payload: { + action: "created", + installation: { id: 456, account: { login: "JSONbored", id: 1, type: "User" } }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }], + }, + }); + + expect(await listRepoSyncStates(env)).toMatchObject([{ repoFullName: "JSONbored/gittensory", status: "success" }]); + expect(await listCollisionEdges(env, "JSONbored/gittensory")).not.toHaveLength(0); + expect(await listSignalSnapshots(env, "queue-health", "JSONbored/gittensory")).toHaveLength(1); + expect(await listSignalSnapshots(env, "contributor-decision-pack", "oktofeesh1")).not.toHaveLength(0); + expect(await getContributorEvidence(env, "oktofeesh1")).toMatchObject({ login: "oktofeesh1" }); + expect(await getContributorScoringProfile(env, "oktofeesh1")).toMatchObject({ login: "oktofeesh1" }); + }); + + it("fans out all-repo backfill jobs into repo-scoped queue messages", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + "we-promise/sure": { emission_share: 0.02, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + }, + { kind: "raw-github", url: "fixture://registry" }, + "2026-05-25T00:00:00.000Z", + ), + ); + + await processJob(env, { type: "backfill-registered-repos", requestedBy: "api", force: true, mode: "full" }); + + expect(sent).toEqual([ + { type: "backfill-registered-repos", requestedBy: "api", repoFullName: "JSONbored/gittensory", force: true, mode: "full" }, + { type: "backfill-registered-repos", requestedBy: "api", repoFullName: "we-promise/sure", force: true, mode: "full" }, + ]); + expect(await listRepoSyncStates(env)).toEqual([]); + }); + + it("falls back to inline all-repo backfill when no registered repositories exist", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + + await processJob(env, { type: "backfill-registered-repos", requestedBy: "api", mode: "light" }); + + expect(sent).toEqual([]); + expect(await listRepoSyncStates(env)).toEqual([]); + }); + + it("routes repo-scoped API backfills into open-data segment jobs", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false } }, + { kind: "raw-github", url: "fixture://registry" }, + "2026-05-25T00:00:00.000Z", + ), + ); + await processJob(env, { type: "backfill-registered-repos", requestedBy: "api", repoFullName: "JSONbored/gittensory", force: false, mode: "resume" }); + + expect(sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "backfill-repo-segment", repoFullName: "JSONbored/gittensory", mode: "resume", force: false }), + ]), + ); + }); + + it("repairs incomplete fidelity through queue-backed repo jobs", async () => { + const sent: Array<{ message: import("../../src/types").JobMessage; options?: QueueSendOptions }> = []; + const env = createTestEnv({ + JOBS: { + async send(message: import("../../src/types").JobMessage, options?: QueueSendOptions) { + sent.push(options ? { message, options } : { message }); + }, + } as unknown as Queue, + }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + "we-promise/sure": { emission_share: 0.02, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + }, + { kind: "raw-github", url: "fixture://registry" }, + "2026-05-25T00:00:00.000Z", + ), + ); + + await upsertRepoSyncSegment(env, completeSegment("JSONbored/gittensory", "labels")); + await upsertRepoSyncSegment(env, completeSegment("JSONbored/gittensory", "open_issues")); + await upsertRepoSyncSegment(env, completeSegment("JSONbored/gittensory", "open_pull_requests")); + + await processJob(env, { type: "repair-data-fidelity", requestedBy: "schedule" }); + + expect(sent.map((entry) => entry.message)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "backfill-registered-repos", repoFullName: "we-promise/sure", mode: "resume" }), + expect.objectContaining({ type: "generate-signal-snapshots", repoFullName: "JSONbored/gittensory" }), + ]), + ); + }); + + it("fans out signal snapshot generation instead of doing all repo work inline", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + "we-promise/sure": { emission_share: 0.02, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + }, + { kind: "raw-github", url: "fixture://registry" }, + "2026-05-25T00:00:00.000Z", + ), + ); + + await processJob(env, { type: "generate-signal-snapshots", requestedBy: "schedule" }); + + expect(sent).toEqual([ + expect.objectContaining({ type: "generate-signal-snapshots", repoFullName: "JSONbored/gittensory" }), + expect.objectContaining({ type: "generate-signal-snapshots", repoFullName: "we-promise/sure" }), + ]); + }); + + it("routes repo-scoped backfill jobs into resumable segment and detail processors", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITHUB_PUBLIC_TOKEN: "public-token", + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false } }, + { kind: "raw-github", url: "fixture://registry" }, + "2026-05-25T00:00:00.000Z", + ), + ); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") { + return Response.json({ + data: { + rateLimit: { remaining: 4999, resetAt: "2026-05-25T01:00:00.000Z" }, + repository: { + issues: { totalCount: 0 }, + openPullRequests: { totalCount: 0 }, + mergedPullRequests: { totalCount: 0 }, + closedPullRequests: { totalCount: 0 }, + labels: { totalCount: 0 }, + }, + }, + }); + } + if (url.includes("/issues?") || url.includes("/labels?") || url.includes("/pulls?")) return Response.json([]); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { type: "backfill-registered-repos", requestedBy: "api", repoFullName: "JSONbored/gittensory" }); + await processJob(env, { type: "backfill-repo-segment", requestedBy: "api", repoFullName: "JSONbored/gittensory", segment: "open_issues" }); + await processJob(env, { type: "backfill-pr-details", requestedBy: "api", repoFullName: "JSONbored/gittensory" }); + + expect(sent).toEqual(expect.arrayContaining([expect.objectContaining({ type: "backfill-repo-segment", repoFullName: "JSONbored/gittensory" })])); + expect(await listRepoSyncStates(env)).toEqual(expect.arrayContaining([expect.objectContaining({ repoFullName: "JSONbored/gittensory" })])); + }); + + it("covers optional queue payload branches for fanout, segment, and detail jobs", async () => { + const sent: import("../../src/types").JobMessage[] = []; + const env = createTestEnv({ + GITHUB_PUBLIC_TOKEN: "public-token", + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + "we-promise/sure": { emission_share: 0.02, issue_discovery_share: 0, label_multipliers: {}, trusted_label_pipeline: false }, + }, + { kind: "raw-github", url: "fixture://registry" }, + "2026-05-25T00:00:00.000Z", + ), + ); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") { + return Response.json({ + data: { + rateLimit: { remaining: 4999, resetAt: "2026-05-25T01:00:00.000Z" }, + repository: { + issues: { totalCount: 0 }, + openPullRequests: { totalCount: 0 }, + mergedPullRequests: { totalCount: 0 }, + closedPullRequests: { totalCount: 0 }, + labels: { totalCount: 0 }, + }, + }, + }); + } + if (url.includes("/labels?") || url.includes("/pulls?") || url.includes("/issues?")) return Response.json([]); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { type: "backfill-registered-repos", requestedBy: "api" }); + await processJob(env, { type: "backfill-repo-segment", requestedBy: "api", repoFullName: "JSONbored/gittensory", segment: "labels", mode: "resume", cursor: "2", force: true }); + await processJob(env, { type: "backfill-pr-details", requestedBy: "api", repoFullName: "JSONbored/gittensory", mode: "resume", cursor: 2 }); + + expect(sent).toEqual(expect.arrayContaining([expect.objectContaining({ type: "backfill-registered-repos", repoFullName: "JSONbored/gittensory" })])); + }); + + it("marks installation health from queued installation metadata", async () => { + const env = createTestEnv(); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { checks: "write", metadata: "read", pull_requests: "read" }, + events: ["issues", "pull_request", "repository"], + }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }], + }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }, 123); + + await processJob(env, { type: "refresh-installation-health", requestedBy: "test" }); + expect(await listInstallationHealth(env)).toMatchObject([{ status: "healthy", registeredInstalledCount: 1 }]); + }); + + it("processes GitHub webhook jobs for PRs, issues, comments-off, comment-attempt, and deleted installs", async () => { + const env = createTestEnv(); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { + number: 1, + title: "Prior merged work", + state: "closed", + merged_at: "2026-05-01T00:00:00.000Z", + user: { login: "oktofeesh1" }, + labels: [{ name: "bug" }], + body: "Fixes #1", + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/users/oktofeesh1")) return Response.json({ login: "oktofeesh1", public_repos: 2, followers: 1 }); + if (url.includes("/users/oktofeesh1/repos")) return Response.json([{ language: "TypeScript" }]); + return new Response("not found", { status: 404 }); + }); + + const basePayload = { + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { checks: "write", metadata: "read", pull_requests: "read" }, + events: ["issues", "pull_request", "repository"], + }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }, + }; + + await processJob(env, { + type: "github-webhook", + deliveryId: "pr-off", + eventName: "pull_request", + payload: { + action: "opened", + ...basePayload, + pull_request: { + number: 2, + title: "Fix webhook duplicate delivery", + state: "open", + user: { login: "oktofeesh1" }, + labels: [{ name: "bug" }], + body: "Fixes #1", + }, + }, + }); + expect(await listPullRequests(env, "JSONbored/gittensory")).toEqual(expect.arrayContaining([expect.objectContaining({ number: 2 })])); + + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "detected_contributors_only", + publicSignalLevel: "standard", + checkRunMode: "enabled", + checkRunDetailLevel: "standard", + backfillEnabled: true, + privateTrustEnabled: true, + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "pr-comment-attempt", + eventName: "pull_request", + payload: { + action: "synchronize", + ...basePayload, + pull_request: { + number: 3, + title: "Fix webhook duplicate delivery again", + state: "open", + user: { login: "oktofeesh1" }, + labels: [{ name: "bug" }], + body: "Fixes #1", + }, + }, + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "pr-comment-undetected", + eventName: "pull_request", + payload: { + action: "opened", + ...basePayload, + pull_request: { + number: 4, + title: "New contributor work", + state: "open", + user: { login: "newbie" }, + labels: [], + body: "Fixes #1", + }, + }, + }); + + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "all_prs", + publicSignalLevel: "minimal", + checkRunMode: "enabled", + checkRunDetailLevel: "standard", + backfillEnabled: true, + privateTrustEnabled: true, + }); + await processJob(env, { + type: "github-webhook", + deliveryId: "pr-comment-no-author", + eventName: "pull_request", + payload: { + action: "opened", + ...basePayload, + pull_request: { + number: 5, + title: "Anonymous webhook work", + state: "open", + labels: [], + body: "Fixes #1", + }, + }, + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "issue", + eventName: "issues", + payload: { + action: "opened", + ...basePayload, + issue: { + number: 1, + title: "Webhook duplicate delivery", + state: "open", + user: { login: "reporter" }, + labels: [{ name: "bug" }], + body: "Duplicate delivery should be idempotent.", + }, + }, + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "deleted", + eventName: "installation", + payload: { action: "deleted", installation: { id: 123 } }, + }); + }); + + it("records webhook processing errors when GitHub check creation fails", async () => { + const env = createTestEnv(); + const payload = { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }, + pull_request: { + number: 10, + title: "Check run failure path", + state: "open", + user: { login: "oktofeesh1" }, + head: { sha: "abc123" }, + labels: [], + body: "Fixes #1", + }, + }; + + await expect(processJob(env, { type: "github-webhook", deliveryId: "check-fail", eventName: "pull_request", payload })).rejects.toThrow(); + }); +}); + +function completeSegment(repoFullName: string, segment: "labels" | "open_issues" | "open_pull_requests") { + return { + repoFullName, + segment, + status: "complete" as const, + sourceKind: "test" as const, + mode: "resume" as const, + fetchedCount: 1, + expectedCount: 1, + pageCount: 1, + completedAt: "2026-05-25T00:00:00.000Z", + warnings: [], + }; +} diff --git a/test/unit/registry.test.ts b/test/unit/registry.test.ts index 49754ff746..ca964d117d 100644 --- a/test/unit/registry.test.ts +++ b/test/unit/registry.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { getRepository } from "../../src/db/repositories"; import { normalizeRegistryPayload } from "../../src/registry/normalize"; import { getLatestRegistrySnapshot, persistRegistrySnapshot, refreshRegistry } from "../../src/registry/sync"; import { createTestEnv } from "../helpers/d1"; @@ -33,6 +34,56 @@ describe("registry normalization", () => { }); }); + it("normalizes repository-list and array payload shapes defensively", () => { + const fromRepositoryList = normalizeRegistryPayload( + { + repositories: [ + { + full_name: "entrius/allways", + emission_share: 0.02, + issue_discovery_share: 1, + trusted_label_pipeline: true, + label_multipliers: { bug: 1.2, ignored: "not-a-number" }, + }, + { repo: "", emission_share: 1 }, + null, + ], + }, + { kind: "api", url: "https://example.test/api" }, + "2026-05-22T00:00:00.000Z", + ); + + const fromArray = normalizeRegistryPayload( + [ + { + repository_full_name: "JSONbored/gittensory", + emission_share: 0.03, + issue_discovery_share: 0, + maintainer_cut: 0.1, + default_label_multiplier: 0.5, + fixed_base_score: 2, + eligibility_mode: "active", + }, + { repo: "bad/numbers", emission_share: Number.NaN, issue_discovery_share: "bad" }, + "not-a-repo", + ], + { kind: "raw-github", url: "https://example.test/master_repositories.json" }, + "2026-05-22T00:00:00.000Z", + ); + + const empty = normalizeRegistryPayload("not-json-object", { kind: "raw-github", url: "https://example.test" }, "2026-05-22T00:00:00.000Z"); + + expect(fromRepositoryList.repositories[0]).toMatchObject({ + repo: "entrius/allways", + issueDiscoveryShare: 1, + labelMultipliers: { bug: 1.2 }, + trustedLabelPipeline: true, + }); + expect(fromArray.repositories.map((repo) => repo.repo)).toEqual(["JSONbored/gittensory", "bad/numbers"]); + expect(fromArray.repositories.find((repo) => repo.repo === "bad/numbers")).toMatchObject({ emissionShare: 0, issueDiscoveryShare: 0.5 }); + expect(empty.repoCount).toBe(0); + }); + it("persists and reads the latest snapshot from D1", async () => { const env = createTestEnv(); const snapshot = normalizeRegistryPayload( @@ -48,6 +99,40 @@ describe("registry normalization", () => { expect(latest?.source.kind).toBe("raw-github"); }); + it("marks previously registered repos as unregistered when they disappear from the latest snapshot", async () => { + const env = createTestEnv(); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "JSONbored/gittensory": { emission_share: 0.02, issue_discovery_share: 0 }, + "JSONbored/awesome-claude": { emission_share: 0.01, issue_discovery_share: 0 }, + }, + { kind: "raw-github", url: "fixture://old-registry" }, + "2026-05-22T00:00:00.000Z", + ), + ); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { + "JSONbored/awesome-claude": { emission_share: 0.01, issue_discovery_share: 0 }, + }, + { kind: "raw-github", url: "fixture://current-registry" }, + "2026-05-23T00:00:00.000Z", + ), + ); + + await expect(getRepository(env, "JSONbored/gittensory")).resolves.toMatchObject({ + isRegistered: false, + registryConfig: null, + }); + await expect(getRepository(env, "JSONbored/awesome-claude")).resolves.toMatchObject({ + isRegistered: true, + registryConfig: expect.objectContaining({ repo: "JSONbored/awesome-claude" }), + }); + }); + it("falls back to raw GitHub when registry API probes fail", async () => { vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); diff --git a/test/unit/rules.test.ts b/test/unit/rules.test.ts index c7e6b6d5de..296d14f50b 100644 --- a/test/unit/rules.test.ts +++ b/test/unit/rules.test.ts @@ -84,4 +84,89 @@ describe("advisory rules", () => { expect(advisory.findings.map((finding) => finding.code)).toContain("duplicate_pr_risk"); }); + + it("adds private reviewability context to check output without reward language", () => { + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 12, + title: "Add registry sync", + state: "open", + authorLogin: "oktofeesh1", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [4], + }; + + const advisory = buildPullRequestAdvisory(repo, pr, { + reviewabilityText: "Reviewability 72/100; action needs_author; missing tests and duplicate context should be cleared first.", + }); + + expect(advisory.findings.map((finding) => finding.code)).toContain("private_reviewability_context"); + expect(formatCheckRunOutput(advisory).text).toContain("Reviewability 72/100"); + expect(formatCheckRunOutput(advisory).text).not.toMatch(/reward|farming|wallet|hotkey/i); + }); + + it("covers repository config lane advisories", () => { + const issueDiscoveryRepo: RepositoryRecord = { + ...repo, + registryConfig: { + ...repo.registryConfig!, + issueDiscoveryShare: 1, + maintainerCut: 0.2, + }, + }; + const missingConfigRepo: RepositoryRecord = { ...repo, registryConfig: null }; + const unregisteredRepo: RepositoryRecord = { ...repo, isRegistered: false }; + + expect(buildRepositoryAdvisory(issueDiscoveryRepo, repo.fullName).findings.map((finding) => finding.code)).toEqual([ + "direct_pr_pool_disabled", + "maintainer_cut_enabled", + ]); + expect(buildRepositoryAdvisory(missingConfigRepo, repo.fullName).findings.map((finding) => finding.code)).toContain("repo_config_missing"); + expect(buildRepositoryAdvisory(unregisteredRepo, repo.fullName).conclusion).toBe("action_required"); + }); + + it("classifies closed and maintainer-authored PR metadata", () => { + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 15, + title: "Tidy registry sync", + state: "closed", + authorLogin: "maintainer", + authorAssociation: "OWNER", + labels: ["feature"], + linkedIssues: [9], + }; + const otherOpenPullRequests = Array.from({ length: 10 }, (_, index): PullRequestRecord => ({ + ...pr, + number: 100 + index, + state: "open", + authorAssociation: "NONE", + linkedIssues: [20 + index], + })); + + const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests }); + const codes = advisory.findings.map((finding) => finding.code); + + expect(codes).toEqual(expect.arrayContaining(["pr_not_open", "busy_pr_queue", "label_context_found", "maintainer_authored_pr"])); + }); + + it("handles uncached PRs and closed issues", () => { + const closedIssue: IssueRecord = { + repoFullName: repo.fullName, + number: 22, + title: "Closed issue", + state: "closed", + authorLogin: "reporter", + labels: [], + linkedPrs: [], + }; + const uncachedPr = buildPullRequestAdvisory(repo, null); + const issueAdvisory = buildIssueAdvisory(repo, closedIssue); + + expect(uncachedPr.findings.map((finding) => finding.code)).toContain("pr_not_cached"); + expect(issueAdvisory.findings.map((finding) => finding.code)).toEqual(expect.arrayContaining(["issue_not_open", "issue_discovery_not_configured"])); + expect(formatCheckRunOutput({ ...uncachedPr, findings: [] }).text).toBe("No advisory findings."); + }); }); diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts new file mode 100644 index 0000000000..6a338f9197 --- /dev/null +++ b/test/unit/scoring.test.ts @@ -0,0 +1,198 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getLatestScoringModelSnapshot } from "../../src/db/repositories"; +import { detectActiveModel, parsePythonNumberConstants, refreshScoringModelSnapshot } from "../../src/scoring/model"; +import { buildScorePreview, makeScorePreviewRecord } from "../../src/scoring/preview"; +import type { RepositoryRecord, ScoringModelSnapshotRecord } from "../../src/types"; +import { createTestEnv } from "../helpers/d1"; + +const snapshot: ScoringModelSnapshotRecord = { + id: "score-model-fixture", + sourceKind: "test", + sourceUrl: "fixture://constants.py", + fetchedAt: "2026-05-23T00:00:00.000Z", + activeModel: "current_density_model", + constants: { + OSS_EMISSION_SHARE: 0.9, + MERGED_PR_BASE_SCORE: 25, + MIN_TOKEN_SCORE_FOR_BASE_SCORE: 5, + MAX_CODE_DENSITY_MULTIPLIER: 1.15, + MAX_CONTRIBUTION_BONUS: 25, + CONTRIBUTION_SCORE_FOR_FULL_BONUS: 1500, + STANDARD_ISSUE_MULTIPLIER: 1.33, + MAINTAINER_ISSUE_MULTIPLIER: 1.66, + MIN_CREDIBILITY: 0.8, + REVIEW_PENALTY_RATE: 0.15, + EXCESSIVE_PR_PENALTY_BASE_THRESHOLD: 2, + OPEN_PR_THRESHOLD_TOKEN_SCORE: 300, + MAX_OPEN_PR_THRESHOLD: 30, + OPEN_PR_COLLATERAL_PERCENT: 0.2, + SRC_TOK_SATURATION_SCALE: 58, + }, + programmingLanguages: {}, + registrySnapshotId: "registry-fixture", + warnings: [], + payload: {}, +}; + +const repo: RepositoryRecord = { + fullName: "entrius/allways-ui", + owner: "entrius", + name: "allways-ui", + isInstalled: false, + isRegistered: true, + isPrivate: false, + registryConfig: { + repo: "entrius/allways-ui", + emissionShare: 0.02, + issueDiscoveryShare: 0.25, + labelMultipliers: { bug: 1.2, refactor: 0.5 }, + maintainerCut: 0, + raw: {}, + }, +}; + +describe("scoring model and previews", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("parses known upstream numeric constants and detects the current density model", () => { + const parsed = parsePythonNumberConstants(` +OSS_EMISSION_SHARE = 0.90 +MAX_CODE_DENSITY_MULTIPLIER = 1.15 +MIN_TOKEN_SCORE_FOR_BASE_SCORE = 5 +IGNORED = "not numeric" +`); + expect(parsed).toMatchObject({ OSS_EMISSION_SHARE: 0.9, MAX_CODE_DENSITY_MULTIPLIER: 1.15, MIN_TOKEN_SCORE_FOR_BASE_SCORE: 5 }); + expect(parsed).not.toHaveProperty("IGNORED"); + expect(detectActiveModel(parsed)).toBe("current_density_model"); + expect(detectActiveModel({ SRC_TOK_SATURATION_SCALE: 58 })).toBe("pending_saturation_model"); + expect(detectActiveModel({})).toBe("unknown"); + }); + + it("keeps lane math tied to the recorded model snapshot and clamps score gates", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + labels: ["bug"], + linkedIssueMode: "standard", + sourceTokenScore: 60, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 2, + credibility: 1, + }, + }); + expect(preview.scoringModelSnapshotId).toBe(snapshot.id); + expect(preview.laneMath).toMatchObject({ + repoSlice: 0.018, + directPrSlice: 0.0135, + issueDiscoverySlice: 0.0045, + }); + expect(preview.scoreEstimate.labelMultiplier).toBe(1.2); + expect(preview.scoreEstimate.issueMultiplier).toBe(1.33); + expect(preview.gates.baseTokenGatePassed).toBe(true); + expect(preview.privateOnly).toBe(true); + }); + + it("warns on metadata-only weak previews without using public reward or wallet language", () => { + const preview = buildScorePreview({ + repo: null, + snapshot, + input: { + repoFullName: "missing/repo", + metadataOnly: true, + sourceTokenScore: 1, + totalTokenScore: 1, + openPrCount: 99, + credibility: 0.2, + changesRequestedCount: 4, + }, + }); + expect(preview.recommendation.level).toBe("hold"); + expect(preview.warnings.join(" ")).toMatch(/metadata-only|not registered|base-score|threshold/i); + expect(JSON.stringify(preview)).not.toMatch(/wallet|farming|raw trust|guaranteed payout/i); + }); + + it("covers maintainer issue multipliers, fixed base scores, and evidence-derived credibility", () => { + const preview = buildScorePreview({ + repo: { ...repo, registryConfig: { ...repo.registryConfig!, fixedBaseScore: 12, defaultLabelMultiplier: 1.05 } }, + snapshot, + contributorEvidence: { + login: "jsonbored", + generatedAt: "2026-05-23T00:00:00.000Z", + payload: { mergedPullRequests: 4, stalePullRequests: 0, unlinkedPullRequests: 0 }, + }, + input: { + repoFullName: repo.fullName, + labels: ["unknown"], + linkedIssueMode: "maintainer", + sourceTokenScore: 100, + totalTokenScore: 200, + sourceLines: 10, + openPrCount: 0, + }, + }); + expect(preview.scoreEstimate.baseScore).toBe(12); + expect(preview.scoreEstimate.labelMultiplier).toBe(1.05); + expect(preview.scoreEstimate.issueMultiplier).toBe(1.66); + expect(preview.scoreEstimate.credibilityMultiplier).toBe(1); + + const explicitRecord = makeScorePreviewRecord({ repoFullName: repo.fullName, targetType: "pull_request", targetKey: "pr-1" }, snapshot, preview); + const defaultRecord = makeScorePreviewRecord({ repoFullName: repo.fullName }, snapshot, preview); + expect(explicitRecord).toMatchObject({ targetType: "pull_request", targetKey: "pr-1" }); + expect(defaultRecord).toMatchObject({ targetType: "planned_pr" }); + expect(defaultRecord.targetKey).toContain("entrius/allways-ui:planned_pr:"); + + const fallbackCredibility = buildScorePreview({ + repo, + snapshot, + contributorEvidence: { + login: "riskdev", + generatedAt: "2026-05-23T00:00:00.000Z", + payload: { mergedPullRequests: "not-a-number", stalePullRequests: 0, unlinkedPullRequests: 0 }, + }, + input: { + repoFullName: repo.fullName, + sourceTokenScore: Number.NaN, + totalTokenScore: Number.NaN, + sourceLines: Number.NaN, + }, + }); + expect(fallbackCredibility.gates.credibilityObserved).toBe(0.8); + expect(fallbackCredibility.gates.baseTokenGatePassed).toBe(false); + }); + + it("refreshes scoring snapshots from upstream fixtures and falls back cleanly", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "token" }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("constants.py")) { + return new Response("OSS_EMISSION_SHARE = 0.90\nMIN_TOKEN_SCORE_FOR_BASE_SCORE = 5\nMAX_CODE_DENSITY_MULTIPLIER = 1.15\n"); + } + if (url.includes("programming_languages.json")) return Response.json({ TypeScript: 1, Python: 0.8 }); + return new Response("not found", { status: 404 }); + }); + + const refreshed = await refreshScoringModelSnapshot(env); + expect(refreshed.sourceKind).toBe("raw-github"); + expect(refreshed.activeModel).toBe("current_density_model"); + expect(refreshed.programmingLanguages).toMatchObject({ TypeScript: 1 }); + await expect(getLatestScoringModelSnapshot(env)).resolves.toMatchObject({ id: refreshed.id }); + + const fallbackEnv = createTestEnv(); + vi.stubGlobal("fetch", async () => new Response("missing", { status: 404 })); + const fallback = await refreshScoringModelSnapshot(fallbackEnv); + expect(fallback.sourceKind).toBe("fallback"); + expect(fallback.warnings.join(" ")).toMatch(/fetch failed/i); + expect(fallback.constants.OSS_EMISSION_SHARE).toBe(0.9); + + vi.stubGlobal("fetch", async () => { + throw new Error("network down"); + }); + const thrownFallback = await refreshScoringModelSnapshot(createTestEnv()); + expect(thrownFallback.sourceKind).toBe("fallback"); + }); +}); diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts new file mode 100644 index 0000000000..1e6cb47209 --- /dev/null +++ b/test/unit/signals-v2.test.ts @@ -0,0 +1,1267 @@ +import { describe, expect, it } from "vitest"; +import { + buildCollisionEdges, + buildCollisionReport, + buildBurdenForecast, + buildBountyAdvisory, + buildContributorFit, + buildContributorOutcomeHistory, + buildContributorPatternReport, + buildContributorProfile, + buildContributorScoringProfile, + buildContributorStrategy, + buildContributorIntakeHealth, + buildIssueQualityReport, + buildLabelAudit, + buildLocalDiffPreflightResult, + buildMaintainerCutReadiness, + buildMaintainerLaneReport, + buildMaintainerPacket, + buildPreflightResult, + buildPublicPrIntelligenceComment, + buildPullRequestMaintainerPacket, + buildPullRequestReviewIntelligence, + buildQueueHealth, + buildRegistryChangeReport, + buildRepoFitRecommendation, + buildRoleContext, +} from "../../src/signals/engine"; +import { + buildContributorRewardRiskStrategy, + buildMaintainerNoiseReport, + buildPullRequestReviewability, + buildRepoRewardRisk, +} from "../../src/signals/reward-risk"; +import type { ContributorRepoStatRecord, IssueRecord, PullRequestRecord, RecentMergedPullRequestRecord, RegistrySnapshot, RepoLabelRecord, RepositoryRecord, ScoringModelSnapshotRecord } from "../../src/types"; + +const repo: RepositoryRecord = { + fullName: "JSONbored/gittensory", + owner: "JSONbored", + name: "gittensory", + isInstalled: true, + isRegistered: true, + isPrivate: true, + defaultBranch: "main", + registryConfig: { + repo: "JSONbored/gittensory", + emissionShare: 0.01, + issueDiscoveryShare: 0, + labelMultipliers: { bug: 1.2, "status:ready": 0.2, missing: 0.5 }, + trustedLabelPipeline: true, + maintainerCut: 0, + raw: {}, + }, +}; + +const issues: IssueRecord[] = [ + { + repoFullName: repo.fullName, + number: 1, + title: "Webhook processing fails on duplicate delivery", + state: "open", + authorLogin: "reporter", + labels: ["bug"], + linkedPrs: [], + body: "Duplicate delivery should be idempotent.", + }, +]; + +const pullRequests: PullRequestRecord[] = [ + { + repoFullName: repo.fullName, + number: 10, + title: "Fix webhook processing duplicate delivery", + state: "open", + authorLogin: "oktofeesh1", + authorAssociation: "NONE", + labels: ["bug"], + linkedIssues: [1], + body: "Fixes #1", + updatedAt: "2026-05-23T00:00:00.000Z", + }, + { + repoFullName: repo.fullName, + number: 11, + title: "Alternative webhook processing fix", + state: "open", + authorLogin: "other", + authorAssociation: "MEMBER", + labels: ["bug"], + linkedIssues: [1], + body: "Fixes #1", + updatedAt: "2026-04-01T00:00:00.000Z", + }, +]; + +const recentMergedPullRequests: RecentMergedPullRequestRecord[] = [ + { + repoFullName: repo.fullName, + number: 9, + title: "Fix webhook processing duplicate delivery", + authorLogin: "oktofeesh1", + labels: ["bug"], + linkedIssues: [1], + changedFiles: ["src/github/webhook.ts"], + mergedAt: "2026-05-22T00:00:00.000Z", + payload: {}, + }, +]; + +describe("v2 signal builders", () => { + it("audits trusted label pipeline readiness", () => { + const labels: RepoLabelRecord[] = [ + { repoFullName: repo.fullName, name: "bug", isConfigured: true, observedCount: 3, payload: {} }, + { repoFullName: repo.fullName, name: "enhancement", isConfigured: false, observedCount: 0, payload: {} }, + ]; + const audit = buildLabelAudit(repo, labels, issues, pullRequests, repo.fullName); + expect(audit.missingConfiguredLabels).toEqual(["missing", "status:ready"]); + expect(audit.suspiciousConfiguredLabels).toEqual(["status:ready"]); + expect(audit.trustedPipelineReady).toBe(false); + }); + + it("uses recent merged PRs and linked issues in collision radar", () => { + const report = buildCollisionReport(repo.fullName, issues, pullRequests, recentMergedPullRequests); + expect(report.summary.itemsReviewed).toBe(4); + expect(report.summary.highRiskCount).toBeGreaterThan(0); + const edges = buildCollisionEdges(report); + expect(edges[0]).toMatchObject({ repoFullName: repo.fullName, risk: expect.any(String) }); + }); + + it("keeps collision radar bounded for huge issue queues while preserving queue totals", () => { + const manyIssues = Array.from({ length: 1000 }, (_, index) => ({ + repoFullName: repo.fullName, + number: index + 1, + title: `Issue ${index + 1}`, + state: "open" as const, + labels: [], + linkedPrs: [], + })); + const linkedPr = { ...pullRequests[0]!, number: 5000, linkedIssues: [999], title: "Fix issue 999" }; + const report = buildCollisionReport(repo.fullName, manyIssues, [linkedPr], []); + const health = buildQueueHealth(repo, manyIssues, [linkedPr], report); + + expect(report.summary.itemsReviewed).toBe(1001); + expect(report.clusters).toEqual(expect.arrayContaining([expect.objectContaining({ id: "issue-999" })])); + expect(health.signals.openIssues).toBe(1000); + expect(health.signals.openPullRequests).toBe(1); + }); + + it("uses authoritative queue counts when signal inputs are sampled", () => { + const sampledIssues = issues.slice(0, 1); + const sampledPullRequests = pullRequests.slice(0, 1); + const report = buildCollisionReport(repo.fullName, sampledIssues, sampledPullRequests, []); + const health = buildQueueHealth(repo, sampledIssues, sampledPullRequests, report, { openIssues: 2912, openPullRequests: 169 }); + const intake = buildContributorIntakeHealth(repo, sampledIssues, sampledPullRequests, repo.fullName, report, { openIssues: 2912, openPullRequests: 169 }); + const lane = buildMaintainerLaneReport(repo, sampledIssues, sampledPullRequests, repo.fullName, report, { openIssues: 2912, openPullRequests: 169 }); + + expect(health.signals.openIssues).toBe(2912); + expect(health.signals.openPullRequests).toBe(169); + expect(intake.queueHealth.signals.openIssues).toBe(2912); + expect(lane.queueHealth.signals.openPullRequests).toBe(169); + }); + + it("falls back independently when only one authoritative queue count is present", () => { + const report = buildCollisionReport(repo.fullName, issues, pullRequests, []); + const issueOnly = buildQueueHealth(repo, issues, pullRequests, report, { openIssues: 50 }); + const prOnly = buildQueueHealth(repo, issues, pullRequests, report, { openPullRequests: 25 }); + + expect(issueOnly.signals.openIssues).toBe(50); + expect(issueOnly.signals.openPullRequests).toBe(pullRequests.length); + expect(prOnly.signals.openIssues).toBe(issues.length); + expect(prOnly.signals.openPullRequests).toBe(25); + }); + + it("adds queue age buckets and likely-reviewable counts", () => { + const report = buildCollisionReport(repo.fullName, issues, pullRequests, recentMergedPullRequests); + const health = buildQueueHealth(repo, issues, pullRequests, report); + expect(health.signals.ageBuckets.over30Days).toBeGreaterThanOrEqual(1); + expect(health.signals.maintainerAuthoredPullRequests).toBe(1); + expect(health.signals.likelyReviewablePullRequests).toBeGreaterThanOrEqual(1); + }); + + it("builds contributor fit from language and cached repo stats", () => { + const profile = buildContributorProfile("oktofeesh1", { login: "oktofeesh1", topLanguages: ["TypeScript"], source: "github" }, pullRequests, issues); + const fit = buildContributorFit( + profile, + [repo], + issues, + pullRequests, + [ + { + repoFullName: repo.fullName, + status: "success", + sourceKind: "github", + primaryLanguage: "TypeScript", + openIssuesCount: 1, + openPullRequestsCount: 2, + recentMergedPullRequestsCount: 1, + warnings: [], + }, + ], + [ + { + login: "oktofeesh1", + repoFullName: repo.fullName, + pullRequests: 2, + mergedPullRequests: 1, + openPullRequests: 1, + issues: 0, + stalePullRequests: 0, + unlinkedPullRequests: 0, + dominantLabels: ["bug"], + }, + ], + ); + expect(fit.languageFit[0]).toMatchObject({ repoFullName: repo.fullName, match: true }); + expect(fit.repoStats[0]).toMatchObject({ mergedPullRequests: 1 }); + }); + + it("preflights local diffs without source content", () => { + const result = buildLocalDiffPreflightResult( + { + repoFullName: repo.fullName, + title: "Fix webhook processing duplicate delivery", + commitMessage: "fix: resolve duplicate delivery\n\nFixes #1", + changedFiles: ["src/github/webhook.ts", "test/unit/webhook.test.ts"], + changedLineCount: 120, + }, + repo, + issues, + pullRequests, + ); + expect(result.localDiff).toMatchObject({ changedFileCount: 2, codeFileCount: 1, testFileCount: 1, inferredLinkedIssues: [1] }); + expect(JSON.stringify(result)).not.toMatch(/reward|farming|wallet/i); + }); + + it("builds a PR-specific maintainer packet", () => { + const packet = buildPullRequestMaintainerPacket({ + repo, + pullRequest: pullRequests[0]!, + issues, + pullRequests, + files: [ + { + repoFullName: repo.fullName, + pullNumber: 10, + path: "src/github/webhook.ts", + additions: 20, + deletions: 4, + changes: 24, + payload: {}, + }, + ], + reviews: [ + { + id: "review-1", + repoFullName: repo.fullName, + pullNumber: 10, + reviewerLogin: "maintainer", + state: "CHANGES_REQUESTED", + payload: {}, + }, + ], + checks: [{ id: "check-1", repoFullName: repo.fullName, pullNumber: 10, name: "test", status: "completed", conclusion: "failure", payload: {} }], + recentMergedPullRequests, + repoFullName: repo.fullName, + pullNumber: 10, + }); + expect(packet.reviewPriority).toBe("needs_author"); + expect(packet.changeSummary.additions).toBe(20); + expect(packet.reviewSignals.checkFailureCount).toBe(1); + }); + + it("reports registry changes between snapshots", () => { + const current = snapshot("new", [ + { repo: "JSONbored/gittensory", emissionShare: 0.02, issueDiscoveryShare: 0, labelMultipliers: { bug: 1.1 } }, + { repo: "JSONbored/awesome-claude", emissionShare: 0.01, issueDiscoveryShare: 0, labelMultipliers: {} }, + ]); + const previous = snapshot("old", [{ repo: "JSONbored/gittensory", emissionShare: 0.01, issueDiscoveryShare: 0, labelMultipliers: {} }]); + const report = buildRegistryChangeReport([current, previous]); + expect(report.addedRepos).toEqual(["JSONbored/awesome-claude"]); + expect(report.changedRepos[0]?.changes).toContain("emission_share 0.01 -> 0.02"); + }); + + it("builds repo-level maintainer packets with fallback actions", () => { + const packet = buildMaintainerPacket(repo, [], [], repo.fullName); + const busyPacket = buildMaintainerPacket(repo, issues, pullRequests, repo.fullName); + + expect(packet.suggestedActions).toEqual(["Queue looks manageable from cached Gittensory signals."]); + expect(busyPacket.pullRequestPackets.map((item) => item.reviewPriority)).toContain("needs_author"); + expect(busyPacket.suggestedActions.length).toBeGreaterThan(1); + }); + + it("covers preflight statuses, review burden levels, and local diff warnings", () => { + const ready = buildPreflightResult( + { repoFullName: repo.fullName, title: "Update docs", body: "Fixes #1", changedFiles: ["docs/guide.md"], tests: ["manual docs check"] }, + repo, + [], + [], + ); + const medium = buildPreflightResult( + { repoFullName: repo.fullName, title: "Small typed change", body: "Fixes #1", changedFiles: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts", "src/e.ts"], tests: ["vitest"] }, + repo, + [], + [], + ); + const hold = buildPreflightResult({ repoFullName: "unknown/repo", title: "Unknown lane" }, null, [], []); + const large = buildLocalDiffPreflightResult( + { repoFullName: repo.fullName, title: "Large diff", body: "Fixes #1", changedFiles: ["src/a.ts"], changedLineCount: 900 }, + repo, + [], + [], + ); + + expect(ready.status).toBe("ready"); + expect(ready.reviewBurden).toBe("low"); + expect(medium.reviewBurden).toBe("medium"); + expect(hold.status).toBe("hold"); + expect(large.findings.map((finding) => finding.code)).toEqual(expect.arrayContaining(["large_local_diff", "local_diff_missing_tests"])); + }); + + it("builds clean, missing, and watch PR maintainer packets", () => { + const cleanPr = { ...pullRequests[0]!, linkedIssues: [1], body: "Fixes #1" }; + const cleanPacket = buildPullRequestMaintainerPacket({ + repo, + pullRequest: cleanPr, + issues: [], + pullRequests: [cleanPr], + files: [ + { repoFullName: repo.fullName, pullNumber: cleanPr.number, path: "src/github/webhook.ts", additions: 8, deletions: 2, changes: 10, payload: {} }, + { repoFullName: repo.fullName, pullNumber: cleanPr.number, path: "test/unit/webhook.test.ts", additions: 12, deletions: 0, changes: 12, payload: {} }, + ], + reviews: [{ id: "approved", repoFullName: repo.fullName, pullNumber: cleanPr.number, state: "APPROVED", payload: {} }], + checks: [{ id: "ok", repoFullName: repo.fullName, pullNumber: cleanPr.number, name: "test", status: "completed", conclusion: "success", payload: {} }], + recentMergedPullRequests: [], + repoFullName: repo.fullName, + pullNumber: cleanPr.number, + }); + const watchPacket = buildPullRequestMaintainerPacket({ + repo, + pullRequest: cleanPr, + issues: [], + pullRequests: [cleanPr], + files: [], + reviews: [], + checks: [], + recentMergedPullRequests: [], + repoFullName: repo.fullName, + pullNumber: cleanPr.number, + }); + const unlinkedPacket = buildPullRequestMaintainerPacket({ + repo, + pullRequest: { ...cleanPr, linkedIssues: [] }, + issues: [], + pullRequests: [{ ...cleanPr, linkedIssues: [] }], + files: [], + reviews: [], + checks: [], + recentMergedPullRequests: [], + repoFullName: repo.fullName, + pullNumber: cleanPr.number, + }); + const missingPacket = buildPullRequestMaintainerPacket({ + repo, + pullRequest: null, + issues: [], + pullRequests: [], + files: [], + reviews: [], + checks: [], + recentMergedPullRequests: [], + repoFullName: repo.fullName, + pullNumber: 404, + }); + + expect(cleanPacket.reviewPriority).toBe("review"); + expect(watchPacket.reviewPriority).toBe("watch"); + expect(unlinkedPacket.findings.map((finding) => finding.code)).toContain("missing_linked_issue"); + expect(missingPacket.findings.map((finding) => finding.code)).toContain("pr_not_cached"); + }); + + it("handles registry change report boundaries and all tracked fields", () => { + const onlyCurrent = snapshot("only", [{ repo: "JSONbored/gittensory", emissionShare: 0.02, issueDiscoveryShare: 0, labelMultipliers: {} }]); + const current = snapshot("current", [ + { repo: "JSONbored/gittensory", emissionShare: 0.02, issueDiscoveryShare: 1, labelMultipliers: { bug: 1 } }, + ]); + current.repositories[0]!.maintainerCut = 0.5; + current.repositories[0]!.trustedLabelPipeline = true; + const previous = snapshot("previous", [ + { repo: "JSONbored/gittensory", emissionShare: 0.02, issueDiscoveryShare: 0, labelMultipliers: {} }, + { repo: "old/repo", emissionShare: 0.01, issueDiscoveryShare: 0, labelMultipliers: {} }, + ]); + + expect(buildRegistryChangeReport([]).summary).toMatch(/No registry snapshots/); + expect(buildRegistryChangeReport([onlyCurrent]).addedRepos).toEqual(["JSONbored/gittensory"]); + const changed = buildRegistryChangeReport([current, previous]); + expect(changed.removedRepos).toEqual(["old/repo"]); + expect(changed.changedRepos[0]?.changes).toEqual( + expect.arrayContaining(["issue_discovery_share 0 -> 1", "maintainer_cut 0 -> 0.5", "label_multipliers changed", "trusted_label_pipeline false -> true"]), + ); + }); + + it("keeps collision edge generation stable for short and low-risk clusters", () => { + const edges = buildCollisionEdges({ + repoFullName: repo.fullName, + generatedAt: "2026-05-23T00:00:00.000Z", + summary: { clusterCount: 2, highRiskCount: 0, itemsReviewed: 2 }, + clusters: [ + { id: "single", risk: "low", reason: "Single item", items: [{ type: "issue", number: 1, title: "" }] }, + { + id: "low-risk", + risk: "low", + reason: "Manual low risk", + items: [ + { type: "issue", number: 1, title: "" }, + { type: "pull_request", number: 2, title: "docs only", body: "docs only" }, + ], + }, + ], + }); + + expect(edges).toHaveLength(1); + expect(edges[0]?.risk).toBe("low"); + }); + + it("classifies issue quality, burden forecasts, and contributor strategy branches", () => { + const issueSet: IssueRecord[] = [ + { + repoFullName: repo.fullName, + number: 21, + title: "Ready issue with clear reproduction", + state: "open", + body: "This issue has a clear reproduction path, expected behavior, actual behavior, logs, screenshots, and a narrow implementation scope for a contributor.", + labels: ["bug"], + linkedPrs: [], + }, + { + repoFullName: repo.fullName, + number: 22, + title: "Thin report", + state: "open", + body: "bad", + labels: [], + linkedPrs: [], + updatedAt: "2025-01-01T00:00:00.000Z", + }, + { + repoFullName: repo.fullName, + number: 23, + title: "Already solved report", + state: "open", + body: "This already has linked work.", + labels: ["bug"], + linkedPrs: [44], + }, + ]; + const prSet: PullRequestRecord[] = [ + { + repoFullName: repo.fullName, + number: 44, + title: "Fix already solved report", + state: "open", + linkedIssues: [23], + labels: ["bug"], + authorLogin: "oktofeesh1", + body: "Fixes #23", + updatedAt: "2025-01-01T00:00:00.000Z", + }, + ]; + const issueQuality = buildIssueQualityReport( + { ...repo, registryConfig: { ...repo.registryConfig!, issueDiscoveryShare: 0.5 } }, + issueSet, + prSet, + repo.fullName, + ); + expect(issueQuality.issues.map((issue) => issue.status)).toEqual(expect.arrayContaining(["ready", "needs_proof", "do_not_use"])); + + const collisions = buildCollisionReport(repo.fullName, issueSet, prSet); + const forecast = buildBurdenForecast(repo, issueSet, prSet, collisions, 7); + expect(forecast.horizonDays).toBe(7); + expect(forecast.forecast.stalePullRequests).toBeGreaterThanOrEqual(1); + + const profile = buildContributorProfile("oktofeesh1", { login: "oktofeesh1", topLanguages: ["TypeScript"], source: "github" }, prSet, issueSet); + const fit = buildContributorFit( + profile, + [repo, { ...repo, fullName: "unknown/lane", isRegistered: false, registryConfig: null }], + issueSet, + prSet, + [ + { repoFullName: repo.fullName, status: "success", sourceKind: "github", primaryLanguage: "TypeScript", openIssuesCount: 3, openPullRequestsCount: 1, recentMergedPullRequestsCount: 0, warnings: [] }, + { repoFullName: "unknown/lane", status: "skipped", sourceKind: "github", primaryLanguage: "Rust", openIssuesCount: 0, openPullRequestsCount: 10, recentMergedPullRequestsCount: 0, warnings: [] }, + ], + [ + { + login: "oktofeesh1", + repoFullName: repo.fullName, + pullRequests: 1, + mergedPullRequests: 3, + openPullRequests: 1, + issues: 2, + stalePullRequests: 0, + unlinkedPullRequests: 0, + dominantLabels: ["bug"], + }, + ], + ); + const scoringProfile = buildContributorScoringProfile({ login: "oktofeesh1", fit, scoringSnapshot: scoringSnapshot() }); + const strategy = buildContributorStrategy({ login: "oktofeesh1", fit, scoringProfile, scoringSnapshot: scoringSnapshot() }); + expect(scoringProfile.evidence.credibilityAssumption).toBeGreaterThanOrEqual(0.8); + expect(strategy.nextActions).toContain("Start with the highest-fit repo that has low duplicate and queue pressure."); + expect(JSON.stringify(strategy)).not.toMatch(/wallet|farming|reward/i); + }); + + it("builds role-aware maintainer lanes, outcome history, and review intelligence", () => { + const awesomeRepo: RepositoryRecord = { + ...repo, + fullName: "JSONbored/awesome-claude", + owner: "JSONbored", + name: "awesome-claude", + registryConfig: { ...repo.registryConfig!, repo: "jsonbored/awesome-claude", maintainerCut: 0 }, + }; + const sureRepo: RepositoryRecord = { + ...repo, + fullName: "we-promise/sure", + owner: "we-promise", + name: "sure", + registryConfig: { ...repo.registryConfig!, repo: "we-promise/sure", emissionShare: 0.03 }, + }; + const profile = buildContributorProfile( + "jsonbored", + { login: "JSONbored", topLanguages: ["Ruby", "TypeScript"], source: "github" }, + [], + [], + [], + { + source: "gittensor_api", + githubId: "49853598", + githubUsername: "JSONbored", + uid: 29, + hotkey: "hotkey", + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + issueDiscoveryScore: 0, + issueTokenScore: 0, + issueCredibility: 1, + isIssueEligible: false, + issueEligibleRepoCount: 0, + alphaPerDay: 0, + taoPerDay: 0, + usdPerDay: 0, + totals: { + pullRequests: 63, + mergedPullRequests: 46, + openPullRequests: 9, + closedPullRequests: 8, + openIssues: 44, + closedIssues: 4, + solvedIssues: 1, + validSolvedIssues: 1, + }, + repositories: [ + { + repoFullName: "jsonbored/awesome-claude", + pullRequests: 0, + mergedPullRequests: 0, + openPullRequests: 0, + closedPullRequests: 0, + openIssues: 42, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + isEligible: false, + isIssueEligible: false, + credibility: 0, + issueCredibility: 0, + totalScore: 0, + baseTotalScore: 0, + }, + { + repoFullName: "we-promise/sure", + pullRequests: 47, + mergedPullRequests: 37, + openPullRequests: 6, + closedPullRequests: 4, + openIssues: 0, + closedIssues: 0, + solvedIssues: 1, + validSolvedIssues: 1, + isEligible: true, + isIssueEligible: false, + credibility: 0.902439, + issueCredibility: 1, + totalScore: 43, + baseTotalScore: 681, + }, + ], + pullRequests: [], + issueLabels: ["feature"], + }, + ); + const repoStats: ContributorRepoStatRecord[] = [ + { login: "jsonbored", repoFullName: "we-promise/sure", pullRequests: 47, mergedPullRequests: 37, openPullRequests: 6, issues: 0, stalePullRequests: 0, unlinkedPullRequests: 0, dominantLabels: ["bug"] }, + ]; + const history = buildContributorOutcomeHistory({ + login: "jsonbored", + profile, + repositories: [awesomeRepo, sureRepo], + pullRequests: [{ ...pullRequests[0]!, repoFullName: awesomeRepo.fullName, authorLogin: "jsonbored", authorAssociation: "OWNER" }], + issues: [{ ...issues[0]!, repoFullName: awesomeRepo.fullName, authorLogin: "jsonbored", authorAssociation: "OWNER" }], + repoStats, + }); + const role = buildRoleContext({ login: "jsonbored", repo: awesomeRepo, repoFullName: awesomeRepo.fullName, pullRequests, issues, profile }); + const fit = buildContributorFit(profile, [awesomeRepo, sureRepo], issues, pullRequests, [], repoStats); + const scoringProfile = buildContributorScoringProfile({ login: "jsonbored", fit, scoringSnapshot: scoringSnapshot() }); + const strategy = buildContributorStrategy({ login: "jsonbored", fit, scoringProfile, scoringSnapshot: scoringSnapshot(), outcomeHistory: history }); + const recommendation = buildRepoFitRecommendation({ login: "jsonbored", repo: awesomeRepo, repoFullName: awesomeRepo.fullName, profile, outcomeHistory: history, issues, pullRequests }); + const intake = buildContributorIntakeHealth(awesomeRepo, issues, pullRequests, awesomeRepo.fullName); + const lane = buildMaintainerLaneReport(awesomeRepo, issues, pullRequests, awesomeRepo.fullName); + const cut = buildMaintainerCutReadiness(awesomeRepo, issues, pullRequests, awesomeRepo.fullName); + const review = buildPullRequestReviewIntelligence({ + repo: awesomeRepo, + pullRequest: { ...pullRequests[0]!, repoFullName: awesomeRepo.fullName, authorLogin: "jsonbored", authorAssociation: "OWNER" }, + issues, + pullRequests, + files: [], + reviews: [], + checks: [], + recentMergedPullRequests: [], + repoFullName: awesomeRepo.fullName, + pullNumber: 10, + profile, + outcomeHistory: history, + }); + + expect(role).toMatchObject({ role: "owner", maintainerLane: true, normalContributorEvidenceAllowed: false }); + expect(history.repoOutcomes.filter((outcome) => outcome.repoFullName.toLowerCase() === "jsonbored/awesome-claude")).toHaveLength(1); + expect(history.repoOutcomes.find((outcome) => outcome.repoFullName === "jsonbored/awesome-claude")).toMatchObject({ successLevel: "maintainer_context" }); + expect(buildContributorPatternReport(history, "failure").patterns.map((pattern) => pattern.title)).toContain("Raw issue activity is not solved discovery evidence"); + expect(strategy.maintainerLaneRepos).toEqual(expect.arrayContaining([expect.objectContaining({ repoFullName: "jsonbored/awesome-claude" })])); + expect(recommendation.recommendation).toBe("maintainer_lane"); + expect(intake.level).toEqual(expect.stringMatching(/healthy|watch|strained|blocked/)); + expect(lane.summary).toContain("Maintainer lane"); + expect(cut.recommendedAction).toEqual(expect.stringMatching(/consider_small_cut|fix_config_first|leave_disabled|review_existing_cut/)); + expect(review.recommendation).toBe("maintainer_lane"); + expect(JSON.stringify({ strategy, review })).not.toMatch(/wallet|farming|reward/i); + }); + + it("classifies role context from GitHub associations, official activity, cache activity, and unknown state", () => { + const memberPr: PullRequestRecord = { + ...pullRequests[0]!, + repoFullName: "org/project", + authorLogin: "dev", + authorAssociation: "MEMBER", + }; + const collaboratorIssue: IssueRecord = { + ...issues[0]!, + repoFullName: "org/project", + authorLogin: "helper", + authorAssociation: "COLLABORATOR", + }; + const officialProfile = buildContributorProfile( + "officialdev", + { login: "officialdev", topLanguages: [], source: "github" }, + [], + [], + [], + { + source: "gittensor_api", + githubId: "1", + githubUsername: "officialdev", + uid: 1, + hotkey: undefined, + isEligible: false, + credibility: 0, + eligibleRepoCount: 0, + issueDiscoveryScore: 0, + issueTokenScore: 0, + issueCredibility: 0, + isIssueEligible: false, + issueEligibleRepoCount: 0, + alphaPerDay: 0, + taoPerDay: 0, + usdPerDay: 0, + totals: { + pullRequests: 1, + mergedPullRequests: 0, + openPullRequests: 1, + closedPullRequests: 0, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + }, + repositories: [ + { + repoFullName: "org/project", + pullRequests: 1, + mergedPullRequests: 0, + openPullRequests: 1, + closedPullRequests: 0, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + isEligible: false, + isIssueEligible: false, + credibility: 0, + issueCredibility: 0, + totalScore: 0, + baseTotalScore: 0, + }, + ], + pullRequests: [], + issueLabels: [], + }, + ); + const cachedProfile = buildContributorProfile("cacheddev", { login: "cacheddev", topLanguages: [], source: "github" }, [{ ...memberPr, authorLogin: "cacheddev", authorAssociation: "NONE" }], []); + + expect(buildRoleContext({ login: "dev", repo: null, repoFullName: "org/project", pullRequests: [memberPr], issues: [] })).toMatchObject({ + role: "org_member", + maintainerLane: true, + source: "github_association", + association: "MEMBER", + }); + expect(buildRoleContext({ login: "helper", repo: null, repoFullName: "org/project", pullRequests: [], issues: [collaboratorIssue] })).toMatchObject({ + role: "collaborator", + maintainerLane: true, + source: "github_association", + association: "COLLABORATOR", + }); + expect(buildRoleContext({ login: "officialdev", repo: null, repoFullName: "org/project", profile: officialProfile })).toMatchObject({ + role: "outside_contributor", + maintainerLane: false, + source: "gittensor_api", + }); + expect(buildRoleContext({ login: "cacheddev", repo: null, repoFullName: "org/project", pullRequests: [{ ...memberPr, authorLogin: "cacheddev", authorAssociation: "NONE" }], issues: [], profile: cachedProfile })).toMatchObject({ + role: "outside_contributor", + source: "cache", + }); + expect(buildRoleContext({ login: "newdev", repo: null, repoFullName: "org/project", pullRequests: [], issues: [] })).toMatchObject({ + role: "unknown", + source: "unknown", + normalContributorEvidenceAllowed: true, + }); + }); + + it("branches repo fit recommendations across pursue, avoid, cleanup, unknown, and maintainer lanes", () => { + const cleanRepo: RepositoryRecord = { + ...repo, + fullName: "org/clean", + owner: "org", + name: "clean", + registryConfig: { ...repo.registryConfig!, repo: "org/clean", labelMultipliers: {} }, + }; + const profile = buildContributorProfile("dev", { login: "dev", topLanguages: ["TypeScript"], source: "github" }, [], []); + const noHistory = buildContributorOutcomeHistory({ login: "dev", profile, repositories: [cleanRepo], pullRequests: [], issues: [], repoStats: [] }); + const riskyProfile = buildContributorProfile( + "riskdev", + { login: "riskdev", topLanguages: ["TypeScript"], source: "github" }, + [], + [], + [], + { + source: "gittensor_api", + githubId: "2", + githubUsername: "riskdev", + uid: 2, + hotkey: undefined, + isEligible: false, + credibility: 0.7, + eligibleRepoCount: 0, + issueDiscoveryScore: 0, + issueTokenScore: 0, + issueCredibility: 0, + isIssueEligible: false, + issueEligibleRepoCount: 0, + alphaPerDay: 0, + taoPerDay: 0, + usdPerDay: 0, + totals: { + pullRequests: 10, + mergedPullRequests: 2, + openPullRequests: 5, + closedPullRequests: 3, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + }, + repositories: [ + { + repoFullName: "org/clean", + pullRequests: 10, + mergedPullRequests: 2, + openPullRequests: 5, + closedPullRequests: 3, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + isEligible: false, + isIssueEligible: false, + credibility: 0.7, + issueCredibility: 0, + totalScore: 0, + baseTotalScore: 0, + }, + ], + pullRequests: [], + issueLabels: [], + }, + ); + const cleanupHistory = buildContributorOutcomeHistory({ login: "riskdev", profile: riskyProfile, repositories: [cleanRepo], pullRequests: [], issues: [], repoStats: [] }); + const collisionIssue: IssueRecord = { ...issues[0]!, repoFullName: cleanRepo.fullName, number: 42, title: "Improve sync reliability", linkedPrs: [] }; + const collidingPrs: PullRequestRecord[] = [ + { ...pullRequests[0]!, repoFullName: cleanRepo.fullName, number: 1, title: "Improve sync reliability", authorLogin: "other-a", authorAssociation: "NONE", linkedIssues: [42] }, + { ...pullRequests[1]!, repoFullName: cleanRepo.fullName, number: 2, title: "Improve sync reliability alternative", authorLogin: "other-b", authorAssociation: "NONE", linkedIssues: [42] }, + ]; + const ownerProfile = buildContributorProfile("org", { login: "org", topLanguages: [], source: "github" }, [], []); + + expect(buildRepoFitRecommendation({ login: "dev", repo: cleanRepo, repoFullName: cleanRepo.fullName, profile, outcomeHistory: noHistory, issues: [], pullRequests: [] }).recommendation).toBe("pursue"); + expect(buildRepoFitRecommendation({ login: "dev", repo: cleanRepo, repoFullName: cleanRepo.fullName, profile, outcomeHistory: noHistory, issues: [collisionIssue], pullRequests: collidingPrs }).recommendation).toBe("avoid_for_now"); + expect(buildRepoFitRecommendation({ login: "riskdev", repo: cleanRepo, repoFullName: cleanRepo.fullName, profile: riskyProfile, outcomeHistory: cleanupHistory, issues: [], pullRequests: [] }).recommendation).toBe("cleanup_first"); + expect(buildRepoFitRecommendation({ login: "dev", repo: null, repoFullName: "missing/repo", profile, outcomeHistory: noHistory, issues: [], pullRequests: [] }).recommendation).toBe("unknown"); + expect(buildRepoFitRecommendation({ login: "org", repo: cleanRepo, repoFullName: cleanRepo.fullName, profile: ownerProfile, outcomeHistory: noHistory, issues: [], pullRequests: [] }).recommendation).toBe("maintainer_lane"); + }); + + it("covers maintainer-cut readiness and contributor outcome pressure branches", () => { + const cleanRepo: RepositoryRecord = { + ...repo, + fullName: "org/ready", + owner: "org", + name: "ready", + registryConfig: { ...repo.registryConfig!, repo: "org/ready", labelMultipliers: {}, maintainerCut: 0 }, + }; + const paidRepo: RepositoryRecord = { + ...cleanRepo, + registryConfig: { ...cleanRepo.registryConfig!, maintainerCut: 0.05 }, + }; + const fragileRepo: RepositoryRecord = { + ...cleanRepo, + registryConfig: { ...cleanRepo.registryConfig!, emissionShare: 0, labelMultipliers: { missing: 0.2, absent: 0.1, stale: 0.1, unused: 0.1 } }, + }; + const riskProfile = buildContributorProfile( + "riskdev", + { login: "riskdev", topLanguages: [], source: "github" }, + [], + [], + [], + { + source: "gittensor_api", + githubId: "3", + githubUsername: "riskdev", + uid: 3, + hotkey: undefined, + isEligible: false, + credibility: 0.6, + eligibleRepoCount: 0, + issueDiscoveryScore: 0, + issueTokenScore: 0, + issueCredibility: 0, + isIssueEligible: false, + issueEligibleRepoCount: 0, + alphaPerDay: 0, + taoPerDay: 0, + usdPerDay: 0, + totals: { + pullRequests: 10, + mergedPullRequests: 3, + openPullRequests: 3, + closedPullRequests: 4, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + }, + repositories: [ + { + repoFullName: cleanRepo.fullName, + pullRequests: 10, + mergedPullRequests: 3, + openPullRequests: 3, + closedPullRequests: 4, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + isEligible: false, + isIssueEligible: false, + credibility: 0.6, + issueCredibility: 0, + totalScore: 0, + baseTotalScore: 0, + }, + ], + pullRequests: [], + issueLabels: [], + }, + ); + const history = buildContributorOutcomeHistory({ login: "riskdev", profile: riskProfile, repositories: [cleanRepo], pullRequests: [], issues: [], repoStats: [] }); + const failureTitles = buildContributorPatternReport(history, "failure").patterns.map((pattern) => pattern.title); + + expect(buildMaintainerCutReadiness(null, [], [], "missing/repo")).toMatchObject({ ready: false, recommendedAction: "leave_disabled" }); + expect(buildMaintainerCutReadiness(paidRepo, [], [], paidRepo.fullName)).toMatchObject({ maintainerCut: 0.05, recommendedAction: "review_existing_cut" }); + expect(buildMaintainerCutReadiness(fragileRepo, [], [], fragileRepo.fullName)).toMatchObject({ ready: false, recommendedAction: "fix_config_first" }); + expect(buildMaintainerCutReadiness(cleanRepo, [], [], cleanRepo.fullName)).toMatchObject({ ready: true, recommendedAction: "consider_small_cut" }); + expect(failureTitles).toEqual(expect.arrayContaining(["Closed PR credibility pressure", "Repo-specific closed PR risk", "Repo-specific open PR pressure"])); + }); + + it("builds private reward/risk strategy with cleanup leverage, lane blockers, and maintainer-lane actions", () => { + const directRepo: RepositoryRecord = { + ...repo, + fullName: "we-promise/sure", + owner: "we-promise", + name: "sure", + registryConfig: { ...repo.registryConfig!, repo: "we-promise/sure", emissionShare: 0.03, issueDiscoveryShare: 0, labelMultipliers: {} }, + }; + const issueOnlyRepo: RepositoryRecord = { + ...repo, + fullName: "entrius/allways", + owner: "entrius", + name: "allways", + registryConfig: { ...repo.registryConfig!, repo: "entrius/allways", emissionShare: 0.05, issueDiscoveryShare: 1, labelMultipliers: { bug: 1.25 } }, + }; + const splitRepo: RepositoryRecord = { + ...repo, + fullName: "entrius/das-github-mirror", + owner: "entrius", + name: "das-github-mirror", + registryConfig: { ...repo.registryConfig!, repo: "entrius/das-github-mirror", emissionShare: 0.02, issueDiscoveryShare: 0.35, labelMultipliers: { bug: 1.1 } }, + }; + const inactiveRepo: RepositoryRecord = { + ...repo, + fullName: "owner/inactive", + owner: "owner", + name: "inactive", + registryConfig: { ...repo.registryConfig!, repo: "owner/inactive", emissionShare: 0, issueDiscoveryShare: 0, labelMultipliers: {} }, + }; + const maintainerRepo: RepositoryRecord = { + ...repo, + fullName: "JSONbored/awesome-claude", + owner: "JSONbored", + name: "awesome-claude", + registryConfig: { ...repo.registryConfig!, repo: "JSONbored/awesome-claude", emissionShare: 0.01, issueDiscoveryShare: 0 }, + }; + const unregisteredProject: RepositoryRecord = { + ...repo, + fullName: "JSONbored/gittensory", + owner: "JSONbored", + name: "gittensory", + isRegistered: false, + registryConfig: null, + }; + const profile = buildContributorProfile( + "jsonbored", + { login: "jsonbored", topLanguages: ["Ruby", "TypeScript"], source: "github" }, + [], + [], + [], + { + source: "gittensor_api", + githubId: "49853598", + githubUsername: "jsonbored", + uid: 29, + hotkey: undefined, + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + issueDiscoveryScore: 0, + issueTokenScore: 0, + issueCredibility: 1, + isIssueEligible: false, + issueEligibleRepoCount: 0, + alphaPerDay: 0, + taoPerDay: 0, + usdPerDay: 0, + totals: { + pullRequests: 12, + mergedPullRequests: 5, + openPullRequests: 7, + closedPullRequests: 0, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + }, + repositories: [ + { + repoFullName: "we-promise/sure", + pullRequests: 9, + mergedPullRequests: 5, + openPullRequests: 7, + closedPullRequests: 0, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + isEligible: true, + isIssueEligible: false, + credibility: 1, + issueCredibility: 1, + totalScore: 0, + baseTotalScore: 0, + }, + ], + pullRequests: [], + issueLabels: [], + }, + ); + const history = buildContributorOutcomeHistory({ + login: "jsonbored", + profile, + repositories: [directRepo, issueOnlyRepo, maintainerRepo, unregisteredProject], + pullRequests: [{ ...pullRequests[0]!, repoFullName: maintainerRepo.fullName, authorLogin: "jsonbored", authorAssociation: "OWNER" }], + issues: [], + repoStats: [], + }); + const fit = buildContributorFit(profile, [directRepo, issueOnlyRepo, maintainerRepo, unregisteredProject], issues, pullRequests, [], []); + const scoringProfile = buildContributorScoringProfile({ login: "jsonbored", fit, scoringSnapshot: scoringSnapshot() }); + const direct = buildRepoRewardRisk({ + login: "jsonbored", + repo: directRepo, + repoFullName: directRepo.fullName, + profile, + outcomeHistory: history, + scoringSnapshot: scoringSnapshot(), + scoringProfile, + issues, + pullRequests, + }); + const issueOnly = buildRepoRewardRisk({ + login: "jsonbored", + repo: issueOnlyRepo, + repoFullName: issueOnlyRepo.fullName, + profile, + outcomeHistory: history, + scoringSnapshot: scoringSnapshot(), + scoringProfile, + issues, + pullRequests: [], + }); + const split = buildRepoRewardRisk({ + login: "jsonbored", + repo: splitRepo, + repoFullName: splitRepo.fullName, + profile, + outcomeHistory: history, + scoringSnapshot: scoringSnapshot(), + scoringProfile, + issues: [], + pullRequests: [], + }); + const inactive = buildRepoRewardRisk({ + login: "jsonbored", + repo: inactiveRepo, + repoFullName: inactiveRepo.fullName, + profile, + outcomeHistory: history, + scoringSnapshot: scoringSnapshot(), + scoringProfile, + issues: [], + pullRequests: [], + }); + const maintainer = buildRepoRewardRisk({ + login: "jsonbored", + repo: maintainerRepo, + repoFullName: maintainerRepo.fullName, + profile, + outcomeHistory: history, + scoringSnapshot: scoringSnapshot(), + scoringProfile, + issues: [], + pullRequests: [{ ...pullRequests[0]!, repoFullName: maintainerRepo.fullName, authorLogin: "jsonbored", authorAssociation: "OWNER" }], + }); + const strategy = buildContributorRewardRiskStrategy({ + login: "jsonbored", + fit, + scoringProfile, + scoringSnapshot: scoringSnapshot(), + outcomeHistory: history, + repositories: [directRepo, issueOnlyRepo, maintainerRepo, unregisteredProject], + allIssues: issues, + allPullRequests: pullRequests, + recentMergedPullRequests, + }); + + expect(direct.currentPreview.scoreEstimate.openPrMultiplier).toBe(0); + expect(direct.afterCleanupPreview.scoreEstimate.openPrMultiplier).toBe(1); + expect(direct.scoreBlockers).toContain("Open PR count exceeds the current threshold assumption."); + expect(direct.actions[0]?.actionKind).toBe("cleanup_existing_prs"); + expect(issueOnly.rewardUpside.relevantLane).toBe("issue_discovery"); + expect(issueOnly.scoreBlockers).toContain("Direct PR-side lane value is disabled for this repo."); + expect(issueOnly.actions.map((action) => action.actionKind)).toContain("file_issue_discovery"); + expect(split.lane.lane).toBe("split"); + expect(split.rewardUpside.relevantLane).toBe("direct_pr"); + expect(split.actions.map((action) => action.actionKind)).toEqual(expect.arrayContaining(["open_new_direct_pr", "file_issue_discovery"])); + expect(inactive.rewardUpside.relevantLane).toBe("none"); + expect(inactive.scoreBlockers).toContain("Repository allocation is inactive."); + expect(maintainer.roleContext.maintainerLane).toBe(true); + expect(maintainer.actions.map((action) => action.actionKind)).toEqual(expect.arrayContaining(["maintainer_lane_improve_repo", "maintainer_cut_readiness"])); + expect(strategy.repoAnalyses.map((analysis) => analysis.repoFullName)).not.toContain("JSONbored/gittensory"); + expect(strategy.topActions[0]?.actionKind).toBe("cleanup_existing_prs"); + expect(JSON.stringify(strategy)).not.toMatch(/wallet|hotkey|guaranteed payout|farming/i); + }); + + it("builds maintainer noise and PR reviewability without public shaming fields", () => { + const noise = buildMaintainerNoiseReport(repo, issues, pullRequests, recentMergedPullRequests, repo.fullName); + const reviewability = buildPullRequestReviewability({ + repo, + pullRequest: { ...pullRequests[0]!, linkedIssues: [] }, + issues, + pullRequests, + files: [{ repoFullName: repo.fullName, pullNumber: 10, path: "src/github/webhook.ts", additions: 200, deletions: 20, changes: 220, payload: {} }], + reviews: [{ id: "changes", repoFullName: repo.fullName, pullNumber: 10, state: "CHANGES_REQUESTED", payload: {} }], + checks: [{ id: "failed", repoFullName: repo.fullName, pullNumber: 10, name: "test", status: "completed", conclusion: "failure", payload: {} }], + recentMergedPullRequests, + repoFullName: repo.fullName, + pullNumber: 10, + }); + + expect(noise.noiseSources.length).toBeGreaterThan(0); + expect(noise.maintainerActions).toEqual(expect.arrayContaining(["likely_duplicate"])); + expect(reviewability.action).toEqual(expect.stringMatching(/needs_author|likely_duplicate|watch/)); + expect(reviewability.noiseSources).toEqual(expect.arrayContaining(["Missing linked issue or no-issue rationale.", "Code changes do not include cached test files."])); + expect(JSON.stringify({ noise, reviewability })).not.toMatch(/wallet|hotkey|raw trust score|ranking/i); + }); + + it("branches PR reviewability maintainer actions for clean, closed, maintainer, and watch cases", () => { + const cleanPr = { ...pullRequests[0]!, linkedIssues: [1], authorAssociation: "NONE" }; + const clean = buildPullRequestReviewability({ + repo, + pullRequest: cleanPr, + issues: [], + pullRequests: [cleanPr], + files: [ + { repoFullName: repo.fullName, pullNumber: 10, path: "src/github/webhook.ts", additions: 20, deletions: 2, changes: 22, payload: {} }, + { repoFullName: repo.fullName, pullNumber: 10, path: "test/unit/webhook.test.ts", additions: 25, deletions: 0, changes: 25, payload: {} }, + ], + reviews: [{ id: "approved", repoFullName: repo.fullName, pullNumber: 10, state: "APPROVED", payload: {} }], + checks: [{ id: "ok", repoFullName: repo.fullName, pullNumber: 10, name: "test", status: "completed", conclusion: "success", payload: {} }], + recentMergedPullRequests: [], + repoFullName: repo.fullName, + pullNumber: 10, + }); + const closed = buildPullRequestReviewability({ + repo, + pullRequest: { ...cleanPr, state: "closed" }, + issues: [], + pullRequests: [{ ...cleanPr, state: "closed" }], + files: [], + reviews: [], + checks: [], + recentMergedPullRequests: [], + repoFullName: repo.fullName, + pullNumber: 10, + }); + const maintainer = buildPullRequestReviewability({ + repo, + pullRequest: { ...cleanPr, authorAssociation: "OWNER" }, + issues: [], + pullRequests: [{ ...cleanPr, authorAssociation: "OWNER" }], + files: [], + reviews: [], + checks: [], + recentMergedPullRequests: [], + repoFullName: repo.fullName, + pullNumber: 10, + }); + const watch = buildPullRequestReviewability({ + repo, + pullRequest: { ...cleanPr, linkedIssues: [] }, + issues: [], + pullRequests: [{ ...cleanPr, linkedIssues: [] }], + files: [{ repoFullName: repo.fullName, pullNumber: 10, path: "src/large.ts", additions: 900, deletions: 0, changes: 900, payload: {} }], + reviews: [], + checks: [{ id: "failed", repoFullName: repo.fullName, pullNumber: 10, name: "test", status: "completed", conclusion: "failure", payload: {} }], + recentMergedPullRequests: [], + repoFullName: repo.fullName, + pullNumber: 10, + }); + + expect(clean.action).toBe("review_now"); + expect(closed.action).toBe("close_or_redirect"); + expect(maintainer.action).toBe("maintainer_lane"); + expect(watch.action).toBe("watch"); + expect(clean.maintainerNextSteps[0]).toContain("Review"); + expect(closed.maintainerNextSteps[0]).toContain("Redirect"); + expect(maintainer.maintainerNextSteps[0]).toContain("stewardship"); + expect(watch.maintainerNextSteps[0]).toContain("Watch"); + }); + + it("covers defensive signal branches for empty text, unmatched languages, active bounties, and public comment fallbacks", () => { + const emptyCollision = buildCollisionReport( + repo.fullName, + [ + { repoFullName: repo.fullName, number: 1, title: "", state: "open", labels: [], linkedPrs: [], body: "" }, + { repoFullName: repo.fullName, number: 2, title: "ab", state: "open", labels: [], linkedPrs: [], body: "" }, + ], + [], + ); + expect(emptyCollision.summary.clusterCount).toBe(0); + + const noLanguageProfile = buildContributorProfile("newdev", { login: "newdev", topLanguages: ["Rust"], source: "github" }, [], []); + const noLanguageFit = buildContributorFit( + noLanguageProfile, + [repo], + [], + [], + [{ repoFullName: repo.fullName, status: "success", sourceKind: "github", primaryLanguage: "TypeScript", openIssuesCount: 0, openPullRequestsCount: 0, recentMergedPullRequestsCount: 0, warnings: [] }], + [], + ); + expect(noLanguageFit.findings.map((finding) => finding.code)).toContain("no_language_fit"); + + const activeBounty = buildBountyAdvisory( + { id: "bounty-active", repoFullName: repo.fullName, issueNumber: 1, status: "Active", payload: { bounty_alpha: "1.0000" } }, + repo, + { repoFullName: repo.fullName, number: 1, title: "Funded", state: "open", labels: [], linkedPrs: [1, 2] }, + ); + expect(activeBounty).toMatchObject({ lifecycle: "active", fundingStatus: "funded", consensusRisk: "medium" }); + + const comment = buildPublicPrIntelligenceComment({ + repo, + pr: { ...pullRequests[0]!, authorLogin: undefined, linkedIssues: [] }, + profile: noLanguageProfile, + detection: { detected: false, reason: "none", priorPullRequests: 0, priorMergedPullRequests: 0, priorIssues: 0 }, + queueHealth: buildQueueHealth(repo, [], [], buildCollisionReport(repo.fullName, [], [])), + collisions: buildCollisionReport(repo.fullName, [], []), + preflight: buildPreflightResult({ repoFullName: repo.fullName, title: "Docs", body: "No linked issue", changedFiles: ["README.md"], tests: ["manual"] }, repo, [], []), + settings: { repoFullName: repo.fullName, commentMode: "all_prs", publicSignalLevel: "standard", checkRunMode: "enabled", checkRunDetailLevel: "standard", backfillEnabled: true, privateTrustEnabled: true }, + }); + expect(comment).toContain("Author: `unknown`"); + expect(comment).toContain("No prior cached registered-repo activity detected."); + expect(comment).not.toMatch(/wallet|raw trust score|ranking/i); + }); +}); + +function snapshot(id: string, repositories: Array<{ repo: string; emissionShare: number; issueDiscoveryShare: number; labelMultipliers: Record }>): RegistrySnapshot { + return { + id, + generatedAt: "2026-05-23T00:00:00.000Z", + fetchedAt: "2026-05-23T00:00:00.000Z", + source: { kind: "raw-github", url: "https://example.test" }, + repoCount: repositories.length, + totalEmissionShare: repositories.reduce((sum, repo) => sum + repo.emissionShare, 0), + warnings: [], + repositories: repositories.map((repo) => ({ + ...repo, + trustedLabelPipeline: false, + maintainerCut: 0, + raw: {}, + })), + }; +} + +function scoringSnapshot(): ScoringModelSnapshotRecord { + return { + id: "scoring-fixture", + sourceKind: "test", + sourceUrl: "fixture://scoring", + fetchedAt: "2026-05-23T00:00:00.000Z", + activeModel: "current_density_model", + constants: {}, + programmingLanguages: {}, + warnings: [], + payload: {}, + }; +} diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts index 3a085e7454..5f400fb187 100644 --- a/test/unit/signals.test.ts +++ b/test/unit/signals.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; import { + buildBountyAdvisory, buildCollisionReport, buildConfigQuality, buildContributorOpportunities, + buildContributorFit, buildContributorProfile, + buildContributorScoringProfile, + buildLabelAudit, buildLaneAdvice, buildPreflightResult, buildPublicPrIntelligenceComment, @@ -11,7 +15,7 @@ import { detectGittensorContributor, shouldPublishPrIntelligenceComment, } from "../../src/signals/engine"; -import type { IssueRecord, PullRequestRecord, RepositoryRecord } from "../../src/types"; +import type { BountyRecord, ContributorRepoStatRecord, IssueRecord, PullRequestRecord, RepositoryRecord, RepositorySettings, ScoringModelSnapshotRecord } from "../../src/types"; const repo: RepositoryRecord = { fullName: "entrius/allways-ui", @@ -114,6 +118,150 @@ describe("world-class backend signals", () => { expect(opportunities[0]?.repoFullName).toBe(repo.fullName); }); + it("profiles contributors from cached repo stats when sampled PR rows miss their history", () => { + const repoStats: ContributorRepoStatRecord[] = [ + { + login: "JSONbored", + repoFullName: "JSONbored/awesome-claude", + pullRequests: 49, + mergedPullRequests: 47, + openPullRequests: 1, + issues: 12, + stalePullRequests: 0, + unlinkedPullRequests: 1, + dominantLabels: ["bug", "ci"], + lastActivityAt: "2026-05-25T00:00:00.000Z", + }, + ]; + const profile = buildContributorProfile("jsonbored", { login: "JSONbored", topLanguages: ["TypeScript"], source: "github" }, [], [], repoStats); + const detection = detectGittensorContributor("jsonbored", { ...pullRequests[0]!, authorLogin: "JSONbored" }, [], [], repoStats); + + expect(profile.registeredRepoActivity).toMatchObject({ + pullRequests: 49, + mergedPullRequests: 47, + issues: 12, + reposTouched: ["JSONbored/awesome-claude"], + }); + expect(profile.trustSignals.level).toBe("established"); + expect(detection).toMatchObject({ detected: true, priorMergedPullRequests: 47, priorIssues: 12 }); + }); + + it("prefers Gittensor API contributor totals over broad GitHub cache history", () => { + const profile = buildContributorProfile( + "jsonbored", + { login: "JSONbored", topLanguages: ["Ruby", "Python"], source: "github" }, + [], + [], + [ + { + login: "jsonbored", + repoFullName: "JSONbored/awesome-claude", + pullRequests: 183, + mergedPullRequests: 164, + openPullRequests: 1, + issues: 86, + stalePullRequests: 0, + unlinkedPullRequests: 0, + dominantLabels: ["feature"], + }, + ], + { + source: "gittensor_api", + githubId: "49853598", + githubUsername: "JSONbored", + uid: 29, + hotkey: "hotkey", + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + issueDiscoveryScore: 0, + issueTokenScore: 0, + issueCredibility: 1, + isIssueEligible: false, + issueEligibleRepoCount: 0, + alphaPerDay: 72, + taoPerDay: 0.3, + usdPerDay: 92, + totals: { + pullRequests: 63, + mergedPullRequests: 46, + openPullRequests: 9, + closedPullRequests: 8, + openIssues: 44, + closedIssues: 4, + solvedIssues: 1, + validSolvedIssues: 1, + }, + repositories: [ + { + repoFullName: "we-promise/sure", + pullRequests: 47, + mergedPullRequests: 37, + openPullRequests: 6, + closedPullRequests: 4, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + isEligible: true, + isIssueEligible: false, + credibility: 0.9, + issueCredibility: 0, + totalScore: 43, + baseTotalScore: 549, + }, + { + repoFullName: "jsonbored/awesome-claude", + pullRequests: 0, + mergedPullRequests: 0, + openPullRequests: 0, + closedPullRequests: 0, + openIssues: 42, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + isEligible: false, + isIssueEligible: false, + credibility: 0, + issueCredibility: 0, + totalScore: 0, + baseTotalScore: 0, + }, + ], + pullRequests: [{ repoFullName: "we-promise/sure", number: 1869, title: "feat(imports): verify Sure NDJSON import readback", state: "MERGED", label: null, score: 13.55, baseScore: 16.73, tokenScore: 128.47 }], + issueLabels: ["feature", "help wanted"], + }, + ); + + expect(profile.source).toBe("gittensor_api"); + expect(profile.registeredRepoActivity).toMatchObject({ pullRequests: 63, mergedPullRequests: 46, issues: 48 }); + expect(profile.gittensor?.githubId).toBe("49853598"); + + const fit = buildContributorFit(profile, [], [], [], [], [ + { + login: "jsonbored", + repoFullName: "gittensor/api-official", + pullRequests: 63, + mergedPullRequests: 46, + openPullRequests: 9, + issues: 48, + stalePullRequests: 0, + unlinkedPullRequests: 0, + dominantLabels: [], + }, + ]); + const scoring = buildContributorScoringProfile({ login: "jsonbored", fit, scoringSnapshot: scoringModelSnapshot() }); + + expect(fit.summary).toContain("Gittensor API registered-repo PR"); + expect(scoring.evidence).toMatchObject({ + registeredRepoPullRequests: 63, + mergedPullRequests: 46, + openPullRequests: 9, + issueDiscoveryReports: 1, + }); + expect(scoring.privateSignals.join("\n")).toContain("Gittensor API"); + }); + it("preflights planned PRs without reward language", () => { const result = buildPreflightResult( { @@ -145,6 +293,9 @@ describe("world-class backend signals", () => { commentMode: "detected_contributors_only" as const, publicSignalLevel: "standard" as const, checkRunMode: "enabled" as const, + checkRunDetailLevel: "standard" as const, + backfillEnabled: true, + privateTrustEnabled: true, }; const collisions = buildCollisionReport(repo.fullName, issues, pullRequests); const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions); @@ -165,4 +316,175 @@ describe("world-class backend signals", () => { expect(comment).toContain(""); expect(comment).not.toMatch(/wallet|raw trust score|ranking|farming|reward/i); }); + + it("classifies every participation lane boundary", () => { + const inactive = buildLaneAdvice({ ...repo, registryConfig: { ...repo.registryConfig!, emissionShare: 0 } }, repo.fullName); + const issueDiscovery = buildLaneAdvice({ ...repo, registryConfig: { ...repo.registryConfig!, issueDiscoveryShare: 1 } }, repo.fullName); + const split = buildLaneAdvice({ ...repo, registryConfig: { ...repo.registryConfig!, issueDiscoveryShare: 0.4 } }, repo.fullName); + const unknown = buildLaneAdvice(null, "unknown/repo"); + + expect(inactive.lane).toBe("inactive"); + expect(issueDiscovery.lane).toBe("issue_discovery"); + expect(split.lane).toBe("split"); + expect(unknown.lane).toBe("unknown"); + }); + + it("keeps config quality useful for fragile and inactive repos", () => { + const unknownQuality = buildConfigQuality(null, [], [], "unknown/repo"); + const inactiveQuality = buildConfigQuality({ ...repo, registryConfig: { ...repo.registryConfig!, emissionShare: 0 } }, [], [], repo.fullName); + const noMultiplierQuality = buildConfigQuality({ ...repo, registryConfig: { ...repo.registryConfig!, labelMultipliers: {} } }, [], [], repo.fullName); + + expect(unknownQuality.level).toBe("needs_attention"); + expect(inactiveQuality.findings.map((finding) => finding.code)).toContain("inactive_allocation"); + expect(noMultiplierQuality.findings.map((finding) => finding.code)).toContain("trusted_labels_without_multipliers"); + }); + + it("keeps contributor detection and comment modes conservative", () => { + const currentPr = pullRequests[0]!; + const settings: RepositorySettings = { + repoFullName: repo.fullName, + commentMode: "off", + publicSignalLevel: "minimal", + checkRunMode: "enabled", + checkRunDetailLevel: "minimal", + backfillEnabled: true, + privateTrustEnabled: true, + }; + const undetected = detectGittensorContributor("newbie", currentPr, [currentPr], []); + + expect(undetected.detected).toBe(false); + expect(shouldPublishPrIntelligenceComment(settings, undetected)).toBe(false); + expect(shouldPublishPrIntelligenceComment({ ...settings, commentMode: "all_prs" }, undetected)).toBe(true); + }); + + it("returns hold/caution opportunities for inactive and issue-discovery lanes", () => { + const profile = buildContributorProfile("oktofeesh1", { login: "oktofeesh1", topLanguages: ["TypeScript"], source: "github" }, pullRequests, issues); + const inactiveRepo: RepositoryRecord = { + ...repo, + fullName: "owner/inactive", + registryConfig: { ...repo.registryConfig!, repo: "owner/inactive", emissionShare: 0 }, + }; + const issueDiscoveryRepo: RepositoryRecord = { + ...repo, + fullName: "owner/issues-only", + registryConfig: { ...repo.registryConfig!, repo: "owner/issues-only", issueDiscoveryShare: 1 }, + }; + const issueForInactive: IssueRecord = { ...issues[0]!, repoFullName: inactiveRepo.fullName, number: 70, title: "Inactive issue" }; + const issueForDiscovery: IssueRecord = { ...issues[1]!, repoFullName: issueDiscoveryRepo.fullName, number: 71, title: "Discovery issue" }; + + const opportunities = buildContributorOpportunities(profile, [inactiveRepo, issueDiscoveryRepo], [issueForInactive, issueForDiscovery], []); + + expect(opportunities.find((opportunity) => opportunity.repoFullName === inactiveRepo.fullName)?.fit).toBe("hold"); + expect(opportunities.find((opportunity) => opportunity.repoFullName === issueDiscoveryRepo.fullName)?.warnings).toContain("This repo is not a direct-PR-first lane."); + }); + + it("summarizes public comments at minimal signal level", () => { + const currentPr: PullRequestRecord = { ...pullRequests[0]!, linkedIssues: [], body: "" }; + const detection = detectGittensorContributor("newbie", currentPr, [], []); + const collisions = buildCollisionReport(repo.fullName, issues, [currentPr]); + const queueHealth = buildQueueHealth(repo, issues, [currentPr], collisions); + const preflight = buildPreflightResult({ repoFullName: repo.fullName, title: currentPr.title, changedFiles: ["README.md"] }, repo, issues, [currentPr]); + const profile = buildContributorProfile("newbie", { login: "newbie", topLanguages: [], source: "unavailable" }, [], []); + const settings: RepositorySettings = { + repoFullName: repo.fullName, + commentMode: "all_prs", + publicSignalLevel: "minimal", + checkRunMode: "enabled", + checkRunDetailLevel: "standard", + backfillEnabled: true, + privateTrustEnabled: true, + }; + + const comment = buildPublicPrIntelligenceComment({ repo, pr: currentPr, profile, detection, queueHealth, collisions, preflight, settings }); + + expect(comment).toContain("Linked issues: None detected"); + expect(comment).toContain("Public profile languages: not available"); + expect(comment).not.toMatch(/trust score|wallet|ranking/i); + }); + + it("separates active and historical bounty lifecycle risk", () => { + const active: BountyRecord = { + id: "bounty-1", + repoFullName: repo.fullName, + issueNumber: 7, + status: "Active", + amountText: "1.0", + payload: { bounty_amount: 1 }, + }; + const historical: BountyRecord = { + ...active, + id: "bounty-2", + status: "Completed", + payload: { target_bounty: 2, bounty_amount: 0 }, + }; + const linkedIssue: IssueRecord = { ...issues[0]!, linkedPrs: [12, 13] }; + + expect(buildBountyAdvisory(active, repo, null)).toMatchObject({ lifecycle: "active", fundingStatus: "funded", consensusRisk: "high" }); + expect(buildBountyAdvisory(historical, null, linkedIssue)).toMatchObject({ lifecycle: "historical", fundingStatus: "target_only", consensusRisk: "medium" }); + }); + + it("covers contributor fit and label audit warning boundaries", () => { + const noUsageAudit = buildLabelAudit( + { ...repo, registryConfig: { ...repo.registryConfig!, labelMultipliers: { feature: 1 } } }, + [], + [], + [], + repo.fullName, + ); + expect(noUsageAudit.findings.map((finding) => finding.code)).toContain("configured_labels_unused"); + + const mergedPullRequests = Array.from({ length: 4 }, (_, index): PullRequestRecord => ({ + ...pullRequests[0]!, + number: 200 + index, + state: "merged", + mergedAt: "2026-05-01T00:00:00.000Z", + })); + const established = buildContributorProfile("oktofeesh1", { login: "oktofeesh1", topLanguages: ["Rust"], source: "github" }, mergedPullRequests, []); + const busyPullRequests = Array.from({ length: 8 }, (_, index): PullRequestRecord => ({ + ...pullRequests[0]!, + number: 300 + index, + repoFullName: "owner/split", + linkedIssues: [index + 1], + })); + const splitRepo: RepositoryRecord = { + ...repo, + fullName: "owner/split", + registryConfig: { ...repo.registryConfig!, repo: "owner/split", issueDiscoveryShare: 0.5 }, + }; + const splitIssues = [{ ...issues[0]!, repoFullName: "owner/split", number: 100, labels: ["bug"] }]; + const fit = buildContributorFit( + established, + [splitRepo], + splitIssues, + busyPullRequests, + [{ repoFullName: "owner/split", status: "success", sourceKind: "github", primaryLanguage: "TypeScript", openIssuesCount: 1, openPullRequestsCount: 8, recentMergedPullRequestsCount: 0, warnings: [] }], + [], + ); + + expect(established.trustSignals.level).toBe("established"); + expect(fit.findings.map((finding) => finding.code)).toEqual(expect.arrayContaining(["no_language_fit", "busy_queue_matches"])); + expect(fit.opportunities[0]?.warnings).toContain("This repo has a busy open PR queue."); + }); + + it("detects prior non-merged activity as contributor context", () => { + const currentPr = pullRequests[0]!; + const priorOpenPr: PullRequestRecord = { ...currentPr, number: 99, mergedAt: undefined }; + const detection = detectGittensorContributor("oktofeesh1", currentPr, [currentPr, priorOpenPr], []); + + expect(detection).toMatchObject({ detected: true, priorPullRequests: 1, priorMergedPullRequests: 0 }); + }); }); + +function scoringModelSnapshot(): ScoringModelSnapshotRecord { + return { + id: "scoring-fixture", + sourceKind: "test", + sourceUrl: "fixture://scoring", + fetchedAt: "2026-05-25T00:00:00.000Z", + activeModel: "current_density_model", + constants: {}, + programmingLanguages: {}, + warnings: [], + payload: {}, + }; +} diff --git a/test/workers/worker-runtime.test.ts b/test/workers/worker-runtime.test.ts new file mode 100644 index 0000000000..5b12866831 --- /dev/null +++ b/test/workers/worker-runtime.test.ts @@ -0,0 +1,19 @@ +import { createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import worker from "../../src/index"; + +describe("worker runtime", () => { + it("serves health and keeps private routes locked in the Workers runtime", async () => { + const ctx = createExecutionContext(); + const health = await worker.fetch(new Request("https://gittensory.test/health"), {} as Env, ctx); + await waitOnExecutionContext(ctx); + expect(health.status).toBe(200); + await expect(health.json()).resolves.toMatchObject({ status: "ok", service: "gittensory-api" }); + + const openApi = await worker.fetch(new Request("https://gittensory.test/openapi.json"), {} as Env, createExecutionContext()); + expect(openApi.status).toBe(401); + + const mcp = await worker.fetch(new Request("https://gittensory.test/mcp", { method: "POST" }), {} as Env, createExecutionContext()); + expect(mcp.status).toBe(401); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 2361ef2126..ef257bc3e5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "lib": ["ES2022"], "module": "ESNext", "moduleResolution": "Bundler", - "types": ["vitest/globals", "node"], + "types": ["vitest/globals", "node", "@cloudflare/vitest-pool-workers/types"], "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, @@ -17,5 +17,5 @@ "forceConsistentCasingInFileNames": true, "outDir": "dist" }, - "include": ["src", "test", "worker-configuration.d.ts", "vitest.config.ts", "drizzle.config.ts"] + "include": ["src", "test", "worker-configuration.d.ts", "vitest.config.ts", "vitest.workers.config.ts", "drizzle.config.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts index 553d754d34..d5302a67a2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,5 +14,17 @@ export default defineConfig({ environment: "node", globals: true, include: ["test/**/*.test.ts"], + exclude: ["test/workers/**/*.test.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/env.d.ts"], + thresholds: { + lines: 90, + functions: 90, + branches: 90, + statements: 90, + }, + }, }, }); diff --git a/vitest.workers.config.ts b/vitest.workers.config.ts new file mode 100644 index 0000000000..143e4137ab --- /dev/null +++ b/vitest.workers.config.ts @@ -0,0 +1,14 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + ], + test: { + globals: true, + include: ["test/workers/**/*.test.ts"], + }, +}); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index c3bbf8d146..50e4f6a7c6 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,11 +1,12 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 7a5d4b1b5a07d9a2bf1c6f46b4a0de9c) -// Runtime types generated with workerd@1.20260521.1 2026-05-22 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: c49fe6e75bac710794f5642ad3209b98) +// Runtime types generated with workerd@1.20260521.1 2026-05-26 nodejs_compat interface __BaseEnv_Env { DB: D1Database; JOBS: Queue; GITHUB_APP_ID: "3824093"; GITHUB_APP_SLUG: "gittensory"; + GITHUB_OAUTH_CLIENT_ID: "Iv23li574mpdLo2PnVN4"; GITTENSOR_REGISTRY_URL: "https://raw.githubusercontent.com/entrius/gittensor/test/gittensor/validator/weights/master_repositories.json"; PUBLIC_API_ORIGIN: "https://gittensory-api.zeronode.workers.dev"; GITTENSORY_API_TOKEN: string; @@ -13,10 +14,12 @@ interface __BaseEnv_Env { INTERNAL_JOB_TOKEN: string; GITHUB_WEBHOOK_SECRET: string; GITHUB_APP_PRIVATE_KEY: string; + RATE_LIMITER: DurableObjectNamespace; } declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./src/index"); + durableNamespaces: "RateLimiter"; } interface Env extends __BaseEnv_Env {} } @@ -25,7 +28,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/wrangler.jsonc b/wrangler.jsonc index 571ed6c23e..f9ff295c85 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -2,7 +2,7 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "gittensory-api", "main": "src/index.ts", - "compatibility_date": "2026-05-22", + "compatibility_date": "2026-05-26", "compatibility_flags": ["nodejs_compat"], "observability": { "enabled": true, @@ -11,6 +11,7 @@ "vars": { "GITHUB_APP_ID": "3824093", "GITHUB_APP_SLUG": "gittensory", + "GITHUB_OAUTH_CLIENT_ID": "Iv23li574mpdLo2PnVN4", "GITTENSOR_REGISTRY_URL": "https://raw.githubusercontent.com/entrius/gittensor/test/gittensor/validator/weights/master_repositories.json", "PUBLIC_API_ORIGIN": "https://gittensory-api.zeronode.workers.dev" }, @@ -22,6 +23,20 @@ "migrations_dir": "migrations" } ], + "durable_objects": { + "bindings": [ + { + "name": "RATE_LIMITER", + "class_name": "RateLimiter" + } + ] + }, + "migrations": [ + { + "tag": "v1-rate-limiter", + "new_sqlite_classes": ["RateLimiter"] + } + ], "queues": { "producers": [ { From 5c04799afe31e44dbfa811d0ab47f3d957a3b18f Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 25 May 2026 22:38:05 -0700 Subject: [PATCH 2/4] ci(release): use trusted publishing defaults Remove the explicit provenance flag from the MCP publish workflow because npm only generates provenance from eligible public repositories. Keep the workflow tokenless through trusted publishing and pin the npm CLI used for release publishes. --- .github/workflows/npm-publish.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index bac9161077..b2b523e7cb 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -25,6 +25,7 @@ jobs: uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 24 + registry-url: https://registry.npmjs.org - name: Verify release tag env: @@ -52,4 +53,4 @@ jobs: "$TMP/node_modules/.bin/gittensory-mcp" --help >/dev/null - name: Publish with npm trusted publishing - run: npm publish --workspace @jsonbored/gittensory-mcp --access public --provenance + run: npx -y npm@11.15.0 publish --workspace @jsonbored/gittensory-mcp --access public From a662d29ce07a0167996073766c99a3b6463d111b Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 25 May 2026 22:49:35 -0700 Subject: [PATCH 3/4] fix(release): keep mcp package private by default Bump the MCP package after the unpublished bootstrap version and make future package publishes restricted by default. Keep the release workflow aligned with the private-beta posture so tags cannot accidentally publish the MCP package publicly. --- .github/workflows/npm-publish.yml | 2 +- package-lock.json | 2 +- packages/gittensory-mcp/package.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index b2b523e7cb..cc43b39492 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -53,4 +53,4 @@ jobs: "$TMP/node_modules/.bin/gittensory-mcp" --help >/dev/null - name: Publish with npm trusted publishing - run: npx -y npm@11.15.0 publish --workspace @jsonbored/gittensory-mcp --access public + run: npx -y npm@11.15.0 publish --workspace @jsonbored/gittensory-mcp --access restricted diff --git a/package-lock.json b/package-lock.json index 9c12941893..1d95cead1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5916,7 +5916,7 @@ }, "packages/gittensory-mcp": { "name": "@jsonbored/gittensory-mcp", - "version": "0.1.0", + "version": "0.1.1", "license": "AGPL-3.0-only", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/packages/gittensory-mcp/package.json b/packages/gittensory-mcp/package.json index 00344d33f3..2db9687525 100644 --- a/packages/gittensory-mcp/package.json +++ b/packages/gittensory-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@jsonbored/gittensory-mcp", - "version": "0.1.0", + "version": "0.1.1", "license": "AGPL-3.0-only", "type": "module", "description": "Local stdio MCP wrapper for private Gittensory contributor intelligence.", @@ -22,7 +22,7 @@ "preflight" ], "publishConfig": { - "access": "public" + "access": "restricted" }, "bin": { "gittensory-mcp": "bin/gittensory-mcp.js" From 3c4a100ad80aedf643265ba87ec674f204a89af7 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 26 May 2026 00:26:42 -0700 Subject: [PATCH 4/4] feat(docs): add install site and mcp diagnostics --- .github/workflows/pages.yml | 67 + .gitignore | 1 + README.md | 211 +- package-lock.json | 2018 +++++++++++++++-- package.json | 8 +- packages/gittensory-mcp/README.md | 53 +- packages/gittensory-mcp/bin/gittensory-mcp.js | 171 +- scripts/check-docs.mjs | 60 + site/.vitepress/config.mts | 52 + site/.vitepress/theme/custom.css | 38 + site/.vitepress/theme/index.ts | 4 + site/guide/auth.md | 54 + site/guide/github-app-setup.md | 77 + site/guide/install.md | 62 + site/guide/maintainers.md | 55 + site/guide/mcp.md | 71 + site/guide/miners.md | 54 + site/index.md | 48 + site/public/logo.svg | 5 + site/reference/api.md | 49 + site/security/privacy.md | 50 + site/troubleshooting.md | 67 + src/api/routes.ts | 5 +- src/db/repositories.ts | 4 +- src/github/app.ts | 42 +- src/github/backfill.ts | 108 +- src/openapi/schemas.ts | 8 + src/utils/crypto.ts | 48 +- test/integration/api.test.ts | 9 +- test/unit/auth.test.ts | 10 +- test/unit/backfill.test.ts | 151 +- test/unit/github-app.test.ts | 55 +- test/unit/mcp-cli.test.ts | 114 + test/unit/queue.test.ts | 33 +- test/unit/registry.test.ts | 3 + 35 files changed, 3519 insertions(+), 346 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 scripts/check-docs.mjs create mode 100644 site/.vitepress/config.mts create mode 100644 site/.vitepress/theme/custom.css create mode 100644 site/.vitepress/theme/index.ts create mode 100644 site/guide/auth.md create mode 100644 site/guide/github-app-setup.md create mode 100644 site/guide/install.md create mode 100644 site/guide/maintainers.md create mode 100644 site/guide/mcp.md create mode 100644 site/guide/miners.md create mode 100644 site/index.md create mode 100644 site/public/logo.svg create mode 100644 site/reference/api.md create mode 100644 site/security/privacy.md create mode 100644 site/troubleshooting.md create mode 100644 test/unit/mcp-cli.test.ts diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000000..ff7eba4559 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,67 @@ +name: Pages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build docs + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check docs + run: npm run docs:check + + - name: Configure Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Build docs + env: + GITTENSORY_SITE_URL: https://jsonbored.github.io/gittensory/ + GITTENSORY_SITE_BASE: /gittensory/ + run: npm run docs:build + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site/.vitepress/dist + + deploy: + name: Deploy docs + runs-on: ubuntu-latest + needs: build + if: ${{ github.ref == 'refs/heads/main' && vars.GITTENSORY_DOCS_DEPLOY == 'true' }} + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.gitignore b/.gitignore index f26dd338ab..ee9a7112fd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ dist/ .DS_Store coverage/ *.tsbuildinfo +site/.vitepress/cache/ !migrations/*.sql diff --git a/README.md b/README.md index a460afd53c..90d96ec6fa 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,162 @@ # Gittensory -Gittensory is a backend-only GitHub App/API layer for Gittensor registered repositories. +Gittensory is a backend-only intelligence layer for Gittensor registered repositories. -It gives maintainers and serious contributors advisory signals around repository configuration, -pull requests, issues, bounty context, duplicate risk, and queue health. It does not auto-label, -comment, close, merge, or store user GitHub PATs. +It helps miners and contributors make better decisions before they open work, and it helps maintainers review Gittensor-driven PRs with less noise. The product is the signal: role-aware contributor context, official Gittensor stats, local MCP preflight, queue health, collision risk, reviewability, and repo configuration quality. -The product wedge is signal, not UI. Gittensory is not a replacement frontend for Gittensor or -gittensor-hub; it is a private API and GitHub App surface that exposes evidence-backed context -other frontends usually do not show clearly. - -The frontend is intentionally out of scope for this repo slice. Lovable can consume the JSON API -and OpenAPI document once the backend is deployed. +Gittensory is not a Gittensor frontend, not a public leaderboard, and not an auto-label/auto-close bot. ## What It Does -- Helps contributors understand which repos fit their GitHub history and which submissions need - cleanup before they become maintainer burden. -- Helps maintainers identify noisy queues, duplicate or overlapping work, missing issue linkage, - stale PRs, and repo config problems. -- Helps repo owners see whether their Gittensor registration, labels, and participation lane are - likely to attract useful work. -- Exposes the same backend intelligence through a private MCP endpoint for coding agents and - contributor tooling. -- Can publish opt-in, public-safe GitHub PR context comments for detected Gittensor contributors - while keeping detailed trust signals in checks/API. -- Keeps the first intelligence layer deterministic and evidence-based. LLMs may summarize later, - but core trust/ranking decisions stay rule-driven. - -## Backend Stack - -- Cloudflare Workers + Hono -- Cloudflare D1 + Drizzle schema/migrations -- Cloudflare Queues for async webhook/check processing -- GitHub App webhooks and check runs -- Zod schemas with generated OpenAPI JSON - -## Local Setup - -```bash +- Builds private contributor decision packs from official Gittensor stats plus cached GitHub context. +- Analyzes local branches through the MCP wrapper without uploading source contents. +- Explains private reward/risk context: score blockers, open PR pressure, lane fit, duplicate risk, credibility assumptions, and maintainer friction. +- Generates public-safe PR packets that help contributors write cleaner submissions. +- Gives maintainers private PR reviewability packets and advisory check runs. +- Tracks repository intelligence: lane correctness, registry changes, queue health, label/config quality, collisions, bounties, and sync fidelity. + +## Surfaces + +- Worker API: Cloudflare Workers + Hono + D1 + Queues. +- MCP package: `@jsonbored/gittensory-mcp`, a local stdio wrapper for coding agents. +- GitHub App: check runs and optional sanitized sticky PR comments. +- Docs site: VitePress under `site/`, deployable by GitHub Pages when the repo is public. + +## MCP Install + +Private beta: + +```sh +npm install +npm link --workspace @jsonbored/gittensory-mcp +gittensory-mcp login +gittensory-mcp doctor +gittensory-mcp --stdio +``` + +Public npm path, once intentionally launched: + +```sh +npx @jsonbored/gittensory-mcp login +npm install -g @jsonbored/gittensory-mcp +gittensory-mcp --stdio +``` + +The package is restricted until launch. Public release requires changing npm access to `public`, bumping the MCP package version, and publishing through the tag-gated release workflow. + +## MCP Client Config + +Print client snippets: + +```sh +gittensory-mcp init-client --print codex +gittensory-mcp init-client --print claude +gittensory-mcp init-client --print cursor +``` + +Generic stdio command: + +```json +{ + "mcpServers": { + "gittensory": { + "command": "gittensory-mcp", + "args": ["--stdio"] + } + } +} +``` + +Use an absolute command path if your MCP client does not inherit your shell `PATH`. + +## Backend Setup + +```sh npm install npm run cf-typegen npm run db:migrate:local npm run dev ``` -Secrets are configured through Cloudflare, not committed: +Cloudflare secrets: -```bash +```sh wrangler secret put GITHUB_WEBHOOK_SECRET wrangler secret put GITHUB_APP_PRIVATE_KEY wrangler secret put GITHUB_PUBLIC_TOKEN +wrangler secret put GITHUB_OAUTH_CLIENT_ID wrangler secret put GITTENSORY_API_TOKEN wrangler secret put GITTENSORY_MCP_TOKEN wrangler secret put INTERNAL_JOB_TOKEN ``` -`GITHUB_PUBLIC_TOKEN` is a server-side GitHub API token used only to raise rate limits when -backfilling public registered repositories that have not installed the GitHub App. It is not a -contributor token, and Gittensory does not store user PATs. +`GITHUB_PUBLIC_TOKEN` is a server-side token used to raise public GitHub API rate limits during registered-repo backfill. It is not a contributor token. -For local development, put non-production test values in `.dev.vars`. +## Canonical API -## API - -Private beta REST endpoints use `Authorization: Bearer `. -`/health`, signed GitHub webhooks, internal-token routes, and `/mcp` use their own auth paths. +Private beta endpoints use `Authorization: Bearer ` or a Gittensory OAuth session where supported. - `GET /health` - `GET /openapi.json` +- `GET /v1/readiness` +- `GET /v1/sync/status` - `GET /v1/registry/snapshot` - `GET /v1/registry/changes` -- `GET /v1/sync/status` -- `GET /v1/readiness` +- `GET /v1/scoring/model` +- `POST /v1/scoring/preview` - `GET /v1/installations` - `GET /v1/installations/:id/health` - `GET /v1/repos` - `GET /v1/repos/:owner/:repo` -- `GET /v1/repos/:owner/:repo/advisory` -- `GET /v1/repos/:owner/:repo/lane` -- `GET /v1/repos/:owner/:repo/workboard` -- `GET /v1/repos/:owner/:repo/queue-health` -- `GET /v1/repos/:owner/:repo/collisions` -- `GET /v1/repos/:owner/:repo/config-quality` -- `GET /v1/repos/:owner/:repo/labels/audit` -- `GET /v1/repos/:owner/:repo/settings` -- `GET /v1/repos/:owner/:repo/maintainer-packet` +- `GET /v1/repos/:owner/:repo/intelligence` - `GET /v1/repos/:owner/:repo/pulls/:number/maintainer-packet` -- `GET /v1/repos/:owner/:repo/pulls/:number/advisory` -- `GET /v1/repos/:owner/:repo/issues/:number/advisory` +- `GET /v1/repos/:owner/:repo/pulls/:number/reviewability` - `GET /v1/contributors/:login/profile` -- `GET /v1/contributors/:login/opportunities` -- `GET /v1/contributors/:login/fit` +- `GET /v1/contributors/:login/decision-pack` +- `GET /v1/contributors/:login/repos/:owner/:repo/decision` - `POST /v1/preflight/pr` - `POST /v1/preflight/local-diff` +- `POST /v1/local/branch-analysis` - `GET /v1/bounties` - `GET /v1/bounties/:id/advisory` - `POST /mcp` - `POST /v1/github/webhook` -- `POST /v1/internal/jobs/refresh-registry` -- `POST /v1/internal/jobs/refresh-registry/run` -- `POST /v1/internal/jobs/backfill-registered-repos` -- `POST /v1/internal/jobs/backfill-registered-repos/run` -- `POST /v1/internal/jobs/generate-signal-snapshots` -- `POST /v1/internal/jobs/refresh-installation-health/run` -- `POST /v1/internal/bounties/import` -- `POST /v1/internal/repos/:owner/:repo/settings` - -## Operational Readiness - -Use the protected readiness endpoint before widening beta access or making the repository public: - -```bash -curl -fsS "$GITTENSORY_URL/v1/readiness" \ - -H "Authorization: Bearer $GITTENSORY_API_TOKEN" -``` -The response reports registry freshness, GitHub backfill state, installation health, required -secret presence, and blocking warnings. It does not expose secret values. +Internal job routes are protected by `INTERNAL_JOB_TOKEN`. + +## GitHub App Requirements + +Required repository permissions: + +- Metadata: read +- Checks: write +- Pull requests: read +- Issues: read -## MCP +Optional repository permission: -`POST /mcp` exposes private-beta MCP tools over JSON-RPC/Streamable HTTP style requests. -Use `Authorization: Bearer `. +- Issues: write, only when public-safe sticky PR comments are enabled. -Tools: +Required events: -- `gittensory_get_repo_context` -- `gittensory_get_contributor_profile` -- `gittensory_find_opportunities` -- `gittensory_get_contributor_fit` -- `gittensory_preflight_pr` -- `gittensory_get_queue_health` -- `gittensory_get_collisions` -- `gittensory_get_bounty_advisory` -- `gittensory_get_registry_changes` -- `gittensory_audit_repo_labels` -- `gittensory_explain_lane` -- `gittensory_preflight_local_diff` +- Pull request +- Issues +- Repository -## GitHub App PR Intelligence +If GitHub shows `Installation target`, select it. Gittensory should not block install health on event names that GitHub does not show in the app UI. -Repo comments are off by default. Enable public-safe sticky PR comments through the protected -settings endpoint: +## Docs -```bash -curl -X POST "$GITTENSORY_URL/v1/internal/repos/OWNER/REPO/settings" \ - -H "Authorization: Bearer $INTERNAL_JOB_TOKEN" \ - -H "content-type: application/json" \ - --data '{"commentMode":"detected_contributors_only","publicSignalLevel":"standard"}' +```sh +npm run docs:dev +npm run docs:build +npm run docs:preview ``` -Detailed maintainer intelligence stays in check runs and API responses. PR comments intentionally -avoid raw trust scores, rankings, wallet data, or compensation estimates. +The Pages workflow builds the docs on `main`, but deploys only when the repository variable `GITTENSORY_DOCS_DEPLOY` is set to `true`. ## Validation -```bash +```sh npm run test:ci ``` diff --git a/package-lock.json b/package-lock.json index 1d95cead1a..561f5bad5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,9 @@ "node-addon-api": "^8.5.0", "node-gyp": "^12.1.0", "typescript": "^5.9.3", + "vitepress": "^2.0.0-alpha.17", "vitest": "^4.1.7", + "vue": "^3.5.33", "wrangler": "^4.51.0" }, "engines": { @@ -353,6 +355,27 @@ "node": ">=12" } }, + "node_modules/@docsearch/css": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.3.tgz", + "integrity": "sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-4.6.3.tgz", + "integrity": "sha512-qUIX2b4Apew3tv4F0qhmgShsl/Lfw4m6mqv/5/5dWNxwTcDdLMp2s3YwZ+NMGh3IKCg0pBaXm7Q5VdyU5Rj+cQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/sidepanel-js": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/@docsearch/sidepanel-js/-/sidepanel-js-4.6.3.tgz", + "integrity": "sha512-grGSmvXzG0if+mrzdIKykvpIAuEQ9u0sEJ2eLRRCaQfJvsWqh2C2/aY04bIzWvDh7myi5rvl8D+tUNsVrjYQ3A==", + "dev": true, + "license": "MIT" + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", @@ -882,6 +905,23 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.84", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.84.tgz", + "integrity": "sha512-v4JVu6xIewGoETD4mm2k6UAdFAbTlY1duw5ZNSxYORfs2yFsHDhoU9Omn/BgrV0nR/ptWkF3ZIr/ZHoYXI/6Jw==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1969,161 +2009,718 @@ "dev": true, "license": "MIT" }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@speed-highlight/core": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", - "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "CC0-1.0" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } + "os": [ + "freebsd" + ] }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/lodash": { - "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", - "license": "MIT" + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@vercel/oidc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", - "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">= 20" - } + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", - "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.7", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.7", - "vitest": "4.1.7" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-3.23.0.tgz", + "integrity": "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" @@ -2132,99 +2729,416 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", + "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.33", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz", + "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-dom/node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz", + "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.33", + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.10", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz", + "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-ssr/node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/devtools-api": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.2.tgz", + "integrity": "sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.1.2" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.2.tgz", + "integrity": "sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.1.2", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.2.tgz", + "integrity": "sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz", + "integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/reactivity/node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz", + "integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-core/node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz", + "integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "@vue/reactivity": "3.5.33", + "@vue/runtime-core": "3.5.33", + "@vue/shared": "3.5.33", + "csstype": "^3.2.3" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "node_modules/@vue/runtime-dom/node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz", + "integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.1.0" + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33" }, - "funding": { - "url": "https://opencollective.com/vitest" + "peerDependencies": { + "vue": "3.5.33" } }, - "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "node_modules/@vue/server-renderer/node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", - "pathe": "^2.0.3" + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "node_modules/@vueuse/integrations": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.3.0.tgz", + "integrity": "sha512-76I5FT2ESvCmCaSwapI+a/u/CFtNXmzl9f9lNp1hRtx8vKB8hfiokJr8IvQqcQG5ckGXElyXK516b54ozV3MvA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" + "@vueuse/core": "14.3.0", + "@vueuse/shared": "14.3.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7 || ^8", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7 || ^8", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } } }, - "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", "dev": true, "license": "MIT", "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.7", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" } }, "node_modules/abbrev": { @@ -2446,6 +3360,16 @@ "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", "license": "Apache-2.0" }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -2531,6 +3455,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2541,6 +3476,28 @@ "node": ">=18" } }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -2572,6 +3529,17 @@ "node": ">=20" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -2700,6 +3668,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2726,6 +3701,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2736,6 +3721,20 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/drizzle-kit": { "version": "0.31.10", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", @@ -2912,6 +3911,19 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -3310,6 +4322,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/focus-trap": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.2.1.tgz", + "integrity": "sha512-6CxwrrFRquH7pDXb1mWxudkU9LSfYBMRZutpgddb2o6iwCk7cIRrBhyY3c8SGKcmIKdeMTrGSNg4Bedh2RSF/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3601,6 +4623,44 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hono": { "version": "4.12.22", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.22.tgz", @@ -3610,6 +4670,13 @@ "node": ">=16.9.0" } }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3617,6 +4684,17 @@ "dev": true, "license": "MIT" }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -4223,6 +5301,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4232,6 +5317,28 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -4253,6 +5360,100 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -4365,6 +5566,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, "node_modules/minizlib": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", @@ -4546,6 +5754,25 @@ "wrappy": "1" } }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/openapi3-ts": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz", @@ -4647,6 +5874,13 @@ "dev": true, "license": "MIT" }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4745,6 +5979,17 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4824,6 +6069,33 @@ "node": ">=0.10.0" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4877,6 +6149,58 @@ "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/rosie-skills": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/rosie-skills/-/rosie-skills-0.6.4.tgz", @@ -5099,6 +6423,23 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -5222,6 +6563,17 @@ "source-map": "^0.6.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -5262,6 +6614,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -5303,6 +6670,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "dev": true, + "license": "MIT" + }, "node_modules/tar": { "version": "7.5.15", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", @@ -5372,6 +6746,17 @@ "node": ">=0.6" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5471,6 +6856,79 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universal-user-agent": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", @@ -5495,6 +6953,36 @@ "node": ">= 0.8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.0.14", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", @@ -5573,6 +7061,128 @@ } } }, + "node_modules/vitepress": { + "version": "2.0.0-alpha.17", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-2.0.0-alpha.17.tgz", + "integrity": "sha512-Z3VPUpwk/bHYqt1uMVOOK1/4xFiWQov1GNc2FvMdz6kvje4JRXEOngVI9C+bi5jeedMSHiA4dwKkff1NCvbZ9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "^4.5.3", + "@docsearch/js": "^4.5.3", + "@docsearch/sidepanel-js": "^4.5.3", + "@iconify-json/simple-icons": "^1.2.69", + "@shikijs/core": "^3.22.0", + "@shikijs/transformers": "^3.22.0", + "@shikijs/types": "^3.22.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^6.0.4", + "@vue/devtools-api": "^8.0.5", + "@vue/shared": "^3.5.27", + "@vueuse/core": "^14.2.0", + "@vueuse/integrations": "^14.2.0", + "focus-trap": "^8.0.0", + "mark.js": "8.11.1", + "minisearch": "^7.2.0", + "shiki": "^3.22.0", + "vite": "^7.3.1", + "vue": "^3.5.27" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "oxc-minify": "*", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "oxc-minify": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vitepress/node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/vitest": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", @@ -5663,6 +7273,35 @@ } } }, + "node_modules/vue": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", + "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-sfc": "3.5.33", + "@vue/runtime-dom": "3.5.33", + "@vue/server-renderer": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue/node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -5914,6 +7553,17 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "packages/gittensory-mcp": { "name": "@jsonbored/gittensory-mcp", "version": "0.1.1", diff --git a/package.json b/package.json index 395105a202..5cf47e7510 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,10 @@ "drizzle:generate": "drizzle-kit generate", "build:mcp": "npm --workspace @jsonbored/gittensory-mcp run build", "test:mcp-pack": "node scripts/check-mcp-package.mjs", + "docs:dev": "vitepress dev site --host 127.0.0.1", + "docs:build": "vitepress build site", + "docs:preview": "vitepress preview site --host 127.0.0.1", + "docs:check": "node scripts/check-docs.mjs", "changelog": "git-cliff --config cliff.toml --output CHANGELOG.md", "typecheck": "tsc --noEmit", "test": "vitest run", @@ -24,7 +28,7 @@ "test:integration": "vitest run test/integration", "test:workers": "vitest run --config vitest.workers.config.ts", "test:coverage": "vitest run --coverage", - "test:ci": "npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm audit --audit-level=moderate", + "test:ci": "npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run docs:check && npm run docs:build && npm audit --audit-level=moderate", "test:watch": "vitest", "validate": "npm run typecheck && npm run test:coverage" }, @@ -48,7 +52,9 @@ "node-addon-api": "^8.5.0", "node-gyp": "^12.1.0", "typescript": "^5.9.3", + "vitepress": "^2.0.0-alpha.17", "vitest": "^4.1.7", + "vue": "^3.5.33", "wrangler": "^4.51.0" }, "engines": { diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index a0099c29df..a5f5dfc214 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -4,18 +4,61 @@ Local stdio MCP wrapper for Gittensory contributor intelligence. It inspects local git metadata and calls the private Gittensory API for branch preflight, score blockers, reward/risk reasoning, contributor decision packs, and public-safe PR packets. It does not upload source contents in v1. -```bash +## Status + +The package is restricted during private beta. Public npm install will be enabled only after the launch gate passes. + +## Install + +Private beta from the repo: + +```sh +npm install +npm link --workspace @jsonbored/gittensory-mcp +gittensory-mcp login +``` + +Public npm path once launched: + +```sh npx @jsonbored/gittensory-mcp login -npx @jsonbored/gittensory-mcp status -npx @jsonbored/gittensory-mcp analyze-branch --login jsonbored --json -npx @jsonbored/gittensory-mcp --stdio +npm install -g @jsonbored/gittensory-mcp +``` + +## Commands + +```sh +gittensory-mcp login +gittensory-mcp logout +gittensory-mcp whoami +gittensory-mcp status +gittensory-mcp doctor +gittensory-mcp init-client --print codex +gittensory-mcp init-client --print claude +gittensory-mcp init-client --print cursor +gittensory-mcp analyze-branch --login jsonbored --json +gittensory-mcp preflight --login jsonbored --json +gittensory-mcp --stdio +``` + +## Auth + +`login` uses GitHub Device Flow by default. For non-interactive bootstrap: + +```sh +gittensory-mcp login --github-token "$(gh auth token)" ``` -Environment overrides: +The wrapper stores a Gittensory session token, not a GitHub token. + +## Environment - `GITTENSORY_API_URL` - `GITTENSORY_CONFIG_PATH` or `GITTENSORY_CONFIG_DIR` - `GITTENSORY_API_TOKEN`, `GITTENSORY_MCP_TOKEN`, or `GITTENSORY_TOKEN` - `GITHUB_TOKEN` for non-interactive login bootstrap - `GITTENSOR_SCORE_PREVIEW_CMD` +- `GITTENSOR_ROOT` - `GITTENSORY_UPLOAD_SOURCE=false` + +`GITTENSORY_UPLOAD_SOURCE=true` is not supported and fails closed. diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 3d1ffd8d87..0a79d47ba5 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { delimiter, dirname, join } from "node:path"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; @@ -338,6 +338,8 @@ async function runCli(args) { if (command === "logout") return logout(options); if (command === "whoami") return whoami(options); if (command === "status") return status(options); + if (command === "doctor") return doctor(options); + if (command === "init-client") return initClient(options); if (command !== "analyze-branch" && command !== "preflight") throw new Error(`Unknown command: ${command}`); const contributorLogin = options.login ?? process.env.GITTENSORY_LOGIN ?? process.env.GITHUB_LOGIN; if (!contributorLogin) throw new Error("Pass --login or set GITTENSORY_LOGIN."); @@ -350,6 +352,7 @@ async function runCli(args) { body: options.body, labels: options.label, linkedIssues: options.issue?.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value > 0), + validation: validationFromOptions(options), scorePreviewCommand: options.scorePreviewCommand, }); const payload = command === "preflight" ? { local: result.local, preflight: result.analysis.preflight, prPacket: result.analysis.prPacket } : result; @@ -359,6 +362,14 @@ async function runCli(args) { } process.stdout.write(`${result.analysis.summary}\n`); process.stdout.write(`Top action: ${result.analysis.nextActions?.[0]?.actionKind ?? "none"}\n`); + if (result.analysis.nextActions?.[0]?.whyThisHelps?.length) { + process.stdout.write("Why this helps:\n"); + for (const line of result.analysis.nextActions[0].whyThisHelps.slice(0, 3)) process.stdout.write(`- ${line}\n`); + } + if (result.analysis.scoreBlockers?.length) { + process.stdout.write("Score blockers:\n"); + for (const blocker of result.analysis.scoreBlockers.slice(0, 5)) process.stdout.write(`- ${blocker}\n`); + } process.stdout.write(`Preflight: ${result.analysis.preflight.status}\n`); process.stdout.write(`Source upload: disabled\n`); } @@ -370,8 +381,10 @@ function printHelp() { gittensory-mcp logout [--json] gittensory-mcp whoami [--json] gittensory-mcp status [--json] - gittensory-mcp analyze-branch --login [--repo owner/repo] [--base origin/main] [--json] - gittensory-mcp preflight --login [--repo owner/repo] [--base origin/main] [--json] + gittensory-mcp doctor [--cwd path] [--json] + gittensory-mcp init-client --print codex|claude|cursor [--json] + gittensory-mcp analyze-branch --login [--repo owner/repo] [--base origin/main] [--validation "passed|npm test|summary"] [--json] + gittensory-mcp preflight --login [--repo owner/repo] [--base origin/main] [--validation "passed|npm test|summary"] [--json] Environment: GITTENSORY_API_URL @@ -386,6 +399,7 @@ Environment: function parseOptions(args) { const options = {}; + const repeatable = new Set(["label", "issue", "validation", "validationCommand", "validationStatus", "validationSummary"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--json") { @@ -400,7 +414,7 @@ function parseOptions(args) { continue; } index += 1; - if (key === "label" || key === "issue") options[key] = [...(options[key] ?? []), value]; + if (repeatable.has(key)) options[key] = [...(options[key] ?? []), value]; else options[key] = value; } return options; @@ -485,10 +499,147 @@ async function status(options) { } } +async function doctor(options) { + const checks = []; + const add = (name, statusValue, detail, remediation) => checks.push(stripUndefined({ name, status: statusValue, detail, remediation })); + + try { + const health = await apiFetch("/health", { method: "GET" }, { auth: false }); + add("api_health", health.status === "ok" ? "pass" : "warn", `API responded from ${apiUrl}.`); + } catch (error) { + add("api_health", "fail", error instanceof Error ? error.message : "health_check_failed", "Check GITTENSORY_API_URL or network access."); + } + + const token = getApiToken(); + if (!token) { + add("auth", "fail", "No Gittensory API/session token is configured.", "Run `gittensory-mcp login`."); + } else { + try { + const session = await apiGet("/v1/auth/session"); + add("auth", "pass", `Authenticated as ${session.login}; session expires ${session.expiresAt}.`); + } catch (error) { + add("auth", "warn", `A token is configured but no user session was verified: ${error instanceof Error ? error.message : "session_check_failed"}.`, "If this is a static beta token, this can be expected. Otherwise run `gittensory-mcp login`."); + } + } + + if (/^(1|true|yes)$/i.test(process.env.GITTENSORY_UPLOAD_SOURCE ?? "false")) { + add("source_upload", "fail", "GITTENSORY_UPLOAD_SOURCE is enabled.", "Unset GITTENSORY_UPLOAD_SOURCE. Source upload is unsupported in v1."); + } else { + add("source_upload", "pass", "Source upload is disabled and unsupported in v1."); + } + + try { + const metadata = collectLocalBranchMetadata({ + cwd: options.cwd ?? process.cwd(), + baseRef: options.base, + repoFullName: options.repo, + login: options.login ?? config.session?.login ?? "local", + }); + add("git_metadata", "pass", `${metadata.repoFullName} on ${metadata.branchName}; ${metadata.changedFiles.length} changed file(s).`); + } catch (error) { + add("git_metadata", "warn", error instanceof Error ? error.message : "git_metadata_failed", "Run from a git repo or pass --repo owner/repo."); + } + + const commandPath = findExecutable("gittensory-mcp"); + if (commandPath) add("client_path", "pass", `gittensory-mcp is visible on PATH at ${commandPath}.`); + else add("client_path", "warn", "gittensory-mcp was not found on PATH.", "Use an absolute command path in Codex, Claude, or Cursor config."); + + const payload = { + status: checks.some((check) => check.status === "fail") ? "needs_attention" : checks.some((check) => check.status === "warn") ? "warnings" : "ok", + apiUrl, + configPath, + sourceUploadSupported: false, + checks, + }; + if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + else { + process.stdout.write(`Gittensory doctor: ${payload.status}\n`); + for (const check of checks) { + process.stdout.write(`- ${check.status}: ${check.name} - ${check.detail}\n`); + if (check.remediation) process.stdout.write(` ${check.remediation}\n`); + } + } +} + +function initClient(options) { + const client = String(options.print ?? options.client ?? "").toLowerCase(); + if (!client) throw new Error("Pass --print codex, --print claude, or --print cursor."); + const command = options.command ?? "gittensory-mcp"; + const snippet = clientSnippet(client, command); + const payload = { + client, + command, + args: ["--stdio"], + snippet, + notes: [ + "Run `gittensory-mcp login` before starting the MCP client.", + "Use an absolute command path if the client does not inherit your shell PATH.", + "This command prints config only; it does not edit client files.", + ], + }; + if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + else process.stdout.write(`${snippet}\n`); +} + function getApiToken() { return process.env.GITTENSORY_API_TOKEN ?? process.env.GITTENSORY_TOKEN ?? process.env.GITTENSORY_MCP_TOKEN ?? config.session?.token; } +function validationFromOptions(options) { + const direct = (options.validation ?? []).map((entry) => { + const [statusOrCommand, commandOrSummary, ...summaryParts] = String(entry).split("|"); + const status = isValidationStatus(statusOrCommand) ? statusOrCommand : "not_run"; + const command = isValidationStatus(statusOrCommand) ? commandOrSummary : statusOrCommand; + return stripUndefined({ + command: command?.trim(), + status, + summary: summaryParts.join("|").trim() || (isValidationStatus(statusOrCommand) ? undefined : commandOrSummary?.trim()), + }); + }); + const commands = options.validationCommand ?? []; + const statuses = options.validationStatus ?? []; + const summaries = options.validationSummary ?? []; + const expanded = commands.map((command, index) => + stripUndefined({ + command, + status: isValidationStatus(statuses[index]) ? statuses[index] : "not_run", + summary: summaries[index], + }), + ); + return [...direct, ...expanded].filter((entry) => typeof entry.command === "string" && entry.command.length > 0); +} + +function isValidationStatus(value) { + return value === "passed" || value === "failed" || value === "not_run"; +} + +function clientSnippet(client, command) { + if (client === "codex") return `[mcp_servers.gittensory]\ncommand = ${JSON.stringify(command)}\nargs = ["--stdio"]`; + if (client === "claude" || client === "cursor") { + return JSON.stringify( + { + mcpServers: { + gittensory: { + command, + args: ["--stdio"], + }, + }, + }, + null, + 2, + ); + } + throw new Error(`Unsupported client: ${client}. Use codex, claude, or cursor.`); +} + +function findExecutable(name) { + for (const directory of String(process.env.PATH ?? "").split(delimiter).filter(Boolean)) { + const candidate = join(directory, name); + if (existsSync(candidate)) return candidate; + } + return null; +} + function loadConfig() { if (!existsSync(configPath)) return {}; try { @@ -518,14 +669,18 @@ async function apiPost(path, body) { async function apiFetch(path, init, options = {}) { const token = getApiToken(); if (options.auth !== false && !token) throw new Error("Run `gittensory-mcp login`, or set GITTENSORY_API_TOKEN, GITTENSORY_MCP_TOKEN, or GITTENSORY_TOKEN before starting the MCP wrapper."); + const controller = new AbortController(); + const timeoutMs = Number(process.env.GITTENSORY_API_TIMEOUT_MS ?? options.timeoutMs ?? 30000); + const timeout = setTimeout(() => controller.abort(), Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30000); const response = await fetch(`${apiUrl}${path}`, { ...init, + signal: init?.signal ?? controller.signal, headers: { ...(token && options.auth !== false ? { authorization: `Bearer ${token}` } : {}), "content-type": "application/json", accept: "application/json", }, - }); + }).finally(() => clearTimeout(timeout)); const text = await response.text(); const payload = text ? JSON.parse(text) : {}; if (!response.ok) { @@ -607,3 +762,9 @@ function toolResult(summary, data) { function camel(value) { return value.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); } + +function stripUndefined(value) { + if (Array.isArray(value)) return value.map(stripUndefined); + if (!value || typeof value !== "object") return value; + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined).map(([key, entry]) => [key, stripUndefined(entry)])); +} diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs new file mode 100644 index 0000000000..8d6de73a5f --- /dev/null +++ b/scripts/check-docs.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const root = process.cwd(); +const targets = ["README.md", "packages/gittensory-mcp/README.md", "site"]; +const staleRoutes = [ + "/v1/contributors/:login/opportunities", + "/v1/contributors/:login/fit", + "/v1/contributors/:login/strategy", + "/v1/contributors/:login/reward-risk-strategy", + "/v1/repos/:owner/:repo/queue-health", + "/v1/repos/:owner/:repo/collisions", + "/v1/repos/:owner/:repo/config-quality", + "/v1/repos/:owner/:repo/labels/audit", +]; + +const forbidden = [ + { name: "local macOS home path", pattern: /\/Users\/[A-Za-z0-9._-]+/ }, + { name: "private key block", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ }, + { name: "dev vars assignment", pattern: /^GITHUB_(?:WEBHOOK_SECRET|APP_PRIVATE_KEY|PUBLIC_TOKEN)=.+$/m }, + { name: "gittensory token assignment", pattern: /^GITTENSORY_(?:API_TOKEN|MCP_TOKEN|TOKEN)=.+$/m }, + { name: "internal token assignment", pattern: /^INTERNAL_JOB_TOKEN=.+$/m }, +]; + +const files = targets.flatMap((target) => collect(join(root, target))).filter((file) => /\.(md|mts|ts|js|json|yml)$/.test(file)); +const failures = []; + +for (const file of files) { + const text = readFileSync(file, "utf8"); + const label = file.slice(root.length + 1); + for (const route of staleRoutes) { + if (text.includes(route)) failures.push(`${label}: stale route ${route}`); + } + for (const rule of forbidden) { + if (rule.pattern.test(text)) failures.push(`${label}: ${rule.name}`); + } +} + +const siteIndex = readFileSync(join(root, "site/index.md"), "utf8"); +for (const phrase of ["Gittensor miners", "GitHub App", "MCP", "not a Gittensor frontend"]) { + if (!siteIndex.includes(phrase)) failures.push(`site/index.md: missing required positioning phrase ${JSON.stringify(phrase)}`); +} + +if (failures.length > 0) { + console.error(failures.join("\n")); + process.exit(1); +} + +console.log(`checked ${files.length} docs file(s)`); + +function collect(path) { + const stat = statSync(path); + if (stat.isFile()) return [path]; + return readdirSync(path).flatMap((entry) => { + const next = join(path, entry); + if (entry === "node_modules" || next.includes("site/.vitepress/cache") || next.includes("site/.vitepress/dist")) return []; + return collect(next); + }); +} diff --git a/site/.vitepress/config.mts b/site/.vitepress/config.mts new file mode 100644 index 0000000000..a79a74b90a --- /dev/null +++ b/site/.vitepress/config.mts @@ -0,0 +1,52 @@ +import { defineConfig } from "vitepress"; + +const siteUrl = process.env.GITTENSORY_SITE_URL ?? "https://jsonbored.github.io/gittensory/"; +const siteBase = process.env.GITTENSORY_SITE_BASE ?? "/gittensory/"; + +export default defineConfig({ + title: "Gittensory", + description: "Backend intelligence, MCP preflight, and GitHub App review context for Gittensor contributors and maintainers.", + base: siteBase, + cleanUrls: true, + lastUpdated: true, + head: [ + ["meta", { property: "og:title", content: "Gittensory" }], + ["meta", { property: "og:description", content: "Private decision intelligence for healthier Gittensor repo participation." }], + ["meta", { property: "og:url", content: siteUrl }], + ["meta", { name: "theme-color", content: "#111827" }], + ], + themeConfig: { + logo: "/logo.svg", + nav: [ + { text: "Install", link: "/guide/install" }, + { text: "MCP", link: "/guide/mcp" }, + { text: "GitHub App", link: "/guide/github-app-setup" }, + { text: "API", link: "/reference/api" }, + ], + sidebar: [ + { + text: "Guide", + items: [ + { text: "Install", link: "/guide/install" }, + { text: "MCP", link: "/guide/mcp" }, + { text: "Auth", link: "/guide/auth" }, + { text: "For Miners", link: "/guide/miners" }, + { text: "For Maintainers", link: "/guide/maintainers" }, + { text: "GitHub App Setup", link: "/guide/github-app-setup" }, + ], + }, + { + text: "Reference", + items: [ + { text: "API", link: "/reference/api" }, + { text: "Privacy", link: "/security/privacy" }, + { text: "Troubleshooting", link: "/troubleshooting" }, + ], + }, + ], + socialLinks: [{ icon: "github", link: "https://github.com/JSONbored/gittensory" }], + search: { + provider: "local", + }, + }, +}); diff --git a/site/.vitepress/theme/custom.css b/site/.vitepress/theme/custom.css new file mode 100644 index 0000000000..b388ff7d41 --- /dev/null +++ b/site/.vitepress/theme/custom.css @@ -0,0 +1,38 @@ +:root { + --vp-c-brand-1: #2563eb; + --vp-c-brand-2: #1d4ed8; + --vp-c-brand-3: #60a5fa; + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: linear-gradient(110deg, #2563eb, #10b981 55%, #f59e0b); + --vp-c-bg: #f8fafc; + --vp-c-bg-alt: #eef2f7; + --vp-c-bg-soft: #eef6ff; +} + +.dark { + --vp-c-bg: #0f172a; + --vp-c-bg-alt: #111827; + --vp-c-bg-soft: #182033; +} + +.VPHome .main { + max-width: 1120px; +} + +.VPHomeHero .text { + letter-spacing: 0; +} + +.VPFeature { + border-radius: 8px; +} + +.vp-doc h2 { + border-top: 1px solid var(--vp-c-divider); + padding-top: 24px; +} + +.vp-doc table { + display: table; + width: 100%; +} diff --git a/site/.vitepress/theme/index.ts b/site/.vitepress/theme/index.ts new file mode 100644 index 0000000000..c495bc1b8d --- /dev/null +++ b/site/.vitepress/theme/index.ts @@ -0,0 +1,4 @@ +import DefaultTheme from "vitepress/theme"; +import "./custom.css"; + +export default DefaultTheme; diff --git a/site/guide/auth.md b/site/guide/auth.md new file mode 100644 index 0000000000..75fb70ebc9 --- /dev/null +++ b/site/guide/auth.md @@ -0,0 +1,54 @@ +# Auth + +Gittensory has two auth surfaces: + +- GitHub OAuth for MCP users. +- GitHub App installation auth for repositories. + +## MCP User Auth + +Run: + +```sh +gittensory-mcp login +``` + +The CLI starts GitHub Device Flow, asks you to approve the code on GitHub, then exchanges the GitHub token server-side for a short-lived Gittensory session token. + +Gittensory stores: + +- a hashed server-side session token +- the GitHub login and user id +- scopes and session expiry metadata + +Gittensory does not store user PATs. + +## Non-Interactive Bootstrap + +For local automation, pass a GitHub token only long enough to mint a Gittensory session: + +```sh +gittensory-mcp login --github-token "$(gh auth token)" +``` + +The token is exchanged immediately. The MCP wrapper stores the Gittensory session token, not the GitHub token. + +## Check Auth State + +```sh +gittensory-mcp whoami +gittensory-mcp status +gittensory-mcp doctor +``` + +## Logout + +```sh +gittensory-mcp logout +``` + +Logout revokes the remote session when possible and removes the local config file. + +## Static Tokens + +Static bearer tokens remain only for private backend operations, internal jobs, and temporary beta bootstrap. Normal MCP users should use GitHub OAuth. diff --git a/site/guide/github-app-setup.md b/site/guide/github-app-setup.md new file mode 100644 index 0000000000..4871e1f219 --- /dev/null +++ b/site/guide/github-app-setup.md @@ -0,0 +1,77 @@ +# GitHub App Setup + +The GitHub App is the maintainer/install surface. GitHub OAuth is the MCP user-auth surface. + +## Basic Fields + +Use these values for private beta: + +| Field | Value | +| --- | --- | +| Homepage URL | `https://gittensory-api.zeronode.workers.dev` | +| Webhook URL | `https://gittensory-api.zeronode.workers.dev/v1/github/webhook` | +| Webhook active | enabled | +| SSL verification | enabled | +| Device Flow | enabled | + +Use a generated webhook secret and set the same value in Cloudflare as `GITHUB_WEBHOOK_SECRET`. + +## Required Repository Permissions + +| Permission | Access | Why | +| --- | --- | --- | +| Metadata | Read | Required for repository identity and repository events. | +| Checks | Write | Required to create and update the Gittensory check run. | +| Pull requests | Read | Required for PR metadata, reviewability, and webhook events. | +| Issues | Read | Required for issue linkage, issue-discovery context, and duplicate signals. | + +Optional: + +| Permission | Access | Why | +| --- | --- | --- | +| Issues | Write | Only needed if public-safe sticky PR comments are enabled. | +| Contents | Read | Only needed if a future feature reads repository files directly through the App. | + +## Required Events + +Subscribe to: + +- Pull request +- Issues +- Repository + +If GitHub shows `Installation target`, select it. Some installation-related events are not always shown as normal selectable event rows; Gittensory should not block health on event names that are hidden in the app UI. + +## Install Or Repair + +1. Update the GitHub App permissions and events. +2. Reinstall the app or approve the changed permissions. +3. Select the repos Gittensory should inspect. +4. Trigger installation-health refresh: + +```sh +curl -X POST "$GITTENSORY_API_URL/v1/internal/jobs/refresh-installation-health/run" \ + -H "Authorization: Bearer $INTERNAL_JOB_TOKEN" +``` + +5. Check health: + +```sh +curl "$GITTENSORY_API_URL/v1/readiness" \ + -H "Authorization: Bearer $GITTENSORY_API_TOKEN" +``` + +Healthy app installation state should remove the readiness warning about GitHub App installations needing attention. + +## Marketplace Readiness + +Before Marketplace submission, add: + +- public docs URL +- support contact +- privacy policy +- terms page if needed +- clear setup flow +- valid webhook and install diagnostics + +Do not submit while Gittensory is private beta or invite-only. diff --git a/site/guide/install.md b/site/guide/install.md new file mode 100644 index 0000000000..d33ceb3509 --- /dev/null +++ b/site/guide/install.md @@ -0,0 +1,62 @@ +# Install + +Gittensory has two install paths: private beta from the repository, and public npm once the package is intentionally launched. + +## Private Beta + +Use this while `JSONbored/gittensory` is still private. + +```sh +git clone https://github.com/JSONbored/gittensory.git +cd gittensory +npm install +npm link --workspace @jsonbored/gittensory-mcp +gittensory-mcp login +gittensory-mcp status +``` + +After login, start the MCP server with: + +```sh +gittensory-mcp --stdio +``` + +The login command uses GitHub Device Flow and stores a short-lived Gittensory session token in your local config directory. + +## Public npm + +This path is prepared but stays disabled until public launch. + +```sh +npx @jsonbored/gittensory-mcp login +npm install -g @jsonbored/gittensory-mcp +gittensory-mcp status +gittensory-mcp --stdio +``` + +The package should remain restricted until the launch gate passes. Public launch means changing npm publish access to `public`, bumping the MCP package version, and publishing from the release workflow. + +## Verify The Install + +Run: + +```sh +gittensory-mcp doctor +gittensory-mcp whoami +gittensory-mcp analyze-branch --login YOUR_GITHUB_LOGIN --json +``` + +`doctor` checks API health, auth state, source-upload defaults, local git metadata, and whether the binary is likely visible to MCP clients. + +## Privacy Defaults + +Gittensory MCP v1 sends structured metadata only: + +- repository full name +- branch and base refs +- changed file paths and counts +- linked issue references +- commit messages +- validation command summaries + +It does not upload source contents. `GITTENSORY_UPLOAD_SOURCE=true` is rejected. diff --git a/site/guide/maintainers.md b/site/guide/maintainers.md new file mode 100644 index 0000000000..7f850bcb4a --- /dev/null +++ b/site/guide/maintainers.md @@ -0,0 +1,55 @@ +# For Maintainers + +Gittensory is meant to make Gittensor-driven contribution flow less noisy. + +## GitHub App Surface + +The GitHub App creates the detailed maintainer surface: + +- Gittensory check runs on PRs +- reviewability context +- linked issue and duplicate signals +- contributor role context +- queue and repo-lane context +- optional public-safe sticky PR comments + +The check run is the canonical detailed report. Public comments are opt-in and sanitized. + +## Reviewability Actions + +Gittensory maps PRs to maintainer-friendly actions: + +- `review_now` +- `needs_author` +- `likely_duplicate` +- `close_or_redirect` +- `watch` +- `maintainer_lane` + +The point is not to shame contributors. The point is to identify the lowest-friction next step. + +## Public Comments + +Public comments stay off by default. + +When enabled, comments can include: + +- contribution context +- PR hygiene +- duplicate or WIP risk +- maintainer review notes +- contributor next steps + +Comments must not include raw trust scores, wallet data, hotkeys, public reward estimates, or public score optimization language. + +## Repo Owner Signals + +Repo owners can use Gittensory to inspect: + +- repo lane clarity +- label configuration +- maintainer cut readiness +- queue health +- contributor intake health +- GitHub App installation health +- stale or degraded backfill state diff --git a/site/guide/mcp.md b/site/guide/mcp.md new file mode 100644 index 0000000000..d7d5d1068e --- /dev/null +++ b/site/guide/mcp.md @@ -0,0 +1,71 @@ +# MCP + +The MCP package is the contributor-facing surface for coding agents. It runs locally over stdio and calls the private Gittensory API with your Gittensory session token. + +## Generate Client Config + +Print a config snippet: + +```sh +gittensory-mcp init-client --print codex +gittensory-mcp init-client --print claude +gittensory-mcp init-client --print cursor +``` + +These commands do not edit your files. Use an absolute command path if your client does not inherit your shell `PATH`. + +## Codex + +```toml +[mcp_servers.gittensory] +command = "gittensory-mcp" +args = ["--stdio"] +``` + +## Claude Desktop + +```json +{ + "mcpServers": { + "gittensory": { + "command": "gittensory-mcp", + "args": ["--stdio"] + } + } +} +``` + +## Cursor + +```json +{ + "mcpServers": { + "gittensory": { + "command": "gittensory-mcp", + "args": ["--stdio"] + } + } +} +``` + +## Tools + +The local wrapper exposes repo context, contributor decision packs, local branch preflight, score blockers, PR packets, variant comparison, and registry change tools. + +Useful tools: + +- `gittensory_local_status` +- `gittensory_get_decision_pack` +- `gittensory_explain_repo_decision` +- `gittensory_preflight_current_branch` +- `gittensory_preview_current_branch_score` +- `gittensory_rank_local_next_actions` +- `gittensory_explain_local_blockers` +- `gittensory_prepare_pr_packet` + +## Runtime Rules + +- Stdio only for the local wrapper. +- No source upload in v1. +- Private score and reward/risk reasoning stay in MCP/API output. +- Public PR packets are sanitized and do not include wallet, hotkey, raw trust score, or public score estimates. diff --git a/site/guide/miners.md b/site/guide/miners.md new file mode 100644 index 0000000000..8d49b33038 --- /dev/null +++ b/site/guide/miners.md @@ -0,0 +1,54 @@ +# For Miners + +Gittensory helps miners decide what to do next with evidence instead of guesswork. + +## What It Answers + +- Is this repo a direct-PR lane, issue-discovery lane, split lane, inactive lane, or unknown lane? +- Am I in a normal contributor lane or a maintainer lane for this repo? +- Does my current branch look reviewable? +- What blocks scoreability right now? +- Should I clean up open PRs before opening more work? +- Is there duplicate or WIP collision risk? +- What public-safe PR packet should I give a maintainer? + +## Branch Analysis + +Run from a Git repo: + +```sh +gittensory-mcp analyze-branch --login YOUR_GITHUB_LOGIN --json +``` + +The response includes: + +- lane context +- role context +- preflight findings +- private score blockers +- reward/risk reasoning +- maintainer-fit notes +- public-safe PR packet +- ranked next actions + +## Preflight + +```sh +gittensory-mcp preflight --login YOUR_GITHUB_LOGIN --json +``` + +Use this before opening a PR. It is especially useful when you need to know whether a branch is missing tests, missing a linked issue, colliding with active work, or likely to increase maintainer burden. + +## How This Helps + +Gittensory does not promise payouts. It explains scoreability and risk: + +- open PR pressure +- credibility assumptions +- lane eligibility +- issue-discovery vs direct PR fit +- duplicate clusters +- stale work +- review friction + +That makes recommendations actionable: land or withdraw blocked work, avoid direct PRs in issue-discovery-only repos, improve validation evidence, or pick a repo where your history and the lane actually fit. diff --git a/site/index.md b/site/index.md new file mode 100644 index 0000000000..35b930200a --- /dev/null +++ b/site/index.md @@ -0,0 +1,48 @@ +--- +layout: home + +hero: + name: Gittensory + text: Gittensor repo intelligence for miners, maintainers, and coding agents. + tagline: Backend-only signals, MCP branch preflight, and GitHub App review context. Not a Gittensor frontend. + actions: + - theme: brand + text: Install MCP + link: /guide/install + - theme: alt + text: GitHub App Setup + link: /guide/github-app-setup + +features: + - title: Miner Decision Packs + details: Rank next actions with private reward/risk reasoning, lane context, score blockers, open PR pressure, and maintainer friction. + - title: Local MCP Preflight + details: Let Codex, Claude, Cursor, and other MCP clients inspect branch metadata without uploading source contents. + - title: Maintainer Reviewability + details: Generate PR packets, check runs, duplicate context, and public-safe guidance that reduces noisy Gittensor-driven review load. + - title: Registry-Aware Signals + details: Normalize Gittensor registry data, repo lanes, label multipliers, queue health, collisions, bounties, and configuration readiness. + - title: Private By Default + details: GitHub OAuth sessions are short-lived Gittensory tokens. User PATs are not stored. Public comments never include private score context. + - title: API First + details: The API is the product surface. Lovable or other frontends can consume it later without turning Gittensory into another Gittensor dashboard. +--- + +## What Gittensory Is + +Gittensory is a private backend intelligence layer for Gittensor registered repositories. +It helps Gittensor miners and contributors decide what to work on next, and it helps maintainers understand whether a Gittensor-driven PR is clean, duplicate-prone, stale, missing evidence, or worth reviewing. + +It is not a Gittensor frontend, not a public leaderboard, and not a reward estimator for public comments. The useful surface is the signal: role-aware repo context, official Gittensor stats, local branch preflight, queue health, duplicate risk, and maintainer-friendly review packets. + +## Primary Surfaces + +| Surface | Who uses it | What it does | +| --- | --- | --- | +| MCP package | miners and coding agents | Runs local branch analysis, score blocker checks, preflight, and PR packet generation. | +| REST API | internal tools and future clients | Serves decision packs, repo intelligence, reviewability, readiness, and branch analysis. | +| GitHub App | maintainers and repo owners | Adds private check-run intelligence and optional public-safe PR comments. | + +## Current Status + +Gittensory is still private beta. The MCP package remains restricted until the public launch gate passes, but the install flow is already shaped for a simple public npm path. diff --git a/site/public/logo.svg b/site/public/logo.svg new file mode 100644 index 0000000000..2617a317cb --- /dev/null +++ b/site/public/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/site/reference/api.md b/site/reference/api.md new file mode 100644 index 0000000000..bd3f42fb98 --- /dev/null +++ b/site/reference/api.md @@ -0,0 +1,49 @@ +# API Reference + +The REST API is private beta and bearer-token protected except for `/health`, GitHub webhook delivery, and public auth start/poll endpoints. + +Use the live OpenAPI document when authenticated: + +```sh +curl "$GITTENSORY_API_URL/openapi.json" \ + -H "Authorization: Bearer $GITTENSORY_API_TOKEN" +``` + +## Contributor APIs + +| Endpoint | Purpose | +| --- | --- | +| `GET /v1/contributors/:login/profile` | Contributor evidence profile using official Gittensor stats first when available. | +| `GET /v1/contributors/:login/decision-pack` | Canonical private miner decision payload for MCP and internal clients. | +| `GET /v1/contributors/:login/repos/:owner/:repo/decision` | Repo-specific decision extracted from the decision pack. | + +## Repo APIs + +| Endpoint | Purpose | +| --- | --- | +| `GET /v1/repos` | Known registered and installed repositories. | +| `GET /v1/repos/:owner/:repo` | Repository metadata. | +| `GET /v1/repos/:owner/:repo/intelligence` | Canonical repository intelligence bundle. | +| `GET /v1/repos/:owner/:repo/pulls/:number/maintainer-packet` | PR-specific maintainer review packet. | +| `GET /v1/repos/:owner/:repo/pulls/:number/reviewability` | Private PR reviewability score and maintainer action. | + +## Local And Preflight APIs + +| Endpoint | Purpose | +| --- | --- | +| `POST /v1/local/branch-analysis` | MCP-oriented local branch analysis from structured metadata. | +| `POST /v1/preflight/pr` | Planned PR metadata preflight. | +| `POST /v1/preflight/local-diff` | Local-diff metadata preflight. | + +## Ops APIs + +| Endpoint | Purpose | +| --- | --- | +| `GET /v1/readiness` | Service health, signal fidelity, secrets presence, and installation health summary. | +| `GET /v1/sync/status` | Repo sync segments, GitHub totals, rate-limit state, and signal fidelity. | +| `GET /v1/installations` | GitHub App installations and health records. | +| `GET /v1/installations/:id/health` | Exact installation permission/event remediation context. | + +## MCP Endpoint + +`POST /mcp` exposes remote private-beta MCP over Streamable HTTP style requests. The local npm wrapper is still the preferred MCP user surface. diff --git a/site/security/privacy.md b/site/security/privacy.md new file mode 100644 index 0000000000..8861733c34 --- /dev/null +++ b/site/security/privacy.md @@ -0,0 +1,50 @@ +# Privacy And Security + +Gittensory handles contribution intelligence, not wallets or private source code. + +## No PAT Storage + +MCP login uses GitHub OAuth Device Flow. The backend exchanges the GitHub token for a Gittensory session token and stores only the hashed Gittensory token server-side. + +## No Source Upload + +Local MCP branch analysis sends metadata only: + +- repo full name +- branch name +- base and head refs +- changed file paths +- additions and deletions +- linked issue references +- commit messages +- validation summaries + +Source contents are not uploaded in v1. `GITTENSORY_UPLOAD_SOURCE=true` fails closed. + +## Public Output Boundaries + +Public comments and public-safe PR packets must not include: + +- wallets +- hotkeys +- raw trust scores +- public score estimates +- public reward estimates +- farming language +- public shaming + +Private API and MCP responses can include scoreability and reward/risk reasoning because they are authenticated private outputs. + +## Rate Limiting + +Gittensory uses route classes: + +- strict: auth routes +- normal: read APIs and MCP tools +- expensive: branch analysis, scoring preview, decision-pack refresh, signal refresh + +Rate-limited responses return `429` with retry metadata. + +## GitHub App Boundaries + +The GitHub App is advisory-only. It does not auto-label, close, merge, or rewrite contributor work. diff --git a/site/troubleshooting.md b/site/troubleshooting.md new file mode 100644 index 0000000000..d913a058da --- /dev/null +++ b/site/troubleshooting.md @@ -0,0 +1,67 @@ +# Troubleshooting + +## `gittensory-mcp: command not found` + +For private beta: + +```sh +npm link --workspace @jsonbored/gittensory-mcp +``` + +Then retry: + +```sh +gittensory-mcp doctor +``` + +If your MCP client does not inherit your shell `PATH`, use an absolute command path in that client config. + +## Login Fails + +Check: + +```sh +gittensory-mcp doctor +gittensory-mcp status +``` + +GitHub Device Flow must be enabled on the GitHub App or OAuth app configured for Gittensory. + +## Session Expired + +Run: + +```sh +gittensory-mcp login +``` + +Sessions are intentionally short-lived. + +## Source Upload Error + +If you see a source-upload error, remove this env var: + +```sh +unset GITTENSORY_UPLOAD_SOURCE +``` + +Gittensory rejects source upload mode in v1. + +## GitHub App Installation Needs Attention + +Check the installation health endpoint: + +```sh +curl "$GITTENSORY_API_URL/v1/installations/INSTALLATION_ID/health" \ + -H "Authorization: Bearer $GITTENSORY_API_TOKEN" +``` + +Fix the reported missing permissions and events, approve the app permission update in GitHub, then refresh installation health. + +## Rate Limited + +If a command returns `429`, retry after the reported `retry-after` value. Expensive analysis routes have stricter limits than normal read routes. + +## Stale Decision Pack + +If `decision-pack` returns `needs_snapshot_refresh`, Gittensory has enqueued a rebuild. Retry after the queue drains. diff --git a/src/api/routes.ts b/src/api/routes.ts index d824bce500..21e5e99bb9 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -51,6 +51,7 @@ import { backfillOpenPullRequestDetails, backfillRegisteredRepositories, backfillRepositorySegment, + enrichInstallationHealth, refreshContributorActivity, refreshInstallationHealth, } from "../github/backfill"; @@ -455,7 +456,7 @@ export function createApp() { app.get("/v1/installations", async (c) => c.json({ installations: await listInstallations(c.env), - health: await listInstallationHealth(c.env), + health: (await listInstallationHealth(c.env)).map(enrichInstallationHealth), }), ); @@ -464,7 +465,7 @@ export function createApp() { if (!Number.isFinite(installationId)) return c.json({ error: "invalid_installation_id" }, 400); const health = await getInstallationHealth(c.env, installationId); if (!health) return c.json({ error: "installation_health_not_found" }, 404); - return c.json(health); + return c.json(enrichInstallationHealth(health)); }); app.get("/v1/repos", async (c) => c.json(await listRepositories(c.env))); diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 30205c4695..f86d9692cb 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1331,7 +1331,7 @@ export async function upsertInstallationHealth(env: Env, health: InstallationHea permissionsJson: jsonString(health.permissions), eventsJson: jsonString(health.events), checkedAt: health.checkedAt, - errorSummary: health.errorSummary, + errorSummary: health.errorSummary ?? null, }) .onConflictDoUpdate({ target: installationHealth.installationId, @@ -1346,7 +1346,7 @@ export async function upsertInstallationHealth(env: Env, health: InstallationHea permissionsJson: jsonString(health.permissions), eventsJson: jsonString(health.events), checkedAt: health.checkedAt, - errorSummary: health.errorSummary, + errorSummary: health.errorSummary ?? null, }, }); } diff --git a/src/github/app.ts b/src/github/app.ts index a40892a6a4..c9c1d1d3ab 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -17,18 +17,7 @@ type CheckRunListResponse = { }; export async function createInstallationToken(env: Env, installationId: number): Promise { - if (!env.GITHUB_APP_PRIVATE_KEY) { - throw new Error("GitHub App credentials are not configured."); - } - const now = Math.floor(Date.now() / 1000); - const jwt = await signRs256Jwt( - { - iss: env.GITHUB_APP_ID, - iat: now - 60, - exp: now + 540, - }, - env.GITHUB_APP_PRIVATE_KEY, - ); + const jwt = await createAppJwt(env); const response = await fetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, { method: "POST", headers: githubHeaders(`Bearer ${jwt}`), @@ -42,6 +31,35 @@ export async function createInstallationToken(env: Env, installationId: number): return payload.token; } +export async function getAppInstallation(env: Env, installationId: number): Promise> { + const jwt = await createAppJwt(env); + const response = await fetch(`https://api.github.com/app/installations/${installationId}`, { + headers: githubHeaders(`Bearer ${jwt}`), + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`Failed to fetch GitHub App installation (${response.status}): ${body.slice(0, 200)}`); + } + const payload = (await response.json()) as NonNullable; + if (!payload.id) throw new Error("GitHub installation response did not include an id."); + return payload; +} + +async function createAppJwt(env: Env): Promise { + if (!env.GITHUB_APP_PRIVATE_KEY) { + throw new Error("GitHub App credentials are not configured."); + } + const now = Math.floor(Date.now() / 1000); + return signRs256Jwt( + { + iss: env.GITHUB_APP_ID, + iat: now - 60, + exp: now + 540, + }, + env.GITHUB_APP_PRIVATE_KEY, + ); +} + export async function createOrUpdateCheckRun( env: Env, installationId: number, diff --git a/src/github/backfill.ts b/src/github/backfill.ts index df1059f8fb..650782e85d 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -18,6 +18,7 @@ import { markUnseenOpenPullRequestsClosed, persistRepoGithubTotalsSnapshot, recordGitHubRateLimitObservation, + upsertInstallation, upsertCheckSummary, upsertContributor, upsertContributorRepoStat, @@ -40,6 +41,8 @@ import type { GitHubIssuePayload, GitHubPullRequestPayload, GitHubRepositoryPayload, + InstallationHealthRecord, + InstallationRecord, JsonValue, PullRequestRecord, RecentMergedPullRequestRecord, @@ -49,7 +52,7 @@ import type { RepositoryRecord, } from "../types"; import { nowIso, repoParts } from "../utils/json"; -import { createInstallationToken } from "./app"; +import { createInstallationToken, getAppInstallation } from "./app"; type GitHubLabelPayload = { name: string; @@ -573,42 +576,111 @@ export async function refreshContributorActivity( return { ok: true, login, repoCount: repositories.length, updatedRepoStats, warnings }; } +export const REQUIRED_INSTALLATION_PERMISSIONS: Record = { + checks: "write", + metadata: "read", + pull_requests: "read", + issues: "read", +}; + +export const REQUIRED_INSTALLATION_EVENTS = ["issues", "pull_request", "repository"] as const; +export const OPTIONAL_VISIBLE_INSTALLATION_EVENTS = ["installation_target"] as const; + +export function enrichInstallationHealth(health: InstallationHealthRecord) { + const missingPermissions = new Set(health.missingPermissions); + const missingEvents = new Set(health.missingEvents); + return { + ...health, + requiredPermissions: REQUIRED_INSTALLATION_PERMISSIONS, + requiredEvents: [...REQUIRED_INSTALLATION_EVENTS], + optionalVisibleEvents: [...OPTIONAL_VISIBLE_INSTALLATION_EVENTS], + permissionRemediation: Object.entries(REQUIRED_INSTALLATION_PERMISSIONS).map(([permission, access]) => ({ + permission, + requiredAccess: access, + currentAccess: health.permissions[permission] ?? "missing", + ok: !missingPermissions.has(permission), + action: missingPermissions.has(permission) ? `Set repository permission ${permission} to ${access}.` : "No change needed.", + })), + eventRemediation: REQUIRED_INSTALLATION_EVENTS.map((event) => ({ + event, + ok: !missingEvents.has(event), + action: missingEvents.has(event) ? `Subscribe to the ${event} webhook event.` : "No change needed.", + })), + repairSteps: + health.status === "healthy" + ? ["No repair needed."] + : [ + "Update the GitHub App permissions and subscribed events.", + "Approve the changed permissions or reinstall the app on the target account.", + "Run refresh-installation-health after GitHub sends the updated installation payload.", + "Recheck /v1/readiness and this installation health endpoint.", + ], + }; +} + export async function refreshInstallationHealth(env: Env) { const [installations, repositories] = await Promise.all([listInstallations(env), listRepositories(env)]); - const requiredPermissions: Record = { - checks: "write", - metadata: "read", - pull_requests: "read", - }; - const requiredEvents = ["issues", "pull_request", "repository"]; const health = []; for (const installation of installations) { - const installedRepos = repositories.filter((repo) => repo.installationId === installation.id && repo.isInstalled); + const { installation: currentInstallation, errorSummary } = await refreshStoredInstallation(env, installation); + const installedRepos = repositories.filter((repo) => repo.installationId === currentInstallation.id && repo.isInstalled); const registeredInstalled = installedRepos.filter((repo) => repo.isRegistered); - const missingPermissions = Object.entries(requiredPermissions) - .filter(([permission, expected]) => installation.permissions[permission] !== expected) + const missingPermissions = Object.entries(REQUIRED_INSTALLATION_PERMISSIONS) + .filter(([permission, expected]) => !permissionSatisfies(currentInstallation.permissions[permission], expected)) .map(([permission]) => permission); - const missingEvents = requiredEvents.filter((event) => !installation.events.includes(event)); - const status = missingPermissions.length > 0 || missingEvents.length > 0 ? "needs_attention" : "healthy"; + const missingEvents = REQUIRED_INSTALLATION_EVENTS.filter((event) => !currentInstallation.events.includes(event)); + const status = errorSummary || missingPermissions.length > 0 || missingEvents.length > 0 ? "needs_attention" : "healthy"; const record = { - installationId: installation.id, - accountLogin: installation.accountLogin, - repositorySelection: installation.repositorySelection, + installationId: currentInstallation.id, + accountLogin: currentInstallation.accountLogin, + repositorySelection: currentInstallation.repositorySelection, installedReposCount: installedRepos.length, registeredInstalledCount: registeredInstalled.length, status, missingPermissions, missingEvents, - permissions: installation.permissions, - events: installation.events, + permissions: currentInstallation.permissions, + events: currentInstallation.events, checkedAt: nowIso(), + errorSummary, } as const; await upsertInstallationHealth(env, record); - health.push(record); + health.push(enrichInstallationHealth(record)); } return { ok: true, installations: health }; } +async function refreshStoredInstallation(env: Env, installation: InstallationRecord): Promise<{ installation: InstallationRecord; errorSummary?: string }> { + try { + const live = await getAppInstallation(env, installation.id); + await upsertInstallation(env, { installation: live }); + return { + installation: { + ...installation, + accountLogin: live.account?.login ?? installation.accountLogin, + accountId: live.account?.id ?? installation.accountId, + targetType: live.target_type ?? live.account?.type ?? installation.targetType, + repositorySelection: live.repository_selection ?? installation.repositorySelection, + permissions: live.permissions ?? {}, + events: live.events ?? [], + suspendedAt: live.suspended_at ?? undefined, + updatedAt: nowIso(), + }, + }; + } catch (error) { + return { + installation, + errorSummary: String(error).replace(/^Error: /, "") || "Failed to refresh GitHub App installation metadata.", + }; + } +} + +function permissionSatisfies(current: string | undefined, expected: string): boolean { + if (current === expected) return true; + const order: Record = { read: 1, write: 2, admin: 3 }; + return (order[current ?? ""] ?? 0) >= (order[expected] ?? Number.POSITIVE_INFINITY); +} + async function tokenForRepo(env: Env, repo: RepositoryRecord): Promise { const installationToken = repo.installationId ? await createInstallationToken(env, repo.installationId).catch(() => undefined) : undefined; return installationToken ?? env.GITHUB_PUBLIC_TOKEN; diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index c5db6fafe9..5cfca796e9 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -579,6 +579,14 @@ export const InstallationHealthSchema = z events: z.array(z.string()), checkedAt: z.string(), errorSummary: z.string().nullable().optional(), + requiredPermissions: z.record(z.string()).optional(), + requiredEvents: z.array(z.string()).optional(), + optionalVisibleEvents: z.array(z.string()).optional(), + permissionRemediation: z + .array(z.object({ permission: z.string(), requiredAccess: z.string(), currentAccess: z.string(), ok: z.boolean(), action: z.string() })) + .optional(), + eventRemediation: z.array(z.object({ event: z.string(), ok: z.boolean(), action: z.string() })).optional(), + repairSteps: z.array(z.string()).optional(), }) .openapi("InstallationHealth"); diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts index 4344f8a8d3..559ee81758 100644 --- a/src/utils/crypto.ts +++ b/src/utils/crypto.ts @@ -60,13 +60,14 @@ export async function signRs256Jwt(payload: Record, pri async function importPkcs8PrivateKey(privateKeyPem: string): Promise { const normalized = privateKeyPem.replace(/\\n/g, "\n"); + const isPkcs1Rsa = normalized.includes("-----BEGIN RSA PRIVATE KEY-----"); const base64 = normalized .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") .replace(/\s+/g, ""); - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + const bytes = isPkcs1Rsa ? wrapPkcs1RsaPrivateKey(base64ToBytes(base64)) : base64ToBytes(base64); return crypto.subtle.importKey( "pkcs8", bytes, @@ -75,3 +76,44 @@ async function importPkcs8PrivateKey(privateKeyPem: string): Promise ["sign"], ); } + +function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +function wrapPkcs1RsaPrivateKey(pkcs1Der: Uint8Array): Uint8Array { + const version = der(0x02, new Uint8Array([0])); + const rsaEncryptionOid = new Uint8Array([0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01]); + const nullParam = new Uint8Array([0x05, 0x00]); + const algorithm = der(0x30, concatBytes(rsaEncryptionOid, nullParam)); + const privateKey = der(0x04, pkcs1Der); + return der(0x30, concatBytes(version, algorithm, privateKey)); +} + +function der(tag: number, content: Uint8Array): Uint8Array { + return concatBytes(new Uint8Array([tag]), derLength(content.length), content); +} + +function derLength(length: number): Uint8Array { + if (length < 0x80) return new Uint8Array([length]); + const bytes: number[] = []; + let remaining = length; + while (remaining > 0) { + bytes.unshift(remaining & 0xff); + remaining >>= 8; + } + return new Uint8Array([0x80 | bytes.length, ...bytes]); +} + +function concatBytes(...chunks: Uint8Array[]): Uint8Array { + const output = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0)); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.length; + } + return output; +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 79e2d39d4f..22a084907c 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -484,7 +484,12 @@ describe("api routes", () => { const installationHealth = await app.request("/v1/installations/123/health", { headers: apiHeaders(env) }, env); expect(installationHealth.status).toBe(200); - await expect(installationHealth.json()).resolves.toMatchObject({ installationId: 123 }); + await expect(installationHealth.json()).resolves.toMatchObject({ + installationId: 123, + requiredPermissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "read" }, + permissionRemediation: expect.arrayContaining([expect.objectContaining({ permission: "checks", ok: true })]), + repairSteps: ["No repair needed."], + }); const invalidInstallationHealth = await app.request("/v1/installations/not-a-number/health", { headers: apiHeaders(env) }, env); expect(invalidInstallationHealth.status).toBe(400); @@ -1497,7 +1502,7 @@ async function seedSignalData(env: Env): Promise { status: "healthy", missingPermissions: [], missingEvents: [], - permissions: { checks: "write", metadata: "read", pull_requests: "read" }, + permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "read" }, events: ["issues", "pull_request", "repository"], checkedAt: "2026-05-23T00:00:00.000Z", }); diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 102b6a9163..48bf2a99b7 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createSessionFromGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow } from "../../src/auth/github-oauth"; -import { RateLimiter } from "../../src/auth/rate-limit"; +import { RateLimiter, routeClassForPath } from "../../src/auth/rate-limit"; import { authenticatePrivateToken, createSessionForGitHubUser, revokeSession } from "../../src/auth/security"; import { createTestEnv } from "../helpers/d1"; @@ -41,6 +41,14 @@ describe("private-beta auth and rate limiting", () => { expect(invalid.status).toBe(400); }); + it("classifies rate-limit route costs", () => { + expect(routeClassForPath("/v1/auth/github/device/start")).toBe("strict"); + expect(routeClassForPath("/v1/local/branch-analysis")).toBe("expensive"); + expect(routeClassForPath("/v1/contributors/jsonbored/decision-pack")).toBe("expensive"); + expect(routeClassForPath("/v1/internal/jobs/generate-signal-snapshots")).toBe("expensive"); + expect(routeClassForPath("/v1/repos")).toBe("normal"); + }); + it("starts GitHub device flow and rejects malformed provider responses", async () => { const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id" }); vi.stubGlobal("fetch", async () => diff --git a/test/unit/backfill.test.ts b/test/unit/backfill.test.ts index 61f82440a1..bf49990169 100644 --- a/test/unit/backfill.test.ts +++ b/test/unit/backfill.test.ts @@ -230,7 +230,7 @@ describe("GitHub backfill", () => { }); it("reports installation health from stored permissions and events", async () => { - const env = createTestEnv(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await seedRegisteredRepo(env); await upsertInstallation(env, { installation: { @@ -241,12 +241,35 @@ describe("GitHub backfill", () => { events: ["pull_request"], }, }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/app/installations/123")) { + return Response.json({ + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { checks: "write", metadata: "read" }, + events: ["pull_request"], + }); + } + if (url.endsWith("/app/installations/124")) { + return Response.json({ + id: 124, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "write" }, + events: ["issues", "pull_request", "repository"], + }); + } + return new Response("not found", { status: 404 }); + }); const result = await refreshInstallationHealth(env); expect(result.installations[0]).toMatchObject({ status: "needs_attention", - missingPermissions: ["pull_requests"], + missingPermissions: ["pull_requests", "issues"], missingEvents: ["issues", "repository"], + repairSteps: expect.arrayContaining(["Update the GitHub App permissions and subscribed events."]), }); await upsertInstallation(env, { @@ -254,7 +277,7 @@ describe("GitHub backfill", () => { id: 124, account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", - permissions: { checks: "write", metadata: "read", pull_requests: "read" }, + permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "write" }, events: ["issues", "pull_request", "repository"], }, }); @@ -262,6 +285,100 @@ describe("GitHub backfill", () => { expect(refreshed.installations).toEqual(expect.arrayContaining([expect.objectContaining({ installationId: 124, status: "healthy" })])); }); + it("refreshes installation health from live GitHub App metadata", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await seedRegisteredRepo(env); + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "unknown", id: 0, type: "unknown" }, + repository_selection: "selected", + permissions: {}, + events: [], + }, + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/app/installations/123")) { + return Response.json({ + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + target_type: "User", + repository_selection: "selected", + permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "write" }, + events: ["issues", "pull_request", "repository"], + }); + } + return new Response("not found", { status: 404 }); + }); + + const refreshed = await refreshInstallationHealth(env); + + expect(refreshed.installations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + installationId: 123, + accountLogin: "JSONbored", + status: "healthy", + missingPermissions: [], + missingEvents: [], + }), + ]), + ); + + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/app/installations/123")) { + return Response.json({ + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "read" }, + events: ["issues", "pull_request", "repository"], + }); + } + return new Response("not found", { status: 404 }); + }); + + const recovered = await refreshInstallationHealth(env); + expect(recovered.installations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + installationId: 123, + status: "healthy", + errorSummary: undefined, + }), + ]), + ); + }); + + it("surfaces installation metadata refresh failures in health diagnostics", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await seedRegisteredRepo(env); + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "read" }, + events: ["issues", "pull_request", "repository"], + }, + }); + vi.stubGlobal("fetch", async () => new Response("installation unavailable", { status: 503 })); + + const refreshed = await refreshInstallationHealth(env); + + expect(refreshed.installations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + installationId: 123, + status: "needs_attention", + errorSummary: expect.stringContaining("Failed to fetch GitHub App installation"), + }), + ]), + ); + }); + it("skips repositories with backfill disabled", async () => { const env = createTestEnv(); await seedRegisteredRepo(env); @@ -452,6 +569,34 @@ describe("GitHub backfill", () => { ); }); + it("runs a targeted labels segment refresh", async () => { + const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); + await seedRegisteredRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/graphql")) { + return Response.json({ + data: { + repository: { + issues: { totalCount: 0 }, + openPullRequests: { totalCount: 0 }, + mergedPullRequests: { totalCount: 0 }, + closedPullRequests: { totalCount: 0 }, + labels: { totalCount: 1 }, + }, + }, + }); + } + if (url.includes("/labels?")) return Response.json([{ name: "bug", color: "cc0000", description: "Bug" }]); + return new Response("not found", { status: 404 }); + }); + + const result = await backfillRepositorySegment(env, { repoFullName: "JSONbored/gittensory", segment: "labels", mode: "resume", force: true }); + + expect(result).toMatchObject({ status: "complete", fetchedCount: 1, expectedCount: 1 }); + expect(await listRepoLabels(env, "JSONbored/gittensory")).toEqual(expect.arrayContaining([expect.objectContaining({ name: "bug" })])); + }); + it("resumes paginated segments from stored cursors instead of restarting from page one", async () => { const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); await seedRegisteredRepo(env); diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 335ffd1e6e..0807dc53b0 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createInstallationToken, createOrUpdateCheckRun, getInstallationId } from "../../src/github/app"; +import { generateKeyPairSync } from "node:crypto"; +import { createInstallationToken, createOrUpdateCheckRun, getAppInstallation, getInstallationId } from "../../src/github/app"; import type { Advisory } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; @@ -60,6 +61,17 @@ describe("GitHub check runs", () => { expect(calls.some((url) => url.includes("/repos/JSONbored/gittensory/check-runs"))).toBe(true); }); + it("accepts GitHub App RSA private key PEMs for installation tokens", async () => { + const privateKey = generateRsaPrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + return new Response("not found", { status: 404 }); + }); + + await expect(createInstallationToken(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123)).resolves.toBe("installation-token"); + }); + it("updates an existing Gittensory check run for the same head SHA", async () => { const privateKey = await generatePrivateKeyPem(); const methods: string[] = []; @@ -152,6 +164,42 @@ describe("GitHub check runs", () => { vi.stubGlobal("fetch", async () => Response.json({})); await expect(createInstallationToken(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123)).rejects.toThrow(/did not include a token/); }); + + it("fetches live GitHub App installation metadata", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/app/installations/123")) { + return Response.json({ + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + target_type: "User", + repository_selection: "selected", + permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "write" }, + events: ["issues", "pull_request", "repository"], + }); + } + return new Response("not found", { status: 404 }); + }); + + const installation = await getAppInstallation(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123); + + expect(installation).toMatchObject({ + id: 123, + account: { login: "JSONbored" }, + permissions: { checks: "write" }, + events: expect.arrayContaining(["pull_request"]), + }); + }); + + it("surfaces live GitHub App installation fetch failures", async () => { + const privateKey = await generatePrivateKeyPem(); + vi.stubGlobal("fetch", async () => new Response("installation missing", { status: 404 })); + await expect(getAppInstallation(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123)).rejects.toThrow(/Failed to fetch GitHub App installation/); + + vi.stubGlobal("fetch", async () => Response.json({})); + await expect(getAppInstallation(createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), 123)).rejects.toThrow(/did not include an id/); + }); }); async function generatePrivateKeyPem(): Promise { @@ -169,3 +217,8 @@ async function generatePrivateKeyPem(): Promise { const base64 = Buffer.from(exported as ArrayBuffer).toString("base64").replace(/(.{64})/g, "$1\n"); return `-----BEGIN PRIVATE KEY-----\n${base64}\n-----END PRIVATE KEY-----`; } + +function generateRsaPrivateKeyPem(): string { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return privateKey.export({ type: "pkcs1", format: "pem" }).toString(); +} diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts new file mode 100644 index 0000000000..3c15e2800d --- /dev/null +++ b/test/unit/mcp-cli.test.ts @@ -0,0 +1,114 @@ +import { execFile, execFileSync } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const bin = join(process.cwd(), "packages/gittensory-mcp/bin/gittensory-mcp.js"); +let server: Server | null = null; + +describe("gittensory-mcp CLI", () => { + let tempDir: string | null = null; + + afterEach(async () => { + if (server) await new Promise((resolve) => server?.close(() => resolve())); + server = null; + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + }); + + it("prints MCP client snippets without mutating client config", () => { + const codex = run(["init-client", "--print", "codex"]); + expect(codex).toContain("[mcp_servers.gittensory]"); + expect(codex).toContain('args = ["--stdio"]'); + + const claude = JSON.parse(run(["init-client", "--print", "claude", "--json"])) as { snippet: string }; + expect(claude.snippet).toContain('"mcpServers"'); + expect(claude.snippet).toContain('"gittensory"'); + }); + + it("runs doctor against a local health/session fixture", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + const payload = JSON.parse( + await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }), + ) as { status: string; checks: Array<{ name: string; status: string; detail: string }> }; + + expect(payload.status).toMatch(/ok|warnings/); + expect(payload.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "api_health", status: "pass" }), + expect.objectContaining({ name: "auth", status: "pass", detail: expect.stringContaining("JSONbored") }), + expect.objectContaining({ name: "source_upload", status: "pass" }), + expect.objectContaining({ name: "git_metadata", status: "pass" }), + ]), + ); + }); + + it("rejects unsupported client snippets", () => { + expect(() => run(["init-client", "--print", "other"])).toThrow(/Unsupported client/); + }); +}); + +function run(args: string[], env: Record = {}) { + return execFileSync("node", [bin, ...args], { + encoding: "utf8", + env: { + ...process.env, + GITTENSORY_API_TIMEOUT_MS: "1000", + GITTENSORY_CONFIG_DIR: mkdtempSync(join(tmpdir(), "gittensory-cli-config-")), + ...env, + }, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function runAsync(args: string[], env: Record = {}) { + return new Promise((resolve, reject) => { + execFile( + "node", + [bin, ...args], + { + encoding: "utf8", + env: { + ...process.env, + GITTENSORY_API_TIMEOUT_MS: "1000", + GITTENSORY_CONFIG_DIR: mkdtempSync(join(tmpdir(), "gittensory-cli-config-")), + ...env, + }, + }, + (error, stdout, stderr) => { + if (error) { + reject(new Error(`${error.message}\n${stderr}`)); + return; + } + resolve(stdout); + }, + ); + }); +} + +async function startFixtureServer() { + server = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + if (request.url === "/health") { + response.end(JSON.stringify({ status: "ok", service: "gittensory-api" })); + return; + } + if (request.url === "/v1/auth/session" && request.headers.authorization === "Bearer session-token") { + response.end(JSON.stringify({ status: "authenticated", login: "JSONbored", expiresAt: "2026-06-02T00:00:00.000Z", scopes: ["read:user"] })); + return; + } + response.statusCode = 404; + response.end(JSON.stringify({ error: "not_found" })); + }); + await new Promise((resolve) => server?.listen(0, "127.0.0.1", () => resolve())); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("fixture server did not bind a TCP port"); + return `http://127.0.0.1:${address.port}`; +} diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index d519b57b9d..3c2a2a1ca1 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -330,7 +330,7 @@ describe("queue processors", () => { }); it("marks installation health from queued installation metadata", async () => { - const env = createTestEnv(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await persistRegistrySnapshot( env, normalizeRegistryPayload( @@ -344,12 +344,25 @@ describe("queue processors", () => { id: 123, account: { login: "JSONbored", id: 1, type: "User" }, repository_selection: "selected", - permissions: { checks: "write", metadata: "read", pull_requests: "read" }, + permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "read" }, events: ["issues", "pull_request", "repository"], }, repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }], }); await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: true, owner: { login: "JSONbored" } }, 123); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.endsWith("/app/installations/123")) { + return Response.json({ + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { checks: "write", metadata: "read", pull_requests: "read", issues: "read" }, + events: ["issues", "pull_request", "repository"], + }); + } + return new Response("not found", { status: 404 }); + }); await processJob(env, { type: "refresh-installation-health", requestedBy: "test" }); expect(await listInstallationHealth(env)).toMatchObject([{ status: "healthy", registeredInstalledCount: 1 }]); @@ -543,3 +556,19 @@ function completeSegment(repoFullName: string, segment: "labels" | "open_issues" warnings: [], }; } + +async function generatePrivateKeyPem(): Promise { + const key = (await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + const exported = await crypto.subtle.exportKey("pkcs8", key.privateKey); + const base64 = Buffer.from(exported as ArrayBuffer).toString("base64").replace(/(.{64})/g, "$1\n"); + return `-----BEGIN PRIVATE KEY-----\n${base64}\n-----END PRIVATE KEY-----`; +} diff --git a/test/unit/registry.test.ts b/test/unit/registry.test.ts index ca964d117d..e5f087b49d 100644 --- a/test/unit/registry.test.ts +++ b/test/unit/registry.test.ts @@ -37,6 +37,8 @@ describe("registry normalization", () => { it("normalizes repository-list and array payload shapes defensively", () => { const fromRepositoryList = normalizeRegistryPayload( { + ignored: null, + alsoIgnored: ["not", "a", "config"], repositories: [ { full_name: "entrius/allways", @@ -65,6 +67,7 @@ describe("registry normalization", () => { eligibility_mode: "active", }, { repo: "bad/numbers", emission_share: Number.NaN, issue_discovery_share: "bad" }, + {}, "not-a-repo", ], { kind: "raw-github", url: "https://example.test/master_repositories.json" },