diff --git a/README.md b/README.md index aabb5b5..1db3986 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ This workflow file [follows the standard workflow syntax for Github Actions.](ht A sample workflow file for you to use as a drop-in is in [sample_workflow.yml](./sample_workflow.yml). +For a list of options and their description, see [action.yml](./action.yml). + Here's an abbreviated example with just the step for this action: ```yaml @@ -54,9 +56,9 @@ steps: # Labels this action will apply to issues stale-issue-label: closing-soon - exempt-issue-label: awaiting-approval + exempt-issue-labels: awaiting-approval stale-pr-label: no-pr-activity - exempt-pr-label: awaiting-approval + exempt-pr-labels: awaiting-approval response-requested-label: response-requested closed-for-staleness-label: closed-for-staleness diff --git a/action.yml b/action.yml index 0c18421..120d17f 100644 --- a/action.yml +++ b/action.yml @@ -12,34 +12,34 @@ inputs: 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' + 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' + 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' + description: 'The label to apply when an issue is stale.' default: 'Stale' - exempt-issue-label: - description: 'The label to apply when an issue is exempt from being marked 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' + description: 'The label to apply when a pull request is stale.' default: 'Stale' - exempt-pr-label: - description: 'The label to apply when a pull request is exempt from being marked 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 label to apply when an issue is very old' + description: 'The message to post when an issue is very old.' ancient-pr-message: - description: 'The label to apply when a pr is very old' + 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' + 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' + 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' + 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' + 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: @@ -58,9 +58,9 @@ runs: DAYS_BEFORE_CLOSE: ${{ inputs.days-before-close }} DAYS_BEFORE_ANCIENT: ${{ inputs.days-before-ancient }} STALE_ISSUE_LABEL: ${{ inputs.stale-issue-label }} - EXEMPT_ISSUE_LABEL: ${{ inputs.exempt-issue-label }} + EXEMPT_ISSUE_LABELS: ${{ inputs.exempt-issue-labels }} STALE_PR_LABEL: ${{ inputs.stale-pr-label }} - EXEMPT_PR_LABEL: ${{ inputs.exempt-pr-label }} + EXEMPT_PR_LABELS: ${{ inputs.exempt-pr-labels }} RESPONSE_REQUESTED_LABEL: ${{ inputs.response-requested-label }} CFS_LABEL: ${{ inputs.closed-for-staleness-label }} MINIMUM_UPVOTES_TO_EXEMPT: ${{ inputs.minimum-upvotes-to-exempt }} diff --git a/sample_workflow.yml b/sample_workflow.yml index 5e4fbb5..9c8fdb0 100644 --- a/sample_workflow.yml +++ b/sample_workflow.yml @@ -20,9 +20,9 @@ jobs: # These labels are required stale-issue-label: closing-soon - exempt-issue-label: awaiting-approval + exempt-issue-labels: awaiting-approval stale-pr-label: no-pr-activity - exempt-pr-label: awaiting-approval + exempt-pr-labels: awaiting-approval response-requested-label: response-requested # Don't set this to not apply a label when closing issues diff --git a/src/entrypoint.js b/src/entrypoint.js index 45dcebe..1b9a0a3 100644 --- a/src/entrypoint.js +++ b/src/entrypoint.js @@ -16,6 +16,7 @@ const { getLastCommentTime, asyncForEach, dateFormatToIsoUtc, + parseCommaSeparatedString, } = require('./utils.js'); const MS_PER_DAY = 86400000; @@ -34,9 +35,9 @@ function getAndValidateInputs() { daysBeforeClose: parseFloat(process.env.DAYS_BEFORE_CLOSE), daysBeforeAncient: parseFloat(process.env.DAYS_BEFORE_ANCIENT), staleIssueLabel: process.env.STALE_ISSUE_LABEL, - exemptIssueLabel: process.env.EXEMPT_ISSUE_LABEL, + exemptIssueLabels: process.env.EXEMPT_ISSUE_LABELS, stalePrLabel: process.env.STALE_PR_LABEL, - exemptPrLabel: process.env.EXEMPT_PR_LABEL, + exemptPrLabels: process.env.EXEMPT_PR_LABELS, cfsLabel: process.env.CFS_LABEL, responseRequestedLabel: process.env.RESPONSE_REQUESTED_LABEL, minimumUpvotesToExempt: parseInt(process.env.MINIMUM_UPVOTES_TO_EXEMPT), @@ -81,18 +82,20 @@ async function processIssues(client, args) { const ancientMessage = args.ancientIssueMessage; const staleLabel = isPr ? args.stalePrLabel : args.staleIssueLabel; - const exemptLabel = isPr ? args.exemptPrLabel : args.exemptIssueLabel; - const responseRequestedLabel = isPr - ? args.responseRequestedLabel - : args.responseRequestedLabel; + const exemptLabels = parseCommaSeparatedString(isPr ? args.exemptPrLabels : args.exemptIssueLabels); + const responseRequestedLabel = isPr ? args.responseRequestedLabel : args.responseRequestedLabel; const issueTimelineEvents = await getTimelineEvents(client, issue); const currentTime = new Date(Date.now()); - if (exemptLabel && isLabeled(issue, exemptLabel)) { - // If issue contains exempt label, do nothing - log.debug(`issue contains exempt label`); - return; + + if (exemptLabels && + exemptLabels.some((s) => isLabeled(issue, s)) + ) { + // If issue contains exempt label, do nothing + log.debug(`issue contains exempt label`); + return; } + if (isLabeled(issue, staleLabel)) { log.debug(`issue contains the stale label`); const lastCommentTime = getLastCommentTime(issueTimelineEvents); diff --git a/src/utils.js b/src/utils.js index ac9fb0a..d96fdb6 100644 --- a/src/utils.js +++ b/src/utils.js @@ -103,4 +103,15 @@ module.exports.asyncForEach = async (array, callback) => { */ module.exports.dateFormatToIsoUtc = (dateTime) => { return dateFormat(dateTime, "isoUtcDateTime"); +}; + +/** + * Splits a string `s` into an array of substrings, using + * a comma as a separator + * @param {String} s + * @return {Array} Array of strings + */ +module.exports.parseCommaSeparatedString = (s) => { + if (!s.length) return []; + return s.split(',').map(l => l.trim()); }; \ No newline at end of file diff --git a/test/.env.test b/test/.env.test index 7376485..eb4dc8d 100644 --- a/test/.env.test +++ b/test/.env.test @@ -11,9 +11,9 @@ DAYS_BEFORE_STALE=0.05 DAYS_BEFORE_CLOSE='0.06' DAYS_BEFORE_ANCIENT='1' STALE_ISSUE_LABEL=closing-soon -EXEMPT_ISSUE_LABEL=go-away-bot +EXEMPT_ISSUE_LABELS=go-away-bot STALE_PR_LABEL=stale-pr -EXEMPT_PR_LABEL=go-away-bot +EXEMPT_PR_LABELS=go-away-bot RESPONSE_REQUESTED_LABEL=response-requested CFS_LABEL=closed-for-staleness MINIMUM_UPVOTES_TO_EXEMPT=1 diff --git a/test/entrypoint.test.js b/test/entrypoint.test.js index 1d1a2c1..f930329 100644 --- a/test/entrypoint.test.js +++ b/test/entrypoint.test.js @@ -41,9 +41,9 @@ describe('GitHub issue parser', () => { daysBeforeClose: parseFloat(process.env.DAYS_BEFORE_CLOSE), daysBeforeAncient: parseFloat(process.env.DAYS_BEFORE_ANCIENT), staleIssueLabel: process.env.STALE_ISSUE_LABEL, - exemptIssueLabel: process.env.EXEMPT_ISSUE_LABEL, + exemptIssueLabels: process.env.EXEMPT_ISSUE_LABELS, stalePrLabel: process.env.STALE_PR_LABEL, - exemptPrLabel: process.env.EXEMPT_PR_LABEL, + exemptPrLabels: process.env.EXEMPT_PR_LABELS, responseRequestedLabel: process.env.RESPONSE_REQUESTED_LABEL, minimumUpvotesToExempt: parseInt(process.env.MINIMUM_UPVOTES_TO_EXEMPT), cfsLabel: process.env.CFS_LABEL, @@ -534,4 +534,303 @@ describe('GitHub issue parser', () => { expect(scope.isDone()).toEqual(true); }); + + + describe('Exempt Labels Input', () => { + const scope = nock('https://api.github.com') + + const issue256Reply = { + url:"https://api.github.com/repos/aws-actions/stale-issue-cleanup/issues/256", + id:172115562, + node_id:"MDU6SXNzdWUxNzIxMTU1NjI=", + number:256, + title:"Exempt?", + labels:[{ + id:600797884, + node_id:"MDU6TGFiZWw2MDA3OTc4ODQ=", + url:"https://api.github.com/repos/aws-actions/stale-issue-cleanup/labels/go-away-bot", + name:"go-away-bot"}], + state:"open", + comments:0, + created_at:"2016-08-19T11:57:17Z", + updated_at:"2017-05-08T21:20:09Z", + closed_at:"2016-08-19T12:48:43Z", + author_association:"NONE", + body:null + } + + const issue121Reply = { + url:"https://api.github.com/repos/aws-actions/stale-issue-cleanup/issues/121", + id:677599418, + node_id:"MDU6SXNzdWU2Nzc1OTk0MTg=", + number:121, + title:"Exempt too?", + labels:[{ + id:743820433, + node_id:"MDU6TGFiZWw3NDM4MjA0Mw=", + url:"https://api.github.com/repos/aws-actions/stale-issue-cleanup/labels/help-wanted", + name:"help-wanted"}], + state:"open", + comments:0, + created_at:"2018-08-12T11:03:19Z", + updated_at:"2018-08-15T11:07:32Z", + closed_at:null, + author_association:"NONE", + body:null + } + + const issue256Timeline = [{ + id:1073560592, + node_id:"MDEyOpxhvmVsZWRFdmVudDEwNzM1NjA1OTE=", + url:"https://api.github.com/repos/aws-actions/stale-issue-cleanup/issues/events/1073560592", + actor:{login:"octocat",id:583231,node_id:"MDQ6VXNlcjU4MzIzMQ==",type:"User",site_admin:!1}, + event:"labeled", + commit_id:null, + commit_url:null, + created_at:"2016-08-19T11:57:18Z", + label:{name:"go-away-bot",color:"c5def5"} + }] + + const issue121Timeline = [{ + id:3647155910, + node_id:"MDEyOkxhYmVsZWRFdmVudDM2NDcxNTU5MTA=", + url:"https://api.github.com/repos/aws-actions/stale-issue-cleanup/issues/events/3647155910", + actor:{login:"octocat",id:583231,node_id:"MDQ6VXNlcjU4MzIzMQ==",type:"User",site_admin:!1}, + event:"labeled", + commit_id:null, + commit_url:null, + created_at:"2016-08-19T11:57:18Z", + label:{name:"help-wanted",color:"008672"} + }] + + beforeEach(() => { + scope + .get('/repos/aws-actions/stale-issue-cleanup/issues') + .query({ + state: 'open', + labels: process.env.RESPONSE_REQUESTED_LABEL, + per_page: 100, + }) + .reply(200, []) + + .get('/repos/aws-actions/stale-issue-cleanup/issues') + .query({ + state: 'open', + labels: process.env.STALE_ISSUE_LABEL, + per_page: 100, + }) + .reply(200, []) + + .get('/repos/aws-actions/stale-issue-cleanup/issues') + .query({ + state: 'open', + labels: process.env.STALE_PR_LABEL, + per_page: 100, + }) + .reply(200, []) + + .get('/repos/aws-actions/stale-issue-cleanup/issues') + .query({ + state: 'open', + sort: 'updated', + direction: 'asc', + per_page: 100, + }) + .reply(200,[issue256Reply,issue121Reply]) + + + }); + + afterEach(() => { + if (!nock.isDone()) { + nock.cleanAll(); + } + }); + + test('no exempt label', async () => { + process.env.EXEMPT_ISSUE_LABELS = ''; + + scope + .get('/repos/aws-actions/stale-issue-cleanup/issues/256/timeline') + .matchHeader('accept', 'application/vnd.github.mockingbird-preview+json') + .query({ per_page: 100 }) + .reply(200, issue256Timeline) + + .get('/repos/aws-actions/stale-issue-cleanup/issues/256/reactions') + .query({ per_page: 100 }) + .reply(200, []) + + .post('/repos/aws-actions/stale-issue-cleanup/issues/256/comments', { + body: 'Ancient issue message.', + }) + .reply(201, {}) + + .post('/repos/aws-actions/stale-issue-cleanup/issues/256/labels', { + labels: ['closing-soon'], + }) + .reply(201, {}) + + .get('/repos/aws-actions/stale-issue-cleanup/issues/121/timeline') + .matchHeader('accept', 'application/vnd.github.mockingbird-preview+json') + .query({ per_page: 100 }) + .reply(200, issue121Timeline) + + .get('/repos/aws-actions/stale-issue-cleanup/issues/121/reactions') + .query({ per_page: 100 }) + .reply(200, []) + + .post('/repos/aws-actions/stale-issue-cleanup/issues/121/comments', { + body: 'Ancient issue message.', + }) + .reply(201, {}) + + .post('/repos/aws-actions/stale-issue-cleanup/issues/121/labels', { + labels: ['closing-soon'], + }) + .reply(201, {}) + + await run(); + + expect(scope.isDone()).toEqual(true); + + }); + + test('one exempt label, but no issue has it', async () => { + process.env.EXEMPT_ISSUE_LABELS = 'no-auto-closure-please'; + + scope + .get('/repos/aws-actions/stale-issue-cleanup/issues/256/timeline') + .matchHeader('accept', 'application/vnd.github.mockingbird-preview+json') + .query({ per_page: 100 }) + .reply(200, issue256Timeline) + + .get('/repos/aws-actions/stale-issue-cleanup/issues/256/reactions') + .query({ per_page: 100 }) + .reply(200, []) + + .post('/repos/aws-actions/stale-issue-cleanup/issues/256/comments', { + body: 'Ancient issue message.', + }) + .reply(201, {}) + + .post('/repos/aws-actions/stale-issue-cleanup/issues/256/labels', { + labels: ['closing-soon'], + }) + .reply(201, {}) + + .get('/repos/aws-actions/stale-issue-cleanup/issues/121/timeline') + .matchHeader('accept', 'application/vnd.github.mockingbird-preview+json') + .query({ per_page: 100 }) + .reply(200, issue121Timeline) + + .get('/repos/aws-actions/stale-issue-cleanup/issues/121/reactions') + .query({ per_page: 100 }) + .reply(200, []) + + .post('/repos/aws-actions/stale-issue-cleanup/issues/121/comments', { + body: 'Ancient issue message.', + }) + .reply(201, {}) + + .post('/repos/aws-actions/stale-issue-cleanup/issues/121/labels', { + labels: ['closing-soon'], + }) + .reply(201, {}) + + await run(); + + expect(scope.isDone()).toEqual(true); + + }); + + test('one exempt label, one issue has it', async () => { + process.env.EXEMPT_ISSUE_LABELS = 'go-away-bot'; + + scope + .get('/repos/aws-actions/stale-issue-cleanup/issues/256/timeline') + .matchHeader('accept', 'application/vnd.github.mockingbird-preview+json') + .query({ per_page: 100 }) + .reply(200, issue256Timeline) + + .get('/repos/aws-actions/stale-issue-cleanup/issues/121/timeline') + .matchHeader('accept', 'application/vnd.github.mockingbird-preview+json') + .query({ per_page: 100 }) + .reply(200, issue121Timeline) + + .get('/repos/aws-actions/stale-issue-cleanup/issues/121/reactions') + .query({ per_page: 100 }) + .reply(200, []) + + .post('/repos/aws-actions/stale-issue-cleanup/issues/121/comments', { + body: 'Ancient issue message.', + }) + .reply(201, {}) + + .post('/repos/aws-actions/stale-issue-cleanup/issues/121/labels', { + labels: ['closing-soon'], + }) + .reply(201, {}) + + await run(); + + expect(scope.isDone()).toEqual(true); + + }); + + test('two exempt labels, one issue has one of the labels', async () => { + process.env.EXEMPT_ISSUE_LABELS = 'go-away-bot, bot-stay-away'; + + scope + .get('/repos/aws-actions/stale-issue-cleanup/issues/256/timeline') + .matchHeader('accept', 'application/vnd.github.mockingbird-preview+json') + .query({ per_page: 100 }) + .reply(200, issue256Timeline) + + .get('/repos/aws-actions/stale-issue-cleanup/issues/121/timeline') + .matchHeader('accept', 'application/vnd.github.mockingbird-preview+json') + .query({ per_page: 100 }) + .reply(200, issue121Timeline) + + .get('/repos/aws-actions/stale-issue-cleanup/issues/121/reactions') + .query({ per_page: 100 }) + .reply(200, []) + + .post('/repos/aws-actions/stale-issue-cleanup/issues/121/comments', { + body: 'Ancient issue message.', + }) + .reply(201, {}) + + .post('/repos/aws-actions/stale-issue-cleanup/issues/121/labels', { + labels: ['closing-soon'], + }) + .reply(201, {}) + + await run(); + + expect(scope.isDone()).toEqual(true); + + }); + + test('two exempt labels, two issues have one label each', async () => { + process.env.EXEMPT_ISSUE_LABELS = 'help-wanted, go-away-bot'; + + scope + .get('/repos/aws-actions/stale-issue-cleanup/issues/256/timeline') + .matchHeader('accept', 'application/vnd.github.mockingbird-preview+json') + .query({ per_page: 100 }) + .reply(200, issue256Timeline) + + .get('/repos/aws-actions/stale-issue-cleanup/issues/121/timeline') + .matchHeader('accept', 'application/vnd.github.mockingbird-preview+json') + .query({ per_page: 100 }) + .reply(200, issue121Timeline) + + await run(); + + expect(scope.isDone()).toEqual(true); + + }); + + }); + }); diff --git a/test/local-docker.env b/test/local-docker.env index 66f6585..cb1d30e 100644 --- a/test/local-docker.env +++ b/test/local-docker.env @@ -10,10 +10,11 @@ DAYS_BEFORE_STALE=7 DAYS_BEFORE_CLOSE=4 DAYS_BEFORE_ANCIENT=1095 STALE_ISSUE_LABEL=closing-soon -EXEMPT_ISSUE_LABEL=automation-exempt +EXEMPT_ISSUE_LABELS=automation-exempt, help wanted STALE_PR_LABEL=closing-soon -EXEMPT_PR_LABEL=automation-exempt +EXEMPT_PR_LABELS=automation-exempt RESPONSE_REQUESTED_LABEL=response-requested +CFS_LABEL=closed-for-staleness MINIMUM_UPVOTES_TO_EXEMPT=1 DRYRUN=true LOGLEVEL=DEBUG