✨(tooling) require PR fields workflow: handle edits on project items #146
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Fields Check | ||
| on: | ||
| pull_request: | ||
| types: [opened, synchronize, reopened, edited, ready_for_review, labeled, unlabeled] | ||
| projects_v2_item: | ||
| types: [edited] | ||
| merge_group: | ||
| permissions: | ||
| pull-requests: read | ||
| jobs: | ||
| check-pr-fields: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: Check PR fields | ||
| # GH_TOKEN must be a PAT with read:project scope to query GitHub Projects v2 | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| with: | ||
| github-token: ${{ secrets.GH_TOKEN || github.token }} | ||
| script: | | ||
| const VALID_LABELS = new Set([ | ||
| 'breaking', 'added', 'modified', 'removed', 'bugfix', 'dependencies', 'documentation' | ||
| ]); | ||
| const REQUIRED_PROJECT_FIELDS = ['Importance', 'Size', 'Iteration', 'Epic']; | ||
| const FIELD_VALUES_FRAGMENT = ` | ||
| fieldValues(first: 30) { | ||
| nodes { | ||
| ... on ProjectV2ItemFieldSingleSelectValue { | ||
| name | ||
| field { ... on ProjectV2FieldCommon { name } } | ||
| } | ||
| ... on ProjectV2ItemFieldIterationValue { | ||
| title | ||
| field { ... on ProjectV2FieldCommon { name } } | ||
| } | ||
| ... on ProjectV2ItemFieldTextValue { | ||
| text | ||
| field { ... on ProjectV2FieldCommon { name } } | ||
| } | ||
| ... on ProjectV2ItemFieldNumberValue { | ||
| number | ||
| field { ... on ProjectV2FieldCommon { name } } | ||
| } | ||
| } | ||
| } | ||
| `; | ||
| async function fetchPRByNodeId(nodeId) { | ||
| const result = await github.graphql(` | ||
| query($nodeId: ID!) { | ||
| node(id: $nodeId) { | ||
| ... on PullRequest { | ||
| labels(first: 20) { nodes { name } } | ||
| closingIssuesReferences(first: 10) { totalCount } | ||
| projectItems(first: 10) { | ||
| nodes { | ||
| project { title } | ||
| ${FIELD_VALUES_FRAGMENT} | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| `, { nodeId }); | ||
| const pr = result.node; | ||
| pr.labels = pr.labels.nodes.map(l => l.name); | ||
| return pr; | ||
| } | ||
| async function fetchPRByNumber(owner, repo, number) { | ||
| const result = await github.graphql(` | ||
| query($owner: String!, $repo: String!, $number: Int!) { | ||
| repository(owner: $owner, name: $repo) { | ||
| pullRequest(number: $number) { | ||
| labels(first: 20) { nodes { name } } | ||
| closingIssuesReferences(first: 10) { totalCount } | ||
| projectItems(first: 10) { | ||
| nodes { | ||
| project { title } | ||
| ${FIELD_VALUES_FRAGMENT} | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| `, { owner, repo, number }); | ||
| const pr = result.repository.pullRequest; | ||
| pr.labels = pr.labels.nodes.map(l => l.name); | ||
| return pr; | ||
| } | ||
| function checkLabel(labels) { | ||
| const matching = labels.filter(l => VALID_LABELS.has(l)); | ||
| if (matching.length !== 1) { | ||
| core.setFailed( | ||
| `This pull request must have exactly one changelog label (${[...VALID_LABELS].join(', ')}). Found: ${matching.length === 0 ? 'none' : matching.join(', ')}.` | ||
| ); | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
| function checkLinkedIssueOrProject(pr) { | ||
| const hasDevelopment = pr.closingIssuesReferences.totalCount > 0; | ||
| const hasProjects = pr.projectItems.nodes.length > 0; | ||
| if (!hasDevelopment && !hasProjects) { | ||
| core.setFailed( | ||
| 'This pull request must have either the "Development" field (a linked issue) or be added to a "Projects" board.' | ||
| ); | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
| function checkProjectFields(projectItems) { | ||
| const errors = []; | ||
| for (const item of projectItems) { | ||
| const filledFields = new Set(); | ||
| for (const fieldValue of item.fieldValues.nodes) { | ||
| const fieldName = fieldValue.field?.name; | ||
| if (!fieldName) continue; | ||
| const value = fieldValue.name ?? fieldValue.title ?? fieldValue.text ?? fieldValue.number; | ||
| if (value !== null && value !== undefined) filledFields.add(fieldName); | ||
| } | ||
| const missingFields = REQUIRED_PROJECT_FIELDS.filter(f => !filledFields.has(f)); | ||
| if (missingFields.length > 0) { | ||
| errors.push(`Project "${item.project.title}" is missing required fields: ${missingFields.join(', ')}.`); | ||
| } | ||
| } | ||
| if (errors.length > 0) core.setFailed(errors.join('\n')); | ||
| } | ||
| // --- main --- | ||
| let pr; | ||
| if (context.eventName === 'projects_v2_item') { | ||
| const item = context.payload.projects_v2_item; | ||
| if (item.content_type !== 'PullRequest') { | ||
| core.info('Project item is not a pull request, skipping.'); | ||
| return; | ||
| } | ||
| pr = await fetchPRByNodeId(item.content_node_id); | ||
| } else { | ||
| const { owner, repo } = context.repo; | ||
| pr = await fetchPRByNumber(owner, repo, context.payload.pull_request.number); | ||
| } | ||
| if (!checkLabel(pr.labels)) return; | ||
| if (!checkLinkedIssueOrProject(pr)) return; | ||
| checkProjectFields(pr.projectItems.nodes); | ||