diff --git a/.claude/rules/pr-description.md b/.claude/rules/pr-description.md index 0abf849db386f8..05ff9abf58e0cc 100644 --- a/.claude/rules/pr-description.md +++ b/.claude/rules/pr-description.md @@ -15,19 +15,22 @@ When creating pull requests for sentry-docs, use the following format: ## IS YOUR CHANGE URGENT? Help us prioritize incoming PRs by letting us know when the change needs to go live. -- [ ] Urgent deadline (GA date, etc.): -- [ ] Other deadline: -- [x] None: Not urgent, can wait up to 1 week+ +Select exactly one option. For deadlines, replace `YYYY-MM-DD` with the due date. You can update this information later by editing the PR description. + +- [ ] Urgent deadline (GA date, etc.): YYYY-MM-DD +- [ ] Other deadline: YYYY-MM-DD +- [x] No deadline: Not urgent, can wait up to 1 week+ ## SLA - Teamwork makes the dream work, so please add a reviewer to your PRs. -- Please give the docs team up to 1 week to review your PR unless you've added an urgent due date to it. +- Please give the docs team up to 1 week to review your PR unless you've supplied a deadline. + Thanks in advance for your help! ## PRE-MERGE CHECKLIST -*Make sure you've checked the following before merging your changes:* +_Make sure you've checked the following before merging your changes:_ - [ ] Checked Vercel preview for correctness, including links - [ ] PR was reviewed and approved by any necessary SMEs (subject matter experts) @@ -36,7 +39,9 @@ Thanks in advance for your help! ## Notes -- Default urgency to "None" unless the user specifies otherwise +- Keep the "IS YOUR CHANGE URGENT?" section in every PR description +- Default urgency to "No deadline" unless the user specifies otherwise +- Use `YYYY-MM-DD` for urgent and other deadline dates - Include preview URLs when relevant (Vercel deploys preview URLs automatically) - Keep the "DESCRIBE YOUR PR" section concise but informative - Use bullet points for multiple discrete changes diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e459399c5d55e4..98a26d825870d7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,24 +1,28 @@ - + ## DESCRIBE YOUR PR -*Tell us what you're changing and why. If your PR **resolves an issue**, please link it so it closes automatically.* -## IS YOUR CHANGE URGENT? +_Tell us what you're changing and why. If your PR **resolves an issue**, please link it so it closes automatically._ + +## IS YOUR CHANGE URGENT? Help us prioritize incoming PRs by letting us know when the change needs to go live. -- [ ] Urgent deadline (GA date, etc.): -- [ ] Other deadline: -- [ ] None: Not urgent, can wait up to 1 week+ +Select exactly one option. For deadlines, replace `YYYY-MM-DD` with the due date. You can update this information later by editing the PR description. + +- [ ] Urgent deadline (GA date, etc.): YYYY-MM-DD +- [ ] Other deadline: YYYY-MM-DD +- [ ] No deadline: Not urgent, can wait up to 1 week+ ## SLA - Teamwork makes the dream work, so please add a reviewer to your PRs. -- Please give the docs team up to 1 week to review your PR unless you've added an urgent due date to it. +- Please give the docs team up to 1 week to review your PR unless you've supplied a deadline. + Thanks in advance for your help! ## PRE-MERGE CHECKLIST -*Make sure you've checked the following before merging your changes:* +_Make sure you've checked the following before merging your changes:_ - [ ] Checked Vercel preview for correctness, including links - [ ] PR was reviewed and approved by any necessary SMEs (subject matter experts) diff --git a/.github/labels.yml b/.github/labels.yml index 759b3499139c1e..a5e3ec38bc1804 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -17,23 +17,33 @@ color: F6F6F8 description: Pull requests that update a dependency file -# Issue types +# Docs issue categories +- name: Docs + color: ededed + description: Documentation content issues +- name: 'Docs Content' + color: 26b5ce +- name: 'Docs Platform' + color: ededed + description: Documentation site and tooling issues + +# Legacy issue types - name: 'Type: Content' color: FBCA04 -- name: 'Type: Platform' - color: 1D76DB -# -- name: 'Develop' - color: F6F6F8 - -# Impacts -- name: 'Impact: Large' - color: C83852 -- name: 'Impact: Medium' - color: FFB287 -- name: 'Impact: Small' - color: '452650' +# PR priorities +- name: 'Priority: Urgent' + color: B60205 + description: Docs review is needed immediately for an urgent deadline +- name: 'Priority: Deadline' + color: D93F0B + description: Docs review has a specific non-urgent due date +- name: 'Priority: Needs Triage' + color: FBCA04 + description: The PR description is missing valid priority information +- name: 'Priority: Normal' + color: 0E8A16 + description: Docs review has no urgent deadline # Platforms - name: 'Platform: .NET' @@ -94,6 +104,10 @@ color: '8D5494' - name: 'Stale' color: '8D5494' +- name: 'State: Unreproducible' + color: f2994a +- name: "State: Won't Do" + color: 95a2b3 # Product Areas - www.notion.so/sentry/473791bae5bf43399d46093050b77bf0 - name: 'Product Area: Unknown' @@ -246,8 +260,9 @@ description: team-web-sdk-backend - name: 'Team: Workflow' color: 8D5494 -- name: 'All SDKs' - color: 1D76DB +- name: 'Team: SDKs' + color: ededed + description: Issues that affect all or multiple SDK teams - name: 'SDKs' color: 1D76DB - name: 'Develop' @@ -256,8 +271,6 @@ color: 8D5494 - name: 'Improvement' color: 8D5494 -- name: 'Launch 🚀' - color: 8D5494 - name: 'Product' color: 8D5494 @@ -282,12 +295,6 @@ color: 1279F1 - name: duplicate color: F6F6F8 -- name: 'Effort: Large' - color: FBCA04 -- name: 'Effort: Medium' - color: FBCA04 -- name: 'Effort: Small' - color: FBCA04 - name: enhancement color: F6F6F8 - name: filler @@ -310,7 +317,7 @@ color: F6F6F8 description: Pull requests that update Javascript code - name: Mobile - color: '584774' + color: f2c94c - name: performance color: '584774' - name: up-for-grabs @@ -335,3 +342,21 @@ color: F6F6F8 - name: github_actions color: F6F6F8 +- name: arcade + color: ededed +- name: auth-complex + color: ededed +- name: docs-screenshots + color: ededed +- name: documentation + color: ededed +- name: jared + color: 943CE4 +- name: Playwright + color: ededed +- name: Security + color: ededed +- name: stale-image + color: ededed +- name: Supply-Chain-Vuln + color: ededed diff --git a/.github/workflows/codeowner_assignment.yaml b/.github/workflows/codeowner_assignment.yaml index dddd4cdf09452d..c6dc951c4e5a93 100644 --- a/.github/workflows/codeowner_assignment.yaml +++ b/.github/workflows/codeowner_assignment.yaml @@ -1,18 +1,40 @@ name: Codeowner assignment -on: [pull_request] +on: + pull_request_target: + types: [opened, edited, reopened, synchronize, ready_for_review] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to evaluate + required: true + type: number + dry_run: + description: Log reviewer decisions without requesting reviewers + required: true + default: true + type: boolean + +permissions: + contents: read jobs: codeowner_assignment: name: Codeowner assignment + if: >- + github.event_name == 'workflow_dispatch' || + github.event.action != 'edited' || + github.event.changes.body != null + concurrency: + group: >- + codeowner-assignment-${{ github.event.pull_request.number || inputs.pr_number }}-${{ + github.event_name == 'workflow_dispatch' && inputs.dry_run && github.run_id || + github.event.action == 'edited' && github.run_id || 'assignment' }} + cancel-in-progress: true runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write + timeout-minutes: 5 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: token with: @@ -21,116 +43,45 @@ jobs: owner: ${{ github.repository_owner }} repositories: | sentry-docs + permission-pull-requests: write - - name: Parse CODEOWNERS and assign reviewers - id: assign-reviewers + - name: Resolve trusted base commit + id: base env: - PR_NUMBER: ${{ github.event.number }} + EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + REPOSITORY: ${{ github.repository }} GH_TOKEN: ${{ steps.token.outputs.token }} run: | - # locate CODEOWNERS file exists - if [[ -f .github/CODEOWNERS ]]; then - codeowner_path=".github/CODEOWNERS" - elif [[ -f CODEOWNERS ]]; then - codeowner_path="CODEOWNERS" - else - echo "CODEOWNERS file not found, skipping." - exit 0 + base_sha="$EVENT_BASE_SHA" + if [[ -z "$base_sha" ]]; then + base_sha=$(gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" --jq '.base.sha') fi + if [[ ! "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid base commit SHA" >&2 + exit 1 + fi + echo "sha=$base_sha" >> "$GITHUB_OUTPUT" - # Get changed files in the PR - CHANGED_FILES=$(gh pr view $PR_NUMBER --json files --jq '.files[].path') - - echo "----------------------------------------" - echo "Changed files:" - echo "$CHANGED_FILES" | tr ' ' '\n' | sed 's/^/- /' - echo "----------------------------------------" - - # Get existing reviewers - # merge requested teams and users into a single array - REQUESTED_REVIEWERS=$(gh pr view $PR_NUMBER --json reviewRequests --jq '[.reviewRequests[] | if .__typename == "Team" then .slug else .login end]') - echo "Requested reviewers:" - echo "$REQUESTED_REVIEWERS" | tr ' ' '\n' | sed 's/^/- /' - echo "----------------------------------------" - - # Parse CODEOWNERS and find commented lines - # Add newline to the end of the file if it doesn't have one, otherwise sed will not read the last line - sed -i -e '$a\' $codeowner_path - - while read -r LINE; do - # Skip lines that are not commented, GitHub will take care of un-commented lines - if [[ ! "$LINE" =~ ^# ]]; then continue; fi - - # Extract pattern and reviewer from the commented line - PATTERN=$(echo "$LINE" | sed -E 's/^#\s*([^@]+).*$/\1/' | xargs) - # Capture both individual users and GitHub teams reviewers that have "/" in the name - REVIEWERS=$(echo "$LINE" | grep -o "@[a-zA-Z0-9_-]\+\(/[a-zA-Z0-9_-]\+\)\?" | tr '\n' ' ' | xargs) - - # Skip if no reviewers found - if [[ -z "$REVIEWERS" ]]; then - continue - fi + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ steps.base.outputs.sha }} + persist-credentials: false - # Convert pattern to a regex for matching - REGEX_PATTERN=$(echo "$PATTERN" | sed -e 's/\./\\./g' -e 's/\*/.*/g' -e 's/\?/./g' -e 's|^/||') + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4 + with: + node-version-file: package.json - # Match changed files to the pattern - for FILE in $CHANGED_FILES; do - # For glob patterns(e.g. "**/"), use a different matching approach - if [[ "$PATTERN" == *"*"* ]]; then - # Special handling for **/ pattern - if [[ "$PATTERN" == "**/"* ]]; then - # Get the filename part after **/ - FILENAME=${PATTERN#**/} - # Match either the filename directly or with any path prefix - if [[ "$FILE" == "$FILENAME" ]] || [[ "$FILE" == */"$FILENAME" ]]; then - echo "File $FILE matches glob pattern $PATTERN" - # Assign each reviewer - for REVIEWER in $REVIEWERS; do - # Remove @ symbol from reviewer name - REVIEWER_NAME=${REVIEWER#@} - if [[ "$REQUESTED_REVIEWERS" == *"$REVIEWER_NAME"* ]]; then - echo " - $REVIEWER_NAME is already a requested reviewer, skipping" - else - echo " - Assigning $REVIEWER_NAME to review changes in $FILE" - gh pr edit $PR_NUMBER --add-reviewer "$REVIEWER_NAME" - fi - done - fi - else - # Convert other glob patterns to regex for matching - GLOB_PATTERN=$(echo "$PATTERN" | sed -e 's/\./\\./g' -e 's/\*/.*/g' -e 's/\?/./g') - if [[ "$FILE" =~ $GLOB_PATTERN ]]; then - echo "File $FILE matches glob pattern $PATTERN" - # Assign each reviewer - for REVIEWER in $REVIEWERS; do - # Remove @ symbol from reviewer name - REVIEWER_NAME=${REVIEWER#@} - if [[ "$REQUESTED_REVIEWERS" == *"$REVIEWER_NAME"* ]]; then - echo " - $REVIEWER_NAME is already a requested reviewer, skipping" - else - echo " - Assigning $REVIEWER_NAME to review changes in $FILE" - gh pr edit $PR_NUMBER --add-reviewer "$REVIEWER_NAME" - fi - done - fi - fi - else - # Original directory matching logic - if [[ "$FILE" == ${REGEX_PATTERN}* ]]; then - echo "File $FILE matches pattern $PATTERN" - # Assign each reviewer - for REVIEWER in $REVIEWERS; do - # Remove @ symbol from reviewer name - REVIEWER_NAME=${REVIEWER#@} - if [[ "$REQUESTED_REVIEWERS" == *"$REVIEWER_NAME"* ]]; then - echo " - $REVIEWER_NAME is already a requested reviewer, skipping" - else - echo " - Assigning $REVIEWER_NAME to review changes in $FILE" - gh pr edit $PR_NUMBER --add-reviewer "$REVIEWER_NAME" - fi - done - fi - fi - done - done < $codeowner_path + - name: Evaluate CODEOWNERS and assign reviewers + env: + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + REPOSITORY: ${{ github.repository }} + EVENT_ACTION: ${{ github.event.action || 'workflow_dispatch' }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} + GH_TOKEN: ${{ steps.token.outputs.token }} + run: | + args=() + if [[ "$DRY_RUN" == "true" ]]; then + args+=(--dry-run) + fi + node scripts/assign-pr-reviewers.mjs "${args[@]}" diff --git a/.github/workflows/pr-priority-metadata.yml b/.github/workflows/pr-priority-metadata.yml new file mode 100644 index 00000000000000..7ef924be4ef743 --- /dev/null +++ b/.github/workflows/pr-priority-metadata.yml @@ -0,0 +1,77 @@ +name: PR priority metadata + +on: + pull_request_target: + types: [opened, edited, reopened, ready_for_review, converted_to_draft] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to evaluate + required: true + type: number + dry_run: + description: Log metadata decisions without changing the pull request + required: true + default: true + type: boolean + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + priority_metadata: + name: Priority metadata + if: >- + github.event_name == 'workflow_dispatch' || + github.event.action != 'edited' || + github.event.changes.body != null + concurrency: + group: >- + priority-metadata-${{ github.event.pull_request.number || inputs.pr_number }}-${{ + github.event_name == 'workflow_dispatch' && inputs.dry_run && github.run_id || 'event' }} + cancel-in-progress: true + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Resolve trusted base commit + id: base + env: + EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + run: | + base_sha="$EVENT_BASE_SHA" + if [[ -z "$base_sha" ]]; then + base_sha=$(gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" --jq '.base.sha') + fi + if [[ ! "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid base commit SHA" >&2 + exit 1 + fi + echo "sha=$base_sha" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ steps.base.outputs.sha }} + persist-credentials: false + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v4 + with: + node-version-file: package.json + + - name: Synchronize priority metadata + env: + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + REPOSITORY: ${{ github.repository }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} + GH_TOKEN: ${{ github.token }} + run: | + args=() + if [[ "$DRY_RUN" == "true" ]]; then + args+=(--dry-run) + fi + node scripts/sync-pr-priority-metadata.mjs "${args[@]}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea07211516cd50..741b244e1554bb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: description: This hook runs Prettier on JavaScript and TypeScript files entry: prettier language: node - files: '\.[jt]sx?$' + files: '\.[cm]?[jt]sx?$' exclude: 'node_modules' types: [text] args: ['--write'] @@ -39,7 +39,7 @@ repos: description: This hook runs eslint on JavaScript and TypeScript files entry: eslint language: node - files: '\.[jt]sx?$' + files: '\.[cm]?[jt]sx?$' exclude: 'node_modules' types: [text] - repo: https://github.com/crate-ci/typos diff --git a/scripts/assign-pr-reviewers.mjs b/scripts/assign-pr-reviewers.mjs new file mode 100644 index 00000000000000..da63a517ba0459 --- /dev/null +++ b/scripts/assign-pr-reviewers.mjs @@ -0,0 +1,117 @@ +import fs from 'node:fs/promises'; + +import { + evaluateDocsReview, + getPriorityAlertReason, + parsePriority, + shouldRequestDocsReviewForEvent, +} from './docs-pr-triage.mjs'; +import {createGitHubClient, getGitHubRuntime} from './github-api.mjs'; +import {buildReviewerPlan} from './pr-reviewer-assignment.mjs'; + +const {token, repository, pullRequestNumber, apiBase} = getGitHubRuntime(); +const eventAction = process.env.EVENT_ACTION || 'workflow_dispatch'; +const dryRun = process.argv.includes('--dry-run'); +const [repositoryOwner] = repository.split('/'); +const {request: github, paginate} = createGitHubClient({token, apiBase}); + +async function readCodeowners() { + for (const path of ['.github/CODEOWNERS', 'CODEOWNERS']) { + try { + return await fs.readFile(path, 'utf8'); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + } + throw new Error('CODEOWNERS file not found in the trusted base checkout'); +} + +async function getPreviousPriority() { + if (eventAction !== 'edited' || !process.env.GITHUB_EVENT_PATH) { + return null; + } + const event = JSON.parse(await fs.readFile(process.env.GITHUB_EVENT_PATH, 'utf8')); + return event.changes?.body?.from === undefined + ? null + : parsePriority(event.changes.body.from); +} + +const pullRequestPath = `/repos/${repository}/pulls/${pullRequestNumber}`; +const [pullRequest, files, requestedReviewers, codeowners, previousPriority] = + await Promise.all([ + github(pullRequestPath), + paginate(`${pullRequestPath}/files`), + github(`${pullRequestPath}/requested_reviewers`), + readCodeowners(), + getPreviousPriority(), + ]); + +const triage = evaluateDocsReview({ + body: pullRequest.body, + files, + author: pullRequest.user, + authorAssociation: pullRequest.author_association, + isDraft: pullRequest.draft, +}); +const priorityAlertReason = previousPriority + ? getPriorityAlertReason(previousPriority, triage.priority) + : null; +const requestDocsReview = shouldRequestDocsReviewForEvent( + eventAction, + triage, + previousPriority +); +const includeSpecialists = [ + 'opened', + 'reopened', + 'synchronize', + 'ready_for_review', + 'workflow_dispatch', +].includes(eventAction); +const plan = buildReviewerPlan({ + codeowners, + files, + repositoryOwner, + requestedUsers: requestedReviewers.users, + requestedTeams: requestedReviewers.teams, + excludedUsers: [pullRequest.user?.login], + includeSpecialists, + requestDocsReview, +}); + +console.log( + JSON.stringify( + { + pullRequest: pullRequestNumber, + eventAction, + dryRun, + triage: { + priority: triage.priority, + changes: triage.changes, + author: triage.author, + reasons: triage.reasons, + requestDocsReview, + priorityAlertReason, + }, + reviewerPlan: plan, + }, + null, + 2 + ) +); + +if (dryRun || (plan.users.length === 0 && plan.teams.length === 0)) { + process.exit(0); +} + +await github(`${pullRequestPath}/requested_reviewers`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({reviewers: plan.users, team_reviewers: plan.teams}), +}); + +console.log( + `Requested ${plan.users.length} user reviewer(s) and ${plan.teams.length} team reviewer(s)` +); diff --git a/scripts/codeowner-workflow.test.mjs b/scripts/codeowner-workflow.test.mjs new file mode 100644 index 00000000000000..777fed9f1e36c1 --- /dev/null +++ b/scripts/codeowner-workflow.test.mjs @@ -0,0 +1,95 @@ +import fs from 'node:fs'; + +import yaml from 'js-yaml'; +import {describe, expect, it} from 'vitest'; + +const workflow = yaml.load( + fs.readFileSync('.github/workflows/codeowner_assignment.yaml', 'utf8') +); + +describe('codeowner assignment workflow', () => { + it('uses trusted pull_request_target events and supports manual dry runs', () => { + expect(workflow.on.pull_request).toBeUndefined(); + expect(workflow.on.pull_request_target.types).toEqual([ + 'opened', + 'edited', + 'reopened', + 'synchronize', + 'ready_for_review', + ]); + expect(workflow.on.workflow_dispatch.inputs.dry_run.default).toBe(true); + }); + + it('keeps the workflow token read-only', () => { + expect(workflow.permissions).toEqual({contents: 'read'}); + }); + + it('serializes equivalent assignment events without mixing body edits or dry runs', () => { + const concurrency = workflow.jobs.codeowner_assignment.concurrency; + + expect(concurrency.group).toContain('inputs.dry_run'); + expect(concurrency.group).toContain( + "github.event.action == 'edited' && github.run_id" + ); + expect(concurrency.group).toContain("|| 'assignment'"); + expect(concurrency['cancel-in-progress']).toBe(true); + }); + + it('checks out only the trusted base commit without persisting credentials', () => { + const checkout = workflow.jobs.codeowner_assignment.steps.find(step => + step.uses?.startsWith('actions/checkout@') + ); + + expect(checkout.with.ref).toContain('steps.base.outputs.sha'); + expect(checkout.with.ref).not.toContain('head.sha'); + expect(checkout.with['persist-credentials']).toBe(false); + }); + + it('limits the GitHub App token to the repository and required permissions', () => { + const token = workflow.jobs.codeowner_assignment.steps.find(step => + step.uses?.startsWith('actions/create-github-app-token@') + ); + + expect(token.with.repositories.trim()).toBe('sentry-docs'); + expect(token.with['permission-contents']).toBeUndefined(); + expect(token.with['permission-pull-requests']).toBe('write'); + }); + + it('resolves manual runs to the selected pull request base commit', () => { + const resolver = workflow.jobs.codeowner_assignment.steps.find( + step => step.name === 'Resolve trusted base commit' + ); + + expect(resolver.run).toContain('gh api "repos/$REPOSITORY/pulls/$PR_NUMBER"'); + expect(resolver.run).toContain('^[0-9a-f]{40}$'); + }); + + it('runs the base-branch reviewer assignment script', () => { + const assignment = workflow.jobs.codeowner_assignment.steps.find( + step => step.name === 'Evaluate CODEOWNERS and assign reviewers' + ); + + expect(assignment.run).toContain('node scripts/assign-pr-reviewers.mjs'); + expect(assignment.run).toContain('--dry-run'); + }); + + it('uses the repository Node version without installing dependencies', () => { + const setup = workflow.jobs.codeowner_assignment.steps.find(step => + step.uses?.startsWith('actions/setup-node@') + ); + + expect(setup.with['node-version-file']).toBe('package.json'); + }); +}); + +describe('non-blocking Docs review', () => { + it('does not define Docs as an active CODEOWNER', () => { + const codeowners = fs.readFileSync('.github/CODEOWNERS', 'utf8'); + const activeRules = codeowners + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')); + + expect(activeRules.some(line => line.includes('@getsentry/docs'))).toBe(false); + }); +}); diff --git a/scripts/docs-pr-triage.mjs b/scripts/docs-pr-triage.mjs new file mode 100644 index 00000000000000..525cfb799ae1bd --- /dev/null +++ b/scripts/docs-pr-triage.mjs @@ -0,0 +1,347 @@ +export const PRIORITIES = Object.freeze({ + URGENT: 'urgent', + DEADLINE: 'deadline', + NEEDS_TRIAGE: 'needs-triage', + NORMAL: 'normal', +}); + +export const PRIORITY_LABELS = Object.freeze({ + [PRIORITIES.URGENT]: 'Priority: Urgent', + [PRIORITIES.DEADLINE]: 'Priority: Deadline', + [PRIORITIES.NEEDS_TRIAGE]: 'Priority: Needs Triage', + [PRIORITIES.NORMAL]: 'Priority: Normal', +}); + +export const REVIEW_THRESHOLDS = Object.freeze({ + docsLines: 50, + docsFiles: 3, + totalLines: 200, + totalFiles: 6, +}); + +const GENERATED_EXACT_PATHS = new Set([ + 'pnpm-lock.yaml', + 'skills-lock.json', + 'includes/docs-changelog.mdx', + 'next-env.d.ts', + 'tsconfig.tsbuildinfo', + 'public/doctree.json', + 'public/doctree-dev.json', + 'public/llms.txt', +]); + +const GENERATED_PATH_PREFIXES = [ + '.next/', + 'out/', + 'coverage/', + '.nyc_output/', + 'node_modules/', + '.cache/', + 'public/~partytown/', + 'public/page-data/', + 'public/md-exports/', + 'public/mdx-images/', + 'public/og-images/', + 'static/_platforms/', + 'scripts/screenshot-pipeline/output/', +]; + +function normalizePath(path) { + return String(path ?? '') + .replaceAll('\\', '/') + .replace(/^\.?\//, ''); +} + +function getUrgencySection(body) { + const lines = getVisibleMarkdownLines(body); + const headingIndexes = lines.reduce((indexes, line, index) => { + if (/^##\s+IS YOUR CHANGE URGENT\?\s*$/i.test(line.trim())) { + indexes.push(index); + } + return indexes; + }, []); + + if (headingIndexes.length === 0) { + return {issue: 'missing-section', lines: null}; + } + if (headingIndexes.length > 1) { + return {issue: 'multiple-sections', lines: null}; + } + + const section = []; + for (const line of lines.slice(headingIndexes[0] + 1)) { + if (/^##\s+/.test(line.trim())) { + break; + } + section.push(line); + } + return {issue: null, lines: section}; +} + +function getVisibleMarkdownLines(body) { + const lines = String(body ?? '') + .replace(//g, '') + .split(/\r?\n/); + const visible = []; + let fence = null; + + for (const line of lines) { + const trimmed = line.trim(); + if (fence) { + const closingMarker = trimmed.match(/^(`{3,}|~{3,})\s*$/)?.[1]; + if ( + closingMarker?.[0] === fence.character && + closingMarker.length >= fence.length + ) { + fence = null; + } + continue; + } + + const openingMarker = trimmed.match(/^(`{3,}|~{3,})/)?.[1]; + if (openingMarker) { + fence = {character: openingMarker[0], length: openingMarker.length}; + continue; + } + visible.push(line); + } + return visible; +} + +function getCheckedText(line) { + const checked = line.match(/^\s*[-*+]\s+\[\s*x\s*\]\s+(.+)$/i); + return checked?.[1].trim() ?? null; +} + +function getSelectedOption(text) { + if (/^urgent deadline(?:\b|\s*\()/i.test(text)) { + return {priority: PRIORITIES.URGENT, text}; + } + if (/^other deadline\b/i.test(text)) { + return {priority: PRIORITIES.DEADLINE, text}; + } + if (/^(?:no deadline|none)\b/i.test(text)) { + return {priority: PRIORITIES.NORMAL, text}; + } + return null; +} + +function isValidIsoDate(date) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return false; + } + + const [year, month, day] = date.split('-').map(Number); + const value = new Date(0); + value.setUTCHours(0, 0, 0, 0); + value.setUTCFullYear(year, month - 1, day); + return ( + value.getUTCFullYear() === year && + value.getUTCMonth() === month - 1 && + value.getUTCDate() === day + ); +} + +export function parsePriority(body) { + const section = getUrgencySection(body); + if (section.issue) { + return buildPriorityResult(PRIORITIES.NEEDS_TRIAGE, null, [section.issue]); + } + + const checked = section.lines.map(getCheckedText).filter(Boolean); + if (checked.length === 0) { + return buildPriorityResult(PRIORITIES.NEEDS_TRIAGE, null, ['missing-selection']); + } + if (checked.length > 1) { + return buildPriorityResult(PRIORITIES.NEEDS_TRIAGE, null, ['multiple-selections']); + } + + const selected = getSelectedOption(checked[0]); + if (!selected) { + return buildPriorityResult(PRIORITIES.NEEDS_TRIAGE, null, ['unknown-selection']); + } + + const {priority, text} = selected; + if (priority === PRIORITIES.NORMAL) { + return buildPriorityResult(priority, null, []); + } + + const dueDate = text.match(/\b\d{4}-\d{2}-\d{2}\b/)?.[0] ?? null; + if (!dueDate) { + return buildPriorityResult(PRIORITIES.NEEDS_TRIAGE, null, ['missing-due-date']); + } + if (!isValidIsoDate(dueDate)) { + return buildPriorityResult(PRIORITIES.NEEDS_TRIAGE, null, ['invalid-due-date']); + } + + return buildPriorityResult(priority, dueDate, []); +} + +function buildPriorityResult(priority, dueDate, issues) { + return { + priority, + label: PRIORITY_LABELS[priority], + dueDate, + valid: issues.length === 0, + issues, + }; +} + +export function getPriorityAlertReason(previous, current) { + if ( + !current.valid || + ![PRIORITIES.URGENT, PRIORITIES.DEADLINE].includes(current.priority) + ) { + return null; + } + + if (!previous.valid || previous.priority === PRIORITIES.NORMAL) { + return 'priority-escalated'; + } + if ( + previous.priority === PRIORITIES.DEADLINE && + current.priority === PRIORITIES.URGENT + ) { + return 'priority-escalated'; + } + if (previous.dueDate && current.dueDate && current.dueDate < previous.dueDate) { + return 'deadline-moved-earlier'; + } + return null; +} + +export function shouldRequestDocsReviewForEvent(eventAction, triage, previousPriority) { + if (!triage.requestDocsReview) { + return false; + } + if (eventAction !== 'edited') { + return true; + } + return previousPriority + ? getPriorityAlertReason(previousPriority, triage.priority) !== null + : false; +} + +export function isGeneratedFile(path) { + const normalized = normalizePath(path); + if (normalized === 'public/og-images/README.md') { + return false; + } + if (GENERATED_EXACT_PATHS.has(normalized)) { + return true; + } + if (/^public\/_platforms\/.*\.json$/i.test(normalized)) { + return true; + } + return GENERATED_PATH_PREFIXES.some(prefix => normalized.startsWith(prefix)); +} + +function getFilePaths(file) { + if (typeof file === 'string') { + return {path: normalizePath(file), previousPath: ''}; + } + return { + path: normalizePath(file?.path ?? file?.filename), + previousPath: normalizePath(file?.previousPath ?? file?.previous_filename), + }; +} + +function getChangedLines(file) { + if (typeof file === 'string') { + return 0; + } + const additions = Math.max(0, Number(file?.additions) || 0); + const deletions = Math.max(0, Number(file?.deletions) || 0); + return additions + deletions; +} + +export function calculateReviewableChanges(files) { + const reviewableFiles = []; + const excludedFiles = []; + const seen = new Set(); + + for (const file of files ?? []) { + const {path, previousPath} = getFilePaths(file); + if (!path || seen.has(path)) { + continue; + } + seen.add(path); + + if (isGeneratedFile(path) && (!previousPath || isGeneratedFile(previousPath))) { + excludedFiles.push(path); + continue; + } + reviewableFiles.push({path, previousPath, changedLines: getChangedLines(file)}); + } + + const docsFiles = reviewableFiles.filter( + file => file.path.startsWith('docs/') || file.previousPath.startsWith('docs/') + ); + return { + reviewableLines: reviewableFiles.reduce( + (total, file) => total + file.changedLines, + 0 + ), + reviewableFiles: reviewableFiles.length, + docsLines: docsFiles.reduce((total, file) => total + file.changedLines, 0), + docsFiles: docsFiles.length, + excludedFiles, + }; +} + +export function classifyAuthor(author, authorAssociation) { + const login = typeof author === 'string' ? author : author?.login; + const isBot = Boolean( + (typeof author === 'object' && + (author?.is_bot || author?.type === 'Bot' || author?.__typename === 'Bot')) || + /\[bot\]$/i.test(login ?? '') || + /^app\//i.test(login ?? '') || + /^dependabot$/i.test(login ?? '') + ); + const association = String(authorAssociation ?? '').toUpperCase(); + return { + isBot, + isExternal: + !isBot && + Boolean(login) && + Boolean(association) && + !['MEMBER', 'OWNER'].includes(association), + }; +} + +export function evaluateDocsReview({body, files, author, authorAssociation, isDraft}) { + const priority = parsePriority(body); + const changes = calculateReviewableChanges(files); + const authorStatus = classifyAuthor(author, authorAssociation); + const reasons = []; + + if ( + priority.valid && + [PRIORITIES.URGENT, PRIORITIES.DEADLINE].includes(priority.priority) + ) { + reasons.push(`priority:${priority.priority}`); + } + if (authorStatus.isExternal) { + reasons.push('external-author'); + } + if (changes.docsLines >= REVIEW_THRESHOLDS.docsLines) { + reasons.push('docs-lines'); + } + if (changes.docsFiles >= REVIEW_THRESHOLDS.docsFiles) { + reasons.push('docs-files'); + } + if (changes.reviewableLines >= REVIEW_THRESHOLDS.totalLines) { + reasons.push('total-lines'); + } + if (changes.reviewableFiles >= REVIEW_THRESHOLDS.totalFiles) { + reasons.push('total-files'); + } + + return { + priority, + changes, + author: authorStatus, + reasons, + requestDocsReview: isDraft === false && !authorStatus.isBot && reasons.length > 0, + }; +} diff --git a/scripts/docs-pr-triage.test.mjs b/scripts/docs-pr-triage.test.mjs new file mode 100644 index 00000000000000..4e79269b0b5a44 --- /dev/null +++ b/scripts/docs-pr-triage.test.mjs @@ -0,0 +1,544 @@ +import {describe, expect, it} from 'vitest'; + +import { + PRIORITIES, + calculateReviewableChanges, + classifyAuthor, + evaluateDocsReview, + getPriorityAlertReason, + isGeneratedFile, + parsePriority, + shouldRequestDocsReviewForEvent, +} from './docs-pr-triage.mjs'; + +const SECTION = '## IS YOUR CHANGE URGENT?'; + +function bodyWith(lines) { + return [ + '## DESCRIBE YOUR PR', + 'A useful change.', + '', + SECTION, + '', + ...lines, + '', + '## SLA', + ].join('\n'); +} + +function file(path, additions = 0, deletions = 0) { + return {path, additions, deletions}; +} + +describe('parsePriority', () => { + it('parses an urgent deadline and ISO due date', () => { + const result = parsePriority( + bodyWith([ + '- [X] Urgent deadline (GA date, etc.): 2026-09-15', + '- [ ] Other deadline: YYYY-MM-DD', + '- [ ] No deadline: Not urgent', + ]) + ); + + expect(result).toEqual({ + priority: PRIORITIES.URGENT, + label: 'Priority: Urgent', + dueDate: '2026-09-15', + valid: true, + issues: [], + }); + }); + + it('accepts an overdue date as a valid deadline', () => { + const result = parsePriority( + bodyWith([ + '- [ ] Urgent deadline (GA date, etc.): YYYY-MM-DD', + '- [x] Other deadline: 2020-01-01', + '- [ ] No deadline: Not urgent', + ]) + ); + + expect(result.priority).toBe(PRIORITIES.DEADLINE); + expect(result.dueDate).toBe('2020-01-01'); + expect(result.valid).toBe(true); + }); + + it('parses the current no-deadline option', () => { + const result = parsePriority(bodyWith(['- [x] No deadline: Not urgent'])); + + expect(result.priority).toBe(PRIORITIES.NORMAL); + expect(result.label).toBe('Priority: Normal'); + expect(result.dueDate).toBeNull(); + expect(result.valid).toBe(true); + }); + + it('parses the persisted legacy None option', () => { + const result = parsePriority( + bodyWith(['- [x] None: Not urgent, can wait up to 1 week+']) + ); + + expect(result.priority).toBe(PRIORITIES.NORMAL); + expect(result.valid).toBe(true); + }); + + it('recognizes dates entered before the legacy HTML placeholder', () => { + const result = parsePriority( + bodyWith(['- [x] Other deadline: 2026-10-02 ']) + ); + + expect(result.priority).toBe(PRIORITIES.DEADLINE); + expect(result.dueDate).toBe('2026-10-02'); + }); + + it.each([ + [null, 'missing-section'], + [bodyWith(['- [ ] Urgent deadline: YYYY-MM-DD']), 'missing-selection'], + [bodyWith(['- [x] Urgent deadline: YYYY-MM-DD']), 'missing-due-date'], + [bodyWith(['- [x] Other deadline: 2026-02-30']), 'invalid-due-date'], + ])('returns Needs Triage for malformed metadata', (body, issue) => { + const result = parsePriority(body); + + expect(result.priority).toBe(PRIORITIES.NEEDS_TRIAGE); + expect(result.label).toBe('Priority: Needs Triage'); + expect(result.valid).toBe(false); + expect(result.issues).toEqual([issue]); + }); + + it('rejects multiple selected options', () => { + const result = parsePriority( + bodyWith(['- [x] Urgent deadline: 2026-09-15', '- [x] No deadline: Not urgent']) + ); + + expect(result.priority).toBe(PRIORITIES.NEEDS_TRIAGE); + expect(result.issues).toEqual(['multiple-selections']); + }); + + it('rejects a recognized option selected with a rewritten option', () => { + const result = parsePriority( + bodyWith(['- [x] No deadline: Not urgent', '- [x] Custom deadline: 2026-09-15']) + ); + + expect(result.priority).toBe(PRIORITIES.NEEDS_TRIAGE); + expect(result.issues).toEqual(['multiple-selections']); + }); + + it('rejects a single rewritten option', () => { + const result = parsePriority(bodyWith(['- [x] Custom deadline: 2026-09-15'])); + + expect(result.priority).toBe(PRIORITIES.NEEDS_TRIAGE); + expect(result.issues).toEqual(['unknown-selection']); + }); + + it('does not read checkboxes from later sections', () => { + const body = [ + SECTION, + '- [ ] Urgent deadline: YYYY-MM-DD', + '', + '## PRE-MERGE CHECKLIST', + '- [x] Urgent deadline: 2026-09-15', + ].join('\n'); + + expect(parsePriority(body).issues).toEqual(['missing-selection']); + }); + + it('ignores urgency examples in HTML comments and code fences', () => { + const body = [ + '', + '```markdown', + SECTION, + '- [x] Urgent deadline: 2026-09-15', + '```', + SECTION, + '- [x] No deadline: Not urgent', + ].join('\n'); + + expect(parsePriority(body).priority).toBe(PRIORITIES.NORMAL); + }); + + it('requires a closing code fence to use the same character and sufficient length', () => { + const body = [ + '````markdown', + SECTION, + '- [x] Urgent deadline: 2026-09-15', + '```', + SECTION, + '- [x] Other deadline: 2026-09-16', + '~~~', + SECTION, + '- [x] Other deadline: 2026-09-17', + '````', + SECTION, + '- [x] No deadline: Not urgent', + ].join('\n'); + + expect(parsePriority(body).priority).toBe(PRIORITIES.NORMAL); + }); + + it('accepts a closing code fence that is longer than the opening fence', () => { + const body = [ + '```markdown', + SECTION, + '- [x] Urgent deadline: 2026-09-15', + '````', + SECTION, + '- [x] No deadline: Not urgent', + ].join('\n'); + + expect(parsePriority(body).priority).toBe(PRIORITIES.NORMAL); + }); + + it('rejects duplicate visible urgency sections', () => { + const body = [ + SECTION, + '- [x] No deadline: Not urgent', + '## DETAILS', + SECTION, + '- [x] Urgent deadline: 2026-09-15', + ].join('\n'); + + expect(parsePriority(body).issues).toEqual(['multiple-sections']); + }); +}); + +describe('getPriorityAlertReason', () => { + const normal = parsePriority(bodyWith(['- [x] No deadline: Not urgent'])); + const invalid = parsePriority(bodyWith(['- [ ] No deadline: Not urgent'])); + const deadline = date => + parsePriority(bodyWith([`- [x] Other deadline: ${date}`, '- [ ] No deadline'])); + const urgent = date => + parsePriority(bodyWith([`- [x] Urgent deadline: ${date}`, '- [ ] No deadline'])); + + it('alerts when priority escalates', () => { + expect(getPriorityAlertReason(normal, deadline('2026-10-01'))).toBe( + 'priority-escalated' + ); + expect(getPriorityAlertReason(deadline('2026-10-01'), urgent('2026-10-01'))).toBe( + 'priority-escalated' + ); + expect(getPriorityAlertReason(invalid, urgent('2026-10-01'))).toBe( + 'priority-escalated' + ); + }); + + it('alerts when a deadline moves earlier', () => { + expect(getPriorityAlertReason(deadline('2026-10-10'), deadline('2026-10-01'))).toBe( + 'deadline-moved-earlier' + ); + }); + + it('does not alert for a later, unchanged, normal, or malformed priority', () => { + expect( + getPriorityAlertReason(deadline('2026-10-01'), deadline('2026-10-10')) + ).toBeNull(); + expect( + getPriorityAlertReason(deadline('2026-10-01'), deadline('2026-10-01')) + ).toBeNull(); + expect(getPriorityAlertReason(deadline('2026-10-01'), normal)).toBeNull(); + expect(getPriorityAlertReason(normal, invalid)).toBeNull(); + }); +}); + +describe('shouldRequestDocsReviewForEvent', () => { + const normal = parsePriority(bodyWith(['- [x] No deadline: Not urgent'])); + const deadline = parsePriority(bodyWith(['- [x] Other deadline: 2026-10-01'])); + + it('allows qualifying review requests on reviewer-assignment events', () => { + expect( + shouldRequestDocsReviewForEvent('opened', { + requestDocsReview: true, + priority: normal, + }) + ).toBe(true); + expect( + shouldRequestDocsReviewForEvent('synchronize', { + requestDocsReview: true, + priority: normal, + }) + ).toBe(true); + }); + + it('allows body edits only when they escalate priority', () => { + expect( + shouldRequestDocsReviewForEvent( + 'edited', + {requestDocsReview: true, priority: deadline}, + normal + ) + ).toBe(true); + expect( + shouldRequestDocsReviewForEvent( + 'edited', + {requestDocsReview: true, priority: normal}, + normal + ) + ).toBe(false); + expect( + shouldRequestDocsReviewForEvent('edited', { + requestDocsReview: true, + priority: deadline, + }) + ).toBe(false); + }); + + it('never overrides a non-qualifying triage result', () => { + expect( + shouldRequestDocsReviewForEvent('opened', { + requestDocsReview: false, + priority: deadline, + }) + ).toBe(false); + }); +}); + +describe('isGeneratedFile', () => { + it.each([ + 'pnpm-lock.yaml', + 'skills-lock.json', + 'includes/docs-changelog.mdx', + 'public/_platforms/javascript.json', + 'public/doctree.json', + 'public/md-exports/platforms/javascript/index.md', + 'public/og-images/product/issues.png', + 'scripts/screenshot-pipeline/output/home.png', + ])('identifies generated path %s', path => { + expect(isGeneratedFile(path)).toBe(true); + }); + + it.each([ + 'package.json', + 'public/_platforms/_README.md', + 'public/og-images/README.md', + 'public/images/product/issues.png', + 'src/data/ea-features.json', + 'docs/organization/early-adopter-features/index.mdx', + '.github/workflows/test.yml', + ])('keeps reviewable path %s', path => { + expect(isGeneratedFile(path)).toBe(false); + }); +}); + +describe('calculateReviewableChanges', () => { + it('excludes generated churn from total and docs significance', () => { + const result = calculateReviewableChanges([ + file('docs/product/issues/index.mdx', 30, 20), + file('docs/product/alerts/index.mdx', 5, 5), + file('src/components/banner.tsx', 10, 2), + file('pnpm-lock.yaml', 500, 500), + file('public/_platforms/javascript.json', 1000, 1000), + ]); + + expect(result).toEqual({ + reviewableLines: 72, + reviewableFiles: 3, + docsLines: 60, + docsFiles: 2, + excludedFiles: ['pnpm-lock.yaml', 'public/_platforms/javascript.json'], + }); + }); + + it('supports REST filenames, string paths, and duplicate paths', () => { + const result = calculateReviewableChanges([ + {filename: 'docs/index.mdx', additions: 2, deletions: 1}, + file('docs/index.mdx', 100, 100), + 'app/page.tsx', + ]); + + expect(result.reviewableLines).toBe(3); + expect(result.reviewableFiles).toBe(2); + }); + + it('counts renamed files that move out of docs', () => { + const result = calculateReviewableChanges([ + { + filename: 'archive/a.mdx', + previous_filename: 'docs/a.mdx', + status: 'renamed', + additions: 0, + deletions: 0, + }, + { + filename: 'archive/b.mdx', + previous_filename: 'docs/b.mdx', + status: 'renamed', + additions: 0, + deletions: 0, + }, + { + filename: 'archive/c.mdx', + previous_filename: 'docs/c.mdx', + status: 'renamed', + additions: 0, + deletions: 0, + }, + ]); + + expect(result.docsFiles).toBe(3); + expect(result.reviewableFiles).toBe(3); + }); + + it('counts a generated destination when the previous path was reviewable', () => { + const result = calculateReviewableChanges([ + { + filename: 'public/md-exports/page.md', + previous_filename: 'docs/page.mdx', + status: 'renamed', + additions: 0, + deletions: 0, + }, + ]); + + expect(result.reviewableFiles).toBe(1); + expect(result.docsFiles).toBe(1); + expect(result.excludedFiles).toEqual([]); + }); +}); + +describe('classifyAuthor', () => { + it('distinguishes organization members and external contributors', () => { + expect(classifyAuthor({login: 'employee'}, 'MEMBER')).toEqual({ + isBot: false, + isExternal: false, + }); + expect(classifyAuthor({login: 'contributor'}, 'CONTRIBUTOR')).toEqual({ + isBot: false, + isExternal: true, + }); + }); + + it.each([ + [{login: 'dependabot[bot]'}, 'NONE'], + [{login: 'app/sentry'}, 'NONE'], + [{login: 'automation', __typename: 'Bot'}, 'NONE'], + [{login: 'automation', is_bot: true}, 'NONE'], + ])('does not classify bot authors as external', (author, association) => { + expect(classifyAuthor(author, association)).toEqual({isBot: true, isExternal: false}); + }); + + it('does not classify an unknown author or association as external', () => { + expect(classifyAuthor(null, 'NONE')).toEqual({isBot: false, isExternal: false}); + expect(classifyAuthor({login: 'contributor'}, null)).toEqual({ + isBot: false, + isExternal: false, + }); + }); +}); + +describe('evaluateDocsReview', () => { + const normalBody = bodyWith(['- [x] No deadline: Not urgent']); + + it.each([ + { + name: 'urgent priority', + body: bodyWith(['- [x] Urgent deadline: 2026-10-01']), + files: [file('README.md', 1)], + reason: 'priority:urgent', + }, + { + name: 'deadline priority', + body: bodyWith(['- [x] Other deadline: 2026-10-01']), + files: [file('README.md', 1)], + reason: 'priority:deadline', + }, + { + name: 'external author', + body: normalBody, + files: [file('README.md', 1)], + authorAssociation: 'CONTRIBUTOR', + reason: 'external-author', + }, + { + name: 'docs line threshold', + body: normalBody, + files: [file('docs/product/issues/index.mdx', 50)], + reason: 'docs-lines', + }, + { + name: 'docs file threshold', + body: normalBody, + files: [file('docs/a.mdx', 1), file('docs/b.mdx', 1), file('docs/c.mdx', 1)], + reason: 'docs-files', + }, + { + name: 'total line threshold', + body: normalBody, + files: [file('app/page.tsx', 200)], + reason: 'total-lines', + }, + { + name: 'total file threshold', + body: normalBody, + files: Array.from({length: 6}, (_, index) => file(`app/${index}.tsx`, 1)), + reason: 'total-files', + }, + ])('requests Docs for $name', input => { + const result = evaluateDocsReview({ + author: {login: 'employee'}, + authorAssociation: input.authorAssociation ?? 'MEMBER', + isDraft: false, + ...input, + }); + + expect(result.requestDocsReview).toBe(true); + expect(result.reasons).toContain(input.reason); + }); + + it('does not request Docs below every threshold', () => { + const result = evaluateDocsReview({ + body: normalBody, + files: [file('docs/a.mdx', 24, 25), file('app/page.tsx', 149)], + author: {login: 'employee'}, + authorAssociation: 'MEMBER', + isDraft: false, + }); + + expect(result.changes).toMatchObject({ + reviewableLines: 198, + reviewableFiles: 2, + docsLines: 49, + docsFiles: 1, + }); + expect(result.reasons).toEqual([]); + expect(result.requestDocsReview).toBe(false); + }); + + it('never requests Docs for drafts or bots', () => { + const files = [file('docs/product/issues/index.mdx', 1000)]; + const draft = evaluateDocsReview({ + body: normalBody, + files, + author: {login: 'employee'}, + authorAssociation: 'MEMBER', + isDraft: true, + }); + const bot = evaluateDocsReview({ + body: normalBody, + files, + author: {login: 'dependabot[bot]', type: 'Bot'}, + authorAssociation: 'NONE', + isDraft: false, + }); + + expect(draft.requestDocsReview).toBe(false); + expect(bot.requestDocsReview).toBe(false); + }); + + it('returns priority, author, and reason details for later workflows', () => { + const result = evaluateDocsReview({ + body: bodyWith(['- [ ] No deadline: Not urgent']), + files: [file('docs/platforms/javascript/index.mdx', 10)], + author: {login: 'contributor'}, + authorAssociation: 'CONTRIBUTOR', + isDraft: false, + }); + + expect(result).toMatchObject({ + priority: {priority: PRIORITIES.NEEDS_TRIAGE, valid: false}, + author: {isBot: false, isExternal: true}, + reasons: ['external-author'], + requestDocsReview: true, + }); + }); +}); diff --git a/scripts/github-api.mjs b/scripts/github-api.mjs new file mode 100644 index 00000000000000..787fc044a4e3a4 --- /dev/null +++ b/scripts/github-api.mjs @@ -0,0 +1,58 @@ +export function getGitHubRuntime(environment = process.env) { + const token = environment.GH_TOKEN; + const repository = environment.REPOSITORY; + const pullRequestNumber = Number(environment.PR_NUMBER); + + if (!token) { + throw new Error('GH_TOKEN is required'); + } + if (!/^[^/]+\/[^/]+$/.test(repository ?? '')) { + throw new Error('REPOSITORY must use the owner/name format'); + } + if (!Number.isInteger(pullRequestNumber) || pullRequestNumber <= 0) { + throw new Error('PR_NUMBER must be a positive integer'); + } + + return { + token, + repository, + pullRequestNumber, + apiBase: environment.GITHUB_API_URL || 'https://api.github.com', + }; +} + +export function createGitHubClient({token, apiBase, fetchImplementation = fetch}) { + async function request(path, options = {}) { + const response = await fetchImplementation(`${apiBase}${path}`, { + ...options, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...options.headers, + }, + }); + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + if (!response.ok) { + const error = new Error(`GitHub API ${response.status}: ${data?.message ?? text}`); + error.status = response.status; + throw error; + } + return data; + } + + async function paginate(path) { + const items = []; + for (let page = 1; ; page += 1) { + const separator = path.includes('?') ? '&' : '?'; + const result = await request(`${path}${separator}per_page=100&page=${page}`); + items.push(...result); + if (result.length < 100) { + return items; + } + } + } + + return {request, paginate}; +} diff --git a/scripts/github-api.test.mjs b/scripts/github-api.test.mjs new file mode 100644 index 00000000000000..f53e662e945352 --- /dev/null +++ b/scripts/github-api.test.mjs @@ -0,0 +1,87 @@ +import {describe, expect, it, vi} from 'vitest'; + +import {createGitHubClient, getGitHubRuntime} from './github-api.mjs'; + +describe('getGitHubRuntime', () => { + it('normalizes the shared GitHub runtime configuration', () => { + expect( + getGitHubRuntime({ + GH_TOKEN: 'token', + REPOSITORY: 'getsentry/sentry-docs', + PR_NUMBER: '123', + }) + ).toEqual({ + token: 'token', + repository: 'getsentry/sentry-docs', + pullRequestNumber: 123, + apiBase: 'https://api.github.com', + }); + }); + + it.each([ + [{REPOSITORY: 'getsentry/sentry-docs', PR_NUMBER: '1'}, 'GH_TOKEN is required'], + [ + {GH_TOKEN: 'token', REPOSITORY: 'invalid', PR_NUMBER: '1'}, + 'REPOSITORY must use the owner/name format', + ], + [ + {GH_TOKEN: 'token', REPOSITORY: 'getsentry/sentry-docs', PR_NUMBER: '0'}, + 'PR_NUMBER must be a positive integer', + ], + ])('rejects invalid runtime configuration', (environment, message) => { + expect(() => getGitHubRuntime(environment)).toThrow(message); + }); +}); + +describe('createGitHubClient', () => { + it('adds authentication headers and returns JSON', async () => { + const fetchImplementation = vi.fn(() => + Promise.resolve(new Response(JSON.stringify({ok: true}), {status: 200})) + ); + const {request} = createGitHubClient({ + token: 'secret', + apiBase: 'https://api.github.test', + fetchImplementation, + }); + + await expect(request('/resource')).resolves.toEqual({ok: true}); + expect(fetchImplementation).toHaveBeenCalledWith( + 'https://api.github.test/resource', + expect.objectContaining({ + headers: expect.objectContaining({Authorization: 'Bearer secret'}), + }) + ); + }); + + it('paginates until GitHub returns fewer than 100 items', async () => { + const fetchImplementation = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify(Array.from({length: 100}, (_, index) => index)), { + status: 200, + }) + ) + .mockResolvedValueOnce(new Response(JSON.stringify([100]), {status: 200})); + const {paginate} = createGitHubClient({ + token: 'secret', + apiBase: 'https://api.github.test', + fetchImplementation, + }); + + await expect(paginate('/resource')).resolves.toHaveLength(101); + expect(fetchImplementation).toHaveBeenCalledTimes(2); + }); + + it('exposes the HTTP status on API errors', async () => { + const {request} = createGitHubClient({ + token: 'secret', + apiBase: 'https://api.github.test', + fetchImplementation: () => + Promise.resolve( + new Response(JSON.stringify({message: 'Not Found'}), {status: 404}) + ), + }); + + await expect(request('/missing')).rejects.toMatchObject({status: 404}); + }); +}); diff --git a/scripts/pr-priority-metadata.mjs b/scripts/pr-priority-metadata.mjs new file mode 100644 index 00000000000000..63bb7cfc075521 --- /dev/null +++ b/scripts/pr-priority-metadata.mjs @@ -0,0 +1,91 @@ +import {parsePriority} from './docs-pr-triage.mjs'; + +export const PRIORITY_REMINDER_MARKER = ''; + +export const PRIORITY_REMINDER_BODY = `${PRIORITY_REMINDER_MARKER} + +Please update the **IS YOUR CHANGE URGENT?** section of the PR description: + +- Select exactly one option. +- If you selected an urgent or other deadline, provide the date as \`YYYY-MM-DD\`. + +This information helps the Docs team prioritize your review.`; + +function labelName(label) { + return typeof label === 'string' ? label : label?.name; +} + +export function isPriorityReminderComment(comment) { + return ( + comment.user?.login === 'github-actions[bot]' && + comment.body?.split(/\r?\n/, 1)[0].trim() === PRIORITY_REMINDER_MARKER + ); +} + +export function buildPriorityMetadataPlan({priority, currentLabels, isDraft, comments}) { + const labels = (currentLabels ?? []).map(labelName).filter(Boolean); + const priorityLabels = labels.filter(label => label.startsWith('Priority:')); + const botReminders = (comments ?? []).filter(isPriorityReminderComment); + const retainedReminder = botReminders[0]; + const deleteCommentIds = botReminders.slice(1).map(comment => comment.id); + const needsReminder = !isDraft && !priority.valid; + + const comment = { + create: false, + update: null, + deleteIds: deleteCommentIds, + }; + if (needsReminder) { + if (!retainedReminder) { + comment.create = true; + } else if (retainedReminder.body !== PRIORITY_REMINDER_BODY) { + comment.update = {id: retainedReminder.id, body: PRIORITY_REMINDER_BODY}; + } + } else { + comment.deleteIds = botReminders.map(existing => existing.id); + } + + return { + labels: { + add: labels.includes(priority.label) ? [] : [priority.label], + remove: priorityLabels.filter(label => label !== priority.label), + }, + comment, + }; +} + +export async function reconcilePriorityMetadata({ + load, + apply, + dryRun = false, + maxAttempts = 3, + onPlan = () => {}, +}) { + let state = await load(); + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const priority = parsePriority(state.body); + const plan = buildPriorityMetadataPlan({ + priority, + currentLabels: state.labels, + isDraft: state.isDraft, + comments: state.comments, + }); + onPlan({attempt, priority, plan}); + + if (dryRun) { + return {attempts: attempt, priority, plan, dryRun: true}; + } + + await apply(plan); + const latest = await load(); + if (latest.body === state.body && latest.isDraft === state.isDraft) { + return {attempts: attempt, priority, plan, dryRun: false}; + } + state = latest; + } + + throw new Error( + `Pull request metadata did not stabilize after ${maxAttempts} attempts` + ); +} diff --git a/scripts/pr-priority-metadata.test.mjs b/scripts/pr-priority-metadata.test.mjs new file mode 100644 index 00000000000000..c4b55bf6007c57 --- /dev/null +++ b/scripts/pr-priority-metadata.test.mjs @@ -0,0 +1,207 @@ +import {describe, expect, it, vi} from 'vitest'; + +import {parsePriority} from './docs-pr-triage.mjs'; +import { + PRIORITY_REMINDER_BODY, + PRIORITY_REMINDER_MARKER, + buildPriorityMetadataPlan, + isPriorityReminderComment, + reconcilePriorityMetadata, +} from './pr-priority-metadata.mjs'; + +function bodyWith(option) { + return `## IS YOUR CHANGE URGENT? + +${option} + +## SLA`; +} + +function priority(option) { + return parsePriority(bodyWith(option)); +} + +function reminder(id, body = PRIORITY_REMINDER_BODY) { + return {id, body, user: {login: 'github-actions[bot]'}}; +} + +describe('buildPriorityMetadataPlan labels', () => { + it('adds the parsed priority while preserving unrelated labels', () => { + const plan = buildPriorityMetadataPlan({ + priority: priority('- [x] No deadline: Not urgent'), + currentLabels: ['Docs', 'Team: SDKs'], + isDraft: false, + comments: [], + }); + + expect(plan.labels).toEqual({add: ['Priority: Normal'], remove: []}); + }); + + it('removes conflicting priority labels and skips an existing desired label', () => { + const plan = buildPriorityMetadataPlan({ + priority: priority('- [x] Other deadline: 2026-10-01'), + currentLabels: [ + {name: 'Priority: Normal'}, + {name: 'Priority: Urgent'}, + {name: 'Priority: Deadline'}, + {name: 'Docs'}, + ], + isDraft: false, + comments: [], + }); + + expect(plan.labels).toEqual({ + add: [], + remove: ['Priority: Normal', 'Priority: Urgent'], + }); + }); +}); + +describe('buildPriorityMetadataPlan reminder comments', () => { + const invalid = parsePriority(bodyWith('- [ ] No deadline: Not urgent')); + const valid = priority('- [x] No deadline: Not urgent'); + + it('creates one reminder for an invalid ready PR', () => { + const plan = buildPriorityMetadataPlan({ + priority: invalid, + currentLabels: [], + isDraft: false, + comments: [], + }); + + expect(plan.comment).toEqual({create: true, update: null, deleteIds: []}); + }); + + it('updates an outdated bot reminder and ignores a contributor marker', () => { + const plan = buildPriorityMetadataPlan({ + priority: invalid, + currentLabels: [], + isDraft: false, + comments: [ + reminder(1, `${PRIORITY_REMINDER_MARKER}\nOld instructions`), + {id: 2, body: PRIORITY_REMINDER_MARKER, user: {login: 'contributor'}}, + ], + }); + + expect(plan.comment).toEqual({ + create: false, + update: {id: 1, body: PRIORITY_REMINDER_BODY}, + deleteIds: [], + }); + }); + + it('does not claim a bot comment containing the marker after the first line', () => { + const unrelated = { + id: 1, + body: `Another workflow comment\n${PRIORITY_REMINDER_MARKER}`, + user: {login: 'github-actions[bot]'}, + }; + + expect(isPriorityReminderComment(unrelated)).toBe(false); + const plan = buildPriorityMetadataPlan({ + priority: invalid, + currentLabels: [], + isDraft: false, + comments: [unrelated], + }); + expect(plan.comment).toEqual({create: true, update: null, deleteIds: []}); + }); + + it('retains one current reminder and deletes duplicate bot reminders', () => { + const plan = buildPriorityMetadataPlan({ + priority: invalid, + currentLabels: [], + isDraft: false, + comments: [reminder(1), reminder(2), reminder(3)], + }); + + expect(plan.comment).toEqual({create: false, update: null, deleteIds: [2, 3]}); + }); + + it('deletes bot reminders after correction', () => { + const plan = buildPriorityMetadataPlan({ + priority: valid, + currentLabels: [], + isDraft: false, + comments: [reminder(1), reminder(2)], + }); + + expect(plan.comment).toEqual({create: false, update: null, deleteIds: [1, 2]}); + }); + + it('does not remind drafts and removes a reminder after conversion to draft', () => { + const plan = buildPriorityMetadataPlan({ + priority: invalid, + currentLabels: [], + isDraft: true, + comments: [reminder(1)], + }); + + expect(plan.comment).toEqual({create: false, update: null, deleteIds: [1]}); + expect(plan.labels.add).toEqual(['Priority: Needs Triage']); + }); +}); + +describe('reconcilePriorityMetadata', () => { + const normalBody = bodyWith('- [x] No deadline: Not urgent'); + const urgentBody = bodyWith('- [x] Urgent deadline: 2026-09-15'); + + it('reapplies metadata when the PR changes during a write', async () => { + let state = {body: normalBody, labels: [], isDraft: false, comments: []}; + const applied = []; + const apply = plan => { + applied.push(plan.labels.add[0]); + state = { + ...state, + body: applied.length === 1 ? urgentBody : state.body, + labels: [plan.labels.add[0]], + }; + return Promise.resolve(); + }; + + await expect( + reconcilePriorityMetadata({load: () => Promise.resolve(state), apply}) + ).resolves.toMatchObject({attempts: 2, priority: {label: 'Priority: Urgent'}}); + expect(applied).toEqual(['Priority: Normal', 'Priority: Urgent']); + }); + + it('does not mutate during a dry run', async () => { + const apply = vi.fn(); + + await expect( + reconcilePriorityMetadata({ + load: () => + Promise.resolve({ + body: normalBody, + labels: [], + isDraft: false, + comments: [], + }), + apply, + dryRun: true, + }) + ).resolves.toMatchObject({attempts: 1, dryRun: true}); + expect(apply).not.toHaveBeenCalled(); + }); + + it('fails visibly when metadata does not stabilize', async () => { + let urgent = false; + + await expect( + reconcilePriorityMetadata({ + load: () => + Promise.resolve({ + body: urgent ? urgentBody : normalBody, + labels: [], + isDraft: false, + comments: [], + }), + apply: () => { + urgent = !urgent; + return Promise.resolve(); + }, + maxAttempts: 2, + }) + ).rejects.toThrow('did not stabilize after 2 attempts'); + }); +}); diff --git a/scripts/pr-reviewer-assignment.mjs b/scripts/pr-reviewer-assignment.mjs new file mode 100644 index 00000000000000..adbe7ea74b1604 --- /dev/null +++ b/scripts/pr-reviewer-assignment.mjs @@ -0,0 +1,179 @@ +function normalizePath(path) { + return String(path ?? '') + .replaceAll('\\', '/') + .replace(/^\.?\//, ''); +} + +function parseReviewer(token) { + const value = token.slice(1); + const slash = value.indexOf('/'); + if (slash === -1) { + return {type: 'user', login: value}; + } + return { + type: 'team', + organization: value.slice(0, slash), + slug: value.slice(slash + 1), + }; +} + +export function parseCommentedCodeowners(codeowners) { + const rules = []; + for (const line of String(codeowners ?? '').split(/\r?\n/)) { + const match = line.match(/^#\s*(\S+)\s+(.+)$/); + if (!match) { + continue; + } + + const reviewers = [...match[2].matchAll(/@[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)?/g)].map( + reviewer => parseReviewer(reviewer[0]) + ); + if (reviewers.length > 0) { + rules.push({pattern: match[1], reviewers}); + } + } + return rules; +} + +function globToRegex(pattern) { + let expression = ''; + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index]; + if (character === '*' && pattern[index + 1] === '*') { + if (pattern[index + 2] === '/') { + expression += '(?:.*/)?'; + index += 2; + } else { + expression += '.*'; + index += 1; + } + } else if (character === '*') { + expression += '[^/]*'; + } else if (character === '?') { + expression += '[^/]'; + } else { + expression += character.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'); + } + } + return expression; +} + +export function matchesCodeownerPattern(pattern, filePath) { + const originalPattern = String(pattern ?? '').trim(); + const path = normalizePath(filePath); + const normalizedPattern = normalizePath(originalPattern); + if (!normalizedPattern || !path) { + return false; + } + + const patternWithoutTrailingSlash = normalizedPattern.replace(/\/$/, ''); + const anchored = + originalPattern.startsWith('/') || + (patternWithoutTrailingSlash.includes('/') && !normalizedPattern.startsWith('**/')); + const prefix = anchored ? '^' : '(?:^|.*/)'; + const lastSegment = patternWithoutTrailingSlash.split('/').at(-1); + + if (!/[?*]/.test(normalizedPattern)) { + if (normalizedPattern.endsWith('/')) { + const directory = normalizedPattern + .slice(0, -1) + .replace(/[|\\{}()[\]^$+?.]/g, '\\$&'); + return new RegExp(`${prefix}${directory}/.*$`).test(path); + } + if (lastSegment?.includes('.')) { + const exact = normalizedPattern.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'); + return new RegExp(`${prefix}${exact}$`).test(path); + } + const directoryOrFile = normalizedPattern.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'); + return new RegExp(`${prefix}${directoryOrFile}(?:/.*)?$`).test(path); + } + + let suffix = '$'; + if (normalizedPattern.endsWith('/')) { + suffix = '.*$'; + } else if (lastSegment && !lastSegment.includes('.') && !/[?*]/.test(lastSegment)) { + suffix = '(?:/.*)?$'; + } + return new RegExp(`${prefix}${globToRegex(normalizedPattern)}${suffix}`).test(path); +} + +function getChangedPaths(file) { + if (typeof file === 'string') { + return [normalizePath(file)]; + } + return [ + normalizePath(file?.path ?? file?.filename), + normalizePath(file?.previousPath ?? file?.previous_filename), + ].filter(Boolean); +} + +export function collectSpecialistReviewers(codeowners, files, repositoryOwner) { + const users = new Set(); + const teams = new Set(); + const matchedRules = []; + + for (const rule of parseCommentedCodeowners(codeowners)) { + const matchedFiles = (files ?? []) + .flatMap(getChangedPaths) + .filter(path => matchesCodeownerPattern(rule.pattern, path)); + if (matchedFiles.length === 0) { + continue; + } + + const reviewers = []; + for (const reviewer of rule.reviewers) { + if (reviewer.type === 'user') { + users.add(reviewer.login); + reviewers.push(`@${reviewer.login}`); + } else if (reviewer.organization === repositoryOwner) { + teams.add(reviewer.slug); + reviewers.push(`@${reviewer.organization}/${reviewer.slug}`); + } + } + if (reviewers.length > 0) { + matchedRules.push({ + pattern: rule.pattern, + files: [...new Set(matchedFiles)], + reviewers, + }); + } + } + + return {users: [...users].sort(), teams: [...teams].sort(), matchedRules}; +} + +function reviewerName(reviewer) { + return typeof reviewer === 'string' ? reviewer : (reviewer?.login ?? reviewer?.slug); +} + +export function buildReviewerPlan({ + codeowners, + files, + repositoryOwner, + requestedUsers = [], + requestedTeams = [], + excludedUsers = [], + includeSpecialists = true, + requestDocsReview = false, +}) { + const specialists = includeSpecialists + ? collectSpecialistReviewers(codeowners, files, repositoryOwner) + : {users: [], teams: [], matchedRules: []}; + const users = new Set(specialists.users); + const teams = new Set(specialists.teams); + if (requestDocsReview) { + teams.add('docs'); + } + + const existingUsers = new Set(requestedUsers.map(reviewerName).filter(Boolean)); + const existingTeams = new Set(requestedTeams.map(reviewerName).filter(Boolean)); + const excluded = new Set(excludedUsers.filter(Boolean)); + + return { + users: [...users] + .filter(user => !existingUsers.has(user) && !excluded.has(user)) + .sort(), + teams: [...teams].filter(team => !existingTeams.has(team)).sort(), + matchedRules: specialists.matchedRules, + }; +} diff --git a/scripts/pr-reviewer-assignment.test.mjs b/scripts/pr-reviewer-assignment.test.mjs new file mode 100644 index 00000000000000..5df04173c03e86 --- /dev/null +++ b/scripts/pr-reviewer-assignment.test.mjs @@ -0,0 +1,170 @@ +import {describe, expect, it} from 'vitest'; + +import { + buildReviewerPlan, + collectSpecialistReviewers, + matchesCodeownerPattern, + parseCommentedCodeowners, +} from './pr-reviewer-assignment.mjs'; + +const CODEOWNERS = ` +# A documentation comment without reviewers +**/vercel.json @getsentry/security +# /docs/platforms/android/ @getsentry/team-mobile-core +# /docs/platforms/native/ @getsentry/product-owners-sdks-native +# /docs/product/explore/session-replay/web @getsentry/replay-sdk-web @getsentry/replay-frontend +# /includes/session-replay-web-report-bug.mdx @getsentry/replay-sdk-web +# /docs/product/sentry-toolbar/ @ryan953 +`; + +describe('parseCommentedCodeowners', () => { + it('parses only commented patterns with user or team reviewers', () => { + expect(parseCommentedCodeowners(CODEOWNERS)).toEqual([ + { + pattern: '/docs/platforms/android/', + reviewers: [{type: 'team', organization: 'getsentry', slug: 'team-mobile-core'}], + }, + { + pattern: '/docs/platforms/native/', + reviewers: [ + { + type: 'team', + organization: 'getsentry', + slug: 'product-owners-sdks-native', + }, + ], + }, + { + pattern: '/docs/product/explore/session-replay/web', + reviewers: [ + {type: 'team', organization: 'getsentry', slug: 'replay-sdk-web'}, + {type: 'team', organization: 'getsentry', slug: 'replay-frontend'}, + ], + }, + { + pattern: '/includes/session-replay-web-report-bug.mdx', + reviewers: [{type: 'team', organization: 'getsentry', slug: 'replay-sdk-web'}], + }, + { + pattern: '/docs/product/sentry-toolbar/', + reviewers: [{type: 'user', login: 'ryan953'}], + }, + ]); + }); +}); + +describe('matchesCodeownerPattern', () => { + it.each([ + ['/docs/platforms/android/', 'docs/platforms/android/index.mdx'], + [ + '/docs/product/explore/session-replay/web', + 'docs/product/explore/session-replay/web/index.mdx', + ], + ['**/README.md', 'docs/platforms/python/README.md'], + ['README.md', 'docs/README.md'], + ['apps/', 'deep/apps/file.js'], + ['**/logs', 'deep/logs/file.txt'], + ['/docs/*/', 'docs/product/index.mdx'], + ['docs/*', 'docs/page.mdx'], + ['*.mdx', 'docs/page.mdx'], + ['/.github/labels.yml', '.github/labels.yml'], + ])('matches %s against %s', (pattern, path) => { + expect(matchesCodeownerPattern(pattern, path)).toBe(true); + }); + + it.each([ + ['/docs/platforms/android/', 'docs/platforms/apple/index.mdx'], + ['/includes/example.mdx', 'includes/example.mdx.bak'], + ['docs/*', 'deep/docs/page.mdx'], + ['foo/bar', 'deep/foo/bar'], + ['*.mdx', 'docs/page.md'], + ['/.github/labels.yml', '.github/labels.yaml'], + ])('does not match %s against %s', (pattern, path) => { + expect(matchesCodeownerPattern(pattern, path)).toBe(false); + }); +}); + +describe('collectSpecialistReviewers', () => { + it('deduplicates reviewers and records matched rules', () => { + const result = collectSpecialistReviewers( + CODEOWNERS, + [ + 'docs/product/explore/session-replay/web/index.mdx', + 'includes/session-replay-web-report-bug.mdx', + ], + 'getsentry' + ); + + expect(result.users).toEqual([]); + expect(result.teams).toEqual(['replay-frontend', 'replay-sdk-web']); + expect(result.matchedRules).toHaveLength(2); + }); + + it('matches both sides of renamed files', () => { + const result = collectSpecialistReviewers( + CODEOWNERS, + [ + { + filename: 'archive/android.mdx', + previous_filename: 'docs/platforms/android/index.mdx', + }, + ], + 'getsentry' + ); + + expect(result.teams).toEqual(['team-mobile-core']); + }); + + it('ignores teams from another organization', () => { + const result = collectSpecialistReviewers( + '# /docs/ @another-org/docs', + ['docs/index.mdx'], + 'getsentry' + ); + + expect(result.teams).toEqual([]); + }); +}); + +describe('buildReviewerPlan', () => { + it('adds Docs without making it an active CODEOWNERS rule', () => { + const result = buildReviewerPlan({ + codeowners: CODEOWNERS, + files: ['docs/platforms/android/index.mdx'], + repositoryOwner: 'getsentry', + requestDocsReview: true, + }); + + expect(result.teams).toEqual(['docs', 'team-mobile-core']); + }); + + it('skips existing requests and the pull request author', () => { + const result = buildReviewerPlan({ + codeowners: CODEOWNERS, + files: [ + 'docs/product/sentry-toolbar/index.mdx', + 'docs/platforms/android/index.mdx', + ], + repositoryOwner: 'getsentry', + requestedTeams: [{slug: 'team-mobile-core'}], + excludedUsers: ['ryan953'], + requestDocsReview: true, + }); + + expect(result.users).toEqual([]); + expect(result.teams).toEqual(['docs']); + }); + + it('can evaluate a body edit without re-requesting specialists', () => { + const result = buildReviewerPlan({ + codeowners: CODEOWNERS, + files: ['docs/platforms/android/index.mdx'], + repositoryOwner: 'getsentry', + includeSpecialists: false, + requestDocsReview: true, + }); + + expect(result.teams).toEqual(['docs']); + expect(result.matchedRules).toEqual([]); + }); +}); diff --git a/scripts/priority-metadata-workflow.test.mjs b/scripts/priority-metadata-workflow.test.mjs new file mode 100644 index 00000000000000..e2ac9f820847de --- /dev/null +++ b/scripts/priority-metadata-workflow.test.mjs @@ -0,0 +1,67 @@ +import fs from 'node:fs'; + +import yaml from 'js-yaml'; +import {describe, expect, it} from 'vitest'; + +const workflow = yaml.load( + fs.readFileSync('.github/workflows/pr-priority-metadata.yml', 'utf8') +); + +describe('priority metadata workflow', () => { + it('runs only on metadata lifecycle events and manual dispatch', () => { + expect(workflow.on.pull_request_target.types).toEqual([ + 'opened', + 'edited', + 'reopened', + 'ready_for_review', + 'converted_to_draft', + ]); + expect(workflow.on.pull_request_target.types).not.toContain('synchronize'); + expect(workflow.on.workflow_dispatch.inputs.dry_run.default).toBe(true); + }); + + it('grants only the permissions required for labels and issue comments', () => { + expect(workflow.permissions).toEqual({ + contents: 'read', + issues: 'write', + 'pull-requests': 'read', + }); + }); + + it('serializes event updates without allowing manual dry runs to cancel them', () => { + const concurrency = workflow.jobs.priority_metadata.concurrency; + + expect(concurrency.group).toContain('github.run_id'); + expect(concurrency.group).toContain('inputs.dry_run'); + expect(concurrency.group).toContain("|| 'event'"); + expect(concurrency['cancel-in-progress']).toBe(true); + }); + + it('checks out a validated base SHA without persisted credentials', () => { + const steps = workflow.jobs.priority_metadata.steps; + const resolver = steps.find(step => step.name === 'Resolve trusted base commit'); + const checkout = steps.find(step => step.uses?.startsWith('actions/checkout@')); + + expect(resolver.run).toContain('^[0-9a-f]{40}$'); + expect(checkout.with.ref).toContain('steps.base.outputs.sha'); + expect(checkout.with['persist-credentials']).toBe(false); + }); + + it('supports a non-mutating metadata dry run', () => { + const metadata = workflow.jobs.priority_metadata.steps.find( + step => step.name === 'Synchronize priority metadata' + ); + + expect(metadata.env.GH_TOKEN).toContain('github.token'); + expect(metadata.run).toContain('node scripts/sync-pr-priority-metadata.mjs'); + expect(metadata.run).toContain('--dry-run'); + }); + + it('uses the repository Node version without installing dependencies', () => { + const setup = workflow.jobs.priority_metadata.steps.find(step => + step.uses?.startsWith('actions/setup-node@') + ); + + expect(setup.with['node-version-file']).toBe('package.json'); + }); +}); diff --git a/scripts/sync-pr-priority-metadata.mjs b/scripts/sync-pr-priority-metadata.mjs new file mode 100644 index 00000000000000..b639bfc50879b0 --- /dev/null +++ b/scripts/sync-pr-priority-metadata.mjs @@ -0,0 +1,111 @@ +import {createGitHubClient, getGitHubRuntime} from './github-api.mjs'; +import { + PRIORITY_REMINDER_BODY, + isPriorityReminderComment, + reconcilePriorityMetadata, +} from './pr-priority-metadata.mjs'; + +const {token, repository, pullRequestNumber, apiBase} = getGitHubRuntime(); +const dryRun = process.argv.includes('--dry-run'); +const {request: github, paginate} = createGitHubClient({token, apiBase}); + +const pullRequestPath = `/repos/${repository}/pulls/${pullRequestNumber}`; +const issuePath = `/repos/${repository}/issues/${pullRequestNumber}`; + +async function loadMetadataState() { + const [pullRequest, comments] = await Promise.all([ + github(pullRequestPath), + paginate(`${issuePath}/comments`), + ]); + return { + body: pullRequest.body, + labels: pullRequest.labels, + isDraft: pullRequest.draft, + comments, + }; +} + +async function createReminderIfMissing() { + const latestComments = await paginate(`${issuePath}/comments`); + if (!latestComments.some(isPriorityReminderComment)) { + await github(`${issuePath}/comments`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({body: PRIORITY_REMINDER_BODY}), + }); + } +} + +async function applyMetadataPlan(plan) { + for (const label of plan.labels.remove) { + try { + await github(`${issuePath}/labels/${encodeURIComponent(label)}`, { + method: 'DELETE', + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + if (plan.labels.add.length > 0) { + await github(`${issuePath}/labels`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({labels: plan.labels.add}), + }); + } + + for (const commentId of plan.comment.deleteIds) { + try { + await github(`/repos/${repository}/issues/comments/${commentId}`, { + method: 'DELETE', + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + } + + if (plan.comment.update) { + try { + await github(`/repos/${repository}/issues/comments/${plan.comment.update.id}`, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({body: plan.comment.update.body}), + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + await createReminderIfMissing(); + } + } else if (plan.comment.create) { + await createReminderIfMissing(); + } +} + +const result = await reconcilePriorityMetadata({ + load: loadMetadataState, + apply: applyMetadataPlan, + dryRun, + onPlan: ({attempt, priority, plan}) => + console.log( + JSON.stringify( + { + pullRequest: pullRequestNumber, + dryRun, + attempt, + priority, + metadataPlan: plan, + }, + null, + 2 + ) + ), +}); + +if (!dryRun) { + console.log(`Priority metadata synchronized in ${result.attempts} attempt(s)`); +}