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..cc43b39492 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,56 @@ +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 + registry-url: https://registry.npmjs.org + + - 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: npx -y npm@11.15.0 publish --workspace @jsonbored/gittensory-mcp --access restricted 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/.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..90d96ec6fa 100644 --- a/README.md +++ b/README.md @@ -1,122 +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 ``` -For local development, put non-production test values in `.dev.vars`. +`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. -## API +## Canonical 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/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/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/settings` -- `GET /v1/repos/:owner/:repo/maintainer-packet` -- `GET /v1/repos/:owner/:repo/pulls/:number/advisory` -- `GET /v1/repos/:owner/:repo/issues/:number/advisory` +- `GET /v1/repos/:owner/:repo/intelligence` +- `GET /v1/repos/:owner/:repo/pulls/:number/maintainer-packet` +- `GET /v1/repos/:owner/:repo/pulls/:number/reviewability` - `GET /v1/contributors/:login/profile` -- `GET /v1/contributors/:login/opportunities` +- `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` -## MCP +Internal job routes are protected by `INTERNAL_JOB_TOKEN`. + +## GitHub App Requirements + +Required repository permissions: + +- Metadata: read +- Checks: write +- Pull requests: read +- Issues: read + +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. -Initial tools: +Required events: -- `gittensory_get_repo_context` -- `gittensory_get_contributor_profile` -- `gittensory_find_opportunities` -- `gittensory_preflight_pr` -- `gittensory_get_queue_health` -- `gittensory_get_collisions` -- `gittensory_get_bounty_advisory` +- 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 -npm run validate +```sh +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..561f5bad5b 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,12 +22,18 @@ "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", + "vitepress": "^2.0.0-alpha.17", "vitest": "^4.1.7", + "vue": "^3.5.33", "wrangler": "^4.51.0" }, "engines": { @@ -108,6 +118,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 +175,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 +231,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", @@ -266,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", @@ -795,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", @@ -1380,6 +1507,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,128 +2009,736 @@ "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==", + "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", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } + "optional": true, + "os": [ + "android" + ] }, - "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-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": "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": [ + "android" + ] }, - "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-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", "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } + "os": [ + "darwin" + ] }, - "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-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": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } + "optional": true, + "os": [ + "darwin" + ] }, - "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-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" + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "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-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" - }, - "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" + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "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-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, + "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-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, + "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-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/@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-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": { - "@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" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "optional": true, + "os": [ + "linux" + ] }, - "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/@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", - "dependencies": { - "@vitest/spy": "4.1.7", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "optional": true, + "os": [ + "linux" + ] + }, + "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", + "optional": true, + "os": [ + "linux" + ] + }, + "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", + "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" + }, + "funding": { + "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==", + "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" @@ -2060,29 +2799,346 @@ "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==", + "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": { + "@vue/reactivity": "3.5.33", + "@vue/runtime-core": "3.5.33", + "@vue/shared": "3.5.33", + "csstype": "^3.2.3" + } + }, + "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": { + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "vue": "3.5.33" + } + }, + "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": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "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": { + "@vueuse/core": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "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/@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": { @@ -2275,12 +3331,45 @@ "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", "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", @@ -2366,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", @@ -2376,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", @@ -2386,6 +3508,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", @@ -2400,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", @@ -2528,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", @@ -2554,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", @@ -2564,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", @@ -2740,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", @@ -2900,6 +4084,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 +4207,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 +4285,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", @@ -3072,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", @@ -3172,6 +4432,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 +4462,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 +4589,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", @@ -3228,13 +4623,76 @@ "node": ">= 0.4" } }, - "node_modules/hono": { - "version": "4.12.22", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.22.tgz", - "integrity": "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw==", + "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", + "integrity": "sha512-7fvVPbB92zNRsQke+uiRGwtTuef0tB2Dg4hWxYfFNvkQhIltWoyi0ONReM5LWA+jJWS3nfT5lTq+qbsIpX0IQw==", + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "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", - "engines": { - "node": ">=16.9.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/http-errors": { @@ -3257,6 +4715,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 +4786,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 +4841,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 +4908,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 +5273,41 @@ "@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/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", @@ -3716,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", @@ -3737,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", @@ -3849,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", @@ -3947,6 +5671,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", @@ -4000,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", @@ -4009,6 +5782,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", @@ -4088,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", @@ -4160,6 +5953,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", @@ -4170,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", @@ -4183,6 +6003,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", @@ -4232,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", @@ -4285,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", @@ -4507,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", @@ -4586,6 +6519,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", @@ -4617,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", @@ -4657,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", @@ -4672,6 +6644,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", @@ -4685,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", @@ -4754,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", @@ -4840,6 +6843,92 @@ "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/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", @@ -4864,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", @@ -4942,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", @@ -5032,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", @@ -5227,6 +7497,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 +7552,32 @@ "peerDependencies": { "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", + "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..5cf47e7510 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,22 @@ "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", + "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", + "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 run docs:check && npm run docs:build && 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,12 +43,18 @@ "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", + "vitepress": "^2.0.0-alpha.17", "vitest": "^4.1.7", + "vue": "^3.5.33", "wrangler": "^4.51.0" }, "engines": { @@ -40,5 +62,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..a5f5dfc214 --- /dev/null +++ b/packages/gittensory-mcp/README.md @@ -0,0 +1,64 @@ +# @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. + +## 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 +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)" +``` + +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 new file mode 100755 index 0000000000..0a79d47ba5 --- /dev/null +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -0,0 +1,770 @@ +#!/usr/bin/env node +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +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"; +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 === "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."); + 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), + validation: validationFromOptions(options), + 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`); + 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`); +} + +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 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 + 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 = {}; + 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") { + 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 (repeatable.has(key)) 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"); + } +} + +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 { + 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 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) { + 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()); +} + +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/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..2db9687525 --- /dev/null +++ b/packages/gittensory-mcp/package.json @@ -0,0 +1,44 @@ +{ + "name": "@jsonbored/gittensory-mcp", + "version": "0.1.1", + "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": "restricted" + }, + "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-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/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/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 0eba104164..21e5e99bb9 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1,47 +1,100 @@ 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, + enrichInstallationHealth, + 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 +110,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 +233,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)).map(enrichInstallationHealth), + }), + ); + + 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(enrichInstallationHealth(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 +486,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 +609,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 +664,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 +839,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 +849,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 +859,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..f86d9692cb 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 ?? null, + }) + .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 ?? null, + }, + }); +} + +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/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 new file mode 100644 index 0000000000..650782e85d --- /dev/null +++ b/src/github/backfill.ts @@ -0,0 +1,1890 @@ +import { + getRepositorySettings, + getRepository, + countOpenIssues, + countOpenPullRequests, + countRecentMergedPullRequests, + countRepoLabels, + getLatestRepoGithubTotalsSnapshot, + getRepoSyncSegment, + getRepoSyncState, + listLatestGitHubRateLimitObservations, + listOpenIssueNumbers, + listOpenPullRequests, + listInstallations, + listPullRequestDetailSyncStates, + listRepositories, + markUnseenOpenIssuesClosed, + markUnseenOpenPullRequestsClosed, + persistRepoGithubTotalsSnapshot, + recordGitHubRateLimitObservation, + upsertInstallation, + 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, + InstallationHealthRecord, + InstallationRecord, + JsonValue, + PullRequestRecord, + RecentMergedPullRequestRecord, + RepoGithubTotalsSnapshotRecord, + RepoSyncSegmentRecord, + RepoSyncStateRecord, + RepositoryRecord, +} from "../types"; +import { nowIso, repoParts } from "../utils/json"; +import { createInstallationToken, getAppInstallation } 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 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 health = []; + for (const installation of installations) { + 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(REQUIRED_INSTALLATION_PERMISSIONS) + .filter(([permission, expected]) => !permissionSatisfies(currentInstallation.permissions[permission], expected)) + .map(([permission]) => permission); + 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: currentInstallation.id, + accountLogin: currentInstallation.accountLogin, + repositorySelection: currentInstallation.repositorySelection, + installedReposCount: installedRepos.length, + registeredInstalledCount: registeredInstalled.length, + status, + missingPermissions, + missingEvents, + permissions: currentInstallation.permissions, + events: currentInstallation.events, + checkedAt: nowIso(), + errorSummary, + } as const; + await upsertInstallationHealth(env, 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; +} + +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..5cfca796e9 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,681 @@ 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(), + 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"); + +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/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/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..22a084907c 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(opportunityPayload.profile.github.topLanguages).toEqual(["TypeScript", "Python"]); - expect(opportunityPayload.opportunities[0]).toMatchObject({ repoFullName: "entrius/allways-ui" }); + 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(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,316 @@ 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, + 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); + + 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 +820,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 +864,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 +875,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 +1283,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 +1352,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 +1374,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", issues: "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 +1536,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 +1584,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..48bf2a99b7 --- /dev/null +++ b/test/unit/auth.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSessionFromGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow } from "../../src/auth/github-oauth"; +import { RateLimiter, routeClassForPath } 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("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 () => + 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..bf49990169 --- /dev/null +++ b/test/unit/backfill.test.ts @@ -0,0 +1,1331 @@ +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({ 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" }, + 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", "issues"], + missingEvents: ["issues", "repository"], + repairSteps: expect.arrayContaining(["Update the GitHub App permissions and subscribed events."]), + }); + + await upsertInstallation(env, { + installation: { + 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"], + }, + }); + const refreshed = await refreshInstallationHealth(env); + 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); + 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("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); + 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..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 { createOrUpdateCheckRun } 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[] = []; @@ -102,6 +114,92 @@ 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/); + }); + + 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 { @@ -119,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/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/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/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..3c2a2a1ca1 --- /dev/null +++ b/test/unit/queue.test.ts @@ -0,0 +1,574 @@ +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({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + 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", 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 }]); + }); + + 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: [], + }; +} + +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 49754ff746..e5f087b49d 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,59 @@ 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", + 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 +102,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": [ {