From 35fe3618fe7784753596c8a1a58b933c8004d7f7 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Tue, 27 Aug 2024 16:11:44 -0300 Subject: [PATCH] Exempt issue/pr stale for specific author names This allows excluding specific authors' issues/PRs. Useful for those created by project owners, for example. Fixes #933 --- README.md | 7 +++ .../constants/default-processor-options.ts | 3 +- __tests__/functions/generate-iissue.ts | 4 ++ __tests__/functions/generate-issue.ts | 7 ++- __tests__/main.spec.ts | 61 +++++++++++++++++++ action.yml | 4 ++ dist/index.js | 17 +++++- src/classes/issue.spec.ts | 15 ++++- src/classes/issue.ts | 7 +++ src/classes/issues-processor.ts | 16 +++++ src/classes/user.ts | 11 ++++ src/enums/option.ts | 3 +- src/interfaces/issue.ts | 4 +- src/interfaces/issues-processor-options.ts | 1 + src/main.ts | 3 +- 15 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 src/classes/user.ts diff --git a/README.md b/README.md index eb65b46b9..31badb429 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ 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` | +| [exempt-authors](#exempt-authors) | Skip issues or pull requests by these authors | | ### List of output options @@ -547,6 +548,12 @@ If set to `true`, only the issues or the pull requests with an assignee will be Default value: `false` +#### exempt-authors + +Comma separated list of authors to exclude from being marked as stale (e.g: issue, pull request). + +Default value: unset + ### Usage See also [action.yml](./action.yml) for a comprehensive list of all the options. diff --git a/__tests__/constants/default-processor-options.ts b/__tests__/constants/default-processor-options.ts index 0265b6446..7654ee999 100644 --- a/__tests__/constants/default-processor-options.ts +++ b/__tests__/constants/default-processor-options.ts @@ -55,5 +55,6 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({ ignorePrUpdates: undefined, exemptDraftPr: false, closeIssueReason: 'not_planned', - includeOnlyAssigned: false + includeOnlyAssigned: false, + exemptAuthors: '' }); diff --git a/__tests__/functions/generate-iissue.ts b/__tests__/functions/generate-iissue.ts index e289386d0..5fa2c98ac 100644 --- a/__tests__/functions/generate-iissue.ts +++ b/__tests__/functions/generate-iissue.ts @@ -15,6 +15,10 @@ export function generateIIssue( title: 'dummy-title', locked: false, state: 'dummy-state', + user: { + login: 'dummy-login', + type: 'User' + }, ...partialIssue }; } diff --git a/__tests__/functions/generate-issue.ts b/__tests__/functions/generate-issue.ts index 0198c42cb..7c16e6ecd 100644 --- a/__tests__/functions/generate-issue.ts +++ b/__tests__/functions/generate-issue.ts @@ -15,10 +15,15 @@ export function generateIssue( isClosed = false, isLocked = false, milestone: string | undefined = undefined, - assignees: string[] = [] + assignees: string[] = [], + author: string = 'author' ): Issue { return new Issue(options, { number: id, + user: { + login: author, + type: 'User' + }, labels: labels.map(l => { return {name: l}; }), diff --git a/__tests__/main.spec.ts b/__tests__/main.spec.ts index 80d660e88..1ae02352e 100644 --- a/__tests__/main.spec.ts +++ b/__tests__/main.spec.ts @@ -1018,6 +1018,67 @@ test('stale locked prs will not be closed', async () => { expect(processor.closedIssues).toHaveLength(0); }); +test('exempt issue authors will not be marked stale', async () => { + const opts = {...DefaultProcessorOptions}; + opts.exemptAuthors = 'author'; + const TestIssueList: Issue[] = [ + generateIssue( + opts, + 1, + 'My first issue', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + false, + ['Exempt'], + ) + ]; + const processor = new IssuesProcessorMock( + opts, + alwaysFalseStateMock, + async p => (p === 1 ? TestIssueList : []), + async () => [], + async () => new Date().toDateString() + ); + + // process our fake issue list + await processor.processIssues(1); + + expect(processor.staleIssues.length).toStrictEqual(0); + expect(processor.closedIssues.length).toStrictEqual(0); + expect(processor.removedLabelIssues.length).toStrictEqual(0); +}); + +test('non exempt issue authors will be marked stale', async () => { + const opts = {...DefaultProcessorOptions}; + opts.exemptAuthors = 'dummy1,dummy2'; + const TestIssueList: Issue[] = [ + generateIssue( + opts, + 1, + 'My first issue', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + false, + ['Exempt'], + ) + ]; + const processor = new IssuesProcessorMock( + opts, + alwaysFalseStateMock, + async p => (p === 1 ? TestIssueList : []), + async () => [], + async () => new Date().toDateString() + ); + + // process our fake issue list + await processor.processIssues(1); + + expect(processor.staleIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); +}); + test('exempt issue labels will not be marked stale', async () => { expect.assertions(3); const opts = {...DefaultProcessorOptions}; diff --git a/action.yml b/action.yml index d55f8547c..2f93f7546 100644 --- a/action.yml +++ b/action.yml @@ -204,6 +204,10 @@ inputs: description: 'Only the issues or the pull requests with an assignee will be marked as stale automatically.' default: 'false' required: false + exempt-authors: + description: 'Skip issues or pull requests by these authors.' + 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 f2786a0f6..3422d5305 100644 --- a/dist/index.js +++ b/dist/index.js @@ -274,10 +274,16 @@ const is_pull_request_1 = __nccwpck_require__(5400); const operations_1 = __nccwpck_require__(7957); class Issue { constructor(options, issue) { + var _a, _b, _c, _d; this.operations = new operations_1.Operations(); this._options = options; this.title = issue.title; this.number = issue.number; + //this.user = issue.user; + this.user = { + login: (_b = (_a = issue.user) === null || _a === void 0 ? void 0 : _a.login) !== null && _b !== void 0 ? _b : "", + type: (_d = (_c = issue.user) === null || _c === void 0 ? void 0 : _c.type) !== null && _d !== void 0 ? _d : "User" + }; this.created_at = issue.created_at; this.updated_at = issue.updated_at; this.draft = Boolean(issue.draft); @@ -561,6 +567,13 @@ class IssuesProcessor { IssuesProcessor._endIssueProcessing(issue); return; // Don't process exempt issues } + const exemptAuthors = (0, words_to_list_1.wordsToList)(this.options.exemptAuthors); + const hasExemptAuthors = exemptAuthors.some((exemptAuthor) => issue.user.login == exemptAuthor); + if (hasExemptAuthors) { + issueLogger.info(`Skipping this $$type because it contains an exempt author, see ${issueLogger.createOptionLink(option_1.Option.ExemptAuthors)} for more details`); + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process exempt issues + } const anyOfLabels = (0, words_to_list_1.wordsToList)(this._getAnyOfLabels(issue)); if (anyOfLabels.length > 0) { issueLogger.info(`The option ${issueLogger.createOptionLink(option_1.Option.AnyOfLabels)} was specified to only process the issues and pull requests with one of those labels (${logger_service_1.LoggerService.cyan(anyOfLabels.length)})`); @@ -2222,6 +2235,7 @@ var Option; Option["IgnorePrUpdates"] = "ignore-pr-updates"; Option["ExemptDraftPr"] = "exempt-draft-pr"; Option["CloseIssueReason"] = "close-issue-reason"; + Option["ExemptAuthors"] = "exempt-authors"; })(Option || (exports.Option = Option = {})); @@ -2567,7 +2581,8 @@ function _getAndValidateArgs() { ignorePrUpdates: _toOptionalBoolean('ignore-pr-updates'), exemptDraftPr: core.getInput('exempt-draft-pr') === 'true', closeIssueReason: core.getInput('close-issue-reason'), - includeOnlyAssigned: core.getInput('include-only-assigned') === 'true' + includeOnlyAssigned: core.getInput('include-only-assigned') === 'true', + exemptAuthors: core.getInput('exempt-authors'), }; for (const numberInput of ['days-before-stale']) { if (isNaN(parseFloat(core.getInput(numberInput)))) { diff --git a/src/classes/issue.spec.ts b/src/classes/issue.spec.ts index a2c82e268..5a7aee9b0 100644 --- a/src/classes/issue.spec.ts +++ b/src/classes/issue.spec.ts @@ -64,11 +64,16 @@ describe('Issue', (): void => { ignorePrUpdates: undefined, exemptDraftPr: false, closeIssueReason: '', - includeOnlyAssigned: false + includeOnlyAssigned: false, + exemptAuthors: '' }; issueInterface = { title: 'dummy-title', number: 8, + user: { + login: 'dummy-author', + type: 'User' + }, created_at: 'dummy-created-at', updated_at: 'dummy-updated-at', draft: false, @@ -106,6 +111,12 @@ describe('Issue', (): void => { expect(issue.number).toStrictEqual(8); }); + it('should set the author with the given issue author', (): void => { + expect.assertions(1); + + expect(issue.user.login).toStrictEqual('dummy-author'); + }); + it('should set the created_at with the given issue created_at', (): void => { expect.assertions(1); @@ -194,9 +205,9 @@ describe('Issue', (): void => { expect.assertions(1); expect(issue.isStale).toStrictEqual(true); - }); }); }); +}); describe('get isPullRequest', (): void => { describe('when the issue pull_request is not set', (): void => { diff --git a/src/classes/issue.ts b/src/classes/issue.ts index b90631835..f0c5de31e 100644 --- a/src/classes/issue.ts +++ b/src/classes/issue.ts @@ -5,12 +5,14 @@ import {IIssue, OctokitIssue} from '../interfaces/issue'; import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options'; import {ILabel} from '../interfaces/label'; import {IMilestone} from '../interfaces/milestone'; +import { IUser } from '../interfaces/user'; import {IsoDateString} from '../types/iso-date-string'; import {Operations} from './operations'; export class Issue implements IIssue { readonly title: string; readonly number: number; + readonly user: IUser; created_at: IsoDateString; updated_at: IsoDateString; readonly draft: boolean; @@ -32,6 +34,11 @@ export class Issue implements IIssue { this._options = options; this.title = issue.title; this.number = issue.number; + //this.user = issue.user; + this.user = { + login: issue.user?.login ?? "", + type: issue.user?.type ?? "User" + }; this.created_at = issue.created_at; this.updated_at = issue.updated_at; this.draft = Boolean(issue.draft); diff --git a/src/classes/issues-processor.ts b/src/classes/issues-processor.ts index 486c6a78a..f3148ef77 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -368,6 +368,22 @@ export class IssuesProcessor { return; // Don't process exempt issues } + const exemptAuthors: string[] = wordsToList(this.options.exemptAuthors); + + const hasExemptAuthors = exemptAuthors.some((exemptAuthor: Readonly) => + issue.user.login == exemptAuthor + ); + + if (hasExemptAuthors) { + issueLogger.info( + `Skipping this $$type because it contains an exempt author, see ${issueLogger.createOptionLink( + Option.ExemptAuthors + )} for more details` + ); + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process exempt issues + } + const anyOfLabels: string[] = wordsToList(this._getAnyOfLabels(issue)); if (anyOfLabels.length > 0) { diff --git a/src/classes/user.ts b/src/classes/user.ts new file mode 100644 index 000000000..3c6053a00 --- /dev/null +++ b/src/classes/user.ts @@ -0,0 +1,11 @@ +import {IUser} from '../interfaces/user'; + +class User implements IUser { + type: string; + login: string; + + constructor(user: IUser) { + this.type = user.type; + this.login = user.login; + } +} \ No newline at end of file diff --git a/src/enums/option.ts b/src/enums/option.ts index 7a9bff026..d278ad038 100644 --- a/src/enums/option.ts +++ b/src/enums/option.ts @@ -48,5 +48,6 @@ export enum Option { IgnoreIssueUpdates = 'ignore-issue-updates', IgnorePrUpdates = 'ignore-pr-updates', ExemptDraftPr = 'exempt-draft-pr', - CloseIssueReason = 'close-issue-reason' + CloseIssueReason = 'close-issue-reason', + ExemptAuthors = 'exempt-authors', } diff --git a/src/interfaces/issue.ts b/src/interfaces/issue.ts index defdb75d6..f104f96d3 100644 --- a/src/interfaces/issue.ts +++ b/src/interfaces/issue.ts @@ -3,9 +3,11 @@ import {Assignee} from './assignee'; import {ILabel} from './label'; import {IMilestone} from './milestone'; import {components} from '@octokit/openapi-types'; +import { IUser } from './user'; export interface IIssue { title: string; number: number; + user: IUser; created_at: IsoDateString; updated_at: IsoDateString; draft: boolean; @@ -17,4 +19,4 @@ export interface IIssue { assignees?: Assignee[] | null; } -export type OctokitIssue = components['schemas']['issue']; +export type OctokitIssue = components['schemas']['issue']; \ No newline at end of file diff --git a/src/interfaces/issues-processor-options.ts b/src/interfaces/issues-processor-options.ts index 930992284..fbb50ac0f 100644 --- a/src/interfaces/issues-processor-options.ts +++ b/src/interfaces/issues-processor-options.ts @@ -54,4 +54,5 @@ export interface IIssuesProcessorOptions { exemptDraftPr: boolean; closeIssueReason: string; includeOnlyAssigned: boolean; + exemptAuthors: string; } diff --git a/src/main.ts b/src/main.ts index a7836c160..e11265f0a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -123,7 +123,8 @@ function _getAndValidateArgs(): IIssuesProcessorOptions { ignorePrUpdates: _toOptionalBoolean('ignore-pr-updates'), exemptDraftPr: core.getInput('exempt-draft-pr') === 'true', closeIssueReason: core.getInput('close-issue-reason'), - includeOnlyAssigned: core.getInput('include-only-assigned') === 'true' + includeOnlyAssigned: core.getInput('include-only-assigned') === 'true', + exemptAuthors: core.getInput('exempt-authors'), }; for (const numberInput of ['days-before-stale']) {