Phase 3: canvas smoke-test evidence and weekly review metrics (#4184) (#4191)

* Phase 3: canvas smoke-test evidence, safe auto-merge, and weekly review metrics (#4184)

Adds the canvas-smoke-test check with review evidence, a config-gated safe auto-merge workflow (disabled by default), and a weekly review operating metrics workflow, plus maintainer docs.

Refs github/awesome-copilot#4184

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Trigger safe auto-merge sweep after Submission Gate Writer completes

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Rename thirdParty identifier to satisfy codespell

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Address Copilot review on canvas smoke test, auto-merge, and metrics

- Bind canvas report writer to the triggering workflow_run PR (base/head repo, ref, SHA)
- Bound PNG inflation by IHDR-derived size and a decoded-bytes budget
- Classify file: URLs as unsafe manifest paths
- Follow CommonJS require() and literal dynamic imports; error on missing
  reachable targets and devDependency-only runtime imports
- Validate removed extension paths and orphaned plugins instead of skipping
- Warn on .py/.rb/.pl scripts
- Auto-merge: support CODEOWNERS teams (fail closed), front matter authors,
  list PRs by head SHA, only fall back to direct merge on clean status,
  drop ineffective check_run trigger
- Metrics: ignore reviews before the PR was ready for review

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Docs: note microsoft/azure-dev-tools as a portable marketplace for the review model

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Auto-merge: only trust submission-gate runs from the Submission Gate Writer

Adds trusted_checks (default submission-gate -> external_id
submission-gate-writer). Any other check run or status with that name on the
head SHA blocks arming, so a PR-defined job cannot satisfy the gate.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Harden canvas smoke-test checker

- String-, template-, and regex-aware comment stripping so literals containing // cannot hide imports
- Never follow symlinks or read non-regular files; bounded, contained reads
- Validate plugins whose plugin.json was deleted
- Reject unsafe extension IDs in plugin manifests before materialization

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Phase 3: remove auto-merge, isolate canvas smoke test, harden checker

- Remove safe auto-merge (config, workflow, script, tests, labels) per
  maintainer review; documented as deferred pending a security review.
- Restore validate-canvas-extensions.yml to main and move the smoke test
  to its own canvas-smoke-test.yml workflow scoped to extensions/**.
- Reject duplicate IHDR chunks in preview PNGs.
- Mask string/template/regex literal contents before extracting dynamic
  import() and require() specifiers.
- Resolve package.json imports aliases through the same classifier and
  module graph; fail closed on alias shapes that cannot be analyzed.
- Rename docs to canvas-evidence-and-metrics.md and update references.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Fix canvas smoke and review metrics findings

- Trigger canvas smoke tests for plugin changes while skipping expensive setup when target detection finds no canvas coverage.
- Fail reachable data: imports while preserving warnings for unreachable module data: imports.
- Use base plugin manifests for deleted bundle plugin detection and fail closed when base manifests cannot be read.
- Paginate GraphQL PR review pages before computing weekly review metrics.
- Report workflow run API collection errors as incomplete automation health instead of not found.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Address follow-up canvas review findings

Fail closed on non-literal runtime imports, require canonical PNG termination, restrict comment updates to github-actions[bot], and paginate external-plugin label events.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Aaron Powell <me@aaron-powell.com>
This commit is contained in:
James Montemagno
2026-10-07 16:36:13 +11:00
committed by GitHub
co-authored by Copilot App Aaron Powell
parent b30b12e40e
commit d82df27473
12 changed files with 4142 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
# Weekly review operating metrics (github/awesome-copilot#4184, phase 3).
#
# Read by eng/review-metrics.mjs, which .github/workflows/review-metrics.yml
# runs every Monday. See docs/maintainers/canvas-evidence-and-metrics.md.
# Days of activity summarized in each report.
window_days: 7
# Review targets, in business days (weekends are skipped).
targets_business_days: [2, 4]
# Base branch for contribution PRs.
base_branch: main
# State labels (maintained by the submission gate). Open PRs without any of
# these are reported as "unlabeled".
state_labels:
- awaiting-automation
- requires-submitter-fixes
- ready-for-review
- review-in-progress
- approved
# Risk tier labels. Open PRs without any of these are "unclassified".
risk_labels:
- merge-risk:low
- merge-risk:medium
- merge-risk:high
# External plugin submission issues are tracked alongside PRs.
external_plugin_label: external-plugin
external_plugin_state_labels:
- awaiting-review
- requires-submitter-fixes
- ready-for-review
- awaiting-approval
# Review-automation workflows whose failed/errored runs count toward the
# automation failure rate. Files that do not exist yet are reported as
# "not found" and skipped, so later phases can be listed ahead of time.
automation_workflows:
- submission-gate.yml
- submission-gate-writer.yml
- review-routing.yml
- canvas-smoke-test.yml
- canvas-smoke-test-comment.yml
- pr-risk-scan.yml
- pr-risk-scan-comment.yml
- label-pr-intent.yml
- label-pr-intent-writer.yml
- check-plugin-structure.yml
- external-plugin-intake.yml
- external-plugin-quality-gates.yml
- external-plugin-pr-quality-gates.yml
- external-plugin-pr-quality-gates-writer.yml
- external-plugin-command-router.yml
# Tracking issue that receives the weekly report.
tracking_issue:
title: Review operating metrics
label: review-metrics
@@ -0,0 +1,157 @@
name: Canvas Smoke Test — Comment
# Writer half of the canvas-smoke-test reader/writer split. The reader
# (canvas-smoke-test.yml) runs on untrusted PR code with a read-only
# token; this workflow only reads its artifact and never checks out PR code.
on:
workflow_run:
workflows: ["Canvas Smoke Test"]
types: [completed]
permissions:
actions: read
issues: write
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request'
steps:
- name: Download review artifact
id: download
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: canvas-smoke-test-results
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Upsert PR comment
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const fs = require('fs');
const marker = '<!-- canvas-smoke-test -->';
if (!fs.existsSync('report.md')) {
core.info('No canvas smoke test report artifact found. Skipping.');
return;
}
let status = 'unknown';
try {
status = JSON.parse(fs.readFileSync('results.json', 'utf8')).status || 'unknown';
} catch {
core.warning('results.json is missing or invalid.');
}
const workflowRun = context.payload.workflow_run;
const artifactPr = fs.existsSync('pr-number.txt') ? fs.readFileSync('pr-number.txt', 'utf8').trim() : '';
const prNumber = /^[1-9][0-9]*$/.test(artifactPr) ? Number(artifactPr) : workflowRun.pull_requests?.[0]?.number;
if (!prNumber) {
core.warning('Could not determine PR number. Skipping.');
return;
}
// The artifact is untrusted: bind the target PR to the triggering run
// (base repo, head repo/ref/SHA, and a unique association) before writing.
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.state !== 'open') {
core.info(`Skipping non-open PR #${prNumber}.`);
return;
}
const expectedBaseRepository = `${context.repo.owner}/${context.repo.repo}`.toLowerCase();
const runHeadRepository = String(workflowRun.head_repository?.full_name || '');
const runHeadRepositoryParts = runHeadRepository.split('/');
const runHeadRef = String(workflowRun.head_branch || '');
if (String(pr.base?.repo?.full_name || '').toLowerCase() !== expectedBaseRepository) {
core.setFailed(`PR #${prNumber} does not target this repository.`);
return;
}
if (pr.head.sha !== workflowRun.head_sha) {
core.info(`PR #${prNumber} head ${pr.head.sha} does not match run head ${workflowRun.head_sha}. Skipping stale or mismatched report.`);
return;
}
if (
runHeadRepositoryParts.length !== 2 ||
!runHeadRepositoryParts[0] ||
!runHeadRepositoryParts[1] ||
!runHeadRef ||
String(pr.head?.repo?.full_name || '').toLowerCase() !== runHeadRepository.toLowerCase() ||
String(pr.head?.ref || '') !== runHeadRef
) {
core.setFailed(`PR #${prNumber} head repository/ref did not match workflow_run.`);
return;
}
const workflowRunPullRequests = Array.isArray(workflowRun.pull_requests) ? workflowRun.pull_requests : [];
if (workflowRunPullRequests.length > 0) {
if (!workflowRunPullRequests.some((pullRequest) => pullRequest.number === prNumber)) {
core.setFailed(`PR #${prNumber} was not present in workflow_run.pull_requests.`);
return;
}
} else {
const candidatePullRequests = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${runHeadRepositoryParts[0]}:${runHeadRef}`,
per_page: 100,
});
const trustedMatches = candidatePullRequests.filter((candidate) =>
candidate.head?.sha === workflowRun.head_sha &&
String(candidate.head?.ref || '') === runHeadRef &&
String(candidate.head?.repo?.full_name || '').toLowerCase() === runHeadRepository.toLowerCase() &&
String(candidate.base?.repo?.full_name || '').toLowerCase() === expectedBaseRepository
);
if (trustedMatches.length !== 1 || trustedMatches[0].number !== prNumber) {
core.setFailed(`PR #${prNumber} could not be uniquely associated with workflow_run.`);
return;
}
}
let body = fs.readFileSync('report.md', 'utf8').replace(/@/g, '@\u200b');
const maxLength = 65000;
if (body.length > maxLength) {
body = `${body.slice(0, maxLength)}\n\n_...(truncated)..._`;
}
if (!body.includes(marker)) {
body = `${marker}\n${body}`;
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
per_page: 100,
});
const existing = comments.find((comment) =>
comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker));
if (status === 'skipped' && !existing) {
core.info('No canvas changes and no previous comment. Nothing to post.');
return;
}
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
core.info(`Updated canvas smoke test comment ${existing.id}`);
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
core.info('Created canvas smoke test comment');
}
+111
View File
@@ -0,0 +1,111 @@
name: Canvas Smoke Test
# Reader half of the canvas-smoke-test reader/writer split. Runs untrusted PR
# content with a read-only token and no secrets; extension code is parsed and
# compiled but never executed. canvas-smoke-test-comment.yml posts the report.
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
paths:
- "extensions/**"
- "plugins/**"
- ".github/workflows/canvas-smoke-test.yml"
- "eng/canvas-smoke-test.mjs"
permissions:
contents: read
concurrency:
group: canvas-smoke-test-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
canvas-smoke-test:
name: canvas-smoke-test
runs-on: ubuntu-latest
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
CANVAS_PREVIEW_MIN_WIDTH: ${{ vars.CANVAS_PREVIEW_MIN_WIDTH }}
CANVAS_PREVIEW_MIN_HEIGHT: ${{ vars.CANVAS_PREVIEW_MIN_HEIGHT }}
CANVAS_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
cache: "npm"
- name: Detect canvas targets
id: detect
run: |
set -euo pipefail
git diff --name-only "${BASE_SHA}...HEAD" > "${RUNNER_TEMP}/changed-files.txt"
node ./eng/canvas-smoke-test.mjs \
--changed-files "${RUNNER_TEMP}/changed-files.txt" \
--base-ref "${BASE_SHA}" \
--detect-only
- name: Write skipped report
if: steps.detect.outputs.canvas != 'true'
run: |
set -euo pipefail
mkdir -p canvas-smoke-results
printf '<!-- canvas-smoke-test -->\n## 🧩 Canvas smoke test\n\n⏭️ **Skipped** — no canvas extension or extension-bearing plugin paths changed.\n' > canvas-smoke-results/report.md
printf '{"schema_version":"canvas-smoke-test/v1","status":"skipped"}\n' > canvas-smoke-results/results.json
echo "${PR_NUMBER}" > canvas-smoke-results/pr-number.txt
echo "${HEAD_SHA}" > canvas-smoke-results/head-sha.txt
cat canvas-smoke-results/report.md >> "$GITHUB_STEP_SUMMARY"
- name: Install dependencies
if: steps.detect.outputs.canvas == 'true'
run: npm ci --ignore-scripts
- name: Install GitHub Copilot CLI
if: steps.detect.outputs.canvas == 'true'
continue-on-error: true
run: npm install -g @github/copilot
- name: Run canvas smoke test
if: steps.detect.outputs.canvas == 'true'
run: |
set -uo pipefail
export CANVAS_PREVIEW_BASE_URL="https://raw.githubusercontent.com/${HEAD_REPO}/${HEAD_SHA}/"
node ./eng/canvas-smoke-test.mjs \
--changed-files "${RUNNER_TEMP}/changed-files.txt" \
--base-ref "${BASE_SHA}" \
--install require \
--output-dir canvas-smoke-results > /dev/null
exit_code=$?
if [ ! -f canvas-smoke-results/report.md ]; then
mkdir -p canvas-smoke-results
printf '<!-- canvas-smoke-test -->\n## 🧩 Canvas smoke test\n\n⚠️ **Infrastructure error** — the checker crashed. See the workflow logs.\n' > canvas-smoke-results/report.md
echo '{"schema_version":"canvas-smoke-test/v1","status":"infra_error"}' > canvas-smoke-results/results.json
fi
echo "${PR_NUMBER}" > canvas-smoke-results/pr-number.txt
echo "${HEAD_SHA}" > canvas-smoke-results/head-sha.txt
cat canvas-smoke-results/report.md >> "$GITHUB_STEP_SUMMARY"
case "$exit_code" in
0) ;;
1) echo "::error::Canvas smoke test found contribution issues. See the job summary." ;;
*) echo "::error::Canvas smoke test hit an infrastructure error (not a contribution failure)." ;;
esac
exit "$exit_code"
- name: Upload review artifact
if: always() && hashFiles('canvas-smoke-results/report.md') != ''
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: canvas-smoke-test-results
path: canvas-smoke-results/
retention-days: 7
+76
View File
@@ -0,0 +1,76 @@
name: Review Operating Metrics
# Publishes weekly review operating metrics (#4184) to a pinned tracking
# issue labeled `review-metrics`. Reads repository data only; the sole write is
# the tracking issue. Definitions: docs/maintainers/canvas-evidence-and-metrics.md
on:
schedule:
# Mondays 14:00 UTC, covering the previous seven days.
- cron: "0 14 * * 1"
workflow_dispatch:
inputs:
window_days:
description: "Days of activity to summarize"
required: false
default: "7"
type: string
dry_run:
description: "Only write the job summary; do not update the tracking issue"
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: review-metrics
cancel-in-progress: false
jobs:
metrics:
name: review-metrics
if: github.repository_owner == 'github' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
issues: write
pull-requests: read
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
cache: npm
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Compute and publish metrics
env:
GITHUB_TOKEN: ${{ github.token }}
METRICS_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
WINDOW_DAYS: ${{ inputs.window_days || '7' }}
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
run: |
set -euo pipefail
args=(--repo "$GITHUB_REPOSITORY" --window-days "$WINDOW_DAYS" --output-dir "$RUNNER_TEMP/review-metrics")
if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run); fi
node ./eng/review-metrics.mjs "${args[@]}"
- name: Upload metrics
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: review-metrics
path: ${{ runner.temp }}/review-metrics
if-no-files-found: ignore
retention-days: 90
+5
View File
@@ -90,6 +90,11 @@ jobs:
color: 'FBCA04', color: 'FBCA04',
description: 'External plugin awaiting maintainer approval' description: 'External plugin awaiting maintainer approval'
}, },
// Review metrics tracking label (see docs/maintainers/canvas-evidence-and-metrics.md)
'review-metrics': {
color: 'C5DEF5',
description: 'Weekly review operating metrics tracking issue'
},
// Submission gate labels (docs/maintainers/submission-gate.md). // Submission gate labels (docs/maintainers/submission-gate.md).
// ready-for-review, requires-submitter-fixes, and approved are shared with intake above. // ready-for-review, requires-submitter-fixes, and approved are shared with intake above.
'awaiting-automation': { 'awaiting-automation': {
@@ -0,0 +1,114 @@
# Canvas review evidence and review metrics
This guide covers phase 3 ("safe acceleration") of [#4184](https://github.com/github/awesome-copilot/issues/4184):
1. [Canvas review evidence](#canvas-review-evidence): the `canvas-smoke-test` check.
2. [Weekly operating metrics](#weekly-operating-metrics): `.github/review-metrics.yml` and `.github/workflows/review-metrics.yml`.
3. [Deferred: auto-merge](#deferred-auto-merge).
4. [Portability to MOS3](#portability-to-mos3).
Phase 1 owns reviewer routing (CODEOWNERS, `.github/review-routing.yml`, reviewer labels). Phase 2 owns the `submission-gate` check, `merge-risk:*` tiers, and state labels. This phase consumes those signals and falls back gracefully when they do not exist yet.
## Canvas review evidence
### What runs
`.github/workflows/canvas-smoke-test.yml` runs on PRs to `main` that change `extensions/**`, `plugins/**`, or the checker and its workflow. It first runs the checker's target detection without installing dependencies or the Copilot CLI; ordinary plugin-only changes that do not add, remove, or reference canvas extensions skip the expensive steps. Canvas extensions are still submitted only under `extensions/`, so `validate-canvas-extensions.yml` keeps its existing scope and is unchanged. The `canvas-smoke-test` job reports:
- **Skipped (success)** when the PR changes no canvas extension or extension-bearing plugin paths, including ordinary plugin-only changes and checker/workflow-only changes.
- **Pass (removal accepted)** when an extension directory and its plugin were both deleted. Deleting only `extension.mjs`, or leaving a plugin that still references or was the direct plugin for a deleted extension, fails.
- **Pass or fail** otherwise, from `eng/canvas-smoke-test.mjs`.
For each affected extension, the checker:
| Area | Checks |
|---|---|
| Module graph | Parses `extension.mjs` and every reachable local module **without executing it** (`vm.SourceTextModule` / `vm.compileFunction`). Reachability follows static imports, literal dynamic `import()` calls, and literal CommonJS `require()` calls (call-shaped text inside strings, templates, and regexes is ignored). `package.json` `imports` aliases (`#name`) resolve through the same checks; alias shapes the checker cannot analyze fail. Every reachable reference must resolve to a file inside the extension, a Node.js built-in, the host-provided `@github/copilot-sdk`, or a runtime dependency (`dependencies`, `optionalDependencies`, `peerDependencies`). Remote (`http:`/`https:`), `data:`, absolute, `..`, missing, `devDependencies`-only, and undeclared references fail. Modules not reachable from `extension.mjs` (often browser assets for the canvas webview) produce warnings instead. |
| Files | Missing referenced files, unsafe paths (`..`, absolute paths, `file:` URLs), symlinks, committed `node_modules`, native binaries (ELF, PE, Mach-O, `.node`, `.dll`, `.so`, WebAssembly), executable git modes, and files over 5 MB fail. Shell, batch, PowerShell, Python, Ruby, and Perl scripts, shebang files, and unrecognized binaries are reported as warnings for the reviewer. |
| Preview | `assets/preview.png` must exist, decode as a real PNG (signature, chunk CRCs, `IHDR`, palette, `IEND`, inflated image data and filter bytes), be at most 5 MB, decode to at most 256 MiB of image data (checked before decompression), and meet the minimum dimensions. |
| Plugin | Materializes the plugin with `eng/materialize-plugins.mjs` in a temporary copy, validates the served `plugin.json` against the Agent Plugins schema, and confirms `com.github.copilot/extensions/<id>/extension.mjs` exists. |
| Install | Installs the materialized plugin with the GitHub Copilot CLI from an ephemeral local marketplace, in an isolated `COPILOT_HOME` with all tokens removed, and verifies it with `copilot plugin list --json`. CI uses `--install require`. |
| Capabilities | Summarizes what an extension uses (for example filesystem, child processes, network, `process.env`, dynamic code) so reviewers can compare it with the stated purpose. |
Preview minimum dimensions default to **400 × 160 px**. Every existing preview meets this; the smallest are 475 × 440, 720 × 165, 544 × 306, and 657 × 270. Change the minimum with the repository variables `CANVAS_PREVIEW_MIN_WIDTH` and `CANVAS_PREVIEW_MIN_HEIGHT`, or with `--min-preview-width` and `--min-preview-height` locally.
### Review artifact
The job writes a concise report to the job summary and uploads the `canvas-smoke-test-results` artifact (7-day retention) with `report.md`, `results.json`, and the preview images. The report includes plugin and extension metadata, changed files, capabilities, the preview, and every validation and smoke-test result.
`.github/workflows/canvas-smoke-test-comment.yml` follows the repository's reader/writer split. The reader (`canvas-smoke-test.yml`) runs untrusted PR content with a read-only token and no secrets. The writer runs from `main` on `workflow_run` and binds the artifact to the triggering run before writing: the PR must target this repository, its head repository, branch, and SHA must match the run, and it must appear in `workflow_run.pull_requests` or be the only open PR with that head. It also neutralizes `@` mentions, and upserts one PR comment marked `<!-- canvas-smoke-test -->`. It does not comment on skipped PRs that never had a report.
### Running locally
```bash
node eng/canvas-smoke-test.mjs --all --install never # every extension, no CLI install
node eng/canvas-smoke-test.mjs --changed-files changed.txt # only what a diff touches
node --test eng/canvas-smoke-test.test.mjs
```
Exit codes: `0` pass or skipped, `1` contribution problems, `2` infrastructure error.
## Weekly operating metrics
`.github/workflows/review-metrics.yml` runs every Monday at 14:00 UTC and on demand. `eng/review-metrics.mjs` collects read-only data and then:
- writes the report to the job summary and uploads the `review-metrics` artifact (`metrics.json` and `report.md`, 90-day retention);
- updates the body of the tracking issue labeled `review-metrics`, creating and pinning it the first time;
- adds the week's report to that issue as a comment so trends stay visible.
### Definitions
All times are UTC. The window is the previous `window_days` (default 7) days. PRs authored by bots are excluded from contribution counts and review timings. A **maintainer review** is a submitted review from a user with write access who is not the PR author and not a bot.
| Metric | Definition |
|---|---|
| Open contributions by state | Open, non-draft contribution PRs to `main`, grouped by phase 2 state label; PRs without one are `unlabeled`. Open `external-plugin` issues are grouped by their state labels. |
| Open contributions by risk tier | The same PRs grouped by `merge-risk:*`; PRs without one are `unclassified`. |
| Time to first review | For PRs whose first maintainer review landed in the window: first review time minus the later of creation and the last ready-for-review event. Reviews submitted before that start (for example before a PR went back to draft) are ignored. Median and p90 (linear interpolation), in wall-clock hours or days. |
| Time to merge | For PRs merged in the window: merge time minus creation time. Median and p90. |
| Reviews per maintainer | Maintainer reviews submitted in the window, and the distinct PRs each maintainer reviewed. |
| Reviewer concentration | Based on distinct PRs reviewed per maintainer. **Top-reviewer share** is the busiest maintainer's share. **HHI** (Herfindahl-Hirschman index) is the sum of squared shares × 10,000: 10,000 means one reviewer did everything, and above 2,500 is highly concentrated. **Effective reviewers** is 1 divided by the unscaled HHI. |
| Items past 2 and 4 business days | Items waiting on a maintainer longer than each target: open, non-draft PRs without a maintainer review since the clock started and without `requires-submitter-fixes` (the clock starts at creation or the last ready-for-review event), and `external-plugin` issues labeled `ready-for-review` or `awaiting-approval` (the clock starts when that label was last added). Business days count Monday through Friday only, with partial days counted fractionally. |
| Automation failure rate | For the configured `automation_workflows`: runs created in the window with conclusion `failure`, `timed_out`, or `startup_failure`, divided by completed runs excluding `cancelled`, `skipped`, `neutral`, `action_required`, and `stale`. Workflows that do not exist yet are listed as "not found". |
`.github/review-metrics.yml` configures the window, targets, label sets, workflows, and tracking issue. Missing labels never cause errors.
### Running locally
```bash
node eng/review-metrics.mjs --repo github/awesome-copilot --dry-run --output-dir ./metrics
node --test eng/review-metrics.test.mjs
```
## Deferred: auto-merge
Safe auto-merge was proposed in this phase and removed before merge. Arming GitHub auto-merge from automation would codify workarounds to repository and organization policy (required reviewers and Copilot code review approvals), and its triggers widen the attack surface; this repository does not use `pull_request_target` ([#2625](https://github.com/github/awesome-copilot/pull/2625)). It is deferred until maintainers have reviewed the approach with GitHub security. Until then, maintainers merge PRs once `submission-gate` passes.
## Portability to MOS3
The #4184 automation is driven by repository-local configuration and self-contained scripts, so another repository such as MOS3 can adopt it by copying files and editing configuration rather than code.
| Artifact | Phase | How to port | Repository-specific settings |
|---|---|---|---|
| `CODEOWNERS` with team owners | 1 | Copy the structure | Team names and path patterns |
| `.github/review-routing.yml` | 1 | Copy and edit | Reviewer pools, targets, escalation |
| `.github/risk-tiers.yml` | 2 | Copy and edit | Path and risk classification rules |
| `submission-gate` check | 2 | Copy the workflow and script | Required checks per tier; include `canvas-smoke-test` only where canvas extensions exist |
| `canvas-smoke-test` (`eng/canvas-smoke-test.mjs`, `canvas-smoke-test.yml`, `canvas-smoke-test-comment.yml`) | 3 | Copy as-is where the repository ships canvas extensions and `eng/materialize-plugins.mjs` | `CANVAS_PREVIEW_MIN_WIDTH` and `CANVAS_PREVIEW_MIN_HEIGHT` variables |
| `.github/review-metrics.yml`, `eng/review-metrics.mjs`, `.github/workflows/review-metrics.yml` | 3 | Copy as-is | State and risk labels, automation workflow list, tracking issue title |
| `eng/lib/review-automation-github.mjs` | 3 | Copy as-is | None (uses `GITHUB_TOKEN` or the `gh` CLI) |
The phase 3 scripts depend only on Node.js 22 and `js-yaml`. Workflows gate automatic runs on `github.repository_owner == 'github'`; update that condition when porting. `.github/workflows/setup-labels.yml` creates the `review-metrics` label.
### Other marketplaces: `microsoft/azure-dev-tools`
[`microsoft/azure-dev-tools`](https://github.com/microsoft/azure-dev-tools) is another Copilot plugin marketplace (`copilot plugin marketplace add microsoft/azure-dev-tools`, marketplace ID `azure-dev-tools`). It ships canvas plugins such as `azure-functions-hosted-skills`, `azure-resources-query`, `azure-cost-health-check`, and `azure-sre-agent`, plus the skill-only `canvas-authoring` plugin. It can reuse the same review model: CODEOWNERS teams, `review-routing.yml`, risk tiers, `submission-gate`, `canvas-smoke-test`, and the metrics workflow.
- **Maps directly:**
- Canvas smoke-test and preview evidence: module graph, capability summary, unsafe paths, binaries, and `assets/preview.png` checks.
- Risk tiers and `submission-gate` rules.
- Weekly metrics.
- **Differences:**
- The plugins are first-party and Microsoft-owned, so resource ownership comes from CODEOWNERS teams more than contributor front matter. Contributor-history signals matter less there.
- Releases are pinned to immutable per-plugin tags, while awesome-copilot materializes plugins from the default branch. Point the smoke test's install step at the plugin source for the release tag. Treat release-tag or version bumps as their own risk tier that always needs maintainer review.
- Plugins with no canvas extension, such as `canvas-authoring`, report `canvas-smoke-test` as skipped. The check's layout detection (`extensions/<id>/` plus a matching `plugins/<id>/plugin.json`) may need adjusting to that repository's structure.
+8
View File
@@ -32,6 +32,14 @@ node eng/review-routing.mjs validate # validate .github/review-routing.yml
node --test eng/review-routing.test.mjs # unit tests node --test eng/review-routing.test.mjs # unit tests
``` ```
## Review automation
See [docs/maintainers/canvas-evidence-and-metrics.md](../docs/maintainers/canvas-evidence-and-metrics.md) for details.
- `canvas-smoke-test.mjs` — static checks, preview validation, materialization, and install smoke test for canvas extensions (`canvas-smoke-test` check).
- `review-metrics.mjs` — computes weekly review operating metrics and publishes them to the tracking issue.
- `lib/review-automation-github.mjs` — small GitHub API client used by `review-metrics.mjs` (uses `GITHUB_TOKEN`, or the `gh` CLI locally).
## Contributor Tools ## Contributor Tools
- `contributor-report.mjs` — generates a markdown report of merged PRs for missing contributors (includes shared helpers). - `contributor-report.mjs` — generates a markdown report of merged PRs for missing contributors (includes shared helpers).
File diff suppressed because it is too large Load Diff
+673
View File
@@ -0,0 +1,673 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import zlib from "node:zlib";
import { test } from "node:test";
import {
checkExtensionModules,
checkPreview,
classifySpecifier,
detectCanvasTargets,
findDynamicImportSpecifiers,
findNonLiteralRuntimeLoads,
findRequireSpecifiers,
findUnsafeManifestPaths,
importsAliasTargets,
inspectExtensionFiles,
inspectPng,
parseEsModule,
isSafeExtensionId,
readRegularFile,
renderMarkdownReport,
runCanvasSmokeTest,
stripComments,
unsafeExtensionRefs,
unsafePathReason,
} from "./canvas-smoke-test.mjs";
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let k = 0; k < 8; k++) crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
}
return (crc ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([length, body, crc]);
}
function makePng(width, height, { truncate = false } = {}) {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8; // bit depth
header[9] = 2; // truecolor
const rows = Buffer.alloc(height * (width * 3 + 1));
const raw = truncate ? rows.subarray(0, rows.length / 2) : rows;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", header),
chunk("IDAT", zlib.deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
function makeRepo(files) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "canvas-smoke-test-"));
for (const [relative, content] of Object.entries(files)) {
const target = path.join(root, relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, typeof content === "string" || Buffer.isBuffer(content) ? content : JSON.stringify(content, null, 2));
}
return root;
}
function git(root, args) {
const result = spawnSync("git", args, { cwd: root, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr || result.stdout);
return result.stdout.trim();
}
const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
function extensionPlugin(name) {
return {
$schema: PLUGIN_SCHEMA,
name,
description: `${name} canvas`,
version: "1.0.0",
extensions: { "com.github.copilot": { logo: "assets/preview.png" } },
};
}
test("inspectPng accepts a valid PNG and reports dimensions", () => {
const result = inspectPng(makePng(32, 16));
assert.equal(result.ok, true);
assert.equal(result.width, 32);
assert.equal(result.height, 16);
});
test("inspectPng rejects non-PNG, corrupted, and truncated data", () => {
assert.match(inspectPng(Buffer.from("GIF89a-not-a-png-at-all")).errors[0], /signature/);
const corrupted = makePng(8, 8);
corrupted[corrupted.length - 20] ^= 0xff;
assert.equal(inspectPng(corrupted).ok, false);
const truncated = inspectPng(makePng(8, 8, { truncate: true }));
assert.equal(truncated.ok, false);
assert.match(truncated.errors[0], /truncated/);
});
test("inspectPng rejects malformed IEND chunks and trailing data", () => {
const valid = makePng(8, 8);
assert.match(inspectPng(Buffer.concat([valid, Buffer.from("trailing")])).errors[0], /after the IEND/);
const iendOffset = valid.length - 12;
const nonEmptyIend = Buffer.concat([
valid.subarray(0, iendOffset),
chunk("IEND", Buffer.from([0])),
]);
assert.match(inspectPng(nonEmptyIend).errors[0], /IEND chunk has invalid length/);
});
test("checkPreview enforces configurable minimum dimensions", () => {
const root = makeRepo({ "ext/assets/preview.png": makePng(100, 50) });
const small = checkPreview(path.join(root, "ext"), { minWidth: 400, minHeight: 160 });
assert.match(small.errors[0], /minimum is 400×160/);
const ok = checkPreview(path.join(root, "ext"), { minWidth: 100, minHeight: 50 });
assert.deepEqual(ok.errors, []);
const missing = checkPreview(path.join(root, "missing"), { minWidth: 1, minHeight: 1 });
assert.match(missing.errors[0], /missing/);
});
test("unsafePathReason flags absolute and traversal paths", () => {
assert.equal(unsafePathReason("./assets/preview.png"), null);
assert.equal(unsafePathReason("assets/preview.png"), null);
assert.match(unsafePathReason("../secrets.txt"), /traversal/);
assert.match(unsafePathReason("./a/../../b"), /traversal/);
assert.match(unsafePathReason("/etc/passwd"), /absolute/);
assert.match(unsafePathReason("C:\\Windows\\system32"), /Windows/);
assert.match(unsafePathReason("file:///tmp/x.mjs"), /file:/);
});
test("findUnsafeManifestPaths ignores prose and URLs", () => {
const findings = findUnsafeManifestPaths({
description: "Reads ../ style docs and / separators in text",
homepage: "https://example.com/../x",
extensions: { "com.github.copilot": { logo: "../outside.png" } },
main: "/abs/entry.mjs",
});
assert.deepEqual(findings.map((finding) => finding.field).sort(), ["$.extensions.com.github.copilot.logo", "$.main"]);
});
test("classifySpecifier distinguishes builtins, host packages, dependencies, and unsafe specifiers", () => {
const pkg = { dependencies: { playwright: "1.0.0" }, devDependencies: { vitest: "1.0.0" } };
assert.equal(classifySpecifier("node:fs", pkg).kind, "builtin");
assert.equal(classifySpecifier("path", pkg).kind, "builtin");
assert.equal(classifySpecifier("@github/copilot-sdk/extension", pkg).kind, "host");
assert.equal(classifySpecifier("playwright/test", pkg).kind, "dependency");
assert.equal(classifySpecifier("vitest", pkg).kind, "dev-dependency");
assert.equal(classifySpecifier("left-pad", pkg).kind, "undeclared");
assert.equal(classifySpecifier("/tmp/evil.mjs", pkg).kind, "unsafe");
assert.equal(classifySpecifier("https://example.com/x.mjs", pkg).kind, "remote");
assert.equal(classifySpecifier("./local.mjs", pkg).kind, "relative");
});
test("parseEsModule returns static specifiers without executing code", () => {
const marker = path.join(os.tmpdir(), `canvas-smoke-exec-${process.pid}-${Date.now()}`);
const source = `import fs from "node:fs";\nimport { x } from "./x.mjs";\nfs.writeFileSync(${JSON.stringify(marker)}, "ran");\n`;
const result = parseEsModule(source, "entry.mjs");
assert.equal(result.ok, true);
assert.deepEqual(result.specifiers.sort(), ["./x.mjs", "node:fs"]);
assert.equal(fs.existsSync(marker), false);
const broken = parseEsModule("export const = 1;", "broken.mjs");
assert.equal(broken.ok, false);
});
test("checkExtensionModules reports syntax errors, missing files, traversal, and undeclared packages", () => {
const root = makeRepo({
"extensions/bad/extension.mjs": [
'import { joinSession } from "@github/copilot-sdk/extension";',
'import { spawn } from "node:child_process";',
'import helper from "./lib/helper.mjs";',
'import missing from "./lib/missing.mjs";',
'import outside from "../other/secret.mjs";',
'import pad from "left-pad";',
"export default { joinSession, spawn, helper, missing, outside, pad };",
].join("\n"),
"extensions/bad/lib/helper.mjs": "export default fetch;\nexport const broken = ;\n",
"extensions/bad/public/app.js": 'import { h } from "/vendor/preact.js";\nexport default h;\n',
"extensions/bad/package.json": { name: "bad", version: "1.0.0" },
"extensions/other/secret.mjs": "export default 1;\n",
});
const result = checkExtensionModules(path.join(root, "extensions", "bad"));
const text = result.errors.join("\n");
assert.match(text, /lib\/helper\.mjs: syntax error/);
assert.match(text, /\.\/lib\/missing\.mjs" references a missing file/);
assert.match(text, /escapes the extension directory/);
assert.match(text, /"left-pad" is not a Node\.js builtin/);
assert.doesNotMatch(text, /public\/app\.js/);
assert.match(result.warnings.join("\n"), /public\/app\.js: import "\/vendor\/preact\.js" uses an unsafe absolute path \(module is not reachable/);
assert.deepEqual(result.builtins, ["child_process"]);
});
test("inspectExtensionFiles flags native binaries, executables, and vendored node_modules", () => {
const elf = Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.alloc(32)]);
const root = makeRepo({
"extensions/bin/extension.mjs": "export {};\n",
"extensions/bin/tool": elf,
"extensions/bin/addon.node": "not really native",
"extensions/bin/run.sh": "#!/bin/sh\necho hi\n",
"extensions/bin/assets/preview.png": makePng(4, 4),
"extensions/bin/node_modules/dep/index.js": "module.exports = 1;\n",
"extensions/bin/data.bin.dat": Buffer.from([0, 1, 2, 3]),
"extensions/bin/tool.py": "print(1)\n",
});
const modes = new Map([["extensions/bin/run.sh", "100755"]]);
const result = inspectExtensionFiles(path.join(root, "extensions", "bin"), { rootDir: root, fileModes: modes });
const errors = result.errors.join("\n");
assert.match(errors, /tool: contains a ELF executable/);
assert.match(errors, /addon\.node: native\/compiled binary/);
assert.match(errors, /run\.sh: file is marked executable/);
assert.match(errors, /node_modules\/: vendored node_modules/);
assert.match(result.warnings.join("\n"), /run\.sh: script file/);
assert.match(result.warnings.join("\n"), /data\.bin\.dat: unexpected binary content/);
assert.match(result.warnings.join("\n"), /tool\.py: script file/);
assert.doesNotMatch(result.warnings.join("\n"), /preview\.png/);
});
test("detectCanvasTargets maps changed paths to extensions and extension-bearing plugins", () => {
const root = makeRepo({
"extensions/orb/extension.mjs": "export {};\n",
"extensions/shared/extension.mjs": "export {};\n",
"plugins/orb/plugin.json": extensionPlugin("orb"),
"plugins/bundle/plugin.json": {
$schema: PLUGIN_SCHEMA,
name: "bundle",
description: "bundle",
version: "1.0.0",
extensions: { "com.github.awesome-copilot": { extensions: ["./extensions/shared"] } },
},
"plugins/plain/plugin.json": { $schema: PLUGIN_SCHEMA, name: "plain", description: "plain", version: "1.0.0" },
});
assert.deepEqual(detectCanvasTargets(["README.md", "plugins/plain/README.md", "skills/x/SKILL.md"], { rootDir: root }).extensions, []);
const orb = detectCanvasTargets(["extensions/orb/extension.mjs"], { rootDir: root });
assert.deepEqual(orb.extensions, ["orb"]);
assert.deepEqual(orb.plugins, ["orb"]);
const bundle = detectCanvasTargets(["plugins/bundle/plugin.json", "extensions/gone/extension.mjs"], { rootDir: root });
assert.deepEqual(bundle.extensions, ["shared"]);
assert.deepEqual(bundle.plugins, ["bundle"]);
assert.deepEqual(bundle.removedExtensions, ["gone"]);
});
test("runCanvasSmokeTest reports skipped when no canvas paths change", async () => {
const root = makeRepo({ "plugins/plain/plugin.json": { name: "plain" } });
const report = await runCanvasSmokeTest({ rootDir: root, changedFiles: ["docs/README.md"], install: "never" });
assert.equal(report.status, "skipped");
assert.match(renderMarkdownReport(report), /Skipped/);
});
test("runCanvasSmokeTest materializes a valid extension plugin and renders evidence", async () => {
const root = makeRepo({
"extensions/orb/extension.mjs": 'import { joinSession } from "@github/copilot-sdk/extension";\nimport http from "node:http";\nexport default { joinSession, http };\n',
"extensions/orb/package.json": { name: "orb", version: "1.0.0", type: "module" },
"extensions/orb/assets/preview.png": makePng(800, 400),
"plugins/orb/plugin.json": extensionPlugin("orb"),
"plugins/orb/README.md": "# Orb\n",
});
const report = await runCanvasSmokeTest({ rootDir: root, changedFiles: ["extensions/orb/extension.mjs"], install: "never" });
assert.equal(report.status, "pass", JSON.stringify(report, null, 2));
assert.equal(report.smoke.materialize.orb.status, "pass");
assert.equal(report.smoke.installStatus, "skipped");
assert.equal(report.extensions[0].preview.width, 800);
const markdown = renderMarkdownReport(report, { previewBaseUrl: "https://raw.githubusercontent.com/o/r/sha/" });
assert.match(markdown, /<!-- canvas-smoke-test -->/);
assert.match(markdown, /Network sockets/);
assert.match(markdown, /extensions\/orb\/assets\/preview\.png/);
});
test("runCanvasSmokeTest fails for an unregistered extension with a missing preview", async () => {
const root = makeRepo({ "extensions/lonely/extension.mjs": "export {};\n" });
const report = await runCanvasSmokeTest({ rootDir: root, changedFiles: ["extensions/lonely/extension.mjs"], install: "never" });
assert.equal(report.status, "fail");
const errors = report.extensions[0].errors.join("\n");
assert.match(errors, /preview\.png is missing/);
assert.match(errors, /not registered by any plugin/);
});
test("inspectPng rejects images whose decoded data exceeds the budget before inflating", () => {
const header = Buffer.alloc(13);
header.writeUInt32BE(16000, 0);
header.writeUInt32BE(16000, 4);
header[8] = 16; // bit depth
header[9] = 6; // RGBA
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", header),
chunk("IDAT", zlib.deflateSync(Buffer.alloc(1024))),
chunk("IEND", Buffer.alloc(0)),
]);
const result = inspectPng(png);
assert.equal(result.ok, false);
assert.match(result.errors[0], /decoded image data would be/);
});
test("inspectPng rejects image data larger than IHDR allows", () => {
const header = Buffer.alloc(13);
header.writeUInt32BE(4, 0);
header.writeUInt32BE(4, 4);
header[8] = 8;
header[9] = 2;
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", header),
chunk("IDAT", zlib.deflateSync(Buffer.alloc(10_000))),
chunk("IEND", Buffer.alloc(0)),
]);
assert.match(inspectPng(png).errors[0], /larger than the IHDR/);
});
test("findUnsafeManifestPaths reports file: URLs", () => {
const findings = findUnsafeManifestPaths({ main: "file:///tmp/x.mjs", homepage: "https://example.com/x" });
assert.deepEqual(findings.map((finding) => finding.field), ["$.main"]);
assert.match(findings[0].reason, /file:/);
});
test("checkExtensionModules follows CommonJS requires and literal dynamic imports", () => {
const root = makeRepo({
"extensions/graph/extension.mjs": [
'import cjs from "./lib/legacy.cjs";',
'const worker = await import("./lib/worker.mjs");',
'const later = await import("./lib/missing.mjs");',
"// import(\"./lib/commented.mjs\")",
"export default { cjs, worker, later };",
].join("\n"),
"extensions/graph/lib/legacy.cjs": [
'const helper = require("./helper");',
'const pad = require("left-pad");',
'const outside = require("../../other/x.js");',
"// require(\"ignored-in-comment\")",
"module.exports = { helper, pad, outside };",
].join("\n"),
"extensions/graph/lib/helper.js": "module.exports = 1;\n",
"extensions/graph/lib/worker.mjs": 'import { spawn } from "node:child_process";\nimport vitest from "vitest";\nexport default { spawn, vitest };\n',
"extensions/graph/package.json": { name: "graph", version: "1.0.0", devDependencies: { vitest: "1.0.0" } },
"extensions/other/x.js": "module.exports = 1;\n",
});
const result = checkExtensionModules(path.join(root, "extensions", "graph"));
const errors = result.errors.join("\n");
assert.match(errors, /legacy\.cjs: require\("left-pad"\) is not a Node\.js builtin/);
assert.match(errors, /legacy\.cjs: require\("\.\.\/\.\.\/other\/x\.js"\) escapes the extension directory/);
assert.doesNotMatch(errors, /require\("\.\/helper"\)/);
assert.doesNotMatch(errors, /ignored-in-comment|commented\.mjs/);
assert.match(errors, /dynamic import\("\.\/lib\/missing\.mjs"\) references a missing file/);
assert.match(errors, /worker\.mjs: import "vitest" is only declared in devDependencies/);
assert.deepEqual(result.builtins, ["child_process"]);
assert.ok(result.modules.find((entry) => entry.path === "lib/worker.mjs").reachable);
assert.ok(result.modules.find((entry) => entry.path === "lib/helper.js").reachable);
});
test("checkExtensionModules fails reachable data URL imports and warns for unreachable dynamic data imports", () => {
const root = makeRepo({
"extensions/data/extension.mjs": [
'import inline from "data:text/javascript,export default 1";',
'const later = await import("data:text/javascript,export default 2");',
"export default { inline, later };",
].join("\n"),
"extensions/data/public/app.js": 'await import("data:text/javascript,export default 3");\n',
});
const result = checkExtensionModules(path.join(root, "extensions", "data"));
const errors = result.errors.join("\n");
assert.match(errors, /extension\.mjs: import "data:text\/javascript,export default 1" is a data: URL import/);
assert.match(errors, /extension\.mjs: dynamic import\("data:text\/javascript,export default 2"\) is a data: URL import/);
assert.match(result.warnings.join("\n"), /public\/app\.js: dynamic import\("data:text\/javascript,export default 3"\) is a data: URL import \(module is not reachable/);
});
test("removed canvas paths are validated instead of skipped", async () => {
// Entry point deleted but the extension directory remains.
const partial = makeRepo({
"extensions/orb/assets/preview.png": makePng(800, 400),
"plugins/orb/plugin.json": extensionPlugin("orb"),
});
const partialReport = await runCanvasSmokeTest({ rootDir: partial, changedFiles: ["extensions/orb/extension.mjs"], install: "never" });
assert.equal(partialReport.status, "fail");
assert.match(partialReport.extensions[0].errors.join("\n"), /extension\.mjs: entry point is missing/);
// Extension deleted, but its direct plugin and a bundling plugin remain.
const orphaned = makeRepo({
"plugins/orb/plugin.json": extensionPlugin("orb"),
"plugins/bundle/plugin.json": {
$schema: PLUGIN_SCHEMA,
name: "bundle",
description: "bundle",
version: "1.0.0",
extensions: { "com.github.awesome-copilot": { extensions: ["./extensions/orb"] } },
},
});
const orphanedTargets = detectCanvasTargets(["extensions/orb/extension.mjs"], { rootDir: orphaned });
assert.deepEqual(orphanedTargets.plugins, ["bundle", "orb"]);
const orphanedReport = await runCanvasSmokeTest({ rootDir: orphaned, changedFiles: ["extensions/orb/extension.mjs"], install: "never" });
assert.equal(orphanedReport.status, "fail");
const pluginErrors = orphanedReport.plugins.flatMap((plugin) => plugin.errors).join("\n");
assert.match(pluginErrors, /plugins\/orb is the plugin for removed extension/);
assert.match(pluginErrors, /plugins\/bundle\/plugin\.json references missing extension extensions\/orb/);
// Extension and plugin both deleted: accepted.
const clean = makeRepo({ "plugins/plain/plugin.json": { name: "plain" } });
const cleanReport = await runCanvasSmokeTest({
rootDir: clean,
changedFiles: ["extensions/orb/extension.mjs", "plugins/orb/plugin.json"],
install: "never",
});
assert.equal(cleanReport.status, "pass");
assert.match(renderMarkdownReport(cleanReport), /removed extensions/);
});
test("stripComments keeps string, template, and regex literals that contain comment markers", () => {
const source = [
'const s = "x//y"; await import("./a.mjs");',
"const t = `/* ${'//'} */`; await import(\"./b.mjs\");",
'const r = /\\/\\*/; await import("./c.mjs");',
'// await import("./hidden.mjs");',
'/* await import("./hidden2.mjs"); */',
'const q = a / b; const w = c / d; await import("./d.mjs");',
].join("\n");
const text = stripComments(source);
for (const name of ["a", "b", "c", "d"]) assert.match(text, new RegExp(`import\\("\\./${name}\\.mjs"\\)`));
assert.doesNotMatch(text, /hidden/);
assert.equal(text.split("\n").length, source.split("\n").length);
});
test("checkExtensionModules follows imports placed after strings containing //", () => {
const root = makeRepo({
"extensions/str/extension.mjs": 'const s = "x//y"; const w = await import("./worker.mjs");\nexport default { s, w };\n',
"extensions/str/worker.mjs": 'import pad from "left-pad";\nexport default pad;\n',
});
const result = checkExtensionModules(path.join(root, "extensions", "str"));
assert.match(result.errors.join("\n"), /worker\.mjs: import "left-pad" is not a Node\.js builtin/);
assert.ok(result.modules.find((entry) => entry.path === "worker.mjs").reachable);
});
test("inspectPng rejects a duplicate IHDR chunk", () => {
const valid = makePng(4, 4);
const ihdr = valid.subarray(8, 8 + 25);
const png = Buffer.concat([valid.subarray(0, 33), ihdr, valid.subarray(33)]);
const result = inspectPng(png);
assert.equal(result.ok, false);
assert.match(result.errors.join("\n"), /duplicate IHDR chunk/);
});
test("dynamic import and require extraction ignores call-shaped text inside literals", () => {
const source = [
'const a = \'import("./in-single.mjs")\';',
'const b = "require(\\"./in-double.cjs\\")";',
"const c = `import(\"./in-template.mjs\") ${await import(\"./in-expr.mjs\")}`;",
'const d = /import\\("\\.\\/in-regex\\.mjs"\\)/;',
'const e = await import("./real.mjs"); const f = require("./real.cjs");',
].join("\n");
assert.deepEqual(findDynamicImportSpecifiers(source).sort(), ["./in-expr.mjs", "./real.mjs"]);
assert.deepEqual(findRequireSpecifiers(source), ["./real.cjs"]);
const root = makeRepo({
"extensions/lit/extension.mjs": 'const note = \'import("./missing.mjs")\';\nexport default { note };\n',
});
assert.deepEqual(checkExtensionModules(path.join(root, "extensions", "lit")).errors, []);
});
test("non-literal runtime loads are detected outside comments and literals", () => {
const source = [
'const literalImport = import("./literal.mjs");',
'const literalRequire = require("left-pad");',
'const computedImport = import("node:" + moduleName);',
"const computedRequire = require(moduleName);",
'const text = "require(hidden)";',
"// import(variable)",
"const templateImport = import(`./${name}.mjs`);",
].join("\n");
assert.deepEqual(findNonLiteralRuntimeLoads(source), ["dynamic import", "dynamic import", "require"]);
const root = makeRepo({
"extensions/runtime/extension.mjs": 'const name = "fs";\nexport default import("node:" + name);\n',
});
assert.match(
checkExtensionModules(path.join(root, "extensions", "runtime")).errors.join("\n"),
/extension\.mjs: non-literal dynamic import cannot be analyzed safely/,
);
});
test("checkExtensionModules resolves package.json imports aliases and fails closed on unknown shapes", () => {
assert.deepEqual(importsAliasTargets({ node: "./a.mjs", default: { import: "./b.mjs" } }), ["./a.mjs", "./b.mjs"]);
assert.equal(importsAliasTargets(["./a.mjs"]), null);
const root = makeRepo({
"extensions/alias/extension.mjs": [
'import ok from "#ok";',
'import gone from "#gone";',
'import pad from "#pad";',
'import odd from "#odd";',
'import up from "#up";',
"export default { ok, gone, pad, odd, up };",
].join("\n"),
"extensions/alias/lib/ok.mjs": 'import pad from "left-pad";\nexport default pad;\n',
"extensions/alias/package.json": {
name: "alias",
version: "1.0.0",
type: "module",
imports: { "#ok": "./lib/ok.mjs", "#gone": "./lib/gone.mjs", "#pad": "left-pad", "#odd": ["./lib/ok.mjs"], "#up": "../x.mjs" },
},
});
const result = checkExtensionModules(path.join(root, "extensions", "alias"));
const errors = result.errors.join("\n");
assert.ok(result.modules.find((entry) => entry.path === "lib/ok.mjs").reachable);
assert.match(errors, /lib\/ok\.mjs: import "left-pad" is not a Node\.js builtin/);
assert.match(errors, /import "#gone" -> "\.\/lib\/gone\.mjs" references a missing file/);
assert.match(errors, /import "#pad" -> "left-pad" is not a Node\.js builtin/);
assert.match(errors, /import "#odd" uses a package\.json "imports" alias that cannot be analyzed/);
assert.match(errors, /import "#up" uses a package\.json "imports" alias that cannot be analyzed/);
});
test("readRegularFile and checkExtensionModules refuse symlinked files", (t) => {
const root = makeRepo({
"outside/secret.txt": "secret\n",
"extensions/link/extension.mjs": 'import x from "./lib.mjs";\nexport default x;\n',
});
const extensionDir = path.join(root, "extensions", "link");
try {
fs.symlinkSync(path.join(root, "outside", "secret.txt"), path.join(extensionDir, "lib.mjs"));
fs.symlinkSync(path.join(root, "outside", "secret.txt"), path.join(extensionDir, "package.json"));
} catch (error) {
if (["EPERM", "EACCES"].includes(error.code)) return t.skip("symlinks unavailable");
throw error;
}
assert.match(readRegularFile(path.join(extensionDir, "lib.mjs")).error, /symbolic link/);
const errors = checkExtensionModules(extensionDir).errors.join("\n");
assert.match(errors, /package\.json/);
assert.match(errors, /lib\.mjs/);
assert.doesNotMatch(errors, /secret/);
});
test("readRegularFile enforces the size cap and containment root", () => {
const root = makeRepo({ "a/big.json": "x".repeat(64), "b/ok.json": "{}" });
assert.match(readRegularFile(path.join(root, "a", "big.json"), { maxBytes: 10 }).error, /limit/);
assert.match(readRegularFile(path.join(root, "b", "ok.json"), { root: path.join(root, "a") }).error, /outside/);
assert.equal(readRegularFile(path.join(root, "b", "ok.json"), { root: path.join(root, "b") }).text, "{}");
assert.equal(readRegularFile(path.join(root, "b", "missing.json")).missing, true);
});
test("detectCanvasTargets still validates a plugin whose plugin.json was deleted", async () => {
const root = makeRepo({
"extensions/orb/extension.mjs": "export {};\n",
"extensions/orb/assets/preview.png": makePng(800, 400),
});
const targets = detectCanvasTargets(["plugins/orb/plugin.json"], { rootDir: root });
assert.deepEqual(targets.plugins, ["orb"]);
assert.deepEqual(targets.extensions, ["orb"]);
const report = await runCanvasSmokeTest({ rootDir: root, changedFiles: ["plugins/orb/plugin.json"], install: "never" });
assert.equal(report.status, "fail");
});
test("detectCanvasTargets uses base plugin manifests when deleted bundle manifests referenced extensions", async () => {
const root = makeRepo({
"extensions/daily-focus-board/extension.mjs": "export {};\n",
"extensions/daily-focus-board/assets/preview.png": makePng(800, 400),
"plugins/ember/plugin.json": {
$schema: PLUGIN_SCHEMA,
name: "ember",
description: "Ember bundle",
version: "1.0.0",
extensions: { "com.github.awesome-copilot": { extensions: ["./extensions/daily-focus-board"] } },
},
});
git(root, ["init"]);
git(root, ["config", "user.email", "test@example.com"]);
git(root, ["config", "user.name", "Test User"]);
git(root, ["add", "."]);
git(root, ["commit", "-m", "base"]);
const baseRef = git(root, ["rev-parse", "HEAD"]);
fs.rmSync(path.join(root, "plugins", "ember", "plugin.json"));
const targets = detectCanvasTargets(["plugins/ember/plugin.json"], { rootDir: root, baseRef });
assert.deepEqual(targets.extensions, ["daily-focus-board"]);
assert.deepEqual(targets.plugins, ["ember"]);
const report = await runCanvasSmokeTest({ rootDir: root, changedFiles: ["plugins/ember/plugin.json"], baseRef, install: "never" });
assert.equal(report.status, "fail");
assert.equal(report.extensions[0].id, "daily-focus-board");
assert.match(report.plugins[0].errors.join("\n"), /plugins\/ember\/plugin\.json is missing/);
});
test("deleted plugin manifest base read errors fail closed when a base ref is provided", async () => {
const root = makeRepo({});
const report = await runCanvasSmokeTest({
rootDir: root,
changedFiles: ["plugins/ember/plugin.json"],
baseRef: "missing-base",
install: "never",
});
assert.equal(report.status, "fail");
assert.match(report.plugins[0].errors.join("\n"), /cannot read deleted plugins\/ember\/plugin\.json from base missing-base/);
});
test("detectCanvasTargets unions base and head extension references for an edited bundle manifest", () => {
const manifest = (refs) => ({
$schema: PLUGIN_SCHEMA,
name: "ember",
description: "Ember bundle",
version: "1.0.0",
extensions: { "com.github.awesome-copilot": { extensions: refs } },
});
const root = makeRepo({
"extensions/daily-focus-board/extension.mjs": "export {};\n",
"extensions/daily-focus-board/assets/preview.png": makePng(800, 400),
"extensions/orb/extension.mjs": "export {};\n",
"extensions/orb/assets/preview.png": makePng(800, 400),
"plugins/ember/plugin.json": manifest(["./extensions/daily-focus-board"]),
});
git(root, ["init"]);
git(root, ["config", "user.email", "test@example.com"]);
git(root, ["config", "user.name", "Test User"]);
git(root, ["add", "."]);
git(root, ["commit", "-m", "base"]);
const baseRef = git(root, ["rev-parse", "HEAD"]);
fs.writeFileSync(
path.join(root, "plugins", "ember", "plugin.json"),
`${JSON.stringify(manifest(["./extensions/orb"]), null, 2)}\n`,
);
const targets = detectCanvasTargets(["plugins/ember/plugin.json"], { rootDir: root, baseRef });
assert.deepEqual(targets.extensions, ["daily-focus-board", "orb"]);
assert.deepEqual(targets.plugins, ["ember"]);
});
test("the comment stripper treats a slash after a postfix update as division", () => {
const source = [
'for (let i = 0; i < n; i++) {}',
'const r = x++ / y; const dep = require("left-pad");',
'const s = count-- / total; const dyn = import("./real.mjs");',
'const t = ++x / y;',
].join("\n");
assert.deepEqual(findRequireSpecifiers(source), ["left-pad"]);
assert.deepEqual(findDynamicImportSpecifiers(source), ["./real.mjs"]);
// A prefix update still allows a regex literal to follow.
assert.deepEqual(findRequireSpecifiers('const re = ++i, m = /require("hidden")/;'), []);
});
test("unsafe extension references in plugin manifests are rejected and never materialized", async () => {
assert.equal(isSafeExtensionId("orb"), true);
for (const id of ["..", "../x", "a/b", "", ".hidden"]) assert.equal(isSafeExtensionId(id), false, id);
const manifest = {
$schema: PLUGIN_SCHEMA,
name: "evil",
description: "evil",
version: "1.0.0",
extensions: { "com.github.awesome-copilot": { extensions: ["./extensions/../../x", "./extensions/ok"] } },
};
assert.deepEqual(unsafeExtensionRefs(manifest), ["./extensions/../../x"]);
const root = makeRepo({
"plugins/evil/plugin.json": manifest,
"extensions/ok/extension.mjs": "export {};\n",
"extensions/ok/assets/preview.png": makePng(800, 400),
});
const targets = detectCanvasTargets(["plugins/evil/plugin.json"], { rootDir: root });
assert.deepEqual(targets.plugins, ["evil"]);
assert.deepEqual(targets.extensions, ["ok"]);
const report = await runCanvasSmokeTest({ rootDir: root, changedFiles: ["plugins/evil/plugin.json"], install: "never" });
assert.equal(report.status, "fail");
const pluginErrors = report.plugins.flatMap((plugin) => plugin.errors).join("\n");
assert.match(pluginErrors, /\.\/extensions\/\.\.\/\.\.\/x/);
assert.notEqual(report.smoke.materialize.evil?.status, "pass");
});
+147
View File
@@ -0,0 +1,147 @@
// Minimal GitHub REST/GraphQL client for maintainer automation scripts.
//
// In workflows it uses GITHUB_TOKEN (or GH_TOKEN) with the global fetch API.
// For local dry runs without a token it falls back to the authenticated `gh`
// CLI (`gh api`), so maintainers never need to export credentials.
import { spawn } from "node:child_process";
const DEFAULT_API_URL = "https://api.github.com";
export function createGitHubClient({
token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN,
apiUrl = process.env.GITHUB_API_URL || DEFAULT_API_URL,
userAgent = "awesome-copilot-maintainer-automation",
transport,
} = {}) {
const baseUrl = apiUrl.replace(/\/$/, "");
const send = transport ?? (token ? createFetchTransport({ token, userAgent }) : createGhTransport(baseUrl));
async function request(method, route, { body, query, allowStatuses = [] } = {}) {
const url = new URL(route.startsWith("http") ? route : `${baseUrl}${route}`);
for (const [key, value] of Object.entries(query ?? {})) {
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
}
for (let attempt = 0; ; attempt++) {
const response = await send(method, url, body);
if ((response.status === 502 || response.status === 503) && attempt < 2) {
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
continue;
}
const data = response.text ? safeJson(response.text) : null;
if ((response.status < 200 || response.status >= 300) && !allowStatuses.includes(response.status)) {
const message = data?.message || response.text || `HTTP ${response.status}`;
const error = new Error(`${method} ${url.pathname} failed: ${response.status} ${message}`);
error.status = response.status;
throw error;
}
return { status: response.status, data, headers: response.headers };
}
}
async function paginate(route, { query = {}, itemsKey, maxPages = 20 } = {}) {
const items = [];
let next = null;
let page = 0;
do {
const response = next ? await request("GET", next) : await request("GET", route, { query: { per_page: 100, ...query } });
const pageItems = itemsKey ? response.data?.[itemsKey] ?? [] : response.data ?? [];
items.push(...pageItems);
next = parseLink(response.headers.get("link"), "next");
page++;
} while (next && page < maxPages);
return items;
}
async function graphql(query, variables = {}) {
const response = await request("POST", "/graphql", { body: { query, variables } });
if (response.data?.errors?.length) {
const error = new Error(`GraphQL request failed: ${response.data.errors.map((item) => item.message).join("; ")}`);
error.errors = response.data.errors;
throw error;
}
return response.data?.data;
}
return { request, paginate, graphql };
}
function createFetchTransport({ token, userAgent }) {
const headers = {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"User-Agent": userAgent,
"X-GitHub-Api-Version": "2022-11-28",
};
return async (method, url, body) => {
const response = await fetch(url, {
method,
headers: body ? { ...headers, "Content-Type": "application/json" } : headers,
body: body ? JSON.stringify(body) : undefined,
});
return { status: response.status, text: await response.text(), headers: response.headers };
};
}
function createGhTransport(baseUrl) {
return (method, url, body) =>
new Promise((resolve, reject) => {
const endpoint = url.href.startsWith(baseUrl) ? url.href.slice(baseUrl.length + 1) : url.href;
const args = ["api", "--include", "--method", method, endpoint, "-H", "X-GitHub-Api-Version: 2022-11-28"];
if (body) args.push("--input", "-");
const child = spawn("gh", args, { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk));
child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
child.on("error", (error) =>
reject(new Error(`No GITHUB_TOKEN/GH_TOKEN set and the gh CLI could not be started: ${error.message}`)),
);
child.on("close", () => {
const parsed = parseIncludedResponse(stdout);
if (!parsed) {
reject(new Error(`gh api ${method} ${endpoint} failed: ${stderr.trim() || "no response"}`));
return;
}
resolve(parsed);
});
child.stdin.end(body ? JSON.stringify(body) : undefined);
});
}
export function parseIncludedResponse(output) {
const normalized = output.replace(/\r\n/g, "\n");
const match = normalized.match(/^HTTP\/[\d.]+ (\d{3})[^\n]*\n([\s\S]*?)\n\n([\s\S]*)$/);
if (!match) return null;
const headers = new Headers();
for (const line of match[2].split("\n")) {
const index = line.indexOf(":");
if (index > 0) headers.append(line.slice(0, index).trim(), line.slice(index + 1).trim());
}
return { status: Number(match[1]), text: match[3], headers };
}
export function parseLink(linkHeader, rel) {
if (!linkHeader) return null;
for (const part of linkHeader.split(",")) {
const match = part.match(/<([^>]+)>\s*;\s*rel="([^"]+)"/);
if (match && match[2] === rel) return match[1];
}
return null;
}
export function parseRepository(value = process.env.GITHUB_REPOSITORY) {
const [owner, repo] = String(value ?? "").split("/");
if (!owner || !repo) {
throw new Error("Repository must be provided as owner/repo (use --repo or GITHUB_REPOSITORY).");
}
return { owner, repo };
}
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return null;
}
}
+669
View File
@@ -0,0 +1,669 @@
#!/usr/bin/env node
// Weekly review operating metrics (github/awesome-copilot#4184, phase 3).
//
// Collects read-only data from the GitHub API, computes review throughput and
// load metrics, and publishes them to a tracking issue. Labels introduced by
// other phases (state and risk tiers) are optional: missing labels are
// reported as "unlabeled" / "unclassified" instead of failing.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import * as yaml from "js-yaml";
import { createGitHubClient, parseRepository } from "./lib/review-automation-github.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const ROOT_FOLDER = path.resolve(__dirname, "..");
export const DEFAULT_CONFIG_PATH = path.join(ROOT_FOLDER, ".github", "review-metrics.yml");
export const TRACKING_MARKER = "<!-- review-metrics-tracking -->";
export const REPORT_MARKER = "<!-- review-metrics-report -->";
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
export const DEFAULT_CONFIG = Object.freeze({
window_days: 7,
targets_business_days: [2, 4],
base_branch: "main",
state_labels: ["awaiting-automation", "requires-submitter-fixes", "ready-for-review", "review-in-progress", "approved"],
risk_labels: ["merge-risk:low", "merge-risk:medium", "merge-risk:high"],
external_plugin_label: "external-plugin",
external_plugin_state_labels: ["awaiting-review", "requires-submitter-fixes", "ready-for-review", "awaiting-approval"],
automation_workflows: [],
tracking_issue: { title: "Review operating metrics", label: "review-metrics" },
});
export function normalizeConfig(raw = {}) {
const input = raw && typeof raw === "object" ? raw : {};
const config = { ...DEFAULT_CONFIG, ...input, tracking_issue: { ...DEFAULT_CONFIG.tracking_issue, ...(input.tracking_issue ?? {}) } };
config.window_days = Math.max(1, Number(config.window_days) || DEFAULT_CONFIG.window_days);
config.targets_business_days = (Array.isArray(config.targets_business_days) ? config.targets_business_days : [config.targets_business_days])
.map(Number)
.filter((value) => Number.isFinite(value) && value > 0)
.sort((a, b) => a - b);
for (const key of ["state_labels", "risk_labels", "external_plugin_state_labels", "automation_workflows"]) {
config[key] = (Array.isArray(config[key]) ? config[key] : []).map(String);
}
return config;
}
export function loadMetricsConfig(filePath = DEFAULT_CONFIG_PATH) {
if (!fs.existsSync(filePath)) return normalizeConfig({});
return normalizeConfig(yaml.load(fs.readFileSync(filePath, "utf8")) ?? {});
}
// ---------------------------------------------------------------------------
// Statistics helpers (pure)
// ---------------------------------------------------------------------------
// Elapsed business time in days between two instants. Saturdays and Sundays
// (UTC) contribute nothing; partial weekdays count fractionally.
export function businessDaysBetween(start, end) {
let from = new Date(start).getTime();
const to = new Date(end).getTime();
if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) return 0;
let total = 0;
while (from < to) {
const dayStart = Math.floor(from / DAY_MS) * DAY_MS;
const dayEnd = Math.min(dayStart + DAY_MS, to);
const weekday = new Date(dayStart).getUTCDay();
if (weekday !== 0 && weekday !== 6) total += (dayEnd - from) / DAY_MS;
from = dayEnd;
}
return total;
}
// Linear-interpolated percentile (same method as numpy's default).
export function percentile(values, p) {
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
if (sorted.length === 0) return null;
const rank = (p / 100) * (sorted.length - 1);
const lower = Math.floor(rank);
const upper = Math.ceil(rank);
return sorted[lower] + (sorted[upper] - sorted[lower]) * (rank - lower);
}
export function summarizeDurations(hours) {
return {
count: hours.length,
median_hours: roundOrNull(percentile(hours, 50)),
p90_hours: roundOrNull(percentile(hours, 90)),
};
}
// Herfindahl-Hirschman index on a 0-10,000 scale plus the top reviewer share.
export function concentration(counts) {
const values = Object.values(counts).filter((value) => value > 0);
const total = values.reduce((sum, value) => sum + value, 0);
if (total === 0) return { total: 0, reviewers: 0, top_share: null, hhi: null, effective_reviewers: null };
const shares = values.map((value) => value / total);
const hhi = shares.reduce((sum, share) => sum + share * share, 0);
return {
total,
reviewers: values.length,
top_share: round(Math.max(...shares), 3),
hhi: Math.round(hhi * 10000),
effective_reviewers: round(1 / hhi, 2),
};
}
function round(value, digits = 1) {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function roundOrNull(value, digits = 1) {
return value === null ? null : round(value, digits);
}
function isBot(actor) {
return !actor || actor.type === "Bot" || /\[bot\]$/i.test(actor.login ?? "");
}
// ---------------------------------------------------------------------------
// Metric computation (pure)
// ---------------------------------------------------------------------------
// First maintainer review submitted at or after the current review clock
// start, so reviews from before a draft/re-ready cycle are not counted.
function firstHumanReview(pr) {
const clockStart = Date.parse(reviewClockStart(pr) ?? "");
const reviews = (pr.reviews ?? [])
.filter((review) => isMaintainerReview(review, pr) && review.submittedAt && review.state !== "PENDING")
.filter((review) => !Number.isFinite(clockStart) || Date.parse(review.submittedAt) >= clockStart)
.sort((a, b) => Date.parse(a.submittedAt) - Date.parse(b.submittedAt));
return reviews[0] ?? null;
}
// Reviews from bots, the PR author, and users without write access do not count.
function isMaintainerReview(review, pr) {
return !isBot(review.author) && review.author?.login !== pr.author?.login && review.authorCanPushToRepository !== false;
}
function reviewClockStart(pr) {
return pr.readyForReviewAt ?? pr.createdAt;
}
function tally(items, labelsFor, buckets, fallback) {
const counts = Object.fromEntries([...buckets, fallback].map((bucket) => [bucket, 0]));
for (const item of items) {
const labels = new Set(labelsFor(item).map((label) => label.toLowerCase()));
const matched = buckets.filter((bucket) => labels.has(bucket.toLowerCase()));
if (matched.length === 0) counts[fallback]++;
for (const bucket of matched) counts[bucket]++;
}
return counts;
}
export function computeMetrics(data, config, { now = new Date() } = {}) {
const nowMs = new Date(now).getTime();
const windowStart = nowMs - config.window_days * DAY_MS;
const inWindow = (value) => {
const time = Date.parse(value ?? "");
return Number.isFinite(time) && time >= windowStart && time <= nowMs;
};
const contributionPrs = (data.openPrs ?? []).filter((pr) => !isBot(pr.author));
const readyPrs = contributionPrs.filter((pr) => !pr.isDraft);
const openIssues = data.externalPluginIssues ?? [];
const open = {
pull_requests: {
total: contributionPrs.length,
drafts: contributionPrs.length - readyPrs.length,
automation_authored_excluded: (data.openPrs ?? []).length - contributionPrs.length,
by_state: tally(readyPrs, (pr) => pr.labels, config.state_labels, "unlabeled"),
by_risk: tally(readyPrs, (pr) => pr.labels, config.risk_labels, "unclassified"),
},
external_plugin_issues: {
total: openIssues.length,
by_state: tally(openIssues, (issue) => issue.labels, config.external_plugin_state_labels, "unlabeled"),
},
};
// Time to first review: PRs whose first human review landed in the window.
const firstReviewHours = [];
// Time to merge: PRs merged in the window.
const mergeHours = [];
const reviewsByMaintainer = {};
const prsByMaintainer = {};
for (const pr of data.windowPrs ?? []) {
if (isBot(pr.author)) continue;
const first = firstHumanReview(pr);
if (first && inWindow(first.submittedAt)) {
firstReviewHours.push((Date.parse(first.submittedAt) - Date.parse(reviewClockStart(pr))) / HOUR_MS);
}
if (pr.mergedAt && inWindow(pr.mergedAt)) {
mergeHours.push((Date.parse(pr.mergedAt) - Date.parse(pr.createdAt)) / HOUR_MS);
}
for (const review of pr.reviews ?? []) {
if (!isMaintainerReview(review, pr) || !inWindow(review.submittedAt)) continue;
const login = review.author.login;
reviewsByMaintainer[login] = (reviewsByMaintainer[login] ?? 0) + 1;
(prsByMaintainer[login] ??= new Set()).add(pr.number);
}
}
const maintainers = Object.keys(reviewsByMaintainer)
.map((login) => ({ login, reviews: reviewsByMaintainer[login], pull_requests: prsByMaintainer[login].size }))
.sort((a, b) => b.pull_requests - a.pull_requests || b.reviews - a.reviews || a.login.localeCompare(b.login));
const prCounts = Object.fromEntries(maintainers.map((maintainer) => [maintainer.login, maintainer.pull_requests]));
// Items waiting on a maintainer beyond the business-day targets.
const waiting = [];
for (const pr of readyPrs) {
const labels = new Set(pr.labels.map((label) => label.toLowerCase()));
if (labels.has("requires-submitter-fixes")) continue;
if (firstHumanReview(pr)) continue;
const since = reviewClockStart(pr);
waiting.push({ kind: "pr", number: pr.number, title: pr.title, url: pr.url, since, business_days: round(businessDaysBetween(since, nowMs), 2) });
}
for (const issue of openIssues) {
const labels = new Set(issue.labels.map((label) => label.toLowerCase()));
if (!labels.has("ready-for-review") && !labels.has("awaiting-approval")) continue;
const since = issue.readyAt ?? issue.createdAt;
waiting.push({ kind: "issue", number: issue.number, title: issue.title, url: issue.url, since, business_days: round(businessDaysBetween(since, nowMs), 2) });
}
waiting.sort((a, b) => b.business_days - a.business_days);
const overdue = Object.fromEntries(
config.targets_business_days.map((target) => {
const items = waiting.filter((item) => item.business_days > target);
return [String(target), { count: items.length, items }];
}),
);
// Automation failure rate over completed runs of the review workflows.
const automation = [];
const automationErrors = [];
let failedTotal = 0;
let consideredTotal = 0;
for (const workflow of data.workflowRuns ?? []) {
if (workflow.error) {
automationErrors.push({ workflow: workflow.file, error: workflow.error });
automation.push({ workflow: workflow.file, found: true, error: workflow.error });
continue;
}
if (!workflow.found) {
automation.push({ workflow: workflow.file, found: false });
continue;
}
const runs = workflow.runs.filter((run) => run.status === "completed" && inWindow(run.created_at));
const considered = runs.filter((run) => !["cancelled", "skipped", "neutral", "action_required", "stale"].includes(run.conclusion));
const failed = considered.filter((run) => ["failure", "timed_out", "startup_failure"].includes(run.conclusion));
failedTotal += failed.length;
consideredTotal += considered.length;
automation.push({
workflow: workflow.file,
name: workflow.name,
found: true,
runs: considered.length,
failed: failed.length,
failure_rate: considered.length ? round(failed.length / considered.length, 3) : null,
});
}
return {
generated_at: new Date(nowMs).toISOString(),
window: { days: config.window_days, start: new Date(windowStart).toISOString(), end: new Date(nowMs).toISOString() },
open,
time_to_first_review: summarizeDurations(firstReviewHours),
time_to_merge: summarizeDurations(mergeHours),
reviews_per_maintainer: maintainers,
reviewer_concentration: { ...concentration(prCounts), basis: "distinct PRs reviewed" },
overdue,
automation: {
failure_rate: consideredTotal ? round(failedTotal / consideredTotal, 3) : null,
failed: failedTotal,
runs: consideredTotal,
incomplete: automationErrors.length > 0,
errors: automationErrors,
workflows: automation,
},
};
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
function formatHours(hours) {
if (hours === null || hours === undefined) return "n/a";
if (hours < 48) return `${round(hours, 1)} h`;
return `${round(hours / 24, 1)} d`;
}
function formatPercent(value) {
return value === null || value === undefined ? "n/a" : `${round(value * 100, 1)}%`;
}
function escapeCell(value) {
return String(value ?? "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ").replace(/@(?=[A-Za-z0-9])/g, "@\u200b");
}
function countsTable(title, counts) {
const lines = [`| ${title} | Count |`, "|---|---:|"];
for (const [key, value] of Object.entries(counts)) lines.push(`| \`${key}\` | ${value} |`);
return lines.join("\n");
}
export function renderReport(metrics, { repository, runUrl } = {}) {
const lines = [REPORT_MARKER, `## Review operating metrics — week ending ${metrics.window.end.slice(0, 10)}`, ""];
lines.push(`Window: ${metrics.window.start.slice(0, 10)} → ${metrics.window.end.slice(0, 10)} (${metrics.window.days} days, UTC). Business-day targets skip weekends.`, "");
const prs = metrics.open.pull_requests;
lines.push("### Open contributions", "");
lines.push(`**${prs.total}** open contribution PRs (${prs.drafts} draft, ${prs.automation_authored_excluded} automation-authored excluded) and **${metrics.open.external_plugin_issues.total}** open external plugin submissions.`, "");
lines.push(countsTable("PR state (non-draft)", prs.by_state), "", countsTable("PR risk tier (non-draft)", prs.by_risk), "");
lines.push(countsTable("External plugin state", metrics.open.external_plugin_issues.by_state), "");
lines.push("### Review speed", "", "| Metric | Samples | Median | p90 |", "|---|---:|---:|---:|");
lines.push(`| Time to first review | ${metrics.time_to_first_review.count} | ${formatHours(metrics.time_to_first_review.median_hours)} | ${formatHours(metrics.time_to_first_review.p90_hours)} |`);
lines.push(`| Time to merge | ${metrics.time_to_merge.count} | ${formatHours(metrics.time_to_merge.median_hours)} | ${formatHours(metrics.time_to_merge.p90_hours)} |`, "");
const conc = metrics.reviewer_concentration;
lines.push("### Reviewer load", "");
if (metrics.reviews_per_maintainer.length === 0) {
lines.push("No maintainer reviews in this window.", "");
} else {
lines.push(`Top reviewer share: **${formatPercent(conc.top_share)}** · HHI: **${conc.hhi}** (0–10,000; >2,500 = highly concentrated) · effective reviewers: **${conc.effective_reviewers}**`, "");
lines.push("| Maintainer | PRs reviewed | Reviews |", "|---|---:|---:|");
for (const maintainer of metrics.reviews_per_maintainer) lines.push(`| ${escapeCell(`@${maintainer.login}`)} | ${maintainer.pull_requests} | ${maintainer.reviews} |`);
lines.push("");
}
lines.push("### Waiting on maintainers", "", "| Target | Items past target |", "|---|---:|");
for (const [target, entry] of Object.entries(metrics.overdue)) lines.push(`| ${target} business days | ${entry.count} |`);
const targets = Object.keys(metrics.overdue);
const firstTarget = targets[0];
if (firstTarget && metrics.overdue[firstTarget].count > 0) {
lines.push("", `<details><summary>Items past ${firstTarget} business days</summary>`, "", "| Item | Waiting (business days) | Title |", "|---|---:|---|");
for (const item of metrics.overdue[firstTarget].items.slice(0, 50)) {
lines.push(`| ${item.kind === "pr" ? "PR" : "Issue"} [#${item.number}](${item.url}) | ${item.business_days} | ${escapeCell(item.title)} |`);
}
if (metrics.overdue[firstTarget].items.length > 50) lines.push(`| … | | ${metrics.overdue[firstTarget].items.length - 50} more |`);
lines.push("", "</details>");
}
lines.push("");
const automation = metrics.automation;
lines.push("### Automation health", "");
lines.push(`Failure rate: **${formatPercent(automation.failure_rate)}${automation.incomplete ? " (incomplete)" : ""}** (${automation.failed} failed of ${automation.runs} completed runs; cancelled/skipped excluded).`, "");
if (automation.incomplete) lines.push("⚠️ Collection was incomplete because one or more workflow APIs returned errors; affected workflows are excluded from the denominator.", "");
lines.push("| Workflow | Runs | Failed | Failure rate |", "|---|---:|---:|---:|");
for (const workflow of automation.workflows) {
if (workflow.error) {
lines.push(`| \`${workflow.workflow}\` | – | – | collection error: ${escapeCell(workflow.error)} |`);
} else {
lines.push(workflow.found ? `| \`${workflow.workflow}\` | ${workflow.runs} | ${workflow.failed} | ${formatPercent(workflow.failure_rate)} |` : `| \`${workflow.workflow}\` | – | – | not found |`);
}
}
lines.push("", `_Generated ${metrics.generated_at}${repository ? ` for ${repository}` : ""}${runUrl ? ` by [this run](${runUrl})` : ""}. Definitions: docs/maintainers/canvas-evidence-and-metrics.md._`);
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Data collection
// ---------------------------------------------------------------------------
const PR_FIELDS = `
id number title url createdAt mergedAt isDraft state
author { login __typename }
labels(first: 50) { nodes { name } }
reviews(first: 100) { pageInfo { hasNextPage endCursor } nodes { state submittedAt authorCanPushToRepository author { login __typename } } }
timelineItems(last: 1, itemTypes: [READY_FOR_REVIEW_EVENT]) { nodes { ... on ReadyForReviewEvent { createdAt } } }
`;
const REVIEW_FIELDS = "state submittedAt authorCanPushToRepository author { login __typename }";
async function searchAll(client, query, fields, maxItems = 1000) {
const items = [];
let cursor = null;
do {
const data = await client.graphql(
`query($q: String!, $cursor: String) {
search(query: $q, type: ISSUE, first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { ... on PullRequest { ${fields} } ... on Issue { ${ISSUE_FIELDS} } }
}
}`,
{ q: query, cursor },
);
items.push(...(data.search.nodes ?? []).filter(Boolean));
cursor = data.search.pageInfo.hasNextPage ? data.search.pageInfo.endCursor : null;
} while (cursor && items.length < maxItems);
return items;
}
async function hydrateReviewPages(client, pullRequests) {
for (const pr of pullRequests) {
let cursor = pr.reviews?.pageInfo?.hasNextPage ? pr.reviews.pageInfo.endCursor : null;
while (cursor) {
const data = await client.graphql(
`query($id: ID!, $cursor: String) {
node(id: $id) {
... on PullRequest {
reviews(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { ${REVIEW_FIELDS} }
}
}
}
}`,
{ id: pr.id, cursor },
);
const page = data.node?.reviews;
if (!page) break;
pr.reviews.nodes.push(...(page.nodes ?? []));
cursor = page.pageInfo?.hasNextPage ? page.pageInfo.endCursor : null;
}
}
return pullRequests;
}
async function hydrateIssueLabelEventPages(client, issues) {
for (const issue of issues) {
let cursor = issue.timelineItems?.pageInfo?.hasPreviousPage ? issue.timelineItems.pageInfo.startCursor : null;
while (cursor) {
const data = await client.graphql(
`query($id: ID!, $cursor: String) {
node(id: $id) {
... on Issue {
timelineItems(last: 100, before: $cursor, itemTypes: [LABELED_EVENT]) {
pageInfo { hasPreviousPage startCursor }
nodes { ... on LabeledEvent { createdAt label { name } } }
}
}
}
}`,
{ id: issue.id, cursor },
);
const page = data.node?.timelineItems;
if (!page) break;
issue.timelineItems.nodes.unshift(...(page.nodes ?? []));
cursor = page.pageInfo?.hasPreviousPage ? page.pageInfo.startCursor : null;
}
}
return issues;
}
const ISSUE_FIELDS = `
id number title url createdAt
labels(first: 50) { nodes { name } }
timelineItems(last: 100, itemTypes: [LABELED_EVENT]) {
pageInfo { hasPreviousPage startCursor }
nodes { ... on LabeledEvent { createdAt label { name } } }
}
`;
function normalizePr(node) {
return {
number: node.number,
title: node.title,
url: node.url,
createdAt: node.createdAt,
mergedAt: node.mergedAt,
isDraft: node.isDraft,
readyForReviewAt: node.timelineItems?.nodes?.[0]?.createdAt ?? null,
author: node.author ? { login: node.author.login, type: node.author.__typename } : null,
labels: (node.labels?.nodes ?? []).map((label) => label.name),
reviews: (node.reviews?.nodes ?? []).map((review) => ({
state: review.state,
submittedAt: review.submittedAt,
authorCanPushToRepository: review.authorCanPushToRepository,
author: review.author ? { login: review.author.login, type: review.author.__typename } : null,
})),
};
}
function normalizeIssue(node) {
const readyEvents = (node.timelineItems?.nodes ?? []).filter((event) => ["ready-for-review", "awaiting-approval"].includes(event?.label?.name));
return {
number: node.number,
title: node.title,
url: node.url,
createdAt: node.createdAt,
readyAt: readyEvents.length ? readyEvents[readyEvents.length - 1].createdAt : null,
labels: (node.labels?.nodes ?? []).map((label) => label.name),
};
}
async function fetchWorkflowRuns(client, { owner, repo }, file, since) {
const probe = await client.request("GET", `/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(file)}`, { allowStatuses: [404] });
if (probe.status === 404) return { file, found: false, runs: [] };
const runs = await client.paginate(`/repos/${owner}/${repo}/actions/workflows/${encodeURIComponent(file)}/runs`, {
query: { created: `>=${since.slice(0, 10)}`, exclude_pull_requests: true },
itemsKey: "workflow_runs",
maxPages: 10,
});
return {
file,
found: true,
name: probe.data?.name,
runs: runs.map((run) => ({ status: run.status, conclusion: run.conclusion, created_at: run.created_at })),
};
}
export async function collectData(client, repository, config, { now = new Date() } = {}) {
const repo = `${repository.owner}/${repository.repo}`;
const since = new Date(new Date(now).getTime() - config.window_days * DAY_MS).toISOString();
const sinceDate = since.slice(0, 10);
const openPrNodes = await hydrateReviewPages(client, await searchAll(client, `repo:${repo} is:pr is:open base:${config.base_branch}`, PR_FIELDS));
const windowPrNodes = await hydrateReviewPages(client, await searchAll(client, `repo:${repo} is:pr base:${config.base_branch} updated:>=${sinceDate}`, PR_FIELDS));
const openPrs = openPrNodes.map(normalizePr);
const windowPrs = windowPrNodes.map(normalizePr);
let externalPluginIssues = [];
if (config.external_plugin_label) {
const externalPluginIssueNodes = await searchAll(
client,
`repo:${repo} is:issue is:open label:"${config.external_plugin_label}"`,
PR_FIELDS,
);
externalPluginIssues = (await hydrateIssueLabelEventPages(client, externalPluginIssueNodes)).map(normalizeIssue);
}
const workflowRuns = [];
for (const file of config.automation_workflows) {
try {
workflowRuns.push(await fetchWorkflowRuns(client, repository, file, since));
} catch (error) {
console.warn(`Could not read runs for ${file}: ${error.message}`);
workflowRuns.push({ file, found: true, runs: [], error: error.message });
}
}
return { openPrs, windowPrs, externalPluginIssues, workflowRuns };
}
// ---------------------------------------------------------------------------
// Publishing
// ---------------------------------------------------------------------------
async function ensureLabel(client, { owner, repo }, name) {
const existing = await client.request("GET", `/repos/${owner}/${repo}/labels/${encodeURIComponent(name)}`, { allowStatuses: [404] });
if (existing.status === 404) {
await client.request("POST", `/repos/${owner}/${repo}/labels`, {
body: { name, color: "C5DEF5", description: "Weekly review operating metrics tracking issue" },
allowStatuses: [422],
});
}
}
export function renderTrackingBody(report) {
return [
TRACKING_MARKER,
"This issue tracks weekly review operating metrics for contribution review (#4184).",
"It is updated automatically by `.github/workflows/review-metrics.yml`; each week's report is also posted as a comment so trends stay visible.",
"",
"---",
"",
report,
].join("\n");
}
export async function publishReport(client, repository, config, report) {
const { owner, repo } = repository;
const label = config.tracking_issue.label;
await ensureLabel(client, repository, label);
const issues = await client.paginate(`/repos/${owner}/${repo}/issues`, { query: { state: "open", labels: label }, maxPages: 5 });
let issue = issues.find((candidate) => !candidate.pull_request && candidate.body?.includes(TRACKING_MARKER));
const body = renderTrackingBody(report);
if (issue) {
await client.request("PATCH", `/repos/${owner}/${repo}/issues/${issue.number}`, { body: { body } });
} else {
issue = (await client.request("POST", `/repos/${owner}/${repo}/issues`, { body: { title: config.tracking_issue.title, body, labels: [label] } })).data;
try {
await client.graphql(`mutation($id: ID!) { pinIssue(input: { issueId: $id }) { issue { number } } }`, { id: issue.node_id });
} catch (error) {
console.warn(`Could not pin tracking issue #${issue.number}: ${error.message}`);
}
}
await client.request("POST", `/repos/${owner}/${repo}/issues/${issue.number}/comments`, { body: { body: report } });
return issue;
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export function parseArgs(argv) {
const options = {
config: DEFAULT_CONFIG_PATH,
repo: process.env.GITHUB_REPOSITORY,
dryRun: false,
outputDir: null,
summaryFile: process.env.GITHUB_STEP_SUMMARY,
now: new Date(),
windowDays: null,
runUrl: process.env.METRICS_RUN_URL || null,
};
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
const next = () => {
const value = argv[++index];
if (value === undefined) throw new Error(`Missing value for ${arg}`);
return value;
};
switch (arg) {
case "--config":
options.config = next();
break;
case "--repo":
options.repo = next();
break;
case "--dry-run":
options.dryRun = true;
break;
case "--output-dir":
options.outputDir = next();
break;
case "--summary-file":
options.summaryFile = next();
break;
case "--now": {
const value = new Date(next());
if (Number.isNaN(value.getTime())) throw new Error("--now expects an ISO date");
options.now = value;
break;
}
case "--window-days":
options.windowDays = Number(next());
if (!Number.isFinite(options.windowDays) || options.windowDays <= 0) throw new Error("--window-days expects a positive number");
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const config = loadMetricsConfig(options.config);
if (options.windowDays) config.window_days = options.windowDays;
const repository = parseRepository(options.repo);
const client = createGitHubClient();
const data = await collectData(client, repository, config, { now: options.now });
const metrics = computeMetrics(data, config, { now: options.now });
const report = renderReport(metrics, { repository: `${repository.owner}/${repository.repo}`, runUrl: options.runUrl });
if (options.outputDir) {
fs.mkdirSync(options.outputDir, { recursive: true });
fs.writeFileSync(path.join(options.outputDir, "metrics.json"), `${JSON.stringify(metrics, null, 2)}\n`);
fs.writeFileSync(path.join(options.outputDir, "report.md"), `${report}\n`);
}
if (options.summaryFile) fs.appendFileSync(options.summaryFile, `${report}\n`);
if (options.dryRun) {
if (!options.summaryFile) console.log(report);
console.log("Dry run: tracking issue not updated.");
return;
}
const issue = await publishReport(client, repository, config, report);
console.log(`Published review metrics to ${issue.html_url ?? `#${issue.number}`}`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
+297
View File
@@ -0,0 +1,297 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
businessDaysBetween,
collectData,
computeMetrics,
concentration,
loadMetricsConfig,
normalizeConfig,
parseArgs,
percentile,
renderReport,
renderTrackingBody,
TRACKING_MARKER,
} from "./review-metrics.mjs";
test("business days skip weekends and count partial weekdays", () => {
// 2025-01-03 is a Friday.
assert.equal(businessDaysBetween("2025-01-03T00:00:00Z", "2025-01-06T00:00:00Z"), 1);
assert.equal(businessDaysBetween("2025-01-04T00:00:00Z", "2025-01-06T00:00:00Z"), 0);
assert.equal(businessDaysBetween("2025-01-03T12:00:00Z", "2025-01-06T12:00:00Z"), 1);
assert.equal(businessDaysBetween("2025-01-06T00:00:00Z", "2025-01-13T00:00:00Z"), 5);
assert.equal(businessDaysBetween("2025-01-06T00:00:00Z", "2025-01-06T06:00:00Z"), 0.25);
assert.equal(businessDaysBetween("2025-01-06T00:00:00Z", "2025-01-05T00:00:00Z"), 0);
});
test("percentiles interpolate linearly and ignore invalid values", () => {
assert.equal(percentile([], 50), null);
assert.equal(percentile([5], 90), 5);
assert.equal(percentile([1, 2, 3, 4], 50), 2.5);
assert.equal(percentile([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 90), 9.1);
assert.equal(percentile([3, NaN, 1, 2], 50), 2);
});
test("reviewer concentration reports top share and HHI", () => {
assert.deepEqual(concentration({}), { total: 0, reviewers: 0, top_share: null, hhi: null, effective_reviewers: null });
const single = concentration({ a: 4 });
assert.equal(single.hhi, 10000);
assert.equal(single.top_share, 1);
const even = concentration({ a: 5, b: 5, c: 5, d: 5 });
assert.equal(even.hhi, 2500);
assert.equal(even.top_share, 0.25);
assert.equal(even.effective_reviewers, 4);
});
const config = normalizeConfig({ automation_workflows: ["a.yml", "missing.yml"] });
const now = "2025-01-10T12:00:00Z"; // Friday
const maintainer = (login, submittedAt, state = "APPROVED") => ({ state, submittedAt, authorCanPushToRepository: true, author: { login, type: "User" } });
function fixture() {
return {
openPrs: [
{ number: 1, title: "Waiting a while", url: "u1", createdAt: "2025-01-03T12:00:00Z", isDraft: false, author: { login: "alice", type: "User" }, labels: ["ready-for-review", "merge-risk:low"], reviews: [] },
{ number: 2, title: "Needs fixes", url: "u2", createdAt: "2025-01-02T12:00:00Z", isDraft: false, author: { login: "bob", type: "User" }, labels: ["requires-submitter-fixes"], reviews: [] },
{ number: 3, title: "Reviewed", url: "u3", createdAt: "2025-01-02T12:00:00Z", isDraft: false, author: { login: "carol", type: "User" }, labels: [], reviews: [maintainer("m1", "2025-01-06T12:00:00Z", "COMMENTED")] },
{ number: 4, title: "Draft", url: "u4", createdAt: "2025-01-09T12:00:00Z", isDraft: true, author: { login: "dave", type: "User" }, labels: [], reviews: [] },
{ number: 5, title: "Bot PR", url: "u5", createdAt: "2025-01-09T12:00:00Z", isDraft: false, author: { login: "github-actions", type: "Bot" }, labels: [], reviews: [] },
{ number: 6, title: "Fresh", url: "u6", createdAt: "2025-01-09T12:00:00Z", isDraft: false, author: { login: "erin", type: "User" }, labels: ["merge-risk:high"], reviews: [] },
],
windowPrs: [
{ number: 10, createdAt: "2025-01-06T00:00:00Z", mergedAt: "2025-01-07T00:00:00Z", author: { login: "x", type: "User" }, labels: [], reviews: [maintainer("m1", "2025-01-06T02:00:00Z"), maintainer("m1", "2025-01-06T05:00:00Z")] },
{ number: 11, createdAt: "2025-01-06T00:00:00Z", mergedAt: "2025-01-09T00:00:00Z", author: { login: "y", type: "User" }, labels: [], reviews: [maintainer("m2", "2025-01-06T10:00:00Z"), maintainer("y", "2025-01-06T01:00:00Z"), { ...maintainer("bot", "2025-01-06T00:30:00Z"), author: { login: "copilot", type: "Bot" } }] },
{ number: 12, createdAt: "2024-12-01T00:00:00Z", mergedAt: null, author: { login: "z", type: "User" }, labels: [], reviews: [maintainer("m1", "2024-12-02T00:00:00Z")] },
{ number: 13, createdAt: "2025-01-08T00:00:00Z", mergedAt: null, author: { login: "w", type: "User" }, labels: [], reviews: [{ ...maintainer("drive-by", "2025-01-08T01:00:00Z"), authorCanPushToRepository: false }] },
],
externalPluginIssues: [
{ number: 20, title: "Plugin", url: "i20", createdAt: "2024-12-20T00:00:00Z", readyAt: "2025-01-03T00:00:00Z", labels: ["external-plugin", "ready-for-review"] },
{ number: 21, title: "Plugin fixes", url: "i21", createdAt: "2024-12-20T00:00:00Z", labels: ["external-plugin", "requires-submitter-fixes"] },
],
workflowRuns: [
{
file: "a.yml",
found: true,
name: "A",
runs: [
{ status: "completed", conclusion: "success", created_at: "2025-01-08T00:00:00Z" },
{ status: "completed", conclusion: "failure", created_at: "2025-01-08T00:00:00Z" },
{ status: "completed", conclusion: "startup_failure", created_at: "2025-01-08T00:00:00Z" },
{ status: "completed", conclusion: "cancelled", created_at: "2025-01-08T00:00:00Z" },
{ status: "completed", conclusion: "skipped", created_at: "2025-01-08T00:00:00Z" },
{ status: "in_progress", conclusion: null, created_at: "2025-01-08T00:00:00Z" },
{ status: "completed", conclusion: "failure", created_at: "2024-12-01T00:00:00Z" },
],
},
{ file: "missing.yml", found: false, runs: [] },
],
};
}
test("computes open contribution breakdowns with graceful label fallbacks", () => {
const metrics = computeMetrics(fixture(), config, { now });
const prs = metrics.open.pull_requests;
assert.equal(prs.total, 5, "bot PRs are excluded");
assert.equal(prs.drafts, 1);
assert.equal(prs.automation_authored_excluded, 1);
assert.equal(prs.by_state["ready-for-review"], 1);
assert.equal(prs.by_state["requires-submitter-fixes"], 1);
assert.equal(prs.by_state.unlabeled, 2);
assert.equal(prs.by_risk["merge-risk:low"], 1);
assert.equal(prs.by_risk["merge-risk:high"], 1);
assert.equal(prs.by_risk.unclassified, 2);
assert.equal(metrics.open.external_plugin_issues.by_state["ready-for-review"], 1);
});
test("computes review speed, maintainer load, and concentration", () => {
const metrics = computeMetrics(fixture(), config, { now });
// First maintainer reviews in window: #10 after 2h, #11 after 10h. Author, bot,
// and non-maintainer reviews (#13) are ignored.
assert.equal(metrics.time_to_first_review.count, 2);
assert.equal(metrics.time_to_first_review.median_hours, 6);
assert.equal(metrics.time_to_merge.count, 2);
assert.equal(metrics.time_to_merge.median_hours, 48);
assert.deepEqual(
metrics.reviews_per_maintainer.map((item) => [item.login, item.pull_requests, item.reviews]),
[["m1", 1, 2], ["m2", 1, 1]],
);
assert.equal(metrics.reviewer_concentration.top_share, 0.5);
assert.equal(metrics.reviewer_concentration.hhi, 5000);
});
test("flags items past business-day targets", () => {
const metrics = computeMetrics(fixture(), config, { now });
// #1 waits 5 business days, #6 waits 1, issue #20 waits 5.5; #2 needs submitter fixes; #3 was reviewed.
assert.deepEqual(metrics.overdue["2"].items.map((item) => `${item.kind}#${item.number}`), ["issue#20", "pr#1"]);
assert.equal(metrics.overdue["4"].count, 2);
});
test("computes automation failure rate over completed, non-cancelled runs", () => {
const metrics = computeMetrics(fixture(), config, { now });
assert.equal(metrics.automation.runs, 3);
assert.equal(metrics.automation.failed, 2);
assert.equal(metrics.automation.failure_rate, 0.667);
assert.equal(metrics.automation.workflows[1].found, false);
});
test("automation API errors are reported as incomplete instead of not found", () => {
const data = fixture();
data.workflowRuns.push({ file: "error.yml", found: true, runs: [], error: "server unavailable" });
const metrics = computeMetrics(data, config, { now });
assert.equal(metrics.automation.incomplete, true);
assert.deepEqual(metrics.automation.errors, [{ workflow: "error.yml", error: "server unavailable" }]);
assert.equal(metrics.automation.runs, 3);
assert.equal(metrics.automation.failed, 2);
assert.equal(metrics.automation.failure_rate, 0.667);
const report = renderReport(metrics, {});
assert.match(report, /Failure rate: \*\*66\.7% \(incomplete\)\*\*/);
assert.match(report, /`error\.yml` \| – \| – \| collection error: server unavailable/);
});
test("collectData paginates PR reviews beyond the first 100", async () => {
const pageOneReviews = Array.from({ length: 100 }, (_, index) => maintainer(`m${index}`, "2025-01-06T01:00:00Z"));
const pageTwoReview = maintainer("last", "2025-01-06T02:00:00Z");
const client = {
graphql: async (query) => {
if (query.includes("search(")) {
return {
search: {
pageInfo: { hasNextPage: false, endCursor: null },
nodes: [{
id: "PR_1",
number: 1,
title: "Many reviews",
url: "https://example.test/pull/1",
createdAt: "2025-01-06T00:00:00Z",
mergedAt: null,
isDraft: false,
author: { login: "author", __typename: "User" },
labels: { nodes: [] },
reviews: { pageInfo: { hasNextPage: true, endCursor: "page-1" }, nodes: [...pageOneReviews] },
timelineItems: { nodes: [] },
}],
},
};
}
assert.match(query, /node\(id: \$id\)/);
return {
node: {
reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [pageTwoReview] },
},
};
},
request: async () => { throw new Error("request should not be called"); },
paginate: async () => { throw new Error("paginate should not be called"); },
};
const data = await collectData(client, { owner: "o", repo: "r" }, normalizeConfig({ automation_workflows: [], external_plugin_label: "" }), { now });
assert.equal(data.openPrs[0].reviews.length, 101);
assert.equal(data.windowPrs[0].reviews.length, 101);
assert.equal(data.windowPrs[0].reviews[100].author.login, "last");
});
test("collectData paginates external plugin issue label events", async () => {
let searchCalls = 0;
const client = {
graphql: async (query, variables) => {
if (query.includes("search(")) {
searchCalls++;
if (searchCalls <= 2) {
return { search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] } };
}
return {
search: {
pageInfo: { hasNextPage: false, endCursor: null },
nodes: [{
id: "ISSUE_1",
number: 42,
title: "Long-lived plugin",
url: "https://example.test/issues/42",
createdAt: "2024-01-01T00:00:00Z",
labels: { nodes: [{ name: "external-plugin" }, { name: "ready-for-review" }] },
timelineItems: {
pageInfo: { hasPreviousPage: true, startCursor: "recent-page" },
nodes: [{ createdAt: "2025-01-09T00:00:00Z", label: { name: "triage" } }],
},
}],
},
};
}
assert.equal(variables.id, "ISSUE_1");
assert.equal(variables.cursor, "recent-page");
return {
node: {
timelineItems: {
pageInfo: { hasPreviousPage: false, startCursor: null },
nodes: [{ createdAt: "2025-01-03T00:00:00Z", label: { name: "ready-for-review" } }],
},
},
};
},
request: async () => { throw new Error("request should not be called"); },
paginate: async () => { throw new Error("paginate should not be called"); },
};
const data = await collectData(
client,
{ owner: "o", repo: "r" },
normalizeConfig({ automation_workflows: [], external_plugin_label: "external-plugin" }),
{ now },
);
assert.equal(data.externalPluginIssues[0].readyAt, "2025-01-03T00:00:00Z");
});
test("handles an empty repository without errors", () => {
const metrics = computeMetrics({}, config, { now });
assert.equal(metrics.time_to_first_review.median_hours, null);
assert.equal(metrics.automation.failure_rate, null);
const report = renderReport(metrics, {});
assert.match(report, /No maintainer reviews/);
assert.match(report, /n\/a/);
});
test("renders a report and tracking issue body", () => {
const metrics = computeMetrics(fixture(), config, { now });
const report = renderReport(metrics, { repository: "o/r", runUrl: "https://example.test/run" });
assert.match(report, /Review operating metrics — week ending 2025-01-10/);
assert.match(report, /Time to first review \| 2 \| 6 h/);
assert.match(report, /HHI: \*\*5000\*\*/);
assert.match(report, /`missing.yml` \| – \| – \| not found/);
assert.match(report, /@\u200bm1/, "mentions are neutralized");
assert.ok(renderTrackingBody(report).startsWith(TRACKING_MARKER));
});
test("config and CLI parsing", () => {
const loaded = loadMetricsConfig();
assert.equal(loaded.window_days, 7);
assert.deepEqual(loaded.targets_business_days, [2, 4]);
assert.ok(loaded.automation_workflows.includes("canvas-smoke-test.yml"));
assert.equal(loadMetricsConfig("does-not-exist.yml").tracking_issue.label, "review-metrics");
const options = parseArgs(["--repo", "o/r", "--dry-run", "--now", "2025-01-10T00:00:00Z", "--window-days", "14"]);
assert.equal(options.dryRun, true);
assert.equal(options.windowDays, 14);
assert.equal(options.now.toISOString(), "2025-01-10T00:00:00.000Z");
assert.throws(() => parseArgs(["--now", "nope"]), /ISO date/);
assert.throws(() => parseArgs(["--bogus"]), /Unknown argument/);
});
test("time to first review ignores reviews submitted before the PR was ready", () => {
const data = {
openPrs: [],
externalPluginIssues: [],
workflowRuns: [],
windowPrs: [
{
number: 30,
createdAt: "2025-01-06T00:00:00Z",
readyForReviewAt: "2025-01-07T00:00:00Z",
mergedAt: null,
author: { login: "x", type: "User" },
labels: [],
reviews: [maintainer("m1", "2025-01-06T01:00:00Z", "COMMENTED"), maintainer("m2", "2025-01-07T04:00:00Z")],
},
],
};
const metrics = computeMetrics(data, config, { now });
assert.equal(metrics.time_to_first_review.count, 1);
assert.equal(metrics.time_to_first_review.median_hours, 4);
});