From 1d0f64546742c2fbc8fbfdec49c85a57baf1811d Mon Sep 17 00:00:00 2001 From: Noah Koontz Date: Mon, 10 Aug 2020 12:33:59 -0700 Subject: [PATCH] feat: start creating issue management lib, need to add tests --- src/createorUpdateIssue.ts | 241 +++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 src/createorUpdateIssue.ts diff --git a/src/createorUpdateIssue.ts b/src/createorUpdateIssue.ts new file mode 100644 index 0000000..00ab14d --- /dev/null +++ b/src/createorUpdateIssue.ts @@ -0,0 +1,241 @@ +import * as core from '@actions/core' +import { + RequestError, + IssuesListForRepoResponseData, + IssuesCreateResponseData, + IssuesUpdateResponseData +} from '@octokit/types' +import Octokit from './getOctokit' + +export interface CreateIssueOpts { + owner: string + repo: string + issueName: string + issueContent?: string + issueAssignee?: string + labelName: string + labelColor: string + shouldClose?: boolean + forceCreateIssue?: boolean +} + +type Octo = InstanceType + +/** + * @brief Create or update a single up-to-date issue wit the latest output + * from the repolinter action. + * + * This function exists to limit the number of issues created by this + * action to the fewest possible, instead opting to quietly update the + * content of the existing issue (if one is present). This function + * uses a specific label (options.labelName) as well as verifying that + * the issue was created by the user this action is impersonating (usually + * github-actions-bot). + * + * @note options.labelName should be a label that is unique to the repolinter action, otherwise + * there is a small chance this function may attempt to edit other people's issues. + * + * @param options.owner The owner of the repository to create an issue on + * @param options.repo The repository to create the issue on + * @param options.issueContent The text content to use for the issue body (ex. the markdown output of repolinter). + * @param options.issueName The name to use for this issue + * @param options.issueAssignee The username to assign this issue to, falsey for no one. + * @param options.labelName The name of the label to use to track issues opened by this bot. + * @param options.labelColor The color to use when creating this label (this value will be ignored if the label already exists). + * Should be a hex string with no prefix (ex. "ff2a63"). + * @param options.shouldClose Set this to true to close the issue. If this value is true and + * no issue exists, this function will do nothing. + * @param options.forceCreateIssue Set to truthy to always create a new issue, instead of editing the old one. The old issue + * will automatically be closed if found. + * @returns The issue number of the created issue, or null if no issue was created. + */ +export default async function createOrUpdateIssue( + client: Octo, + options: CreateIssueOpts +): Promise { + // error check + if (options.forceCreateIssue && options.shouldClose) + throw new Error(`Both forceCreateIssue and shouldClose cannot be set!`) + // get the current username + const context = await client.users.getAuthenticated() + // attempt to find an issue created by Repolinter + const issue = await findRepolinterIssue( + client, + Object.assign(options, {selfUsername: context.data.name}) + ) + // if no issue exists and we should close the issue, exit and do nothing + if (!issue && options.shouldClose) { + core.debug(`No issue was found and shouldClose is set, doing nothing.`) + return null + } + let res + if (!issue || options.forceCreateIssue) { + // if an old issue is present, close it + if (issue) + await updateRepolinterIssue(client, { + issueNumber: issue.number, + owner: options.owner, + repo: options.repo, + shouldClose: true + }) + // create a new issue + res = await createRepolinterIssue(client, options) + core.info(`Created issue #${res.number}`) + } else { + // update the existing issue + res = await updateRepolinterIssue( + client, + Object.assign(options, {issueNumber: issue.number}) + ) + core.info( + options.shouldClose + ? `Closed issue #${res.number}` + : `Updated issue #${res.number}` + ) + } + return res.number +} + +interface FindRepolinterIssueOpts { + owner: string + repo: string + labelName: string + selfUsername: string +} + +/** + * Find the issue corresponding to this repolinter action instance, if + * such an issue exists. If more than one issue matching the criteria + * is found, the issue that was created soonest will be returned. + * + * @param client The authenticated octokit client to use + * @param options.owner The owner of the repository to search + * @param options.repo The name of the repository to search + * @param labelName The label to filter repolinter issues by + * @param selfUsername The current username of this octokit client. + * Only issues created by this username will be enumerated. + * @returns The issue data found, or null if no issue was found. + */ +async function findRepolinterIssue( + client: Octo, + options: FindRepolinterIssueOpts +): Promise { + // get the list of open issues on this repository + const issues = await client.issues.listForRepo({ + owner: options.owner, + repo: options.repo, + state: 'open', + creator: options.selfUsername, + labels: options.labelName, + sort: 'created', + direction: 'desc' + }) + // return none if there's no issue + if (issues.data.length === 0) return null + // omit a warning if there's more than one issue here + if (issues.data.length > 1) + core.warning( + `Found more than one matching open issue: ${issues.data + .map(i => `#${i.number}`) + .join(', ')}. Defaulting to the most recent.` + ) + // return the issue data! + return issues.data[0] +} + +interface CreateRepolinterIssueOpts { + owner: string + repo: string + issueName: string + issueContent?: string + issueAssignee?: string + labelName: string + labelColor: string +} + +/** + * Creates a label if one doesn't exists, then creates an issue + * with that label and the specified content, assignee, and so on. + * + * + * @param client The authenticated octokit client to use + * @param options.owner The owner of the repository the the issue will be created on + * @param options.repo The name of the repository that the issue will be created on + * @param options.issueName The title to use for the issue + * @param options.issueContent The body of the issue, formatted as markdown (optional) + * @param options.issueAssignee The username of the person to assign this issue to (optional) + * @param options.labelName The name of the label to create/assign to this issue + * @param options.labelColor The color to use when creating the label. This value will be ignored + * if the label already exists. + */ +async function createRepolinterIssue( + client: Octo, + options: CreateRepolinterIssueOpts +): Promise { + // create the label, if it doesn't exist + try { + await client.issues.getLabel({ + owner: options.owner, + repo: options.repo, + name: options.labelName + }) + } catch (err) { + if ((err as RequestError).status === 404) { + core.debug(`Creating label ${options.labelName}`) + await client.issues.createLabel({ + owner: options.owner, + repo: options.repo, + name: options.labelName, + color: options.labelColor + }) + } + } + core.debug(`Creating issue "${options.issueName}"...`) + // create the issue + const issue = await client.issues.create({ + owner: options.owner, + repo: options.repo, + title: options.issueName, + body: options.issueContent, + labels: [options.labelName], + assignee: options.issueAssignee + }) + core.debug(`Successfully created issue #${issue.data.number}`) + return issue.data +} + +interface UpdateReplolinterIssueOpts { + repo: string + owner: string + issueNumber: number + issueContent?: string + shouldClose?: boolean +} + +/** + * Replace the body of a given issue with the specified value, + * then close the issue if needed. + * + * @param client The authenticated Octokit client + * @param options.owner The owner of the repository to update the issue on + * @param options.repo The name of the repository to update the issue on + * @param options.issueNumber The issue number to update (ex. #2, different from the ID) + * @param options.issueContent The body to update the issue with, formatted as markdown + * @param options.shouldClose Set this to true to close the issue, otherwise the issue + * state will remain unchanged. + */ +async function updateRepolinterIssue( + client: Octo, + options: UpdateReplolinterIssueOpts +): Promise { + // replace the issue body with the new one + // we may choose to add a comment later but we can just update the body for now + const res = await client.issues.update({ + owner: options.owner, + repo: options.repo, + issue_number: options.issueNumber, + body: options.issueContent, + state: options.shouldClose ? 'closed' : undefined + }) + return res.data +}