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();
|
||||
Reference in New Issue
Block a user