diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..beb679f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,38 @@ +root = true + +[.*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml}] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[Makefile] +charset = utf-8 +end_of_line = lf +indent_style = tab +insert_final_newline = true +trim_trailing_whitespace = true + +[*.sh] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..8444a0f --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,18 @@ +name: Tests + +on: + workflow_dispatch: {} + pull_request: + branches: + - main + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Run tests + shell: bash + run: make test diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..8226afb --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1 @@ +external-sources=true diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6da3c26 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: all test clean + +PLATFORM := $(shell docker version --format '{{.Server.Os}}/{{.Server.Arch}}') +DOCKER := docker run --rm --network none --platform $(PLATFORM) + +test: unit-tests lint + +unit-tests: + @set -e; \ + for f in tests/test*.sh; do \ + echo "sh $$f"; \ + sh "$$f"; \ + done + +lint: + $(DOCKER) -v ./Makefile:/work/Makefile:ro backplane/checkmake Makefile + $(DOCKER) -v .:/workspace:ro mstruebing/editorconfig-checker ec -exclude '^\.git/' + $(DOCKER) -v .:/mnt:ro koalaman/shellcheck -a -s sh --source-path=tests src/** tests/** diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee2a6a6 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +[![Build and Test](https://github.com/codereaper/create-issue-action/actions/workflows/test.yaml/badge.svg)](https://github.com/codereaper/create-issue-action/actions/workflows/test.yaml) + +# Create Issue Action + +A simple GitHub Action that **creates, updates, comments on, or closes issues** using the GitHub CLI (`gh`). + +Ideal for CI/CD workflows that need to: + +- Automatically open or update tracking issues +- Comment on existing issues from automation +- Close issues after builds or deployments are complete + +## Features + +- Create new issues with titles, bodies, templates, labels, and assignees +- Update existing issues automatically +- Add comments to existing issues +- Close issues by title and label search +- Uses `gh` CLI under the hood (no extra dependencies) + +## Inputs + +| Name | Description | Default | Required | +| ----------- | --------------------------------------------------------------------------------- | -------------------------- | -------- | +| `token` | GitHub token (PAT or `${{ github.token }}`) used for authentication | `${{ github.token }}` | Yes | +| `mode` | Operation mode: `create` or `close` | `create` | Yes | +| `state` | Issue state filter when searching for existing issues: `open`, `closed`, or `all` | `open` | Yes | +| `title` | Title of the issue to create or update | — | Yes | +| `labels` | Comma-separated list of labels used for creation and search | — | No | +| `assignees` | Comma-separated list of users to assign the issue to | — | No | +| `body` | Custom body text for the issue (overrides `template`) | — | No | +| `comment` | Optional comment text to add to an existing issue | — | No | +| `repo` | Repository to operate on (`owner/repo`) | `${{ github.repository }}` | Yes | + +## Example Usage + +```yaml +name: Reporting on failed builds +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build + run: make build + + report-failure: + runs-on: ubuntu-latest + needs: build + if: failure() + permissions: + contents: read + issues: write + steps: + - name: Report build failure + uses: CodeReaper/create-issue-action@v1 + with: + title: "{{ github.workflow }} failed to build" + labels: automation + assignees: "@me" + body: See [the action log](https://github.com/{{ github.repository }}/actions/runs/{{ github.run_id }}) for more details. +``` + +# License + +This project is released under the [MIT License](LICENSE) diff --git a/action.yaml b/action.yaml new file mode 100644 index 0000000..e6bae5d --- /dev/null +++ b/action.yaml @@ -0,0 +1,74 @@ +name: Create issue +description: Creates an issue + +branding: + icon: git-pull-request + color: gray-dark + +inputs: + token: + description: Your Github PAT, defaults to actions token + default: ${{ github.token }} + required: true + repo: + description: GitHub repository to create/update/close an issue in + default: ${{ github.repository }} + required: true + mode: + description: > + Dictates whether to create, update or close an issue. + Valid options: create | close + default: create + state: + description: > + State of issue to create, update or close. + Valid options: open | closed | all + default: open + title: + description: Title of issue to create or update + required: true + labels: + description: Labels (comma-separated) to both create the issue with and to filter the existing issue search with + required: false + assignees: + description: GitHub handle of the user(s) to assign the issue (comma-separated), only used for issue creation + required: false + body: + description: Body text of the issue + required: false + comment: + description: > + If set, an existing issue have this comment added to the issue. + Note, if the mode is set to create, then any previously added comment is updated instead + required: false + +outputs: + url: + description: URL of the issue that was created + value: ${{ steps.action.outputs.url }} + +runs: + using: composite + steps: + - name: Check dependencies are installed + shell: bash + run: | + if ! command -v gh >/dev/null 2>&1; then + echo "Command gh not found. This CLI tool is required." + exit 1 + fi + + - name: Create issue + id: action + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + INPUT_REPO: ${{ inputs.repo }} + INPUT_MODE: ${{ inputs.mode }} + INPUT_STATE: ${{ inputs.state }} + INPUT_TITLE: ${{ inputs.title }} + INPUT_LABELS: ${{ inputs.labels }} + INPUT_ASSIGNEES: ${{ inputs.assignees }} + INPUT_BODY: ${{ inputs.body }} + INPUT_COMMENT: ${{ inputs.comment }} + run: chmod +x "${{ github.action_path }}/scripts/main.sh" && "${{ github.action_path }}/src/main.sh" diff --git a/src/main.sh b/src/main.sh new file mode 100644 index 0000000..29a3666 --- /dev/null +++ b/src/main.sh @@ -0,0 +1,74 @@ +#!/bin/sh + +# cspell:ignore endgroup + +set -eu + +MODE="${INPUT_MODE:-create}" +STATE="${INPUT_STATE:-open}" +TITLE="${INPUT_TITLE}" +LABELS="${INPUT_LABELS:-}" +ASSIGNEES="${INPUT_ASSIGNEES:-}" +BODY="${INPUT_BODY:-}" +COMMENT="${INPUT_COMMENT:-}" +REPO="${INPUT_REPO:-}" + +BODY_PRESENCE=${BODY:+(set)} +COMMENT_PRESENCE=${COMMENT:+(set)} + +echo "::group::Debug info:" +echo " REPO: ${REPO}" +echo " MODE: ${MODE}" +echo " STATE: ${STATE}" +echo " TITLE: ${TITLE}" +echo " LABELS: ${LABELS}" +echo " ASSIGNEES: ${ASSIGNEES}" +echo " BODY: ${BODY_PRESENCE:-(not set)}" +echo " COMMENT: ${COMMENT_PRESENCE:-(not set)}" +echo '::endgroup::' + +ISSUE_NUMBER=$(gh issue list --repo "${REPO}" --state "${STATE}" --label "${LABELS}" --search "in:title \"${TITLE}\"" --limit 1 --json number --jq '.[].number' || true) + +# Perform action based on mode +case "${MODE}" in +create) + # Determine body args + if [ -n "${BODY}" ]; then + BODY_ARGS="\"${BODY}\"" + else + BODY_ARGS="\"Auto-generated issue with title: ${TITLE}\"" + fi + + if [ -n "${ISSUE_NUMBER}" ]; then + echo "Issue already exists (#${ISSUE_NUMBER}), updating instead." + if [ -n "${COMMENT}" ]; then + echo "Adding comment to existing issue..." + gh issue comment "${ISSUE_NUMBER}" --repo "${REPO}" --body "${COMMENT}" + else + echo "Updating issue body..." + gh issue edit "${ISSUE_NUMBER}" --repo "${REPO}" --title "${TITLE}" --body "${BODY_ARGS}" + fi + else + echo "Creating new issue..." + gh issue create --repo "${REPO}" --title "${TITLE}" --body "${BODY_ARGS}" --label "${LABELS}" --assignee "${ASSIGNEES}" + fi + ;; +close) + if [ -z "${ISSUE_NUMBER}" ]; then + echo "No matching issue found to close." + exit 0 + fi + if [ -n "${COMMENT}" ]; then + echo "Adding closure comment..." + gh issue comment "${ISSUE_NUMBER}" --repo "${REPO}" --body "${COMMENT}" + fi + echo "Closing issue #${ISSUE_NUMBER}..." + gh issue close "${ISSUE_NUMBER}" --repo "${REPO}" + ;; +*) + echo "Invalid mode '${MODE}'. Valid options: create | close" + exit 1 + ;; +esac + +echo "Done." diff --git a/tests/gh.sh b/tests/gh.sh new file mode 100644 index 0000000..18717c4 --- /dev/null +++ b/tests/gh.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -eu + +case "$*" in +*"issue list"*) + # Simulate no issue found unless overridden + if [ "${GH_FAKE_MODE:-none}" = "issue-exists" ]; then + echo '42' + else + echo '' + fi + ;; +*"issue create"*) + echo "FAKE: created issue" + ;; +*"issue edit"*) + echo "FAKE: edited issue" + ;; +*"issue comment"*) + echo "FAKE: added comment" + ;; +*"issue close"*) + echo "FAKE: closed issue" + ;; +*) + echo "FAKE: unknown gh command $*" >&2 + exit 1 + ;; +esac diff --git a/tests/test-add-comment-to-existing-issue.sh b/tests/test-add-comment-to-existing-issue.sh new file mode 100644 index 0000000..31df624 --- /dev/null +++ b/tests/test-add-comment-to-existing-issue.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +export GH_FAKE_MODE="issue-exists" +# shellcheck source=utils.sh +. tests/utils.sh + +export INPUT_TITLE="Hello world" +export INPUT_COMMENT="Hello comment of world" +output=$(bash "${SCRIPT_PATH}" 2>&1) +assert_contains "$output" "Adding comment to existing issue..." +assert_contains "$output" "FAKE: added comment" +echo "passed" diff --git a/tests/test-close-existing-issue-with-comment.sh b/tests/test-close-existing-issue-with-comment.sh new file mode 100644 index 0000000..03f5c65 --- /dev/null +++ b/tests/test-close-existing-issue-with-comment.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu + +export GH_FAKE_MODE="issue-exists" +# shellcheck source=utils.sh +. tests/utils.sh + +export INPUT_MODE="close" +export INPUT_TITLE="Hello world" +export INPUT_COMMENT="Hello comment of world" +output=$(bash "${SCRIPT_PATH}" 2>&1) +assert_contains "$output" "Adding closure comment..." +assert_contains "$output" "FAKE: added comment" +assert_contains "$output" "Closing issue #42..." +assert_contains "$output" "FAKE: closed issue" +echo "passed" diff --git a/tests/test-close-existing-issue.sh b/tests/test-close-existing-issue.sh new file mode 100644 index 0000000..dea8d4c --- /dev/null +++ b/tests/test-close-existing-issue.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +export GH_FAKE_MODE="issue-exists" +# shellcheck source=utils.sh +. tests/utils.sh + +export INPUT_MODE="close" +export INPUT_TITLE="Hello world" +output=$(bash "${SCRIPT_PATH}" 2>&1) +assert_contains "$output" "Closing issue #42..." +assert_contains "$output" "FAKE: closed issue" +echo "passed" diff --git a/tests/test-close-with-no-matches.sh b/tests/test-close-with-no-matches.sh new file mode 100644 index 0000000..f866e61 --- /dev/null +++ b/tests/test-close-with-no-matches.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +# shellcheck source=utils.sh +. tests/utils.sh + +export INPUT_MODE="close" +export INPUT_TITLE="Hello world" +output=$(bash "${SCRIPT_PATH}" 2>&1) +assert_contains "$output" "No matching issue found to close." +echo "passed" diff --git a/tests/test-create-new-issue.sh b/tests/test-create-new-issue.sh new file mode 100644 index 0000000..4b20836 --- /dev/null +++ b/tests/test-create-new-issue.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +# shellcheck source=utils.sh +. tests/utils.sh + +export INPUT_TITLE="Hello world" +output=$(bash "${SCRIPT_PATH}" 2>&1) +assert_contains "$output" "Creating new issue..." +assert_contains "$output" "FAKE: created issue" +echo "passed" diff --git a/tests/test-update-existing-issue.sh b/tests/test-update-existing-issue.sh new file mode 100644 index 0000000..8cb1315 --- /dev/null +++ b/tests/test-update-existing-issue.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +export GH_FAKE_MODE="issue-exists" +# shellcheck source=utils.sh +. tests/utils.sh + +export INPUT_TITLE="Hello world" +output=$(bash "${SCRIPT_PATH}" 2>&1) +assert_contains "$output" "Issue already exists (#42)" +assert_contains "$output" "FAKE: edited issue" +echo "passed" diff --git a/tests/utils.sh b/tests/utils.sh new file mode 100644 index 0000000..bab041d --- /dev/null +++ b/tests/utils.sh @@ -0,0 +1,32 @@ +#!/bin/sh +set -eu + +SCRIPT_PATH=src/main.sh +TMPDIR=$(mktemp -d) +FAKE_GH="${TMPDIR}/gh" +PATH="${TMPDIR}:${PATH}" + +# Assert helper +assert_contains() { + haystack="$1" + needle="$2" + + # if ! eval printf "%s" "$haystack" | grep -qF "$needle"; then + # if ! grep -qF "$needle" <<<"$haystack"; then + if ! printf '%s' "$haystack" | grep -qF "$needle"; then + echo "Expected output to contain '$needle' but it didn't" + echo "-----" + echo "$haystack" + echo "-----" + exit 1 + fi +} + +trap 'rm -rf $TMPDIR' EXIT +cp tests/gh.sh "${FAKE_GH}" +chmod +x "${FAKE_GH}" + +export TMPDIR +export FAKE_GH +export PATH +export SCRIPT_PATH