diff --git a/create-or-update-issue/README.md b/create-or-update-issue/README.md new file mode 100644 index 00000000..7624a77a --- /dev/null +++ b/create-or-update-issue/README.md @@ -0,0 +1,24 @@ +# Create Or Update Issue + +An action to create or update an issue in a repository. +It supports posting a comment under an existing issue with the same title or +closing it based on the outcome of a previous step. + +## Usage + +```yaml +- uses: Homebrew/actions/create-or-update-issue@master + with: + token: ${{ github.token }} # defaults to this + repository: ${{ github.repository }} # defaults to this + title: Issue title + body: Issue body + labels: label1,label2 # optional + assignees: user1,user2 # optional + # If true: post `body` as a comment under the issue with the same title, if + # such an issue is found; otherwise, create a new issue. + update-existing: ${{ steps..conclusion == 'failure' }} + # If true: close an existing issue with the same title as completed, if such + # an issue is found; otherwise, do nothing. + close-existing: ${{ steps..conclusion == 'success' }} +``` diff --git a/create-or-update-issue/action.yml b/create-or-update-issue/action.yml new file mode 100644 index 00000000..a86fab4e --- /dev/null +++ b/create-or-update-issue/action.yml @@ -0,0 +1,56 @@ +name: Create or update issue +description: Create or update an issue in a repository +author: ZhongRuoyu +branding: + icon: alert-circle + color: blue +inputs: + token: + description: GitHub token + required: false + default: ${{ github.token }} + repository: + description: Repository to create or update the issue in + required: false + default: ${{ github.repository }} + title: + description: The title of the issue + required: true + body: + description: The body of the issue + required: true + labels: + description: Comma-separated list of labels to add to the issue + required: false + assignees: + description: Comma-separated list of users to assign the issue to + required: false + update-existing: + description: > + Whether to post `body` as a comment under the issue with the same title, + if such an issue is found; otherwise, create a new issue + required: false + default: "false" + close-existing: + description: > + Whether to close an existing issue with the same title as completed, if + such an issue is found; otherwise, do nothing. + NOTE: if set to `true`, no new issue will be created! + required: false + default: "false" +outputs: + outcome: + description: > + One of `created`, `commented`, `closed`, or `none`; indicates the action + taken + issue_number: + description: > + The number of the created, updated, or closed issue; undefined if + `outcome` is `none` + node_id: + description: > + The node ID of the created or updated issue, used in GitHub GraphQL API + queries; undefined if `outcome` is `none` +runs: + using: node20 + main: main.mjs diff --git a/create-or-update-issue/main.mjs b/create-or-update-issue/main.mjs new file mode 100644 index 00000000..b9607276 --- /dev/null +++ b/create-or-update-issue/main.mjs @@ -0,0 +1,106 @@ +import core from "@actions/core"; +import github from "@actions/github"; + +async function main() { + try { + const token = core.getInput("token", { required: true }); + const [owner, repo] = + core.getInput("repository", { required: true }).split("/"); + + const title = core.getInput("title", { required: true }); + const body = core.getInput("body", { required: true }); + + const labelsInput = core.getInput("labels"); + const labels = labelsInput ? labelsInput.split(",") : []; + const assigneesInput = core.getInput("assignees"); + const assignees = assigneesInput ? assigneesInput.split(",") : []; + + const updateExisting = core.getBooleanInput("update-existing"); + const closeExisting = core.getBooleanInput("close-existing"); + + const client = github.getOctokit(token); + + let existingIssue = undefined; + if (updateExisting || closeExisting) { + for await (const response of client.paginate.iterator( + client.rest.issues.listForRepo, + { + owner, + repo, + state: "open", + sort: "created", + direction: "desc", + per_page: 100, + } + )) { + existingIssue = response.data.find((issue) => issue.title === title); + if (existingIssue) { + break; + } + } + } + if (existingIssue) { + if (updateExisting) { + const response = await client.rest.issues.createComment({ + owner, + repo, + issue_number: existingIssue.number, + body, + }); + const commentUrl = response.data.html_url; + + core.info(`Posted comment under existing issue: ${commentUrl}`); + + core.setOutput("outcome", "commented"); + core.setOutput("number", existingIssue.number); + core.setOutput("node_id", existingIssue.node_id); + return; + } + if (closeExisting) { + const response = await client.rest.issues.update({ + owner, + repo, + issue_number: existingIssue.number, + state: "closed", + state_reason: "completed", + }); + const issueUrl = response.data.html_url; + + core.info(`Closed existing issue as completed: ${issueUrl}`); + + core.setOutput("outcome", "closed"); + core.setOutput("number", existingIssue.number); + core.setOutput("node_id", existingIssue.node_id); + return; + } + } + + if (closeExisting) { + core.info("No existing issue found."); + core.setOutput("outcome", "none"); + return; + } + + const response = await client.rest.issues.create({ + owner, + repo, + title, + body, + labels, + assignees, + }); + const issueNumber = response.data.number; + const issueNodeId = response.data.node_id; + const issueUrl = response.data.html_url; + + core.info(`Issue created: ${issueUrl}`); + + core.setOutput("outcome", "created"); + core.setOutput("number", issueNumber); + core.setOutput("node_id", issueNodeId); + } catch (error) { + core.setFailed(error); + } +} + +await main(); diff --git a/create-or-update-issue/main.test.mjs b/create-or-update-issue/main.test.mjs new file mode 100644 index 00000000..1790472d --- /dev/null +++ b/create-or-update-issue/main.test.mjs @@ -0,0 +1,136 @@ +import util from "node:util"; + +describe("create-issue", async () => { + const token = "fake-token"; + const title = "Issue title"; + const body = "Issue body.\nLorem ipsum dolor sit amet."; + const labels = "label1,label2"; + const assignees = "assignee1,assignee2"; + + const issueNumber = 12345; + + beforeEach(async () => { + mockInput("token", token); + mockInput("repository", GITHUB_REPOSITORY); + mockInput("title", title); + mockInput("body", body); + mockInput("labels", labels); + mockInput("assignees", assignees); + }); + + it("creates an issue", async () => { + mockInput("update-existing", "false"); + mockInput("close-existing", "false"); + + const mockPool = githubMockPool(); + + mockPool.intercept({ + method: "POST", + path: `/repos/${GITHUB_REPOSITORY}/issues`, + headers: { + Authorization: `token ${token}`, + }, + body: (htmlBody) => util.isDeepStrictEqual(JSON.parse(htmlBody), { + title, + body, + labels: labels.split(","), + assignees: assignees.split(","), + }), + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, { + number: issueNumber, + }); + + await loadMain(); + }); + + it("for advanced use case with `close-existing: true`", async () => { + mockInput("update-existing", "true"); + mockInput("close-existing", "false"); + + const mockPool = githubMockPool(); + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/issues?` + + `direction=desc&per_page=100&sort=created&state=open`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, [ + { + title: "Not the same issue", + number: 54321, + }, + { + title, + number: issueNumber, + }, + ]); + + mockPool.intercept({ + method: "POST", + path: `/repos/${GITHUB_REPOSITORY}/issues/${issueNumber}/comments`, + headers: { + Authorization: `token ${token}`, + }, + body: (htmlBody) => util.isDeepStrictEqual(JSON.parse(htmlBody), { + body, + }), + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, { + html_url: "https://github.com/owner/repo/issues/12345#issuecomment-67890", + }); + + await loadMain(); + }); + + it("for advanced use case with `close-existing: true`", async () => { + mockInput("update-existing", "false"); + mockInput("close-existing", "true"); + + const mockPool = githubMockPool(); + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/issues?` + + `direction=desc&per_page=100&sort=created&state=open`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, [ + { + title: "Not the same issue", + number: 54321, + }, + { + title, + number: issueNumber, + }, + ]); + + mockPool.intercept({ + method: "PATCH", + path: `/repos/${GITHUB_REPOSITORY}/issues/${issueNumber}`, + headers: { + Authorization: `token ${token}`, + }, + body: (htmlBody) => util.isDeepStrictEqual(JSON.parse(htmlBody), { + state: "closed", + state_reason: "completed", + }), + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, { + html_url: "https://github.com/owner/repo/issues/12345#issuecomment-67890", + }); + + await loadMain(); + }); +});