diff --git a/.github/label-commenter-config.yml b/.github/label-commenter-config.yml index 477ddc82..80e497ce 100644 --- a/.github/label-commenter-config.yml +++ b/.github/label-commenter-config.yml @@ -15,6 +15,7 @@ labels: pr: body: Thank you @{{ pull_request.user.login }} for suggesting this. Please follow the pull request templates. action: close + draft: true unlabeled: issue: body: Thank you for following the template. The repository owner will reply. diff --git a/Makefile b/Makefile index 0b7ba011..dcc268b0 100644 --- a/Makefile +++ b/Makefile @@ -25,11 +25,12 @@ all: release: bash ./scripts/release.sh -.PHONY: pre-release -pre-release: +.PHONY: create-pre-release +create-pre-release: bash ./scripts/pre-release.sh + git rev-parse HEAD -.PHONY: remove-pre-release -remove-pre-release: - git rm -f ./lib/index.js - git commit -m "chore: remove lib/index.js" +.PHONY: pre-release +pre-release: create-pre-release + git rm -f ./lib/* + git commit -m "chore(release): Remove build assets [skip ci]" diff --git a/__tests__/classes/action-processor.test.ts b/__tests__/classes/action-processor.test.ts index 6389e39a..1f20a149 100644 --- a/__tests__/classes/action-processor.test.ts +++ b/__tests__/classes/action-processor.test.ts @@ -8,14 +8,18 @@ const commentBody = `hello`; const githubClient = getOctokit('token'); const issueMock: Issue = { githubClient: githubClient, + id: 'MDExOlB1bGxSZXF1ZXN0NzA2MTE5NTg0', number: 1, locked: false, setLocked: jest.fn(), createComment: jest.fn(), updateState: jest.fn(), lock: jest.fn(), - unlock: jest.fn() + unlock: jest.fn(), + markPullRequestReadyForReview: jest.fn(), + convertPullRequestToDraft: jest.fn() }; +const tests = ['issue', 'pr']; // beforeAll(() => { // }); @@ -25,157 +29,190 @@ afterEach(() => { jest.resetModules(); }); -describe('issue', () => { - test('Comment and close', async () => { - const config: IConfig = { - parentFieldName: 'labels.invalid.labeled.issue', - labelIndex: '0', - action: 'close', - locking: undefined, - lockReason: undefined - }; - const actionProcessor = new ActionProcessor(config, commentBody, issueMock); - await actionProcessor.process(); - expect(issueMock.createComment).toBeCalledTimes(1); - expect(issueMock.createComment).toBeCalledWith(commentBody); - expect(issueMock.updateState).toBeCalledTimes(1); - expect(issueMock.updateState).toBeCalledWith('closed'); - expect(issueMock.lock).toBeCalledTimes(0); - expect(issueMock.unlock).toBeCalledTimes(0); - }); +describe('Comment and close', () => { + for (const t of tests) { + test(`${t}`, async () => { + const config: IConfig = { + parentFieldName: `labels.invalid.labeled.${t}`, + labelIndex: '0', + action: 'close', + locking: undefined, + lockReason: undefined + }; + const actionProcessor = new ActionProcessor(config, commentBody, issueMock); + await actionProcessor.process(); + expect(issueMock.createComment).toBeCalledTimes(1); + expect(issueMock.createComment).toBeCalledWith(commentBody); + expect(issueMock.updateState).toBeCalledTimes(1); + expect(issueMock.updateState).toBeCalledWith('closed'); + expect(issueMock.lock).toBeCalledTimes(0); + expect(issueMock.unlock).toBeCalledTimes(0); + }); + } +}); - test('Comment, close, and lock without lockReason', async () => { - const config: IConfig = { - parentFieldName: 'labels.locked (resolved).labeled.issue', - labelIndex: '0', - action: 'close', - locking: 'lock', - lockReason: undefined - }; - const actionProcessor = new ActionProcessor(config, commentBody, issueMock); - await actionProcessor.process(); - expect(issueMock.createComment).toBeCalledTimes(1); - expect(issueMock.createComment).toBeCalledWith(commentBody); - expect(issueMock.updateState).toBeCalledTimes(1); - expect(issueMock.updateState).toBeCalledWith('closed'); - expect(issueMock.lock).toBeCalledTimes(1); - expect(issueMock.unlock).toBeCalledTimes(0); - }); +describe('Comment, close, and lock without lockReason', () => { + for (const t of tests) { + test(`${t}`, async () => { + const config: IConfig = { + parentFieldName: `labels.locked (resolved).labeled.${t}`, + labelIndex: '0', + action: 'close', + locking: 'lock', + lockReason: undefined + }; + const actionProcessor = new ActionProcessor(config, commentBody, issueMock); + await actionProcessor.process(); + expect(issueMock.createComment).toBeCalledTimes(1); + expect(issueMock.createComment).toBeCalledWith(commentBody); + expect(issueMock.updateState).toBeCalledTimes(1); + expect(issueMock.updateState).toBeCalledWith('closed'); + expect(issueMock.lock).toBeCalledTimes(1); + expect(issueMock.unlock).toBeCalledTimes(0); + }); + } +}); - test('Comment, close, and lock with lockReason', async () => { - const config: IConfig = { - parentFieldName: 'labels.locked (spam).labeled.issue', - labelIndex: '0', - action: 'close', - locking: 'lock', - lockReason: 'spam' - }; - const actionProcessor = new ActionProcessor(config, commentBody, issueMock); - await actionProcessor.process(); - expect(issueMock.createComment).toBeCalledTimes(1); - expect(issueMock.createComment).toBeCalledWith(commentBody); - expect(issueMock.updateState).toBeCalledTimes(1); - expect(issueMock.updateState).toBeCalledWith('closed'); - expect(issueMock.lock).toBeCalledTimes(1); - expect(issueMock.lock).toBeCalledWith('spam'); - expect(issueMock.unlock).toBeCalledTimes(0); - }); +describe('Comment, close, and lock with lockReason', () => { + for (const t of tests) { + test(`${t}`, async () => { + const config: IConfig = { + parentFieldName: `labels.locked (spam).labeled.${t}`, + labelIndex: '0', + action: 'close', + locking: 'lock', + lockReason: 'spam' + }; + const actionProcessor = new ActionProcessor(config, commentBody, issueMock); + await actionProcessor.process(); + expect(issueMock.createComment).toBeCalledTimes(1); + expect(issueMock.createComment).toBeCalledWith(commentBody); + expect(issueMock.updateState).toBeCalledTimes(1); + expect(issueMock.updateState).toBeCalledWith('closed'); + expect(issueMock.lock).toBeCalledTimes(1); + expect(issueMock.lock).toBeCalledWith('spam'); + expect(issueMock.unlock).toBeCalledTimes(0); + }); + } +}); - test('Unlock, open and comment', async () => { - const config: IConfig = { - parentFieldName: 'labels.locked (heated).labeled.issue', - labelIndex: '0', - action: 'open', - locking: 'unlock', - lockReason: undefined - }; - const actionProcessor = new ActionProcessor(config, commentBody, issueMock); - await actionProcessor.process(); - expect(issueMock.createComment).toBeCalledTimes(1); - expect(issueMock.createComment).toBeCalledWith(commentBody); - expect(issueMock.updateState).toBeCalledTimes(1); - expect(issueMock.updateState).toBeCalledWith('open'); - expect(issueMock.lock).toBeCalledTimes(0); - expect(issueMock.unlock).toBeCalledTimes(1); - }); +describe('Unlock, open and comment', () => { + for (const t of tests) { + test(`${t}`, async () => { + const config: IConfig = { + parentFieldName: `labels.locked (heated).labeled.${t}`, + labelIndex: '0', + action: 'open', + locking: 'unlock', + lockReason: undefined + }; + const actionProcessor = new ActionProcessor(config, commentBody, issueMock); + await actionProcessor.process(); + expect(issueMock.createComment).toBeCalledTimes(1); + expect(issueMock.createComment).toBeCalledWith(commentBody); + expect(issueMock.updateState).toBeCalledTimes(1); + expect(issueMock.updateState).toBeCalledWith('open'); + expect(issueMock.lock).toBeCalledTimes(0); + expect(issueMock.unlock).toBeCalledTimes(1); + }); + } +}); - test('Comment and open', async () => { - const config: IConfig = { - parentFieldName: 'labels.invalid.labeled.issue', - labelIndex: '0', - action: 'open', - locking: undefined, - lockReason: undefined - }; - const actionProcessor = new ActionProcessor(config, commentBody, issueMock); - await actionProcessor.process(); - expect(issueMock.createComment).toBeCalledTimes(1); - expect(issueMock.createComment).toBeCalledWith(commentBody); - expect(issueMock.updateState).toBeCalledTimes(1); - expect(issueMock.updateState).toBeCalledWith('open'); - expect(issueMock.lock).toBeCalledTimes(0); - expect(issueMock.unlock).toBeCalledTimes(0); - }); +describe('Comment and open', () => { + for (const t of tests) { + test(`${t}`, async () => { + const config: IConfig = { + parentFieldName: `labels.invalid.labeled.${t}`, + labelIndex: '0', + action: 'open', + locking: undefined, + lockReason: undefined + }; + const actionProcessor = new ActionProcessor(config, commentBody, issueMock); + await actionProcessor.process(); + expect(issueMock.createComment).toBeCalledTimes(1); + expect(issueMock.createComment).toBeCalledWith(commentBody); + expect(issueMock.updateState).toBeCalledTimes(1); + expect(issueMock.updateState).toBeCalledWith('open'); + expect(issueMock.lock).toBeCalledTimes(0); + expect(issueMock.unlock).toBeCalledTimes(0); + }); + } +}); - test('Open without comment if the issue is locked', async () => { - const config: IConfig = { - parentFieldName: 'labels.invalid.labeled.issue', - labelIndex: '0', - action: 'open', - locking: undefined, - lockReason: undefined - }; - const issueMock: Issue = { - githubClient: githubClient, - number: 1, - locked: true, - setLocked: jest.fn(), - createComment: jest.fn(), - updateState: jest.fn(), - lock: jest.fn(), - unlock: jest.fn() - }; - const actionProcessor = new ActionProcessor(config, commentBody, issueMock); - await actionProcessor.process(); - expect(issueMock.createComment).toBeCalledTimes(0); - expect(issueMock.updateState).toBeCalledTimes(1); - expect(issueMock.updateState).toBeCalledWith('open'); - expect(issueMock.lock).toBeCalledTimes(0); - expect(issueMock.unlock).toBeCalledTimes(0); - }); +describe('Open without comment if the issue is locked', () => { + for (const t of tests) { + test(`${t}`, async () => { + const config: IConfig = { + parentFieldName: `labels.invalid.labeled.${t}`, + labelIndex: '0', + action: 'open', + locking: undefined, + lockReason: undefined + }; + const issueMock: Issue = { + githubClient: githubClient, + id: 'MDExOlB1bGxSZXF1ZXN0NzA2MTE5NTg0', + number: 1, + locked: true, + setLocked: jest.fn(), + createComment: jest.fn(), + updateState: jest.fn(), + lock: jest.fn(), + unlock: jest.fn(), + markPullRequestReadyForReview: jest.fn(), + convertPullRequestToDraft: jest.fn() + }; + const actionProcessor = new ActionProcessor(config, commentBody, issueMock); + await actionProcessor.process(); + expect(issueMock.createComment).toBeCalledTimes(0); + expect(issueMock.updateState).toBeCalledTimes(1); + expect(issueMock.updateState).toBeCalledWith('open'); + expect(issueMock.lock).toBeCalledTimes(0); + expect(issueMock.unlock).toBeCalledTimes(0); + }); + } +}); - test('Skip all actions for a label that has no configuration', async () => { - const config: IConfig = { - parentFieldName: 'labels.unknown.labeled.issue', - labelIndex: '', - action: undefined, - locking: undefined, - lockReason: undefined - }; - const actionProcessor = new ActionProcessor(config, commentBody, issueMock); - await actionProcessor.process(); - expect(issueMock.createComment).toBeCalledTimes(0); - expect(issueMock.updateState).toBeCalledTimes(0); - expect(issueMock.lock).toBeCalledTimes(0); - expect(issueMock.unlock).toBeCalledTimes(0); - }); +describe('Skip all actions for a label that has no configuration', () => { + for (const t of tests) { + test(`${t}`, async () => { + const config: IConfig = { + parentFieldName: `labels.unknown.labeled.${t}`, + labelIndex: '', + action: undefined, + locking: undefined, + lockReason: undefined + }; + const actionProcessor = new ActionProcessor(config, commentBody, issueMock); + await actionProcessor.process(); + expect(issueMock.createComment).toBeCalledTimes(0); + expect(issueMock.updateState).toBeCalledTimes(0); + expect(issueMock.lock).toBeCalledTimes(0); + expect(issueMock.unlock).toBeCalledTimes(0); + }); + } +}); - test('Skip comment if body is empty', async () => { - const config: IConfig = { - parentFieldName: 'labels.spam.labeled.issue', - labelIndex: '1', - action: 'close', - locking: 'lock', - lockReason: 'spam' - }; - const commentBody = ''; - const actionProcessor = new ActionProcessor(config, commentBody, issueMock); - await actionProcessor.process(); - expect(issueMock.createComment).toBeCalledTimes(0); - expect(issueMock.updateState).toBeCalledTimes(1); - expect(issueMock.updateState).toBeCalledWith('closed'); - expect(issueMock.lock).toBeCalledTimes(1); - expect(issueMock.lock).toBeCalledWith('spam'); - expect(issueMock.unlock).toBeCalledTimes(0); - }); +describe('Skip comment if body is empty', () => { + for (const t of tests) { + test(`${t}`, async () => { + const config: IConfig = { + parentFieldName: `labels.spam.labeled.${t}`, + labelIndex: '1', + action: 'close', + locking: 'lock', + lockReason: 'spam' + }; + const commentBody = ''; + const actionProcessor = new ActionProcessor(config, commentBody, issueMock); + await actionProcessor.process(); + expect(issueMock.createComment).toBeCalledTimes(0); + expect(issueMock.updateState).toBeCalledTimes(1); + expect(issueMock.updateState).toBeCalledWith('closed'); + expect(issueMock.lock).toBeCalledTimes(1); + expect(issueMock.lock).toBeCalledWith('spam'); + expect(issueMock.unlock).toBeCalledTimes(0); + }); + } }); diff --git a/__tests__/classes/comment.test.ts b/__tests__/classes/comment.test.ts index 713d8589..39849eca 100644 --- a/__tests__/classes/comment.test.ts +++ b/__tests__/classes/comment.test.ts @@ -1,15 +1,9 @@ import {context} from '@actions/github'; import {Context} from '@actions/github/lib/context'; -import { - IssuesEvent, - IssuesLabeledEvent, - PullRequestEvent, - PullRequestLabeledEvent -} from '@octokit/webhooks-types'; import {Comment} from '../../src/classes/comment'; -import {Locking, Action, IConfig, IConfigLoader} from '../../src/classes/config'; -import {RunContext, IContext} from '../../src/classes/context-loader'; +import {Locking, Action, Draft, IConfig, IConfigLoader} from '../../src/classes/config'; +import {Payload, RunContext, IContext} from '../../src/classes/context-loader'; import {Inputs} from '../../src/classes/inputs'; import {LockReason} from '../../src/classes/issue'; import {getDefaultInputs, cleanupEnvs} from '../../src/test-helper'; @@ -70,7 +64,9 @@ describe('getRawBody', () => { class ContextLoaderMock implements IContext { readonly inputs: Inputs; readonly context: Context; - readonly payload: IssuesEvent | IssuesLabeledEvent | PullRequestEvent | PullRequestLabeledEvent; + readonly payload: Payload; + + readonly id: string; readonly eventName: string; readonly eventType: string; readonly action: string; @@ -79,17 +75,16 @@ describe('getRawBody', () => { readonly userLogin: string; readonly senderLogin: string; readonly locked: boolean; + readonly runContext: RunContext; constructor(inputs: Inputs, context: Context) { try { this.inputs = inputs; this.context = context; - this.payload = context.payload as - | IssuesEvent - | IssuesLabeledEvent - | PullRequestEvent - | PullRequestLabeledEvent; + this.payload = context.payload as Payload; + + this.id = this.getId(); this.eventName = this.getEventName(); this.eventType = this.getEventType(); this.action = this.getAction(); @@ -98,6 +93,7 @@ describe('getRawBody', () => { this.userLogin = this.getUserLogin(); this.senderLogin = this.getSenderLogin(); this.locked = this.getLocked(); + this.runContext = this.getRunContext(); } catch (error) { throw new Error(error.message); @@ -110,6 +106,7 @@ describe('getRawBody', () => { getRunContext(): RunContext { const runContext: RunContext = { + Id: this.id, ConfigFilePath: this.inputs.ConfigFilePath, LabelName: this.labelName as string, LabelEvent: this.action, @@ -119,6 +116,10 @@ describe('getRawBody', () => { return runContext; } + getId(): string { + return 'MDExOlB1bGxSZXF1ZXN0NzA2MTE5NTg0'; + } + getEventName(): string { return 'issues'; } @@ -158,9 +159,10 @@ describe('getRawBody', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly config: any; readonly labelIndex: string; - readonly locking: Locking; readonly action: Action; + readonly locking: Locking; readonly lockReason: LockReason; + readonly draft: Draft; constructor(runContext: RunContext) { try { @@ -168,9 +170,10 @@ describe('getRawBody', () => { this.parentFieldName = `labels.${this.runContext.LabelName}.${this.runContext.LabelEvent}.${this.runContext.EventType}`; this.config = this.loadConfig(); this.labelIndex = this.getLabelIndex(); - this.locking = this.getLocking(); this.action = this.getAction(); + this.locking = this.getLocking(); this.lockReason = this.getLockReason(); + this.draft = this.getDraft(); } catch (error) { throw new Error(error.message); } @@ -211,6 +214,10 @@ describe('getRawBody', () => { getLockReason(): LockReason { return 'resolved'; } + + getDraft(): Draft { + return undefined; + } } test('isDebug is false', () => { @@ -264,7 +271,9 @@ describe('Mustache issues', () => { class ContextLoaderMock implements IContext { readonly inputs: Inputs; readonly context: Context; - readonly payload: IssuesEvent | IssuesLabeledEvent | PullRequestEvent | PullRequestLabeledEvent; + readonly payload: Payload; + + readonly id: string; readonly eventName: string; readonly eventType: string; readonly action: string; @@ -273,17 +282,16 @@ describe('Mustache issues', () => { readonly userLogin: string; readonly senderLogin: string; readonly locked: boolean; + readonly runContext: RunContext; constructor(inputs: Inputs, context: Context) { try { this.inputs = inputs; this.context = context; - this.payload = context.payload as - | IssuesEvent - | IssuesLabeledEvent - | PullRequestEvent - | PullRequestLabeledEvent; + this.payload = context.payload as Payload; + + this.id = this.getId(); this.eventName = this.getEventName(); this.eventType = this.getEventType(); this.action = this.getAction(); @@ -292,6 +300,7 @@ describe('Mustache issues', () => { this.userLogin = this.getUserLogin(); this.senderLogin = this.getSenderLogin(); this.locked = this.getLocked(); + this.runContext = this.getRunContext(); } catch (error) { throw new Error(error.message); @@ -304,6 +313,7 @@ describe('Mustache issues', () => { getRunContext(): RunContext { const runContext: RunContext = { + Id: this.id, ConfigFilePath: this.inputs.ConfigFilePath, LabelName: this.labelName as string, LabelEvent: this.action, @@ -313,6 +323,10 @@ describe('Mustache issues', () => { return runContext; } + getId(): string { + return 'MDExOlB1bGxSZXF1ZXN0NzA2MTE5NTg0'; + } + getEventName(): string { return 'issues'; } @@ -352,9 +366,10 @@ describe('Mustache issues', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly config: any; readonly labelIndex: string; - readonly locking: Locking; readonly action: Action; + readonly locking: Locking; readonly lockReason: LockReason; + readonly draft?: Draft; constructor(runContext: RunContext) { try { @@ -362,9 +377,10 @@ describe('Mustache issues', () => { this.parentFieldName = `labels.${this.runContext.LabelName}.${this.runContext.LabelEvent}.${this.runContext.EventType}`; this.config = this.loadConfig(); this.labelIndex = this.getLabelIndex(); - this.locking = this.getLocking(); this.action = this.getAction(); + this.locking = this.getLocking(); this.lockReason = this.getLockReason(); + this.draft = this.getDraft(); } catch (error) { throw new Error(error.message); } @@ -405,6 +421,10 @@ describe('Mustache issues', () => { getLockReason(): LockReason { return 'resolved'; } + + getDraft(): Draft { + return false; + } } test('invalid.labeled.issue', () => { @@ -433,7 +453,9 @@ describe('Mustache pull_request', () => { class ContextLoaderMock implements IContext { readonly inputs: Inputs; readonly context: Context; - readonly payload: IssuesEvent | IssuesLabeledEvent | PullRequestEvent | PullRequestLabeledEvent; + readonly payload: Payload; + + readonly id: string; readonly eventName: string; readonly eventType: string; readonly action: string; @@ -442,17 +464,16 @@ describe('Mustache pull_request', () => { readonly userLogin: string; readonly senderLogin: string; readonly locked: boolean; + readonly runContext: RunContext; constructor(inputs: Inputs, context: Context) { try { this.inputs = inputs; this.context = context; - this.payload = context.payload as - | IssuesEvent - | IssuesLabeledEvent - | PullRequestEvent - | PullRequestLabeledEvent; + this.payload = context.payload as Payload; + + this.id = this.getId(); this.eventName = this.getEventName(); this.eventType = this.getEventType(); this.action = this.getAction(); @@ -461,6 +482,7 @@ describe('Mustache pull_request', () => { this.userLogin = this.getUserLogin(); this.senderLogin = this.getSenderLogin(); this.locked = this.getLocked(); + this.runContext = this.getRunContext(); } catch (error) { throw new Error(error.message); @@ -473,6 +495,7 @@ describe('Mustache pull_request', () => { getRunContext(): RunContext { const runContext: RunContext = { + Id: this.id, ConfigFilePath: this.inputs.ConfigFilePath, LabelName: this.labelName as string, LabelEvent: this.action, @@ -482,6 +505,10 @@ describe('Mustache pull_request', () => { return runContext; } + getId(): string { + return 'MDExOlB1bGxSZXF1ZXN0NzA2MTE5NTg0'; + } + getEventName(): string { return 'pull_request'; } @@ -521,9 +548,10 @@ describe('Mustache pull_request', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly config: any; readonly labelIndex: string; - readonly locking: Locking; readonly action: Action; + readonly locking: Locking; readonly lockReason: LockReason; + readonly draft?: Draft; constructor(runContext: RunContext) { try { @@ -531,9 +559,10 @@ describe('Mustache pull_request', () => { this.parentFieldName = `labels.${this.runContext.LabelName}.${this.runContext.LabelEvent}.${this.runContext.EventType}`; this.config = this.loadConfig(); this.labelIndex = this.getLabelIndex(); - this.locking = this.getLocking(); this.action = this.getAction(); + this.locking = this.getLocking(); this.lockReason = this.getLockReason(); + this.draft = this.getDraft(); } catch (error) { throw new Error(error.message); } @@ -574,6 +603,10 @@ describe('Mustache pull_request', () => { getLockReason(): LockReason { return 'resolved'; } + + getDraft(): Draft { + return false; + } } test('invalid.labeled.pr', () => { @@ -602,7 +635,9 @@ describe('Mustache pull_request_target', () => { class ContextLoaderMock implements IContext { readonly inputs: Inputs; readonly context: Context; - readonly payload: IssuesEvent | IssuesLabeledEvent | PullRequestEvent | PullRequestLabeledEvent; + readonly payload: Payload; + + readonly id: string; readonly eventName: string; readonly eventType: string; readonly action: string; @@ -611,17 +646,16 @@ describe('Mustache pull_request_target', () => { readonly userLogin: string; readonly senderLogin: string; readonly locked: boolean; + readonly runContext: RunContext; constructor(inputs: Inputs, context: Context) { try { this.inputs = inputs; this.context = context; - this.payload = context.payload as - | IssuesEvent - | IssuesLabeledEvent - | PullRequestEvent - | PullRequestLabeledEvent; + this.payload = context.payload as Payload; + + this.id = this.getId(); this.eventName = this.getEventName(); this.eventType = this.getEventType(); this.action = this.getAction(); @@ -630,6 +664,7 @@ describe('Mustache pull_request_target', () => { this.userLogin = this.getUserLogin(); this.senderLogin = this.getSenderLogin(); this.locked = this.getLocked(); + this.runContext = this.getRunContext(); } catch (error) { throw new Error(error.message); @@ -642,6 +677,7 @@ describe('Mustache pull_request_target', () => { getRunContext(): RunContext { const runContext: RunContext = { + Id: this.id, ConfigFilePath: this.inputs.ConfigFilePath, LabelName: this.labelName as string, LabelEvent: this.action, @@ -651,6 +687,10 @@ describe('Mustache pull_request_target', () => { return runContext; } + getId(): string { + return 'MDExOlB1bGxSZXF1ZXN0NzA2MTE5NTg0'; + } + getEventName(): string { return 'pull_request_target'; } @@ -690,9 +730,10 @@ describe('Mustache pull_request_target', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly config: any; readonly labelIndex: string; - readonly locking: Locking; readonly action: Action; + readonly locking: Locking; readonly lockReason: LockReason; + readonly draft?: Draft; constructor(runContext: RunContext) { try { @@ -700,9 +741,10 @@ describe('Mustache pull_request_target', () => { this.parentFieldName = `labels.${this.runContext.LabelName}.${this.runContext.LabelEvent}.${this.runContext.EventType}`; this.config = this.loadConfig(); this.labelIndex = this.getLabelIndex(); - this.locking = this.getLocking(); this.action = this.getAction(); + this.locking = this.getLocking(); this.lockReason = this.getLockReason(); + this.draft = this.getDraft(); } catch (error) { throw new Error(error.message); } @@ -743,6 +785,10 @@ describe('Mustache pull_request_target', () => { getLockReason(): LockReason { return 'resolved'; } + + getDraft(): Draft { + return false; + } } test('invalid.labeled.pr', () => { diff --git a/package-lock.json b/package-lock.json index ac73e514..311d7269 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@actions/core": "^1.4.0", "@actions/github": "^5.0.0", + "@octokit/graphql": "^4.6.4", "@octokit/types": "^6.24.0", "@octokit/webhooks-types": "^4.3.0", "js-yaml": "^4.1.0", diff --git a/package.json b/package.json index 10083481..04ceec7c 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "postinstall": "npx husky install", "lint": "eslint --ext .ts .", "lint:fix": "eslint --ext .ts --fix .", - "test": "jest --config jest.config.json", - "build": "ncc build ./src/index.ts -o lib --minify", + "test": "env ACTIONS_LABEL_COMMENTER_TEST=true jest --config jest.config.json", + "build": "ncc build ./src/index.ts -o lib --minify --source-map", "tsc": "tsc --noEmit", "fmt": "prettier --write '**/*.ts'", "fmt:check": "prettier --check '**/*.ts'", @@ -48,6 +48,7 @@ "dependencies": { "@actions/core": "^1.4.0", "@actions/github": "^5.0.0", + "@octokit/graphql": "^4.6.4", "@octokit/types": "^6.24.0", "@octokit/webhooks-types": "^4.3.0", "js-yaml": "^4.1.0", diff --git a/scripts/pre-release.sh b/scripts/pre-release.sh index b7fb1ae6..9f6f5a76 100644 --- a/scripts/pre-release.sh +++ b/scripts/pre-release.sh @@ -7,5 +7,5 @@ NEXT_VERSION=$(git rev-parse HEAD) sed -i "s/Version: '.*'/Version: '${NEXT_VERSION}'/" ./src/constants.ts npm run build git checkout ./src/constants.ts -git add ./lib/index.js -git commit -m "chore: npm run build" +git add ./lib/* +git commit -m "chore(release): Add build assets" diff --git a/scripts/release.sh b/scripts/release.sh index 0ba787d4..604bdf70 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -53,7 +53,7 @@ sed -i "s/Version: '.*'/Version: '${NEXT_VERSION}'/" ./src/constants.ts mkdir ./lib npm run build -git add ./lib/index.js ./src/constants.ts +git add ./lib/* ./src/constants.ts git commit -m "chore(release): Add build assets" npm run release -- --release-as "${RELEASE_TYPE}" --preset eslint diff --git a/src/classes/action-processor.ts b/src/classes/action-processor.ts index 6f90475e..d7c72e1d 100644 --- a/src/classes/action-processor.ts +++ b/src/classes/action-processor.ts @@ -51,6 +51,14 @@ class ActionProcessor implements IActionProcessor { this.issue.setLocked(false); } + await this.updateState(); + + if (this.config.draft) { + await this.issue.convertPullRequestToDraft(); + } else if (this.config.draft === false) { + await this.issue.markPullRequestReadyForReview(); + } + if (this.issue.locked) { info(`Issue #${this.issue.number} is locked, skip creating comment`); } else if (!this.commentBody) { @@ -59,8 +67,6 @@ class ActionProcessor implements IActionProcessor { await this.issue.createComment(this.commentBody); } - await this.updateState(); - if (this.config.locking === 'lock') { await this.issue.lock(this.config.lockReason); this.issue.setLocked(true); diff --git a/src/classes/comment.ts b/src/classes/comment.ts index 6ef2bab3..bdc9d167 100644 --- a/src/classes/comment.ts +++ b/src/classes/comment.ts @@ -134,6 +134,7 @@ class Comment implements ICommentGenerator { } get render(): string { + if (!this.main) return ''; const renderedBody = Mustache.render(this.rawBody, this.view); groupConsoleLog('commentBodyRendered', renderedBody); return renderedBody; diff --git a/src/classes/config.ts b/src/classes/config.ts index b14d9c22..05348f5b 100644 --- a/src/classes/config.ts +++ b/src/classes/config.ts @@ -9,6 +9,7 @@ import {LockReason} from './issue'; type Locking = 'lock' | 'unlock' | undefined; type Action = 'close' | 'open' | undefined; +type Draft = boolean | undefined; interface IConfig { readonly parentFieldName: string; @@ -16,6 +17,7 @@ interface IConfig { readonly action: Action; readonly locking: Locking; readonly lockReason: LockReason; + readonly draft?: Draft; } interface IConfigLoader extends IConfig { @@ -42,6 +44,7 @@ class ConfigLoader implements IConfigLoader { readonly action: Action; readonly locking: Locking; readonly lockReason: LockReason; + readonly draft?: Draft; constructor(runContext: RunContext) { try { @@ -52,6 +55,7 @@ class ConfigLoader implements IConfigLoader { this.action = this.getAction(); this.locking = this.getLocking(); this.lockReason = this.getLockReason(); + this.draft = this.getDraft(); } catch (error) { throw new Error(error.message); } @@ -63,7 +67,8 @@ class ConfigLoader implements IConfigLoader { labelIndex: this.labelIndex, action: this.action, locking: this.locking, - lockReason: this.lockReason + lockReason: this.lockReason, + draft: this.draft }; return config; } @@ -117,6 +122,13 @@ class ConfigLoader implements IConfigLoader { `${this.runContext.LabelEvent}.${this.runContext.EventType}.lock_reason` ); } + + getDraft(): Draft { + return get( + this.config.labels[this.labelIndex as string], + `${this.runContext.LabelEvent}.${this.runContext.EventType}.draft` + ); + } } -export {Locking, Action, IConfig, IConfigLoader, ConfigLoader}; +export {Locking, Action, Draft, IConfig, IConfigLoader, ConfigLoader}; diff --git a/src/classes/context-loader.ts b/src/classes/context-loader.ts index 50c0bd9b..4583648b 100644 --- a/src/classes/context-loader.ts +++ b/src/classes/context-loader.ts @@ -1,15 +1,20 @@ import {Context} from '@actions/github/lib/context'; import { - IssuesEvent, IssuesLabeledEvent, - PullRequestEvent, - PullRequestLabeledEvent + IssuesUnlabeledEvent, + PullRequestLabeledEvent, + PullRequestUnlabeledEvent } from '@octokit/webhooks-types'; import {groupConsoleLog, info} from '../logger'; import {Inputs} from './inputs'; +type IssuePayload = IssuesLabeledEvent | IssuesUnlabeledEvent; +type PullRequestPayload = PullRequestLabeledEvent | PullRequestUnlabeledEvent; +type Payload = IssuePayload | PullRequestPayload; + interface RunContext { + readonly Id: string; readonly ConfigFilePath: string; readonly LabelName: string; readonly LabelEvent: string; @@ -20,7 +25,9 @@ interface RunContext { interface IContext { readonly inputs: Inputs; readonly context: Context; - readonly payload: IssuesEvent | IssuesLabeledEvent | PullRequestEvent | PullRequestLabeledEvent; + readonly payload: Payload; + + readonly id: string; readonly eventName: string; readonly eventType: string; readonly action: string; @@ -29,12 +36,14 @@ interface IContext { readonly userLogin: string; readonly senderLogin: string; readonly locked: boolean; + readonly runContext: RunContext; } interface IContextLoader extends IContext { dumpContext(): void; getRunContext(): RunContext; + getId(): string; getEventName(): string; getEventType(): string; getAction(): string; @@ -48,7 +57,9 @@ interface IContextLoader extends IContext { class ContextLoader implements IContextLoader { readonly inputs: Inputs; readonly context: Context; - readonly payload: IssuesEvent | IssuesLabeledEvent | PullRequestEvent | PullRequestLabeledEvent; + readonly payload: Payload; + + readonly id: string; readonly eventName: string; readonly eventType: string; readonly action: string; @@ -57,17 +68,16 @@ class ContextLoader implements IContextLoader { readonly userLogin: string; readonly senderLogin: string; readonly locked: boolean; + readonly runContext: RunContext; constructor(inputs: Inputs, context: Context) { try { this.inputs = inputs; this.context = context; - this.payload = context.payload as - | IssuesEvent - | IssuesLabeledEvent - | PullRequestEvent - | PullRequestLabeledEvent; + this.payload = context.payload as Payload; + + this.id = this.getId(); this.eventName = this.getEventName(); this.eventType = this.getEventType(); this.action = this.getAction(); @@ -76,8 +86,10 @@ class ContextLoader implements IContextLoader { this.userLogin = this.getUserLogin(); this.senderLogin = this.getSenderLogin(); this.locked = this.getLocked(); + this.runContext = this.getRunContext(); } catch (error) { + groupConsoleLog('Dump context', context); throw new Error(error.message); } } @@ -89,6 +101,7 @@ class ContextLoader implements IContextLoader { getRunContext(): RunContext { const runContext: RunContext = { + Id: this.id, ConfigFilePath: this.inputs.ConfigFilePath, LabelName: this.labelName as string, LabelEvent: this.action, @@ -99,6 +112,13 @@ class ContextLoader implements IContextLoader { return runContext; } + getId(): string { + if (this.eventName === 'issues') { + return (this.payload as IssuePayload).issue?.node_id; + } + return (this.payload as PullRequestPayload).pull_request?.node_id; + } + getEventName(): string { const eventName: string = this.context.eventName; info(`Event name: ${eventName}`); @@ -122,7 +142,6 @@ class ContextLoader implements IContextLoader { return 'issue'; } - // if (this.eventName === 'pull_request' || this.eventName === 'pull_request_target') return 'pr'; } @@ -132,43 +151,43 @@ class ContextLoader implements IContextLoader { getLabelName(): string | undefined { if (this.eventName === 'issues') { - return (this.payload as IssuesLabeledEvent).label?.name; + return (this.payload as IssuePayload).label?.name; } - // pull_request OR pull_request_target - return (this.payload as PullRequestLabeledEvent).label?.name; + + return (this.payload as PullRequestPayload).label?.name; } getIssueNumber(): number { if (this.eventName === 'issues') { - return (this.payload as IssuesEvent).issue.number; + return (this.payload as IssuePayload).issue.number; } - // pull_request OR pull_request_target - return (this.payload as PullRequestEvent).number; + + return (this.payload as PullRequestPayload).number; } getUserLogin(): string { if (this.eventName === 'issues') { - return (this.payload as IssuesEvent).issue.user.login; + return (this.payload as IssuePayload).issue.user.login; } - // pull_request OR pull_request_target - return (this.payload as PullRequestEvent).pull_request.user.login; + + return (this.payload as PullRequestPayload).pull_request.user.login; } getSenderLogin(): string { if (this.eventName === 'issues') { - return (this.payload as IssuesEvent).sender.login; + return (this.payload as IssuePayload).sender.login; } - // pull_request OR pull_request_target - return (this.payload as PullRequestEvent).sender.login; + + return (this.payload as PullRequestPayload).sender.login; } getLocked(): boolean { if (this.eventName === 'issues') { - return Boolean((this.payload as IssuesEvent).issue.locked); + return Boolean((this.payload as IssuePayload).issue.locked); } - // pull_request OR pull_request_target - return Boolean((this.payload as PullRequestEvent).pull_request.locked); + + return Boolean((this.payload as PullRequestPayload).pull_request.locked); } } -export {RunContext, IContext, ContextLoader}; +export {Payload, RunContext, IContext, ContextLoader}; diff --git a/src/classes/issue.ts b/src/classes/issue.ts index ad796e1c..87354d49 100644 --- a/src/classes/issue.ts +++ b/src/classes/issue.ts @@ -1,5 +1,7 @@ import {context} from '@actions/github'; import {GitHub} from '@actions/github/lib/utils'; +import type {GraphQlQueryResponseData} from '@octokit/graphql'; +import type {RequestParameters} from '@octokit/graphql/dist-types/types'; import {GetResponseTypeFromEndpointMethod} from '@octokit/types'; import {groupConsoleLog, info} from '../logger'; @@ -17,6 +19,7 @@ type LockReason = 'off-topic' | 'too heated' | 'resolved' | 'spam' | undefined; interface IIssue { readonly githubClient: InstanceType; + readonly id: string; readonly number: number; locked: boolean; } @@ -31,11 +34,18 @@ interface IIssueProcessor extends IIssue { class Issue implements IIssueProcessor { readonly githubClient: InstanceType; + readonly id: string; readonly number: number; locked: boolean; - constructor(githubClient: InstanceType, number: number, locked: boolean) { + constructor( + githubClient: InstanceType, + id: string, + number: number, + locked: boolean + ) { this.githubClient = githubClient; + this.id = id; this.number = number; this.locked = locked; } @@ -45,77 +55,147 @@ class Issue implements IIssueProcessor { } async createComment(body: string): Promise { - const ret: IssuesCreateCommentResponse = await this.githubClient.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: body - }); - - groupConsoleLog('IssuesCreateCommentResponse', ret); - - if (ret.status === 201) { - info(`New comment has been created in issue #${this.number}`); - info(`Comment URL: ${ret.data.html_url}`); - return; - } else { - throw new Error(`IssuesCreateCommentResponse.status: ${ret.status}`); + try { + const res: IssuesCreateCommentResponse = await this.githubClient.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + + groupConsoleLog('IssuesCreateCommentResponse', res); + + if (res.status === 201) { + info(`New comment has been created in issue #${this.number}`); + info(`Comment URL: ${res.data.html_url}`); + return; + } else { + throw new Error(`IssuesCreateCommentResponse.status: ${res.status}`); + } + } catch (error) { + throw new Error(error.message); } } async updateState(state: IssueState): Promise { - const ret: IssuesUpdateResponse = await this.githubClient.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: this.number, - state: state - }); - - groupConsoleLog('IssuesUpdateResponse', ret); - - if (ret.status === 200) { - if (state === 'closed') { - info(`Issue #${this.number} has been closed`); - return; + try { + const res: IssuesUpdateResponse = await this.githubClient.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: this.number, + state: state + }); + + groupConsoleLog('IssuesUpdateResponse', res); + + if (res.status === 200) { + if (state === 'closed') { + info(`Issue #${this.number} has been closed`); + return; + } + info(`Issue #${this.number} has been reopened`); + } else { + throw new Error(`IssuesUpdateResponse.status: ${res.status}`); } - info(`Issue #${this.number} has been reopened`); - } else { - throw new Error(`IssuesUpdateResponse.status: ${ret.status}`); + } catch (error) { + throw new Error(error.message); } } async lock(reason: LockReason): Promise { - const ret: IssuesLockResponse = await this.githubClient.rest.issues.lock({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: this.number, - lock_reason: reason || 'resolved' - }); - - groupConsoleLog('IssuesLockResponse', ret); - - if (ret.status === 204) { - info(`Issue #${this.number} has been locked`); - return; - } else { - throw new Error(`IssuesLockResponse.status: ${ret.status}`); + try { + const res: IssuesLockResponse = await this.githubClient.rest.issues.lock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: this.number, + lock_reason: reason || 'resolved' + }); + + groupConsoleLog('IssuesLockResponse', res); + + if (res.status === 204) { + info(`Issue #${this.number} has been locked`); + return; + } else { + throw new Error(`IssuesLockResponse.status: ${res.status}`); + } + } catch (error) { + throw new Error(error.message); } } async unlock(): Promise { - const ret: IssuesUnlockResponse = await this.githubClient.rest.issues.unlock({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: this.number - }); - - groupConsoleLog('IssuesUnlockResponse', ret); - - if (ret.status === 204) { - info(`Issue #${this.number} has been unlocked`); - return; - } else { - throw new Error(`IssuesUnlockResponse.status: ${ret.status}`); + try { + const res: IssuesUnlockResponse = await this.githubClient.rest.issues.unlock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: this.number + }); + + groupConsoleLog('IssuesUnlockResponse', res); + + if (res.status === 204) { + info(`Issue #${this.number} has been unlocked`); + return; + } else { + throw new Error(`IssuesUnlockResponse.status: ${res.status}`); + } + } catch (error) { + throw new Error(error.message); + } + } + + async markPullRequestReadyForReview(): Promise { + const query = ` + mutation MarkPullRequestReadyForReview($input: MarkPullRequestReadyForReviewInput!) { + __typename + markPullRequestReadyForReview(input: $input) { + pullRequest { + isDraft + } + } + } + `; + const variables: RequestParameters = { + input: { + pullRequestId: this.id + } + }; + + try { + const res: GraphQlQueryResponseData = await this.githubClient.graphql(query, variables); + info(`Pull-request #${this.number} has been marked as ready for review`); + groupConsoleLog('GraphQlQueryResponseData', res); + } catch (error) { + groupConsoleLog('Request failed', error.request); + throw new Error(error.message); + } + } + + async convertPullRequestToDraft(): Promise { + const query = ` + mutation ConvertPullRequestToDraft($input: ConvertPullRequestToDraftInput!) { + __typename + convertPullRequestToDraft(input: $input) { + pullRequest { + isDraft + } + } + } + `; + const variables: RequestParameters = { + input: { + pullRequestId: this.id + } + }; + + try { + const res: GraphQlQueryResponseData = await this.githubClient.graphql(query, variables); + info(`Pull-request #${this.number} has been converted to draft`); + groupConsoleLog('GraphQlQueryResponseData', res); + } catch (error) { + groupConsoleLog('Request failed', error.request); + throw new Error(error.message); } } } diff --git a/src/index.ts b/src/index.ts index 01744fdb..5843f27a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,13 @@ import {setFailed} from '@actions/core'; +import {groupConsoleLog} from './logger'; import {run} from './main'; (async (): Promise => { try { await run(); } catch (error) { + groupConsoleLog('Dump error.stack', error.stack); setFailed(`Action failed with error: ${error.message}`); } })(); diff --git a/src/logger.ts b/src/logger.ts index 6c6b521d..e45ba6e4 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,14 +1,17 @@ -import {isDebug, startGroup, endGroup, info as coreInfo} from '@actions/core'; +import {startGroup, endGroup, info as coreInfo} from '@actions/core'; + +const isTest = Boolean(process.env['ACTIONS_LABEL_COMMENTER_TEST']); // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any function groupConsoleLog(title: string, body: any): void { - if (!isDebug()) return; + if (isTest) return; startGroup(`${title}`); console.log(body); endGroup(); } function info(message: string): void { + if (isTest) return; coreInfo(`${message}`); } diff --git a/src/main.ts b/src/main.ts index 989f3fd1..0b7fb65e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,7 +21,12 @@ export async function run(): Promise { const configLoader = new ConfigLoader(contextLoader.runContext); const comment = new Comment(contextLoader, configLoader); comment.dumpComponents(); - const issue = new Issue(githubClient, contextLoader.issueNumber, contextLoader.locked); + const issue = new Issue( + githubClient, + contextLoader.runContext.Id, + contextLoader.issueNumber, + contextLoader.locked + ); const actionProcessor = new ActionProcessor(configLoader.getConfig(), comment.render, issue); await actionProcessor.process(); } catch (error) {