@@ -2,61 +2,339 @@ name: Project Board Automation
22
33on :
44 issues :
5- types : [opened, closed, assigned]
5+ types : [opened, closed, assigned, labeled ]
66 pull_request :
7- types : [opened, closed, ready_for_review]
7+ types : [opened, closed, ready_for_review, converted_to_draft]
8+
9+ env :
10+ PROJECT_ID : PVT_kwHOAGEyUM4A_SBA # GitHub Project V2 ID
811
912jobs :
1013 update-project :
1114 runs-on : ubuntu-latest
1215 steps :
1316 - name : Auto-move items
14- uses : actions/github-script@v6
17+ uses : actions/github-script@v7
1518 with :
1619 github-token : ${{ secrets.GITHUB_TOKEN }}
1720 script : |
18- // This is a simplified version - you'll need to adapt based on your project board setup
19- console.log('Event:', context.eventName);
20- console.log('Action:', context.payload.action);
21+ const PROJECT_ID = process.env.PROJECT_ID;
22+
23+ // Helper function to get project info by ID
24+ async function getProjectInfo() {
25+ const query = `
26+ query($projectId: ID!) {
27+ node(id: $projectId) {
28+ ... on ProjectV2 {
29+ id
30+ fields(first: 20) {
31+ nodes {
32+ ... on ProjectV2Field {
33+ id
34+ name
35+ }
36+ ... on ProjectV2SingleSelectField {
37+ id
38+ name
39+ options {
40+ id
41+ name
42+ }
43+ }
44+ }
45+ }
46+ }
47+ }
48+ }
49+ `;
50+
51+ try {
52+ const result = await github.graphql(query, {
53+ projectId: PROJECT_ID
54+ });
55+ return result.node;
56+ } catch (error) {
57+ console.log('Error fetching project info:', error);
58+ return null;
59+ }
60+ }
61+
62+ // Helper function to get item ID for issue/PR
63+ async function getProjectItemId(contentId) {
64+ const query = `
65+ query($projectId: ID!, $contentId: ID!) {
66+ node(id: $projectId) {
67+ ... on ProjectV2 {
68+ items(first: 100) {
69+ nodes {
70+ id
71+ content {
72+ ... on Issue {
73+ id
74+ }
75+ ... on PullRequest {
76+ id
77+ }
78+ }
79+ }
80+ }
81+ }
82+ }
83+ }
84+ `;
85+
86+ try {
87+ const result = await github.graphql(query, {
88+ projectId: PROJECT_ID,
89+ contentId
90+ });
91+
92+ const item = result.node.items.nodes.find(
93+ item => item.content && item.content.id === contentId
94+ );
95+ return item ? item.id : null;
96+ } catch (error) {
97+ console.log('Error finding project item:', error);
98+ return null;
99+ }
100+ }
101+
102+ // Helper function to add item to project
103+ async function addToProject(contentId) {
104+ const projectInfo = await getProjectInfo();
105+ if (!projectInfo) return null;
106+
107+ const mutation = `
108+ mutation($projectId: ID!, $contentId: ID!) {
109+ addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
110+ item {
111+ id
112+ }
113+ }
114+ }
115+ `;
116+
117+ try {
118+ const result = await github.graphql(mutation, {
119+ projectId: projectInfo.id,
120+ contentId
121+ });
122+ return result.addProjectV2ItemById.item.id;
123+ } catch (error) {
124+ console.log('Error adding to project:', error);
125+ return null;
126+ }
127+ }
128+
129+ // Helper function to update item status
130+ async function updateItemStatus(itemId, statusName) {
131+ const projectInfo = await getProjectInfo();
132+ if (!projectInfo) return;
133+
134+ const statusField = projectInfo.fields.nodes.find(
135+ field => field.name.toLowerCase() === 'status'
136+ );
137+
138+ if (!statusField || !statusField.options) {
139+ console.log('Status field not found');
140+ return;
141+ }
142+
143+ const statusOption = statusField.options.find(
144+ option => option.name.toLowerCase() === statusName.toLowerCase()
145+ );
146+
147+ if (!statusOption) {
148+ console.log(`Status option "${statusName}" not found`);
149+ return;
150+ }
151+
152+ const mutation = `
153+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {
154+ updateProjectV2ItemFieldValue(input: {
155+ projectId: $projectId,
156+ itemId: $itemId,
157+ fieldId: $fieldId,
158+ value: $value
159+ }) {
160+ projectV2Item {
161+ id
162+ }
163+ }
164+ }
165+ `;
166+
167+ try {
168+ await github.graphql(mutation, {
169+ projectId: projectInfo.id,
170+ itemId,
171+ fieldId: statusField.id,
172+ value: {
173+ singleSelectOptionId: statusOption.id
174+ }
175+ });
176+ console.log(`✅ Updated item status to: ${statusName}`);
177+ } catch (error) {
178+ console.log(`❌ Failed to update status to ${statusName}:`, error);
179+ }
180+ }
21181
182+ // Helper function to get current item status
183+ async function getCurrentItemStatus(itemId) {
184+ const projectInfo = await getProjectInfo();
185+ if (!projectInfo) return null;
186+
187+ const query = `
188+ query($itemId: ID!) {
189+ node(id: $itemId) {
190+ ... on ProjectV2Item {
191+ fieldValues(first: 20) {
192+ nodes {
193+ ... on ProjectV2ItemFieldSingleSelectValue {
194+ name
195+ field {
196+ ... on ProjectV2SingleSelectField {
197+ name
198+ }
199+ }
200+ }
201+ }
202+ }
203+ }
204+ }
205+ }
206+ `;
207+
208+ try {
209+ const result = await github.graphql(query, { itemId });
210+ const statusField = result.node.fieldValues.nodes.find(
211+ field => field.field && field.field.name.toLowerCase() === 'status'
212+ );
213+ return statusField ? statusField.name : null;
214+ } catch (error) {
215+ console.log('Error getting current status:', error);
216+ return null;
217+ }
218+ }
219+
220+ // Main automation logic
22221 if (context.eventName === 'issues') {
23222 const action = context.payload.action;
24223 const issue = context.payload.issue;
224+ const issueId = issue.node_id;
225+
226+ console.log(`📝 Processing issue #${issue.number} - Action: ${action}`);
25227
26- if (action === 'opened') {
27- console.log(`📝 New issue #${issue.number} - would move to Todo`);
28- } else if (action === 'assigned') {
29- console.log(`👤 Issue #${issue.number} assigned - would move to In Progress`);
30- } else if (action === 'closed') {
31- console.log(`✅ Issue #${issue.number} closed - would move to Done`);
228+ // Ensure issue is added to project
229+ let itemId = await getProjectItemId(issueId);
230+ if (!itemId) {
231+ itemId = await addToProject(issueId);
232+ if (itemId) {
233+ console.log(`✅ Added issue #${issue.number} to project`);
234+ }
235+ }
236+
237+ if (itemId) {
238+ // Smart status transitions based on action
239+ if (action === 'opened') {
240+ // New issues go to backlog for triage
241+ await updateItemStatus(itemId, 'Backlog');
242+ } else if (action === 'assigned') {
243+ // Assignment moves to Ready (not automatically In Progress)
244+ // Team member decides when to start work
245+ await updateItemStatus(itemId, 'Ready');
246+ } else if (action === 'closed') {
247+ await updateItemStatus(itemId, 'Done');
248+ } else if (action === 'labeled') {
249+ // Label-based transitions for more granular control
250+ const label = context.payload.label;
251+ if (label.name === 'ready') {
252+ await updateItemStatus(itemId, 'Ready');
253+ } else if (label.name === 'in-progress') {
254+ await updateItemStatus(itemId, 'In Progress');
255+ } else if (label.name === 'blocked') {
256+ await updateItemStatus(itemId, 'Blocked');
257+ } else if (label.name === 'needs-review') {
258+ await updateItemStatus(itemId, 'In Review');
259+ } else if (label.name === 'priority-high') {
260+ // High priority items can jump to Ready if not already started
261+ const currentStatus = await getCurrentItemStatus(itemId);
262+ if (currentStatus === 'Backlog') {
263+ await updateItemStatus(itemId, 'Ready');
264+ }
265+ }
266+ }
32267 }
33268 }
34269
35270 if (context.eventName === 'pull_request') {
36271 const action = context.payload.action;
37272 const pr = context.payload.pull_request;
273+ const prId = pr.node_id;
38274
39- if (action === 'opened' || action === 'ready_for_review') {
40- console.log(`🔍 PR #${pr.number} ready for review - would move to In Review`);
41- } else if (action === 'closed' && pr.merged) {
42- console.log(`🎉 PR #${pr.number} merged - would move to Done`);
43-
44- // Auto-close linked issues
45- const body = pr.body || '';
46- const issueNumbers = [...body.matchAll(/(?:closes|fixes|resolves)\s+#(\d+)/gi)]
47- .map(match => parseInt(match[1]));
48-
49- for (const issueNumber of issueNumbers) {
50- try {
51- await github.rest.issues.update({
52- owner: context.repo.owner,
53- repo: context.repo.repo,
54- issue_number: issueNumber,
55- state: 'closed'
56- });
57- console.log(`✅ Auto-closed issue #${issueNumber}`);
58- } catch (error) {
59- console.log(`❌ Failed to close issue #${issueNumber}`);
275+ console.log(`🔍 Processing PR #${pr.number} - Action: ${action}`);
276+
277+ // Ensure PR is added to project
278+ let itemId = await getProjectItemId(prId);
279+ if (!itemId) {
280+ itemId = await addToProject(prId);
281+ if (itemId) {
282+ console.log(`✅ Added PR #${pr.number} to project`);
283+ }
284+ }
285+
286+ if (itemId) {
287+ // Move based on action
288+ if (action === 'opened' || action === 'ready_for_review') {
289+ await updateItemStatus(itemId, 'In Review');
290+ } else if (action === 'converted_to_draft') {
291+ await updateItemStatus(itemId, 'In Progress');
292+ } else if (action === 'closed') {
293+ if (pr.merged) {
294+ await updateItemStatus(itemId, 'Done');
295+ console.log(`🎉 PR #${pr.number} merged and moved to Done`);
296+
297+ // Auto-close and move linked issues
298+ const body = pr.body || '';
299+ const issueNumbers = [...body.matchAll(/(?:closes|fixes|resolves)\s+#(\d+)/gi)]
300+ .map(match => parseInt(match[1]));
301+
302+ for (const issueNumber of issueNumbers) {
303+ try {
304+ // Close the issue
305+ await github.rest.issues.update({
306+ owner: context.repo.owner,
307+ repo: context.repo.repo,
308+ issue_number: issueNumber,
309+ state: 'closed'
310+ });
311+
312+ // Get issue details and move to Done
313+ const issueResponse = await github.rest.issues.get({
314+ owner: context.repo.owner,
315+ repo: context.repo.repo,
316+ issue_number: issueNumber
317+ });
318+
319+ const linkedIssueId = issueResponse.data.node_id;
320+ let linkedItemId = await getProjectItemId(linkedIssueId);
321+
322+ if (!linkedItemId) {
323+ linkedItemId = await addToProject(linkedIssueId);
324+ }
325+
326+ if (linkedItemId) {
327+ await updateItemStatus(linkedItemId, 'Done');
328+ }
329+
330+ console.log(`✅ Auto-closed and moved issue #${issueNumber} to Done`);
331+ } catch (error) {
332+ console.log(`❌ Failed to process linked issue #${issueNumber}:`, error);
333+ }
334+ }
335+ } else {
336+ await updateItemStatus(itemId, 'Todo');
337+ console.log(`📝 PR #${pr.number} closed without merge, moved back to Todo`);
60338 }
61339 }
62340 }
0 commit comments