Skip to content

Info Needed Closer #450

Info Needed Closer

Info Needed Closer #450

name: Info Needed Closer
on:
schedule:
- cron: 30 5 * * * # 10:30pm PT
workflow_dispatch:
workflow_call:
inputs:
days:
description: "Number of days without activity before closing (default: 14)"
required: false
type: string
default: "14"
permissions:
issues: write
contents: read
jobs:
main:
runs-on: ubuntu-latest
steps:
- name: Close Info Needed Issues
uses: actions/github-script@v8
env:
DAYS: ${{ inputs.days || '14' }}
with:
script: |
const days = parseInt(process.env.DAYS);
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);
core.info(`Closing issues with 'info-needed' label inactive for more than ${days} days (cutoff: ${cutoffDate.toISOString()})`);
// Cache of permission lookups to avoid redundant API calls for the same user
const permissionCache = {};
async function hasWriteAccess(username) {
if (permissionCache[username] !== undefined) {
return permissionCache[username];
}
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
});
// 'admin', 'maintain', and 'write' all grant write access
const result = data.permission === 'admin' || data.permission === 'maintain' || data.permission === 'write';
permissionCache[username] = result;
return result;
} catch (err) {
// User is not a collaborator or permission lookup failed
permissionCache[username] = false;
return false;
}
}
// Fetch all open issues with the 'info-needed' label
const issues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'info-needed',
per_page: 100,
});
core.info(`Found ${issues.length} open issue(s) with 'info-needed' label`);
let closedCount = 0;
for (const issue of issues) {
// Skip locked issues and pull requests
if (issue.locked) {
core.info(`Issue #${issue.number}: locked, skipping`);
continue;
}
if (issue.pull_request) {
continue;
}
// The listComments endpoint returns comments in ascending chronological
// order and does not support sort/direction params. Use the issue's
// comment count to jump directly to the last page.
if (issue.comments === 0) {
core.info(`Issue #${issue.number}: no comments, skipping`);
continue;
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
per_page: 1,
page: issue.comments, // Jump to the last page (with per_page=1), to get the last comment directly
});
if (comments.length === 0) {
core.info(`Issue #${issue.number}: could not fetch last comment, skipping`);
continue;
}
const lastComment = comments[0];
const lastCommentDate = new Date(lastComment.created_at);
// Skip issues with recent comment activity
if (lastCommentDate >= cutoffDate) {
core.info(`Issue #${issue.number}: last comment on ${lastComment.created_at} is within the activity window, skipping`);
continue;
}
// Only close if the last comment was made by someone with write access
const commenter = lastComment.user?.login;
if (!commenter || !(await hasWriteAccess(commenter))) {
core.info(`Issue #${issue.number}: last comment by ${commenter} does not have write access, skipping`);
continue;
}
core.info(`Closing issue #${issue.number}: last write-access comment by ${commenter} on ${lastComment.created_at}`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: 'This issue has been closed automatically because it needs more information and has not had recent activity. See also our <a href="https://aka.ms/azcodeissuereporting">issue reporting</a> guidelines.\n\nHappy Coding!',
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
closedCount++;
}
core.info(`Done. Closed ${closedCount} issue(s).`);