AIRA-64: Project Automation Action Update #11
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: Project Board Automation | |
| on: | |
| issues: | |
| types: [opened, closed, assigned, labeled] | |
| pull_request: | |
| types: [opened, closed, ready_for_review, converted_to_draft] | |
| env: | |
| PROJECT_ID: PVT_kwHOAGEyUM4A_SBA # Your project ID | |
| jobs: | |
| update-project: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Auto-move items | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.PROJECT_TOKEN }} | |
| script: | | |
| const PROJECT_ID = process.env.PROJECT_ID; | |
| // Helper function to get project info with better error handling | |
| async function getProjectInfo() { | |
| console.log(`🔍 Looking up project by ID: ${PROJECT_ID}`); | |
| const directQuery = ` | |
| query($projectId: ID!) { | |
| node(id: $projectId) { | |
| ... on ProjectV2 { | |
| id | |
| title | |
| fields(first: 20) { | |
| nodes { | |
| ... on ProjectV2Field { | |
| id | |
| name | |
| } | |
| ... on ProjectV2SingleSelectField { | |
| id | |
| name | |
| options { | |
| id | |
| name | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| try { | |
| const result = await github.graphql(directQuery, { | |
| projectId: PROJECT_ID | |
| }); | |
| if (result.node) { | |
| console.log(`✅ Found project: ${result.node.title} (${result.node.id})`); | |
| return result.node; | |
| } else { | |
| throw new Error('Project ID resolved but returned null'); | |
| } | |
| } catch (directError) { | |
| console.log('❌ Direct project lookup failed:', directError.message); | |
| return null; | |
| } | |
| } | |
| // FIXED: Helper function to get item ID with proper pagination | |
| async function getProjectItemId(contentId) { | |
| const projectInfo = await getProjectInfo(); | |
| if (!projectInfo) { | |
| console.log('❌ Cannot get project item - project info unavailable'); | |
| return null; | |
| } | |
| // Use pagination to handle large projects | |
| let cursor = null; | |
| let found = false; | |
| let itemId = null; | |
| while (!found) { | |
| const query = ` | |
| query($projectId: ID!, $cursor: String) { | |
| node(id: $projectId) { | |
| ... on ProjectV2 { | |
| items(first: 100, after: $cursor) { | |
| pageInfo { | |
| hasNextPage | |
| endCursor | |
| } | |
| nodes { | |
| id | |
| content { | |
| ... on Issue { | |
| id | |
| number | |
| } | |
| ... on PullRequest { | |
| id | |
| number | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| try { | |
| const result = await github.graphql(query, { | |
| projectId: projectInfo.id, | |
| cursor | |
| }); | |
| const items = result.node.items.nodes; | |
| const item = items.find(item => item.content && item.content.id === contentId); | |
| if (item) { | |
| console.log(`✅ Found project item for content ${item.content.number}`); | |
| itemId = item.id; | |
| found = true; | |
| } | |
| // Check if we need to paginate further | |
| if (!found && result.node.items.pageInfo.hasNextPage) { | |
| cursor = result.node.items.pageInfo.endCursor; | |
| console.log(`🔄 Paginating... cursor: ${cursor}`); | |
| } else { | |
| found = true; // Exit loop if no more pages or item found | |
| } | |
| } catch (error) { | |
| console.log('❌ Error finding project item:', error.message); | |
| return null; | |
| } | |
| } | |
| if (!itemId) { | |
| console.log(`❌ Content not found in project items`); | |
| } | |
| return itemId; | |
| } | |
| // Helper function to add item to project | |
| async function addToProject(contentId) { | |
| const projectInfo = await getProjectInfo(); | |
| if (!projectInfo) { | |
| console.log('❌ Cannot add to project - project info unavailable'); | |
| return null; | |
| } | |
| const mutation = ` | |
| mutation($projectId: ID!, $contentId: ID!) { | |
| addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { | |
| item { | |
| id | |
| } | |
| } | |
| } | |
| `; | |
| try { | |
| console.log(`🔄 Adding content to project...`); | |
| const result = await github.graphql(mutation, { | |
| projectId: projectInfo.id, | |
| contentId | |
| }); | |
| const itemId = result.addProjectV2ItemById.item.id; | |
| console.log(`✅ Successfully added to project with item ID: ${itemId}`); | |
| return itemId; | |
| } catch (error) { | |
| console.log('❌ Error adding to project:', error.message); | |
| return null; | |
| } | |
| } | |
| // Helper function to update item status | |
| async function updateItemStatus(itemId, statusName) { | |
| const projectInfo = await getProjectInfo(); | |
| if (!projectInfo) { | |
| console.log('❌ Cannot update status - project info unavailable'); | |
| return; | |
| } | |
| const statusField = projectInfo.fields.nodes.find( | |
| field => field.name.toLowerCase() === 'status' | |
| ); | |
| if (!statusField || !statusField.options) { | |
| console.log('❌ Status field not found'); | |
| console.log('Available fields:', projectInfo.fields.nodes.map(f => f.name)); | |
| return; | |
| } | |
| const statusOption = statusField.options.find( | |
| option => option.name.toLowerCase() === statusName.toLowerCase() | |
| ); | |
| if (!statusOption) { | |
| console.log(`❌ Status option "${statusName}" not found`); | |
| console.log('Available status options:', statusField.options.map(o => o.name)); | |
| return; | |
| } | |
| const mutation = ` | |
| mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) { | |
| updateProjectV2ItemFieldValue(input: { | |
| projectId: $projectId, | |
| itemId: $itemId, | |
| fieldId: $fieldId, | |
| value: $value | |
| }) { | |
| projectV2Item { | |
| id | |
| } | |
| } | |
| } | |
| `; | |
| try { | |
| await github.graphql(mutation, { | |
| projectId: projectInfo.id, | |
| itemId, | |
| fieldId: statusField.id, | |
| value: { | |
| singleSelectOptionId: statusOption.id | |
| } | |
| }); | |
| console.log(`✅ Updated item status to: ${statusName}`); | |
| } catch (error) { | |
| console.log(`❌ Failed to update status to ${statusName}:`, error.message); | |
| } | |
| } | |
| // Helper function to get current item status | |
| async function getCurrentItemStatus(itemId) { | |
| const query = ` | |
| query($itemId: ID!) { | |
| node(id: $itemId) { | |
| ... on ProjectV2Item { | |
| fieldValues(first: 20) { | |
| nodes { | |
| ... on ProjectV2ItemFieldSingleSelectValue { | |
| name | |
| field { | |
| ... on ProjectV2SingleSelectField { | |
| name | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| try { | |
| const result = await github.graphql(query, { itemId }); | |
| const statusField = result.node.fieldValues.nodes.find( | |
| field => field.field && field.field.name.toLowerCase() === 'status' | |
| ); | |
| return statusField ? statusField.name : null; | |
| } catch (error) { | |
| console.log('❌ Error getting current status:', error.message); | |
| return null; | |
| } | |
| } | |
| // FIXED: Enhanced issue linking function | |
| async function findLinkedIssues(prTitle, prBody) { | |
| const issues = []; | |
| const fullText = `${prTitle} ${prBody}`.toLowerCase(); | |
| console.log(`🔍 Searching for linked issues in PR content...`); | |
| console.log(` Title: "${prTitle}"`); | |
| // Pattern 1: GitHub keywords (closes #123, fixes #123, resolves #123) | |
| const githubKeywords = [...fullText.matchAll(/(?:closes?|fix(?:es)?|resolves?)\s+#(\d+)/gi)]; | |
| githubKeywords.forEach(match => { | |
| const issueNum = parseInt(match[1]); | |
| if (!issues.includes(issueNum)) { | |
| issues.push(issueNum); | |
| console.log(` ✅ Found GitHub keyword link: #${issueNum}`); | |
| } | |
| }); | |
| // Pattern 2: AIRA format in title (AIRA-64: Description) | |
| const airaInTitle = prTitle.match(/^AIRA-(\d+):/i); | |
| if (airaInTitle) { | |
| const issueNum = parseInt(airaInTitle[1]); | |
| if (!issues.includes(issueNum)) { | |
| issues.push(issueNum); | |
| console.log(` ✅ Found AIRA reference in title: AIRA-${issueNum}`); | |
| } | |
| } | |
| // Pattern 3: AIRA references in body (AIRA-64, implements AIRA-64, etc.) | |
| const airaReferences = [...fullText.matchAll(/(?:implements?|closes?|fix(?:es)?|resolves?|addresses?|related to)?\s*AIRA-(\d+)/gi)]; | |
| airaReferences.forEach(match => { | |
| const issueNum = parseInt(match[1]); | |
| if (!issues.includes(issueNum)) { | |
| issues.push(issueNum); | |
| console.log(` ✅ Found AIRA reference in body: AIRA-${issueNum}`); | |
| } | |
| }); | |
| // Pattern 4: Direct AIRA mentions (AIRA-64) | |
| const directAiraRefs = [...fullText.matchAll(/AIRA-(\d+)/gi)]; | |
| directAiraRefs.forEach(match => { | |
| const issueNum = parseInt(match[1]); | |
| if (!issues.includes(issueNum)) { | |
| issues.push(issueNum); | |
| console.log(` ✅ Found direct AIRA mention: AIRA-${issueNum}`); | |
| } | |
| }); | |
| console.log(`🔗 Total unique linked issues found: ${issues.length}`); | |
| return issues; | |
| } | |
| // Helper function to find issue by AIRA number (REST API fallback) | |
| async function findIssueByAiraNumber(airaNumber) { | |
| try { | |
| // First try GraphQL search | |
| console.log(` 🔍 Searching for AIRA-${airaNumber} using GraphQL...`); | |
| const query = ` | |
| query($searchQuery: String!) { | |
| search(query: $searchQuery, type: ISSUE, first: 10) { | |
| nodes { | |
| ... on Issue { | |
| number | |
| title | |
| state | |
| id | |
| } | |
| } | |
| } | |
| } | |
| `; | |
| const searchQuery = `repo:${context.repo.owner}/${context.repo.repo} AIRA-${airaNumber} in:title`; | |
| console.log(` 🔍 GraphQL search query: ${searchQuery}`); | |
| const result = await github.graphql(query, { | |
| searchQuery | |
| }); | |
| const issues = result.search.nodes.filter(node => | |
| node.title.includes(`AIRA-${airaNumber}`) | |
| ); | |
| if (issues.length > 0) { | |
| console.log(` ✅ Found issue via GraphQL: #${issues[0].number} - ${issues[0].title}`); | |
| return issues[0]; | |
| } | |
| console.log(` ❌ No results from GraphQL search, trying REST API...`); | |
| } catch (graphqlError) { | |
| console.log(` ❌ GraphQL search failed: ${graphqlError.message}`); | |
| console.log(` 🔄 Falling back to REST API search...`); | |
| } | |
| try { | |
| // Fallback to REST API search | |
| const searchResponse = await github.rest.search.issuesAndPullRequests({ | |
| q: `repo:${context.repo.owner}/${context.repo.repo} AIRA-${airaNumber} in:title type:issue`, | |
| per_page: 10 | |
| }); | |
| console.log(` 🔍 REST API found ${searchResponse.data.total_count} results`); | |
| const matchingIssues = searchResponse.data.items.filter(issue => | |
| issue.title.includes(`AIRA-${airaNumber}`) | |
| ); | |
| if (matchingIssues.length > 0) { | |
| const issue = matchingIssues[0]; | |
| console.log(` ✅ Found issue via REST API: #${issue.number} - ${issue.title}`); | |
| return { | |
| number: issue.number, | |
| title: issue.title, | |
| state: issue.state, | |
| id: issue.node_id | |
| }; | |
| } else { | |
| console.log(` ❌ No matching issues found for AIRA-${airaNumber}`); | |
| return null; | |
| } | |
| } catch (restError) { | |
| console.log(` ❌ REST API search also failed: ${restError.message}`); | |
| // Last resort: try to find by issue number if AIRA number matches | |
| try { | |
| console.log(` 🔄 Last resort: checking if AIRA-${airaNumber} corresponds to issue #${airaNumber}...`); | |
| const issueResponse = await github.rest.issues.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: airaNumber | |
| }); | |
| if (issueResponse.data.title.includes(`AIRA-${airaNumber}`)) { | |
| console.log(` ✅ Found by direct lookup: #${issueResponse.data.number} - ${issueResponse.data.title}`); | |
| return { | |
| number: issueResponse.data.number, | |
| title: issueResponse.data.title, | |
| state: issueResponse.data.state, | |
| id: issueResponse.data.node_id | |
| }; | |
| } | |
| } catch (directError) { | |
| console.log(` ❌ Direct lookup failed: ${directError.message}`); | |
| } | |
| return null; | |
| } | |
| } | |
| // Main automation logic | |
| console.log(`🚀 Starting automation for ${context.eventName} event`); | |
| if (context.eventName === 'issues') { | |
| const action = context.payload.action; | |
| const issue = context.payload.issue; | |
| const issueId = issue.node_id; | |
| console.log(`📝 Processing issue #${issue.number}: "${issue.title}"`); | |
| console.log(` Action: ${action}`); | |
| // Ensure issue is added to project | |
| let itemId = await getProjectItemId(issueId); | |
| if (!itemId) { | |
| console.log(`🔄 Issue not in project, adding...`); | |
| itemId = await addToProject(issueId); | |
| if (itemId) { | |
| console.log(`✅ Added issue #${issue.number} to project`); | |
| } else { | |
| console.log(`❌ Failed to add issue #${issue.number} to project`); | |
| return; | |
| } | |
| } else { | |
| console.log(`✅ Issue #${issue.number} already in project`); | |
| } | |
| if (itemId) { | |
| // Smart status transitions based on action | |
| if (action === 'opened') { | |
| console.log(`🆕 New issue opened, moving to Backlog`); | |
| await updateItemStatus(itemId, 'Backlog'); | |
| } else if (action === 'assigned') { | |
| console.log(`👤 Issue assigned, moving to Ready`); | |
| await updateItemStatus(itemId, 'Ready'); | |
| } else if (action === 'closed') { | |
| console.log(`✅ Issue closed, moving to Done`); | |
| await updateItemStatus(itemId, 'Done'); | |
| } else if (action === 'labeled') { | |
| const label = context.payload.label; | |
| console.log(`🏷️ Label added: ${label.name}`); | |
| if (label.name === 'ready') { | |
| await updateItemStatus(itemId, 'Ready'); | |
| } else if (label.name === 'in-progress') { | |
| await updateItemStatus(itemId, 'In Progress'); | |
| } else if (label.name === 'blocked') { | |
| await updateItemStatus(itemId, 'Blocked'); | |
| } else if (label.name === 'needs-review') { | |
| await updateItemStatus(itemId, 'In Review'); | |
| } else if (label.name === 'priority-high') { | |
| const currentStatus = await getCurrentItemStatus(itemId); | |
| if (currentStatus === 'Backlog') { | |
| console.log(`🚀 High priority item, promoting from Backlog to Ready`); | |
| await updateItemStatus(itemId, 'Ready'); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| if (context.eventName === 'pull_request') { | |
| const action = context.payload.action; | |
| const pr = context.payload.pull_request; | |
| const prId = pr.node_id; | |
| console.log(`🔍 Processing PR #${pr.number}: "${pr.title}"`); | |
| console.log(` Action: ${action}`); | |
| // Ensure PR is added to project | |
| let itemId = await getProjectItemId(prId); | |
| if (!itemId) { | |
| console.log(`🔄 PR not in project, adding...`); | |
| itemId = await addToProject(prId); | |
| if (itemId) { | |
| console.log(`✅ Added PR #${pr.number} to project`); | |
| } else { | |
| console.log(`❌ Failed to add PR #${pr.number} to project`); | |
| return; | |
| } | |
| } else { | |
| console.log(`✅ PR #${pr.number} already in project`); | |
| } | |
| if (itemId) { | |
| if (action === 'opened' || action === 'ready_for_review') { | |
| console.log(`📝 PR ready for review, moving to In Review`); | |
| await updateItemStatus(itemId, 'In Review'); | |
| } else if (action === 'converted_to_draft') { | |
| console.log(`📝 PR converted to draft, moving to In Progress`); | |
| await updateItemStatus(itemId, 'In Progress'); | |
| } else if (action === 'closed') { | |
| if (pr.merged) { | |
| console.log(`🎉 PR merged, moving to Done`); | |
| await updateItemStatus(itemId, 'Done'); | |
| // FIXED: Enhanced issue linking and auto-close | |
| const linkedAiraNumbers = await findLinkedIssues(pr.title, pr.body || ''); | |
| if (linkedAiraNumbers.length > 0) { | |
| console.log(`🔗 Processing ${linkedAiraNumbers.length} linked issues...`); | |
| for (const airaNumber of linkedAiraNumbers) { | |
| try { | |
| console.log(`🔄 Processing AIRA-${airaNumber}...`); | |
| // Find the actual GitHub issue | |
| const issue = await findIssueByAiraNumber(airaNumber); | |
| if (issue && issue.state === 'open') { | |
| // Close the issue | |
| await github.rest.issues.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.number, | |
| state: 'closed' | |
| }); | |
| // Move to Done in project | |
| let linkedItemId = await getProjectItemId(issue.id); | |
| if (!linkedItemId) { | |
| linkedItemId = await addToProject(issue.id); | |
| } | |
| if (linkedItemId) { | |
| await updateItemStatus(linkedItemId, 'Done'); | |
| } | |
| console.log(`✅ Auto-closed and moved AIRA-${airaNumber} (issue #${issue.number}) to Done`); | |
| } else if (issue) { | |
| console.log(`ℹ️ Issue AIRA-${airaNumber} already closed`); | |
| } | |
| } catch (error) { | |
| console.log(`❌ Failed to process AIRA-${airaNumber}:`, error.message); | |
| } | |
| } | |
| } else { | |
| console.log(`ℹ️ No linked issues found to auto-close`); | |
| } | |
| } else { | |
| console.log(`📝 PR closed without merge, moving back to Todo`); | |
| await updateItemStatus(itemId, 'Todo'); | |
| } | |
| } | |
| } | |
| } | |
| console.log(`🎯 Automation completed for ${context.eventName} event`); |