diff --git a/action.yml b/action.yml index 0ddbed6..1d56b89 100644 --- a/action.yml +++ b/action.yml @@ -14,64 +14,20 @@ inputs: dry-run: description: 'Set to true to not perform repository changes' default: false - expiration-label-map: - description: 'A multiline input mapping labels with actions and destination labels, applied after an expiration time. See README.md' + issue-expiration-label-map: + description: 'A multiline input mapping labels with actions and destination labels for issues, applied after an expiration time. See README.md' default: |- '' - update-remove-labels: + issue-update-remove-labels: description: 'A comma-seperated list of labels that should be removed on issue update. See README.md' default: '' - - -#name: "'Stale Issue Cleanup' Action for GitHub Actions" -#description: 'Close issues and pull requests with no recent activity' -#branding: -# icon: 'cloud' -# color: 'orange' -#inputs: -# repo-token: -# description: 'Token for the repository. Can be passed in using {{ secrets.GITHUB_TOKEN }}' -# required: true -# issue-types: -# description: 'Issue types to process ("issues", "pull_requests", or "issues,pull_requests")' -# default: 'issues,pull_requests' -# stale-issue-message: -# description: 'The message to post on the issue when tagging it. If none provided, will not mark issues stale.' -# stale-pr-message: -# description: 'The message to post on the pr when tagging it. If none provided, will not mark pull requests stale.' -# days-before-stale: -# description: 'The number of days old an issue can be before marking it stale.' -# default: 60 -# days-before-close: -# description: 'The number of days to wait to close an issue or pull request after it being marked stale.' -# default: 7 -# stale-issue-label: -# description: 'The label to apply when an issue is stale.' -# default: 'Stale' -# exempt-issue-labels: -# description: 'The labels to apply when an issue is exempt from being marked stale. Separate multiple labels with commas (eg. "label1,label2")' -# stale-pr-label: -# description: 'The label to apply when a pull request is stale.' -# default: 'Stale' -# exempt-pr-labels: -# description: 'The labels to apply when a pull request is exempt from being marked stale. Separate multiple labels with commas (eg. "label1,label2")' -# ancient-issue-message: -# description: 'The message to post when an issue is very old.' -# ancient-pr-message: -# description: 'The message to post when a pr is very old.' -# days-before-ancient: -# description: 'The number of days old an issue can be before marking it ancient.' -# default: 360 -# response-requested-label: -# description: 'The label that gets applied when a response is requested.' -# closed-for-staleness-label: -# description: 'The label that gets applied when an issue is closed for staleness.' -# minimum-upvotes-to-exempt: -# description: 'The minimum number of "upvotes" that an issue needs to have before not marking as ancient.' -# loglevel: -# description: 'Set to DEBUG to enable debug logging' -# dry-run: -# description: 'Set to true to not perform repository changes' + pr-expiration-label-map: + description: 'A multiline input mapping labels with actions and destination labels for PRs, applied after an expiration time. See README.md' + default: |- + '' + pr-update-remove-labels: + description: 'A comma-seperated list of labels that should be removed on PR update. See README.md' + default: '' runs: using: 'node16' diff --git a/src/github.ts b/src/github.ts index e804e80..33f5f1b 100644 --- a/src/github.ts +++ b/src/github.ts @@ -18,16 +18,28 @@ export async function getIssues(labels: string[], token: string) { }); } +// Issue processing steps +// Step 1: Skip closed/merged/locked +// Step 2: If the issue is a PR, use the PR configuration, else use the issue configuration +// Step 3: Iterate all labels in the issue. If labeled, iterate over the configured labels and see if the issue's labels +// match the configured ones. +// Step 4: If they do, take the action specified in the configuration line, and repeat for all configuration lines +// Step 5: Do step 4 but for the updateRemoveLabels export async function processIssues(issues: Issue[], args: args) { issues.forEach(async issue => { + // Skip closed and locked issues + if (issue.state === 'closed' || issue.state === 'merged' || issue.locked) return; + const timeline = await getIssueLabelTimeline(issue.number, args.token); + const expirationLabelMap = isPr(issue) ? args.prExpirationLabelMap : args.expirationLabelMap; + const removeLabelMap = isPr(issue) ? args.prUpdateRemoveLabels : args.updateRemoveLabels; // Enumerate labels in issue and check if each matches our action list issue.labels.forEach(label => { const issueLabel = typeof label === 'string' ? label : label.name; if (issueLabel) { - if (args.expirationLabelMap) { + if (expirationLabelMap) { // These are labels that we apply if an issue hasn't been updated in a specified timeframe - args.expirationLabelMap.forEach(async lam => { + expirationLabelMap.forEach(async lam => { const sourceLabelList = lam.split(':')[0].split(','); const configuredAction = lam.split(':')[1]; const configuredTime = parseInt(lam.split(':')[2]); @@ -36,13 +48,13 @@ export async function processIssues(issues: Issue[], args: args) { // Issue contains label specified and configured time has elapsed switch (configuredAction) { case 'add': - await addLabelToIssue(issue.number, lam.split(':')[3]); + await addLabelToIssue(issue.number, lam.split(':')[3], args.token); break; case 'remove': - await removeLabelFromIssue(issue.number, lam.split(':')[3]); + await removeLabelFromIssue(issue.number, lam.split(':')[3], args.token); break; case 'close': - await closeIssue(issue.number); + await closeIssue(issue.number, args.token); break; default: core.error(`Unknown action ${configuredAction} for issue #${issue.number}, doing nothing`); @@ -50,11 +62,11 @@ export async function processIssues(issues: Issue[], args: args) { } }); } - if (args.updateRemoveLabels) { + if (removeLabelMap) { // These are labels that need removed if an issue has been updated after they were applied - args.updateRemoveLabels.forEach(async removeMe => { + removeLabelMap.forEach(async removeMe => { if (Date.parse(issue.updated_at) > getIssueLabelDate(timeline, removeMe)) { - removeLabelFromIssue(issue.number, removeMe); + removeLabelFromIssue(issue.number, removeMe, args.token); } }); } @@ -74,6 +86,36 @@ async function getIssueLabelTimeline(issueNumber: number, token: string) { ).filter(event => event.event === 'labeled'); } +async function addLabelToIssue(issue: number, label: string, token: string) { + const octokit = github.getOctokit(token); + await octokit.rest.issues.addLabels({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: issue, + labels: [label], + }); +} + +async function removeLabelFromIssue(issue: number, label: string, token: string) { + const octokit = github.getOctokit(token); + await octokit.rest.issues.removeLabel({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: issue, + name: label, + }); +} + +async function closeIssue(issue: number, token: string) { + const octokit = github.getOctokit(token); + await octokit.rest.issues.update({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: issue, + state: 'closed', + }); +} + function getIssueLabelDate(timeline: Timeline, label: string) { // Return when the label was last applied return timeline.reduce((p, c) => { @@ -94,3 +136,7 @@ function issueDateCompare(issueDate: string, configuredDays: number) { d.setDate(d.getDate() + configuredDays); return d.valueOf() < Date.now(); } + +function isPr(issue: Issue) { + return !!issue.pull_request; +} diff --git a/src/input.ts b/src/input.ts index 4a5614c..4be0086 100644 --- a/src/input.ts +++ b/src/input.ts @@ -6,6 +6,8 @@ export interface args { token: string; expirationLabelMap?: string[]; updateRemoveLabels?: string[]; + prExpirationLabelMap?: string[]; + prUpdateRemoveLabels?: string[]; } export function getAndValidateInputs(): args { @@ -20,10 +22,15 @@ export function getAndValidateInputs(): args { // Action map const labelValidationRegex = new RegExp(`^[A-Za-z0-9_.-,]+:(${labelActions.join('|')}):\\d+(:[A-Za-z0-9_.-,]+)?/i`); const expirationLabelMap = core - .getMultilineInput('expiration-label-map', { required: false }) + .getMultilineInput('issue-expiration-label-map', { required: false }) .filter(m => labelValidationRegex.test(m)); - core.debug(`Parsed label mapping: ${expirationLabelMap}`); - const updateRemoveLabels = core.getInput('update-remove-labels', { required: false }).split(','); + core.debug(`Parsed issue label mapping: ${expirationLabelMap}`); + const prExpirationLabelMap = core + .getMultilineInput('pr-expiration-label-map', { required: false }) + .filter(m => labelValidationRegex.test(m)); + core.debug(`Parsed PR label mapping: ${prExpirationLabelMap}`); + const updateRemoveLabels = core.getInput('issue-update-remove-labels', { required: false }).split(','); + const prUpdateRemoveLabels = core.getInput('pr-update-remove-labels', { required: false }).split(','); return { dryrun: core.getBooleanInput('dry-run', { required: false }), @@ -31,5 +38,7 @@ export function getAndValidateInputs(): args { token: core.getInput('repo-token'), expirationLabelMap, updateRemoveLabels, + prExpirationLabelMap, + prUpdateRemoveLabels, }; }