diff --git a/.github/actions/github.pr.size-labeler/.gitignore b/.github/actions/github.pr.size-labeler/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.github/actions/github.pr.size-labeler/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/.github/actions/github.pr.size-labeler/README.md b/.github/actions/github.pr.size-labeler/README.md new file mode 100644 index 0000000..673d890 --- /dev/null +++ b/.github/actions/github.pr.size-labeler/README.md @@ -0,0 +1,169 @@ +# GitHub - PR Size Labeler + +Label pull requests as XS, S, M, L, or XL using additions and deletions reported by GitHub. This composite action uses `actions/github-script@v9` and plain JavaScript. It sets up Node 24 and installs locked npm dependencies into a fresh runner-temporary directory before execution. It runs on AMD64 and ARM64 without Docker, checkout, `gh`, `jq`, or a build step. The caller’s package files and node_modules are not modified. Package installation requires registry access; npm lifecycle scripts are disabled. Self-hosted runners need Actions Runner v2.327.1 or newer for Node 24. + +## Usage + +```yaml +name: PR Size + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: pr-size-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + label: + runs-on: [self-hosted, linux, ARM64] + steps: + - uses: tedilabs/github-actions/.github/actions/github.pr.size-labeler@main + with: + ignore_file_deletions: "true" + ignore_line_deletions: "false" + overwrite_size_labels: "false" +``` + +The same action works on `ubuntu-24.04`, `ubuntu-24.04-arm`, and compatible AMD64 self-hosted runners. No PR code is checked out or executed. Configuration is read from the base repository at the PR's base SHA, including for fork PRs. Configuration changes in a PR therefore take effect after merging; they cannot change that PR's labeling policy. Use `pr_number` for events without a PR payload. + +The existing `github.pr.auto-label.yaml` reusable workflow delegates to this action. Its existing `pr_size_*_label` and `pr_size_*_max` inputs keep their behavior. New settings are available as `pr_size_config_file`, `pr_size_files_to_ignore`, `pr_size_ignore_file_deletions`, `pr_size_ignore_line_deletions`, `pr_size_overwrite_size_labels`, `pr_size_fail_if_xl`, `pr_size_message_if_xl`, and `pr_size_dry_run`. + +## Configuration precedence + +1. Built-in defaults. +2. Action inputs. +3. Values explicitly present in the configuration file. + +With an empty `config_file`, the action tries `.github/size-labeler.yaml` and then `.github/size-labeler.yml`. The first existing file wins; files are not merged. If neither exists, inputs are used. + +Set `config_file: .github/custom-size.yaml` to read only that path. An explicitly selected missing file fails, while a missing auto-discovery file is optional. Invalid configuration and permission errors always fail before changes are made. + +Files group size settings under `sizes`, keyed by `xs`, `s`, `m`, `l`, and `xl`. Each size accepts `label`; XS through L also accept `max_size`. XL has no upper bound and rejects `max_size`. Other policy settings remain top-level keys. Action inputs keep their flat names; flat size keys such as `xs_label` and `xs_max_size` are not accepted in config files. `github_token`, `github_api_url`, `pr_number`, and `config_file` remain invocation inputs. Unknown keys, duplicate YAML keys, multiple documents, invalid booleans, and non-increasing thresholds fail validation. An empty file or `{}` applies no overrides. + +For a complete configuration with built-in defaults, copy [config.example.yaml](config.example.yaml) to `.github/size-labeler.yaml` in the calling repository. Remove any fields that should inherit action inputs instead. + +For example, a customized `.github/size-labeler.yaml`: + +```yaml +sizes: + xs: + label: size/XS + max_size: 15 + s: + label: size/S + max_size: 100 + m: + label: size/M + max_size: 500 + l: + label: size/L + max_size: 1000 + xl: + label: size/XL + +files_to_ignore: +- "**/*.lock" +- "**/package-lock.json" +- "generated/**" + +ignore_file_deletions: true +ignore_line_deletions: false +overwrite_size_labels: false +fail_if_xl: false +message_if_xl: Please consider splitting this PR into smaller changes. +dry_run: false +``` + +Overrides apply per field: omitted sizes and fields retain their action input or built-in default. For example, this changes only the XS threshold and XL label: + +```yaml +sizes: + xs: + max_size: 20 + xl: + label: review/too-large +``` + +`max_size` is exclusive: with the defaults, XS is below 15 and XL is at least 1000. Size order is always XS → S → M → L → XL, regardless of YAML key order. `sizes: {}` and `sizes: {xs: {}}` apply no size overrides; null, scalar, and list values are invalid for these mappings. Unknown size names and fields fail validation. + +## Policy inputs + +| Input | Default | Behavior | +| --- | --- | --- | +| `xs_label`, `s_label`, `m_label`, `l_label`, `xl_label` | `size/XS`, `size/S`, `size/M`, `size/L`, `size/XL` | Five distinct managed label names. | +| `xs_max_size`, `s_max_size`, `m_max_size`, `l_max_size` | `15`, `100`, `500`, `1000` | Strictly increasing **exclusive** upper bounds. | +| `files_to_ignore` | Empty | Whitespace-separated globs in inputs; a string or list in YAML. | +| `ignore_file_deletions` | `true` | Exclude entirely removed files from line and file counts. | +| `ignore_line_deletions` | `false` | Count additions only, excluding deleted lines in all files. | +| `overwrite_size_labels` | `true` | Replace existing labels from the five configured names, including manually applied labels. When false, preserve existing configured labels and add one only when none exists. | +| `fail_if_xl` | `false` | Fail the step after labeling/commenting on an XL PR. | +| `message_if_xl` | Existing tedilabs warning text | Sticky XL comment. Set `""` to disable. | +| `dry_run` | `false` | Compute planned label changes without writes or XL failure enforcement. | + +The defaults preserve the tedilabs workflow: XS is 0–14 lines, S is 15–99, M is 100–499, L is 500–999, and XL is 1000 or more. Size/deletion inputs retain the CodelyTV names; `github_token` follows the other tedilabs composite actions. Defaults use tedilabs' uppercase labels, XS threshold of 15, and exclusion of deleted files. It does not copy CodelyTV's lowercase standalone defaults. + +Glob rules support Bash-style character classes and extglobs. Patterns match the current repository-relative filename, including dotfiles. Use `**/*.lock` for lockfiles at any depth. Quote whitespace-containing patterns in string inputs, or use a YAML list. Rename matching uses the new path. Patterns use minimatch glob syntax; they are not regular expressions. An excluded file contributes neither lines nor a file count. Binary-only changes with zero additions/deletions contribute zero lines. File counts are informational and do not affect the size grade. + +Deletion examples for a PR modifying a file with 10 additions and 20 deletions, and deleting another 100-line file: + +| `ignore_file_deletions` | `ignore_line_deletions` | Counted lines | Counted files | +| --- | --- | ---: | ---: | +| `true` | `false` | 30 | 1 | +| `false` | `false` | 130 | 2 | +| `true` | `true` | 10 | 1 | +| `false` | `true` | 10 | 2 | + +## Label ownership and comments + +Only the five configured XS/S/M/L/XL names are size labels for this action, matched case-insensitively. Unrelated labels, including other `size/*` names, are always preserved. + +- `overwrite_size_labels: true` (default): replace existing configured size labels with the calculated label, including labels added manually. This preserves automatic size updates after pushes. +- `overwrite_size_labels: false`: if any configured size label already exists, neither add nor remove labels. If none exists, add the calculated label. + +For a PR containing `size/L`, `size/manual`, and `bug`, recalculated as S: + +| Setting | Result | +| --- | --- | +| `overwrite_size_labels: true` | `size/S`, `size/manual`, `bug` | +| `overwrite_size_labels: false` | `size/L`, `size/manual`, `bug` | + +The action does not distinguish who applied a configured label. Disabling overwrite preserves manually applied labels **and labels from earlier action runs**; it can therefore pin a PR's size label until it is removed or overwrite is enabled. All existing configured labels are retained in this mode, even when more than one exists. Outputs still report the calculated size; `added_labels` and `removed_labels` are empty when labels are preserved. XL comments and `fail_if_xl` continue to follow the calculated size independently of label overwriting. + +XL comments have an action-specific HTML marker and are matched to the authenticated token author's login. Reruns update the same comment instead of posting duplicates. Other users' marker-containing comments are not changed. Existing comments remain as history when the PR shrinks or commenting is disabled. Changing the token author starts a separate comment. + +## Outputs and limits + +| Output | Meaning | +| --- | --- | +| `size` | `xs`, `s`, `m`, `l`, or `xl`. | +| `label` | Selected label name. | +| `changed_lines`, `changed_files` | Counts after exclusions. | +| `count_complete` | `true` when all changed files were returned. | +| `config_file` | Loaded config path, or empty. | +| `added_labels`, `removed_labels` | JSON arrays of changes; plans only in dry-run mode. | +| `dry_run` | Effective dry-run setting after file overrides. | + +For an initial read-only trial, set `dry_run: "true"`, grant only `contents: read` and `pull-requests: read`, and ensure the base configuration does not override `dry_run` to `false`. Invalid inputs and API errors still fail dry runs. Configuration precedence applies to `dry_run` just like other policy inputs. + +Files and labels are paginated. GitHub's file API returns at most 3,000 files: if an incomplete count already reaches XL, the action labels XL and returns `count_complete: false`. Otherwise it fails before writing rather than understating the PR size. Counts in that case are lower bounds. A second PR read rejects head/base changes during calculation. Use per-PR workflow concurrency as shown above to reduce overlapping runs; GitHub does not offer atomic label updates. + +The action neither manages label colors/descriptions nor removes repository label definitions. Keep those in the existing `.github/labels*.yaml` synchronization workflow. It only adds/removes labels on the PR. + +## Development + +From this action's directory, using Node 24: + +```sh +npm ci --ignore-scripts +npm test +``` + +There are no generated JavaScript artifacts. Commit package-lock.json when updating dependencies. The `tests/contract.json` scenarios cover policy decisions and API mutations, including existing configured labels with overwrite enabled and disabled. The dedicated CI workflow runs behavior tests and a read-only composite smoke test, including runtime dependency installation, on AMD64 and ARM64. + +Potential follow-ups are file-count thresholds and `.gitattributes` generated-file exclusion. They change the classification policy or add config parsing beyond the current requirement, so this action initially keeps line-based grading and explicit globs. diff --git a/.github/actions/github.pr.size-labeler/action.yaml b/.github/actions/github.pr.size-labeler/action.yaml new file mode 100644 index 0000000..e112f18 --- /dev/null +++ b/.github/actions/github.pr.size-labeler/action.yaml @@ -0,0 +1,151 @@ +name: GitHub - PR Size Labeler +description: Label pull requests by changed lines, with YAML configuration and independent deletion filters. Installs locked npm dependencies at runtime on AMD64 and ARM64. + + +inputs: + config_file: + required: false + default: "" + description: "(Optional) Repository-relative YAML path at the PR base commit. When omitted, tries `.github/size-labeler.yaml`, then `.github/size-labeler.yml`. File values override matching action inputs; size settings use sizes..label/max_size (no max_size for xl). An explicitly requested missing file fails." + pr_number: + required: false + default: "" + description: "(Optional) The pull request number. Defaults to the PR event number. Required for workflow_dispatch or workflow_run." + github_token: + required: false + default: ${{ github.token }} + description: "(Optional) The GitHub token used to read configuration and manage pull request labels and comments. Requires contents: read and pull-requests: write; a dry run only needs read permissions. Defaults to the workflow's default token." + github_api_url: + required: false + default: ${{ github.api_url }} + description: "(Optional) GitHub REST API URL, including GitHub Enterprise Server. Defaults to github.api_url." + xs_label: + required: false + default: size/XS + description: "(Optional) Label for XS pull requests. Defaults to `size/XS`." + xs_max_size: + required: false + default: "15" + description: "(Optional) Exclusive upper bound for XS. Defaults to `15` (0-14 lines)." + s_label: + required: false + default: size/S + description: "(Optional) Label for S pull requests. Defaults to `size/S`." + s_max_size: + required: false + default: "100" + description: "(Optional) Exclusive upper bound for S. Defaults to `100`." + m_label: + required: false + default: size/M + description: "(Optional) Label for M pull requests. Defaults to `size/M`." + m_max_size: + required: false + default: "500" + description: "(Optional) Exclusive upper bound for M. Defaults to `500`." + l_label: + required: false + default: size/L + description: "(Optional) Label for L pull requests. Defaults to `size/L`." + l_max_size: + required: false + default: "1000" + description: "(Optional) Exclusive upper bound for L. Values at or above this threshold are XL. Defaults to `1000`." + xl_label: + required: false + default: size/XL + description: "(Optional) Label for XL pull requests. Defaults to `size/XL`." + files_to_ignore: + required: false + default: "" + description: "(Optional) Whitespace-separated glob patterns, or a YAML list in the config file. Matches repository-relative paths, including dotfiles. Quote patterns containing spaces; use `**/*.lock` for nested lockfiles." + ignore_file_deletions: + required: false + default: "true" + description: "(Optional) Exclude completely removed files from both line and file counts. Defaults to `true`, matching the existing tedilabs workflow." + ignore_line_deletions: + required: false + default: "false" + description: "(Optional) Count additions only instead of additions plus deletions. Defaults to `false`." + overwrite_size_labels: + required: false + default: "true" + description: "(Optional) Whether to replace existing XS/S/M/L/XL labels from the configured set, including manually applied labels. When false, preserve all labels if a configured size label already exists; otherwise add the calculated label. Applies regardless of who added the label. Defaults to `true`." + fail_if_xl: + required: false + default: "false" + description: "(Optional) Fail the step after labeling an XL PR. Defaults to `false`. Not enforced in dry_run mode." + message_if_xl: + required: false + default: This PR has too many changes. Please make sure you are NOT addressing multiple issues with one PR. + description: "(Optional) Sticky comment for an XL PR. An empty string disables commenting. Reuses this action's comment by the same token author; historical comments remain when the PR shrinks." + dry_run: + required: false + default: "false" + description: "(Optional) Calculate and output planned changes without modifying labels/comments or enforcing fail_if_xl. Defaults to `false`." + +outputs: + size: + value: ${{ steps.label.outputs.size }} + description: The calculated size, one of xs, s, m, l, xl. + label: + value: ${{ steps.label.outputs.label }} + description: The calculated label name. + changed_lines: + value: ${{ steps.label.outputs.changed_lines }} + description: Changed lines after exclusions; a lower bound if count_complete is false. + changed_files: + value: ${{ steps.label.outputs.changed_files }} + description: Included files after exclusions; a lower bound if count_complete is false. + count_complete: + value: ${{ steps.label.outputs.count_complete }} + description: Whether all changed files were counted. An incomplete count is accepted only when XL is certain. + config_file: + value: ${{ steps.label.outputs.config_file }} + description: The loaded repository-relative configuration path, or empty when none exists. + added_labels: + value: ${{ steps.label.outputs.added_labels }} + description: JSON array of labels to add; planned changes only in dry_run mode. + removed_labels: + value: ${{ steps.label.outputs.removed_labels }} + description: JSON array of labels to remove; planned changes only in dry_run mode. + dry_run: + value: ${{ steps.label.outputs.dry_run }} + description: The effective dry_run setting after configuration overrides. + + +runs: + using: composite + + steps: + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "24" + package-manager-cache: false + + - name: Install Action Dependencies + id: dependencies + shell: bash + env: + SIZE_LABELER_ACTION_PATH: ${{ github.action_path }} + run: | + runtime_dir="$(mktemp -d "$RUNNER_TEMP/pr-size-labeler.XXXXXX")" + cp "$SIZE_LABELER_ACTION_PATH/package.json" "$SIZE_LABELER_ACTION_PATH/package-lock.json" "$runtime_dir/" + cp -R "$SIZE_LABELER_ACTION_PATH/src" "$runtime_dir/src" + cd "$runtime_dir" + npm ci --ignore-scripts --omit=dev --no-audit --no-fund + echo "runtime_dir=$runtime_dir" >> "$GITHUB_OUTPUT" + + - name: Label Pull Request Size + id: label + uses: actions/github-script@v9 + env: + SIZE_LABELER_RUNTIME_DIR: ${{ steps.dependencies.outputs.runtime_dir }} + SIZE_LABELER_INPUTS: ${{ toJSON(inputs) }} + with: + github-token: ${{ inputs.github_token || github.token }} + base-url: ${{ inputs.github_api_url }} + script: | + const { run } = require(`${process.env.SIZE_LABELER_RUNTIME_DIR}/src/index.cjs`); + await run({ github, context, core, inputs: JSON.parse(process.env.SIZE_LABELER_INPUTS) }); diff --git a/.github/actions/github.pr.size-labeler/config.example.yaml b/.github/actions/github.pr.size-labeler/config.example.yaml new file mode 100644 index 0000000..98abdac --- /dev/null +++ b/.github/actions/github.pr.size-labeler/config.example.yaml @@ -0,0 +1,27 @@ +# Copy to .github/size-labeler.yaml in the calling repository. +# All values below match the action's built-in defaults. +# File values override action inputs; remove fields to inherit their input values. + +sizes: + xs: + label: size/XS + max_size: 15 + s: + label: size/S + max_size: 100 + m: + label: size/M + max_size: 500 + l: + label: size/L + max_size: 1000 + xl: + label: size/XL + +files_to_ignore: [] +ignore_file_deletions: true +ignore_line_deletions: false +overwrite_size_labels: true +fail_if_xl: false +message_if_xl: This PR has too many changes. Please make sure you are NOT addressing multiple issues with one PR. +dry_run: false diff --git a/.github/actions/github.pr.size-labeler/package-lock.json b/.github/actions/github.pr.size-labeler/package-lock.json new file mode 100644 index 0000000..7af66b8 --- /dev/null +++ b/.github/actions/github.pr.size-labeler/package-lock.json @@ -0,0 +1,88 @@ +{ + "name": "github-pr-size-labeler", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "github-pr-size-labeler", + "dependencies": { + "js-yaml": "5.5.0", + "minimatch": "10.2.6" + } + }, + "node_modules/argparse": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.4.tgz", + "integrity": "sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "PSF-2.0" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/js-yaml": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.5.0.tgz", + "integrity": "sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^3.0.3" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + } + } +} diff --git a/.github/actions/github.pr.size-labeler/package.json b/.github/actions/github.pr.size-labeler/package.json new file mode 100644 index 0000000..9aaf534 --- /dev/null +++ b/.github/actions/github.pr.size-labeler/package.json @@ -0,0 +1,11 @@ +{ + "name": "github-pr-size-labeler", + "private": true, + "scripts": { + "test": "node --test tests/*.test.cjs" + }, + "dependencies": { + "js-yaml": "5.5.0", + "minimatch": "10.2.6" + } +} diff --git a/.github/actions/github.pr.size-labeler/src/index.cjs b/.github/actions/github.pr.size-labeler/src/index.cjs new file mode 100644 index 0000000..657061f --- /dev/null +++ b/.github/actions/github.pr.size-labeler/src/index.cjs @@ -0,0 +1,226 @@ +const yaml = require('js-yaml'); +const { minimatch } = require('minimatch'); + +const SIZES = ['xs', 's', 'm', 'l', 'xl']; +const DEFAULTS = { + xs_label: 'size/XS', xs_max_size: 15, + s_label: 'size/S', s_max_size: 100, + m_label: 'size/M', m_max_size: 500, + l_label: 'size/L', l_max_size: 1000, + xl_label: 'size/XL', + files_to_ignore: [], + ignore_file_deletions: true, + ignore_line_deletions: false, + overwrite_size_labels: true, + fail_if_xl: false, + message_if_xl: 'This PR has too many changes. Please make sure you are NOT addressing multiple issues with one PR.', + dry_run: false, +}; +const DEFAULT_PATHS = ['.github/size-labeler.yaml', '.github/size-labeler.yml']; +const COMMENT_MARKER = ''; + +function parseConfig(text) { + const documents = yaml.loadAll(text, { schema: yaml.JSON_SCHEMA }); + if (documents.length > 1) throw new Error('Size labeler configuration must contain only one YAML document.'); + const config = documents.length ? documents[0] : {}; + if (config === null || typeof config !== 'object' || Array.isArray(config)) { + throw new Error('Size labeler configuration must be a YAML mapping.'); + } + const overrides = {}; + for (const [key, value] of Object.entries(config)) { + if (key === 'sizes') { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('sizes must be a YAML mapping.'); + } + for (const [size, settings] of Object.entries(value)) { + if (!SIZES.includes(size)) throw new Error(`Unknown configuration key: sizes.${size}`); + if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) { + throw new Error(`sizes.${size} must be a YAML mapping.`); + } + for (const [field, setting] of Object.entries(settings)) { + if (field !== 'label' && !(field === 'max_size' && size !== 'xl')) { + throw new Error(`Unknown configuration key: sizes.${size}.${field}`); + } + overrides[`${size}_${field}`] = setting; + } + } + } else { + if (!Object.hasOwn(DEFAULTS, key) || SIZES.some((size) => key.startsWith(`${size}_`))) { + throw new Error(`Unknown configuration key: ${key}`); + } + overrides[key] = value; + } + } + return overrides; +} + +function resolveOptions(inputs = {}, config = {}) { + const options = { ...DEFAULTS }; + for (const key of Object.keys(DEFAULTS)) { + // Empty action inputs use defaults, except intentionally empty messages/patterns. + if (inputs[key] !== undefined && (inputs[key] !== '' || ['message_if_xl', 'files_to_ignore'].includes(key))) { + options[key] = inputs[key]; + } + } + Object.assign(options, config); + for (const [key, value] of Object.entries(DEFAULTS)) { + if (typeof value === 'boolean') { + if (![true, false, 'true', 'false'].includes(options[key])) { + throw new Error(`${key} must be true or false.`); + } + options[key] = options[key] === true || options[key] === 'true'; + } + } + let previous = 0; + const names = []; + for (const size of SIZES) { + const label = options[`${size}_label`]; + if (typeof label !== 'string' || !label.trim() || label.length > 50) { + throw new Error(`${size}_label must be a nonempty label of at most 50 characters.`); + } + names.push(label.toLowerCase()); + if (size === 'xl') continue; + const key = `${size}_max_size`; + const raw = options[key]; + const limit = typeof raw === 'number' || (typeof raw === 'string' && /^\d+$/.test(raw)) ? Number(raw) : NaN; + if (!Number.isSafeInteger(limit) || limit <= previous) { + throw new Error('Size thresholds must be positive, strictly increasing integers.'); + } + options[key] = limit; + previous = limit; + } + if (new Set(names).size !== names.length) throw new Error('Size labels must be distinct (case-insensitive).'); + if (typeof options.message_if_xl !== 'string') throw new Error('message_if_xl must be a string.'); + if (typeof options.files_to_ignore === 'string') { + // Retain the legacy whitespace-separated input, including quoted patterns. + options.files_to_ignore = (options.files_to_ignore.match(/"[^"]*"|'[^']*'|\S+/g) ?? []) + .map((pattern) => pattern.replace(/^(["'])(.*)\1$/, '$2')); + } + if (!Array.isArray(options.files_to_ignore) || options.files_to_ignore.some((p) => typeof p !== 'string' || !p)) { + throw new Error('files_to_ignore must be a string or a YAML list of nonempty glob patterns.'); + } + return options; +} + +async function loadConfig(github, repository, ref, explicitPath) { + for (const path of explicitPath ? [explicitPath] : DEFAULT_PATHS) { + let response; + try { + response = await github.rest.repos.getContent({ ...repository, ref, path }); + } catch (error) { + if (error.status === 404 && !explicitPath) continue; + throw error; + } + const file = response.data; + if (file.type !== 'file' || file.encoding !== 'base64') { + throw new Error(`Configuration must be a regular UTF-8 file smaller than 1 MB: ${path}`); + } + return { path, config: parseConfig(Buffer.from(file.content, 'base64').toString('utf8')) }; + } + return { path: '', config: {} }; +} + +function classify(lines, options) { + return SIZES.find((size) => size === 'xl' || lines < options[`${size}_max_size`]); +} + +async function calculateSize(github, parameters, changedFiles, options) { + const ignored = (name) => options.files_to_ignore.some((pattern) => { + let negate = false; + while (pattern.startsWith('!') && !pattern.startsWith('!(')) { + negate = !negate; + pattern = pattern.slice(1); + } + return minimatch(name, pattern, { dot: true, nonegate: true, nocomment: true }) !== negate; + }); + let lines = 0; + let files = 0; + let seen = 0; + for await (const page of github.paginate.iterator(github.rest.pulls.listFiles, { ...parameters, per_page: 100 })) { + for (const file of page.data) { + seen++; + if ((options.ignore_file_deletions && file.status === 'removed') || ignored(file.filename)) continue; + files++; + lines += file.additions + (options.ignore_line_deletions ? 0 : file.deletions); + } + } + const complete = seen === changedFiles; + // The API returns at most 3,000 files. A lower bound is enough only when XL is certain. + if (!complete && lines < options.l_max_size) { + throw new Error(`Only ${seen} of ${changedFiles} changed files were returned; cannot assign an accurate size label.`); + } + return { lines, files, complete }; +} + +function labelChanges(existing, desired, options) { + const managed = new Set(SIZES.map((size) => options[`${size}_label`].toLowerCase())); + if (!options.overwrite_size_labels && existing.some((name) => managed.has(name.toLowerCase()))) { + return { add: [], remove: [] }; + } + return { + add: existing.some((name) => name.toLowerCase() === desired.toLowerCase()) ? [] : [desired], + remove: existing.filter((name) => name.toLowerCase() !== desired.toLowerCase() + && managed.has(name.toLowerCase())), + }; +} + +async function updateComment(github, parameters, message) { + const body = `${COMMENT_MARKER}\n${message}`; + // Identify this token's author so a user-provided marker cannot select someone else's comment. + const { viewer } = await github.graphql('{ viewer { login } }'); + const comments = await github.paginate(github.rest.issues.listComments, { ...parameters, per_page: 100 }); + const existing = comments.find((comment) => comment.user?.login === viewer.login && comment.body?.startsWith(COMMENT_MARKER)); + if (!existing) { + await github.rest.issues.createComment({ ...parameters, body }); + } else if (existing.body !== body) { + await github.rest.issues.updateComment({ ...parameters, comment_id: existing.id, body }); + } +} + +async function run({ github, context, core, inputs }) { + const pull_number = Number(inputs.pr_number || context.payload.pull_request?.number); + if (!Number.isSafeInteger(pull_number) || pull_number <= 0) { + throw new Error('A pull request event or a positive pr_number input is required.'); + } + const parameters = { ...context.repo, pull_number }; + const { data: pr } = await github.rest.pulls.get(parameters); + const loaded = await loadConfig(github, context.repo, pr.base.sha, inputs.config_file); + const options = resolveOptions(inputs, loaded.config); + core.info(loaded.path ? `Configuration: ${loaded.path} at base ${pr.base.sha}` : 'No configuration file; using action inputs.'); + const result = await calculateSize(github, parameters, pr.changed_files, options); + const size = classify(result.lines, options); + const label = options[`${size}_label`]; + core.info(`${result.complete ? '' : 'At least '}${result.lines} changed lines in ${result.files} included files: ${label}`); + if (!result.complete) core.warning('GitHub truncated the file list. Counts are lower bounds; the XL classification is certain.'); + const issue = { ...context.repo, issue_number: pull_number }; + // Do not write a label calculated for a head/base that changed during the API reads. + const { data: latest } = await github.rest.pulls.get(parameters); + if (latest.head.sha !== pr.head.sha || latest.base.sha !== pr.base.sha) { + throw new Error('The PR changed during size calculation. Rerun the action for the current revision.'); + } + const existing = await github.paginate(github.rest.issues.listLabelsOnIssue, { ...issue, per_page: 100 }); + const changes = labelChanges(existing.map((entry) => entry.name), label, options); + const outputs = { + size, label, changed_lines: result.lines, changed_files: result.files, + count_complete: result.complete, config_file: loaded.path, + added_labels: JSON.stringify(changes.add), removed_labels: JSON.stringify(changes.remove), + dry_run: options.dry_run, + }; + for (const [key, value] of Object.entries(outputs)) core.setOutput(key, value); + if (options.dry_run) { + core.info(`Dry run: ${JSON.stringify(changes)}; no labels/comments changed and no XL failure enforced.`); + return; + } + if (changes.add.length) await github.rest.issues.addLabels({ ...issue, labels: changes.add }); + for (const name of changes.remove) { + try { + await github.rest.issues.removeLabel({ ...issue, name }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + if (size === 'xl' && options.message_if_xl) await updateComment(github, issue, options.message_if_xl); + if (size === 'xl' && options.fail_if_xl) core.setFailed('The PR reaches the XL size threshold.'); +} + +module.exports = { DEFAULTS, parseConfig, resolveOptions, loadConfig, classify, calculateSize, labelChanges, run }; diff --git a/.github/actions/github.pr.size-labeler/tests/contract.json b/.github/actions/github.pr.size-labeler/tests/contract.json new file mode 100644 index 0000000..a617caa --- /dev/null +++ b/.github/actions/github.pr.size-labeler/tests/contract.json @@ -0,0 +1,1695 @@ +[ + { + "name": "boundary 0", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 0, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": {}, + "outputs": { + "size": "xs", + "label": "size/XS", + "changed_lines": 0, + "count_complete": true + } + }, + { + "name": "boundary 14", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 14, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": {}, + "outputs": { + "size": "xs", + "label": "size/XS", + "changed_lines": 14, + "count_complete": true + } + }, + { + "name": "boundary 15", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 15, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": {}, + "outputs": { + "size": "s", + "label": "size/S", + "changed_lines": 15, + "count_complete": true + } + }, + { + "name": "boundary 99", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 99, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": {}, + "outputs": { + "size": "s", + "label": "size/S", + "changed_lines": 99, + "count_complete": true + } + }, + { + "name": "boundary 100", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 100, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": {}, + "outputs": { + "size": "m", + "label": "size/M", + "changed_lines": 100, + "count_complete": true + } + }, + { + "name": "boundary 499", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 499, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": {}, + "outputs": { + "size": "m", + "label": "size/M", + "changed_lines": 499, + "count_complete": true + } + }, + { + "name": "boundary 500", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 500, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": {}, + "outputs": { + "size": "l", + "label": "size/L", + "changed_lines": 500, + "count_complete": true + } + }, + { + "name": "boundary 999", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 999, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": {}, + "outputs": { + "size": "l", + "label": "size/L", + "changed_lines": 999, + "count_complete": true + } + }, + { + "name": "boundary 1000", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": {}, + "outputs": { + "size": "xl", + "label": "size/XL", + "changed_lines": 1000, + "count_complete": true + } + }, + { + "name": "deletion switches True/False", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 10, + "deletions": 20, + "status": "modified" + }, + { + "filename": "gone.tf", + "additions": 0, + "deletions": 100, + "status": "removed" + } + ] + ] + }, + "inputs": { + "ignore_file_deletions": true, + "ignore_line_deletions": false + }, + "outputs": { + "changed_lines": 30, + "changed_files": 1 + } + }, + { + "name": "deletion switches False/False", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 10, + "deletions": 20, + "status": "modified" + }, + { + "filename": "gone.tf", + "additions": 0, + "deletions": 100, + "status": "removed" + } + ] + ] + }, + "inputs": { + "ignore_file_deletions": false, + "ignore_line_deletions": false + }, + "outputs": { + "changed_lines": 130, + "changed_files": 2 + } + }, + { + "name": "deletion switches True/True", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 10, + "deletions": 20, + "status": "modified" + }, + { + "filename": "gone.tf", + "additions": 0, + "deletions": 100, + "status": "removed" + } + ] + ] + }, + "inputs": { + "ignore_file_deletions": true, + "ignore_line_deletions": true + }, + "outputs": { + "changed_lines": 10, + "changed_files": 1 + } + }, + { + "name": "deletion switches False/True", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 10, + "deletions": 20, + "status": "modified" + }, + { + "filename": "gone.tf", + "additions": 0, + "deletions": 100, + "status": "removed" + } + ] + ] + }, + "inputs": { + "ignore_file_deletions": false, + "ignore_line_deletions": true + }, + "outputs": { + "changed_lines": 10, + "changed_files": 2 + } + }, + { + "name": "glob **/*.lock", + "fixture": { + "pages": [ + [ + { + "filename": "a.lock", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "pkg/a.lock", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": ".cache/a.lock", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "a.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": { + "files_to_ignore": "**/*.lock" + }, + "outputs": { + "changed_lines": 1, + "changed_files": 1 + } + }, + { + "name": "glob *.lock", + "fixture": { + "pages": [ + [ + { + "filename": "a.lock", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "pkg/a.lock", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": ".hidden.lock", + "additions": 1, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": { + "files_to_ignore": "*.lock" + }, + "outputs": { + "changed_lines": 1, + "changed_files": 1 + } + }, + { + "name": "glob docs/**", + "fixture": { + "pages": [ + [ + { + "filename": "docs/a.md", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "docs/sub/b.md", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "main.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": { + "files_to_ignore": "docs/**" + }, + "outputs": { + "changed_lines": 1, + "changed_files": 1 + } + }, + { + "name": "glob **/*.{json,yaml}", + "fixture": { + "pages": [ + [ + { + "filename": "a.json", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "b.yaml", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "sub/c.json", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "main.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": { + "files_to_ignore": "**/*.{json,yaml}" + }, + "outputs": { + "changed_lines": 1, + "changed_files": 1 + } + }, + { + "name": "glob **/@(a|b).tf", + "fixture": { + "pages": [ + [ + { + "filename": "a.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "sub/b.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "c.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": { + "files_to_ignore": "**/@(a|b).tf" + }, + "outputs": { + "changed_lines": 1, + "changed_files": 1 + } + }, + { + "name": "glob file?.[jt]s", + "fixture": { + "pages": [ + [ + { + "filename": "file1.js", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "file2.ts", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "file22.js", + "additions": 1, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": { + "files_to_ignore": "file?.[jt]s" + }, + "outputs": { + "changed_lines": 1, + "changed_files": 1 + } + }, + { + "name": "glob \"docs/a b.md\"", + "fixture": { + "pages": [ + [ + { + "filename": "docs/a b.md", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "docs/other.md", + "additions": 1, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": { + "files_to_ignore": "\"docs/a b.md\"" + }, + "outputs": { + "changed_lines": 1, + "changed_files": 1 + } + }, + { + "name": "glob !**/*.tf", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "readme.md", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "sub/a.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": { + "files_to_ignore": "!**/*.tf" + }, + "outputs": { + "changed_lines": 2, + "changed_files": 2 + } + }, + { + "name": "glob [\"literal\"]", + "fixture": { + "pages": [ + [ + { + "filename": "file.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": { + "files_to_ignore": "[\"literal\"]" + }, + "outputs": { + "changed_lines": 1, + "changed_files": 1 + } + }, + { + "name": "all file pages", + "fixture": { + "pages": [ + [ + { + "filename": "first.tf", + "additions": 0, + "deletions": 0, + "status": "modified" + } + ], + [ + { + "filename": "last.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": {}, + "outputs": { + "changed_files": 2, + "changed_lines": 1000, + "size": "xl" + } + }, + { + "name": "empty PR", + "fixture": { + "pages": [] + }, + "inputs": {}, + "outputs": { + "changed_files": 0, + "changed_lines": 0, + "size": "xs" + } + }, + { + "name": "renamed and binary", + "fixture": { + "pages": [ + [ + { + "filename": "new.tf", + "additions": 5, + "deletions": 2, + "status": "renamed" + }, + { + "filename": "image.png", + "additions": 0, + "deletions": 0, + "status": "added" + } + ] + ] + }, + "inputs": {}, + "outputs": { + "changed_files": 2, + "changed_lines": 7 + } + }, + { + "name": "config precedence", + "fixture": { + "config": { + ".github/size-labeler.yaml": "sizes:\n xs:\n max_size: 5\nignore_file_deletions: false", + ".github/size-labeler.yml": "sizes:\n xs:\n max_size: 20" + } + }, + "inputs": { + "xs_max_size": "30" + }, + "outputs": { + "label": "size/S", + "config_file": ".github/size-labeler.yaml" + } + }, + { + "name": "yml fallback", + "fixture": { + "config": { + ".github/size-labeler.yml": "sizes:\n xs:\n max_size: 5" + } + }, + "inputs": {}, + "outputs": { + "label": "size/S", + "config_file": ".github/size-labeler.yml" + } + }, + { + "name": "explicit config", + "fixture": { + "config": { + "config/size.yml": "sizes:\n xs:\n label: tiny" + } + }, + "inputs": { + "config_file": "config/size.yml" + }, + "outputs": { + "label": "tiny", + "config_file": "config/size.yml" + } + }, + { + "name": "missing explicit config", + "fixture": {}, + "inputs": { + "config_file": "missing.yml" + }, + "error": true + }, + { + "name": "empty config", + "fixture": { + "config": { + ".github/size-labeler.yml": "# no overrides" + } + }, + "inputs": {}, + "outputs": { + "label": "size/XS" + } + }, + { + "name": "reject duplicate", + "fixture": { + "config": { + ".github/size-labeler.yml": "sizes:\n xs:\n max_size: 5\n max_size: 6" + } + }, + "inputs": {}, + "error": true + }, + { + "name": "reject multi-document", + "fixture": { + "config": { + ".github/size-labeler.yml": "{}\n---\n{}" + } + }, + "inputs": {}, + "error": true + }, + { + "name": "reject unknown", + "fixture": { + "config": { + ".github/size-labeler.yml": "unknown: true" + } + }, + "inputs": {}, + "error": true + }, + { + "name": "reject token", + "fixture": { + "config": { + ".github/size-labeler.yml": "github_token: secret" + } + }, + "inputs": {}, + "error": true + }, + { + "name": "reject invalid YAML", + "fixture": { + "config": { + ".github/size-labeler.yml": "sizes:\n xs:\n max_size: [" + } + }, + "inputs": {}, + "error": true + }, + { + "name": "reject null", + "fixture": { + "config": { + ".github/size-labeler.yml": "null" + } + }, + "inputs": {}, + "error": true + }, + { + "name": "reject bad threshold", + "fixture": { + "config": { + ".github/size-labeler.yml": "sizes:\n xs:\n max_size: 100" + } + }, + "inputs": {}, + "error": true + }, + { + "name": "reject bad boolean", + "fixture": { + "config": { + ".github/size-labeler.yml": "dry_run: yes" + } + }, + "inputs": {}, + "error": true + }, + { + "name": "reject duplicate labels", + "fixture": { + "config": { + ".github/size-labeler.yml": "sizes:\n xs:\n label: size/S" + } + }, + "inputs": {}, + "error": true + }, + { + "name": "preserve foreign labels", + "fixture": { + "labels": [ + "size/L", + "size/manual", + "bug" + ] + }, + "inputs": {}, + "outputs": { + "removed_labels": "[\"size/L\"]" + }, + "writes": [ + { + "method": "addLabels", + "labels": [ + "size/XS" + ] + }, + { + "method": "removeLabel", + "name": "size/L" + } + ] + }, + { + "name": "overwrite configured labels only", + "fixture": { + "labels": [ + "size/L", + "SIZE/manual", + "bug" + ] + }, + "inputs": { + "overwrite_size_labels": true + }, + "outputs": { + "removed_labels": "[\"size/L\"]" + } + }, + { + "name": "custom configured label name", + "fixture": { + "labels": [ + "size/L", + "review/manual", + "size/manual", + "bug" + ] + }, + "inputs": { + "overwrite_size_labels": true, + "xs_label": "tiny" + }, + "outputs": { + "label": "tiny", + "added_labels": "[\"tiny\"]", + "removed_labels": "[\"size/L\"]" + } + }, + { + "name": "idempotent case insensitive", + "fixture": { + "labels": [ + "SIZE/xs", + "bug" + ] + }, + "inputs": {}, + "outputs": { + "added_labels": "[]", + "removed_labels": "[]" + }, + "writes": [] + }, + { + "name": "dry run XL", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/S" + ] + }, + "inputs": { + "dry_run": true, + "fail_if_xl": true, + "message_if_xl": "warning" + }, + "outputs": { + "size": "xl", + "removed_labels": "[\"size/S\"]", + "dry_run": true + }, + "writes": [] + }, + { + "name": "config enables dry run", + "fixture": { + "config": { + ".github/size-labeler.yml": "dry_run: true" + } + }, + "inputs": { + "dry_run": false + }, + "outputs": { + "dry_run": true + }, + "writes": [] + }, + { + "name": "config disables dry run", + "fixture": { + "config": { + ".github/size-labeler.yml": "dry_run: false" + } + }, + "inputs": { + "dry_run": true + }, + "outputs": { + "dry_run": false + }, + "writes": [ + { + "labels": [ + "size/XS" + ], + "method": "addLabels" + } + ] + }, + { + "name": "definite XL truncation", + "fixture": { + "changedFiles": 3001, + "pages": [ + [ + { + "filename": "main.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": {}, + "outputs": { + "count_complete": false, + "size": "xl" + } + }, + { + "name": "ambiguous truncation", + "fixture": { + "changedFiles": 3001, + "pages": [ + [ + { + "filename": "main.tf", + "additions": 999, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": {}, + "error": true + }, + { + "name": "stale PR", + "fixture": { + "stale": true + }, + "inputs": {}, + "error": true + }, + { + "name": "XL comment and failure", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ] + }, + "inputs": { + "message_if_xl": "warning", + "fail_if_xl": true + }, + "writes": [ + { + "body": "\nwarning", + "method": "createComment" + } + ], + "failure": true + }, + { + "name": "XL unchanged comment", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ], + "comments": [ + { + "id": 1, + "user": { + "login": "github-actions[bot]" + }, + "body": "\nwarning" + } + ] + }, + "inputs": { + "message_if_xl": "warning" + }, + "writes": [] + }, + { + "name": "XL foreign comment", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ], + "comments": [ + { + "id": 1, + "user": { + "login": "other" + }, + "body": "\nwarning" + } + ] + }, + "inputs": { + "message_if_xl": "warning" + }, + "writes": [ + { + "body": "\nwarning", + "method": "createComment" + } + ] + }, + { + "name": "XL update comment", + "fixture": { + "pages": [ + [ + { + "filename": "main.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ], + "comments": [ + { + "id": 1, + "user": { + "login": "github-actions[bot]" + }, + "body": "\nold" + } + ] + }, + "inputs": { + "message_if_xl": "warning" + }, + "writes": [ + { + "comment_id": 1, + "body": "\nwarning", + "method": "updateComment" + } + ] + }, + { + "name": "portable glob !(a).tf", + "fixture": { + "pages": [ + [ + { + "filename": "a.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "ab.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "aa.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "b.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": { + "files_to_ignore": "!(a).tf" + }, + "outputs": { + "changed_lines": 1, + "changed_files": 1 + } + }, + { + "name": "portable glob [!a].tf", + "fixture": { + "pages": [ + [ + { + "filename": "a.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "b.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "c.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": { + "files_to_ignore": "[!a].tf" + }, + "outputs": { + "changed_lines": 1, + "changed_files": 1 + } + }, + { + "name": "portable glob ./a.tf", + "fixture": { + "pages": [ + [ + { + "filename": "a.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "./a.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "b.tf", + "additions": 1, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": { + "files_to_ignore": "./a.tf" + }, + "outputs": { + "changed_lines": 2, + "changed_files": 2 + } + }, + { + "name": "portable glob a/**", + "fixture": { + "pages": [ + [ + { + "filename": "a", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "a/b", + "additions": 1, + "deletions": 0, + "status": "modified" + }, + { + "filename": "b", + "additions": 1, + "deletions": 0, + "status": "modified" + } + ] + ] + }, + "inputs": { + "files_to_ignore": "a/**" + }, + "outputs": { + "changed_lines": 2, + "changed_files": 2 + } + }, + { + "name": "JSON schema scalar True", + "fixture": { + "pages": [ + [ + { + "filename": "large.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ], + "config": { + ".github/size-labeler.yml": "message_if_xl: True" + } + }, + "inputs": {}, + "writes": [ + { + "method": "createComment", + "body": "\nTrue" + } + ] + }, + { + "name": "JSON schema scalar FALSE", + "fixture": { + "pages": [ + [ + { + "filename": "large.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ], + "config": { + ".github/size-labeler.yml": "message_if_xl: FALSE" + } + }, + "inputs": {}, + "writes": [ + { + "method": "createComment", + "body": "\nFALSE" + } + ] + }, + { + "name": "JSON schema scalar NULL", + "fixture": { + "pages": [ + [ + { + "filename": "large.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ], + "config": { + ".github/size-labeler.yml": "message_if_xl: NULL" + } + }, + "inputs": {}, + "writes": [ + { + "method": "createComment", + "body": "\nNULL" + } + ] + }, + { + "name": "JSON schema scalar ~", + "fixture": { + "pages": [ + [ + { + "filename": "large.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ], + "config": { + ".github/size-labeler.yml": "message_if_xl: ~" + } + }, + "inputs": {}, + "writes": [ + { + "method": "createComment", + "body": "\n~" + } + ] + }, + { + "name": "JSON schema scalar 2026-10-24", + "fixture": { + "pages": [ + [ + { + "filename": "large.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ], + "config": { + ".github/size-labeler.yml": "message_if_xl: 2026-10-24" + } + }, + "inputs": {}, + "writes": [ + { + "method": "createComment", + "body": "\n2026-10-24" + } + ] + }, + { + "name": "JSON schema scalar 015", + "fixture": { + "pages": [ + [ + { + "filename": "large.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ], + "config": { + ".github/size-labeler.yml": "message_if_xl: 015" + } + }, + "inputs": {}, + "writes": [ + { + "method": "createComment", + "body": "\n015" + } + ] + }, + { + "name": "JSON schema scalar 0o17", + "fixture": { + "pages": [ + [ + { + "filename": "large.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ], + "config": { + ".github/size-labeler.yml": "message_if_xl: 0o17" + } + }, + "inputs": {}, + "writes": [ + { + "method": "createComment", + "body": "\n0o17" + } + ] + }, + { + "name": "JSON schema scalar 0xF", + "fixture": { + "pages": [ + [ + { + "filename": "large.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ], + "config": { + ".github/size-labeler.yml": "message_if_xl: 0xF" + } + }, + "inputs": {}, + "writes": [ + { + "method": "createComment", + "body": "\n0xF" + } + ] + }, + { + "name": "JSON schema scalar 1_000", + "fixture": { + "pages": [ + [ + { + "filename": "large.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ], + "config": { + ".github/size-labeler.yml": "message_if_xl: 1_000" + } + }, + "inputs": {}, + "writes": [ + { + "method": "createComment", + "body": "\n1_000" + } + ] + }, + { + "name": "JSON schema scalar .inf", + "fixture": { + "pages": [ + [ + { + "filename": "large.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ], + "config": { + ".github/size-labeler.yml": "message_if_xl: .inf" + } + }, + "inputs": {}, + "writes": [ + { + "method": "createComment", + "body": "\n.inf" + } + ] + }, + { + "name": "JSON schema scalar .nan", + "fixture": { + "pages": [ + [ + { + "filename": "large.tf", + "additions": 1000, + "deletions": 0, + "status": "modified" + } + ] + ], + "labels": [ + "size/XL" + ], + "config": { + ".github/size-labeler.yml": "message_if_xl: .nan" + } + }, + "inputs": {}, + "writes": [ + { + "method": "createComment", + "body": "\n.nan" + } + ] + }, + { + "name": "reject explicit YAML 1.1 boolean", + "fixture": { + "config": { + ".github/size-labeler.yml": "dry_run: !!bool True" + } + }, + "inputs": {}, + "error": true + }, + { + "name": "preserve existing configured label", + "fixture": { + "labels": [ + "size/L", + "size/manual", + "bug" + ] + }, + "inputs": { + "overwrite_size_labels": false + }, + "outputs": { + "label": "size/XS", + "added_labels": "[]", + "removed_labels": "[]" + }, + "writes": [] + }, + { + "name": "preserve multiple configured labels", + "fixture": { + "labels": [ + "SIZE/xs", + "size/L" + ] + }, + "inputs": { + "overwrite_size_labels": false + }, + "outputs": { + "added_labels": "[]", + "removed_labels": "[]" + }, + "writes": [] + }, + { + "name": "first label with overwrite disabled", + "fixture": { + "labels": [ + "size/manual", + "bug" + ] + }, + "inputs": { + "overwrite_size_labels": false + }, + "writes": [ + { + "method": "addLabels", + "labels": [ + "size/XS" + ] + } + ] + }, + { + "name": "file disables overwrite input", + "fixture": { + "labels": [ + "size/L" + ], + "config": { + ".github/size-labeler.yml": "overwrite_size_labels: false" + } + }, + "inputs": { + "overwrite_size_labels": true + }, + "outputs": { + "added_labels": "[]", + "removed_labels": "[]" + }, + "writes": [] + } +] diff --git a/.github/actions/github.pr.size-labeler/tests/contract.test.cjs b/.github/actions/github.pr.size-labeler/tests/contract.test.cjs new file mode 100644 index 0000000..153fbc7 --- /dev/null +++ b/.github/actions/github.pr.size-labeler/tests/contract.test.cjs @@ -0,0 +1,23 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const cases = require('./contract.json'); +const { fixture } = require('./helpers.cjs'); + +for (const scenario of cases) { + test(`contract: ${scenario.name}`, async () => { + const f = fixture(scenario.fixture); + const inputs = { message_if_xl: '', ...scenario.inputs }; + if (scenario.error) { + await assert.rejects(f.execute(inputs)); + assert.deepEqual(f.writes(), []); + return; + } + await f.execute(inputs); + for (const [key, expected] of Object.entries(scenario.outputs ?? {})) { + assert.deepEqual(f.outputs[key], expected, key); + } + const writes = f.writes().map(({ name, parameters: { owner, repo, issue_number, ...parameters } }) => ({ method: name, ...parameters })); + if (scenario.writes) assert.deepEqual(writes, scenario.writes); + assert.equal(f.failures.length, scenario.failure ? 1 : 0); + }); +} diff --git a/.github/actions/github.pr.size-labeler/tests/helpers.cjs b/.github/actions/github.pr.size-labeler/tests/helpers.cjs new file mode 100644 index 0000000..cecf5ba --- /dev/null +++ b/.github/actions/github.pr.size-labeler/tests/helpers.cjs @@ -0,0 +1,69 @@ +const assert = require('node:assert/strict'); +const { run } = require('../src/index.cjs'); +const file = (name, additions, deletions = 0, status = 'modified') => ({ filename: name, additions, deletions, status }); +const notFound = () => Object.assign(new Error('Not Found'), { status: 404 }); + +function fixture({ pages = [[file('main.tf', 10)]], config = {}, labels = [], comments = [], changedFiles, stale = false } = {}) { + const calls = []; + const outputs = {}; + const failures = []; + let reads = 0; + const pr = { + head: { sha: 'head-sha' }, base: { sha: 'trusted-base-sha' }, + changed_files: changedFiles ?? pages.flat().length, + }; + const api = (name, implementation = () => ({})) => async (parameters) => { + calls.push({ name, parameters }); + return implementation(parameters); + }; + const github = { + graphql: api('graphql', () => ({ viewer: { login: 'github-actions[bot]' } })), + rest: { + repos: { + getContent: api('getContent', ({ path }) => { + if (!Object.hasOwn(config, path)) throw notFound(); + return { data: { type: 'file', encoding: 'base64', content: Buffer.from(config[path]).toString('base64') } }; + }), + }, + pulls: { + get: api('getPull', () => ({ data: { ...pr, head: { sha: stale && reads++ ? 'new-head' : 'head-sha' } } })), + listFiles: Symbol('listFiles'), + }, + issues: { + listLabelsOnIssue: Symbol('listLabels'), + listComments: Symbol('listComments'), + addLabels: api('addLabels'), + removeLabel: api('removeLabel'), + createComment: api('createComment'), + updateComment: api('updateComment'), + }, + }, + }; + github.paginate = async (method, parameters) => { + calls.push({ name: method.description, parameters }); + if (method === github.rest.issues.listLabelsOnIssue) return labels.map((name) => ({ name })); + if (method === github.rest.issues.listComments) return comments; + throw new Error('Unexpected pagination method'); + }; + github.paginate.iterator = async function* (method, parameters) { + assert.equal(method, github.rest.pulls.listFiles); + assert.equal(parameters.per_page, 100); + for (const data of pages) { + calls.push({ name: 'filePage', parameters }); + yield { data }; + } + }; + const context = { repo: { owner: 'tedilabs', repo: 'example' }, payload: { pull_request: { number: 42 } } }; + const core = { + info() {}, warning() {}, + setOutput: (name, value) => { outputs[name] = value; }, + setFailed: (message) => { failures.push(message); }, + }; + return { + github, context, core, calls, outputs, failures, + execute: (inputs = {}, implementation = run) => implementation({ github, context, core, inputs }), + writes: () => calls.filter(({ name }) => ['addLabels', 'removeLabel', 'createComment', 'updateComment'].includes(name)), + }; +} + +module.exports = { fixture }; diff --git a/.github/actions/github.pr.size-labeler/tests/size-labeler.test.cjs b/.github/actions/github.pr.size-labeler/tests/size-labeler.test.cjs new file mode 100644 index 0000000..614a2bf --- /dev/null +++ b/.github/actions/github.pr.size-labeler/tests/size-labeler.test.cjs @@ -0,0 +1,324 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const yaml = require('js-yaml'); +const { DEFAULTS, parseConfig, resolveOptions, classify, labelChanges, run } = require('../src/index.cjs'); + +const file = (name, additions, deletions = 0, status = 'modified') => ({ filename: name, additions, deletions, status }); +const notFound = () => Object.assign(new Error('Not Found'), { status: 404 }); +const options = (extra = {}) => resolveOptions(extra); + +const { fixture } = require('./helpers.cjs'); + +test('exclusive boundaries preserve the existing XS/S/M/L/XL policy', () => { + for (const [lines, expected] of [[0, 'xs'], [14, 'xs'], [15, 's'], [99, 's'], [100, 'm'], [499, 'm'], [500, 'l'], [999, 'l'], [1000, 'xl'], [100000, 'xl']]) { + assert.equal(classify(lines, options()), expected, String(lines)); + } +}); + +test('YAML values override only provided inputs, including false and empty message', () => { + const resolved = resolveOptions({ xs_label: 'tiny', xs_max_size: '25', s_max_size: '200', xl_label: 'huge', ignore_file_deletions: 'true', message_if_xl: 'warning' }, + parseConfig('sizes: {xs: {max_size: 30}, xl: {label: review/too-large}}\nignore_file_deletions: false\nmessage_if_xl: ""')); + assert.equal(resolved.xs_max_size, 30); + assert.equal(resolved.xs_label, 'tiny'); + assert.equal(resolved.s_max_size, 200); + assert.equal(resolved.m_max_size, 500); + assert.equal(resolved.xl_label, 'review/too-large'); + assert.equal(resolved.ignore_file_deletions, false); + assert.equal(resolved.message_if_xl, ''); +}); + +test('nested size settings apply all labels and thresholds regardless of key order', () => { + const config = parseConfig(`sizes: + xl: {label: huge} + l: {label: large, max_size: 40} + m: {label: medium, max_size: 30} + s: {label: small, max_size: 20} + xs: {label: tiny, max_size: 10} +`); + const resolved = resolveOptions({}, config); + for (const [lines, size, label] of [[9, 'xs', 'tiny'], [10, 's', 'small'], [20, 'm', 'medium'], [30, 'l', 'large'], [40, 'xl', 'huge']]) { + assert.equal(classify(lines, resolved), size); + assert.equal(resolved[`${size}_label`], label); + } +}); + +test('empty size mappings retain action inputs and defaults', () => { + for (const text of ['sizes: {}', 'sizes: {xs: {}, xl: {}}']) { + assert.deepEqual(resolveOptions({ xs_label: 'tiny' }, parseConfig(text)), { ...DEFAULTS, xs_label: 'tiny' }); + } +}); + +test('invalid size mappings and fields fail before API writes', async () => { + for (const text of [ + 'sizes: null', 'sizes: []', 'sizes: true', 'sizes: xs', + 'sizes: {xs: null}', 'sizes: {xs: []}', 'sizes: {xs: 15}', 'sizes: {xl: huge}', + 'sizes: {xxl: {}}', 'sizes: {XS: {}}', 'sizes: {xs: {max: 15}}', + 'sizes: {xl: {max_size: 2000}}', 'sizes: {xs: {label: tiny, label: other}}', + 'sizes: {xs: {}, xs: {}}', 'sizes: {xs: {label: null}}', 'sizes: {xs: {label: ""}}', + 'sizes: {xs: {label: size/s}}', 'sizes: {xs: {max_size: null}}', + 'sizes: {xs: {max_size: 100}}', 'sizes: {xs: {max_size: 1.5}}', + 'sizes: {xs: {max_size: true}}', 'sizes: {xs: {max_size: 0}}', + ]) { + const f = fixture({ config: { '.github/size-labeler.yaml': text } }); + await assert.rejects(f.execute(), undefined, text); + assert.deepEqual(f.writes(), [], text); + } +}); + +test('flat size keys are rejected in config but remain valid action inputs', () => { + for (const size of ['xs', 's', 'm', 'l', 'xl']) { + for (const field of ['label', 'max_size']) { + const key = `${size}_${field}`; + assert.throws(() => parseConfig(`${key}: 15`), /Unknown configuration key/); + } + } + assert.equal(resolveOptions({ xs_label: 'tiny', xs_max_size: '20' }).xs_max_size, 20); + assert.throws(() => parseConfig('sizes: {xs: {label: tiny}}\nxs_label: old'), /Unknown configuration key/); +}); + +test('invalid YAML, duplicate keys, unknown keys and credential overrides are rejected', () => { + for (const text of ['[xs, s]', 'xs_max_size: [', 'xs_max_size: 1\nxs_max_size: 2', 'ignore_deleted_lines: true', 'github_token: secret', 'size_label_prefix: size/', 'overwrite_other_size_labels: true', 'github_api_url: https://example.com', 'config_file: other.yaml', 'pr_number: 99']) { + assert.throws(() => parseConfig(text), undefined, text); + } +}); + +test('reject invalid thresholds, booleans and duplicate labels before API writes', () => { + for (const invalid of [{ xs_max_size: 0 }, { xs_max_size: 100 }, { l_max_size: -1 }, { s_max_size: 1.5 }, { m_max_size: true }, { xs_label: 'size/S' }, { xs_label: '' }, { ignore_line_deletions: 'yes' }, { overwrite_size_labels: 'yes' }, { message_if_xl: null }, { files_to_ignore: [1] }]) { + assert.throws(() => resolveOptions({}, invalid)); + } +}); + +test('legacy quoted/whitespace-separated patterns and YAML lists are supported', () => { + assert.deepEqual(options({ files_to_ignore: '"**/*.lock"\n\'docs/a b.md\' *.sum' }).files_to_ignore, ['**/*.lock', 'docs/a b.md', '*.sum']); + assert.deepEqual(resolveOptions({}, parseConfig('files_to_ignore:\n- "**/*.lock"')).files_to_ignore, ['**/*.lock']); +}); + +test('autodiscovery reads the first available config from the PR base SHA', async () => { + const f = fixture({ config: { '.github/size-labeler.yaml': 'sizes: {xs: {max_size: 5}}', '.github/size-labeler.yml': 'sizes: {xs: {max_size: 20}}' } }); + await f.execute(); + assert.equal(f.outputs.label, 'size/S'); + assert.equal(f.outputs.config_file, '.github/size-labeler.yaml'); + const reads = f.calls.filter((c) => c.name === 'getContent'); + assert.equal(reads.length, 1); + assert.equal(reads[0].parameters.ref, 'trusted-base-sha'); + assert.equal(reads[0].parameters.repo, 'example'); +}); + +test('autodiscovery falls back to size-labeler.yml and then action inputs', async () => { + const f = fixture({ config: { '.github/size-labeler.yml': 'sizes: {xs: {max_size: 5}}' } }); + await f.execute(); + assert.equal(f.outputs.config_file, '.github/size-labeler.yml'); + assert.equal(f.outputs.label, 'size/S'); + const absent = fixture(); + await absent.execute({ xs_max_size: '5' }); + assert.equal(absent.outputs.config_file, ''); + assert.equal(absent.outputs.label, 'size/S'); +}); + +test('explicit config path does not fall back; missing path fails before writes', async () => { + const f = fixture({ config: { 'config/size.yaml': 'sizes: {xs: {max_size: 5}}' } }); + await f.execute({ config_file: 'config/size.yaml' }); + assert.equal(f.outputs.label, 'size/S'); + assert.deepEqual(f.calls.filter((c) => c.name === 'getContent').map((c) => c.parameters.path), ['config/size.yaml']); + const missing = fixture(); + await assert.rejects(missing.execute({ config_file: 'missing.yaml' }), { status: 404 }); + assert.deepEqual(missing.writes(), []); +}); + +test('permission failures are not treated as missing config', async () => { + const f = fixture(); + f.github.rest.repos.getContent = async () => { throw Object.assign(new Error('Forbidden'), { status: 403 }); }; + await assert.rejects(f.execute(), { status: 403 }); + assert.deepEqual(f.writes(), []); +}); + +test('empty configuration is valid; invalid configuration stops before writes', async () => { + const empty = fixture({ config: { '.github/size-labeler.yml': '# no overrides' } }); + await empty.execute(); + assert.equal(empty.outputs.label, 'size/XS'); + const invalid = fixture({ config: { '.github/size-labeler.yml': 'sizes: {s: {max_size: 2}}' } }); + await assert.rejects(invalid.execute(), /strictly increasing/); + assert.deepEqual(invalid.writes(), []); +}); + +test('all file pages are counted, including the file that changes the size on page two', async () => { + const f = fixture({ pages: [Array.from({ length: 100 }, (_, i) => file(`${i}.tf`, 0)), [file('last.tf', 1000)]] }); + await f.execute({ message_if_xl: '' }); + assert.equal(f.outputs.label, 'size/XL'); + assert.equal(f.outputs.changed_files, 101); + assert.equal(f.outputs.changed_lines, 1000); + assert.equal(f.outputs.count_complete, true); + assert.equal(f.calls.filter((c) => c.name === 'filePage').length, 2); +}); + +test('removed files and deleted lines have independent switches', async () => { + const pages = [[file('edited.tf', 10, 20), file('deleted.tf', 0, 100, 'removed')]]; + for (const [ignoreFiles, ignoreLines, lines, files] of [[true, false, 30, 1], [false, false, 130, 2], [true, true, 10, 1], [false, true, 10, 2]]) { + const f = fixture({ pages }); + await f.execute({ ignore_file_deletions: ignoreFiles, ignore_line_deletions: ignoreLines }); + assert.equal(f.outputs.changed_lines, lines); + assert.equal(f.outputs.changed_files, files); + } +}); + +test('glob exclusions include nested files/dotfiles; renames and binaries count normally', async () => { + const f = fixture({ pages: [[file('pkg/package.lock', 1000), file('.github/generated.json', 1000), file('docs/a b.md', 1000), file('renamed.tf', 5, 2, 'renamed'), file('image.png', 0, 0, 'added')]] }); + await f.execute({ files_to_ignore: '**/*.lock .github/** "docs/a b.md"' }); + assert.equal(f.outputs.changed_lines, 7); + assert.equal(f.outputs.changed_files, 2); +}); + +test('empty PR and fully excluded PR classify as XS', async () => { + for (const pages of [[], [[file('deleted.tf', 0, 1000, 'removed')]]]) { + const f = fixture({ pages }); + await f.execute(); + assert.equal(f.outputs.size, 'xs'); + assert.equal(f.outputs.changed_lines, 0); + } +}); + +test('API truncation reports a lower bound only if XL is certain', async () => { + const f = fixture({ changedFiles: 3001, pages: [[file('large.tf', 1000)]] }); + await f.execute({ message_if_xl: '' }); + assert.equal(f.outputs.count_complete, false); + assert.equal(f.outputs.size, 'xl'); + const ambiguous = fixture({ changedFiles: 3001, pages: [[file('small.tf', 999)]] }); + await assert.rejects(ambiguous.execute(), /cannot assign an accurate size/); + assert.deepEqual(ambiguous.writes(), []); +}); + +test('configured labels are replaced, foreign size labels and unrelated labels are preserved', () => { + assert.deepEqual(labelChanges(['size/L', 'size/manual', 'bug'], 'size/S', options()), { add: ['size/S'], remove: ['size/L'] }); + assert.deepEqual(labelChanges(['SIZE/s', 'size/XS'], 'size/S', options()), { add: [], remove: ['size/XS'] }); +}); + +test('overwrite changes only configured label names, including custom names', () => { + const configured = options({ xs_label: 'tiny', overwrite_size_labels: true }); + assert.deepEqual(labelChanges(['tiny', 'review/manual', 'size/manual', 'bug'], 'size/M', configured), { + add: ['size/M'], remove: ['tiny'], + }); +}); + +test('overwrite disabled preserves existing configured labels regardless of their author', () => { + const configured = options({ overwrite_size_labels: false }); + assert.deepEqual(labelChanges(['SIZE/l', 'size/manual', 'bug'], 'size/S', configured), { add: [], remove: [] }); + assert.deepEqual(labelChanges(['size/S', 'size/L'], 'size/S', configured), { add: [], remove: [] }); +}); + +test('overwrite disabled still adds the first configured label and preserves unrelated names', () => { + assert.deepEqual(labelChanges(['size/manual', 'bug'], 'size/S', options({ overwrite_size_labels: false })), { + add: ['size/S'], remove: [], + }); +}); + +test('preserving an existing label does not change calculated outputs or XL failure policy', async () => { + const f = fixture({ pages: [[file('big.tf', 1000)]], labels: ['size/S'] }); + await f.execute({ overwrite_size_labels: false, fail_if_xl: true, message_if_xl: '' }); + assert.equal(f.outputs.label, 'size/XL'); + assert.equal(f.outputs.added_labels, '[]'); + assert.equal(f.outputs.removed_labels, '[]'); + assert.deepEqual(f.writes(), []); + assert.equal(f.failures.length, 1); +}); + +test('normal run adds the target before removing obsolete labels and is idempotent', async () => { + const f = fixture({ labels: ['size/L', 'size/manual', 'bug'] }); + await f.execute(); + assert.deepEqual(f.writes().map((c) => c.name), ['addLabels', 'removeLabel']); + assert.deepEqual(f.writes()[0].parameters.labels, ['size/XS']); + assert.equal(f.writes()[1].parameters.name, 'size/L'); + const unchanged = fixture({ labels: ['size/XS', 'size/manual', 'bug'] }); + await unchanged.execute(); + assert.deepEqual(unchanged.writes(), []); +}); + +test('dry run outputs intended changes without comments, mutations or XL failure', async () => { + const f = fixture({ pages: [[file('big.tf', 1000)]], labels: ['size/S'] }); + await f.execute({ dry_run: true, fail_if_xl: true }); + assert.equal(f.outputs.size, 'xl'); + assert.equal(f.outputs.added_labels, '["size/XL"]'); + assert.equal(f.outputs.removed_labels, '["size/S"]'); + assert.deepEqual(f.writes(), []); + assert.deepEqual(f.failures, []); +}); + +test('config may override dry_run and other behavior inputs', async () => { + const f = fixture({ config: { '.github/size-labeler.yaml': 'dry_run: true\noverwrite_size_labels: false' }, labels: ['size/L', 'size/manual'] }); + await f.execute({ dry_run: false, overwrite_size_labels: true }); + assert.equal(f.outputs.dry_run, true); + assert.equal(f.outputs.added_labels, '[]'); + assert.equal(f.outputs.removed_labels, '[]'); + assert.deepEqual(f.writes(), []); +}); + +test('XL comments are sticky, scoped to the token author, and separate from failure policy', async () => { + const marker = ''; + const comments = [{ id: 1, user: { login: 'someone-else' }, body: `${marker}\nold` }]; + const f = fixture({ pages: [[file('big.tf', 1000)]], comments }); + await f.execute({ message_if_xl: 'warning', fail_if_xl: true }); + assert.equal(f.writes().at(-1).name, 'createComment'); + assert.equal(f.writes().at(-1).parameters.body, `${marker}\nwarning`); + assert.equal(f.failures.length, 1); + comments.push({ id: 2, user: { login: 'github-actions[bot]' }, body: `${marker}\nwarning` }); + const same = fixture({ pages: [[file('big.tf', 1000)]], comments, labels: ['size/XL'] }); + await same.execute({ message_if_xl: 'warning' }); + assert.deepEqual(same.writes(), []); + const updated = fixture({ pages: [[file('big.tf', 1000)]], comments, labels: ['size/XL'] }); + await updated.execute({ message_if_xl: 'new warning' }); + assert.equal(updated.writes()[0].name, 'updateComment'); + assert.equal(updated.writes()[0].parameters.comment_id, 2); +}); + +test('empty XL message disables comments; fail_if_xl still works', async () => { + const f = fixture({ pages: [[file('big.tf', 1000)]], labels: ['size/XL'] }); + await f.execute({ message_if_xl: '', fail_if_xl: true }); + assert.deepEqual(f.writes(), []); + assert.equal(f.calls.some((c) => c.name === 'graphql'), false); + assert.equal(f.failures.length, 1); +}); + +test('PR updates during calculation prevent stale writes', async () => { + const f = fixture({ stale: true }); + await assert.rejects(f.execute(), /PR changed during size calculation/); + assert.deepEqual(f.writes(), []); +}); + +test('pr_number supports non-PR events and rejects missing context', async () => { + const f = fixture(); + f.context.payload = {}; + await assert.rejects(f.execute(), /pr_number/); + await f.execute({ pr_number: '123' }); + assert.equal(f.calls.find((c) => c.name === 'getPull').parameters.pull_number, 123); +}); + +test('label removal tolerates only a concurrently removed label', async () => { + const f = fixture({ labels: ['size/L'] }); + f.github.rest.issues.removeLabel = async () => { throw notFound(); }; + await f.execute(); + const forbidden = fixture({ labels: ['size/L'] }); + forbidden.github.rest.issues.removeLabel = async () => { throw Object.assign(new Error('Forbidden'), { status: 403 }); }; + await assert.rejects(forbidden.execute(), { status: 403 }); +}); + +test('action metadata defaults match policy defaults', () => { + const metadata = yaml.load(readFileSync(path.join(__dirname, '../action.yaml'), 'utf8')); + const inputs = Object.fromEntries(Object.entries(metadata.inputs).map(([key, value]) => [key, value.default])); + assert.deepEqual(resolveOptions(inputs), DEFAULTS); +}); + +test('the actual composite script invokes the source with environment inputs', async () => { + const metadata = yaml.load(readFileSync(path.join(__dirname, '../action.yaml'), 'utf8')); + const step = metadata.runs.steps.find((step) => step.id === 'label'); + assert.equal(step.uses, 'actions/github-script@v9'); + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const script = new AsyncFunction('require', 'process', 'github', 'context', 'core', step.with.script); + const f = fixture({ pages: [[file('file.tf', 15)]] }); + await script(require, { env: { + SIZE_LABELER_RUNTIME_DIR: path.resolve(__dirname, '..'), + SIZE_LABELER_INPUTS: JSON.stringify({ dry_run: true }), + } }, f.github, f.context, f.core); + assert.equal(f.outputs.label, 'size/S'); + assert.deepEqual(f.writes(), []); +}); diff --git a/.github/workflows/github.pr.auto-label.yaml b/.github/workflows/github.pr.auto-label.yaml index 916e02a..8acfb19 100644 --- a/.github/workflows/github.pr.auto-label.yaml +++ b/.github/workflows/github.pr.auto-label.yaml @@ -43,6 +43,44 @@ on: required: false default: true description: "(Optional) Whether enabled `pr-size-labeler`. Defaults to `true`." + pr_size_config_file: + type: string + required: false + description: "(Optional) Repository-relative config path at the PR base commit. When omitted, tries `.github/size-labeler.yaml`, then `.github/size-labeler.yml`. File settings override pr_size inputs." + pr_size_overwrite_size_labels: + type: boolean + required: false + default: true + description: "(Optional) Whether to overwrite existing configured size labels, including manually applied labels. When false, add a size label only if none of the configured labels exists. Defaults to `true`." + pr_size_files_to_ignore: + type: string + required: false + description: "(Optional) Whitespace-separated glob patterns excluded from size calculations." + pr_size_ignore_file_deletions: + type: boolean + required: false + default: true + description: "(Optional) Exclude completely removed files. Defaults to `true`." + pr_size_ignore_line_deletions: + type: boolean + required: false + default: false + description: "(Optional) Count additions only. Defaults to `false`." + pr_size_fail_if_xl: + type: boolean + required: false + default: false + description: "(Optional) Fail after labeling an XL PR. Defaults to `false`." + pr_size_message_if_xl: + type: string + required: false + default: "This PR has too many changes. Please make sure you are NOT addressing multiple issues with one PR." + description: "(Optional) Sticky comment for XL PRs. Set to an empty string to disable." + pr_size_dry_run: + type: boolean + required: false + default: false + description: "(Optional) Calculate size without modifying labels/comments or enforcing XL failure. Defaults to `false`." pr_size_xs_label: type: string required: false @@ -101,6 +139,15 @@ on: labeler_all_labels: value: ${{ jobs.labeler.outputs.all_labels }} description: "A comma-separated list of all labels that the PR contains." + pr_size_label: + value: ${{ jobs.pr-size-labeler.outputs.label }} + description: "The calculated size label." + pr_size_changed_lines: + value: ${{ jobs.pr-size-labeler.outputs.changed_lines }} + description: "Included changed lines; a lower bound if pr_size_count_complete is false." + pr_size_count_complete: + value: ${{ jobs.pr-size-labeler.outputs.count_complete }} + description: "Whether all changed files were counted." jobs: @@ -130,11 +177,21 @@ jobs: runs-on: ${{ fromJson(inputs.runs_on) }} if: ${{ inputs.pr_size_enabled }} + outputs: + label: ${{ steps.size-labeler.outputs.label }} + changed_lines: ${{ steps.size-labeler.outputs.changed_lines }} + count_complete: ${{ steps.size-labeler.outputs.count_complete }} + steps: - name: Add PR Size Labels for PR - uses: codelytv/pr-size-labeler@v1 + id: size-labeler + uses: tedilabs/github-actions/.github/actions/github.pr.size-labeler@main with: - GITHUB_TOKEN: ${{ secrets.token }} + github_token: ${{ secrets.token }} + config_file: ${{ inputs.pr_size_config_file }} + overwrite_size_labels: ${{ inputs.pr_size_overwrite_size_labels }} + files_to_ignore: ${{ inputs.pr_size_files_to_ignore }} + dry_run: ${{ inputs.pr_size_dry_run }} xs_label: ${{ inputs.pr_size_xs_label }} xs_max_size: ${{ inputs.pr_size_xs_max }} s_label: ${{ inputs.pr_size_s_label }} @@ -144,10 +201,8 @@ jobs: l_label: ${{ inputs.pr_size_l_label }} l_max_size: ${{ inputs.pr_size_l_max }} xl_label: ${{ inputs.pr_size_xl_label }} - fail_if_xl: 'false' - message_if_xl: > - 'This PR has too many changes. - Please make sure you are NOT addressing multiple issues with one PR.' - ignore_line_deletions: 'false' - ignore_file_deletions: 'true' + fail_if_xl: ${{ inputs.pr_size_fail_if_xl }} + message_if_xl: ${{ inputs.pr_size_message_if_xl }} + ignore_line_deletions: ${{ inputs.pr_size_ignore_line_deletions }} + ignore_file_deletions: ${{ inputs.pr_size_ignore_file_deletions }} diff --git a/.github/workflows/github.pr.size-labeler.test.yaml b/.github/workflows/github.pr.size-labeler.test.yaml new file mode 100644 index 0000000..866a8ce --- /dev/null +++ b/.github/workflows/github.pr.size-labeler.test.yaml @@ -0,0 +1,75 @@ +name: Test - PR Size Labeler + + +on: + pull_request: + paths: + - .github/actions/github.pr.size-labeler/** + - .github/workflows/github.pr.size-labeler.test.yaml + - .github/workflows/github.pr.auto-label.yaml + push: + branches: + - main + paths: + - .github/actions/github.pr.size-labeler/** + - .github/workflows/github.pr.size-labeler.test.yaml + - .github/workflows/github.pr.auto-label.yaml + workflow_dispatch: {} + + +permissions: + contents: read + pull-requests: read + +jobs: + test: + name: Test (${{ matrix.runner }}) + strategy: + fail-fast: false + matrix: + runner: + - ubuntu-24.04 + - ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + + defaults: + run: + working-directory: .github/actions/github.pr.size-labeler + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "24" + package-manager-cache: false + + - name: Install Dependencies + run: npm ci --ignore-scripts + + - name: Test + run: npm test + + - name: Smoke Test Composite Action + id: smoke + if: github.event_name == 'pull_request' + uses: ./.github/actions/github.pr.size-labeler + with: + dry_run: "true" + + - name: Check Smoke Test Outputs + if: github.event_name == 'pull_request' + shell: bash + env: + SIZE: ${{ steps.smoke.outputs.size }} + DRY_RUN: ${{ steps.smoke.outputs.dry_run }} + run: | + test "$DRY_RUN" = true + case "$SIZE" in + xs|s|m|l|xl) ;; + *) exit 1 ;; + esac