diff --git a/.github/issue-owners.json b/.github/issue-owners.json new file mode 100644 index 00000000000..7dd64df3a93 --- /dev/null +++ b/.github/issue-owners.json @@ -0,0 +1,128 @@ +{ + "$comment": "[FORK HARNESS] owners narrowed to the only account with push access on wenshao/qwen-code. Label-driven issue assignment map; areas are keyed on the existing issue label taxonomy. Assignment is a pure function of an issue's labels — no model output is involved. Owners need push access but do NOT need a CODEOWNERS entry; every candidate is re-checked against the collaborator API at write time, so adding a login here cannot grant access to someone who lacks it. Areas match in file order, first match wins. An area's optional paths list routes PR assignment (assign-pr-owner.mjs) by changed-file prefix, longest prefix wins; the core area's packages/core/ entry is the fallback for paths no module claims. Issue assignment ignores paths; module areas sit after core so label-based issue matching still resolves core first. See docs/design/2026-08-07-issue-auto-assignment.md.", + "requireLabels": [ + "need-discussion" + ], + "skipLabels": [ + "welcome-pr", + "feature/need-help", + "good first issue", + "help wanted", + "autofix/approved", + "autofix/in-progress" + ], + "areas": [ + { + "name": "core", + "labels": [ + "category/core", + "scope/core" + ], + "paths": [ + "packages/core/" + ], + "owners": [ + "wenshao" + ] + }, + { + "name": "core-skills", + "labels": [ + "scope/core" + ], + "paths": [ + "packages/core/src/skills/" + ], + "owners": [ + "wenshao" + ] + }, + { + "name": "core-memory", + "labels": [ + "scope/core" + ], + "paths": [ + "packages/core/src/memory/" + ], + "owners": [ + "wenshao" + ] + }, + { + "name": "core-goals", + "labels": [ + "scope/core" + ], + "paths": [ + "packages/core/src/goals/" + ], + "owners": [ + "wenshao" + ] + }, + { + "name": "core-telemetry", + "labels": [ + "scope/core" + ], + "paths": [ + "packages/core/src/telemetry/" + ], + "owners": [ + "wenshao" + ] + }, + { + "name": "core-extension", + "labels": [ + "scope/core" + ], + "paths": [ + "packages/core/src/extension/" + ], + "owners": [ + "wenshao" + ] + }, + { + "name": "core-agents", + "labels": [ + "scope/core" + ], + "paths": [ + "packages/core/src/agents/" + ], + "owners": [ + "wenshao" + ] + }, + { + "name": "core-config", + "labels": [ + "scope/core" + ], + "paths": [ + "packages/core/src/config/" + ], + "owners": [ + "wenshao" + ] + }, + { + "name": "core-runtime", + "labels": [ + "scope/core" + ], + "paths": [ + "packages/core/src/core/", + "packages/core/src/services/", + "packages/core/src/tools/", + "packages/core/src/utils/" + ], + "owners": [ + "wenshao" + ] + } + ] +} diff --git a/.github/scripts/assign-issue-owner.mjs b/.github/scripts/assign-issue-owner.mjs new file mode 100644 index 00000000000..fbce2e6d493 --- /dev/null +++ b/.github/scripts/assign-issue-owner.mjs @@ -0,0 +1,314 @@ +#!/usr/bin/env node +// Assign an issue to an area owner, derived purely from the issue's labels. +// +// This script never reads issue title, body, or comments, so untrusted issue +// text cannot steer the assignment. The triage agent's only influence is the +// labels it applies, drawn from the repository's existing label taxonomy; the +// label -> owner map lives in .github/issue-owners.json and is reviewed like +// any other checked-in file. Push access is re-verified against the live +// collaborator API before every write, so an edit to that map cannot assign +// someone who does not already have permission. +import { appendFileSync, readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +const OWNERS_FILE = '.github/issue-owners.json'; +const WRITE_PERMISSIONS = new Set(['admin', 'maintain', 'write']); +const LOGIN = /^(?!.*--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/; + +function isStringArray(value) { + return Array.isArray(value) && value.every((v) => typeof v === 'string'); +} + +// A changed-file prefix for assign-pr-owner.mjs: relative, no `//`, no +// backslash, no `.`/`..` segments, and ending in `/` so startsWith cannot +// leak into a sibling directory (packages/core matching packages/coredump/). +function isPathPrefix(prefix) { + if (typeof prefix !== 'string' || prefix.length === 0) return false; + if (prefix.startsWith('/') || prefix.startsWith('./')) return false; + if (!prefix.endsWith('/')) return false; + if (prefix.includes('\\') || prefix.includes('//')) return false; + return !prefix + .split('/') + .some((segment) => segment === '.' || segment === '..'); +} + +export function loadPolicy(raw) { + const policy = JSON.parse(raw); + if (!policy || typeof policy !== 'object' || Array.isArray(policy)) { + throw new Error(`${OWNERS_FILE}: not an object`); + } + // An empty label entry can never match; in requireLabels it would silently + // skip every issue on a green run, so reject it like other malformed config. + if ( + !isStringArray(policy.requireLabels) || + !isStringArray(policy.skipLabels) || + policy.requireLabels.some((label) => label.length === 0) || + policy.skipLabels.some((label) => label.length === 0) + ) { + throw new Error( + `${OWNERS_FILE}: requireLabels/skipLabels must be non-empty strings`, + ); + } + if (!Array.isArray(policy.areas) || policy.areas.length === 0) { + throw new Error(`${OWNERS_FILE}: areas must be a non-empty array`); + } + const areaNames = new Set(); + for (const area of policy.areas) { + if (typeof area?.name !== 'string' || area.name.length === 0) { + throw new Error(`${OWNERS_FILE}: every area needs a name`); + } + // First match wins, so two areas sharing a name silently shadow one another. + if (areaNames.has(area.name)) { + throw new Error(`${OWNERS_FILE}: duplicate area ${area.name}`); + } + areaNames.add(area.name); + if ( + !isStringArray(area.labels) || + area.labels.length === 0 || + area.labels.some((label) => label.length === 0) + ) { + throw new Error(`${OWNERS_FILE}: area ${area.name} needs labels`); + } + if (!Array.isArray(area.owners) || area.owners.length === 0) { + throw new Error(`${OWNERS_FILE}: area ${area.name} needs owners`); + } + const seen = new Set(); + for (const owner of area.owners) { + // Rejected here rather than at the gh call so a typo fails the config, + // not a single assignment attempt. + if (typeof owner !== 'string' || !LOGIN.test(owner)) { + throw new Error(`${OWNERS_FILE}: invalid login in ${area.name}`); + } + // A repeated login would be counted twice and win ties unfairly. + const normalizedOwner = owner.toLowerCase(); + if (seen.has(normalizedOwner)) { + throw new Error(`${OWNERS_FILE}: duplicate owner ${owner}`); + } + seen.add(normalizedOwner); + } + // A never-matching paths entry silently unroutes the area from PR + // assignment, so reject it here like other malformed config. + if (area.paths !== undefined && !Array.isArray(area.paths)) { + throw new Error( + `${OWNERS_FILE}: area ${area.name} paths must be an array`, + ); + } + // An explicitly empty list can never route the area either, yet the + // entry loop below cannot catch it — reject it like the sibling + // labels/owners checks do. + if (Array.isArray(area.paths) && area.paths.length === 0) { + throw new Error( + `${OWNERS_FILE}: area ${area.name} paths must not be empty; omit paths for a label-only area`, + ); + } + for (const prefix of area.paths ?? []) { + if (!isPathPrefix(prefix)) { + throw new Error( + `${OWNERS_FILE}: invalid paths entry in ${area.name}: ${JSON.stringify(prefix)}`, + ); + } + } + } + return policy; +} + +// Returns a human-readable reason to skip, or null to proceed. Ordered so the +// most informative reason wins when several apply. +export function skipReason(policy, issue) { + const labels = new Set(issue.labels.map((label) => label.name)); + if (issue.state !== 'OPEN') return 'issue is not open'; + if (issue.assignees.length > 0) return 'issue already has an assignee'; + const skipped = policy.skipLabels.filter((label) => labels.has(label)); + if (skipped.length > 0) return `carries ${skipped.join(', ')}`; + const missing = policy.requireLabels.filter((label) => !labels.has(label)); + if (missing.length > 0) return `missing ${missing.join(', ')}`; + return null; +} + +// First matching area wins, so file order is the documented precedence. +export function matchArea(policy, issue) { + const labels = new Set(issue.labels.map((label) => label.name)); + return ( + policy.areas.find((area) => + area.labels.some((label) => labels.has(label)), + ) ?? null + ); +} + +// Rotate by issue number before the stable minimum so a set of equally loaded +// owners spreads round-robin instead of always landing on the first entry. +export function pickOwner(owners, loadByOwner, issueNumber) { + const offset = issueNumber % owners.length; + const rotated = [...owners.slice(offset), ...owners.slice(0, offset)]; + return rotated.reduce((best, owner) => + loadByOwner.get(owner) < loadByOwner.get(best) ? owner : best, + ); +} + +function gh(args) { + const result = spawnSync('gh', args, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `gh ${args.join(' ')} failed`); + } + return result.stdout.trim(); +} + +function record(lines) { + const body = `${lines.join('\n')}\n`; + process.stdout.write(body); + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, body); + } +} + +// A candidate who lost push access, renamed, or deleted their account makes +// the permission lookup fail; warn and drop them rather than failing the run +// over one stale entry. +function canWrite(repository, login) { + try { + return WRITE_PERMISSIONS.has( + gh([ + 'api', + `repos/${repository}/collaborators/${login}/permission`, + '--jq', + '.permission', + ]), + ); + } catch (error) { + console.warn( + `::warning::Cannot verify push access for @${login}: ${error.message}`, + ); + return false; + } +} + +export function openIssueCount(repository, login) { + return Number( + gh([ + 'issue', + 'list', + '--repo', + repository, + '--state', + 'open', + '--assignee', + login, + '--limit', + '100', + '--json', + 'number', + '--jq', + 'length', + ]), + ); +} + +function main() { + const repository = process.env.GITHUB_REPOSITORY; + const issueNumber = Number(process.env.ISSUE_NUMBER); + const dryRun = process.env.DRY_RUN === 'true'; + if (!repository || !/^[^/]+\/[^/]+$/.test(repository)) { + throw new Error('Invalid repository'); + } + if (!Number.isSafeInteger(issueNumber) || issueNumber < 1) { + throw new Error('Invalid issue number'); + } + + const policy = loadPolicy(readFileSync(OWNERS_FILE, 'utf8')); + const issue = JSON.parse( + gh([ + 'issue', + 'view', + String(issueNumber), + '--repo', + repository, + '--json', + 'state,labels,assignees', + ]), + ); + + const skip = skipReason(policy, issue); + if (skip) { + record([`Assignment: skipped — ${skip}`]); + return; + } + + const area = matchArea(policy, issue); + if (!area) { + record(['Assignment: skipped — no area label matched']); + return; + } + + const eligible = area.owners.filter((owner) => canWrite(repository, owner)); + if (eligible.length === 0) { + console.warn( + `::warning::No owner of area ${area.name} has push access; check ${OWNERS_FILE}.`, + ); + record([`Assignment: skipped — no eligible owner for area ${area.name}`]); + return; + } + + const loadByOwner = new Map( + eligible.map((owner) => [owner, openIssueCount(repository, owner)]), + ); + const assignee = pickOwner(eligible, loadByOwner, issueNumber); + + if (dryRun) { + record([ + `Area: ${area.name}`, + `Assignment: dry-run — would assign @${assignee} (${loadByOwner.get(assignee)} open)`, + ]); + return; + } + + const latestIssue = JSON.parse( + gh([ + 'issue', + 'view', + String(issueNumber), + '--repo', + repository, + '--json', + 'state,labels,assignees', + ]), + ); + const latestSkip = skipReason(policy, latestIssue); + if (latestSkip) { + record([`Assignment: skipped — ${latestSkip}`]); + return; + } + if (matchArea(policy, latestIssue)?.name !== area.name) { + record(['Assignment: skipped — issue labels changed']); + return; + } + + gh([ + 'issue', + 'edit', + String(issueNumber), + '--repo', + repository, + '--add-assignee', + assignee, + ]); + record([ + `Area: ${area.name}`, + `Assignment: assigned @${assignee} (${loadByOwner.get(assignee)} open)`, + ]); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/.github/scripts/assign-pr-owner.mjs b/.github/scripts/assign-pr-owner.mjs new file mode 100644 index 00000000000..2e0614d5a7b --- /dev/null +++ b/.github/scripts/assign-pr-owner.mjs @@ -0,0 +1,288 @@ +#!/usr/bin/env node +// Assign a PR to one area owner, derived purely from the PR's changed file +// paths. +// +// PR-side companion to assign-issue-owner.mjs. The script never reads PR +// title, body, or comments, so untrusted PR text cannot steer who gets +// assigned. The diff's file paths are matched against the optional `paths` +// list of each area in .github/issue-owners.json; the longest matching +// prefix wins, so a module-level entry overrides the coarser fallback area +// that contains it. The assignee is the area's least loaded eligible +// owner, rotated by PR number on ties — the same load metric and rotation as +// issue assignment. Push access is re-verified against the live collaborator +// API before the write, coverage is re-checked against the live PR +// immediately before it, and the run no-ops once any mapped owner is already +// an assignee or has reviewed, so repeated pushes never stack assignments. +import { appendFileSync, readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +import { + loadPolicy, + openIssueCount, + pickOwner, +} from './assign-issue-owner.mjs'; + +const OWNERS_FILE = '.github/issue-owners.json'; +const WRITE_PERMISSIONS = new Set(['admin', 'maintain', 'write']); +const BOT_LOGIN = /(\[bot\]|-bot)$/; + +function gh(args) { + const result = spawnSync('gh', args, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `gh ${args.join(' ')} failed`); + } + return result.stdout.trim(); +} + +function record(lines) { + const body = `${lines.join('\n')}\n`; + process.stdout.write(body); + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, body); + } +} + +export function skipPrReason(pr) { + if (pr.state !== 'OPEN') return 'PR is not open'; + if (pr.isDraft) return 'PR is a draft'; + // `gh pr view --json author` exports `"author": null` once the account is + // deleted; skip gracefully like the other skip reasons instead of throwing + // on the null dereference and failing the check on every later trigger. + const authorLogin = pr.author?.login; + if (!authorLogin) return 'PR author account was deleted'; + if (BOT_LOGIN.test(authorLogin)) return 'authored by a bot'; + return null; +} + +// The longest matching prefix wins, so a module entry overrides the coarser +// area that contains it; ties keep the earlier area in file order. Areas +// without a paths list never match. +export function matchedAreasByPath(policy, files) { + const ranked = []; + for (const area of policy.areas) { + let length = 0; + for (const prefix of area.paths ?? []) { + if (files.some((file) => file.path.startsWith(prefix))) { + length = Math.max(length, prefix.length); + } + } + if (length > 0) ranked.push({ area, length }); + } + ranked.sort((a, b) => b.length - a.length); + return ranked.map((entry) => entry.area); +} + +export function matchAreaByPath(policy, files) { + return matchedAreasByPath(policy, files)[0] ?? null; +} + +// An assignee or a submitted review by any mapped owner means this routing +// already happened; never stack a second assignment. +export function alreadyCovered(policy, pr) { + const pool = new Set( + policy.areas + .flatMap((area) => area.owners) + .map((login) => login.toLowerCase()), + ); + const involved = [ + ...pr.assignees.map((assignee) => assignee.login), + ...pr.latestReviews.map((review) => review.author?.login), + ]; + return involved.some((login) => login && pool.has(login.toLowerCase())); +} + +// Same stale-entry tolerance as the issue script: a candidate who lost push +// access is dropped with a warning, not a failed run. +function canWrite(repository, login) { + try { + return WRITE_PERMISSIONS.has( + gh([ + 'api', + `repos/${repository}/collaborators/${login}/permission`, + '--jq', + '.permission', + ]), + ); + } catch (error) { + console.warn( + `::warning::Cannot verify push access for @${login}: ${error.message}`, + ); + return false; + } +} + +// `gh pr view --json files` caps at 100 entries, so the REST files endpoint +// pages through every changed file instead. Each filename is decoded from +// base64 because changed filenames are attacker-controlled on fork PRs and +// git accepts newlines in path components: splitting the rendered text on +// newlines would let a name like "xpackages/core/poc" inject a phantom +// "packages/core/poc" entry that steers which area owner gets assigned. +export function changedFiles(repository, prNumber) { + return gh([ + 'api', + `repos/${repository}/pulls/${prNumber}/files`, + '--paginate', + '--jq', + '.[] | (.filename, (.previous_filename // empty)) | @base64', + ]) + .split('\n') + .filter(Boolean) + .map((line) => ({ + path: Buffer.from(line, 'base64').toString('utf8'), + })); +} + +function main() { + const repository = process.env.GITHUB_REPOSITORY; + const prNumber = Number(process.env.PR_NUMBER); + const dryRun = process.env.DRY_RUN === 'true'; + if (!repository || !/^[^/]+\/[^/]+$/.test(repository)) { + throw new Error('Invalid repository'); + } + if (!Number.isSafeInteger(prNumber) || prNumber < 1) { + throw new Error('Invalid PR number'); + } + + const policy = loadPolicy(readFileSync(OWNERS_FILE, 'utf8')); + const pr = JSON.parse( + gh([ + 'pr', + 'view', + String(prNumber), + '--repo', + repository, + '--json', + 'state,isDraft,author,assignees,latestReviews,headRefOid', + ]), + ); + pr.files = changedFiles(repository, prNumber); + + const skip = skipPrReason(pr); + if (skip) { + record([`Assignment: skipped — ${skip}`]); + return; + } + if (alreadyCovered(policy, pr)) { + record(['Assignment: skipped — a mapped owner is already on the PR']); + return; + } + const matched = matchedAreasByPath(policy, pr.files); + if (matched.length === 0) { + record(['Assignment: skipped — no area path matched']); + return; + } + + // Never assign the PR author to their own work. When a module's owner + // cannot take the PR (they authored it, or lost push access), fall back + // to the next coarser matching area instead of leaving the PR unassigned. + // The null guard mirrors skipPrReason's: a deleted account exports as + // `"author": null` and can exclude no one. + const authorLogin = (pr.author?.login ?? '').toLowerCase(); + let area; + let eligible; + for (area of matched) { + eligible = area.owners.filter( + (owner) => + owner.toLowerCase() !== authorLogin && canWrite(repository, owner), + ); + if (eligible.length > 0) break; + } + if (!eligible || eligible.length === 0) { + console.warn( + `::warning::No eligible owner for the areas touched by this PR; check ${OWNERS_FILE}.`, + ); + record(['Assignment: skipped — no eligible owner for the matched areas']); + return; + } + if (area !== matched[0]) { + record([ + `Area ${matched[0].name} has no eligible owner (author or no push access); falling back to ${area.name}`, + ]); + } + + const loadByOwner = new Map( + eligible.map((owner) => [owner, openIssueCount(repository, owner)]), + ); + const assignee = pickOwner(eligible, loadByOwner, prNumber); + + if (dryRun) { + record([ + `Area: ${area.name}`, + `Assignment: dry-run — would assign @${assignee} (${loadByOwner.get(assignee)} open)`, + ]); + return; + } + + // Between the opening snapshot and this write sit up to ~30 sequential API + // calls (permission and load per candidate), and during that window a + // human or a concurrent run may already have put a mapped owner on the PR + // or closed it. Re-check the live PR immediately before mutating, + // mirroring the sibling issue script's pre-write re-fetch, so a covered PR + // is never assigned twice. + const latestPr = JSON.parse( + gh([ + 'pr', + 'view', + String(prNumber), + '--repo', + repository, + '--json', + 'state,isDraft,author,assignees,latestReviews,headRefOid', + ]), + ); + const latestSkip = skipPrReason(latestPr); + if (latestSkip) { + record([`Assignment: skipped — ${latestSkip}`]); + return; + } + if (latestPr.headRefOid !== pr.headRefOid) { + record(['Assignment: skipped — PR head changed during routing']); + return; + } + if (alreadyCovered(policy, latestPr)) { + record(['Assignment: skipped — a mapped owner is already on the PR']); + return; + } + + try { + gh([ + 'pr', + 'edit', + String(prNumber), + '--repo', + repository, + '--add-assignee', + assignee, + ]); + } catch (error) { + if ( + /permission|403|resource not accessible by integration/i.test( + error.message, + ) + ) { + record(['Assignment: skipped — token cannot assign PRs']); + return; + } + throw error; + } + record([ + `Area: ${area.name}`, + `Assignment: assigned @${assignee} (${loadByOwner.get(assignee)} open)`, + ]); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/.github/workflows/assign-pr-owner.yml b/.github/workflows/assign-pr-owner.yml new file mode 100644 index 00000000000..40174b36fc3 --- /dev/null +++ b/.github/workflows/assign-pr-owner.yml @@ -0,0 +1,62 @@ +name: 'Assign PR owner' + +# Assign one area owner to a PR once it touches that area's paths. Routing is +# a pure function of the changed file paths and .github/issue-owners.json — +# no model runs here and the script never reads PR title, body, or comments. +# Runs on pull_request_target so fork PRs are covered too; the checkout stays +# on the trusted base ref and the script only reads the PR's file list from +# the API, so PR content never executes here. + +on: + pull_request_target: + types: ['opened', 'synchronize', 'reopened', 'ready_for_review'] + workflow_dispatch: + inputs: + number: + description: 'PR number to evaluate' + required: true + type: 'string' + dry_run: + description: 'Report the chosen assignee without assigning' + required: false + default: true + type: 'boolean' + +permissions: + contents: 'read' + +concurrency: + group: 'assign-pr-owner-${{ github.event.pull_request.number || inputs.number }}' + cancel-in-progress: false + +jobs: + assign: + if: "${{ github.repository == 'wenshao/qwen-code' }}" + runs-on: 'ubuntu-latest' + timeout-minutes: 5 + permissions: + contents: 'read' + issues: 'read' + pull-requests: 'write' + steps: + - name: 'Checkout owner map' + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + with: + ref: '${{ github.event.pull_request.base.sha || github.sha }}' + sparse-checkout: |- + .github/issue-owners.json + .github/scripts/assign-issue-owner.mjs + .github/scripts/assign-pr-owner.mjs + persist-credentials: false + + - name: 'Assign area owner' + env: + GH_TOKEN: '${{ github.token }}' + PR_NUMBER: '${{ github.event.pull_request.number || inputs.number }}' + DRY_RUN: "${{ inputs.dry_run || 'false' }}" + run: |- + if [ ! -f .github/scripts/assign-pr-owner.mjs ]; then + echo 'Assignment: skipped — trusted base does not contain assign-pr-owner.mjs yet' + exit 0 + fi + node .github/scripts/assign-pr-owner.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index c410b6cddd7..00000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,222 +0,0 @@ -# .github/workflows/ci.yml - -name: 'Qwen Code CI' - -on: - push: - branches: - - 'main' - - 'release/**' - pull_request: - branches: - - 'main' - - 'release/**' - merge_group: - workflow_dispatch: - inputs: - branch_ref: - description: 'Branch to run on' - required: true - default: 'main' - type: 'string' - -concurrency: - group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' - cancel-in-progress: |- - ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }} - -permissions: - checks: 'write' - contents: 'read' - statuses: 'write' - -defaults: - run: - shell: 'bash' - -env: - ACTIONLINT_VERSION: '1.7.7' - SHELLCHECK_VERSION: '0.11.0' - YAMLLINT_VERSION: '1.35.1' - -jobs: - lint: - name: 'Lint' - runs-on: 'ubuntu-latest' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - with: - ref: '${{ github.event.inputs.branch_ref || github.ref }}' - fetch-depth: 0 - - - name: 'Set up Node.js' - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0 - with: - node-version-file: '.nvmrc' - cache: 'npm' - - - name: 'Install dependencies' - run: 'npm ci' - - - name: 'Check lockfile' - run: 'npm run check:lockfile' - - - name: 'Install linters' - run: 'node scripts/lint.js --setup' - - - name: 'Run ESLint' - run: 'node scripts/lint.js --eslint' - - - name: 'Run actionlint' - run: 'node scripts/lint.js --actionlint' - - - name: 'Run shellcheck' - run: 'node scripts/lint.js --shellcheck' - - - name: 'Run yamllint' - run: 'node scripts/lint.js --yamllint' - - - name: 'Run Prettier' - run: 'node scripts/lint.js --prettier' - - - name: 'Run sensitive keyword linter' - run: 'node scripts/lint.js --sensitive-keywords' - - # - # Test: Node - # - test: - name: 'Test' - runs-on: '${{ matrix.os }}' - needs: - - 'lint' - permissions: - contents: 'read' - checks: 'write' - pull-requests: 'write' - strategy: - fail-fast: false # So we can see all test failures - matrix: - os: - - 'macos-latest' - - 'ubuntu-latest' - - 'windows-latest' - node-version: - - '20.x' - - '22.x' - - '24.x' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - - - name: 'Set up Node.js ${{ matrix.node-version }}' - uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 - with: - node-version: '${{ matrix.node-version }}' - cache: 'npm' - cache-dependency-path: 'package-lock.json' - registry-url: 'https://registry.npmjs.org/' - - - name: 'Configure npm for rate limiting' - run: |- - npm config set fetch-retry-mintimeout 20000 - npm config set fetch-retry-maxtimeout 120000 - npm config set fetch-retries 5 - npm config set fetch-timeout 300000 - - - name: 'Install dependencies' - run: |- - npm ci --prefer-offline --no-audit --progress=false - - - name: 'Build project' - run: |- - npm run build - - - name: 'Run tests and generate reports' - env: - NO_COLOR: true - run: 'npm run test:ci' - - - name: 'Publish Test Report (for non-forks)' - if: |- - ${{ always() && (github.event.pull_request.head.repo.full_name == github.repository) }} - uses: 'dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3' # ratchet:dorny/test-reporter@v2 - with: - name: 'Test Results (Node ${{ matrix.node-version }})' - path: 'packages/*/junit.xml' - reporter: 'java-junit' - fail-on-error: 'false' - - - name: 'Upload Test Results Artifact (for forks)' - if: |- - ${{ always() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }} - uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 - with: - name: 'test-results-fork-${{ matrix.node-version }}-${{ matrix.os }}' - path: 'packages/*/junit.xml' - - - name: 'Upload coverage reports' - if: |- - ${{ always() }} - uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 - with: - name: 'coverage-reports-${{ matrix.node-version }}-${{ matrix.os }}' - path: 'packages/*/coverage' - - post_coverage_comment: - name: 'Post Coverage Comment' - runs-on: 'ubuntu-latest' - needs: 'test' - if: |- - ${{ always() && github.event_name == 'pull_request' && (github.event.pull_request.head.repo.full_name == github.repository) }} - continue-on-error: true - permissions: - contents: 'read' # For checkout - pull-requests: 'write' # For commenting - strategy: - matrix: - # Reduce noise by only posting the comment once - os: - - 'ubuntu-latest' - node-version: - - '22.x' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - - - name: 'Download coverage reports artifact' - uses: 'actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0' # ratchet:actions/download-artifact@v5 - with: - name: 'coverage-reports-${{ matrix.node-version }}-${{ matrix.os }}' - path: 'coverage_artifact' # Download to a specific directory - - - name: 'Post Coverage Comment using Composite Action' - uses: './.github/actions/post-coverage-comment' # Path to the composite action directory - with: - cli_json_file: 'coverage_artifact/cli/coverage/coverage-summary.json' - core_json_file: 'coverage_artifact/core/coverage/coverage-summary.json' - cli_full_text_summary_file: 'coverage_artifact/cli/coverage/full-text-summary.txt' - core_full_text_summary_file: 'coverage_artifact/core/coverage/full-text-summary.txt' - node_version: '${{ matrix.node-version }}' - os: '${{ matrix.os }}' - github_token: '${{ secrets.GITHUB_TOKEN }}' - - codeql: - name: 'CodeQL' - runs-on: 'ubuntu-latest' - permissions: - actions: 'read' - contents: 'read' - security-events: 'write' - steps: - - name: 'Checkout' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - - - name: 'Initialize CodeQL' - uses: 'github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2' # ratchet:github/codeql-action/init@v3 - with: - languages: 'javascript' - - - name: 'Perform CodeQL Analysis' - uses: 'github/codeql-action/analyze@df559355d593797519d70b90fc8edd5db049e7a2' # ratchet:github/codeql-action/analyze@v3 diff --git a/.github/workflows/qwen-code-pr-review.yml b/.github/workflows/qwen-code-pr-review.yml deleted file mode 100644 index 6d7f0934fc0..00000000000 --- a/.github/workflows/qwen-code-pr-review.yml +++ /dev/null @@ -1,190 +0,0 @@ -name: '🧐 Qwen Pull Request Review' - -on: - pull_request_target: - types: ['opened'] - pull_request_review_comment: - types: ['created'] - pull_request_review: - types: ['submitted'] - workflow_dispatch: - inputs: - pr_number: - description: 'PR number to review' - required: true - type: 'number' - -jobs: - review-pr: - if: |- - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request_target' && - github.event.action == 'opened' && - (github.event.pull_request.author_association == 'OWNER' || - github.event.pull_request.author_association == 'MEMBER' || - github.event.pull_request.author_association == 'COLLABORATOR')) || - (github.event_name == 'issue_comment' && - github.event.issue.pull_request && - contains(github.event.comment.body, '@qwen /review') && - (github.event.comment.author_association == 'OWNER' || - github.event.comment.author_association == 'MEMBER' || - github.event.comment.author_association == 'COLLABORATOR')) || - (github.event_name == 'pull_request_review_comment' && - contains(github.event.comment.body, '@qwen /review') && - (github.event.comment.author_association == 'OWNER' || - github.event.comment.author_association == 'MEMBER' || - github.event.comment.author_association == 'COLLABORATOR')) || - (github.event_name == 'pull_request_review' && - contains(github.event.review.body, '@qwen /review') && - (github.event.review.author_association == 'OWNER' || - github.event.review.author_association == 'MEMBER' || - github.event.review.author_association == 'COLLABORATOR')) - timeout-minutes: 15 - runs-on: 'ubuntu-latest' - permissions: - contents: 'read' - id-token: 'write' - pull-requests: 'write' - issues: 'write' - steps: - - name: 'Checkout PR code' - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 - with: - token: '${{ secrets.GITHUB_TOKEN }}' - fetch-depth: 0 - - - name: 'Get PR details (pull_request_target & workflow_dispatch)' - id: 'get_pr' - if: |- - ${{ github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' }} - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - run: |- - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - PR_NUMBER=${{ github.event.inputs.pr_number }} - else - PR_NUMBER=${{ github.event.pull_request.number }} - fi - echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - # Get PR details - PR_DATA=$(gh pr view $PR_NUMBER --json title,body,additions,deletions,changedFiles,baseRefName,headRefName) - echo "pr_data=$PR_DATA" >> "$GITHUB_OUTPUT" - # Get file changes - CHANGED_FILES=$(gh pr diff $PR_NUMBER --name-only) - echo "changed_files<> "$GITHUB_OUTPUT" - echo "$CHANGED_FILES" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - - name: 'Get PR details (issue_comment)' - id: 'get_pr_comment' - if: |- - ${{ github.event_name == 'issue_comment' }} - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - COMMENT_BODY: '${{ github.event.comment.body }}' - run: |- - PR_NUMBER=${{ github.event.issue.number }} - echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - # Extract additional instructions from comment - ADDITIONAL_INSTRUCTIONS=$(echo "$COMMENT_BODY" | sed 's/.*@qwen \/review//' | xargs) - echo "additional_instructions=$ADDITIONAL_INSTRUCTIONS" >> "$GITHUB_OUTPUT" - # Get PR details - PR_DATA=$(gh pr view $PR_NUMBER --json title,body,additions,deletions,changedFiles,baseRefName,headRefName) - echo "pr_data=$PR_DATA" >> "$GITHUB_OUTPUT" - # Get file changes - CHANGED_FILES=$(gh pr diff $PR_NUMBER --name-only) - echo "changed_files<> "$GITHUB_OUTPUT" - echo "$CHANGED_FILES" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - - name: 'Run Qwen PR Review' - uses: 'QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2' - env: - GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - PR_NUMBER: '${{ steps.get_pr.outputs.pr_number || steps.get_pr_comment.outputs.pr_number }}' - PR_DATA: '${{ steps.get_pr.outputs.pr_data || steps.get_pr_comment.outputs.pr_data }}' - CHANGED_FILES: '${{ steps.get_pr.outputs.changed_files || steps.get_pr_comment.outputs.changed_files }}' - ADDITIONAL_INSTRUCTIONS: '${{ steps.get_pr.outputs.additional_instructions || steps.get_pr_comment.outputs.additional_instructions }}' - REPOSITORY: '${{ github.repository }}' - with: - OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' - OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' - OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' - settings_json: |- - { - "coreTools": [ - "run_shell_command", - "write_file" - ], - "sandbox": false - } - prompt: |- - You are an expert code reviewer. You have access to shell commands to gather PR information and perform the review. - - IMPORTANT: Use the available shell commands to gather information. Do not ask for information to be provided. - - Start by running these commands to gather the required data: - 1. Run: echo "$PR_DATA" to get PR details (JSON format) - 2. Run: echo "$CHANGED_FILES" to get the list of changed files - 3. Run: echo "$PR_NUMBER" to get the PR number - 4. Run: echo "$ADDITIONAL_INSTRUCTIONS" to see any specific review instructions from the user - 5. Run: gh pr diff $PR_NUMBER to see the full diff - 6. For any specific files, use: cat filename, head -50 filename, or tail -50 filename - - Additional Review Instructions: - If ADDITIONAL_INSTRUCTIONS contains text, prioritize those specific areas or focus points in your review. - Common instruction examples: "focus on security", "check performance", "review error handling", "check for breaking changes" - - Once you have the information, provide a comprehensive code review by: - 1. Writing your review to a file: write_file("review.md", "") - 2. Posting the review: gh pr comment $PR_NUMBER --body-file review.md --repo $REPOSITORY - - Review Areas: - - **Security**: Authentication, authorization, input validation, data sanitization - - **Performance**: Algorithms, database queries, caching, resource usage - - **Reliability**: Error handling, logging, testing coverage, edge cases - - **Maintainability**: Code structure, documentation, naming conventions - - **Functionality**: Logic correctness, requirements fulfillment - - Output Format: - Structure your review using this exact format with markdown: - - ## 📋 Review Summary - Provide a brief 2-3 sentence overview of the PR and overall assessment. - - ## 🔍 General Feedback - - List general observations about code quality - - Mention overall patterns or architectural decisions - - Highlight positive aspects of the implementation - - Note any recurring themes across files - - ## 🎯 Specific Feedback - Only include sections below that have actual issues. If there are no issues in a priority category, omit that entire section. - - ### 🔴 Critical - (Only include this section if there are critical issues) - Issues that must be addressed before merging (security vulnerabilities, breaking changes, major bugs): - - **File: `filename:line`** - Description of critical issue with specific recommendation - - ### 🟡 High - (Only include this section if there are high priority issues) - Important issues that should be addressed (performance problems, design flaws, significant bugs): - - **File: `filename:line`** - Description of high priority issue with suggested fix - - ### 🟢 Medium - (Only include this section if there are medium priority issues) - Improvements that would enhance code quality (style issues, minor optimizations, better practices): - - **File: `filename:line`** - Description of medium priority improvement - - ### 🔵 Low - (Only include this section if there are suggestions) - Nice-to-have improvements and suggestions (documentation, naming, minor refactoring): - - **File: `filename:line`** - Description of suggestion or enhancement - - **Note**: If no specific issues are found in any category, simply state "No specific issues identified in this review." - - ## ✅ Highlights - (Only include this section if there are positive aspects to highlight) - - Mention specific good practices or implementations - - Acknowledge well-written code sections - - Note improvements from previous versions diff --git a/harness-trigger-run1.txt b/harness-trigger-run1.txt new file mode 100644 index 00000000000..9d1e16ccdb3 --- /dev/null +++ b/harness-trigger-run1.txt @@ -0,0 +1 @@ +trigger run1 diff --git a/harness-trigger-run1b.txt b/harness-trigger-run1b.txt new file mode 100644 index 00000000000..87c4c4cae77 --- /dev/null +++ b/harness-trigger-run1b.txt @@ -0,0 +1 @@ +trigger run1b diff --git a/harness-trigger-run1c.txt b/harness-trigger-run1c.txt new file mode 100644 index 00000000000..32cf1cb3a37 --- /dev/null +++ b/harness-trigger-run1c.txt @@ -0,0 +1 @@ +trigger run1c diff --git a/harness-trigger-run3.txt b/harness-trigger-run3.txt new file mode 100644 index 00000000000..2f467094ef2 --- /dev/null +++ b/harness-trigger-run3.txt @@ -0,0 +1 @@ +trigger run3 diff --git a/harness-trigger-run4.txt b/harness-trigger-run4.txt new file mode 100644 index 00000000000..2632d256bdd --- /dev/null +++ b/harness-trigger-run4.txt @@ -0,0 +1 @@ +trigger run4 diff --git a/harness-trigger-run5.txt b/harness-trigger-run5.txt new file mode 100644 index 00000000000..042b1feec54 --- /dev/null +++ b/harness-trigger-run5.txt @@ -0,0 +1 @@ +trigger run5 diff --git a/harness-trigger-run6.txt b/harness-trigger-run6.txt new file mode 100644 index 00000000000..26f2fbf4442 --- /dev/null +++ b/harness-trigger-run6.txt @@ -0,0 +1 @@ +trigger run6 diff --git a/package-lock.json b/package-lock.json index ff5a902d7c8..319d0d6593d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -416,6 +416,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -891,6 +892,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -914,6 +916,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2353,6 +2356,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -3839,6 +3843,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4430,6 +4435,7 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4440,6 +4446,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4652,6 +4659,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -4912,6 +4920,7 @@ "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", @@ -5062,6 +5071,7 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -5546,6 +5556,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5960,8 +5971,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/array-includes": { "version": "3.1.9", @@ -6562,6 +6572,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -7345,7 +7356,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -8501,6 +8511,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -9216,7 +9227,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9278,7 +9288,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -9288,7 +9297,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9298,7 +9306,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -9506,7 +9513,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -9525,7 +9531,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9534,15 +9539,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -10650,6 +10653,7 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz", "integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -11645,6 +11649,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -12656,7 +12661,6 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -13984,8 +13988,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/path-type": { "version": "3.0.0", @@ -14111,6 +14114,7 @@ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright-core": "1.57.0" }, @@ -14148,7 +14152,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -14193,6 +14196,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14380,6 +14384,7 @@ "integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -14723,6 +14728,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14733,6 +14739,7 @@ "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -14810,6 +14817,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16120,6 +16128,7 @@ "integrity": "sha512-fIQnFtpksRRgHR1CO1onGX3djaog4qsW/c5U8arqYTkUEr2TaWpn05mIJDOBoPJFlOdqFrB4Ttv0PZJxV7avhw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", @@ -17003,6 +17012,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17202,7 +17212,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -17210,6 +17221,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -17404,6 +17416,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17751,7 +17764,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4.0" } @@ -17807,6 +17819,7 @@ "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -17920,6 +17933,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17933,6 +17947,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -18469,6 +18484,7 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -18649,6 +18665,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18748,6 +18765,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", @@ -19377,6 +19395,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", @@ -19771,6 +19790,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -20533,6 +20553,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -20702,39 +20723,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "packages/sdk-typescript/node_modules/@vitest/browser": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-1.6.1.tgz", - "integrity": "sha512-9ZYW6KQ30hJ+rIfJoGH4wAub/KAb4YrFzX0kVLASvTm7nJWVC5EAv5SlzlXVl3h3DaUq5aqHlZl77nmOPnALUQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vitest/utils": "1.6.1", - "magic-string": "^0.30.5", - "sirv": "^2.0.4" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "playwright": "*", - "vitest": "1.6.1", - "webdriverio": "*" - }, - "peerDependenciesMeta": { - "playwright": { - "optional": true - }, - "safaridriver": { - "optional": true - }, - "webdriverio": { - "optional": true - } - } - }, "packages/sdk-typescript/node_modules/@vitest/coverage-v8": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", @@ -21046,6 +21034,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -21549,7 +21538,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", @@ -22189,6 +22177,7 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -23870,6 +23859,7 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23884,6 +23874,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index 490f2444800..fa7b0a0725c 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -7,7 +7,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { HttpError } from './retry.js'; -import { getErrorStatus, retryWithBackoff } from './retry.js'; +import { + getErrorStatus, + isTPMThrottlingError, + retryWithBackoff, +} from './retry.js'; import { setSimulate429 } from './testUtils.js'; import { AuthType } from '../core/contentGenerator.js'; @@ -532,3 +536,157 @@ describe('getErrorStatus', () => { expect(getErrorStatus({ error: {} })).toBeUndefined(); }); }); + +describe('isTPMThrottlingError', () => { + it('should detect TPM throttling error from string', () => { + const errorMessage = + '{"error":{"message":"Throttling: TPM(10680324/10000000)","type":"Throttling","code":"429"}}'; + expect(isTPMThrottlingError(errorMessage)).toBe(true); + }); + + it('should detect TPM throttling error from Error object', () => { + const error = new Error('Throttling: TPM(10680324/10000000)'); + expect(isTPMThrottlingError(error)).toBe(true); + }); + + it('should detect TPM throttling error from nested error object', () => { + const error = { + error: { + message: 'Throttling: TPM(10680324/10000000)', + type: 'Throttling', + code: '429', + }, + }; + expect(isTPMThrottlingError(error)).toBe(true); + }); + + it('should return false for non-TPM errors', () => { + expect(isTPMThrottlingError('Regular error message')).toBe(false); + expect(isTPMThrottlingError(new Error('Regular error'))).toBe(false); + expect( + isTPMThrottlingError({ + error: { message: 'Rate limit exceeded', code: '429' }, + }), + ).toBe(false); + }); + + it('should return false for non-string non-object values', () => { + expect(isTPMThrottlingError(null)).toBe(false); + expect(isTPMThrottlingError(undefined)).toBe(false); + expect(isTPMThrottlingError(429)).toBe(false); + expect(isTPMThrottlingError(true)).toBe(false); + }); +}); + +describe('TPM throttling retry handling', () => { + beforeEach(() => { + vi.useFakeTimers(); + setSimulate429(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('should wait 1 minute for TPM throttling errors before retrying', async () => { + const tpmError: HttpError = new Error('Throttling: TPM(10680324/10000000)'); + tpmError.status = 429; + + const fn = vi + .fn() + .mockRejectedValueOnce(tpmError) + .mockResolvedValue('success'); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + maxDelayMs: 1000, + }); + + // Fast-forward 1 minute for TPM delay + await vi.advanceTimersByTimeAsync(60000); + + await expect(promise).resolves.toBe('success'); + + // Should be called twice (1 failure + 1 success) + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should reset exponential backoff delay after TPM throttling error', async () => { + const tpmError: HttpError = new Error('Throttling: TPM(10680324/10000000)'); + tpmError.status = 429; + const normalError: HttpError = new Error('Server error'); + normalError.status = 500; + + const fn = vi + .fn() + .mockRejectedValueOnce(tpmError) // First: TPM error (1 minute delay) + .mockRejectedValueOnce(normalError) // Second: normal error (should use initialDelay) + .mockResolvedValue('success'); + + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + + const promise = retryWithBackoff(fn, { + maxAttempts: 5, + initialDelayMs: 100, + maxDelayMs: 1000, + }); + + // Fast-forward 1 minute for TPM delay + await vi.advanceTimersByTimeAsync(60000); + + // Now handle the second error with exponential backoff + await vi.runAllTimersAsync(); + + await expect(promise).resolves.toBe('success'); + + // Should be called 3 times + expect(fn).toHaveBeenCalledTimes(3); + + // Check that the second delay (after TPM) uses initialDelayMs, not a doubled value + const delays = setTimeoutSpy.mock.calls.map((call) => call[1] as number); + // First delay should be 60000ms (1 minute for TPM) + // Second delay should be around initialDelayMs (100ms) with jitter + expect(delays[0]).toBe(60000); + expect(delays[1]).toBeGreaterThanOrEqual(100 * 0.7); + expect(delays[1]).toBeLessThanOrEqual(100 * 1.3); + }); + + it('should respect Retry-After header even for TPM throttling errors', async () => { + // Create an error that is both a TPM throttling error AND has a Retry-After header + const tpmErrorWithRetryAfter: HttpError & { + response?: { headers?: { 'retry-after'?: string } }; + } = new Error('Throttling: TPM(10680324/10000000)'); + tpmErrorWithRetryAfter.status = 429; + tpmErrorWithRetryAfter.response = { + headers: { + 'retry-after': '30', // Server says wait 30 seconds + }, + }; + + const fn = vi + .fn() + .mockRejectedValueOnce(tpmErrorWithRetryAfter) + .mockResolvedValue('success'); + + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + maxDelayMs: 1000, + }); + + // Wait for all timers to complete + await vi.runAllTimersAsync(); + + await expect(promise).resolves.toBe('success'); + + // Check that the delay used was from Retry-After (30 seconds), not TPM (60 seconds) + const delays = setTimeoutSpy.mock.calls.map((call) => call[1] as number); + + // The Retry-After header should take precedence over TPM-specific delay + expect(delays[0]).toBe(30000); // Should use Retry-After header value (30 seconds) + }); +}); diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index fd9b5c0257c..429b6632167 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -131,6 +131,17 @@ export async function retryWithBackoff( await delay(retryAfterMs); // Reset currentDelay for next potential non-429 error, or if Retry-After is not present next time currentDelay = initialDelayMs; + } else if (isTPMThrottlingError(error)) { + // Check for TPM throttling error - use fixed 1 minute delay + // This check happens after Retry-After to respect server-specified delays + const tpmDelayMs = 60000; // 1 minute + debugLogger.warn( + `Attempt ${attempt} failed with TPM throttling error. Retrying after ${tpmDelayMs}ms (1 minute)...`, + error, + ); + await delay(tpmDelayMs); + // Reset currentDelay for next potential non-TPM error + currentDelay = initialDelayMs; } else { // Fallback to exponential backoff with jitter logRetryAttempt(attempt, error, errorStatus); @@ -147,6 +158,46 @@ export async function retryWithBackoff( throw new Error('Retry attempts exhausted'); } +/** + * Checks if an error is a TPM (Tokens Per Minute) throttling error. + * These errors occur when the API rate limit is exceeded for TPM. + * Example: {"error":{"message":"Throttling: TPM(10680324/10000000)","type":"Throttling","code":"429"}} + * @param error The error object. + * @returns True if the error is a TPM throttling error. + */ +export function isTPMThrottlingError(error: unknown): boolean { + const checkMessage = (message: string): boolean => + message.includes('Throttling: TPM('); + + if (typeof error === 'string') { + return checkMessage(error); + } + + if (typeof error === 'object' && error !== null) { + // Check error.message + if ('message' in error && typeof (error as Error).message === 'string') { + if (checkMessage((error as Error).message)) { + return true; + } + } + + // Check error.error.message (nested error) + if ( + 'error' in error && + typeof (error as { error?: { message?: string } }).error === 'object' && + (error as { error?: { message?: string } }).error !== null + ) { + const nestedMessage = (error as { error: { message?: string } }).error + .message; + if (typeof nestedMessage === 'string' && checkMessage(nestedMessage)) { + return true; + } + } + } + + return false; +} + /** * Extracts the HTTP status code from an error object. *