Merge upstream main into fix/issue-283-color-scheme-echo
This commit is contained in:
commit
41662c714b
6 files changed
+529
-1
No files matched your search
@@ -0,0 +1,20 @@
|
|||||||
|
# yaml-language-server: $schema=https://www.coderabbit.ai/schema/schema.v2.json
|
||||||
|
language: en-US
|
||||||
|
reviews:
|
||||||
|
profile: chill
|
||||||
|
request_changes_workflow: false
|
||||||
|
auto_review:
|
||||||
|
enabled: false
|
||||||
|
auto_incremental_review: false
|
||||||
|
drafts: false
|
||||||
|
ignore_usernames:
|
||||||
|
- Kuddev
|
||||||
|
path_instructions:
|
||||||
|
- path: "**/*"
|
||||||
|
instructions: >-
|
||||||
|
Follow AGENTS.md and the closest module instructions. Prioritize actionable
|
||||||
|
correctness, regression, security, state/lifetime and compatibility issues.
|
||||||
|
Give the triggering scenario and concrete code evidence. Distinguish
|
||||||
|
confirmed defects from questions; avoid speculative redesign and formatting
|
||||||
|
comments. CI and maintainer review remain the merge constraints. Never
|
||||||
|
recommend weakening required checks, raising budgets, or removing tests.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Review guidance
|
||||||
|
|
||||||
|
Review concrete regressions and missing boundary cases in the changed code. Explain
|
||||||
|
an actionable finding with the trigger, affected behavior, and file/line evidence.
|
||||||
|
Distinguish confirmed defects from questions; avoid speculative redesigns and style
|
||||||
|
comments already covered by formatting. Do not infer success from a passing build.
|
||||||
|
|
||||||
|
Follow `AGENTS.md` and the closest module instructions, `CONTRIBUTING.md`,
|
||||||
|
`docs/architecture.md`, and `docs/project-constraints.md`. Treat them as the
|
||||||
|
source of project policy rather than inventing a second rule set. Check ownership,
|
||||||
|
dependency direction, cancellation/lifetime, persistence compatibility, and tests
|
||||||
|
appropriate to the change. Consult `docs/internationalization.md` for UI text and
|
||||||
|
`packaging/AGENTS.md` for release changes. Verify performance claims against their
|
||||||
|
stated workload and separate platform tests from visual acceptance.
|
||||||
|
|
||||||
|
CI remains the deterministic merge gate. Do not recommend weakening required
|
||||||
|
checks, widening budgets, deleting tests, or treating this automated review as
|
||||||
|
maintainer approval. For CI changes, look for missing-platform, skipped-test,
|
||||||
|
stale-commit, cache-poisoning, fork-permission, and failure-propagation regressions.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
name: AI review after CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [Full native tests, Architecture contracts, PR size]
|
||||||
|
types: [completed]
|
||||||
|
# Metadata only: never check out or execute pull-request content with this token.
|
||||||
|
pull_request_target:
|
||||||
|
types: [ready_for_review]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
pull_number:
|
||||||
|
description: Existing PR to evaluate with the same eligibility and CI checks
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
checks: read
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
# A pending event may be replaced. Every run scans eligible open PRs, so the
|
||||||
|
# retained event still evaluates all PRs, and only one request writer runs at once.
|
||||||
|
concurrency:
|
||||||
|
group: ai-first-review
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
request-review:
|
||||||
|
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
COPILOT_REVIEW_TOKEN: ${{ secrets.COPILOT_REVIEW_TOKEN }}
|
||||||
|
CODERABBIT_FALLBACK_ENABLED: ${{ vars.CODERABBIT_FALLBACK_ENABLED }}
|
||||||
|
with:
|
||||||
|
retries: 0
|
||||||
|
script: |
|
||||||
|
const marker = 'ai-review-requested';
|
||||||
|
const previousMarker = 'copilot-review-requested';
|
||||||
|
const bot = 'copilot-pull-request-reviewer[bot]';
|
||||||
|
const fallbackBot = 'coderabbitai[bot]';
|
||||||
|
const same = (a, b) => a?.toLowerCase() === b?.toLowerCase();
|
||||||
|
const report = (number, text) => core.info(`#${number}: ${text}`);
|
||||||
|
const eligible = pr => pr.state === 'open' && pr.draft === false &&
|
||||||
|
pr.user?.type === 'User' && !same(pr.user.login, context.repo.owner);
|
||||||
|
let pulls;
|
||||||
|
if (context.eventName === 'workflow_dispatch') {
|
||||||
|
const value = context.payload.inputs?.pull_number;
|
||||||
|
if (!/^[1-9][0-9]*$/.test(value || '')) throw new Error('Invalid PR number');
|
||||||
|
pulls = [(await github.rest.pulls.get({
|
||||||
|
...context.repo, pull_number: Number(value),
|
||||||
|
})).data];
|
||||||
|
} else {
|
||||||
|
pulls = await github.paginate(github.rest.pulls.list, {
|
||||||
|
...context.repo, state: 'open', per_page: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const policies = new Map();
|
||||||
|
let reviewer;
|
||||||
|
const provider = async () => {
|
||||||
|
if (!reviewer) {
|
||||||
|
const token = process.env.COPILOT_REVIEW_TOKEN;
|
||||||
|
if (!token) throw new Error('Missing dedicated COPILOT_REVIEW_TOKEN secret');
|
||||||
|
reviewer = new github.constructor({auth: token, retry: {enabled: false}});
|
||||||
|
const user = (await reviewer.rest.users.getAuthenticated()).data;
|
||||||
|
if (!same(user.login, context.repo.owner)) throw new Error('Review token must belong to the repository owner');
|
||||||
|
}
|
||||||
|
// This account endpoint is also used by GitHub's Copilot clients.
|
||||||
|
// Unknown or changed responses stop requests; never infer exhaustion
|
||||||
|
// from an authentication failure, a rate limit, or a network error.
|
||||||
|
const data = (await reviewer.request('GET /copilot_internal/user')).data;
|
||||||
|
const quota = data.quota_snapshots?.premium_interactions;
|
||||||
|
if (!quota || typeof quota.unlimited !== 'boolean' ||
|
||||||
|
!Number.isFinite(quota.quota_remaining) || quota.quota_remaining < 0 ||
|
||||||
|
!Number.isFinite(Date.parse(quota.timestamp_utc)) ||
|
||||||
|
Math.abs(Date.now() - Date.parse(quota.timestamp_utc)) > 5 * 60 * 1000) {
|
||||||
|
throw new Error('Copilot allowance is missing, stale or unrecognized; no review requested');
|
||||||
|
}
|
||||||
|
if (quota.overage_permitted !== false) {
|
||||||
|
throw new Error('Paid overage must remain disabled before automated requests');
|
||||||
|
}
|
||||||
|
core.info(`Copilot allowance remaining: ${quota.quota_remaining}; reset: ${data.quota_reset_date_utc}`);
|
||||||
|
if (quota.unlimited || quota.quota_remaining > 0) return 'copilot';
|
||||||
|
if (process.env.CODERABBIT_FALLBACK_ENABLED !== 'true') {
|
||||||
|
throw new Error('Copilot exhausted; CodeRabbit installation must be verified before enabling fallback');
|
||||||
|
}
|
||||||
|
return 'coderabbit';
|
||||||
|
};
|
||||||
|
for (const pr of pulls) {
|
||||||
|
const number = pr.number;
|
||||||
|
if (!eligible(pr)) { report(number, 'Owner, bot, draft or closed PR; skipped.'); continue; }
|
||||||
|
const args = {...context.repo, pull_number: number};
|
||||||
|
const claimed = item => item.labels.some(label => [marker, previousMarker].includes(label.name)) ||
|
||||||
|
item.requested_reviewers.some(user => same(user.login, bot));
|
||||||
|
if (claimed(pr)) { report(number, 'Review already requested; skipped.'); continue; }
|
||||||
|
const reviews = await github.paginate(github.rest.pulls.listReviews, {...args, per_page: 100});
|
||||||
|
if (reviews.some(review => [bot, fallbackBot].some(login => same(review.user?.login, login)))) {
|
||||||
|
report(number, 'An AI reviewer has already reviewed this PR; manual re-review only.'); continue;
|
||||||
|
}
|
||||||
|
if (!policies.has(pr.base.ref)) {
|
||||||
|
const rules = await github.paginate('GET /repos/{owner}/{repo}/rules/branches/{branch}', {
|
||||||
|
...context.repo, branch: pr.base.ref, per_page: 100,
|
||||||
|
});
|
||||||
|
policies.set(pr.base.ref, rules.filter(rule => rule.type === 'required_status_checks')
|
||||||
|
.flatMap(rule => rule.parameters.required_status_checks));
|
||||||
|
}
|
||||||
|
const required = policies.get(pr.base.ref);
|
||||||
|
if (!required.length) { report(number, 'No required CI policy; manual review only.'); continue; }
|
||||||
|
const ref = pr.head.sha;
|
||||||
|
const checks = await github.paginate(github.rest.checks.listForRef, {
|
||||||
|
...context.repo, ref, filter: 'latest', per_page: 100,
|
||||||
|
});
|
||||||
|
const passed = required.every(rule => {
|
||||||
|
const matching = checks.filter(check => check.name === rule.context &&
|
||||||
|
(rule.integration_id == null || rule.integration_id === -1 || check.app?.id === rule.integration_id));
|
||||||
|
matching.sort((a, b) => b.id - a.id);
|
||||||
|
return matching[0]?.head_sha === ref && matching[0]?.status === 'completed' &&
|
||||||
|
matching[0]?.conclusion === 'success';
|
||||||
|
});
|
||||||
|
if (!passed) { report(number, 'Required CI incomplete or unsuccessful; deferred.'); continue; }
|
||||||
|
const selected = await provider();
|
||||||
|
// Guard a push, draft conversion or close racing the checks above.
|
||||||
|
const current = (await github.rest.pulls.get(args)).data;
|
||||||
|
if (!eligible(current) || current.head.sha !== ref || current.base.ref !== pr.base.ref || claimed(current)) {
|
||||||
|
report(number, 'PR changed while checking; deferred.'); continue;
|
||||||
|
}
|
||||||
|
// Persist the one-shot claim before the non-idempotent review request.
|
||||||
|
// On a timeout/error leave the claim in place: a maintainer must inspect
|
||||||
|
// the result before manually re-requesting, preventing quota retry loops.
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
...context.repo, issue_number: number, labels: [marker, `${selected}-review-requested`],
|
||||||
|
});
|
||||||
|
if (selected === 'copilot') {
|
||||||
|
await reviewer.rest.pulls.requestReviewers({...args, reviewers: [bot]});
|
||||||
|
} else {
|
||||||
|
await reviewer.rest.issues.createComment({
|
||||||
|
...context.repo, issue_number: number,
|
||||||
|
body: '@coderabbitai review',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
report(number, `Requested first ${selected} review after ${required.length} required checks passed.`);
|
||||||
|
}
|
||||||
@@ -63,7 +63,8 @@ jobs:
|
|||||||
scripts.tests.test_stable_release \
|
scripts.tests.test_stable_release \
|
||||||
scripts.tests.test_ci_cache \
|
scripts.tests.test_ci_cache \
|
||||||
scripts.tests.test_platform_cfg \
|
scripts.tests.test_platform_cfg \
|
||||||
scripts.tests.test_pr_size_workflow -v
|
scripts.tests.test_pr_size_workflow \
|
||||||
|
scripts.tests.test_copilot_review_workflow -v
|
||||||
- name: Check platform cfg budget before native jobs
|
- name: Check platform cfg budget before native jobs
|
||||||
run: python3 scripts/check_platform_cfg.py
|
run: python3 scripts/check_platform_cfg.py
|
||||||
- name: Plan native validation
|
- name: Plan native validation
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# AI review after required CI, with an allowance fallback
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implemented; activation requires the default-branch workflow, a dedicated owner
|
||||||
|
credential, and verified CodeRabbit installation before enabling its fallback.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Reviews of every push spend allowance on drafts, failed builds, and changes that
|
||||||
|
already have an initial review. Native branch rules do not express the requested
|
||||||
|
combination of authorship, readiness, CI, review history, and allowance routing.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
GitHub exposes effective required checks through the branch rules API and results
|
||||||
|
for a specific commit. The reviewer API accepts `copilot-pull-request-reviewer[bot]`.
|
||||||
|
A user-authenticated request produced a review, whereas an Actions-token probe
|
||||||
|
returned success without a Copilot run or review. API acceptance alone therefore
|
||||||
|
cannot establish delivery. A user credential is required for activation testing.
|
||||||
|
|
||||||
|
The authenticated Copilot client endpoint exposes a timestamped allowance snapshot,
|
||||||
|
including remaining credits and whether paid overage is permitted. This endpoint
|
||||||
|
is not a stable public billing contract; response drift must stop automation.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The metadata-only [workflow](../../../../.github/workflows/copilot-review.yml)
|
||||||
|
requests one review for an open, non-draft PR by a human other than the repository
|
||||||
|
owner, after every required check on its current head succeeds. It reads effective
|
||||||
|
server-side branch rules rather than copying their check list. No required-check
|
||||||
|
policy means no automatic review.
|
||||||
|
|
||||||
|
Completed CI workflows and draft-to-ready transitions trigger eligibility scans.
|
||||||
|
A repository-wide concurrency group serializes requests; scanning open PRs covers
|
||||||
|
events replaced in GitHub's pending slot. Dispatch evaluates one existing PR with
|
||||||
|
the same guards. Previous Copilot or CodeRabbit reviews prevent automatic repeats.
|
||||||
|
|
||||||
|
The workflow's built-in token reads PR/CI metadata and writes claim labels.
|
||||||
|
`COPILOT_REVIEW_TOKEN` must belong to the repository owner and is used only for
|
||||||
|
account allowance and user-attributed review requests/comments; it needs no Checks,
|
||||||
|
Actions or Contents permission for metadata reads. A positive Copilot
|
||||||
|
allowance selects Copilot. Zero allowance selects CodeRabbit only when
|
||||||
|
`CODERABBIT_FALLBACK_ENABLED=true`, after installation has been verified. Missing,
|
||||||
|
negative, stale, unrecognized, or unreadable allowance data stops requests; an
|
||||||
|
ordinary permission/network error never implies exhaustion. Paid overage must be
|
||||||
|
disabled. Credits are not a fixed number of reviews, and consumption reporting can
|
||||||
|
lag; this router does not establish a provider-side spending cap.
|
||||||
|
|
||||||
|
A persistent `ai-review-requested` label is written before requesting either bot;
|
||||||
|
older `copilot-review-requested` claims remain recognized. On uncertain delivery or
|
||||||
|
failure, keep the claim and require manual inspection before retrying. No automatic
|
||||||
|
retry loops or default dual review. CodeRabbit's own automatic and incremental
|
||||||
|
reviews are disabled in the repository config; the fallback uses an explicit
|
||||||
|
`@coderabbitai review` request. Instructions prioritize evidence-backed defects.
|
||||||
|
|
||||||
|
The workflow never checks out PR code, runs artifacts, interpolates PR text into
|
||||||
|
commands, approves a PR, or merges changes. Human review and required CI remain
|
||||||
|
unchanged. The dedicated credential is a repository secret, never checked in;
|
||||||
|
provision and validate it separately from the workflow's source.
|
||||||
|
|
||||||
|
## Rejected alternatives
|
||||||
|
|
||||||
|
- Branch-wide rules or every-push reviews: cannot enforce the requested policy.
|
||||||
|
- Executing fork code with privileged credentials: unnecessary for metadata routing.
|
||||||
|
- Treating any Copilot error as exhausted credit: hides outages and permission bugs.
|
||||||
|
- In-memory deduplication or uncertain retries: can request two paid reviews.
|
||||||
|
- Hard-coded check names: drift from the effective merge requirements.
|
||||||
|
- Copying a developer's broad CLI credential into Actions without explicit setup.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Maintainers request subsequent reviews manually. A push can race the final API
|
||||||
|
call; rechecking head/draft/base/claim immediately before submission narrows but
|
||||||
|
cannot eliminate that race. An accepted request followed by asynchronous service
|
||||||
|
failure remains for manual inspection rather than risking duplicate reviews.
|
||||||
|
Unprotected target branches and legacy status-only checks require manual review.
|
||||||
|
This is supplemental automation, not another mandatory merge check.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Offline fixtures execute the actual script: successful external PR; owner, bot,
|
||||||
|
draft and closed exclusions; missing/failed/skipped checks; newer pending attempts;
|
||||||
|
wrong app/head; racing changes; review history; claims; policy/quota failures;
|
||||||
|
positive versus exhausted allowance; disabled fallback; and no double request.
|
||||||
|
The required lint job runs the contract before scheduling native runners.
|
||||||
|
Actual owner-secret delivery and CodeRabbit response must be verified on GitHub
|
||||||
|
before claiming end-to-end activation. Do not consume the real allowance merely
|
||||||
|
to force exhaustion; the zero-credit routing boundary is covered with fixtures.
|
||||||
|
|
||||||
|
## Supersedes
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Revisit when
|
||||||
|
|
||||||
|
GitHub exposes a stable allowance API or equivalent native routing, supports atomic
|
||||||
|
SHA-bound review requests, required-check providers change, or ownership changes.
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import textwrap
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
RUNNER = r'''
|
||||||
|
const input = JSON.parse(require('node:fs').readFileSync(0, 'utf8'));
|
||||||
|
const writes = [], logs = [];
|
||||||
|
process.env.COPILOT_REVIEW_TOKEN = input.missingToken ? '' : 'fixture-token';
|
||||||
|
process.env.CODERABBIT_FALLBACK_ENABLED = input.fallbackEnabled ? 'true' : 'false';
|
||||||
|
const list = () => {}, listReviews = () => {}, listForRef = () => {};
|
||||||
|
const github = {
|
||||||
|
rest: {
|
||||||
|
pulls: {
|
||||||
|
list, listReviews,
|
||||||
|
get: async () => ({data: input.current || input.pr}),
|
||||||
|
},
|
||||||
|
checks: {listForRef},
|
||||||
|
issues: {addLabels: async value => {
|
||||||
|
if (input.labelError) throw new Error('claim failed');
|
||||||
|
writes.push(['claim', value]);
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
paginate: async (method, args) => {
|
||||||
|
if (method === list) return [input.pr];
|
||||||
|
if (method === listReviews) return input.reviews || [];
|
||||||
|
if (method === listForRef) return input.checks;
|
||||||
|
if (typeof method === 'string' && method.includes('/rules/branches/')) {
|
||||||
|
if (input.policyError) throw new Error('policy unavailable');
|
||||||
|
return input.rules;
|
||||||
|
}
|
||||||
|
throw new Error('unexpected API');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const reviewer = {
|
||||||
|
rest: {
|
||||||
|
users: {getAuthenticated: async () => ({data: {login: input.tokenOwner || 'owner'}})},
|
||||||
|
pulls: {requestReviewers: async value => {
|
||||||
|
writes.push(['request', value]);
|
||||||
|
if (input.requestError) throw new Error('request failed');
|
||||||
|
}},
|
||||||
|
issues: {createComment: async value => writes.push(['fallback', value])},
|
||||||
|
},
|
||||||
|
request: async () => {
|
||||||
|
if (input.quotaError) throw new Error('quota API failed');
|
||||||
|
return {data: {quota_snapshots: {premium_interactions: input.quota || {
|
||||||
|
unlimited: false, quota_remaining: 100, overage_permitted: false,
|
||||||
|
timestamp_utc: new Date().toISOString(),
|
||||||
|
}}}};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
github.constructor = class {
|
||||||
|
constructor({auth}) {
|
||||||
|
if (auth !== 'fixture-token') throw new Error('Wrong credential');
|
||||||
|
return reviewer;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const context = {repo: {owner: 'owner', repo: 'project'}, eventName: input.event || 'workflow_run',
|
||||||
|
payload: {inputs: {pull_number: input.number || '7'}}};
|
||||||
|
const core = {info: value => logs.push(value)};
|
||||||
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||||
|
new AsyncFunction('github', 'context', 'core', input.script)(github, context, core)
|
||||||
|
.then(() => process.stdout.write(JSON.stringify({writes, logs})))
|
||||||
|
.catch(error => process.stdout.write(JSON.stringify({writes, logs, error: error.message})));
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotReviewWorkflowTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.node = shutil.which('node')
|
||||||
|
if not cls.node:
|
||||||
|
raise RuntimeError('Node.js is required for the Copilot workflow contract')
|
||||||
|
cls.workflow = (ROOT / '.github/workflows/copilot-review.yml').read_text(encoding='utf-8')
|
||||||
|
cls.script = textwrap.dedent(cls.workflow.split(' script: |\n', 1)[1])
|
||||||
|
|
||||||
|
def fixture(self):
|
||||||
|
return {
|
||||||
|
'pr': {'number': 7, 'state': 'open', 'draft': False,
|
||||||
|
'user': {'login': 'contributor', 'type': 'User'},
|
||||||
|
'head': {'sha': 'current'}, 'base': {'ref': 'main'},
|
||||||
|
'labels': [], 'requested_reviewers': []},
|
||||||
|
'rules': [{'type': 'required_status_checks', 'parameters': {
|
||||||
|
'required_status_checks': [{'context': 'native', 'integration_id': 15368},
|
||||||
|
{'context': 'architecture', 'integration_id': 15368}]}}],
|
||||||
|
'checks': [{'id': i, 'name': name, 'head_sha': 'current', 'status': 'completed',
|
||||||
|
'conclusion': 'success', 'app': {'id': 15368}}
|
||||||
|
for i, name in enumerate(('native', 'architecture'), 1)],
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_gate(self, data):
|
||||||
|
result = subprocess.run([self.node, '-e', RUNNER],
|
||||||
|
input=json.dumps({'script': self.script, **data}),
|
||||||
|
text=True, encoding='utf-8', capture_output=True, check=True)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
def test_external_ready_pr_claimed_then_requested_after_all_required_ci(self):
|
||||||
|
result = self.run_gate(self.fixture())
|
||||||
|
self.assertEqual([row[0] for row in result['writes']], ['claim', 'request'])
|
||||||
|
self.assertEqual(result['writes'][1][1]['reviewers'], ['copilot-pull-request-reviewer[bot]'])
|
||||||
|
|
||||||
|
def test_owner_draft_closed_and_bot_prs_never_request(self):
|
||||||
|
for change in ({'user': {'login': 'OwNeR', 'type': 'User'}}, {'draft': True},
|
||||||
|
{'state': 'closed'}, {'user': {'login': 'dependabot', 'type': 'Bot'}}):
|
||||||
|
with self.subTest(change=change):
|
||||||
|
data = self.fixture()
|
||||||
|
data['pr'].update(change)
|
||||||
|
self.assertEqual(self.run_gate(data)['writes'], [])
|
||||||
|
|
||||||
|
def test_failed_pending_skipped_neutral_missing_wrong_app_and_old_checks_defer(self):
|
||||||
|
for change in ({'conclusion': 'failure'}, {'status': 'in_progress'},
|
||||||
|
{'conclusion': 'skipped'}, {'conclusion': 'neutral'},
|
||||||
|
{'name': 'unrelated'}, {'app': {'id': 123}}, {'head_sha': 'old'}):
|
||||||
|
with self.subTest(change=change):
|
||||||
|
data = self.fixture()
|
||||||
|
data['checks'][0].update(change)
|
||||||
|
self.assertEqual(self.run_gate(data)['writes'], [])
|
||||||
|
|
||||||
|
def test_newer_pending_attempt_cannot_reuse_old_success(self):
|
||||||
|
data = self.fixture()
|
||||||
|
data['checks'].append({**data['checks'][0], 'id': 99, 'status': 'queued', 'conclusion': None})
|
||||||
|
self.assertEqual(self.run_gate(data)['writes'], [])
|
||||||
|
|
||||||
|
def test_previous_review_on_any_commit_prevents_automatic_rereview(self):
|
||||||
|
data = self.fixture()
|
||||||
|
data['reviews'] = [{'user': {'login': 'copilot-pull-request-reviewer[bot]'}, 'commit_id': 'old'}]
|
||||||
|
self.assertEqual(self.run_gate(data)['writes'], [])
|
||||||
|
|
||||||
|
def test_pending_request_or_persistent_claim_prevents_duplicates(self):
|
||||||
|
for field, value in (
|
||||||
|
('labels', [{'name': 'copilot-review-requested'}]),
|
||||||
|
('requested_reviewers', [{'login': 'copilot-pull-request-reviewer[bot]'}]),
|
||||||
|
):
|
||||||
|
data = self.fixture()
|
||||||
|
data['pr'][field] = value
|
||||||
|
self.assertEqual(self.run_gate(data)['writes'], [])
|
||||||
|
|
||||||
|
def test_push_draft_close_base_change_or_claim_during_reads_defers(self):
|
||||||
|
for change in ({'head': {'sha': 'new'}}, {'draft': True}, {'state': 'closed'},
|
||||||
|
{'base': {'ref': 'release'}}, {'labels': [{'name': 'copilot-review-requested'}]}):
|
||||||
|
with self.subTest(change=change):
|
||||||
|
data = self.fixture()
|
||||||
|
data['current'] = {**data['pr'], **change}
|
||||||
|
self.assertEqual(self.run_gate(data)['writes'], [])
|
||||||
|
|
||||||
|
def test_missing_or_unreadable_policy_fails_closed(self):
|
||||||
|
data = self.fixture()
|
||||||
|
data['rules'] = []
|
||||||
|
self.assertEqual(self.run_gate(data)['writes'], [])
|
||||||
|
data['policyError'] = True
|
||||||
|
self.assertEqual(self.run_gate(data)['error'], 'policy unavailable')
|
||||||
|
|
||||||
|
def test_write_failure_does_not_retry_or_erase_claim(self):
|
||||||
|
data = self.fixture()
|
||||||
|
data['requestError'] = True
|
||||||
|
result = self.run_gate(data)
|
||||||
|
self.assertEqual(result['error'], 'request failed')
|
||||||
|
self.assertEqual([row[0] for row in result['writes']], ['claim', 'request'])
|
||||||
|
data['labelError'] = True
|
||||||
|
self.assertEqual(self.run_gate(data)['writes'], [])
|
||||||
|
|
||||||
|
def test_manual_backfill_uses_same_gate_and_validates_number(self):
|
||||||
|
data = {**self.fixture(), 'event': 'workflow_dispatch'}
|
||||||
|
self.assertEqual(len(self.run_gate(data)['writes']), 2)
|
||||||
|
data['number'] = '7; arbitrary code'
|
||||||
|
self.assertEqual(self.run_gate(data)['error'], 'Invalid PR number')
|
||||||
|
|
||||||
|
def test_quota_exhaustion_uses_only_enabled_coderabbit(self):
|
||||||
|
data = self.fixture()
|
||||||
|
data['quota'] = {'unlimited': False, 'quota_remaining': 0, 'overage_permitted': False,
|
||||||
|
'timestamp_utc': datetime.now(timezone.utc).isoformat()}
|
||||||
|
self.assertEqual(self.run_gate(data)['writes'], [])
|
||||||
|
data['fallbackEnabled'] = True
|
||||||
|
result = self.run_gate(data)
|
||||||
|
self.assertEqual([row[0] for row in result['writes']], ['claim', 'fallback'])
|
||||||
|
self.assertEqual(result['writes'][1][1]['body'], '@coderabbitai review')
|
||||||
|
|
||||||
|
def test_quota_errors_stale_data_paid_overage_and_wrong_identity_fail_closed(self):
|
||||||
|
for extra in ({'quotaError': True}, {'tokenOwner': 'other'},
|
||||||
|
{'quota': {'unlimited': False, 'quota_remaining': -1}},
|
||||||
|
{'quota': {'unlimited': False, 'quota_remaining': 10,
|
||||||
|
'overage_permitted': True, 'timestamp_utc': '2020-01-01T00:00:00Z'}}):
|
||||||
|
with self.subTest(extra=extra):
|
||||||
|
data = {**self.fixture(), **extra, 'fallbackEnabled': True}
|
||||||
|
result = self.run_gate(data)
|
||||||
|
self.assertEqual(result['writes'], [])
|
||||||
|
self.assertIn('error', result)
|
||||||
|
|
||||||
|
def test_paid_overage_or_stale_allowance_never_routes_to_either_bot(self):
|
||||||
|
for quota in (
|
||||||
|
{'unlimited': False, 'quota_remaining': 50, 'overage_permitted': True,
|
||||||
|
'timestamp_utc': datetime.now(timezone.utc).isoformat()},
|
||||||
|
{'unlimited': False, 'quota_remaining': 0, 'overage_permitted': False,
|
||||||
|
'timestamp_utc': '2020-01-01T00:00:00Z'},
|
||||||
|
):
|
||||||
|
data = {**self.fixture(), 'quota': quota, 'fallbackEnabled': True}
|
||||||
|
self.assertEqual(self.run_gate(data)['writes'], [])
|
||||||
|
|
||||||
|
def test_missing_user_token_does_not_spend_or_claim(self):
|
||||||
|
result = self.run_gate({**self.fixture(), 'missingToken': True})
|
||||||
|
self.assertEqual(result['writes'], [])
|
||||||
|
self.assertIn('Missing dedicated', result['error'])
|
||||||
|
|
||||||
|
def test_user_token_has_no_ci_reading_methods(self):
|
||||||
|
# The two clients deliberately expose disjoint APIs in the fixture.
|
||||||
|
result = self.run_gate(self.fixture())
|
||||||
|
self.assertEqual([row[0] for row in result['writes']], ['claim', 'request'])
|
||||||
|
self.assertNotIn('github-token: ${{ secrets.', self.workflow)
|
||||||
|
|
||||||
|
def test_available_copilot_does_not_trigger_enabled_fallback(self):
|
||||||
|
result = self.run_gate({**self.fixture(), 'fallbackEnabled': True})
|
||||||
|
self.assertEqual([row[0] for row in result['writes']], ['claim', 'request'])
|
||||||
|
|
||||||
|
def test_coderabbit_config_disables_independent_automatic_reviews(self):
|
||||||
|
config = (ROOT / '.coderabbit.yaml').read_text(encoding='utf-8')
|
||||||
|
for entry in ('enabled: false', 'auto_incremental_review: false', 'drafts: false', '- Kuddev'):
|
||||||
|
self.assertIn(entry, config)
|
||||||
|
|
||||||
|
def test_coderabbit_does_not_duplicate_previous_ai_review(self):
|
||||||
|
data = self.fixture()
|
||||||
|
data['reviews'] = [{'user': {'login': 'coderabbitai[bot]'}}]
|
||||||
|
self.assertEqual(self.run_gate(data)['writes'], [])
|
||||||
|
|
||||||
|
def test_workflow_never_executes_fork_content_or_approves_merges(self):
|
||||||
|
self.assertNotIn('actions/checkout', self.workflow)
|
||||||
|
self.assertIn('secrets.COPILOT_REVIEW_TOKEN', self.workflow)
|
||||||
|
self.assertNotIn('contents: write', self.workflow)
|
||||||
|
self.assertNotIn('createReview', self.script)
|
||||||
|
self.assertNotIn('synchronize', self.workflow)
|
||||||
|
self.assertIn('types: [ready_for_review]', self.workflow)
|
||||||
|
self.assertIn("github.event.workflow_run.conclusion == 'success'", self.workflow)
|
||||||
|
self.assertIn('retries: 0', self.workflow)
|
||||||
|
self.assertIn('cancel-in-progress: false', self.workflow)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user