From 2d345d1e23a3d3ef9db6eca6d2bc1b02d1ba9ba5 Mon Sep 17 00:00:00 2001 From: Sukhvir Singh Date: Mon, 10 Feb 2025 21:43:16 +0530 Subject: [PATCH 1/4] fixed issues --- scripts/unassign-inactive-issues.js | 473 +++++++++++++++++----------- 1 file changed, 286 insertions(+), 187 deletions(-) diff --git a/scripts/unassign-inactive-issues.js b/scripts/unassign-inactive-issues.js index 81f4c43..62ee260 100644 --- a/scripts/unassign-inactive-issues.js +++ b/scripts/unassign-inactive-issues.js @@ -22,9 +22,9 @@ const formatUnassignments = (unassignments) => { // Format the grouped unassignments return Object.values(groupedByIssue) .map(({ users, repo, issueNumber, issueUrl }) => - `• ${users.map(u => `@${u}`).join(', ')} from <${issueUrl}|${repo}#${issueNumber}>` + `${users.map(u => `@${u}`).join(', ')} from <${issueUrl}|${repo}#${issueNumber}>` ) - .join('\n\n'); + .join(', '); }; async function getAllIssues(github, owner, repo) { @@ -35,44 +35,213 @@ async function getAllIssues(github, owner, repo) { while (true) { console.log(`Fetching page ${page} of issues...`); - const response = await github.rest.issues.listForRepo({ - owner, - repo, - state: 'open', - per_page: perPage, - page: page - }); - - const issues = response.data; - - if (issues.length === 0) { - break; // No more issues to fetch - } - - allIssues.push(...issues); - console.log(`Fetched ${issues.length} issues from page ${page}`); - - if (issues.length < perPage) { - break; // Last page has fewer items than perPage + try { + const response = await github.rest.issues.listForRepo({ + owner, + repo, + state: 'open', + per_page: perPage, + page: page, + filter: 'all', + pulls: false + }); + + const issues = response.data.filter(issue => !issue.pull_request); + + if (issues.length === 0) { + break; // No more issues to fetch + } + + allIssues.push(...issues); + console.log(`Fetched ${issues.length} issues (excluding PRs) from page ${page}`); + + if (issues.length < perPage) { + break; // Last page has fewer items than perPage + } + + page++; + } catch (error) { + console.error(`Error fetching issues page ${page}:`, error); + break; } - - page++; } - console.log(`Total issues fetched: ${allIssues.length}`); + console.log(`Total issues fetched (excluding PRs): ${allIssues.length}`); return allIssues; } -module.exports = async ({github, context, core}) => { +const checkLinkedPRs = async (issue, github, owner, repo) => { try { - const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL; - - if (!slackWebhookUrl) { - throw new Error('Slack webhook URL missing. Please ensure SLACK_WEBHOOK_URL is set in the workflow.'); + if (!issue || !issue.number) { + console.error('Invalid issue object received:', issue); + return new Set(); // Return empty Set instead of false + } + + let linkedPRs = new Set(); + + // Method 1: Check timeline with enhanced connected event handling + try { + const { data: timelineEvents } = await github.rest.issues.listEventsForTimeline({ + owner, + repo, + issue_number: issue.number, + per_page: 100 + }); + + + for (const event of timelineEvents) { + if ( + (event.event === 'connected' || event.event === 'cross-referenced') || + (event.event === 'referenced' && event?.commit_id && event?.source?.issue?.pull_request) || + (event.event === 'closed' && event?.commit_id && event?.source?.issue?.pull_request) || + (event.event === 'connected' && event?.source?.issue?.pull_request?.merged === false) + ) { + try { + let prNumber = event?.source?.issue?.number; + if (!prNumber && event?.source?.pull_request?.number) { + prNumber = event.source.pull_request.number; + } + + if (prNumber) { + console.log(`Checking PR #${prNumber} from timeline event`); + const { data: pr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: prNumber + }); + + if (pr && pr.state === 'open') { + console.log(`Found valid linked PR #${prNumber} (${pr.state})`); + linkedPRs.add(prNumber); // Use add() instead of push() + } + }else{ + console.log('founded pr linked in the issue'); + linkedPRs.add(1); + } + } catch (e) { + console.log(`Error fetching PR details:`, e.message); + } + } + } + } catch (timelineError) { + console.error(`Error fetching timeline for issue #${issue.number}:`, timelineError.message); } - console.log('Slack webhook URL exists:', !!slackWebhookUrl); + // Method 2: Search for PRs that mention this issue + try { + const searchQuery = `repo:${owner}/${repo} type:pr is:open ${issue.number} in:body,title`; + console.log(`Searching for PRs with query: ${searchQuery}`); + + const searchResult = await github.rest.search.issuesAndPullRequests({ + q: searchQuery + }); + if (searchResult?.data?.items) { + const foundPRs = searchResult.data.items.filter(item => item?.pull_request); + console.log(`Found ${foundPRs.length} PRs mentioning this issue through search`); + + for (const pr of foundPRs) { + if (pr?.number) { + try { + const prDetails = await github.rest.pulls.get({ + owner, + repo, + pull_number: pr.number + }); + if (prDetails?.data?.state === 'open') { + linkedPRs.add(prDetails.data.number); // Use add() with PR number + } + } catch (e) { + console.log(`Error fetching PR #${pr.number} details:`, e.message); + } + } + } + } + } catch (searchError) { + console.log('Search API error:', searchError.message); + } + + // Method 3: Check issue body for PR references + if (issue.body) { + const prReferences = new Set(); + const patterns = [ + /#(\d+)/g, + new RegExp(`https?://github\\.com/${owner}/${repo}/pull/(\\d+)`, 'g'), + /(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s*:?\s*#(\d+)/gi + ]; + + for (const pattern of patterns) { + const matches = [...issue.body.matchAll(pattern)]; + for (const match of matches) { + if (match?.[1]) { + const prNumber = parseInt(match[1], 10); + if (!isNaN(prNumber)) { + prReferences.add(prNumber); + } + } + } + } + + console.log(`Found ${prReferences.size} PR references in issue body`); + + for (const prNumber of prReferences) { + try { + const prDetails = await github.rest.pulls.get({ + owner, + repo, + pull_number: prNumber + }); + if (prDetails?.data?.state === 'open') { + linkedPRs.add(prNumber); // Use add() with PR number + } + } catch (e) { + console.log(`Error fetching PR #${prNumber}:`, e.message); + } + } + } + // Return the Set of linked PR numbers (always return a Set) + return linkedPRs; + } catch (error) { + console.error(`Error in checkLinkedPRs for issue #${issue.number}:`, error); + return new Set(); // Return empty Set instead of false + } +}; + +// Function to check user membership and ownership +const checkUserMembership = async (owner, repo, username, github) => { + try { + // Check if the user is an owner of the repository + const repoDetails = await github.rest.repos.get({ + owner, + repo + }); + + // Check if the repository owner matches the username + if (repoDetails.data.owner.login === username) { + console.log(`${username} is the repository owner`); + return true; + } + + // Check if the user is an organization member + try { + await github.rest.orgs.getMembershipForUser({ + org: owner, + username: username + }); + console.log(`${username} is an organization member`); + return true; + } catch (orgError) { + console.log(`${username} is not an organization member`); + return false; + } + } catch (error) { + console.error(`Error checking user membership for ${username}:`, error); + return false; + } +}; + +module.exports = async ({ github, context, core }) => { + try { const unassignments = []; const inactivityPeriodInMinutes = 1; @@ -96,198 +265,128 @@ module.exports = async ({github, context, core}) => { throw new Error(`Repository access failed. Please check your GitHub App permissions for repository access. Error: ${authError.message}`); } - // Function to check user membership and ownership - const checkUserMembership = async (owner, repo, username) => { - try { - // Check if the user is an owner of the repository - const repoDetails = await github.rest.repos.get({ - owner, - repo - }); - - // Check if the repository owner matches the username - if (repoDetails.data.owner.login === username) { - return true; // User is the repository owner - } - - // Check if the user is an organization member - try { - await github.rest.orgs.getMembershipForUser({ - org: owner, - username: username - }); - return true; // User is a member - } catch (orgError) { - return false; // Not a member - } - } catch (error) { - console.error(`Error checking user membership for ${username}:`, error); - return false; - } - }; - // Get all issues using pagination const issues = await getAllIssues(github, owner, repo); console.log(`Processing ${issues.length} open issues`); for (const issue of issues) { - const assignees = issue.assignees || []; - if (assignees.length === 0) continue; - - console.log(`\nChecking issue #${issue.number}:`); - - // Enhanced PR checking function - const checkLinkedPRs = async () => { - try { - let linkedPRs = []; - if (issue.pull_request) { - const prDetails = await github.rest.pulls.get({ - owner, - repo, - pull_number: issue.pull_request.number - }); - linkedPRs.push(prDetails.data); - } + if (!issue || !issue.number) { + console.error('Skipping invalid issue:', issue); + continue; + } - const timeline = await github.rest.issues.listEventsForTimeline({ - owner, - repo, - issue_number: issue.number, - per_page: 100 - }); + console.log(`\nProcessing issue #${issue.number}`); + console.log('Issue data:', { + number: issue.number, + title: issue.title, + assignees: issue.assignees ? issue.assignees.length : 0, + updated_at: issue.updated_at + }); - const prReferences = timeline.data.filter(event => - (event.event === 'cross-referenced' || - event.event === 'connected' || - event.event === 'referenced') && - event.source && - event.source.type === 'pull_request' - ); + const assignees = issue.assignees || []; + if (assignees.length === 0) { + console.log(`Issue #${issue.number} has no assignees, skipping`); + continue; + } - for (const ref of prReferences) { - try { - const prDetails = await github.rest.pulls.get({ - owner, - repo, - pull_number: ref.source.issue.number - }); - linkedPRs.push(prDetails.data); - } catch (prFetchError) { - console.error(`Error fetching PR details:`, prFetchError); - } - } + // Check if issue is inactive + const lastActivity = new Date(issue.updated_at); + const now = new Date(); + + if (now - lastActivity <= inactivityPeriodInMinutes * 60 * 1000) { + console.log(`Issue #${issue.number} is still active, skipping`); + continue; + } - // Check for PR references in the issue body - const prLinkRegex = /(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)/gi; - const bodyMatches = [...(issue.body || '').matchAll(prLinkRegex)]; - - for (const match of bodyMatches) { - try { - const prNumber = match[1]; - const prDetails = await github.rest.pulls.get({ - owner, - repo, - pull_number: prNumber - }); - linkedPRs.push(prDetails.data); - } catch (prFetchError) { - console.error(`Error fetching PR from body link:`, prFetchError); - } - } + console.log(`Checking for linked PRs for issue #${issue.number}`); + const hasOpenPRs = await checkLinkedPRs(issue, github, owner, repo); + + if (hasOpenPRs.size > 0) { + console.log(`Issue #${issue.number} has open PRs, skipping unassignment`); + continue; + } - const openPRs = linkedPRs.filter(pr => pr.state === 'open'); - - if (openPRs.length > 0) { - console.log(`Issue #${issue.number} has open PRs:`, - openPRs.map(pr => `#${pr.number} (${pr.state})`)); - return true; - } + console.log(`Processing inactive issue #${issue.number} with no open PRs`); - console.log(`No open linked PRs found for issue #${issue.number}`); - return false; - } catch (error) { - console.error(`Error checking PR links for issue #${issue.number}:`, error); - return false; - } - }; + const inactiveAssignees = []; + const activeAssignees = []; - const lastActivity = new Date(issue.updated_at); - const now = new Date(); - - if (now - lastActivity > inactivityPeriodInMinutes * 60 * 1000) { - const hasOpenPRs = await checkLinkedPRs(); - - if (hasOpenPRs) { - console.log(`Skipping issue #${issue.number} as it has open PRs`); + for (const assignee of assignees) { + if (!assignee || !assignee.login) { + console.log('Skipping invalid assignee:', assignee); continue; } - console.log(`Processing inactive issue #${issue.number} with no open PRs`); - - const inactiveAssignees = []; - const activeAssignees = []; - - for (const assignee of assignees) { - if (assignee.site_admin || await checkUserMembership(owner, repo, assignee.login)) { - activeAssignees.push(assignee.login); - continue; - } + if (assignee.site_admin || await checkUserMembership(owner, repo, assignee.login, github)) { + activeAssignees.push(assignee.login); + console.log(`${assignee.login} is an active member, keeping assignment`); + } else { inactiveAssignees.push(assignee.login); + console.log(`${assignee.login} is inactive, will be unassigned`); } + } - if (inactiveAssignees.length === 0) continue; + if (inactiveAssignees.length === 0) { + console.log(`No inactive assignees for issue #${issue.number}, skipping`); + continue; + } - try { - await github.rest.issues.update({ - owner, - repo, - issue_number: issue.number, - assignees: activeAssignees, - }); + try { + // Update issue assignees + await github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + assignees: activeAssignees, + }); + console.log(`Successfully unassigned users from issue #${issue.number}`); - console.log(`Successfully unassigned user from issue #${issue.number}`); + // Add comment + const mentionList = inactiveAssignees.map(login => `@${login}`).join(', '); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `Automatically unassigning ${mentionList} due to inactivity. ${mentionList}, If you're still interested in this issue or already have work in progress, please message us here, and we'll assign you again. Thank you!`, + }); + console.log(`Added comment to issue #${issue.number}`); - const mentionList = inactiveAssignees.map(login => `@${login}`).join(', '); - await github.rest.issues.createComment({ - owner, + // Record unassignments + inactiveAssignees.forEach(login => { + unassignments.push({ + user: login, repo, - issue_number: issue.number, - body: `Automatically unassigning ${mentionList} due to inactivity. ${mentionList}, If you're still interested in this issue or already have work in progress, please message us here, and we'll assign you again. Thank you!`, - }); - - console.log(`Added comment to issue #${issue.number}`); - - inactiveAssignees.forEach(login => { - unassignments.push({ - user: login, - repo, - owner, - issueNumber: issue.number, - issueUrl: `https://github.com/${owner}/${repo}/issues/${issue.number}` - }); + owner, + issueNumber: issue.number, + issueUrl: `https://github.com/${owner}/${repo}/issues/${issue.number}` }); + }); - } catch (issueError) { - console.error('Full error details:', issueError); - console.error(`Error processing issue #${issue.number}:`, issueError.message); - if (issueError.status === 403) { - throw new Error('Token lacks necessary permissions. Ensure it has "issues" and "write" access.'); - } + } catch (issueError) { + console.error(`Error processing issue #${issue.number}:`, { + message: issueError.message, + status: issueError.status + }); + if (issueError.status === 403) { + throw new Error('Token lacks necessary permissions. Ensure it has "issues" and "write" access.'); } } } const formattedUnassignments = formatUnassignments(unassignments); + console.log('Unassignments completed:', unassignments.length); + try { - core.setOutput('unassignments', formattedUnassignments); - return formattedUnassignments; - }catch (error){ + core.setOutput('unassignments', formattedUnassignments); + return formattedUnassignments; + } catch (error) { + console.error('Error setting output:', error); core.setFailed(error.message); } } catch (error) { + console.error('Action failed:', error); console.error('Full error details:', error); - console.error('Action failed:', error.message); core.setFailed(error.message); return ''; } From 3b387f929db7965ad5526b715d5fdda1b604903c Mon Sep 17 00:00:00 2001 From: Sukhvir Singh Date: Sun, 9 Mar 2025 12:44:53 +0530 Subject: [PATCH 2/4] added comments --- scripts/unassign-inactive-issues.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/unassign-inactive-issues.js b/scripts/unassign-inactive-issues.js index 62ee260..cfc60d6 100644 --- a/scripts/unassign-inactive-issues.js +++ b/scripts/unassign-inactive-issues.js @@ -91,9 +91,20 @@ const checkLinkedPRs = async (issue, github, owner, repo) => { for (const event of timelineEvents) { if ( + // Check if the event type is 'connected' or 'cross-referenced'. + // These events indicate that GitHub automatically linked the issue to a PR based on commit messages or references. (event.event === 'connected' || event.event === 'cross-referenced') || + + // Look for a 'referenced' event that includes a commit ID and a linked PR. + // This implies that the commit in a PR mentions the issue, suggesting a connection. (event.event === 'referenced' && event?.commit_id && event?.source?.issue?.pull_request) || + + // Check for a 'closed' event that has an associated commit and a linked PR. + // This typically means the issue was closed by a commit referenced in the PR. (event.event === 'closed' && event?.commit_id && event?.source?.issue?.pull_request) || + + // Confirm a 'connected' event where the linked PR is not merged yet. + // This ensures that we consider PRs that are still open and haven't been merged. (event.event === 'connected' && event?.source?.issue?.pull_request?.merged === false) ) { try { @@ -112,7 +123,7 @@ const checkLinkedPRs = async (issue, github, owner, repo) => { if (pr && pr.state === 'open') { console.log(`Found valid linked PR #${prNumber} (${pr.state})`); - linkedPRs.add(prNumber); // Use add() instead of push() + linkedPRs.add(prNumber); } }else{ console.log('founded pr linked in the issue'); @@ -149,7 +160,7 @@ const checkLinkedPRs = async (issue, github, owner, repo) => { pull_number: pr.number }); if (prDetails?.data?.state === 'open') { - linkedPRs.add(prDetails.data.number); // Use add() with PR number + linkedPRs.add(prDetails.data.number); } } catch (e) { console.log(`Error fetching PR #${pr.number} details:`, e.message); @@ -192,7 +203,7 @@ const checkLinkedPRs = async (issue, github, owner, repo) => { pull_number: prNumber }); if (prDetails?.data?.state === 'open') { - linkedPRs.add(prNumber); // Use add() with PR number + linkedPRs.add(prNumber); } } catch (e) { console.log(`Error fetching PR #${prNumber}:`, e.message); From ed5b9531bdf7356f271d90313f6de746c2bab071 Mon Sep 17 00:00:00 2001 From: Sukhvir Singh Date: Thu, 13 Mar 2025 03:54:48 +0530 Subject: [PATCH 3/4] refactor comments --- scripts/unassign-inactive-issues.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/unassign-inactive-issues.js b/scripts/unassign-inactive-issues.js index 82244d0..5b03fed 100644 --- a/scripts/unassign-inactive-issues.js +++ b/scripts/unassign-inactive-issues.js @@ -108,7 +108,10 @@ const checkLinkedPRs = async (issue, github, owner, repo) => { (event.event === 'connected' && event?.source?.issue?.pull_request?.merged === false) ) { try { + //issue.number is used because every pull request is also an issue. + //Some GitHub events only provide the issue object, so using issue.number ensures consistent access to the PR number across events. let prNumber = event?.source?.issue?.number; + if (!prNumber && event?.source?.pull_request?.number) { prNumber = event.source.pull_request.number; } @@ -126,8 +129,9 @@ const checkLinkedPRs = async (issue, github, owner, repo) => { linkedPRs.add(prNumber); } }else{ - console.log('founded pr linked in the issue'); - linkedPRs.add(1); + // Fallback for PRs linked via GitHub UI where PR number cannot be retrieved from the payload + console.log('found pr linked in the issue'); + linkedPRs.add(1); // Adds a placeholder to indicate a linked PR was found } } catch (e) { console.log(`Error fetching PR details:`, e.message); @@ -277,7 +281,7 @@ module.exports = async ({ github, context, core }) => { throw new Error(`Repository access failed. Please check your GitHub App permissions for repository access. Error: ${authError.message}`); } - // Get all issues using pagination + const issues = await getAllIssues(github, owner, repo); console.log(`Processing ${issues.length} open issues`); From 9e5b5fbb57d4128d60be114b60aa112ab60cde66 Mon Sep 17 00:00:00 2001 From: Sukhvir Singh Date: Thu, 13 Mar 2025 20:44:26 +0530 Subject: [PATCH 4/4] added regex in method 2 --- scripts/unassign-inactive-issues.js | 75 +++++++---------------------- 1 file changed, 18 insertions(+), 57 deletions(-) diff --git a/scripts/unassign-inactive-issues.js b/scripts/unassign-inactive-issues.js index 5b03fed..1999f28 100644 --- a/scripts/unassign-inactive-issues.js +++ b/scripts/unassign-inactive-issues.js @@ -145,74 +145,35 @@ const checkLinkedPRs = async (issue, github, owner, repo) => { // Method 2: Search for PRs that mention this issue try { const searchQuery = `repo:${owner}/${repo} type:pr is:open ${issue.number} in:body,title`; - console.log(`Searching for PRs with query: ${searchQuery}`); - const searchResult = await github.rest.search.issuesAndPullRequests({ - q: searchQuery + q: searchQuery, }); - if (searchResult?.data?.items) { - const foundPRs = searchResult.data.items.filter(item => item?.pull_request); - console.log(`Found ${foundPRs.length} PRs mentioning this issue through search`); - - for (const pr of foundPRs) { - if (pr?.number) { - try { - const prDetails = await github.rest.pulls.get({ - owner, - repo, - pull_number: pr.number - }); - if (prDetails?.data?.state === 'open') { - linkedPRs.add(prDetails.data.number); - } - } catch (e) { - console.log(`Error fetching PR #${pr.number} details:`, e.message); - } - } - } - } - } catch (searchError) { - console.log('Search API error:', searchError.message); - } - - // Method 3: Check issue body for PR references - if (issue.body) { - const prReferences = new Set(); - const patterns = [ - /#(\d+)/g, - new RegExp(`https?://github\\.com/${owner}/${repo}/pull/(\\d+)`, 'g'), - /(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s*:?\s*#(\d+)/gi - ]; - - for (const pattern of patterns) { - const matches = [...issue.body.matchAll(pattern)]; - for (const match of matches) { - if (match?.[1]) { - const prNumber = parseInt(match[1], 10); - if (!isNaN(prNumber)) { - prReferences.add(prNumber); - } - } - } - } + // Local regex for "closes/fixes/resolves #123" + const closingRegex = new RegExp( + `(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\\s*:?\\s*#${issue.number}`, + "i" + ); - console.log(`Found ${prReferences.size} PR references in issue body`); - - for (const prNumber of prReferences) { - try { + for (const prItem of searchResult.data.items || []) { + if (prItem.pull_request && prItem.number) { const prDetails = await github.rest.pulls.get({ owner, repo, - pull_number: prNumber + pull_number: prItem.number, }); - if (prDetails?.data?.state === 'open') { - linkedPRs.add(prNumber); + + if (prDetails.data.state === "open") { + const prBody = prDetails.data.body || ""; + const prTitle = prDetails.data.title || ""; + if (closingRegex.test(prBody) || closingRegex.test(prTitle)) { + linkedPRs.add(prItem.number); + } } - } catch (e) { - console.log(`Error fetching PR #${prNumber}:`, e.message); } } + } catch (searchError) { + console.log('Search API error:', searchError.message); } // Return the Set of linked PR numbers (always return a Set) return linkedPRs;