|  | 
|  | 1 | +/** | 
|  | 2 | + * GitHub Action script for managing issue backlog. | 
|  | 3 | + *  | 
|  | 4 | + * Behavior: | 
|  | 5 | + * - Pull Requests are skipped (only opened issues are processed) | 
|  | 6 | + * - Skips issues with 'to-be-discussed' label. | 
|  | 7 | + * - Closes issues with label 'awaiting-response' or without assignees,  | 
|  | 8 | + *   with a standard closure comment. | 
|  | 9 | + * - Sends a Friendly Reminder comment to assigned issues without  | 
|  | 10 | + *   exempt labels that have been inactive for 90+ days. | 
|  | 11 | + * - Avoids sending duplicate Friendly Reminder comments if one was  | 
|  | 12 | + *   posted within the last 7 days. | 
|  | 13 | + */ | 
|  | 14 | + | 
|  | 15 | +const dedent = (strings, ...values) => { | 
|  | 16 | +    const raw = typeof strings === 'string' ? [strings] : strings.raw; | 
|  | 17 | +    let result = ''; | 
|  | 18 | +    raw.forEach((str, i) => { | 
|  | 19 | +        result += str + (values[i] || ''); | 
|  | 20 | +    }); | 
|  | 21 | +    const lines = result.split('\n'); | 
|  | 22 | +    const minIndent = Math.min(...lines.filter(l => l.trim()).map(l => l.match(/^\s*/)[0].length)); | 
|  | 23 | +    return lines.map(l => l.slice(minIndent)).join('\n').trim(); | 
|  | 24 | +}; | 
|  | 25 | + | 
|  | 26 | +async function fetchAllOpenIssues(github, owner, repo) { | 
|  | 27 | +    const issues = []; | 
|  | 28 | +    let page = 1; | 
|  | 29 | + | 
|  | 30 | +    while (true) { | 
|  | 31 | +        const response = await github.rest.issues.listForRepo({ | 
|  | 32 | +            owner, | 
|  | 33 | +            repo, | 
|  | 34 | +            state: 'open', | 
|  | 35 | +            per_page: 100, | 
|  | 36 | +            page, | 
|  | 37 | +        }); | 
|  | 38 | + | 
|  | 39 | +        const data = response.data || []; | 
|  | 40 | +        if (data.length === 0) break; | 
|  | 41 | +        const onlyIssues = data.filter(issue => !issue.pull_request); | 
|  | 42 | +        issues.push(...onlyIssues); | 
|  | 43 | + | 
|  | 44 | +        if (data.length < 100) break; | 
|  | 45 | +        page++; | 
|  | 46 | +    } | 
|  | 47 | +    return issues; | 
|  | 48 | +} | 
|  | 49 | + | 
|  | 50 | +const shouldSendReminder = (issue, exemptLabels, closeLabels) => { | 
|  | 51 | +  const hasExempt = issue.labels.some(l => exemptLabels.includes(l.name)); | 
|  | 52 | +  const hasClose = issue.labels.some(l => closeLabels.includes(l.name)); | 
|  | 53 | +  return issue.assignees.length > 0 && !hasExempt && !hasClose; | 
|  | 54 | +}; | 
|  | 55 | + | 
|  | 56 | + | 
|  | 57 | +module.exports = async ({ github, context }) => { | 
|  | 58 | +    const { owner, repo } = context.repo; | 
|  | 59 | +    const issues = await fetchAllOpenIssues(github, owner, repo); | 
|  | 60 | +    const now = new Date(); | 
|  | 61 | +    const thresholdDays = 90; | 
|  | 62 | +    const exemptLabels = ['to-be-discussed']; | 
|  | 63 | +    const closeLabels = ['awaiting-response']; | 
|  | 64 | +    const sevenDays = 7 * 24 * 60 * 60 * 1000; | 
|  | 65 | + | 
|  | 66 | +    let totalClosed = 0; | 
|  | 67 | +    let totalReminders = 0; | 
|  | 68 | +    let totalSkipped = 0; | 
|  | 69 | + | 
|  | 70 | +    for (const issue of issues) { | 
|  | 71 | +        const isAssigned = issue.assignees && issue.assignees.length > 0; | 
|  | 72 | +        const lastUpdate = new Date(issue.updated_at); | 
|  | 73 | +        const daysSinceUpdate = Math.floor((now - lastUpdate) / (1000 * 60 * 60 * 24)); | 
|  | 74 | + | 
|  | 75 | +        if (daysSinceUpdate < thresholdDays) { | 
|  | 76 | +            totalSkipped++; | 
|  | 77 | +            continue; | 
|  | 78 | +        } | 
|  | 79 | + | 
|  | 80 | +        const { data: comments } = await github.rest.issues.listComments({ | 
|  | 81 | +            owner, | 
|  | 82 | +            repo, | 
|  | 83 | +            issue_number: issue.number, | 
|  | 84 | +            per_page: 10, | 
|  | 85 | +        }); | 
|  | 86 | + | 
|  | 87 | +        const recentFriendlyReminder = comments.find(comment => | 
|  | 88 | +            comment.user.login === 'github-actions[bot]' && | 
|  | 89 | +            comment.body.includes('⏰ Friendly Reminder') && | 
|  | 90 | +            (now - new Date(comment.created_at)) < sevenDays | 
|  | 91 | +        ); | 
|  | 92 | +        if (recentFriendlyReminder) { | 
|  | 93 | +            totalSkipped++; | 
|  | 94 | +            continue; | 
|  | 95 | +        } | 
|  | 96 | + | 
|  | 97 | +        if (issue.labels.some(label => exemptLabels.includes(label.name))) { | 
|  | 98 | +            totalSkipped++; | 
|  | 99 | +            continue; | 
|  | 100 | +        } | 
|  | 101 | + | 
|  | 102 | +        if (issue.labels.some(label => closeLabels.includes(label.name)) || !isAssigned) { | 
|  | 103 | +            await github.rest.issues.createComment({ | 
|  | 104 | +                owner, | 
|  | 105 | +                repo, | 
|  | 106 | +                issue_number: issue.number, | 
|  | 107 | +                body: '⚠️ This issue was closed automatically due to inactivity. Please reopen or open a new one if still relevant.', | 
|  | 108 | +            }); | 
|  | 109 | +            await github.rest.issues.update({ | 
|  | 110 | +                owner, | 
|  | 111 | +                repo, | 
|  | 112 | +                issue_number: issue.number, | 
|  | 113 | +                state: 'closed', | 
|  | 114 | +            }); | 
|  | 115 | +            totalClosed++; | 
|  | 116 | +            continue; | 
|  | 117 | +        } | 
|  | 118 | + | 
|  | 119 | +        if (shouldSendReminder(issue, exemptLabels, closeLabels)) { | 
|  | 120 | +            const assignees = issue.assignees.map(u => `@${u.login}`).join(', '); | 
|  | 121 | +            const comment = dedent` | 
|  | 122 | +                ⏰ Friendly Reminder | 
|  | 123 | +
 | 
|  | 124 | +                Hi ${assignees}! | 
|  | 125 | +
 | 
|  | 126 | +                This issue has had no activity for ${daysSinceUpdate} days. If it's still relevant: | 
|  | 127 | +                - Please provide a status update | 
|  | 128 | +                - Add any blocking details | 
|  | 129 | +                - Or label it 'awaiting-response' if you're waiting on something | 
|  | 130 | +
 | 
|  | 131 | +                This is just a reminder; the issue remains open for now.`; | 
|  | 132 | + | 
|  | 133 | +            await github.rest.issues.createComment({ | 
|  | 134 | +                owner, | 
|  | 135 | +                repo, | 
|  | 136 | +                issue_number: issue.number, | 
|  | 137 | +                body: comment, | 
|  | 138 | +            }); | 
|  | 139 | +            totalReminders++; | 
|  | 140 | +        } | 
|  | 141 | +    } | 
|  | 142 | + | 
|  | 143 | +    console.log(dedent` | 
|  | 144 | +        === Backlog cleanup summary === | 
|  | 145 | +        Total issues processed: ${issues.length} | 
|  | 146 | +        Total issues closed: ${totalClosed} | 
|  | 147 | +        Total reminders sent: ${totalReminders} | 
|  | 148 | +        Total skipped: ${totalSkipped}`); | 
|  | 149 | +}; | 
0 commit comments