Skip to content

Commit

Permalink
create-or-update-issue: add action
Browse files Browse the repository at this point in the history
Related: Homebrew/homebrew-core#190657 (comment)

Co-authored-by: Sean Molenaar <[email protected]>
Co-authored-by: Mike McQuaid <[email protected]>
  • Loading branch information
3 people committed Sep 20, 2024
1 parent f3fe7e5 commit 42a7fa0
Show file tree
Hide file tree
Showing 4 changed files with 317 additions and 0 deletions.
24 changes: 24 additions & 0 deletions create-or-update-issue/README.md
Original file line number Diff line number Diff line change
@@ -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-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.<step-id>.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.<step-id>.conclusion == 'success' }}
```
56 changes: 56 additions & 0 deletions create-or-update-issue/action.yml
Original file line number Diff line number Diff line change
@@ -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
106 changes: 106 additions & 0 deletions create-or-update-issue/main.mjs
Original file line number Diff line number Diff line change
@@ -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.getInput("update-existing") === "true";
const closeExisting = core.getInput("close-existing") === "true";

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;
}

Check warning on line 82 in create-or-update-issue/main.mjs

View check run for this annotation

Codecov / codecov/patch

create-or-update-issue/main.mjs#L79-L82

Added lines #L79 - L82 were not covered by tests

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);
}

Check warning on line 103 in create-or-update-issue/main.mjs

View check run for this annotation

Codecov / codecov/patch

create-or-update-issue/main.mjs#L102-L103

Added lines #L102 - L103 were not covered by tests
}

await main();
131 changes: 131 additions & 0 deletions create-or-update-issue/main.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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 () => {
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");

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("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();
});
});

0 comments on commit 42a7fa0

Please sign in to comment.