diff --git a/README.md b/README.md index bb99ead..537f531 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ This action scans PR description and comments for Trello card URL(s) or branch n - Assigns a PR author and fellow assignees to a Trello card. - And more... +You can also optionally use the action to create new Trello card by adding `/new-trello-card` in the PR description. + ## Basic configuration ```yaml @@ -51,6 +53,11 @@ github-include-pr-comments: true # DEFAULT: false github-include-pr-branch-name: false +# Creates a new Trello card from PR details if "/new-trello-card" is written in the PR description. +# Replaces "/new-trello-card" with the card link. +# DEFAULT: false +github-include-new-card-command: false + # Only matches Trello URLs prefixed with "Closes" etc. # Just like https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword # DEFAULT: false diff --git a/action.yml b/action.yml index 7ca451b..fa1fe6c 100644 --- a/action.yml +++ b/action.yml @@ -20,6 +20,9 @@ inputs: github-include-pr-branch-name: description: Include PR branch name when searching for Trello cards (e.g. "1234-card-title"). If card ID is found, it automatically comments card URL to the PR. default: false + github-include-new-card-command: + description: Creates a new Trello card from PR details if "/new-trello-card" is written in the PR description. Replaces "/new-trello-card" with the card link. + default: false github-users-to-trello-users: description: |- Newline-separated list of mapping between Github username and Trello username. Example: @@ -51,7 +54,7 @@ inputs: description: Position of the card after being moved to a list. Can be "top" or "bottom". default: 'top' trello-remove-unrelated-members: - description: Enable or disable the removal of unrelated users on Trello cards. + description: Removes card members who are not authors or assignees of the PR. default: true runs: using: node20 diff --git a/dist/index.js b/dist/index.js index 456dc00..de28a08 100644 --- a/dist/index.js +++ b/dist/index.js @@ -33154,7 +33154,7 @@ function wrappy (fn, cb) { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.createComment = exports.getBranchName = exports.getPullRequestAssignees = exports.getPullRequestComments = void 0; +exports.updatePullRequestBody = exports.createComment = exports.getBranchName = exports.getPullRequest = exports.getPullRequestComments = void 0; const core_1 = __nccwpck_require__(2186); const github_1 = __nccwpck_require__(5438); const githubToken = (0, core_1.getInput)('github-token', { required: true }); @@ -33171,15 +33171,15 @@ async function getPullRequestComments() { return response.data; } exports.getPullRequestComments = getPullRequestComments; -async function getPullRequestAssignees() { +async function getPullRequest() { const response = await octokit.rest.issues.get({ owner: repoOwner, repo: payload.repository.name, issue_number: issueNumber, }); - return [...(response.data.assignees || []), response.data.user]; + return response.data; } -exports.getPullRequestAssignees = getPullRequestAssignees; +exports.getPullRequest = getPullRequest; async function getBranchName() { const response = await octokit.rest.pulls.get({ owner: repoOwner, @@ -33190,6 +33190,7 @@ async function getBranchName() { } exports.getBranchName = getBranchName; async function createComment(shortUrl) { + console.log('Creating PR comment', shortUrl); await octokit.rest.issues.createComment({ owner: repoOwner, repo: payload.repository.name, @@ -33198,6 +33199,16 @@ async function createComment(shortUrl) { }); } exports.createComment = createComment; +async function updatePullRequestBody(newBody) { + console.log('Updating PR body', newBody); + await octokit.rest.issues.update({ + owner: repoOwner, + repo: payload.repository.name, + issue_number: issueNumber, + body: newBody, + }); +} +exports.updatePullRequestBody = updatePullRequestBody; /***/ }), @@ -33239,6 +33250,7 @@ const main_1 = __nccwpck_require__(399); githubRequireTrelloCard: core.getBooleanInput('github-require-trello-card'), githubIncludePrComments: core.getBooleanInput('github-include-pr-comments'), githubIncludePrBranchName: core.getBooleanInput('github-include-pr-branch-name'), + githubIncludeNewCardCommand: core.getBooleanInput('github-include-new-card-command'), githubUsersToTrelloUsers: core.getInput('github-users-to-trello-users'), trelloOrganizationName: core.getInput('trello-organization-name'), trelloListIdPrDraft: core.getInput('trello-list-id-pr-draft'), @@ -33266,14 +33278,13 @@ const trelloRequests_1 = __nccwpck_require__(777); async function run(pr, conf = {}) { try { const comments = await (0, githubRequests_1.getPullRequestComments)(); - const cardIds = await getCardIds(conf, pr.head, pr.body, comments); + const cardIds = await getCardIds(conf, pr, comments); if (cardIds.length) { - console.log('Found card IDs', cardIds); await moveCards(conf, cardIds, pr); await addPRLinkToCards(cardIds, pr.html_url || pr.url); - await addCardLinkToPR(conf, cardIds, pr.body, comments); - await updateCardMembers(conf, cardIds); + await addCardLinkToPR(conf, cardIds, pr, comments); await addLabelToCards(conf, cardIds, pr.head); + await updateCardMembers(conf, cardIds); } } catch (error) { @@ -33282,20 +33293,26 @@ async function run(pr, conf = {}) { } } exports.run = run; -async function getCardIds(conf, prHead, prBody = '', comments) { +async function getCardIds(conf, pr, comments) { console.log('Searching for card ids'); - let cardIds = matchCardIds(conf, prBody || ''); + let cardIds = matchCardIds(conf, pr.body || ''); if (conf.githubIncludePrComments) { for (const comment of comments) { cardIds = [...cardIds, ...matchCardIds(conf, comment.body)]; } } + const createdCardId = await createNewCard(conf, pr); + if (createdCardId) { + cardIds = [...cardIds, createdCardId]; + } if (cardIds.length) { + console.log('Found card IDs', cardIds); return [...new Set(cardIds)]; } if (conf.githubIncludePrBranchName) { - const cardId = await getCardIdFromBranch(prHead); + const cardId = await getCardIdFromBranch(pr.head); if (cardId) { + console.log('Found card ID from branch name'); return [cardId]; } } @@ -33322,6 +33339,20 @@ function matchCardIds(conf, text) { return cardIds; }))); } +async function createNewCard(conf, pr) { + if (!conf.githubIncludeNewCardCommand) { + return; + } + const isDraft = isDraftPr(pr); + const listId = pr.state === 'open' && isDraft ? conf.trelloListIdPrDraft : conf.trelloListIdPrOpen; + const commandRegex = /(^|\s)\/new-trello-card(\s|$)/; // Avoids matching URLs + if (listId && pr.body && commandRegex.test(pr.body)) { + const card = await (0, trelloRequests_1.createCard)(listId, pr.title, pr.body.replace('/new-trello-card', '')); + await (0, githubRequests_1.updatePullRequestBody)(pr.body.replace('/new-trello-card', card.url)); + return card.id; + } + return; +} async function getCardIdFromBranch(prHead) { console.log('Searching card from branch name'); const branchName = prHead?.ref || (await (0, githubRequests_1.getBranchName)()); @@ -33393,11 +33424,12 @@ async function addPRLinkToCards(cardIds, link) { return (0, trelloRequests_1.addAttachmentToCard)(cardId, link); })); } -async function addCardLinkToPR(conf, cardIds, prBody = '', comments = []) { +async function addCardLinkToPR(conf, cardIds, pr, comments = []) { if (!conf.githubIncludePrBranchName) { return; } - if (matchCardIds(conf, prBody || '')?.length) { + const pullRequest = conf.githubIncludeNewCardCommand ? await (0, githubRequests_1.getPullRequest)() : pr; + if (matchCardIds(conf, pullRequest.body || '')?.length) { console.log('Card is already linked in the PR description'); return; } @@ -33412,7 +33444,7 @@ async function addCardLinkToPR(conf, cardIds, prBody = '', comments = []) { await (0, githubRequests_1.createComment)(cardInfo.shortUrl); } async function updateCardMembers(conf, cardIds) { - const assignees = await (0, githubRequests_1.getPullRequestAssignees)(); + const assignees = await getPullRequestAssignees(); console.log('Starting to update card members'); if (!assignees?.length) { console.log('No PR assignees found'); @@ -33426,12 +33458,16 @@ async function updateCardMembers(conf, cardIds) { } return Promise.all(cardIds.map(async (cardId) => { const cardInfo = await (0, trelloRequests_1.getCardInfo)(cardId); + await addNewMembers(cardInfo, memberIds); if (conf.trelloRemoveUnrelatedMembers) { await removeUnrelatedMembers(cardInfo, memberIds); } - return addNewMembers(cardInfo, memberIds); })); } +async function getPullRequestAssignees() { + const pr = await (0, githubRequests_1.getPullRequest)(); + return pr ? [...(pr.assignees || []), pr.user] : []; +} async function getTrelloMemberId(conf, githubUserName) { let username = githubUserName?.replace('-', '_'); if (conf.githubUsersToTrelloUsers?.trim()) { @@ -33567,7 +33603,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.getMemberInfo = exports.moveCardToList = exports.removeMemberFromCard = exports.addLabelToCard = exports.getBoardLists = exports.getBoardLabels = exports.addMemberToCard = exports.addAttachmentToCard = exports.getCardAttachments = exports.getCardInfo = exports.searchTrelloCards = void 0; +exports.createCard = exports.getMemberInfo = exports.moveCardToList = exports.removeMemberFromCard = exports.addLabelToCard = exports.getBoardLists = exports.getBoardLabels = exports.addMemberToCard = exports.addAttachmentToCard = exports.getCardAttachments = exports.getCardInfo = exports.searchTrelloCards = void 0; const axios_1 = __importDefault(__nccwpck_require__(8757)); const core = __importStar(__nccwpck_require__(2186)); const trelloApiKey = core.getInput('trello-api-key', { required: true }); @@ -33643,6 +33679,17 @@ async function getMemberInfo(username) { return response?.data; } exports.getMemberInfo = getMemberInfo; +async function createCard(listId, title, body) { + console.log('Creating card based on PR info', title, body); + const response = await makeRequest('post', `https://api.trello.com/1/cards`, { + idList: listId, + name: title, + desc: body, + pos: trelloCardPosition, + }); + return response?.data; +} +exports.createCard = createCard; async function makeRequest(method, url, params) { try { let response; diff --git a/src/githubRequests.ts b/src/githubRequests.ts index 35c5742..a1490fc 100644 --- a/src/githubRequests.ts +++ b/src/githubRequests.ts @@ -18,14 +18,14 @@ export async function getPullRequestComments() { return response.data } -export async function getPullRequestAssignees() { +export async function getPullRequest() { const response = await octokit.rest.issues.get({ owner: repoOwner, repo: payload.repository!.name, issue_number: issueNumber!, }) - return [...(response.data.assignees || []), response.data.user] + return response.data } export async function getBranchName() { @@ -39,6 +39,8 @@ export async function getBranchName() { } export async function createComment(shortUrl: string) { + console.log('Creating PR comment', shortUrl) + await octokit.rest.issues.createComment({ owner: repoOwner, repo: payload.repository!.name, @@ -46,3 +48,14 @@ export async function createComment(shortUrl: string) { body: shortUrl, }) } + +export async function updatePullRequestBody(newBody: string) { + console.log('Updating PR body', newBody) + + await octokit.rest.issues.update({ + owner: repoOwner, + repo: payload.repository!.name, + issue_number: issueNumber!, + body: newBody, + }) +} diff --git a/src/index.ts b/src/index.ts index 2d08bb5..b534a9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ run((context.payload.pull_request || context.payload.issue) as PR, { githubRequireTrelloCard: core.getBooleanInput('github-require-trello-card'), githubIncludePrComments: core.getBooleanInput('github-include-pr-comments'), githubIncludePrBranchName: core.getBooleanInput('github-include-pr-branch-name'), + githubIncludeNewCardCommand: core.getBooleanInput('github-include-new-card-command'), githubUsersToTrelloUsers: core.getInput('github-users-to-trello-users'), trelloOrganizationName: core.getInput('trello-organization-name'), trelloListIdPrDraft: core.getInput('trello-list-id-pr-draft'), diff --git a/src/main.test.ts b/src/main.test.ts index a21ab78..d226b27 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -12,15 +12,22 @@ import { getBoardLabels, addLabelToCard, getBoardLists, + createCard, } from './trelloRequests' -import { getPullRequestComments, getBranchName, createComment, getPullRequestAssignees } from './githubRequests' +import { + getPullRequestComments, + getBranchName, + createComment, + getPullRequest, + updatePullRequestBody, +} from './githubRequests' jest.mock('@actions/core') jest.mock('@actions/github') jest.mock('./githubRequests') jest.mock('./trelloRequests') -const getPullRequestAssigneesMock = getPullRequestAssignees as jest.Mock +const getPullRequestMock = getPullRequest as jest.Mock const getMemberInfoMock = getMemberInfo as jest.Mock const getCardInfoMock = getCardInfo as jest.Mock const getPullRequestCommentsMock = getPullRequestComments as jest.Mock @@ -29,6 +36,7 @@ const searchTrelloCardsMock = searchTrelloCards as jest.Mock const getCardAttachmentsMock = getCardAttachments as jest.Mock const getBoardLabelsMock = getBoardLabels as jest.Mock const getBoardListsMock = getBoardLists as jest.Mock +const createCardMock = createCard as jest.Mock const basePR = { number: 0, state: 'open', title: 'Title' } @@ -87,6 +95,36 @@ describe('Finding cards', () => { }) }) +describe('Creating new card', () => { + const pr = { ...basePR, body: '/new-trello-card Description' } + const conf = { trelloListIdPrOpen: 'open-list-id', githubIncludeNewCardCommand: true } + + it('adds new card, updates PR body and adds to card ids list', async () => { + createCardMock.mockResolvedValueOnce({ id: 'card-id', url: 'card-url' }) + + await run(pr, conf) + + expect(createCard).toHaveBeenCalledWith('open-list-id', 'Title', ' Description') + expect(updatePullRequestBody).toHaveBeenCalledWith('card-url Description') + expect(moveCardToList).toHaveBeenCalledWith('card-id', 'open-list-id', undefined) + }) + + it('skips when no command found', async () => { + await run({ ...pr, body: '' }, conf) + expect(createCard).not.toHaveBeenCalled() + }) + + it('skips when list is missing', async () => { + await run(pr, { ...conf, trelloListIdPrOpen: '' }) + expect(createCard).not.toHaveBeenCalled() + }) + + it('skips when turned off', async () => { + await run(pr, { ...conf, githubIncludeNewCardCommand: false }) + expect(createCard).not.toHaveBeenCalled() + }) +}) + describe('Moving cards', () => { describe('PR is added to draft', () => { const pr = { ...basePR, body: 'https://trello.com/c/card/title' } @@ -222,7 +260,7 @@ describe('Updating card members', () => { const conf = { githubUsersToTrelloUsers: 'jack: jones\namy: amy1993', trelloRemoveUnrelatedMembers: true } it('adds PR author and assignees to the card and removes unrelated members', async () => { - getPullRequestAssigneesMock.mockResolvedValueOnce([{ login: 'phil' }, { login: 'amy' }]) + getPullRequestMock.mockResolvedValueOnce({ user: { login: 'phil' }, assignees: [{ login: 'amy' }] }) getMemberInfoMock.mockImplementation((username) => username === 'amy1993' ? { id: 'amy-id' } : { id: 'phil-id' }, ) @@ -236,7 +274,7 @@ describe('Updating card members', () => { }) it('skips removing unrelated members when turned off', async () => { - getPullRequestAssigneesMock.mockResolvedValueOnce([{ login: 'phil' }]) + getPullRequestMock.mockResolvedValueOnce({ user: { login: 'phil' } }) getMemberInfoMock.mockResolvedValueOnce({ id: 'phil-id' }) getCardInfoMock.mockResolvedValueOnce({ id: 'card', idMembers: ['jones-id'] }) @@ -246,7 +284,7 @@ describe('Updating card members', () => { }) it('skips adding when all members are already assigned to the card', async () => { - getPullRequestAssigneesMock.mockResolvedValueOnce([{ login: 'phil' }]) + getPullRequestMock.mockResolvedValueOnce({ user: { login: 'phil' } }) getMemberInfoMock.mockResolvedValueOnce({ id: 'phil-id' }) getCardInfoMock.mockResolvedValueOnce({ id: 'card', idMembers: ['phil-id'] }) @@ -256,7 +294,7 @@ describe('Updating card members', () => { }) it('skips adding when member not found with GitHub username', async () => { - getPullRequestAssigneesMock.mockResolvedValueOnce([{ login: 'phil' }]) + getPullRequestMock.mockResolvedValueOnce({ user: { login: 'phil' } }) getMemberInfoMock.mockResolvedValue(undefined) await run(pr) diff --git a/src/main.ts b/src/main.ts index e24bb7a..b0d728f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,16 @@ import { setFailed } from '@actions/core' -import { createComment, getBranchName, getPullRequestAssignees, getPullRequestComments } from './githubRequests' +import { + createComment, + getBranchName, + getPullRequest, + getPullRequestComments, + updatePullRequestBody, +} from './githubRequests' import { addAttachmentToCard, addLabelToCard, addMemberToCard, + createCard, getBoardLabels, getBoardLists, getCardAttachments, @@ -18,16 +25,14 @@ import { BoardLabel, Conf, PR, PRHead } from './types' export async function run(pr: PR, conf: Conf = {}) { try { const comments = await getPullRequestComments() - const cardIds = await getCardIds(conf, pr.head, pr.body, comments) + const cardIds = await getCardIds(conf, pr, comments) if (cardIds.length) { - console.log('Found card IDs', cardIds) - await moveCards(conf, cardIds, pr) await addPRLinkToCards(cardIds, pr.html_url || pr.url) - await addCardLinkToPR(conf, cardIds, pr.body, comments) - await updateCardMembers(conf, cardIds) + await addCardLinkToPR(conf, cardIds, pr, comments) await addLabelToCards(conf, cardIds, pr.head) + await updateCardMembers(conf, cardIds) } } catch (error: any) { setFailed(error) @@ -35,10 +40,10 @@ export async function run(pr: PR, conf: Conf = {}) { } } -async function getCardIds(conf: Conf, prHead: PRHead, prBody: string = '', comments: { body?: string }[]) { +async function getCardIds(conf: Conf, pr: PR, comments: { body?: string }[]) { console.log('Searching for card ids') - let cardIds = matchCardIds(conf, prBody || '') + let cardIds = matchCardIds(conf, pr.body || '') if (conf.githubIncludePrComments) { for (const comment of comments) { @@ -46,14 +51,23 @@ async function getCardIds(conf: Conf, prHead: PRHead, prBody: string = '', comme } } + const createdCardId = await createNewCard(conf, pr) + if (createdCardId) { + cardIds = [...cardIds, createdCardId] + } + if (cardIds.length) { + console.log('Found card IDs', cardIds) + return [...new Set(cardIds)] } if (conf.githubIncludePrBranchName) { - const cardId = await getCardIdFromBranch(prHead) + const cardId = await getCardIdFromBranch(pr.head) if (cardId) { + console.log('Found card ID from branch name') + return [cardId] } } @@ -92,6 +106,24 @@ function matchCardIds(conf: Conf, text?: string) { ) } +async function createNewCard(conf: Conf, pr: PR) { + if (!conf.githubIncludeNewCardCommand) { + return + } + const isDraft = isDraftPr(pr) + const listId = pr.state === 'open' && isDraft ? conf.trelloListIdPrDraft : conf.trelloListIdPrOpen + const commandRegex = /(^|\s)\/new-trello-card(\s|$)/ // Avoids matching URLs + + if (listId && pr.body && commandRegex.test(pr.body)) { + const card = await createCard(listId, pr.title, pr.body.replace('/new-trello-card', '')) + await updatePullRequestBody(pr.body.replace('/new-trello-card', card.url)) + + return card.id + } + + return +} + async function getCardIdFromBranch(prHead?: PRHead) { console.log('Searching card from branch name') @@ -185,12 +217,13 @@ async function addPRLinkToCards(cardIds: string[], link: string) { ) } -async function addCardLinkToPR(conf: Conf, cardIds: string[], prBody: string = '', comments: { body?: string }[] = []) { +async function addCardLinkToPR(conf: Conf, cardIds: string[], pr: PR, comments: { body?: string }[] = []) { if (!conf.githubIncludePrBranchName) { return } + const pullRequest = conf.githubIncludeNewCardCommand ? await getPullRequest() : pr - if (matchCardIds(conf, prBody || '')?.length) { + if (matchCardIds(conf, pullRequest.body || '')?.length) { console.log('Card is already linked in the PR description') return @@ -233,17 +266,24 @@ async function updateCardMembers(conf: Conf, cardIds: string[]) { cardIds.map(async (cardId) => { const cardInfo = await getCardInfo(cardId) + await addNewMembers(cardInfo, memberIds) + if (conf.trelloRemoveUnrelatedMembers) { await removeUnrelatedMembers(cardInfo, memberIds) } - - return addNewMembers(cardInfo, memberIds) }), ) } +async function getPullRequestAssignees() { + const pr = await getPullRequest() + + return pr ? [...(pr.assignees || []), pr.user] : [] +} + async function getTrelloMemberId(conf: Conf, githubUserName?: string) { let username = githubUserName?.replace('-', '_') + if (conf.githubUsersToTrelloUsers?.trim()) { username = getTrelloUsernameFromInputMap(conf, githubUserName) || username } diff --git a/src/trelloRequests.ts b/src/trelloRequests.ts index 67a3d3b..2343a35 100644 --- a/src/trelloRequests.ts +++ b/src/trelloRequests.ts @@ -91,6 +91,19 @@ export async function getMemberInfo(username?: string): Promise<{ id: string; or return response?.data } +export async function createCard(listId: string, title: string, body?: string): Promise<{ id: string; url: string }> { + console.log('Creating card based on PR info', title, body) + + const response = await makeRequest('post', `https://api.trello.com/1/cards`, { + idList: listId, + name: title, + desc: body, + pos: trelloCardPosition, + }) + + return response?.data +} + async function makeRequest(method: 'get' | 'put' | 'post' | 'delete', url: string, params?: Record) { try { let response diff --git a/src/types.ts b/src/types.ts index c04448a..74a66a4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ export interface Conf { githubRequireTrelloCard?: boolean githubIncludePrComments?: boolean githubIncludePrBranchName?: boolean + githubIncludeNewCardCommand?: boolean githubRequireKeywordPrefix?: boolean githubUsersToTrelloUsers?: string trelloListIdPrDraft?: string