Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions .github/workflows/test-auto-label-pr-sdlc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
name: Test SDLC / Auto Label PR action

on:
workflow_dispatch:
inputs:
pr_number:
description: "PR number to test against (required for matching tests)"
required: false
pull_request:
paths:
- "auto-label-pr-sdlc/**"
- ".github/workflows/test-auto-label-pr-sdlc.yml"

permissions: {}

jobs:
test-add-mode:
name: Test add mode (dry-run, should succeed)
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Run auto-label-pr-sdlc (add mode, dry-run)
uses: ./auto-label-pr-sdlc
with:
pr-number: ${{ github.event.pull_request.number || inputs.pr_number }}
pr-labels: "[]"
mode: add
dry-run: "true"
config-path: auto-label-pr-sdlc/tests/fixtures/label-pr.json
github-token: ${{ secrets.GITHUB_TOKEN }}

test-replace-mode:
name: Test replace mode preserves non-type labels (dry-run, should succeed)
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Run auto-label-pr-sdlc (replace mode, dry-run)
uses: ./auto-label-pr-sdlc
with:
pr-number: ${{ github.event.pull_request.number || inputs.pr_number }}
pr-labels: '[{"name":"hold"},{"name":"t:bug"}]'
mode: replace
dry-run: "true"
config-path: auto-label-pr-sdlc/tests/fixtures/label-pr.json
github-token: ${{ secrets.GITHUB_TOKEN }}

test-invalid-pr-number:
name: Test invalid PR number (should fail)
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Run auto-label-pr-sdlc (invalid PR number)
uses: ./auto-label-pr-sdlc
with:
pr-number: not-a-number
pr-labels: "[]"
dry-run: "true"
config-path: auto-label-pr-sdlc/tests/fixtures/label-pr.json
github-token: ${{ secrets.GITHUB_TOKEN }}

test-path-traversal:
name: Test config path traversal blocked (should fail)
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Run auto-label-pr-sdlc (path traversal config)
uses: ./auto-label-pr-sdlc
with:
pr-number: "1"
pr-labels: "[]"
dry-run: "true"
config-path: ../../../etc/passwd
github-token: ${{ secrets.GITHUB_TOKEN }}

validate:
name: Validate Test Results
runs-on: ubuntu-24.04
needs:
- test-add-mode
- test-replace-mode
- test-invalid-pr-number
- test-path-traversal
if: always()
permissions:
contents: read
env:
_ADD_MODE: ${{ needs.test-add-mode.result }}
_REPLACE_MODE: ${{ needs.test-replace-mode.result }}
_INVALID_PR_NUMBER: ${{ needs.test-invalid-pr-number.result }}
_PATH_TRAVERSAL: ${{ needs.test-path-traversal.result }}
steps:
- name: Validate results
run: |
failed=false

check_result() {
local name="$1"
local result="$2"
local expected="$3"
if [[ "$result" == "$expected" ]]; then
echo "βœ… [$name] correctly got [$result]" | tee -a $GITHUB_STEP_SUMMARY
else
echo "❌ [$name] expected [$expected] but got [$result]" | tee -a $GITHUB_STEP_SUMMARY
failed=true
fi
}

check_result "test-add-mode" "$_ADD_MODE" "success"
check_result "test-replace-mode" "$_REPLACE_MODE" "success"
check_result "test-invalid-pr-number" "$_INVALID_PR_NUMBER" "failure"
check_result "test-path-traversal" "$_PATH_TRAVERSAL" "failure"

if [[ "$failed" == "true" ]]; then
echo "❌ One or more test results were unexpected. See above for details." | tee -a $GITHUB_STEP_SUMMARY
exit 1
fi

echo "βœ… All test results are as expected." | tee -a $GITHUB_STEP_SUMMARY
108 changes: 108 additions & 0 deletions auto-label-pr-sdlc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# SDLC / Auto Label PR Composite Action

## Description

The `auto-label-pr-sdlc` composite action labels pull requests based on changed file paths and PR title patterns (conventional commit format). Each repository provides its own `label-pr.json` config defining which labels to apply for which file paths or title prefixes.

## Key Features

- **Title-based labeling**: Matches conventional commit prefixes (e.g. `feat:`, `fix(scope):`) to `t:*` labels
- **Path-based labeling**: Matches changed file paths against configurable prefix patterns
- **Negation patterns**: Supports `.gitignore`-style `!` exclusion patterns within path rules
- **Add or replace mode**: Either appends labels or does a full replace (preserving non-`t:`/`app:` labels like `hold`, `needs-qa`)
- **Dry-run support**: Preview what labels would be applied without mutating the PR

## How to use it

### 1. Add a config file to your repository

Create `.github/label-pr.json` in your repository:

```json
{
"title_patterns": {
"t:feature": ["feat", "feature"],
"t:bugfix": ["fix", "bug"],
"t:tech-debt": ["refactor", "chore", "cleanup"],
"t:docs": ["docs"],
"t:ci": ["ci", "build"],
"t:deps": ["deps"]
},
"path_patterns": {
"t:ci": [".github/", "scripts/"],
"t:docs": ["docs/", "README.md"]
}
}
```

Both `title_patterns` and `path_patterns` keys are required.

**Title matching**: checks for `<pattern>:` or `<pattern>(` anywhere in the lowercased PR title (conventional commit format).

**Path matching**: checks if any changed file path starts with the pattern string. Prefix a pattern with `!` to exclude previously matched files (last match wins, like `.gitignore`).

### 2. Add a workflow to your repository

```yaml
name: Label PR
run-name: Label PR #${{ github.event.pull_request.number }}

on:
pull_request:
types: [opened, synchronize, reopened]

permissions: {}

jobs:
label:
name: Label PR
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Label PR
uses: bitwarden/gh-actions/auto-label-pr-sdlc@main
with:
pr-number: ${{ github.event.pull_request.number }}
pr-labels: ${{ toJSON(github.event.pull_request.labels) }}
mode: replace
github-token: ${{ secrets.GITHUB_TOKEN }}
```

## Inputs

| Input | Required | Default | Description |
|-------|----------|---------|-------------|
| `pr-number` | Yes | β€” | The pull request number |
| `pr-labels` | No | `[]` | Current PR labels as JSON array (e.g. `toJSON(github.event.pull_request.labels)`) |
| `mode` | No | `add` | `add` appends labels; `replace` rewrites `t:`/`app:` labels while preserving others |
| `dry-run` | No | `false` | Set to `true` to preview without applying |
| `config-path` | No | `.github/label-pr.json` | Path to config JSON, relative to workspace root |
| `github-token` | Yes | β€” | GitHub token with `pull-requests: write` permission |

## Mode behavior

- **`add`**: Calls `gh pr edit --add-label`. Existing labels are never removed.
- **`replace`**: Calls `PATCH /issues/:number` to set the exact label list. Labels that don't start with `t:` or `app:` (e.g. `hold`, `needs-qa`, `DB-migrations-changed`) are preserved automatically.

## Requirements

- The repository must have the relevant labels created (e.g. `t:feature`, `t:bugfix`).
- The runner must have Python 3.9+ and the `gh` CLI available (both are present on all GitHub-hosted runners).

## Troubleshooting

### No labels applied

- Check that `label-pr.json` has both `title_patterns` and `path_patterns` keys.
- Verify the labels referenced in the config exist in the repository.
- Run with `dry-run: 'true'` to see what would be matched without applying.

### Config file not found

- Ensure `actions/checkout` runs before this action so the workspace is populated.
- The `config-path` is relative to the workspace root; the default is `.github/label-pr.json`.
56 changes: 56 additions & 0 deletions auto-label-pr-sdlc/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: "SDLC / Auto Label PR"
description: "Label pull requests based on changed file paths and PR title patterns (conventional commit format)"
author: "Bitwarden"
branding:
icon: tag
color: blue

inputs:
pr-number:
description: "The pull request number"
required: true
pr-labels:
description: "Current PR labels as JSON array string (e.g. '[{\"name\":\"label1\"}]')"
required: false
default: "[]"
mode:
description: "'add' (default) adds labels without removing existing ones; 'replace' rewrites t:/app: labels while preserving others (e.g. hold, needs-qa)"
required: false
default: "add"
dry-run:
description: "Set to 'true' to run without applying labels"
required: false
default: "false"
config-path:
description: "Path to label config JSON file, relative to the workspace root (default: .github/label-pr.json)"
required: false
default: ".github/label-pr.json"
github-token:
description: "GitHub token with pull-requests:write permission"
required: true

runs:
using: "composite"
steps:
- name: Label PR
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
PR_NUMBER: ${{ inputs.pr-number }}
PR_LABELS: ${{ inputs.pr-labels }}
MODE: ${{ inputs.mode }}
DRY_RUN: ${{ inputs.dry-run }}
CONFIG_PATH: ${{ inputs.config-path }}
run: |
MODE_FLAG=""
[[ "$MODE" == "replace" ]] && MODE_FLAG="--replace"

DRY_RUN_FLAG=""
[[ "$DRY_RUN" == "true" ]] && DRY_RUN_FLAG="--dry-run"

python3 "${{ github.action_path }}/label-pr.py" \
"$PR_NUMBER" \
"$PR_LABELS" \
$MODE_FLAG \
$DRY_RUN_FLAG \
--config "$CONFIG_PATH"
Loading
Loading