diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml index 2f470d25..cf9045b2 100644 --- a/.github/workflows/contributor-check.yml +++ b/.github/workflows/contributor-check.yml @@ -19,6 +19,11 @@ jobs: github.actor != 'github-actions[bot]' && github.actor != 'copilot-swe-agent[bot]' steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: diff --git a/.github/workflows/label-pr-intent.yml b/.github/workflows/label-pr-intent.yml new file mode 100644 index 00000000..ec880b63 --- /dev/null +++ b/.github/workflows/label-pr-intent.yml @@ -0,0 +1,241 @@ +name: Label PR Intent + +on: + pull_request_target: + types: [opened, synchronize, reopened, edited, ready_for_review] + +permissions: + issues: write + pull-requests: read + +jobs: + label-pr: + runs-on: ubuntu-latest + if: >- + github.actor != 'dependabot[bot]' && + github.actor != 'github-actions[bot]' + steps: + - name: Apply intent labels + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const managedLabels = { + 'targets-main': { + color: 'B60205', + description: 'PR targets main instead of staged' + }, + 'branched-main': { + color: 'D93F0B', + description: 'PR appears to include plugin files materialized from main' + }, + 'skills': { + color: '1D76DB', + description: 'PR touches skills' + }, + 'plugin': { + color: '5319E7', + description: 'PR touches plugins' + }, + 'agent': { + color: '0E8A16', + description: 'PR touches agents' + }, + 'instructions': { + color: 'FBCA04', + description: 'PR touches instructions' + }, + 'new-submission': { + color: '006B75', + description: 'PR adds at least one new contribution' + }, + 'website-update': { + color: '0052CC', + description: 'PR touches website content or code' + }, + 'external-plugin': { + color: 'FEF2C0', + description: 'PR updates plugins/external.json' + }, + 'hooks': { + color: 'C2E0C6', + description: 'PR touches hooks' + }, + 'workflow': { + color: 'BFD4F2', + description: 'PR touches workflow automation' + } + }; + + const matchesAny = (filename, patterns) => patterns.some((pattern) => pattern.test(filename)); + + async function listAllFiles() { + const files = []; + let page = 1; + + while (true) { + const response = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100, + page + }); + + files.push(...response.data); + + if (response.data.length < 100) { + return files; + } + + page += 1; + } + } + + async function ensureLabel(name, { color, description }) { + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name, + color, + description + }); + } catch (error) { + if (error.status !== 422) { + throw error; + } + } + } + + const files = await listAllFiles(); + const filenames = files.map((file) => file.filename); + + const patterns = { + branchedMain: [ + /^plugins\/[^/]+\/(?:agents|commands|skills)\// + ], + skills: [ + /^skills\// + ], + plugin: [ + /^plugins\// + ], + agent: [ + /^agents\/.+\.agent\.md$/ + ], + instructions: [ + /^instructions\/.+\.instructions\.md$/ + ], + websiteUpdate: [ + /^website\// + ], + externalPlugin: [ + /^plugins\/external\.json$/ + ], + hooks: [ + /^hooks\// + ], + workflow: [ + /^workflows\/.+\.md$/, + /^\.github\/workflows\/.+\.(?:ya?ml|md)$/ + ], + newSubmission: [ + /^agents\/.+\.agent\.md$/, + /^instructions\/.+\.instructions\.md$/, + /^skills\/[^/]+\/SKILL\.md$/, + /^hooks\/[^/]+\/(?:README\.md|hooks\.json)$/, + /^plugins\/[^/]+\/\.github\/plugin\/plugin\.json$/, + /^workflows\/.+\.md$/, + /^\.github\/workflows\/.+\.(?:ya?ml|md)$/, + /^website\// + ] + }; + + const isBranchedMain = filenames.some((filename) => matchesAny(filename, patterns.branchedMain)); + const hasNewSubmission = files.some( + (file) => file.status === 'added' && matchesAny(file.filename, patterns.newSubmission) + ); + + const desiredLabels = new Set(); + + if (context.payload.pull_request.base.ref === 'main') { + desiredLabels.add('targets-main'); + } + + if (filenames.some((filename) => matchesAny(filename, patterns.externalPlugin))) { + desiredLabels.add('external-plugin'); + } + + if (isBranchedMain) { + desiredLabels.add('branched-main'); + } else { + if (filenames.some((filename) => matchesAny(filename, patterns.skills))) { + desiredLabels.add('skills'); + } + + if (filenames.some((filename) => matchesAny(filename, patterns.plugin))) { + desiredLabels.add('plugin'); + } + + if (filenames.some((filename) => matchesAny(filename, patterns.agent))) { + desiredLabels.add('agent'); + } + + if (filenames.some((filename) => matchesAny(filename, patterns.instructions))) { + desiredLabels.add('instructions'); + } + + if (filenames.some((filename) => matchesAny(filename, patterns.websiteUpdate))) { + desiredLabels.add('website-update'); + } + + if (filenames.some((filename) => matchesAny(filename, patterns.hooks))) { + desiredLabels.add('hooks'); + } + + if (filenames.some((filename) => matchesAny(filename, patterns.workflow))) { + desiredLabels.add('workflow'); + } + + if (hasNewSubmission) { + desiredLabels.add('new-submission'); + } + } + + await Promise.all( + Object.entries(managedLabels).map(([name, config]) => ensureLabel(name, config)) + ); + + const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100 + }); + + const currentManagedLabels = currentLabels + .map((label) => label.name) + .filter((name) => Object.prototype.hasOwnProperty.call(managedLabels, name)); + + const labelsToAdd = [...desiredLabels].filter((name) => !currentManagedLabels.includes(name)); + const labelsToRemove = currentManagedLabels.filter((name) => !desiredLabels.has(name)); + + if (labelsToAdd.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: labelsToAdd + }); + } + + for (const name of labelsToRemove) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name + }); + } + + core.info(`Managed labels: ${[...desiredLabels].sort().join(', ') || 'none'}`);