ci: label pull requests by size (#3560)
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Contract tests for the pull request size label workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "pr-size-label.yml"
|
||||
LINT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml"
|
||||
|
||||
|
||||
def extract_function(source: str, name: str) -> str:
|
||||
"""Return one complete JavaScript function from the trusted workflow."""
|
||||
|
||||
marker = f"function {name}("
|
||||
start = source.find(marker)
|
||||
if start < 0:
|
||||
raise AssertionError(f"{name} is missing")
|
||||
|
||||
body_start = source.find("{", start)
|
||||
if body_start < 0:
|
||||
raise AssertionError(f"{name} has no body")
|
||||
|
||||
depth = 0
|
||||
quote: str | None = None
|
||||
escaped = False
|
||||
for index in range(body_start, len(source)):
|
||||
character = source[index]
|
||||
if quote is not None:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif character == "\\":
|
||||
escaped = True
|
||||
elif character == quote:
|
||||
quote = None
|
||||
continue
|
||||
|
||||
if character in {"'", '"', "`"}:
|
||||
quote = character
|
||||
elif character == "{":
|
||||
depth += 1
|
||||
elif character == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[start : index + 1]
|
||||
|
||||
raise AssertionError(f"{name} has an incomplete body")
|
||||
|
||||
|
||||
def extract_call_argument(source: str, call: str) -> str:
|
||||
"""Return one balanced array/object expression passed to a call."""
|
||||
|
||||
call_start = source.find(call)
|
||||
if call_start < 0:
|
||||
raise AssertionError(f"{call} is missing")
|
||||
|
||||
expression_start = call_start + len(call)
|
||||
while expression_start < len(source) and source[expression_start].isspace():
|
||||
expression_start += 1
|
||||
|
||||
opening = source[expression_start]
|
||||
closing = {"[": "]", "{": "}"}.get(opening)
|
||||
if closing is None:
|
||||
raise AssertionError(f"{call} has no array/object argument")
|
||||
|
||||
depth = 0
|
||||
quote: str | None = None
|
||||
escaped = False
|
||||
for index in range(expression_start, len(source)):
|
||||
character = source[index]
|
||||
if quote is not None:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif character == "\\":
|
||||
escaped = True
|
||||
elif character == quote:
|
||||
quote = None
|
||||
continue
|
||||
|
||||
if character in {"'", '"', "`"}:
|
||||
quote = character
|
||||
elif character == opening:
|
||||
depth += 1
|
||||
elif character == closing:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[expression_start : index + 1]
|
||||
|
||||
raise AssertionError(f"{call} has an incomplete argument")
|
||||
|
||||
|
||||
def run_javascript(function_source: str, function_name: str, values: list[object]) -> object:
|
||||
"""Execute a pure workflow helper without loading the GitHub runtime."""
|
||||
|
||||
script = (
|
||||
f"{function_source}\n"
|
||||
f"process.stdout.write(JSON.stringify({function_name}(...JSON.parse(process.argv[1]))));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "-e", script, json.dumps(values)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(result.stderr or result.stdout)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def run_javascript_for_each(
|
||||
function_source: str, function_name: str, values: list[object]
|
||||
) -> object:
|
||||
"""Execute a pure workflow helper once for every value."""
|
||||
|
||||
script = (
|
||||
f"{function_source}\n"
|
||||
"const values = JSON.parse(process.argv[1]);"
|
||||
f"process.stdout.write(JSON.stringify(values.map(value => {function_name}(value))));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "-e", script, json.dumps(values)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(result.stderr or result.stdout)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
class PullRequestSizeLabelWorkflowContractTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.assertTrue(WORKFLOW.is_file(), "Pull request size workflow is missing")
|
||||
self.workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||
|
||||
# @covers AC-CI-PR-SIZE-001.1, AC-CI-PR-SIZE-001.9
|
||||
def test_recalculates_on_current_pull_request_events_and_serializes_by_number(self) -> None:
|
||||
trigger = self.workflow.partition("on:\n")[2].partition("\nconcurrency:")[0]
|
||||
self.assertEqual(
|
||||
trigger,
|
||||
" pull_request_target: # zizmor: ignore[dangerous-triggers] "
|
||||
"base-controlled metadata-only workflow\n"
|
||||
" types: [opened, reopened, synchronize]\n",
|
||||
)
|
||||
self.assertRegex(
|
||||
self.workflow,
|
||||
r"group: pr-size-label-\$\{\{ github\.event\.pull_request\.number \}\}",
|
||||
)
|
||||
self.assertIn("cancel-in-progress: false", self.workflow)
|
||||
|
||||
# @covers AC-CI-PR-SIZE-001.2
|
||||
def test_classifier_assigns_boundaries_and_confirmed_examples(self) -> None:
|
||||
classifier = extract_function(self.workflow, "classifySize")
|
||||
expected_boundaries = {
|
||||
0: "small",
|
||||
10: "small",
|
||||
11: "medium",
|
||||
50: "medium",
|
||||
51: "big",
|
||||
}
|
||||
for count, expected in expected_boundaries.items():
|
||||
with self.subTest(count=count):
|
||||
self.assertEqual(run_javascript(classifier, "classifySize", [count]), expected)
|
||||
|
||||
examples = {
|
||||
"small": (2, 7),
|
||||
"medium": (15, 17, 41),
|
||||
"big": (80, 96),
|
||||
}
|
||||
for expected, counts in examples.items():
|
||||
for count in counts:
|
||||
with self.subTest(expected=expected, count=count):
|
||||
self.assertEqual(run_javascript(classifier, "classifySize", [count]), expected)
|
||||
|
||||
# @covers AC-CI-PR-SIZE-001.3, AC-CI-PR-SIZE-001.4
|
||||
def test_file_classifier_counts_application_and_translation_paths_only(self) -> None:
|
||||
classifier = extract_function(self.workflow, "isCountedFile")
|
||||
counted = [
|
||||
"apps/backend/internal/workflow/workflow.go",
|
||||
"apps/backend/internal/workflow/workflow_test.go",
|
||||
"apps/backend/internal/common/scripts/scripts.go",
|
||||
"apps/web/src/components/task/task.tsx",
|
||||
"apps/web/src/vite-preload-recovery.ts",
|
||||
"apps/web/src/vite-preload-recovery.test.ts",
|
||||
"apps/web/vitest-environment.test.tsx",
|
||||
"apps/web/vitest-monaco-editor.test.ts",
|
||||
"apps/web/src/locales/en/common.json",
|
||||
"apps/web/src/locales/zh-cn/common.json",
|
||||
"apps/desktop/src-tauri/src/main.rs",
|
||||
]
|
||||
excluded = [
|
||||
"docs/specs/ci/requirements/pull-request-size-labels.md",
|
||||
".github/workflows/pr-size-label.yml",
|
||||
"scripts/pr-size-label.py",
|
||||
"apps/web/src/assets/icon.svg",
|
||||
"apps/web/src/assets/compiled.ts",
|
||||
"apps/web/lib/assets/icons.ts",
|
||||
"apps/web/public/style.css",
|
||||
"apps/backend/internal/notifications/providers/assets/icon.ts",
|
||||
"apps/desktop/src-tauri/icons/generated.ts",
|
||||
"apps/web/src/locales/en/common.yaml",
|
||||
"apps/web/node_modules/example/index.ts",
|
||||
"apps/backend/internal/webapp/embedded/generated/model.go",
|
||||
"apps/web/generated/model.ts",
|
||||
"apps/backend/scripts/check.go",
|
||||
"apps/backend/internal/agentctl/server/api/scripts/inspector.js",
|
||||
"apps/web/e2e/scripts/check.ts",
|
||||
"apps/web/scripts/check.ts",
|
||||
"apps/backend/cmd/sqlguard/check.go",
|
||||
"apps/backend/internal/db/sqlguard/check.go",
|
||||
"apps/web/eslint-rules/no-literal.ts",
|
||||
"apps/commitlint.config.mjs",
|
||||
"apps/prettier.config.mjs",
|
||||
"apps/web/eslint.config.mjs",
|
||||
"apps/web/e2e/playwright.config.ts",
|
||||
"apps/web/postcss.config.mjs",
|
||||
"apps/web/tailwind.config.ts",
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
run_javascript_for_each(classifier, "isCountedFile", counted),
|
||||
[True] * len(counted),
|
||||
)
|
||||
self.assertEqual(
|
||||
run_javascript_for_each(classifier, "isCountedFile", excluded),
|
||||
[False] * len(excluded),
|
||||
)
|
||||
|
||||
# @covers AC-CI-PR-SIZE-001.3, AC-CI-PR-SIZE-001.4
|
||||
def test_file_filter_is_explicit_and_normalizes_filenames(self) -> None:
|
||||
self.assertIn("typeof filename !== 'string'", self.workflow)
|
||||
self.assertIn("filename.startsWith('apps/')", self.workflow)
|
||||
self.assertIn("filename.split('/')", self.workflow)
|
||||
self.assertIn("pathSegments.includes('node_modules')", self.workflow)
|
||||
for directory in (
|
||||
"apps/backend/scripts/",
|
||||
"apps/backend/internal/agentctl/server/api/scripts/",
|
||||
"apps/backend/internal/webapp/embedded/generated/",
|
||||
"apps/web/e2e/scripts/",
|
||||
"apps/web/generated/",
|
||||
"apps/web/scripts/",
|
||||
):
|
||||
self.assertIn(directory, self.workflow)
|
||||
for directory in (
|
||||
"apps/backend/internal/notifications/providers/assets/",
|
||||
"apps/desktop/src-tauri/icons/",
|
||||
"apps/web/lib/assets/",
|
||||
"apps/web/public/",
|
||||
"apps/web/src/assets/",
|
||||
):
|
||||
self.assertIn(directory, self.workflow)
|
||||
for extension in (
|
||||
"c",
|
||||
"cc",
|
||||
"cpp",
|
||||
"css",
|
||||
"go",
|
||||
"graphql",
|
||||
"gql",
|
||||
"h",
|
||||
"html",
|
||||
"js",
|
||||
"jsx",
|
||||
"mjs",
|
||||
"mts",
|
||||
"proto",
|
||||
"rs",
|
||||
"scss",
|
||||
"sql",
|
||||
"ts",
|
||||
"tsx",
|
||||
):
|
||||
self.assertRegex(self.workflow, rf"['\"]{extension}['\"]")
|
||||
self.assertIn(r"apps\/web\/src\/locales\/[^/]+\/[^/]+\.json", self.workflow)
|
||||
|
||||
# @covers AC-CI-PR-SIZE-001.5, AC-CI-PR-SIZE-001.6
|
||||
def test_label_definitions_and_convergence_preserve_unrelated_labels(self) -> None:
|
||||
self.assertIn("const sizeLabelDefinitions =", self.workflow)
|
||||
for name, color in (
|
||||
("small", "0E8A16"),
|
||||
("medium", "FBCA04"),
|
||||
("big", "D93F0B"),
|
||||
):
|
||||
self.assertRegex(self.workflow, rf"{name}: \{{")
|
||||
self.assertIn(f"color: '{color}'", self.workflow)
|
||||
self.assertIn("github.rest.issues.getLabel", self.workflow)
|
||||
self.assertIn("github.rest.issues.createLabel", self.workflow)
|
||||
self.assertIn("github.rest.issues.listLabelsOnIssue", self.workflow)
|
||||
self.assertIn("github.rest.issues.addLabels", self.workflow)
|
||||
self.assertIn("github.rest.issues.removeLabel", self.workflow)
|
||||
|
||||
stale_labels = extract_function(self.workflow, "staleSizeLabels")
|
||||
self.assertEqual(
|
||||
run_javascript(stale_labels, "staleSizeLabels", [["bug", "medium", "big"], "small"]),
|
||||
["medium", "big"],
|
||||
)
|
||||
self.assertEqual(
|
||||
run_javascript(stale_labels, "staleSizeLabels", [["bug", "small"], "small"]),
|
||||
[],
|
||||
)
|
||||
|
||||
add_index = self.workflow.index("github.rest.issues.addLabels")
|
||||
remove_index = self.workflow.index("github.rest.issues.removeLabel")
|
||||
self.assertLess(add_index, remove_index)
|
||||
|
||||
already_exists = extract_function(self.workflow, "isAlreadyExistingLabelError")
|
||||
self.assertTrue(
|
||||
run_javascript(
|
||||
already_exists,
|
||||
"isAlreadyExistingLabelError",
|
||||
[
|
||||
{
|
||||
"status": 422,
|
||||
"response": {
|
||||
"data": {"errors": [{"code": "already_exists"}]}
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
run_javascript(
|
||||
already_exists,
|
||||
"isAlreadyExistingLabelError",
|
||||
[{"status": 422, "response": {"data": {"errors": [{"code": "invalid"}]}}}],
|
||||
)
|
||||
)
|
||||
create_index = self.workflow.index("github.rest.issues.createLabel")
|
||||
retry_read_index = self.workflow.index(
|
||||
"await github.rest.issues.getLabel", create_index
|
||||
)
|
||||
self.assertLess(create_index, retry_read_index)
|
||||
|
||||
# @covers AC-CI-PR-SIZE-001.5
|
||||
def test_summary_row_contains_two_string_cells(self) -> None:
|
||||
summary_row = extract_function(self.workflow, "summaryTableRow")
|
||||
self.assertEqual(
|
||||
run_javascript(summary_row, "summaryTableRow", [4, "small"]),
|
||||
["4", "small"],
|
||||
)
|
||||
table_expression = extract_call_argument(self.workflow, "core.summary.addTable(")
|
||||
script = (
|
||||
f"{summary_row}\n"
|
||||
"const countedFileCount = 4;\n"
|
||||
"const targetLabel = 'small';\n"
|
||||
f"const table = {table_expression};\n"
|
||||
"process.stdout.write(JSON.stringify(table));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AssertionError(result.stderr or result.stdout)
|
||||
table = json.loads(result.stdout)
|
||||
self.assertEqual(table[1], ["4", "small"])
|
||||
self.assertTrue(all(isinstance(cell, str) for cell in table[1]))
|
||||
|
||||
# @covers AC-CI-PR-SIZE-001.7
|
||||
def test_incomplete_file_results_fail_before_label_mutations(self) -> None:
|
||||
self.assertIn("github.paginate(", self.workflow)
|
||||
self.assertIn("github.rest.pulls.listFiles", self.workflow)
|
||||
self.assertIn("per_page: 100", self.workflow)
|
||||
self.assertIn("pull_number: context.payload.pull_request.number", self.workflow)
|
||||
limit_index = self.workflow.index("changedFiles.length >= 3000")
|
||||
self.assertIn("throw new Error", self.workflow[limit_index : limit_index + 240])
|
||||
for mutation in (
|
||||
"github.rest.issues.getLabel",
|
||||
"github.rest.issues.createLabel",
|
||||
"github.rest.issues.addLabels",
|
||||
"github.rest.issues.removeLabel",
|
||||
):
|
||||
self.assertLess(limit_index, self.workflow.index(mutation))
|
||||
|
||||
# @covers AC-CI-PR-SIZE-001.8
|
||||
def test_workflow_uses_trusted_least_privilege_and_pinned_execution(self) -> None:
|
||||
permissions = self.workflow.partition("permissions:\n")[2].partition("\njobs:")[0]
|
||||
self.assertEqual("pull-requests: write", permissions.strip())
|
||||
self.assertNotIn("actions/checkout", self.workflow)
|
||||
self.assertNotIn("\n - run:", self.workflow)
|
||||
self.assertNotIn("child_process", self.workflow)
|
||||
self.assertNotIn("eval(", self.workflow)
|
||||
self.assertRegex(
|
||||
self.workflow,
|
||||
r"uses: actions/github-script@[0-9a-f]{40} # v9\.[0-9]+\.[0-9]+",
|
||||
)
|
||||
|
||||
def test_contract_is_registered_in_the_required_lint_workflow(self) -> None:
|
||||
lint_workflow = LINT_WORKFLOW.read_text(encoding="utf-8")
|
||||
self.assertRegex(
|
||||
lint_workflow,
|
||||
r"(?m)^ - name: Test pull request size label workflow contract$\n"
|
||||
r"^ run: python3 .github/scripts/pr-size-label-workflow-contract_test.py$",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -90,6 +90,9 @@ jobs:
|
||||
- name: Test stale merge approval revocation workflow contract
|
||||
run: python3 .github/scripts/revoke-ready-to-merge-workflow-contract_test.py
|
||||
|
||||
- name: Test pull request size label workflow contract
|
||||
run: python3 .github/scripts/pr-size-label-workflow-contract_test.py
|
||||
|
||||
- name: Test managed runtime pin update workflow contract
|
||||
run: python3 .github/scripts/update-agent-runtime-pins-workflow-contract_test.py
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
name: Label pull request size
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] base-controlled metadata-only workflow
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
concurrency:
|
||||
group: pr-size-label-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Label pull request size
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Calculate and apply pull request size
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const sizeLabelDefinitions = {
|
||||
small: {
|
||||
color: '0E8A16',
|
||||
description: 'Pull request changes 0-10 application files',
|
||||
},
|
||||
medium: {
|
||||
color: 'FBCA04',
|
||||
description: 'Pull request changes 11-50 application files',
|
||||
},
|
||||
big: {
|
||||
color: 'D93F0B',
|
||||
description: 'Pull request changes 51 or more application files',
|
||||
},
|
||||
};
|
||||
|
||||
function isCountedFile(filename) {
|
||||
if (typeof filename !== 'string' || !filename.startsWith('apps/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pathSegments = filename.split('/');
|
||||
if (pathSegments.includes('node_modules')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const excludedDirectories = [
|
||||
'apps/backend/internal/agentctl/server/api/scripts/',
|
||||
'apps/backend/internal/webapp/embedded/generated/',
|
||||
'apps/backend/scripts/',
|
||||
'apps/backend/cmd/sqlguard/',
|
||||
'apps/backend/internal/db/sqlguard/',
|
||||
'apps/web/e2e/scripts/',
|
||||
'apps/web/generated/',
|
||||
'apps/web/eslint-rules/',
|
||||
'apps/web/scripts/',
|
||||
];
|
||||
if (excludedDirectories.some(prefix => filename.startsWith(prefix))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const excludedAssetDirectories = [
|
||||
'apps/backend/internal/notifications/providers/assets/',
|
||||
'apps/desktop/src-tauri/icons/',
|
||||
'apps/web/lib/assets/',
|
||||
'apps/web/public/',
|
||||
'apps/web/src/assets/',
|
||||
];
|
||||
if (excludedAssetDirectories.some(prefix => filename.startsWith(prefix))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const basename = filename.split('/').pop() ?? '';
|
||||
const extension = basename.includes('.') ? basename.split('.').pop() : '';
|
||||
const excludedAssetExtensions = new Set([
|
||||
'avif',
|
||||
'bmp',
|
||||
'bin',
|
||||
'dmg',
|
||||
'dll',
|
||||
'gif',
|
||||
'icns',
|
||||
'ico',
|
||||
'jpeg',
|
||||
'jpg',
|
||||
'otf',
|
||||
'pdf',
|
||||
'png',
|
||||
'so',
|
||||
'svg',
|
||||
'ttf',
|
||||
'wasm',
|
||||
'webp',
|
||||
'woff',
|
||||
'woff2',
|
||||
]);
|
||||
if (excludedAssetExtensions.has(extension)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const toolSettingNames = new Set([
|
||||
'commitlint.config.mjs',
|
||||
'eslint.config.mjs',
|
||||
'eslint.e2e-sleeps.config.mjs',
|
||||
'eslint.i18n.config.mjs',
|
||||
'eslint.i18n.options.mjs',
|
||||
'playwright.config.ts',
|
||||
'postcss.config.mjs',
|
||||
'prettier.config.mjs',
|
||||
'stylelint.config.mjs',
|
||||
'tailwind.config.ts',
|
||||
'vite.config.ts',
|
||||
'vitest.config.ts',
|
||||
'vitest.monaco-editor.ts',
|
||||
'vitest.setup.ts',
|
||||
]);
|
||||
if (toolSettingNames.has(basename)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/^apps\/web\/src\/locales\/[^/]+\/[^/]+\.json$/.test(filename)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const countedExtensions = new Set([
|
||||
'c',
|
||||
'cc',
|
||||
'cpp',
|
||||
'css',
|
||||
'go',
|
||||
'graphql',
|
||||
'gql',
|
||||
'h',
|
||||
'html',
|
||||
'js',
|
||||
'jsx',
|
||||
'mjs',
|
||||
'mts',
|
||||
'proto',
|
||||
'rs',
|
||||
'scss',
|
||||
'sql',
|
||||
'ts',
|
||||
'tsx',
|
||||
]);
|
||||
return countedExtensions.has(extension);
|
||||
}
|
||||
|
||||
function classifySize(count) {
|
||||
if (count <= 10) {
|
||||
return 'small';
|
||||
}
|
||||
if (count <= 50) {
|
||||
return 'medium';
|
||||
}
|
||||
return 'big';
|
||||
}
|
||||
|
||||
function staleSizeLabels(currentLabels, targetLabel) {
|
||||
const sizeLabels = new Set(['small', 'medium', 'big']);
|
||||
return currentLabels.filter(
|
||||
label => sizeLabels.has(label) && label !== targetLabel,
|
||||
);
|
||||
}
|
||||
|
||||
function isAlreadyExistingLabelError(error) {
|
||||
const status = error?.status ?? error?.response?.status;
|
||||
return (
|
||||
status === 422 &&
|
||||
Array.isArray(error?.response?.data?.errors) &&
|
||||
error.response.data.errors.some(detail => detail?.code === 'already_exists')
|
||||
);
|
||||
}
|
||||
|
||||
function summaryTableRow(countedFileCount, targetLabel) {
|
||||
return [String(countedFileCount), targetLabel];
|
||||
}
|
||||
|
||||
const pullNumber = context.payload.pull_request?.number;
|
||||
if (!Number.isInteger(pullNumber)) {
|
||||
throw new Error('pull-request number is missing from the event');
|
||||
}
|
||||
|
||||
const changedFiles = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
per_page: 100,
|
||||
},
|
||||
);
|
||||
if (changedFiles.length >= 3000) {
|
||||
throw new Error(
|
||||
'changed-file result may be incomplete at GitHub’s 3,000-file limit',
|
||||
);
|
||||
}
|
||||
|
||||
const countedFileCount = changedFiles.filter(file =>
|
||||
isCountedFile(file?.filename),
|
||||
).length;
|
||||
const targetLabel = classifySize(countedFileCount);
|
||||
|
||||
for (const [name, definition] of Object.entries(sizeLabelDefinitions)) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error?.status ?? error?.response?.status;
|
||||
if (status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name,
|
||||
color: definition.color,
|
||||
description: definition.description,
|
||||
});
|
||||
} catch (createError) {
|
||||
if (!isAlreadyExistingLabelError(createError)) {
|
||||
throw createError;
|
||||
}
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentLabels = await github.paginate(
|
||||
github.rest.issues.listLabelsOnIssue,
|
||||
{
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullNumber,
|
||||
per_page: 100,
|
||||
},
|
||||
);
|
||||
const currentLabelNames = currentLabels
|
||||
.map(label => label?.name)
|
||||
.filter(name => typeof name === 'string');
|
||||
|
||||
if (!currentLabelNames.includes(targetLabel)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullNumber,
|
||||
labels: [targetLabel],
|
||||
});
|
||||
}
|
||||
|
||||
for (const label of staleSizeLabels(currentLabelNames, targetLabel)) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullNumber,
|
||||
name: label,
|
||||
});
|
||||
}
|
||||
|
||||
core.summary.addHeading(`Pull request size for #${pullNumber}`);
|
||||
core.summary.addTable([
|
||||
[
|
||||
{ data: 'Counted application files', header: true },
|
||||
{ data: 'Size label', header: true },
|
||||
],
|
||||
summaryTableRow(countedFileCount, targetLabel),
|
||||
]);
|
||||
await core.summary.write();
|
||||
@@ -0,0 +1,63 @@
|
||||
# ADR-2026-09-09-count-application-files-for-pr-size: Count Application Files for Pull Request Size
|
||||
|
||||
**Status:** accepted
|
||||
**Date:** 2026-09-09
|
||||
**Area:** workflow, infra
|
||||
|
||||
## Context
|
||||
|
||||
Maintainers want to review small pull requests before large pull requests. The repository needs one stable size measure for this review order.
|
||||
|
||||
Changed-line totals do not match the desired review groups. For example, pull request `#3538` changes more code lines than `#3533`.
|
||||
|
||||
Changed application-file totals match all seven confirmed examples:
|
||||
|
||||
| Group | Pull requests | Counted files |
|
||||
| --- | --- | --- |
|
||||
| Small | `#3526`, `#3524` | 2, 7 |
|
||||
| Medium | `#3534`, `#3532`, `#3538` | 15, 17, 41 |
|
||||
| Big | `#3533`, `#3536` | 80, 96 |
|
||||
|
||||
Documentation and repository tooling can add many files without increasing application-code review effort. Tests and translation catalogs do increase review effort.
|
||||
|
||||
## Decision
|
||||
|
||||
The pull request size is the number of changed application files. Additions, deletions, and changed-line totals do not affect this value.
|
||||
|
||||
The count includes application source, test source, and web translation catalogs under `apps/`. The count excludes these file classes:
|
||||
|
||||
- Documentation and agent guidance.
|
||||
- GitHub workflows and repository scripts.
|
||||
- Generated files and dependency files.
|
||||
- Linter code, linter settings, and other tool settings.
|
||||
- A file with a known image, font, or binary asset extension, or a file under one of these asset directories: `apps/backend/internal/notifications/providers/assets/`, `apps/desktop/src-tauri/icons/`, `apps/web/lib/assets/`, `apps/web/public/`, and `apps/web/src/assets/`.
|
||||
|
||||
The size groups use these fixed limits:
|
||||
|
||||
- `small`: 0 through 10 counted files.
|
||||
- `medium`: 11 through 50 counted files.
|
||||
- `big`: 51 or more counted files.
|
||||
|
||||
Each pull request has exactly one of these labels. The workflow recalculates the label when the pull request diff changes.
|
||||
|
||||
## Consequences
|
||||
|
||||
The labels show review breadth instead of changed-line volume. A large change in one file can remain `small`.
|
||||
|
||||
A pull request that changes only documentation or tooling receives the `small` label. Test-heavy changes can move a pull request to a larger group.
|
||||
|
||||
The workflow owns an explicit file filter. New application languages or new tooling directories can require a filter update.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Count additions and deletions
|
||||
|
||||
This measure makes large test files dominate the result. It does not preserve the confirmed example groups.
|
||||
|
||||
### Count all changed files
|
||||
|
||||
This measure includes specifications, plans, workflows, and tooling. These files do not represent the requested application-code review scope.
|
||||
|
||||
### Use weighted file and line scores
|
||||
|
||||
This measure can model review effort in more detail. It adds policy complexity that the confirmed examples do not require.
|
||||
@@ -259,3 +259,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
|
||||
| 2026-09-05-script-capable-html-preview-isolation | [Capability-Free Runtime for Script-Capable HTML Preview](2026-09-05-script-capable-html-preview-isolation.md) | proposed | frontend, desktop, security | 2026-09-05 |
|
||||
| 2026-09-05-trusted-browser-html-preview | [Treat HTML Preview as Trusted Workspace Code](2026-09-05-trusted-browser-html-preview.md) | accepted | frontend, backend, agentctl, security | 2026-09-05 |
|
||||
| 2026-09-07-activate-managed-runtime-defaults | [Activate Shipped Managed Runtime Defaults](2026-09-07-activate-managed-runtime-defaults.md) | accepted | backend, frontend, protocol | 2026-09-07 |
|
||||
| 2026-09-09-count-application-files-for-pr-size | [Count Application Files for Pull Request Size](2026-09-09-count-application-files-for-pr-size.md) | accepted | workflow, infra | 2026-09-09 |
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
created: 2026-09-09
|
||||
status: done
|
||||
requirements:
|
||||
- REQ-CI-PR-SIZE-001
|
||||
system_design:
|
||||
- ../../specs/ci/system-design/pull-request-size-labels.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Pull Request Size Labels
|
||||
|
||||
## Overview
|
||||
|
||||
Add one trusted workflow that maintains pull request size labels. One work order keeps the workflow, contract test, and CI registration in one reviewable change.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Recalculate size labels for opened, reopened, and synchronized pull requests.
|
||||
- Count the confirmed application and test file classes.
|
||||
- Create missing `small`, `medium`, and `big` label definitions.
|
||||
- Preserve unrelated labels and converge on one size label.
|
||||
- Add focused workflow contract coverage and action-pinning coverage.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Changed-line or language-weighted scores.
|
||||
- A manual override or settings surface.
|
||||
- A deployment backfill for existing open pull requests.
|
||||
- Changes to application code or existing review workflows.
|
||||
|
||||
## Technical approach
|
||||
|
||||
Create `.github/workflows/pr-size-label.yml` with the trusted `pull_request_target` event and per-pull-request serialization.
|
||||
|
||||
Use pinned `actions/github-script` for paginated changed-file reads and label mutations. Keep the classifier and limits explicit in the trusted inline script.
|
||||
|
||||
Create `.github/scripts/pr-size-label-workflow-contract_test.py`. Execute the classifier against representative paths and the seven confirmed pull request file-count totals.
|
||||
|
||||
Register the contract test in `.github/workflows/lint-action-pinning.yml`. Preserve its existing workflow checks and pinned action policy.
|
||||
|
||||
## Tests
|
||||
|
||||
| Acceptance criteria | Evidence |
|
||||
| --- | --- |
|
||||
| `AC-CI-PR-SIZE-001.1`, `AC-CI-PR-SIZE-001.9` | Contract assertions for events, current API reads, and serialized concurrency. |
|
||||
| `AC-CI-PR-SIZE-001.2` | Boundary cases for 0, 10, 11, 50, and 51 files. |
|
||||
| `AC-CI-PR-SIZE-001.3`, `AC-CI-PR-SIZE-001.4` | Representative source, test, translation, documentation, workflow, script, generated, dependency, linter, settings, and asset paths. |
|
||||
| `AC-CI-PR-SIZE-001.5`, `AC-CI-PR-SIZE-001.6` | Contract assertions for label creation, add-before-remove order, exact size names, and unrelated-label preservation. |
|
||||
| `AC-CI-PR-SIZE-001.7` | Contract assertions for the 3,000-file limit and pre-mutation error path. |
|
||||
| `AC-CI-PR-SIZE-001.8` | Contract assertions for base-controlled execution, least privilege, no checkout, and a pinned action. |
|
||||
|
||||
## E2E tests
|
||||
|
||||
The workflow contract test runs the complete classifier and mocked label flow. Browser Playwright tests do not apply to GitHub-owned pull request labels.
|
||||
|
||||
A post-merge pull request event supplies live GitHub evidence. This observation is not a local implementation gate.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Add pull request size workflow](task-01-add-pr-size-workflow.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
- RED: the new contract test failed because the pull request size workflow did
|
||||
not exist yet.
|
||||
- GREEN: `python3
|
||||
.github/scripts/pr-size-label-workflow-contract_test.py` passed, 8 tests.
|
||||
- `python3 .github/scripts/lint-action-pinning_test.py` passed, 9 tests, and
|
||||
`python3 .github/scripts/lint-action-pinning.py` passed for 23 workflows.
|
||||
- `zizmor .github/workflows/pr-size-label.yml` passed with no findings after
|
||||
the intentional `pull_request_target` audit was documented inline.
|
||||
- `python3 scripts/lint-spec-files.py --all` passed.
|
||||
- `git diff --check -- .github` passed.
|
||||
- Review remediation narrowed script and tool-setting exclusions to explicit
|
||||
paths, added asset exclusions, fixed the step-summary row, and handled the
|
||||
duplicate-label creation race.
|
||||
- Review remediation updated the concurrency and asset contracts across the
|
||||
system design, requirements, and ADR.
|
||||
- Fixup verification: `python3
|
||||
.github/scripts/pr-size-label-workflow-contract_test.py` passed, 9 tests;
|
||||
action-pinning tests and linter passed; specification lint passed; targeted
|
||||
`zizmor` passed; and `git diff --check` passed.
|
||||
|
||||
## Risks
|
||||
|
||||
- GitHub limits the files endpoint to 3,000 entries. The workflow fails without mutations at that limit.
|
||||
- The explicit source filter needs updates when the repository adds application languages or tooling directories.
|
||||
- Existing open pull requests need a later qualifying event before they receive a size label.
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
id: "01-add-pr-size-workflow"
|
||||
title: "Add pull request size workflow"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-CI-PR-SIZE-001
|
||||
acceptance_criteria:
|
||||
- AC-CI-PR-SIZE-001.1
|
||||
- AC-CI-PR-SIZE-001.2
|
||||
- AC-CI-PR-SIZE-001.3
|
||||
- AC-CI-PR-SIZE-001.4
|
||||
- AC-CI-PR-SIZE-001.5
|
||||
- AC-CI-PR-SIZE-001.6
|
||||
- AC-CI-PR-SIZE-001.7
|
||||
- AC-CI-PR-SIZE-001.8
|
||||
- AC-CI-PR-SIZE-001.9
|
||||
system_design:
|
||||
- ../../specs/ci/system-design/pull-request-size-labels.md
|
||||
---
|
||||
|
||||
# Task 01: Add Pull Request Size Workflow
|
||||
|
||||
## Summary
|
||||
|
||||
Add the trusted workflow that calculates and maintains one pull request size label. Add focused tests for classification, API behavior, and workflow security.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add the `pull_request_target` workflow and per-pull-request concurrency.
|
||||
- Implement the application-file filter and the 10/50 size limits.
|
||||
- Create missing label definitions and converge on one size label.
|
||||
- Add and register the workflow contract test.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Application source changes.
|
||||
- Existing review workflow changes.
|
||||
- A backfill operation for current open pull requests.
|
||||
- Live mutation of repository labels during local development.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The classifier assigns all boundary cases and seven confirmed examples to the required labels.
|
||||
- A successful run preserves unrelated labels and leaves exactly one size label.
|
||||
- The workflow fails before mutations for incomplete file data and uses only trusted, pinned, least-privilege execution.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
python3 .github/scripts/pr-size-label-workflow-contract_test.py
|
||||
python3 .github/scripts/lint-action-pinning_test.py
|
||||
python3 .github/scripts/lint-action-pinning.py
|
||||
zizmor .github/workflows/pr-size-label.yml
|
||||
git diff --check -- .github
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `.github/workflows/pr-size-label.yml`
|
||||
- `.github/scripts/pr-size-label-workflow-contract_test.py`
|
||||
- `.github/workflows/lint-action-pinning.yml`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- GitHub can return an incomplete changed-file list at its 3,000-file limit.
|
||||
- Label mutations can partially complete before an API error. The next event or rerun converges on the current target.
|
||||
- A new source language requires an explicit classifier update.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `REQ-CI-PR-SIZE-001` and its acceptance criteria.
|
||||
- `docs/specs/ci/system-design/pull-request-size-labels.md`.
|
||||
- `docs/decisions/2026-09-09-count-application-files-for-pr-size.md`.
|
||||
- `.github/AGENTS.md` and the existing label workflow contract pattern.
|
||||
- GitHub files and labels REST API contracts.
|
||||
|
||||
## Results
|
||||
|
||||
- Added the base-controlled `pull_request_target` workflow for opened,
|
||||
reopened, and synchronized pull requests with per-pull-request serialization.
|
||||
- Added the explicit application-file classifier, fixed size boundaries, 3,000
|
||||
file completeness guard, repository label creation, and convergent label
|
||||
mutations that preserve unrelated labels.
|
||||
- Added 9 workflow contract tests covering the classifier, examples, API
|
||||
limits, label lifecycle, concurrency, security, and lint registration.
|
||||
- RED failed because the workflow was missing; GREEN passed after the workflow
|
||||
and lint registration were added.
|
||||
- `python3 .github/scripts/pr-size-label-workflow-contract_test.py` passed.
|
||||
- `python3 .github/scripts/lint-action-pinning_test.py` passed.
|
||||
- `python3 .github/scripts/lint-action-pinning.py` passed for 23 workflows.
|
||||
- `zizmor .github/workflows/pr-size-label.yml` passed with no findings.
|
||||
- `python3 scripts/lint-spec-files.py --all` passed.
|
||||
- `git diff --check -- .github` passed.
|
||||
- Review remediation narrowed script and tool-setting exclusions to explicit
|
||||
paths, added asset exclusions, fixed the step-summary row, handled the
|
||||
duplicate-label creation race, and corrected the concurrency contract.
|
||||
- Fixup verification passed: the contract test passed with 9 tests, action
|
||||
pinning tests and linter passed, specification lint passed, targeted
|
||||
`zizmor` passed, and `git diff --check` passed.
|
||||
@@ -37,11 +37,13 @@ deployment, and pull request walkthrough generation.
|
||||
|
||||
- [Unified contributor PR automation](requirements/unified-contributor-pr-automation.md)
|
||||
- [Contributor merge approval revocation](requirements/contributor-merge-approval-revocation.md)
|
||||
- [Pull request size labels](requirements/pull-request-size-labels.md)
|
||||
|
||||
### System design
|
||||
|
||||
- [Unified contributor PR automation](system-design/unified-contributor-pr-automation.md)
|
||||
- [Contributor merge approval revocation](system-design/contributor-merge-approval-revocation.md)
|
||||
- [Pull request size labels](system-design/pull-request-size-labels.md)
|
||||
|
||||
## Migration
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
status: draft
|
||||
system: ci
|
||||
created: 2026-09-09
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Pull request size label requirements
|
||||
|
||||
## Overview
|
||||
|
||||
Maintainers use pull request size labels to review smaller changes first. The CI automation system owns this base-controlled pull request metadata.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Counted file:** An application source file, test source file, or web translation catalog that the pull request changes.
|
||||
- **Size label:** One of the exact pull request labels `small`, `medium`, or `big`.
|
||||
- **Tooling file:** A workflow, script, generated file, dependency file, linter file, or tool settings file.
|
||||
- **Excluded asset:** A file with a known image, font, or binary asset extension, or a file under one of these asset directories: `apps/backend/internal/notifications/providers/assets/`, `apps/desktop/src-tauri/icons/`, `apps/web/lib/assets/`, `apps/web/public/`, and `apps/web/src/assets/`.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-CI-PR-SIZE-001: Classify pull requests by changed application files
|
||||
|
||||
**Intent:** Give maintainers a stable label that supports a small-first review order.
|
||||
|
||||
**User story:** As a maintainer, I want pull requests grouped by size, so that I can review smaller changes first.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-CI-PR-SIZE-001.1:** When a pull request opens, reopens, or receives a new commit, the system shall recalculate its size from the current changed-file list.
|
||||
- **AC-CI-PR-SIZE-001.2:** When the count is 0 through 10, the system shall apply `small`. For 11 through 50, it shall apply `medium`. For 51 or more, it shall apply `big`.
|
||||
- **AC-CI-PR-SIZE-001.3:** When the system calculates the count, it shall include application source, test source, and web translation catalogs under `apps/`.
|
||||
- **AC-CI-PR-SIZE-001.4:** When the system calculates the count, it shall exclude documentation, workflows, scripts, generated files, dependency files, linter files, tool settings, and assets. Assets are files with known image, font, or binary extensions or files under these asset directories: `apps/backend/internal/notifications/providers/assets/`, `apps/desktop/src-tauri/icons/`, `apps/web/lib/assets/`, `apps/web/public/`, and `apps/web/src/assets/`.
|
||||
- **AC-CI-PR-SIZE-001.5:** After a successful recalculation, the pull request shall have exactly one size label. The system shall preserve all unrelated labels.
|
||||
- **AC-CI-PR-SIZE-001.6:** When any size label definition does not exist, the system shall create all missing definitions before it changes pull request labels.
|
||||
- **AC-CI-PR-SIZE-001.7:** When GitHub does not return a complete changed-file list, the system shall fail without changing the pull request size labels.
|
||||
- **AC-CI-PR-SIZE-001.8:** The workflow shall use trusted base-branch code. It shall not check out or execute pull request content.
|
||||
- **AC-CI-PR-SIZE-001.9:** After concurrent recalculations complete, the size label shall match the current pull request diff.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Size scores that use changed-line totals or language weights.
|
||||
- Manual overrides for size labels.
|
||||
- Automatic updates for open pull requests that receive no new pull request event after deployment.
|
||||
- Changes to review, preview, merge, or walkthrough workflows.
|
||||
- Pull request sizing for repositories other than `kdlbs/kandev`.
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
status: draft
|
||||
system: ci
|
||||
requirements:
|
||||
- REQ-CI-PR-SIZE-001
|
||||
---
|
||||
|
||||
# Pull request size label system design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design defines the trusted GitHub Actions workflow that maintains one review-size label on each pull request.
|
||||
|
||||
The CI system owns the event contract, file filter, size limits, label definitions, permissions, and contract tests. GitHub remains the label source of truth.
|
||||
|
||||
The workflow reads pull request metadata only. It does not read file contents, check out pull request code, or change other review automation.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design sections |
|
||||
| --- | --- |
|
||||
| `REQ-CI-PR-SIZE-001` | [Event contract](#event-contract), [File classification](#file-classification), [Control flow](#control-flow), [Failure and recovery](#failure-and-recovery), [Security](#security) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| `.github/workflows/pr-size-label.yml` | Reads changed-file metadata and maintains the size labels. |
|
||||
| `actions/github-script` | Runs pinned API orchestration from the trusted workflow without a checkout. |
|
||||
| GitHub pull request files API | Returns the current changed-file paths with pagination. |
|
||||
| GitHub labels API | Creates missing label definitions and changes pull request labels. |
|
||||
| `.github/scripts/pr-size-label-workflow-contract_test.py` | Protects events, file rules, limits, mutations, concurrency, and permissions. |
|
||||
| `.github/workflows/lint-action-pinning.yml` | Runs the contract test and the action-pinning checks. |
|
||||
|
||||
## Event contract
|
||||
|
||||
The workflow uses this event contract:
|
||||
|
||||
```yaml
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
```
|
||||
|
||||
`pull_request_target` loads the workflow from the trusted base branch. It also gives fork pull requests the write permission required for labels.
|
||||
|
||||
The concurrency group contains the pull request number. The group uses `cancel-in-progress: false`.
|
||||
|
||||
The workflow serializes surviving runs for one pull request. `cancel-in-progress: false` keeps an active run, but GitHub can replace a pending run with a newer event and does not guarantee dispatch order. Each surviving run reads the current file list and label state instead of relying on event snapshots.
|
||||
|
||||
## File classification
|
||||
|
||||
The workflow paginates `github.rest.pulls.listFiles` with 100 entries per page. It uses only each response item's normalized `filename` value.
|
||||
|
||||
A counted file must start with `apps/`. It must also meet one of these rules:
|
||||
|
||||
- Its extension is `c`, `cc`, `cpp`, `css`, `go`, `graphql`, `gql`, `h`, `html`, `js`, `jsx`, `mjs`, `mts`, `proto`, `rs`, `scss`, `sql`, `ts`, or `tsx`.
|
||||
- Its path matches `apps/web/src/locales/<locale>/<namespace>.json`.
|
||||
|
||||
The filter rejects these repository tooling directories: `apps/backend/internal/agentctl/server/api/scripts/`, `apps/backend/internal/webapp/embedded/generated/`, `apps/backend/scripts/`, `apps/web/e2e/scripts/`, `apps/web/generated/`, and `apps/web/scripts/`. It rejects any path containing a `node_modules` directory.
|
||||
|
||||
The known linter directories are `apps/backend/cmd/sqlguard/`, `apps/backend/internal/db/sqlguard/`, and `apps/web/eslint-rules/`.
|
||||
|
||||
The filter rejects these exact tool setting base names: `commitlint.config.mjs`, `eslint.config.mjs`, `eslint.e2e-sleeps.config.mjs`, `eslint.i18n.config.mjs`, `eslint.i18n.options.mjs`, `playwright.config.ts`, `postcss.config.mjs`, `prettier.config.mjs`, `stylelint.config.mjs`, `tailwind.config.ts`, `vite.config.ts`, `vitest.config.ts`, `vitest.monaco-editor.ts`, and `vitest.setup.ts`. A source or test file with a similar prefix, such as `vite-preload-recovery.ts`, remains counted.
|
||||
|
||||
The filter excludes image, font, and binary files with known asset extensions and files under these asset directories: `apps/backend/internal/notifications/providers/assets/`, `apps/desktop/src-tauri/icons/`, `apps/web/lib/assets/`, `apps/web/public/`, and `apps/web/src/assets/`.
|
||||
|
||||
The explicit application root and extension list exclude these files without separate path rules:
|
||||
|
||||
- `docs/**`, `.github/**`, and root `scripts/**` files.
|
||||
- Markdown guidance and public documentation.
|
||||
- Package manifests, dependency locks, and TypeScript settings.
|
||||
- Dockerfiles, shell scripts, image assets, and generated binaries.
|
||||
|
||||
Tests use the same path and extension rules as production source. The workflow does not inspect additions, deletions, or changed-line totals.
|
||||
|
||||
The count maps to labels as follows:
|
||||
|
||||
| Counted files | Label |
|
||||
| --- | --- |
|
||||
| 0 through 10 | `small` |
|
||||
| 11 through 50 | `medium` |
|
||||
| 51 or more | `big` |
|
||||
|
||||
## Label definitions
|
||||
|
||||
The workflow owns three repository label definitions:
|
||||
|
||||
| Name | Color | Description |
|
||||
| --- | --- | --- |
|
||||
| `small` | `0E8A16` | Pull request changes 0-10 application files |
|
||||
| `medium` | `FBCA04` | Pull request changes 11-50 application files |
|
||||
| `big` | `D93F0B` | Pull request changes 51 or more application files |
|
||||
|
||||
The workflow creates a missing definition before it changes pull request labels. It does not replace the color or description of an existing label.
|
||||
|
||||
## Control flow
|
||||
|
||||
1. GitHub starts the trusted workflow for an allowed pull request event.
|
||||
2. The workflow reads all available changed-file pages for the current pull request.
|
||||
3. The workflow stops before mutations if the changed-file result can be incomplete.
|
||||
4. The workflow filters the paths and calculates the target size label.
|
||||
5. The workflow reads the three repository label definitions and creates missing definitions. If another run created a label after the read, a 422 `already_exists` response is re-read as success; other errors fail the job.
|
||||
6. The workflow adds the target label if the pull request does not have it.
|
||||
7. The workflow removes the other size labels if the pull request has them.
|
||||
8. The workflow writes the counted-file total and final label to the step summary.
|
||||
|
||||
The workflow adds the target label before it removes stale labels. An API error therefore does not remove the last valid size label first.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
The GitHub files endpoint returns at most 3,000 files. A result with 3,000 files can be incomplete, so the workflow fails before a label mutation.
|
||||
|
||||
A changed-file API error also fails before a label mutation. The current size labels remain unchanged in both cases.
|
||||
|
||||
A label-definition or label-mutation error fails the job. A concurrent label-definition creation that returns 422 with `already_exists` is re-read and does not fail the run. A retry reads current GitHub state and converges on one target label.
|
||||
|
||||
The workflow adds the target before it removes stale labels. A partial mutation can temporarily leave two size labels, but it does not leave the pull request unlabeled.
|
||||
|
||||
Surviving serialized runs do not promise event order. Each run reads current file and label state, so a later completed run can converge on the current pull request diff even when GitHub replaces a pending run or dispatches events out of order.
|
||||
|
||||
## Security
|
||||
|
||||
The job declares only `pull-requests: write`. GitHub permits this permission for file reads, label creation, and pull request label changes.
|
||||
|
||||
The workflow uses a full commit SHA for `actions/github-script`. It does not use `actions/checkout`, shell execution, `eval`, or pull request text in executable source.
|
||||
|
||||
The workflow passes repository and pull request values as API parameters. It does not interpolate untrusted metadata into executable workflow code.
|
||||
|
||||
## Observability
|
||||
|
||||
The step summary shows the pull request number, counted-file total, and selected label. API errors remain visible in the workflow result.
|
||||
|
||||
The contract test covers the event list, permission block, pagination, file filter, limits, label definitions, mutation order, and trusted execution boundary.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [Count Application Files for Pull Request Size](../../../decisions/2026-09-09-count-application-files-for-pr-size.md)
|
||||
|
||||
## External contracts
|
||||
|
||||
- [GitHub REST API: List pull request files](https://docs.github.com/en/rest/pulls/pulls#list-pull-requests-files)
|
||||
- [GitHub REST API: Labels](https://docs.github.com/en/rest/issues/labels)
|
||||
- [GitHub Actions: `pull_request_target`](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target)
|
||||
Reference in New Issue
Block a user