Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

create-or-update-issue: add action #588

Merged
merged 1 commit into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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-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.<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.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;
}

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();
136 changes: 136 additions & 0 deletions create-or-update-issue/main.test.mjs
Original file line number Diff line number Diff line change
@@ -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();
});
});