diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..a1218e0 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,21 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + extends: [ + 'eslint:recommended', + '@typescript-eslint/recommended', + ], + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-non-null-assertion': 'warn', + }, + env: { + node: true, + es6: true, + }, +}; \ No newline at end of file diff --git a/.gitignore b/.gitignore index 527b7cf..81620d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,95 @@ -.gradle -/build/ -/plugin/build +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* -# Ignore Gradle GUI config -gradle-app.setting +# Build outputs +dist/ +build/ +*.tsbuildinfo -# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) -!gradle-wrapper.jar +# Runtime data +pids +*.pid +*.seed +*.pid.lock -# Cache of project -.gradletasknamecache +# Coverage directory used by tools like istanbul +coverage/ +*.lcov -# Intellij Files -.idea +# nyc test coverage +.nyc_output -# MacOs? -.DS_Store +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.production +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# next.js build output +.next -# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 -# gradle/wrapper/gradle-wrapper.properties +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a813c65 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index ed9a40d..0a118b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,65 +4,66 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is a Kotlin Multiplatform CLI application for sending Slack notifications, primarily used in GitHub workflows. The project compiles to native executables for different platforms (macOS ARM64, Linux x64/ARM64, Windows x64) using Kotlin/Native. +This is a TypeScript GitHub Action for sending Slack notifications from GitHub workflows. The action provides rich formatting and supports creating new messages or updating existing ones to track workflow progress. ## Key Commands ### Building and Development -- `./gradlew build` - Full build including compilation and tests -- `./gradlew commonBinaries` - Build native executables (default task) -- `./gradlew linkReleaseExecutableCommon` - Build release executable -- `./gradlew linkDebugExecutableCommon` - Build debug executable - -### Running -- `./gradlew runDebugExecutableCommon` - Run debug executable -- `./gradlew runReleaseExecutableCommon` - Run release executable - -### Testing -- `./gradlew commonTest` - Run tests for common target -- `./gradlew allTests` - Run all tests with aggregated report +- `npm install` - Install dependencies +- `npm run build` - Build the action (TypeScript compilation + bundling) +- `npm test` - Run tests +- `npm run lint` - Run ESLint +- `npm run format` - Format code with Prettier ### Code Quality -- `./gradlew ktlintCheck` - Run linting -- `./gradlew ktlintFormat` - Auto-format code -- `./gradlew check` - Run all checks (tests + linting) +- `npm run lint` - Run linting with ESLint +- `npm run format` - Auto-format code with Prettier ## Architecture ### Main Components -- **Main.kt**: Entry point that delegates to PublishSlackCommand -- **PublishSlackCommand**: CLI command parser using Clikt library, handles GitHub Action environment variables -- **PublishSlackService**: Orchestrates the Slack publishing workflow -- **SlackClient**: Handles Slack API interactions (create/update messages) +- **src/index.ts**: Entry point that processes GitHub Action inputs and coordinates the workflow +- **src/services/publish-slack-service.ts**: Orchestrates the Slack publishing workflow +- **src/services/slack-client.ts**: Handles Slack API interactions using @slack/web-api +- **src/utils/github-event-parser.ts**: Parses GitHub event JSON files from various webhook formats +- **src/models/types.ts**: TypeScript types and enums for the application ### Key Models -- **GithubEvent**: Unified GitHub event data from various GitHub webhook payloads -- **JobType/JobStatus**: Enums for workflow job classification -- **SlackMessage/SlackBlock**: Slack message structure for rich formatting +- **GitHubEvent**: Unified GitHub event data from various GitHub webhook payloads +- **JobType/JobStatus**: Enums for workflow job classification with associated labels and colors +- **SlackMessage/SlackBlock/SlackAttachment**: Slack message structure for rich formatting ### Data Flow -1. GitHub Action provides environment variables and event JSON file -2. PublishSlackCommand parses CLI options and GitHub event data -3. Event JSON is normalized into GithubEvent via serializers in `model/serializers/` -4. PublishSlackService coordinates message creation/update via SlackClient -5. Output includes SLACK_MESSAGE_ID for subsequent workflow steps +1. GitHub Action inputs are processed from action.yml metadata +2. GitHub event JSON file is parsed and normalized into GitHubEvent +3. PublishSlackService coordinates message creation/update via SlackClient +4. Output includes SLACK_MESSAGE_ID for subsequent workflow steps ### Dependencies -- **Clikt**: CLI argument parsing -- **Ktor**: HTTP client for Slack API calls -- **kotlinx-serialization**: JSON parsing for GitHub events -- **kotlinx-datetime**: Date/time handling -- **Kotest**: Testing framework +- **@actions/core**: GitHub Actions toolkit for inputs/outputs +- **@actions/github**: GitHub Actions context utilities +- **@slack/web-api**: Official Slack Web API client +- **TypeScript**: Language and type system +- **@vercel/ncc**: Bundler for packaging the action ## Development Notes -### Platform Targeting -The project uses cross-compilation with a single "common" target that maps to the host platform. The native target selection happens at build time based on OS detection. +### GitHub Action Structure +The project follows GitHub Action conventions: +- `action.yml`: Metadata defining inputs, outputs, and runtime +- `dist/index.js`: Bundled JavaScript entry point (auto-generated) +- Inputs can come from workflow YAML or environment variables ### GitHub Integration -The CLI is designed to run in GitHub Actions and expects specific environment variables (GITHUB_EVENT_PATH, GITHUB_REPOSITORY, etc.). Event parsing handles multiple GitHub webhook formats through dedicated serializers. +The action processes GitHub event JSON files and environment variables: +- GITHUB_EVENT_PATH, GITHUB_REPOSITORY, GITHUB_RUN_ID, etc. +- Supports multiple GitHub webhook formats (push, pull_request, issues) +- Outputs SLACK_MESSAGE_ID for workflow chaining + +### Build Process +The action is bundled using @vercel/ncc to create a single JavaScript file with all dependencies included. This eliminates the need for node_modules in the distributed action. ### Usage Context -This tool is typically used indirectly through: -1. [slack-notifier-cli-action](https://github.com/monta-app/slack-notifier-cli-action) - GitHub Action wrapper -2. [github-workflows](https://github.com/monta-app/github-workflows) - Shared workflow templates \ No newline at end of file +This action can be used: +1. Directly in GitHub workflows +2. Indirectly through [github-workflows](https://github.com/monta-app/github-workflows) - Shared workflow templates \ No newline at end of file diff --git a/README.md b/README.md index 1089fdc..21bd9c8 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,63 @@ ### About -A CLI project for sending slack messages usable in GitHub workflows. +A TypeScript GitHub Action for sending Slack notifications from GitHub workflows with rich formatting. ### Usage -Typically you do not use this directly but via the [slack-notifier-cli-action](https://github.com/monta-app/slack-notifier-cli-action) +You can use this action directly in your GitHub workflows: + +```yaml +- name: Send Slack Notification + uses: monta-app/slack-notifier-cli@v1 + with: + job-type: 'build' + job-status: 'success' + slack-token: ${{ secrets.SLACK_BOT_TOKEN }} + slack-channel-id: 'C1234567890' + service-name: 'My Service' + service-emoji: ':rocket:' +``` + +### Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `github-event-path` | GitHub event JSON file path | No | `${{ github.event_path }}` | +| `github-repository` | GitHub repository name | No | `${{ github.repository }}` | +| `github-run-id` | GitHub run ID | No | `${{ github.run_id }}` | +| `github-workflow` | GitHub workflow name | No | `${{ github.workflow }}` | +| `github-ref-name` | GitHub ref name | No | `${{ github.ref_name }}` | +| `service-name` | Service name for display | No | | +| `service-emoji` | Service emoji | No | | +| `job-type` | Job type (build, test, deploy, etc.) | Yes | | +| `job-status` | Job status (success, failure, progress, etc.) | Yes | | +| `slack-token` | Slack Bot Token | Yes | | +| `slack-channel-id` | Slack channel ID | Yes | | +| `slack-message-id` | Message ID to update (for updates) | No | | + +### Outputs + +| Output | Description | +|--------|-------------| +| `slack-message-id` | ID of the created/updated Slack message | + +### Development + +```bash +# Install dependencies +npm install + +# Build the action +npm run build + +# Run tests +npm test + +# Lint code +npm run lint + +# Format code +npm run format +``` Most projects don't use this directly, but indirectly through the [github-workflows](https://github.com/monta-app/github-workflows) diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..2dbebe4 --- /dev/null +++ b/action.yml @@ -0,0 +1,58 @@ +name: 'Slack Notifier CLI' +description: 'Send Slack notifications from GitHub workflows with rich formatting' +author: 'Monta' + +inputs: + github-event-path: + description: 'GitHub Context event json file path' + required: false + default: ${{ github.event_path }} + github-repository: + description: 'GitHub Context repository' + required: false + default: ${{ github.repository }} + github-run-id: + description: 'GitHub Context run id' + required: false + default: ${{ github.run_id }} + github-workflow: + description: 'GitHub Context workflow' + required: false + default: ${{ github.workflow }} + github-ref-name: + description: 'GitHub Context ref name' + required: false + default: ${{ github.ref_name }} + service-name: + description: 'Name of the service for display in Slack' + required: false + service-emoji: + description: 'Emoji for the service' + required: false + job-type: + description: 'Job Type (e.g., build, test, deploy)' + required: true + job-status: + description: 'Job Status (e.g., success, failure, in_progress)' + required: true + slack-token: + description: 'Slack Bot Token (xoxb-...)' + required: true + slack-channel-id: + description: 'Slack channel ID where messages will be posted' + required: true + slack-message-id: + description: 'Slack message ID to update (for updating existing messages)' + required: false + +outputs: + slack-message-id: + description: 'The ID of the Slack message that was created or updated' + +runs: + using: 'node20' + main: 'dist/index.js' + +branding: + icon: 'message-circle' + color: 'blue' \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts deleted file mode 100644 index c25912b..0000000 --- a/build.gradle.kts +++ /dev/null @@ -1,74 +0,0 @@ -plugins { - kotlin("multiplatform") version "2.1.21" - kotlin("plugin.serialization") version "2.1.21" - id("io.kotest.multiplatform") version "5.9.1" - id("org.jlleitschuh.gradle.ktlint") version "12.3.0" -} - -group = "com.monta.slack.notifier" -version = "1.2.0" - -repositories { - mavenCentral() -} - -defaultTasks("commonBinaries") - -kotlin { - - val hostOs = System.getProperty("os.name") - val hostArch = System.getProperty("os.arch") - - // Cross Compilation - val commonTarget = when { - hostOs == "Mac OS X" -> macosArm64("common") - hostOs == "Linux" && hostArch == "aarch64" -> linuxArm64("common") - hostOs == "Linux" && hostArch == "amd64" -> linuxX64("common") - hostOs.startsWith("Windows") -> mingwX64("common") - else -> throw GradleException("Host OS is not supported in Kotlin/Native.") - } - - commonTarget.apply { - binaries { - executable { - entryPoint = "com.monta.slack.notifier.main" - } - } - } - - sourceSets { - val commonMain by getting { - dependencies { - // CLI - implementation("com.github.ajalt.clikt:clikt:5.0.3") - // Date Time Support - implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2") - // Serialization - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") - // Atomic - implementation("org.jetbrains.kotlinx:atomicfu:0.28.0") - // Http Client - val ktorVersion = "3.2.0" - implementation("io.ktor:ktor-client-core:$ktorVersion") - implementation("io.ktor:ktor-client-curl:$ktorVersion") - implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") - implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") - } - } - val commonTest by getting { - dependencies { - val kotestVersion = "5.9.1" - implementation(kotlin("test-common")) - implementation(kotlin("test-annotations-common")) - implementation("io.kotest:kotest-framework-engine:$kotestVersion") - implementation("io.kotest:kotest-assertions-core:$kotestVersion") - } - } - } -} - -kotlin.targets.withType { - binaries.all { - freeCompilerArgs = freeCompilerArgs + "-Xdisable-phases=EscapeAnalysis" - } -} diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index 0f05bbf..0000000 --- a/gradle.properties +++ /dev/null @@ -1,2 +0,0 @@ -kotlin.code.style=official -kotlin.native.binary.memoryModel=experimental \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 1b33c55..0000000 Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 3735f26..0000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,8 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionSha256Sum=7197a12f450794931532469d4ff21a59ea2c1cd59a3ec3f89c035c3c420a6999 -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew deleted file mode 100755 index 23d15a9..0000000 --- a/gradlew +++ /dev/null @@ -1,251 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH="\\\"\\\"" - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat deleted file mode 100644 index db3a6ac..0000000 --- a/gradlew.bat +++ /dev/null @@ -1,94 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH= - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/package.json b/package.json new file mode 100644 index 0000000..c1f8973 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "slack-notifier-cli", + "version": "1.2.0", + "description": "A GitHub Action for sending Slack notifications from workflows", + "main": "dist/index.js", + "scripts": { + "build": "tsc && ncc build dist/index.js -o dist --minify", + "test": "jest", + "lint": "eslint src/**/*.ts", + "format": "prettier --write src/**/*.ts" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/monta-app/slack-notifier-cli.git" + }, + "keywords": [ + "slack", + "notifications", + "github-action", + "ci-cd" + ], + "author": "Monta", + "license": "MIT", + "dependencies": { + "@actions/core": "^1.10.1", + "@actions/github": "^6.0.0", + "@slack/web-api": "^7.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "@vercel/ncc": "^0.38.0", + "eslint": "^8.0.0", + "jest": "^29.0.0", + "prettier": "^3.0.0", + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts deleted file mode 100644 index 75c4189..0000000 --- a/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "slack-notifier-cli" diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/Main.kt b/src/commonMain/kotlin/com/monta/slack/notifier/Main.kt deleted file mode 100644 index fe4cd51..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/Main.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.monta.slack.notifier - -import com.github.ajalt.clikt.core.main -import com.monta.slack.notifier.command.PublishSlackCommand - -fun main(args: Array) { - PublishSlackCommand().main(args) -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/SlackClient.kt b/src/commonMain/kotlin/com/monta/slack/notifier/SlackClient.kt deleted file mode 100644 index 5540afb..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/SlackClient.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.monta.slack.notifier - -import com.monta.slack.notifier.model.GithubEvent -import com.monta.slack.notifier.model.JobStatus -import com.monta.slack.notifier.model.JobType -import com.monta.slack.notifier.model.SlackBlock -import com.monta.slack.notifier.model.SlackMessage -import com.monta.slack.notifier.util.JsonUtil -import com.monta.slack.notifier.util.buildTitle -import com.monta.slack.notifier.util.client -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.utils.io.charsets.* -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -class SlackClient( - private val serviceName: String?, - private val serviceEmoji: String?, - private val slackToken: String, - private val slackChannelId: String, -) { - - suspend fun create( - githubEvent: GithubEvent, - jobType: JobType, - jobStatus: JobStatus, - ): String { - val response = makeSlackRequest( - url = "https://slack.com/api/chat.postMessage", - message = generateMessageFromGithubEvent( - githubEvent = githubEvent, - jobType = jobType, - jobStatus = jobStatus - ) - ) - - return requireNotNull(response?.ts) - } - - suspend fun update( - messageId: String, - githubEvent: GithubEvent, - jobType: JobType, - jobStatus: JobStatus, - ): String { - val previousMessage = getSlackMessageById(messageId) - - val response = makeSlackRequest( - url = "https://slack.com/api/chat.update", - message = generateMessageFromGithubEvent( - githubEvent = githubEvent, - jobType = jobType, - jobStatus = jobStatus, - messageId = messageId, - previousAttachments = previousMessage?.messages?.firstOrNull()?.attachments - ) - ) - - return requireNotNull(response?.ts) - } - - private fun generateSlackMessageFromEvent( - githubEvent: GithubEvent, - serviceName: String?, - serviceEmoji: String?, - slackChannelId: String, - messageId: String?, - attachments: List?, - ): SlackMessage { - val title = buildTitle(githubEvent.repository, githubEvent.workflow, serviceName, serviceEmoji) - - return SlackMessage( - channel = slackChannelId, - ts = messageId, - text = title, - blocks = listOf( - SlackBlock( - type = "header", - text = SlackBlock.Text( - type = "plain_text", - text = title - ) - ), - SlackBlock( - type = "divider" - ), - SlackBlock( - type = "section", - fields = listOf( - SlackBlock.Text( - type = "mrkdwn", - text = " \n*Branch:*\n${githubEvent.refName}" - ), - SlackBlock.Text( - type = "mrkdwn", - text = " \n*Run:*\n<${githubEvent.getRunUrl()}|${githubEvent.runId}>" - ), - SlackBlock.Text( - type = "mrkdwn", - text = " \n*Comitter:*\n${githubEvent.displayName}" - ), - SlackBlock.Text( - type = "mrkdwn", - text = " \n*Message:*\n<${githubEvent.getChangeUrl()}|${githubEvent.getChangeMessage()}>" - ), - SlackBlock.Text( - type = "mrkdwn", - text = " \n*Change:*\n<${githubEvent.getChangeUrl()}|${githubEvent.getChangeIdentifier()}>" - ) - ) - ), - SlackBlock( - type = "divider" - ) - ), - attachments = attachments - ) - } - - private fun generateMessageFromGithubEvent( - githubEvent: GithubEvent, - jobType: JobType, - jobStatus: JobStatus, - messageId: String? = null, - previousAttachments: List? = null, - ): SlackMessage { - val attachments = mutableMapOf() - - previousAttachments?.forEach { previousAttachment -> - if (previousAttachment.jobType == null) { - return@forEach - } - attachments[previousAttachment.jobType] = previousAttachment - } - - attachments[jobType] = SlackMessage.Attachment( - color = jobStatus.color, - fields = listOf( - SlackMessage.Attachment.Field( - title = jobType.label, - short = false, - value = jobStatus.message - ) - ) - ) - - return generateSlackMessageFromEvent( - githubEvent = githubEvent, - serviceName = serviceName, - serviceEmoji = serviceEmoji, - slackChannelId = slackChannelId, - messageId = messageId, - attachments = attachments.values.toList() - ) - } - - private suspend fun getSlackMessageById( - messageId: String, - ): MessageResponse? { - val response = client.get { - header("Authorization", "Bearer $slackToken") - url { - url("https://slack.com/api/conversations.history") - parameters.append("channel", slackChannelId) - parameters.append("oldest", messageId) - parameters.append("inclusive", "true") - parameters.append("limit", "1") - } - } - - val bodyString = response.bodyAsText() - - return if (response.status.value in 200..299) { - println("successfully got message bodyString=$bodyString") - JsonUtil.instance.decodeFromString(bodyString) - } else { - println("failed to get message $bodyString") - null - } - } - - private suspend fun makeSlackRequest(url: String, message: SlackMessage): Response? { - val response = client.post(url) { - header("Authorization", "Bearer $slackToken") - contentType(ContentType.Application.Json.withParameter("charset", Charsets.UTF_8.name)) - setBody(message) - } - - val bodyString = response.bodyAsText() - - return if (response.status.value in 200..299) { - println("successfully posted message bodyString=$bodyString") - JsonUtil.instance.decodeFromString(bodyString) - } else { - println("failed to post message $bodyString") - null - } - } - - @Serializable - private data class Response( - @SerialName("ok") - val ok: Boolean, // true - @SerialName("channel") - val channel: String, // C024BE91L - @SerialName("ts") - val ts: String, // 1401383885.000061 - ) - - @Serializable - private data class MessageResponse( - @SerialName("ok") - val ok: Boolean, // true - @SerialName("messages") - val messages: List, - ) -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/command/PublishSlackCommand.kt b/src/commonMain/kotlin/com/monta/slack/notifier/command/PublishSlackCommand.kt deleted file mode 100644 index 075d77c..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/command/PublishSlackCommand.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.monta.slack.notifier.command - -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.parameters.options.option -import com.github.ajalt.clikt.parameters.options.required -import com.monta.slack.notifier.model.GithubEvent -import com.monta.slack.notifier.model.JobStatus -import com.monta.slack.notifier.model.JobType -import com.monta.slack.notifier.model.serializers.BaseGithubContext -import com.monta.slack.notifier.service.PublishSlackService -import com.monta.slack.notifier.util.populateEventFromJson -import com.monta.slack.notifier.util.readStringFromFile -import kotlinx.coroutines.runBlocking - -class PublishSlackCommand : CliktCommand() { - val githubEventPath: String by option( - help = "Github Context event json file path", - envvar = "GITHUB_EVENT_PATH" - ).required() - - val githubRepository: String by option( - help = "Github Context repository", - envvar = "GITHUB_REPOSITORY" - ).required() - - val githubRunId: String by option( - help = "Github Context run id", - envvar = "GITHUB_RUN_ID" - ).required() - - val githubWorkflow: String by option( - help = "Github Context workflow", - envvar = "GITHUB_WORKFLOW" - ).required() - - val githubRefName: String by option( - help = "Github Context ref name", - envvar = "GITHUB_REF_NAME" - ).required() - - private val serviceName: String? by option( - help = "Emoji for the app!", - envvar = "PUBLISH_SLACK_SERVICE_NAME" - ) - - private val serviceEmoji: String? by option( - help = "Emoji for the app!", - envvar = "PUBLISH_SLACK_SERVICE_EMOJI" - ) - - private val jobType: String by option( - help = "Job Type", - envvar = "PUBLISH_SLACK_JOB_TYPE" - ).required() - - private val jobStatus: String by option( - help = "Job Status", - envvar = "PUBLISH_SLACK_JOB_STATUS" - ).required() - - private val slackToken: String by option( - help = "Slack token used for publishing", - envvar = "SLACK_APP_TOKEN" - ).required() - - private val slackChannelId: String by option( - help = "Slack channel where the changelog will be published to (i.e #my-channel)", - envvar = "SLACK_CHANNEL_ID" - ).required() - - private val slackMessageId: String? by option( - help = "Slack message id to be updated", - envvar = "SLACK_MESSAGE_ID" - ) - - override fun run() { - runBlocking { - val githubEvent = getGithubEvent() - PublishSlackService( - serviceName = serviceName.valueOrNull(), - serviceEmoji = serviceEmoji.valueOrNull(), - slackToken = slackToken, - slackChannelId = slackChannelId - ).publish( - githubEvent = githubEvent, - jobType = JobType.fromString(jobType), - jobStatus = JobStatus.fromString(jobStatus), - slackMessageId = slackMessageId.valueOrNull() - ) - } - } - - private fun getGithubEvent(): GithubEvent { - val baseGithubContext: BaseGithubContext - - val eventJson = readStringFromFile(githubEventPath) - - // The Github events can take many shapes, therefore we - // sort them and transfer them into a simpler object - baseGithubContext = populateEventFromJson(eventJson) - - return GithubEvent( - repository = githubRepository, - refName = githubRefName, - runId = githubRunId, - displayName = baseGithubContext.displayName, - commitSHA = baseGithubContext.sha, - message = baseGithubContext.message, - workflow = githubWorkflow, - prUrl = baseGithubContext.prUrl - ) - } - - /** - * Needed for optional parameters as the return the empty string instead of null - * if set via ENV variables (as we do from our GitHub Actions) - */ - private fun String?.valueOrNull() = if (this.isNullOrBlank()) null else this -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/model/GithubEvent.kt b/src/commonMain/kotlin/com/monta/slack/notifier/model/GithubEvent.kt deleted file mode 100644 index d5c6780..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/model/GithubEvent.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.monta.slack.notifier.model - -class GithubEvent( - val repository: String, - val refName: String, - var runId: String, - val displayName: String?, - val commitSHA: String?, - val message: String?, - val workflow: String?, - val prUrl: String?, -) { - fun getRunUrl(): String { - return "https://github.com/$repository/actions/runs/$runId" - } - fun getChangeIdentifier(): String? { - if (commitSHA != null) { - return commitSHA - } else if (prUrl != null) { - return getPRidentifier(prUrl) - } - return null - } - fun getChangeUrl(): String { - if (commitSHA != null) { - return "https://github.com/$repository/commit/$commitSHA" - } else if (prUrl != null) { - return prUrl - } - return "https://github.com/$repository/" - } - fun getChangeMessage(): String? { - return message - ?.replace("\n", " ") - ?.replace("\r", " ") - ?.replace("<", "") - ?.replace(">", "") - ?.take(120) - } - fun getPRidentifier(url: String): String? { - // Will extract the "pull/51" part of - // "https://github.com/monta-app/data-smart-charge/pull/51", - val regex = Regex("""pull/\d+""") - val matchResult = regex.find(url) - return matchResult?.value - } -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/model/JobStatus.kt b/src/commonMain/kotlin/com/monta/slack/notifier/model/JobStatus.kt deleted file mode 100644 index 6d2c781..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/model/JobStatus.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.monta.slack.notifier.model - -enum class JobStatus( - val message: String, - val color: String, -) { - Progress( - message = "In Progress :construction:", - color = "#DBAB09" - ), - Success( - message = "Success :white_check_mark:", - color = "#00FF00" - ), - Failure( - message = "Failure :x:", - color = "#FF0000" - ), - Cancelled( - message = "Cancelled :warning:", - color = "#FFFF00" - ), - Unknown( - message = "Something went wrong :question:", - color = "#DBAB09" - ), - ; - - companion object { - fun fromString(value: String?): JobStatus { - return values().find { state -> - state.name.equals(value, true) - } ?: Unknown - } - } -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/model/JobType.kt b/src/commonMain/kotlin/com/monta/slack/notifier/model/JobType.kt deleted file mode 100644 index d7675ee..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/model/JobType.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.monta.slack.notifier.model - -enum class JobType( - val label: String, -) { - Test( - label = "Test :test_tube:" - ), - Build( - label = "Build :building_construction:️" - ), - Deploy( - label = "Deploy :package:" - ), - PublishDocs( - label = "Publish Docs :jigsaw:" - ), - ; - - companion object { - fun fromString(value: String): JobType { - return JobType.values().find { state -> - state.name.equals(value, true) - } ?: throw RuntimeException("Unknown job type $value") - } - - fun fromLabel(label: String?): JobType? { - return JobType.values().find { state -> - state.label.equals(label, true) - } - } - } -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/model/SlackBlock.kt b/src/commonMain/kotlin/com/monta/slack/notifier/model/SlackBlock.kt deleted file mode 100644 index 644671d..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/model/SlackBlock.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.monta.slack.notifier.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -class SlackBlock( - @SerialName("type") - val type: String, - @SerialName("text") - val text: Text? = null, - @SerialName("fields") - val fields: List? = null, -) { - @Serializable - class Text( - @SerialName("type") - val type: String, - @SerialName("text") - val text: String, - @SerialName("emoji") - val emoji: Boolean = true, - @SerialName("short") - val short: Boolean = true, - ) -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/model/SlackMessage.kt b/src/commonMain/kotlin/com/monta/slack/notifier/model/SlackMessage.kt deleted file mode 100644 index ae670d8..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/model/SlackMessage.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.monta.slack.notifier.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class SlackMessage( - val channel: String? = null, - val ts: String? = null, - val text: String? = null, - val blocks: List? = null, - val attachments: List? = null, -) { - @Serializable - data class Attachment( - @SerialName("mrkdwn_in") - val mrkdwnIn: List = listOf("text"), - @SerialName("color") - val color: String? = null, - @SerialName("pretext") - val pretext: String? = null, - @SerialName("author_name") - val authorName: String? = null, - @SerialName("author_link") - val authorLink: String? = null, - @SerialName("author_icon") - val authorIcon: String? = null, - @SerialName("title") - val title: String? = null, - @SerialName("title_link") - val titleLink: String? = null, - @SerialName("text") - val text: String? = null, - @SerialName("fields") - val fields: List? = null, - @SerialName("thumb_url") - val thumbUrl: String? = null, - @SerialName("footer") - val footer: String? = null, - @SerialName("footer_icon") - val footerIcon: String? = null, - @SerialName("blocks") - val blocks: List? = null, - ) { - - val jobType = JobType.fromLabel( - label = fields?.firstOrNull()?.title - ) - - @Serializable - data class Field( - @SerialName("title") - val title: String, // A field's title - @SerialName("value") - val value: String, // This field's value - @SerialName("short") - val short: Boolean, // false - ) - - @Serializable - data class Action( - @SerialName("type") - val type: String, // A field's title - @SerialName("value") - val text: String, // This field's value - @SerialName("url") - val url: String, // false - ) - } -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/model/serializers/BaseGithubContext.kt b/src/commonMain/kotlin/com/monta/slack/notifier/model/serializers/BaseGithubContext.kt deleted file mode 100644 index 6e18620..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/model/serializers/BaseGithubContext.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.monta.slack.notifier.model.serializers - -class BaseGithubContext( - val displayName: String?, - val sha: String?, - val message: String?, - val prUrl: String?, -) diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/model/serializers/GithubCreatedContext.kt b/src/commonMain/kotlin/com/monta/slack/notifier/model/serializers/GithubCreatedContext.kt deleted file mode 100644 index d1ddcf3..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/model/serializers/GithubCreatedContext.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.monta.slack.notifier.model.serializers - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -class GithubCreatedContext( - @SerialName("sender") val sender: Sender, - @SerialName("issue") val issue: Issue, -) { - @Serializable - data class Sender( - @SerialName("login") val login: String, - ) - - @Serializable - data class Issue( - @SerialName("title") val title: String, - @SerialName("html_url") val url: String, - ) -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/model/serializers/GithubOpenedContext.kt b/src/commonMain/kotlin/com/monta/slack/notifier/model/serializers/GithubOpenedContext.kt deleted file mode 100644 index 0277e4b..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/model/serializers/GithubOpenedContext.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.monta.slack.notifier.model.serializers - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -class GithubOpenedContext( - @SerialName("pull_request") val pullRequest: PullRequest, -) { - @Serializable - data class PullRequest( - @SerialName("title") val title: String, - @SerialName("user") val user: PullRequestUser, - @SerialName("head") val head: PullRequestHead, - ) - - @Serializable - data class PullRequestHead( - @SerialName("sha") val sha: String, - ) - - @Serializable - data class PullRequestUser( - @SerialName("login") val login: String, - ) -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/model/serializers/GithubPushContext.kt b/src/commonMain/kotlin/com/monta/slack/notifier/model/serializers/GithubPushContext.kt deleted file mode 100644 index ac6a329..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/model/serializers/GithubPushContext.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.monta.slack.notifier.model.serializers - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class GithubPushContext( - @SerialName("ref") - val ref: String? = null, // refs/heads/develop - @SerialName("sha") - val sha: String? = null, // c545a1613f18937a88a13935c4d644e8f81b71d6 - @SerialName("repository") - val repository: String? = null, // monta-app/service-integrations - @SerialName("run_id") - val runId: String? = null, // 4399287439 - @SerialName("actor") - val actor: String? = null, // BrianEstrada - @SerialName("triggering_actor") - val triggeringActor: String? = null, // BrianEstrada - @SerialName("workflow") - val workflow: String? = null, // Deploy Dev - @SerialName("event_name") - val eventName: String? = null, // push - @SerialName("event") - val event: Event? = null, - @SerialName("ref_name") - val refName: String? = null, // develop - @SerialName("ref_type") - val refType: String? = null, // branch -) { - - @Serializable - data class Event( - @SerialName("head_commit") - var headCommit: Commit, - @SerialName("pusher") - val pusher: Committer, - @SerialName("ref") - val ref: String? = null, // refs/heads/develop - ) - - @Serializable - data class Commit( - @SerialName("author") - val author: Committer? = null, - @SerialName("committer") - val committer: Committer? = null, - @SerialName("distinct") - val distinct: Boolean? = null, // true - @SerialName("id") - val id: String, // c545a1613f18937a88a13935c4d644e8f81b71d6 - @SerialName("message") - val message: String, // ignore: test - @SerialName("timestamp") - val timestamp: String? = null, // 2023-03-12T21:24:19+01:00 - @SerialName("tree_id") - val treeId: String? = null, // f3667a7332372de2ccb6a1cc5c310e780915a28e - @SerialName("url") - val url: String? = null, // https://github.com/monta-app/service-integrations/commit/c545a1613f18937a88a13935c4d644e8f81b71d6 - ) - - @Serializable - data class Committer( - @SerialName("email") - val email: String? = null, // lovesguitar@gmail.com - @SerialName("name") - val name: String? = null, // Brian Estrada - @SerialName("username") - val username: String? = null, // BrianEstrada - ) { - val displayName: String = when { - email == null && name == null -> "N/A" - email == null && name != null -> name - email != null && name == null -> email - else -> "$name<$email>" - } - } -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/service/PublishSlackService.kt b/src/commonMain/kotlin/com/monta/slack/notifier/service/PublishSlackService.kt deleted file mode 100644 index 0696d36..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/service/PublishSlackService.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.monta.slack.notifier.service - -import com.monta.slack.notifier.SlackClient -import com.monta.slack.notifier.model.GithubEvent -import com.monta.slack.notifier.model.JobStatus -import com.monta.slack.notifier.model.JobType -import com.monta.slack.notifier.util.writeToOutput - -class PublishSlackService( - serviceName: String?, - serviceEmoji: String?, - slackToken: String, - slackChannelId: String, -) { - - private val slackClient = SlackClient( - serviceName = serviceName, - serviceEmoji = serviceEmoji, - slackToken = slackToken, - slackChannelId = slackChannelId - ) - - suspend fun publish( - githubEvent: GithubEvent, - jobType: JobType, - jobStatus: JobStatus, - slackMessageId: String?, - ): String { - val messageId = if (slackMessageId.isNullOrBlank()) { - slackClient.create( - githubEvent = githubEvent, - jobType = jobType, - jobStatus = jobStatus - ) - } else { - slackClient.update( - messageId = slackMessageId, - githubEvent = githubEvent, - jobType = jobType, - jobStatus = jobStatus - ) - } - - writeToOutput("SLACK_MESSAGE_ID", messageId) - - return messageId - } -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/util/FileUtils.kt b/src/commonMain/kotlin/com/monta/slack/notifier/util/FileUtils.kt deleted file mode 100644 index b689900..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/util/FileUtils.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.monta.slack.notifier.util - -import com.monta.slack.notifier.model.serializers.BaseGithubContext -import com.monta.slack.notifier.model.serializers.GithubCreatedContext -import com.monta.slack.notifier.model.serializers.GithubOpenedContext -import com.monta.slack.notifier.model.serializers.GithubPushContext -import kotlinx.cinterop.ByteVar -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.allocArray -import kotlinx.cinterop.memScoped -import kotlinx.cinterop.toKString -import kotlinx.serialization.SerializationException -import platform.posix.fclose -import platform.posix.fgets -import platform.posix.fopen - -@OptIn(ExperimentalForeignApi::class) -fun readStringFromFile( - filePath: String, -): String { - val returnBuffer = StringBuilder() - val file = fopen(filePath, "r") ?: throw IllegalArgumentException("Cannot open file $filePath for reading") - try { - memScoped { - val readBufferLength = 64 * 1024 - val buffer = allocArray(readBufferLength) - var line = fgets(buffer, readBufferLength, file)?.toKString() - while (line != null) { - returnBuffer.append(line) - line = fgets(buffer, readBufferLength, file)?.toKString() - } - } - } finally { - fclose(file) - } - return returnBuffer.toString() -} - -/** - * Populates the existing event type with information needed to generate - * an entire Slack notification. - */ -fun populateEventFromJson(eventJson: String): BaseGithubContext { - return populateOnJsonPush(eventJson) ?: populateOnJsonOpened(eventJson) ?: populateOnJsonCreated(eventJson) ?: handleFailure() -} - -private fun populateOnJsonPush(eventJson: String): BaseGithubContext? { - @Suppress("SwallowedException") - return try { - val event = JsonUtil.instance.decodeFromString(eventJson) - return BaseGithubContext( - displayName = event.pusher.displayName, - sha = event.headCommit.id, - message = event.headCommit.message, - prUrl = null - ) - } catch (e: SerializationException) { - null - } -} - -private fun populateOnJsonOpened(eventJson: String): BaseGithubContext? { - @Suppress("SwallowedException") - return try { - val event = JsonUtil.instance.decodeFromString(eventJson) - return BaseGithubContext( - displayName = event.pullRequest.user.login, - sha = event.pullRequest.head.sha, - message = event.pullRequest.title, - prUrl = null - ) - } catch (e: SerializationException) { - null - } -} - -private fun populateOnJsonCreated(eventJson: String): BaseGithubContext? { - @Suppress("SwallowedException") - return try { - val event = JsonUtil.instance.decodeFromString(eventJson) - return BaseGithubContext( - displayName = event.sender.login, - sha = null, - message = event.issue.title, - prUrl = event.issue.url - ) - } catch (e: SerializationException) { - null - } -} - -private fun handleFailure(): BaseGithubContext { - return BaseGithubContext( - displayName = null, - sha = null, - message = null, - prUrl = null - ) -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/util/GithubActionExtensions.kt b/src/commonMain/kotlin/com/monta/slack/notifier/util/GithubActionExtensions.kt deleted file mode 100644 index f9ce03d..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/util/GithubActionExtensions.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.monta.slack.notifier.util - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.memScoped -import kotlinx.cinterop.toKString -import platform.posix.EOF -import platform.posix.fclose -import platform.posix.fopen -import platform.posix.fputs -import platform.posix.getenv - -@OptIn(ExperimentalForeignApi::class) -fun writeToOutput( - key: String, - value: String, -) { - println("Writing to output $key $value") - - val githubOutput = getenv("GITHUB_OUTPUT")?.toKString() - - val file = fopen(githubOutput, "w") - - if (file == null) { - println("Cannot open output file $githubOutput") - return - } - - try { - memScoped { - val writeResult = fputs("$key=$value", file) - if (writeResult == EOF) { - println("File write error") - } - } - } finally { - fclose(file) - } -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/util/HttpClient.kt b/src/commonMain/kotlin/com/monta/slack/notifier/util/HttpClient.kt deleted file mode 100644 index 842f81d..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/util/HttpClient.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.monta.slack.notifier.util - -import io.ktor.client.* -import io.ktor.client.engine.curl.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.serialization.kotlinx.json.* -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json - -@OptIn(ExperimentalSerializationApi::class) -val client by lazy { - HttpClient(Curl) { - expectSuccess = false - install(ContentNegotiation) { - json( - Json { - explicitNulls = false - isLenient = true - ignoreUnknownKeys = true - } - ) - } - } -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/util/JsonUtil.kt b/src/commonMain/kotlin/com/monta/slack/notifier/util/JsonUtil.kt deleted file mode 100644 index 3359657..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/util/JsonUtil.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.monta.slack.notifier.util - -import kotlinx.serialization.json.Json - -object JsonUtil { - val instance = Json { - ignoreUnknownKeys = true - } -} diff --git a/src/commonMain/kotlin/com/monta/slack/notifier/util/StringUtils.kt b/src/commonMain/kotlin/com/monta/slack/notifier/util/StringUtils.kt deleted file mode 100644 index a59ec3d..0000000 --- a/src/commonMain/kotlin/com/monta/slack/notifier/util/StringUtils.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.monta.slack.notifier.util - -fun buildTitle( - repository: String?, - workflow: String?, - serviceName: String?, - serviceEmoji: String?, -): String { - val title: String? = getTitle( - serviceName = serviceName, - repository = repository - ) - - return when { - !title.isNullOrBlank() && !serviceEmoji.isNullOrBlank() -> { - "$serviceEmoji $title - $workflow" - } - - !title.isNullOrBlank() -> { - "$title - $workflow" - } - - else -> { - workflow ?: "Something went wrong" - } - } -} - -private fun getTitle( - serviceName: String?, - repository: String?, -): String? { - return if (serviceName.isNullOrBlank()) { - repository.toTitle() - } else { - serviceName - } -} - -private fun String?.toTitle(): String? { - return this?.split("/") - ?.last() - ?.split("-") - ?.joinToString(" ") { word -> - word.replaceFirstChar { firstChar -> - if (firstChar.isLowerCase()) { - firstChar.titlecase() - } else { - firstChar.toString() - } - } - } -} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7b9fd13 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,67 @@ +import * as core from '@actions/core'; +import { PublishSlackService } from './services/publish-slack-service'; +import { parseGitHubEvent } from './utils/github-event-parser'; +import { JobType, JobStatus } from './models/types'; + +async function run(): Promise { + try { + // Get inputs from GitHub Action + const githubEventPath = core.getInput('github-event-path') || process.env.GITHUB_EVENT_PATH || ''; + const githubRepository = core.getInput('github-repository') || process.env.GITHUB_REPOSITORY || ''; + const githubRunId = core.getInput('github-run-id') || process.env.GITHUB_RUN_ID || ''; + const githubWorkflow = core.getInput('github-workflow') || process.env.GITHUB_WORKFLOW || ''; + const githubRefName = core.getInput('github-ref-name') || process.env.GITHUB_REF_NAME || ''; + + const serviceName = core.getInput('service-name') || process.env.PUBLISH_SLACK_SERVICE_NAME || undefined; + const serviceEmoji = core.getInput('service-emoji') || process.env.PUBLISH_SLACK_SERVICE_EMOJI || undefined; + const jobType = core.getInput('job-type') || process.env.PUBLISH_SLACK_JOB_TYPE || ''; + const jobStatus = core.getInput('job-status') || process.env.PUBLISH_SLACK_JOB_STATUS || ''; + const slackToken = core.getInput('slack-token') || process.env.SLACK_APP_TOKEN || ''; + const slackChannelId = core.getInput('slack-channel-id') || process.env.SLACK_CHANNEL_ID || ''; + const slackMessageId = core.getInput('slack-message-id') || process.env.SLACK_MESSAGE_ID || undefined; + + // Validate required inputs + if (!githubEventPath) throw new Error('github-event-path is required'); + if (!githubRepository) throw new Error('github-repository is required'); + if (!githubRunId) throw new Error('github-run-id is required'); + if (!githubWorkflow) throw new Error('github-workflow is required'); + if (!githubRefName) throw new Error('github-ref-name is required'); + if (!jobType) throw new Error('job-type is required'); + if (!jobStatus) throw new Error('job-status is required'); + if (!slackToken) throw new Error('slack-token is required'); + if (!slackChannelId) throw new Error('slack-channel-id is required'); + + // Parse GitHub event + const githubEvent = await parseGitHubEvent({ + eventPath: githubEventPath, + repository: githubRepository, + runId: githubRunId, + workflow: githubWorkflow, + refName: githubRefName + }); + + // Create service and publish notification + const publishService = new PublishSlackService({ + serviceName: serviceName || undefined, + serviceEmoji: serviceEmoji || undefined, + slackToken, + slackChannelId + }); + + const messageId = await publishService.publish({ + githubEvent, + jobType: JobType.fromString(jobType), + jobStatus: JobStatus.fromString(jobStatus), + slackMessageId: slackMessageId || undefined + }); + + // Set output for subsequent workflow steps + core.setOutput('slack-message-id', messageId); + + console.log(`Successfully published Slack notification. Message ID: ${messageId}`); + } catch (error) { + core.setFailed(error instanceof Error ? error.message : 'Unknown error occurred'); + } +} + +run(); \ No newline at end of file diff --git a/src/models/types.ts b/src/models/types.ts new file mode 100644 index 0000000..b9383fc --- /dev/null +++ b/src/models/types.ts @@ -0,0 +1,132 @@ +export enum JobType { + Test = 'Test', + Build = 'Build', + Deploy = 'Deploy', + PublishDocs = 'PublishDocs' +} + +export namespace JobType { + export function fromString(value: string): JobType { + const jobType = Object.values(JobType).find(type => + type.toLowerCase() === value.toLowerCase() + ); + if (!jobType) { + throw new Error(`Unknown job type: ${value}`); + } + return jobType; + } + + export function getLabel(jobType: JobType): string { + switch (jobType) { + case JobType.Test: + return 'Test :test_tube:'; + case JobType.Build: + return 'Build :building_construction:️'; + case JobType.Deploy: + return 'Deploy :package:'; + case JobType.PublishDocs: + return 'Publish Docs :jigsaw:'; + default: + return jobType; + } + } +} + +export enum JobStatus { + Progress = 'Progress', + Success = 'Success', + Failure = 'Failure', + Cancelled = 'Cancelled', + Unknown = 'Unknown' +} + +export namespace JobStatus { + export function fromString(value: string): JobStatus { + const status = Object.values(JobStatus).find(status => + status.toLowerCase() === value.toLowerCase() + ); + return status || JobStatus.Unknown; + } + + export function getMessage(status: JobStatus): string { + switch (status) { + case JobStatus.Progress: + return 'In Progress :construction:'; + case JobStatus.Success: + return 'Success :white_check_mark:'; + case JobStatus.Failure: + return 'Failure :x:'; + case JobStatus.Cancelled: + return 'Cancelled :warning:'; + case JobStatus.Unknown: + return 'Something went wrong :question:'; + default: + return 'Unknown status'; + } + } + + export function getColor(status: JobStatus): string { + switch (status) { + case JobStatus.Progress: + return '#DBAB09'; + case JobStatus.Success: + return '#00FF00'; + case JobStatus.Failure: + return '#FF0000'; + case JobStatus.Cancelled: + return '#FFFF00'; + case JobStatus.Unknown: + return '#DBAB09'; + default: + return '#DBAB09'; + } + } +} + +export interface GitHubEvent { + repository: string; + refName: string; + runId: string; + displayName?: string; + commitSHA?: string; + message?: string; + workflow?: string; + prUrl?: string; +} + +export interface SlackMessage { + channel: string; + ts?: string; + text: string; + blocks: SlackBlock[]; + attachments?: SlackAttachment[]; +} + +export interface SlackBlock { + type: string; + text?: { + type: string; + text: string; + }; + fields?: Array<{ + type: string; + text: string; + }>; +} + +export interface SlackAttachment { + color: string; + fields: Array<{ + title: string; + value: string; + short: boolean; + }>; + jobType?: JobType; +} + +export interface BaseGitHubContext { + displayName?: string; + sha?: string; + message?: string; + prUrl?: string; +} \ No newline at end of file diff --git a/src/services/publish-slack-service.ts b/src/services/publish-slack-service.ts new file mode 100644 index 0000000..01c923e --- /dev/null +++ b/src/services/publish-slack-service.ts @@ -0,0 +1,64 @@ +import * as core from '@actions/core'; +import { SlackClient } from './slack-client'; +import { GitHubEvent, JobType, JobStatus } from '../models/types'; + +export interface PublishSlackServiceConfig { + serviceName?: string; + serviceEmoji?: string; + slackToken: string; + slackChannelId: string; +} + +export interface PublishParams { + githubEvent: GitHubEvent; + jobType: JobType; + jobStatus: JobStatus; + slackMessageId?: string; +} + +export class PublishSlackService { + private slackClient: SlackClient; + + constructor(config: PublishSlackServiceConfig) { + this.slackClient = new SlackClient(config); + } + + async publish(params: PublishParams): Promise { + const { githubEvent, jobType, jobStatus, slackMessageId } = params; + + let messageId: string; + + if (!slackMessageId) { + // Create new message + messageId = await this.slackClient.create(githubEvent, jobType, jobStatus); + console.log(`Created new Slack message with ID: ${messageId}`); + } else { + // Update existing message + messageId = await this.slackClient.update(slackMessageId, githubEvent, jobType, jobStatus); + console.log(`Updated Slack message with ID: ${messageId}`); + } + + // Write to GitHub Actions output (equivalent to the Kotlin writeToOutput function) + this.writeToOutput('SLACK_MESSAGE_ID', messageId); + + return messageId; + } + + private writeToOutput(key: string, value: string): void { + console.log(`Writing to output ${key} ${value}`); + + // Use GitHub Actions core to set output + core.setOutput(key, value); + + // Also write to GITHUB_OUTPUT file for compatibility + const githubOutput = process.env.GITHUB_OUTPUT; + if (githubOutput) { + try { + const fs = require('fs'); + fs.appendFileSync(githubOutput, `${key}=${value}\n`); + } catch (error) { + console.error('Failed to write to GITHUB_OUTPUT file:', error); + } + } + } +} \ No newline at end of file diff --git a/src/services/slack-client.ts b/src/services/slack-client.ts new file mode 100644 index 0000000..4dc461a --- /dev/null +++ b/src/services/slack-client.ts @@ -0,0 +1,246 @@ +import { WebClient } from '@slack/web-api'; +import { GitHubEvent, JobType, JobStatus, SlackMessage, SlackBlock, SlackAttachment } from '../models/types'; + +export class SlackClient { + private client: WebClient; + private serviceName?: string; + private serviceEmoji?: string; + private slackChannelId: string; + + constructor(config: { + serviceName?: string; + serviceEmoji?: string; + slackToken: string; + slackChannelId: string; + }) { + this.client = new WebClient(config.slackToken); + this.serviceName = config.serviceName; + this.serviceEmoji = config.serviceEmoji; + this.slackChannelId = config.slackChannelId; + } + + async create( + githubEvent: GitHubEvent, + jobType: JobType, + jobStatus: JobStatus + ): Promise { + const message = this.generateMessageFromGithubEvent( + githubEvent, + jobType, + jobStatus + ); + + const response = await this.client.chat.postMessage({ + channel: this.slackChannelId, + text: message.text, + blocks: message.blocks, + attachments: message.attachments + }); + + if (!response.ts) { + throw new Error('Failed to create Slack message - no timestamp returned'); + } + + return response.ts; + } + + async update( + messageId: string, + githubEvent: GitHubEvent, + jobType: JobType, + jobStatus: JobStatus + ): Promise { + // Get previous message to preserve existing attachments + const previousMessage = await this.getSlackMessageById(messageId); + + const message = this.generateMessageFromGithubEvent( + githubEvent, + jobType, + jobStatus, + messageId, + previousMessage?.attachments + ); + + const response = await this.client.chat.update({ + channel: this.slackChannelId, + ts: messageId, + text: message.text, + blocks: message.blocks, + attachments: message.attachments + }); + + if (!response.ts) { + throw new Error('Failed to update Slack message - no timestamp returned'); + } + + return response.ts; + } + + private async getSlackMessageById(messageId: string): Promise { + try { + const response = await this.client.conversations.history({ + channel: this.slackChannelId, + oldest: messageId, + inclusive: true, + limit: 1 + }); + + return response.messages?.[0]; + } catch (error) { + console.error('Failed to get Slack message:', error); + return null; + } + } + + private generateMessageFromGithubEvent( + githubEvent: GitHubEvent, + jobType: JobType, + jobStatus: JobStatus, + messageId?: string, + previousAttachments?: any[] + ): SlackMessage { + const attachments = new Map(); + + // Preserve previous attachments + if (previousAttachments) { + for (const attachment of previousAttachments) { + if (attachment.jobType) { + attachments.set(attachment.jobType, attachment); + } + } + } + + // Add/update current job status + attachments.set(jobType, { + color: JobStatus.getColor(jobStatus), + jobType, + fields: [ + { + title: JobType.getLabel(jobType), + value: JobStatus.getMessage(jobStatus), + short: false + } + ] + }); + + return this.generateSlackMessageFromEvent( + githubEvent, + messageId, + Array.from(attachments.values()) + ); + } + + private generateSlackMessageFromEvent( + githubEvent: GitHubEvent, + messageId?: string, + attachments?: SlackAttachment[] + ): SlackMessage { + const title = this.buildTitle(githubEvent); + + const blocks: SlackBlock[] = [ + { + type: 'header', + text: { + type: 'plain_text', + text: title + } + }, + { + type: 'divider' + }, + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: ` \n*Branch:*\n${githubEvent.refName}` + }, + { + type: 'mrkdwn', + text: ` \n*Run:*\n<${this.getRunUrl(githubEvent)}|${githubEvent.runId}>` + }, + { + type: 'mrkdwn', + text: ` \n*Committer:*\n${githubEvent.displayName || 'Unknown'}` + }, + { + type: 'mrkdwn', + text: ` \n*Message:*\n<${this.getChangeUrl(githubEvent)}|${this.getChangeMessage(githubEvent)}>` + }, + { + type: 'mrkdwn', + text: ` \n*Change:*\n<${this.getChangeUrl(githubEvent)}|${this.getChangeIdentifier(githubEvent)}>` + } + ] + }, + { + type: 'divider' + } + ]; + + return { + channel: this.slackChannelId, + ts: messageId, + text: title, + blocks, + attachments + }; + } + + private buildTitle(githubEvent: GitHubEvent): string { + const parts = []; + + if (this.serviceEmoji) { + parts.push(this.serviceEmoji); + } + + parts.push(githubEvent.repository); + + if (githubEvent.workflow) { + parts.push(githubEvent.workflow); + } + + if (this.serviceName) { + parts.push(this.serviceName); + } + + return parts.join(' '); + } + + private getRunUrl(githubEvent: GitHubEvent): string { + return `https://github.com/${githubEvent.repository}/actions/runs/${githubEvent.runId}`; + } + + private getChangeIdentifier(githubEvent: GitHubEvent): string { + if (githubEvent.commitSHA) { + return githubEvent.commitSHA; + } else if (githubEvent.prUrl) { + return this.getPRIdentifier(githubEvent.prUrl) || 'PR'; + } + return 'Unknown'; + } + + private getChangeUrl(githubEvent: GitHubEvent): string { + if (githubEvent.commitSHA) { + return `https://github.com/${githubEvent.repository}/commit/${githubEvent.commitSHA}`; + } else if (githubEvent.prUrl) { + return githubEvent.prUrl; + } + return `https://github.com/${githubEvent.repository}/`; + } + + private getChangeMessage(githubEvent: GitHubEvent): string { + return githubEvent.message + ?.replace(/\n/g, ' ') + ?.replace(/\r/g, ' ') + ?.replace(//g, '') + ?.substring(0, 120) || 'No message'; + } + + private getPRIdentifier(url: string): string | null { + const regex = /pull\/\d+/; + const match = url.match(regex); + return match ? match[0] : null; + } +} \ No newline at end of file diff --git a/src/utils/github-event-parser.ts b/src/utils/github-event-parser.ts new file mode 100644 index 0000000..6b1cc73 --- /dev/null +++ b/src/utils/github-event-parser.ts @@ -0,0 +1,102 @@ +import * as fs from 'fs'; +import { GitHubEvent, BaseGitHubContext } from '../models/types'; + +export interface GitHubEventInput { + eventPath: string; + repository: string; + runId: string; + workflow: string; + refName: string; +} + +export async function parseGitHubEvent(input: GitHubEventInput): Promise { + const eventJson = fs.readFileSync(input.eventPath, 'utf8'); + const context = parseEventFromJson(eventJson); + + return { + repository: input.repository, + refName: input.refName, + runId: input.runId, + displayName: context.displayName, + commitSHA: context.sha, + message: context.message, + workflow: input.workflow, + prUrl: context.prUrl + }; +} + +function parseEventFromJson(eventJson: string): BaseGitHubContext { + return parseOnJsonPush(eventJson) || + parseOnJsonOpened(eventJson) || + parseOnJsonCreated(eventJson) || + handleParseFailure(); +} + +function parseOnJsonPush(eventJson: string): BaseGitHubContext | null { + try { + const event = JSON.parse(eventJson); + + // Check if this looks like a push event + if (event.head_commit && event.pusher) { + return { + displayName: event.pusher.name || event.pusher.username || event.pusher.email, + sha: event.head_commit.id, + message: event.head_commit.message, + prUrl: undefined + }; + } + + return null; + } catch (error) { + return null; + } +} + +function parseOnJsonOpened(eventJson: string): BaseGitHubContext | null { + try { + const event = JSON.parse(eventJson); + + // Check if this looks like a pull request opened event + if (event.pull_request && event.action === 'opened') { + return { + displayName: event.pull_request.user.login, + sha: event.pull_request.head.sha, + message: event.pull_request.title, + prUrl: event.pull_request.html_url + }; + } + + return null; + } catch (error) { + return null; + } +} + +function parseOnJsonCreated(eventJson: string): BaseGitHubContext | null { + try { + const event = JSON.parse(eventJson); + + // Check if this looks like an issue created event + if (event.issue && event.action === 'created') { + return { + displayName: event.sender.login, + sha: undefined, + message: event.issue.title, + prUrl: event.issue.html_url + }; + } + + return null; + } catch (error) { + return null; + } +} + +function handleParseFailure(): BaseGitHubContext { + return { + displayName: undefined, + sha: undefined, + message: undefined, + prUrl: undefined + }; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c986952 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} \ No newline at end of file