From 47c6b2cb49e5a8df5a0e0e0b20325ff7e0b3b8ce Mon Sep 17 00:00:00 2001 From: chiranjib-swain Date: Thu, 5 Feb 2026 14:42:06 +0530 Subject: [PATCH] feat: enhance IssuesProcessor to return label creation date and events, add stale label event checks --- __tests__/classes/issues-processor-mock.ts | 27 +- __tests__/exempt-draft-pr.spec.ts | 1 + ...ve-stale-when-updated-label-events.spec.ts | 288 ++++++++++++++++++ dist/index.js | 45 ++- src/classes/issues-processor.ts | 78 ++++- 5 files changed, 427 insertions(+), 12 deletions(-) create mode 100644 __tests__/remove-stale-when-updated-label-events.spec.ts diff --git a/__tests__/classes/issues-processor-mock.ts b/__tests__/classes/issues-processor-mock.ts index 3b2d488f..4f0cfa13 100644 --- a/__tests__/classes/issues-processor-mock.ts +++ b/__tests__/classes/issues-processor-mock.ts @@ -4,6 +4,7 @@ import {IComment} from '../../src/interfaces/comment'; import {IIssuesProcessorOptions} from '../../src/interfaces/issues-processor-options'; import {IPullRequest} from '../../src/interfaces/pull-request'; import {IState} from '../../src/interfaces/state/state'; +import {IIssueEvent} from '../../src/interfaces/issue-event'; export class IssuesProcessorMock extends IssuesProcessor { constructor( @@ -17,7 +18,15 @@ export class IssuesProcessorMock extends IssuesProcessor { getLabelCreationDate?: ( issue: Issue, label: string - ) => Promise, + ) => + | Promise + | Promise<{creationDate?: string; events: IIssueEvent[]}>, + hasOnlyStaleLabelingEventsSince?: ( + issue: Issue, + sinceDate: string, + staleLabel: string, + events: IIssueEvent[] + ) => Promise, getPullRequest?: (issue: Issue) => Promise ) { super(options, state); @@ -31,7 +40,21 @@ export class IssuesProcessorMock extends IssuesProcessor { } if (getLabelCreationDate) { - this.getLabelCreationDate = getLabelCreationDate; + this.getLabelCreationDate = async ( + issue: Issue, + label: string + ): Promise<{creationDate?: string; events: IIssueEvent[]}> => { + const result = await getLabelCreationDate(issue, label); + if (typeof result === 'string' || typeof result === 'undefined') { + return {creationDate: result, events: []}; + } + + return result; + }; + } + + if (hasOnlyStaleLabelingEventsSince) { + this.hasOnlyStaleLabelingEventsSince = hasOnlyStaleLabelingEventsSince; } if (getPullRequest) { diff --git a/__tests__/exempt-draft-pr.spec.ts b/__tests__/exempt-draft-pr.spec.ts index 49265a92..b19cb5c4 100644 --- a/__tests__/exempt-draft-pr.spec.ts +++ b/__tests__/exempt-draft-pr.spec.ts @@ -129,6 +129,7 @@ class IssuesProcessorBuilder { async p => (p === 1 ? this._issues : []), async () => [], async () => new Date().toDateString(), + undefined, async (): Promise => { return Promise.resolve({ number: 0, diff --git a/__tests__/remove-stale-when-updated-label-events.spec.ts b/__tests__/remove-stale-when-updated-label-events.spec.ts new file mode 100644 index 00000000..8f5ef821 --- /dev/null +++ b/__tests__/remove-stale-when-updated-label-events.spec.ts @@ -0,0 +1,288 @@ +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'; +import {IState} from '../src/interfaces/state/state'; +import {IIssueEvent} from '../src/interfaces/issue-event'; +import {IssuesProcessor} from '../src/classes/issues-processor'; + +describe('remove-stale-when-updated with stale label events', (): void => { + const markedStaleOn = '2025-01-01T00:00:00Z'; + const updatedAt = '2025-01-01T00:01:00Z'; + + let options: IIssuesProcessorOptions; + + beforeEach((): void => { + options = { + ...DefaultProcessorOptions, + removeStaleWhenUpdated: true + }; + }); + + const buildIssue = (): Issue => + generateIssue( + options, + 1, + 'dummy-title', + updatedAt, + markedStaleOn, + false, + false, + ['Stale'] + ); + + const buildEvents = (): IIssueEvent[] => [ + { + event: 'labeled', + created_at: markedStaleOn, + label: {name: 'Stale'} + } + ]; + + test('does not remove stale label when only stale label events occurred', async (): Promise => { + expect.assertions(1); + const issue = buildIssue(); + + const processor = new IssuesProcessorMock( + options, + alwaysFalseStateMock, + async p => (p === 1 ? [issue] : []), + async () => [], + async () => ({creationDate: markedStaleOn, events: buildEvents()}), + async () => true + ); + + await processor.processIssues(); + + expect(processor.removedLabelIssues).toHaveLength(0); + }); + + test('removes stale label when updates are not just stale label events', async (): Promise => { + expect.assertions(1); + const issue = buildIssue(); + + const processor = new IssuesProcessorMock( + options, + alwaysFalseStateMock, + async p => (p === 1 ? [issue] : []), + async () => [], + async () => ({creationDate: markedStaleOn, events: buildEvents()}), + async () => false + ); + + await processor.processIssues(); + + expect(processor.removedLabelIssues).toHaveLength(1); + }); +}); + +class TestIssuesProcessor extends IssuesProcessor { + constructor( + options: IIssuesProcessorOptions, + state: IState, + events: IIssueEvent[] + ) { + super(options, state); + const client = { + rest: { + issues: { + listEvents: { + endpoint: { + merge: () => ({}) + } + } + } + }, + paginate: { + iterator: async function* () { + yield {data: events}; + } + } + }; + (this as any).client = client; + } + + async callhasOnlyStaleLabelingEventsSince( + issue: Issue, + sinceDate: string, + staleLabel: string, + events: IIssueEvent[] + ): Promise { + return this.hasOnlyStaleLabelingEventsSince( + issue, + sinceDate, + staleLabel, + events + ); + } +} + +describe('hasOnlyStaleLabelingEventsSince', (): void => { + const staleLabel = 'Stale'; + const sinceDate = '2025-01-01T00:00:00Z'; + const originalRepo = process.env.GITHUB_REPOSITORY; + + let options: IIssuesProcessorOptions; + + beforeEach((): void => { + process.env.GITHUB_REPOSITORY = 'owner/repo'; + options = { + ...DefaultProcessorOptions, + staleIssueLabel: staleLabel, + removeStaleWhenUpdated: true + }; + }); + + afterEach((): void => { + if (originalRepo === undefined) { + delete process.env.GITHUB_REPOSITORY; + } else { + process.env.GITHUB_REPOSITORY = originalRepo; + } + }); + + const buildIssue = (): Issue => + generateIssue( + options, + 1, + 'dummy-title', + '2025-01-01T00:02:00Z', + sinceDate, + false, + false, + [staleLabel] + ); + + test('returns true when only stale label events exist after the since date', async (): Promise => { + expect.assertions(1); + const issue = buildIssue(); + const events: IIssueEvent[] = [ + // Event before the sinceDate should be ignored. + { + event: 'labeled', + created_at: '2024-12-31T23:59:00Z', + label: {name: staleLabel} + }, + { + event: 'labeled', + created_at: '2025-01-01T00:00:10Z', + label: {name: staleLabel} + } + ]; + const processor = new TestIssuesProcessor( + options, + alwaysFalseStateMock, + events + ); + const result = await processor.callhasOnlyStaleLabelingEventsSince( + issue, + sinceDate, + staleLabel, + events + ); + + expect(result).toBe(true); + }); + + test('returns false when a non-stale label event exists after the since date', async (): Promise => { + expect.assertions(1); + const issue = buildIssue(); + const events: IIssueEvent[] = [ + { + event: 'labeled', + created_at: '2025-01-01T00:00:10Z', + label: {name: 'other-label'} + } + ]; + const processor = new TestIssuesProcessor( + options, + alwaysFalseStateMock, + events + ); + const result = await processor.callhasOnlyStaleLabelingEventsSince( + issue, + sinceDate, + staleLabel, + events + ); + + expect(result).toBe(false); + }); + + test('returns false when stale label is removed after the since date', async (): Promise => { + expect.assertions(1); + const issue = buildIssue(); + const events: IIssueEvent[] = [ + { + event: 'unlabeled', + created_at: '2025-01-01T00:00:10Z', + label: {name: staleLabel} + } + ]; + const processor = new TestIssuesProcessor( + options, + alwaysFalseStateMock, + events + ); + const result = await processor.callhasOnlyStaleLabelingEventsSince( + issue, + sinceDate, + staleLabel, + events + ); + + expect(result).toBe(false); + }); + + test('returns false when a non-label event exists after the since date', async (): Promise => { + expect.assertions(1); + const issue = buildIssue(); + const events: IIssueEvent[] = [ + { + event: 'commented', + created_at: '2025-01-01T00:00:10Z', + label: {name: staleLabel} + } + ]; + const processor = new TestIssuesProcessor( + options, + alwaysFalseStateMock, + events + ); + const result = await processor.callhasOnlyStaleLabelingEventsSince( + issue, + sinceDate, + staleLabel, + events + ); + + expect(result).toBe(false); + }); + + test('includes events that occur exactly at the since date boundary', async (): Promise => { + expect.assertions(1); + const issue = buildIssue(); + const events: IIssueEvent[] = [ + { + event: 'labeled', + created_at: sinceDate, + label: {name: staleLabel} + } + ]; + const processor = new TestIssuesProcessor( + options, + alwaysFalseStateMock, + events + ); + const result = await processor.callhasOnlyStaleLabelingEventsSince( + issue, + sinceDate, + staleLabel, + events + ); + + expect(result).toBe(true); + }); +}); diff --git a/dist/index.js b/dist/index.js index 9fe8b478..5f154fd4 100644 --- a/dist/index.js +++ b/dist/index.js @@ -736,9 +736,35 @@ class IssuesProcessor { (0, clean_label_1.cleanLabel)(event.label.name) === (0, clean_label_1.cleanLabel)(label)); if (!staleLabeledEvent) { // Must be old rather than labeled - return undefined; + return { creationDate: undefined, events }; } - return staleLabeledEvent.created_at; + return { creationDate: staleLabeledEvent.created_at, events }; + }); + } + hasOnlyStaleLabelingEventsSince(issue, sinceDate, staleLabel, events) { + return __awaiter(this, void 0, void 0, function* () { + const issueLogger = new issue_logger_1.IssueLogger(issue); + issueLogger.info(`Checking if only stale label added events on $$type since: ${logger_service_1.LoggerService.cyan(sinceDate)}`); + if (!sinceDate) { + return false; + } + const sinceTimestamp = new Date(sinceDate).getTime(); + if (Number.isNaN(sinceTimestamp)) { + return false; + } + const relevantEvents = events.filter(event => { + const eventTimestamp = new Date(event.created_at).getTime(); + return !Number.isNaN(eventTimestamp) && eventTimestamp >= sinceTimestamp; + }); + if (relevantEvents.length === 0) { + return false; + } + return relevantEvents.every(event => { + if (event.event !== 'labeled') { + return false; + } + return (0, clean_label_1.cleanLabel)(event.label.name) === (0, clean_label_1.cleanLabel)(staleLabel); + }); }); } getPullRequest(issue) { @@ -783,7 +809,8 @@ class IssuesProcessor { _processStaleIssue(issue, staleLabel, staleMessage, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, labelsToRemoveWhenStale, closeMessage, closeLabel) { return __awaiter(this, void 0, void 0, function* () { const issueLogger = new issue_logger_1.IssueLogger(issue); - const markedStaleOn = (yield this.getLabelCreationDate(issue, staleLabel)) || issue.updated_at; + const { creationDate, events } = yield this.getLabelCreationDate(issue, staleLabel); + const markedStaleOn = creationDate || issue.updated_at; issueLogger.info(`$$type marked stale on: ${logger_service_1.LoggerService.cyan(markedStaleOn)}`); const issueHasCommentsSinceStale = yield this._hasCommentsSince(issue, markedStaleOn, staleMessage); issueLogger.info(`$$type has been commented on: ${logger_service_1.LoggerService.cyan(issueHasCommentsSinceStale)}`); @@ -805,7 +832,17 @@ class IssuesProcessor { } // The issue.updated_at and markedStaleOn are not always exactly in sync (they can be off by a second or 2) // isDateMoreRecentThan makes sure they are not the same date within a certain tolerance (15 seconds in this case) - const issueHasUpdateSinceStale = (0, is_date_more_recent_than_1.isDateMoreRecentThan)(new Date(issue.updated_at), new Date(markedStaleOn), 15); + let issueHasUpdateSinceStale = (0, is_date_more_recent_than_1.isDateMoreRecentThan)(new Date(issue.updated_at), new Date(markedStaleOn), 15); + // Check if the only update was the stale label being added + if (issueHasUpdateSinceStale && + shouldRemoveStaleWhenUpdated && + !issue.markedStaleThisRun) { + const onlyStaleLabelAdded = yield this.hasOnlyStaleLabelingEventsSince(issue, markedStaleOn, staleLabel, events); + if (onlyStaleLabelAdded) { + issueHasUpdateSinceStale = false; + issueLogger.info(`Ignoring $$type update since only the stale label was added`); + } + } issueLogger.info(`$$type has been updated since it was marked stale: ${logger_service_1.LoggerService.cyan(issueHasUpdateSinceStale)}`); // Should we un-stale this issue? if (shouldRemoveStaleWhenUpdated && diff --git a/src/classes/issues-processor.ts b/src/classes/issues-processor.ts index e7ef4ccc..c4e444fd 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -608,7 +608,7 @@ export class IssuesProcessor { async getLabelCreationDate( issue: Issue, label: string - ): Promise { + ): Promise<{creationDate?: string; events: IIssueEvent[]}> { const issueLogger: IssueLogger = new IssueLogger(issue); issueLogger.info(`Checking for label on this $$type`); @@ -623,6 +623,7 @@ export class IssuesProcessor { }); const events: IIssueEvent[] = await this.client.paginate(options); + const reversedEvents = events.reverse(); const staleLabeledEvent = reversedEvents.find( @@ -633,10 +634,51 @@ export class IssuesProcessor { if (!staleLabeledEvent) { // Must be old rather than labeled - return undefined; + return {creationDate: undefined, events}; } - return staleLabeledEvent.created_at; + return {creationDate: staleLabeledEvent.created_at, events}; + } + + protected async hasOnlyStaleLabelingEventsSince( + issue: Issue, + sinceDate: string, + staleLabel: string, + events: IIssueEvent[] + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info( + `Checking if only stale label added events on $$type since: ${LoggerService.cyan( + sinceDate + )}` + ); + + if (!sinceDate) { + return false; + } + + const sinceTimestamp = new Date(sinceDate).getTime(); + if (Number.isNaN(sinceTimestamp)) { + return false; + } + + const relevantEvents = events.filter(event => { + const eventTimestamp = new Date(event.created_at).getTime(); + return !Number.isNaN(eventTimestamp) && eventTimestamp >= sinceTimestamp; + }); + + if (relevantEvents.length === 0) { + return false; + } + + return relevantEvents.every(event => { + if (event.event !== 'labeled') { + return false; + } + + return cleanLabel(event.label.name) === cleanLabel(staleLabel); + }); } async getPullRequest(issue: Issue): Promise { @@ -691,8 +733,11 @@ export class IssuesProcessor { closeLabel?: string ) { const issueLogger: IssueLogger = new IssueLogger(issue); - const markedStaleOn: string = - (await this.getLabelCreationDate(issue, staleLabel)) || issue.updated_at; + const {creationDate, events} = await this.getLabelCreationDate( + issue, + staleLabel + ); + const markedStaleOn: string = creationDate || issue.updated_at; issueLogger.info( `$$type marked stale on: ${LoggerService.cyan(markedStaleOn)}` ); @@ -744,12 +789,33 @@ export class IssuesProcessor { // The issue.updated_at and markedStaleOn are not always exactly in sync (they can be off by a second or 2) // isDateMoreRecentThan makes sure they are not the same date within a certain tolerance (15 seconds in this case) - const issueHasUpdateSinceStale = isDateMoreRecentThan( + let issueHasUpdateSinceStale = isDateMoreRecentThan( new Date(issue.updated_at), new Date(markedStaleOn), 15 ); + // Check if the only update was the stale label being added + if ( + issueHasUpdateSinceStale && + shouldRemoveStaleWhenUpdated && + !issue.markedStaleThisRun + ) { + const onlyStaleLabelAdded = await this.hasOnlyStaleLabelingEventsSince( + issue, + markedStaleOn, + staleLabel, + events + ); + + if (onlyStaleLabelAdded) { + issueHasUpdateSinceStale = false; + issueLogger.info( + `Ignoring $$type update since only the stale label was added` + ); + } + } + issueLogger.info( `$$type has been updated since it was marked stale: ${LoggerService.cyan( issueHasUpdateSinceStale