Skip to content

Commit 2d7ede8

Browse files
✨(tooling) require PR fields workflow: handle edits on project items
1 parent ee3b2aa commit 2d7ede8

1 file changed

Lines changed: 105 additions & 70 deletions

File tree

.github/workflows/chore_require_pr_fields.yml

Lines changed: 105 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ name: PR Fields Check
33
on:
44
pull_request:
55
types: [opened, synchronize, reopened, edited, ready_for_review, labeled, unlabeled]
6+
projects_v2_item:
7+
types: [edited]
68
merge_group:
79

810
permissions:
@@ -18,101 +20,134 @@ jobs:
1820
with:
1921
github-token: ${{ secrets.GH_TOKEN || github.token }}
2022
script: |
21-
const validLabels = new Set([
23+
const VALID_LABELS = new Set([
2224
'breaking', 'added', 'modified', 'removed', 'bugfix', 'dependencies', 'documentation'
2325
]);
24-
const labels = context.payload.pull_request.labels.map(l => l.name);
25-
const matching = labels.filter(l => validLabels.has(l));
26-
if (matching.length !== 1) {
27-
core.setFailed(
28-
`This pull request must have exactly one changelog label (${[...validLabels].join(', ')}). Found: ${matching.length === 0 ? 'none' : matching.join(', ')}.`
29-
);
30-
return;
31-
}
26+
const REQUIRED_PROJECT_FIELDS = ['Importance', 'Size', 'Iteration', 'Epic'];
3227
33-
const { owner, repo } = context.repo;
34-
const prNumber = context.payload.pull_request.number;
28+
const FIELD_VALUES_FRAGMENT = `
29+
fieldValues(first: 30) {
30+
nodes {
31+
... on ProjectV2ItemFieldSingleSelectValue {
32+
name
33+
field { ... on ProjectV2FieldCommon { name } }
34+
}
35+
... on ProjectV2ItemFieldIterationValue {
36+
title
37+
field { ... on ProjectV2FieldCommon { name } }
38+
}
39+
... on ProjectV2ItemFieldTextValue {
40+
text
41+
field { ... on ProjectV2FieldCommon { name } }
42+
}
43+
... on ProjectV2ItemFieldNumberValue {
44+
number
45+
field { ... on ProjectV2FieldCommon { name } }
46+
}
47+
}
48+
}
49+
`;
3550
36-
const query = `
37-
query($owner: String!, $repo: String!, $number: Int!) {
38-
repository(owner: $owner, name: $repo) {
39-
pullRequest(number: $number) {
40-
closingIssuesReferences(first: 10) {
41-
totalCount
42-
}
43-
projectItems(first: 10) {
44-
nodes {
45-
project {
46-
title
47-
}
48-
fieldValues(first: 30) {
49-
nodes {
50-
... on ProjectV2ItemFieldSingleSelectValue {
51-
name
52-
field { ... on ProjectV2FieldCommon { name } }
53-
}
54-
... on ProjectV2ItemFieldIterationValue {
55-
title
56-
field { ... on ProjectV2FieldCommon { name } }
57-
}
58-
... on ProjectV2ItemFieldTextValue {
59-
text
60-
field { ... on ProjectV2FieldCommon { name } }
61-
}
62-
... on ProjectV2ItemFieldNumberValue {
63-
number
64-
field { ... on ProjectV2FieldCommon { name } }
65-
}
66-
}
51+
async function fetchPRByNodeId(nodeId) {
52+
const result = await github.graphql(`
53+
query($nodeId: ID!) {
54+
node(id: $nodeId) {
55+
... on PullRequest {
56+
labels(first: 20) { nodes { name } }
57+
closingIssuesReferences(first: 10) { totalCount }
58+
projectItems(first: 10) {
59+
nodes {
60+
project { title }
61+
${FIELD_VALUES_FRAGMENT}
6762
}
6863
}
6964
}
7065
}
7166
}
72-
}
73-
`;
74-
75-
const result = await github.graphql(query, { owner, repo, number: prNumber });
76-
const pr = result.repository.pullRequest;
67+
`, { nodeId });
68+
const pr = result.node;
69+
pr.labels = pr.labels.nodes.map(l => l.name);
70+
return pr;
71+
}
7772
78-
const linkedIssuesCount = pr.closingIssuesReferences.totalCount;
79-
const projectItems = pr.projectItems.nodes;
73+
async function fetchPRByNumber(owner, repo, number) {
74+
const result = await github.graphql(`
75+
query($owner: String!, $repo: String!, $number: Int!) {
76+
repository(owner: $owner, name: $repo) {
77+
pullRequest(number: $number) {
78+
labels(first: 20) { nodes { name } }
79+
closingIssuesReferences(first: 10) { totalCount }
80+
projectItems(first: 10) {
81+
nodes {
82+
project { title }
83+
${FIELD_VALUES_FRAGMENT}
84+
}
85+
}
86+
}
87+
}
88+
}
89+
`, { owner, repo, number });
90+
const pr = result.repository.pullRequest;
91+
pr.labels = pr.labels.nodes.map(l => l.name);
92+
return pr;
93+
}
8094
81-
const hasDevelopment = linkedIssuesCount > 0;
82-
const hasProjects = projectItems.length > 0;
95+
function checkLabel(labels) {
96+
const matching = labels.filter(l => VALID_LABELS.has(l));
97+
if (matching.length !== 1) {
98+
core.setFailed(
99+
`This pull request must have exactly one changelog label (${[...VALID_LABELS].join(', ')}). Found: ${matching.length === 0 ? 'none' : matching.join(', ')}.`
100+
);
101+
return false;
102+
}
103+
return true;
104+
}
83105
84-
if (!hasDevelopment && !hasProjects) {
85-
core.setFailed(
86-
'This pull request must have either the "Development" field (a linked issue) or be added to a "Projects" board.'
87-
);
88-
return;
106+
function checkLinkedIssueOrProject(pr) {
107+
const hasDevelopment = pr.closingIssuesReferences.totalCount > 0;
108+
const hasProjects = pr.projectItems.nodes.length > 0;
109+
if (!hasDevelopment && !hasProjects) {
110+
core.setFailed(
111+
'This pull request must have either the "Development" field (a linked issue) or be added to a "Projects" board.'
112+
);
113+
return false;
114+
}
115+
return true;
89116
}
90117
91-
if (hasProjects) {
92-
const requiredFields = ['Importance', 'Size', 'Iteration', 'Epic'];
118+
function checkProjectFields(projectItems) {
93119
const errors = [];
94-
95120
for (const item of projectItems) {
96121
const filledFields = new Set();
97-
98122
for (const fieldValue of item.fieldValues.nodes) {
99123
const fieldName = fieldValue.field?.name;
100124
if (!fieldName) continue;
101125
const value = fieldValue.name ?? fieldValue.title ?? fieldValue.text ?? fieldValue.number;
102-
if (value !== null && value !== undefined) {
103-
filledFields.add(fieldName);
104-
}
126+
if (value !== null && value !== undefined) filledFields.add(fieldName);
105127
}
106-
107-
const missingFields = requiredFields.filter(f => !filledFields.has(f));
128+
const missingFields = REQUIRED_PROJECT_FIELDS.filter(f => !filledFields.has(f));
108129
if (missingFields.length > 0) {
109-
errors.push(
110-
`Project "${item.project.title}" is missing required fields: ${missingFields.join(', ')}.`
111-
);
130+
errors.push(`Project "${item.project.title}" is missing required fields: ${missingFields.join(', ')}.`);
112131
}
113132
}
133+
if (errors.length > 0) core.setFailed(errors.join('\n'));
134+
}
135+
136+
// --- main ---
114137
115-
if (errors.length > 0) {
116-
core.setFailed(errors.join('\n'));
138+
let pr;
139+
if (context.eventName === 'projects_v2_item') {
140+
const item = context.payload.projects_v2_item;
141+
if (item.content_type !== 'PullRequest') {
142+
core.info('Project item is not a pull request, skipping.');
143+
return;
117144
}
145+
pr = await fetchPRByNodeId(item.content_node_id);
146+
} else {
147+
const { owner, repo } = context.repo;
148+
pr = await fetchPRByNumber(owner, repo, context.payload.pull_request.number);
118149
}
150+
151+
if (!checkLabel(pr.labels)) return;
152+
if (!checkLinkedIssueOrProject(pr)) return;
153+
checkProjectFields(pr.projectItems.nodes);

0 commit comments

Comments
 (0)