@@ -3,10 +3,18 @@ name: PR Visual Recap
33# Visual code review: a coding agent runs the repo's visual-recap skill over the
44# PR diff, publishes a plan, and upserts one sticky comment with a screenshot.
55# Plain `pull_request` (NOT `pull_request_target`) so fork code never sees secrets.
6+ #
7+ # Cost policy: recaps auto-run only for LARGE changes — at least
8+ # VISUAL_RECAP_MIN_FILES changed files OR VISUAL_RECAP_MIN_LINES changed lines
9+ # (defaults 20 / 600, overridable via repo variables). Smaller PRs skip
10+ # silently in the gate job, BEFORE any model invocation or spend. Adding the
11+ # `visual-recap` label forces a run at any size; remove and re-add the label
12+ # to regenerate. `synchronize` is intentionally NOT a trigger (no regeneration
13+ # on every push), and `closed` was removed with it (no regeneration on merge).
614
715on :
816 pull_request :
9- types : [opened, synchronize, reopened, ready_for_review, closed ]
17+ types : [opened, reopened, ready_for_review, labeled ]
1018
1119permissions :
1220 contents : read
@@ -27,14 +35,17 @@ jobs:
2735 # Hard gate, evaluated before any step runs: only same-repository branches
2836 # from trusted authors (OWNER/MEMBER/COLLABORATOR) start this workflow.
2937 # Fork PRs, bot PRs (Dependabot etc.), and drafts are skipped entirely —
30- # no secrets, no comments, no red checks. The gate step below additionally
38+ # no secrets, no comments, no red checks. For `labeled` events, only the
39+ # `visual-recap` label starts the job — every other label addition skips
40+ # the whole workflow with zero noise. The gate step below additionally
3141 # no-ops gracefully while the recap secrets are not yet configured, so this
3242 # file can merge before any API key exists.
3343 if : >-
3444 github.event.pull_request.head.repo.full_name == github.repository &&
3545 contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) &&
3646 github.event.pull_request.user.type != 'Bot' &&
37- github.event.pull_request.draft == false
47+ github.event.pull_request.draft == false &&
48+ (github.event.action != 'labeled' || github.event.label.name == 'visual-recap')
3849 # A custom plain-label runner is allowed only for trusted same-repo authors.
3950 # Fork and untrusted PRs are forced onto GitHub-hosted ubuntu-latest before
4051 # any step starts. The only fromJSON input is this static association list.
6273 VISUAL_RECAP_MODEL : ${{ vars.VISUAL_RECAP_MODEL }}
6374 VISUAL_RECAP_RUNS_ON : ${{ vars.VISUAL_RECAP_RUNS_ON || '"ubuntu-latest"' }}
6475 VISUAL_RECAP_SKILL_SOURCE : ${{ env.VISUAL_RECAP_SKILL_SOURCE }}
76+ # Auto-run size thresholds (cost gate). Overridable via repo variables.
77+ VISUAL_RECAP_MIN_FILES : ${{ vars.VISUAL_RECAP_MIN_FILES || '20' }}
78+ VISUAL_RECAP_MIN_LINES : ${{ vars.VISUAL_RECAP_MIN_LINES || '600' }}
6579 HEAD_SHA : ${{ github.event.pull_request.head.sha }}
6680 with :
6781 script : |
7084
7185 if (!pr) reasons.push('no pull_request payload');
7286 if (pr && pr.draft) reasons.push('draft PR');
73- if (pr && context.payload.action === 'closed' && !pr.merged) {
74- reasons.push('closed without merge');
87+
88+ // Cost gate: auto-run only for large changes. The `visual-recap`
89+ // label forces a run at any size (labeled events reach this job
90+ // only for that exact label, per the job-level if — re-adding the
91+ // label after removing it re-fires this workflow and regenerates
92+ // the recap). Thresholds come from repo variables
93+ // VISUAL_RECAP_MIN_FILES / VISUAL_RECAP_MIN_LINES (defaults
94+ // 20 files / 600 changed lines). Size stats are the raw GitHub PR
95+ // numbers from the event payload (lockfiles included) — no API
96+ // call, no checkout, and the recap job (and every model
97+ // invocation) is skipped entirely when the gate says no.
98+ const parseThreshold = (raw, fallback) => {
99+ const value = Number.parseInt(String(raw == null ? '' : raw).trim(), 10);
100+ return Number.isInteger(value) && value >= 0 ? value : fallback;
101+ };
102+ const minFiles = parseThreshold(process.env.VISUAL_RECAP_MIN_FILES, 20);
103+ const minLines = parseThreshold(process.env.VISUAL_RECAP_MIN_LINES, 600);
104+ const labelNames = ((pr && pr.labels) || []).map((l) => ((l && l.name) || '').toLowerCase());
105+ const hasRecapLabel = labelNames.includes('visual-recap');
106+ const changedFiles = (pr && pr.changed_files) || 0;
107+ const changedLines = ((pr && pr.additions) || 0) + ((pr && pr.deletions) || 0);
108+ let skippedBySize = false;
109+ if (pr && !hasRecapLabel && changedFiles < minFiles && changedLines < minLines) {
110+ skippedBySize = true;
111+ reasons.push(`below auto-run size threshold (${changedFiles} files / ${changedLines} changed lines; auto-runs at >= ${minFiles} files or >= ${minLines} lines) — add the "visual-recap" label to generate a recap anyway`);
75112 }
76113
77114 // Fork PRs only receive repo secrets when the org/repo opts into
@@ -195,8 +232,11 @@ jobs:
195232 }
196233
197234 // When skipping, upsert a sticky recap comment with a short skip
198- // line so the PR always explains why the recap job did not run.
199- if (!run && pr) {
235+ // line so the PR explains why the recap job did not run — EXCEPT
236+ // when the size gate skipped it: size skips are the expected
237+ // outcome for most PRs, so they stay silent (notice annotation
238+ // only, no comment spam on every small PR).
239+ if (!run && pr && !skippedBySize) {
200240 try {
201241 const MARKER = '<!-- pr-visual-recap -->';
202242 const { data: comments } = await github.rest.issues.listComments({
0 commit comments