diff --git a/README.md b/README.md index 9466f9a0..5b776dc1 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,8 @@ Every argument is optional. | [ignore-issue-updates](#ignore-issue-updates) | Override [ignore-updates](#ignore-updates) for issues only | | | [ignore-pr-updates](#ignore-pr-updates) | Override [ignore-updates](#ignore-updates) for PRs only | | | [include-only-assigned](#include-only-assigned) | Process only assigned issues | `false` | -| [sort-by](#sort-by) | What to sort issues and PRs by | `created` | +| [sort-by](#sort-by) | What to sort issues and PRs by | `created` | +| [only-issue-types](#only-issue-types) | Only issues with a matching type are processed as stale/closed. | | ### List of output options @@ -555,6 +556,13 @@ Useful to sort the issues and PRs by the specified field. It accepts `created`, Default value: `created` +#### only-issue-types + +A comma separated list of allowed issue types. Only issues with a matching type will be processed (e.g.: `bug,question`). + +If unset (or an empty string), this option will not alter the stale workflow. + +Default value: unset ### Usage diff --git a/__tests__/functions/generate-issue.ts b/__tests__/functions/generate-issue.ts index 0198c42c..a3b7dc04 100644 --- a/__tests__/functions/generate-issue.ts +++ b/__tests__/functions/generate-issue.ts @@ -15,7 +15,8 @@ export function generateIssue( isClosed = false, isLocked = false, milestone: string | undefined = undefined, - assignees: string[] = [] + assignees: string[] = [], + issue_type?: string ): Issue { return new Issue(options, { number: id, @@ -39,6 +40,7 @@ export function generateIssue( login: assignee, type: 'User' }; - }) + }), + ...(issue_type ? {type: {name: issue_type}} : {}) }); } diff --git a/__tests__/only-issue-types.spec.ts b/__tests__/only-issue-types.spec.ts new file mode 100644 index 00000000..c91d5658 --- /dev/null +++ b/__tests__/only-issue-types.spec.ts @@ -0,0 +1,125 @@ +import {Issue} from '../src/classes/issue'; +import {IIssuesProcessorOptions} from '../src/interfaces/issues-processor-options'; +import {IssuesProcessorMock} from './classes/issues-processor-mock'; +import {DefaultProcessorOptions} from './constants/default-processor-options'; +import {generateIssue} from './functions/generate-issue'; +import {alwaysFalseStateMock} from './classes/state-mock'; + +describe('only-issue-types option', () => { + test('should only process issues with allowed type', async () => { + const opts: IIssuesProcessorOptions = { + ...DefaultProcessorOptions, + onlyIssueTypes: 'bug,question' + }; + const TestIssueList: Issue[] = [ + generateIssue( + opts, + 1, + 'A bug', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + false, + [], + false, + false, + undefined, + [], + 'bug' + ), + generateIssue( + opts, + 2, + 'A feature', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + false, + [], + false, + false, + undefined, + [], + 'feature' + ), + generateIssue( + opts, + 3, + 'A question', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + false, + [], + false, + false, + undefined, + [], + 'question' + ) + ]; + const processor = new IssuesProcessorMock( + opts, + alwaysFalseStateMock, + async p => (p === 1 ? TestIssueList : []), + async () => [], + async () => new Date().toDateString() + ); + await processor.processIssues(1); + expect(processor.staleIssues.map(i => i.title)).toEqual([ + 'A bug', + 'A question' + ]); + }); + + test('should process all issues if onlyIssueTypes is unset', async () => { + const opts: IIssuesProcessorOptions = { + ...DefaultProcessorOptions, + onlyIssueTypes: '' + }; + const TestIssueList: Issue[] = [ + generateIssue( + opts, + 1, + 'A bug', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + false, + [], + false, + false, + undefined, + [], + 'bug' + ), + generateIssue( + opts, + 2, + 'A feature', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + false, + [], + false, + false, + undefined, + [], + 'feature' + ) + ]; + const processor = new IssuesProcessorMock( + opts, + alwaysFalseStateMock, + async p => (p === 1 ? TestIssueList : []), + async () => [], + async () => new Date().toDateString() + ); + await processor.processIssues(1); + expect(processor.staleIssues.map(i => i.title)).toEqual([ + 'A bug', + 'A feature' + ]); + }); +}); diff --git a/action.yml b/action.yml index dffbf045..b3354e9d 100644 --- a/action.yml +++ b/action.yml @@ -208,6 +208,10 @@ inputs: description: 'Only the issues or the pull requests with an assignee will be marked as stale automatically.' default: 'false' required: false + only-issue-types: + description: 'Only issues with a matching type are processed as stale/closed. Defaults to `[]` (disabled) and can be a comma-separated list of issue types.' + default: '' + required: false outputs: closed-issues-prs: description: 'List of all closed issues and pull requests.' diff --git a/dist/index.js b/dist/index.js index d1b7d0a3..1a9c156b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -289,6 +289,13 @@ class Issue { this.assignees = issue.assignees || []; this.isStale = (0, is_labeled_1.isLabeled)(this, this.staleLabel); this.markedStaleThisRun = false; + if (typeof issue.type === 'object' && + issue.type !== null) { + this.issue_type = issue.type.name; + } + else { + this.issue_type = undefined; + } } get isPullRequest() { return (0, is_pull_request_1.isPullRequest)(this); @@ -506,6 +513,18 @@ class IssuesProcessor { IssuesProcessor._endIssueProcessing(issue); return; // If the issue has an 'include-only-assigned' option set, process only issues with nonempty assignees list } + if (this.options.onlyIssueTypes) { + const allowedTypes = this.options.onlyIssueTypes + .split(',') + .map(t => t.trim().toLowerCase()) + .filter(Boolean); + const issueType = (issue.issue_type || '').toLowerCase(); + if (!allowedTypes.includes(issueType)) { + issueLogger.info(`Skipping this $$type because its type ('${issue.issue_type}') is not in onlyIssueTypes (${allowedTypes.join(', ')})`); + IssuesProcessor._endIssueProcessing(issue); + return; + } + } const onlyLabels = (0, words_to_list_1.wordsToList)(this._getOnlyLabels(issue)); if (onlyLabels.length > 0) { issueLogger.info(`The option ${issueLogger.createOptionLink(option_1.Option.OnlyLabels)} was specified to only process issues and pull requests with all those labels (${logger_service_1.LoggerService.cyan(onlyLabels.length)})`); @@ -2225,6 +2244,7 @@ var Option; Option["IgnorePrUpdates"] = "ignore-pr-updates"; Option["ExemptDraftPr"] = "exempt-draft-pr"; Option["CloseIssueReason"] = "close-issue-reason"; + Option["OnlyIssueTypes"] = "only-issue-types"; })(Option || (exports.Option = Option = {})); diff --git a/src/classes/issue.ts b/src/classes/issue.ts index b9063183..9a8a742b 100644 --- a/src/classes/issue.ts +++ b/src/classes/issue.ts @@ -24,6 +24,7 @@ export class Issue implements IIssue { markedStaleThisRun: boolean; operations = new Operations(); private readonly _options: IIssuesProcessorOptions; + readonly issue_type?: string; constructor( options: Readonly, @@ -43,6 +44,15 @@ export class Issue implements IIssue { this.assignees = issue.assignees || []; this.isStale = isLabeled(this, this.staleLabel); this.markedStaleThisRun = false; + + if ( + typeof (issue as any).type === 'object' && + (issue as any).type !== null + ) { + this.issue_type = (issue as any).type.name; + } else { + this.issue_type = undefined; + } } get isPullRequest(): boolean { diff --git a/src/classes/issues-processor.ts b/src/classes/issues-processor.ts index 56c9ecb5..3f7b5563 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -252,6 +252,23 @@ export class IssuesProcessor { return; // If the issue has an 'include-only-assigned' option set, process only issues with nonempty assignees list } + if (this.options.onlyIssueTypes) { + const allowedTypes = this.options.onlyIssueTypes + .split(',') + .map(t => t.trim().toLowerCase()) + .filter(Boolean); + const issueType = (issue.issue_type || '').toLowerCase(); + if (!allowedTypes.includes(issueType)) { + issueLogger.info( + `Skipping this $$type because its type ('${ + issue.issue_type + }') is not in onlyIssueTypes (${allowedTypes.join(', ')})` + ); + IssuesProcessor._endIssueProcessing(issue); + return; + } + } + const onlyLabels: string[] = wordsToList(this._getOnlyLabels(issue)); if (onlyLabels.length > 0) { diff --git a/src/enums/option.ts b/src/enums/option.ts index c7833858..3c1bb515 100644 --- a/src/enums/option.ts +++ b/src/enums/option.ts @@ -49,5 +49,6 @@ export enum Option { IgnoreIssueUpdates = 'ignore-issue-updates', IgnorePrUpdates = 'ignore-pr-updates', ExemptDraftPr = 'exempt-draft-pr', - CloseIssueReason = 'close-issue-reason' + CloseIssueReason = 'close-issue-reason', + OnlyIssueTypes = 'only-issue-types' } diff --git a/src/interfaces/issue.ts b/src/interfaces/issue.ts index defdb75d..89015f2b 100644 --- a/src/interfaces/issue.ts +++ b/src/interfaces/issue.ts @@ -15,6 +15,7 @@ export interface IIssue { locked: boolean; milestone?: IMilestone | null; assignees?: Assignee[] | null; + issue_type?: string; } export type OctokitIssue = components['schemas']['issue']; diff --git a/src/interfaces/issues-processor-options.ts b/src/interfaces/issues-processor-options.ts index ea379af1..273ae461 100644 --- a/src/interfaces/issues-processor-options.ts +++ b/src/interfaces/issues-processor-options.ts @@ -55,4 +55,5 @@ export interface IIssuesProcessorOptions { exemptDraftPr: boolean; closeIssueReason: string; includeOnlyAssigned: boolean; + onlyIssueTypes?: string; }