Skip to content

AIRA-64: Project Automation Action Update #11

AIRA-64: Project Automation Action Update

AIRA-64: Project Automation Action Update #11

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`);