@@ -3,6 +3,8 @@ name: PR Fields Check
33on :
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
810permissions :
@@ -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