feat!: wrap payloads to send to a "method" with "token" or "webhook" (#333)

Co-authored-by: Fil Maj <[email protected]>
This commit is contained in:
Eden Zimbelman
2024-11-14 12:48:36 -08:00
committed by GitHub
co-authored by Fil Maj
parent 74ae656892
commit e9b3a6beef
60 changed files with 4274 additions and 6596 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"include": ["src/*.js"],
"exclude": ["**/*.spec.js"],
"reporter": ["lcov", "text"],
"all": false,
"cache": true
}
-2
View File
@@ -1,2 +0,0 @@
node_modules
dist
-132
View File
@@ -1,132 +0,0 @@
//
// SlackAPI JavaScript style
// ---
// This style helps maintainers enforce safe and consistent programming practices in this project. It is not meant to be
// comprehensive on its own or vastly different from existing styles. The goal is to inherit and aggregate as many of
// the communities' recommended styles for the technologies used as we can. When, and only when, we have a stated need
// to differentiate, we add more rules (or modify options). Therefore, the fewer rules directly defined in this file,
// the better.
module.exports = {
// This is a root of the project, ESLint should not look through parent directories to find more config
root: true,
ignorePatterns: [
// Ignore all build outputs and artifacts (node_modules, dotfiles, and dot directories are implicitly ignored)
'/dist',
'/coverage',
],
// These environments contain lists of global variables which are allowed to be accessed
env: {
// According to https://node.green, the target node version (v10) supports all important ES2018 features. But es2018
// is not an option since it presumably doesn't introduce any new globals over ES2017.
es2017: true,
node: true,
},
extends: [
// ESLint's recommended built-in rules: https://eslint.org/docs/rules/
'eslint:recommended',
// Node plugin's recommended rules: https://github.com/mysticatea/eslint-plugin-node
'plugin:node/recommended',
// AirBnB style guide (without React) rules: https://github.com/airbnb/javascript.
'airbnb-base',
// JSDoc plugin's recommended rules
'plugin:jsdoc/recommended',
],
rules: {
// JavaScript rules
// ---
// The top level of this configuration contains rules which apply to JavaScript (and will also be inherited for
// TypeScript). This section does not contain rules meant to override options or disable rules in the base
// configurations (ESLint, Node, AirBnb). Those rules are added in the final override.
// Eliminate tabs to standardize on spaces for indentation. If you want to use tabs for something other than
// indentation, you may need to turn this rule off using an inline config comments.
'no-tabs': 'error',
// Bans use of comma as an operator because it can obscure side effects and is often an accident.
'no-sequences': 'error',
// This repo uses console.log, which is fine to do in GitHub actions
'no-console': 'off',
// Disallow the use of process.exit()
'node/no-process-exit': 'error',
// Allow safe references to functions before the declaration. Overrides AirBnB config. Not located in the override
// section below because a distinct override is necessary in TypeScript files.
'no-use-before-define': ['error', 'nofunc'],
},
overrides: [
{
files: ['**/*.js'],
rules: {
// Override rules
// ---
// This level of this configuration contains rules which override options or disable rules in the base
// configurations in JavaScript.
// Increase the max line length to 120. The rest of this setting is copied from the AirBnB config.
'max-len': ['error', 120, 2, {
ignoreUrls: true,
ignoreComments: false,
ignoreRegExpLiterals: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
}],
// Restrict the use of backticks to declare a normal string. Template literals should only be used when the
// template string contains placeholders. The rest of this setting is copied from the AirBnb config.
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
// the server side Slack API uses snake_case for parameters often
// for mocking and override support, we need to allow snake_case
// Allow leading underscores for parameter names, which is used to acknowledge unused variables in TypeScript.
// Also, enforce camelCase naming for variables. Ideally, the leading underscore could be restricted to only
// unused parameter names, but this rule isn't capable of knowing when a variable is unused. The camelcase and
// no-underscore-dangle rules are replaced with the naming-convention rule because this single rule can serve
// both purposes, and it works fine on non-TypeScript code.
camelcase: 'off',
'no-underscore-dangle': 'off',
// Remove the minProperties option for enforcing line breaks between braces. The AirBnB config sets this to 4,
// which is arbitrary and not backed by anything specific in the style guide. If we just remove it, we can
// rely on the max-len rule to determine if the line is too long and then enforce line breaks. Overrides AirBnB
// styles.
'object-curly-newline': ['error', { multiline: true, consistent: true }],
},
},
{
files: ['src/test/*.js'],
rules: {
// Test-specific rules
// ---
// Rules that only apply to JavaScript _test_ source files
// With Mocha as a test framework, it is sometimes helpful to assign
// shared state to Mocha's Context object, for example in setup and
// teardown test methods. Assigning stub/mock objects to the Context
// object via `this` is a common pattern in Mocha. As such, using
// `function` over the the arrow notation binds `this` appropriately and
// should be used in tests. So: we turn off the prefer-arrow-callback
// rule.
// See https://github.com/slackapi/bolt-js/pull/1012#pullrequestreview-711232738
// for a case of arrow-vs-function syntax coming up for the team
'prefer-arrow-callback': 'off',
// Using ununamed functions (e.g., null logger) in tests is fine
'func-names': 'off',
// In tests, don't force constructing a Symbol with a descriptor, as
// it's probably just for tests
'symbol-description': 'off',
},
},
],
};
+2 -2
View File
@@ -13,8 +13,8 @@ All you need to work with this project is a supported version of [Node.js](https
### Developing
Iterate quickly by developing and testing a local version of this action using `npm run local`.
Information on setting up and configuring mocked events can be found in [`.github/workflows/local/README.md`](./workflows/local/README.md).
Iterate quickly by developing and testing all techniques of this action with a local version of this action using `npm run dev`.
Information on setting up and configuring mocked events can be found in [`.github/resources/README.md`](./resources/README.md).
### Testing
+1 -1
View File
@@ -1,4 +1,4 @@
### Summary
### Summary
Describe the goal of this PR. Mention any related Issue numbers.
@@ -1,9 +1,12 @@
{
"pull_request": {
"user": {
"login": "dependabot"
},
"title": "Bump actions/checkout from 3 to 4",
"html_url": "https://github.com/slackapi/slack-github-action/pull/238"
},
"repository": {
"full_name": "slackapi/slack-github-action"
},
"sender": {
"login": "dependabot"
}
}
+7
View File
@@ -0,0 +1,7 @@
# Move this file to .env before starting
# https://api.slack.com/apps/A0123456789
export SLACK_BOT_TOKEN=xoxb-01010101-example
export SLACK_CHANNEL_ID=C0123456789
export SLACK_INCOMING_WEBHOOK=https://hooks.slack.com/services/T0123456789/B0123456789/abcdefghijklmnopqrstuvwxyz
export SLACK_WEBHOOK_TRIGGER=https://hooks.slack.com/triggers/T0123456789/00000000000/abcdefghijklmnopqrstuvwxyz
+6
View File
@@ -0,0 +1,6 @@
# Application values
.env.*
.env
!.env.example
.slack/apps.json
.slack/apps.*.json
+5
View File
@@ -0,0 +1,5 @@
{
"experiments": ["bolt"],
"project_id": "c4805b41-d1ce-4ea0-b297-ed2f8c64c267"
}
@@ -0,0 +1,35 @@
{
"text": ":email: A GitHub Action `${{ github.eventName }}` event status is ${{ env.JOB_STATUS }}",
"attachments": [
{
"color": "${{ env.ATTACHMENT_COLOR }}",
"fields": [
{
"title": "Repository",
"short": false,
"value": "<${{ github.payload.repository.html_url }}|${{ github.payload.repository.full_name }}>"
},
{
"title": "Ref",
"short": false,
"value": "${{ github.ref }}"
},
{
"title": "Commit",
"short": false,
"value": "<${{ github.payload.repository.html_url }}/commit/${{ github.sha }}|${{ github.sha }}>"
},
{
"title": "Author",
"short": false,
"value": "<https://github.com/${{ github.actor }}|${{ github.actor }}>"
},
{
"title": "Workflow",
"short": false,
"value": "<${{ github.payload.repository.html_url }}/actions/runs/${{ github.runId }}|${{ github.workflow }}>"
}
]
}
]
}
+71
View File
@@ -0,0 +1,71 @@
{
"_metadata": {
"major_version": 2
},
"display_information": {
"name": "slacktion",
"description": "an application for developing the Slack GitHub Action"
},
"features": {
"app_home": {
"messages_tab_enabled": true,
"messages_tab_read_only_enabled": true
},
"bot_user": {
"display_name": "slacktion"
}
},
"oauth_config": {
"scopes": {
"bot": [
"chat:write",
"chat:write.public",
"files:write",
"incoming-webhook",
"reactions:write"
]
}
},
"settings": {
"org_deploy_enabled": true,
"incoming_webhooks": {
"incoming_webhooks_enabled": true
}
},
"workflows": {
"event_notifications": {
"title": "GitHub event notifications",
"description": "A new changeset has been discovered",
"input_parameters": {
"properties": {
"author": {
"type": "string"
},
"channel_id": {
"type": "slack#/types/channel_id"
},
"repo_name": {
"type": "string"
},
"status": {
"type": "string"
},
"event_url": {
"type": "string"
}
},
"required": ["author", "channel_id", "repo_name", "status", "event_url"]
},
"steps": [
{
"id": "0",
"function_id": "slack#/functions/send_message",
"inputs": {
"channel_id": "{{inputs.channel_id}}",
"message": ":ship: {{inputs.repo_name}}(@{{inputs.author}}): `{{inputs.status}}` <{{inputs.event_url}}|Check it out here>!"
}
}
]
}
}
}
@@ -0,0 +1,23 @@
{
"type": "webhook",
"name": "Post the latest actions from GitHub",
"description": "Share event information to a channel",
"workflow": "#/workflows/event_notifications",
"inputs": {
"author": {
"value": "{{data.author}}"
},
"channel_id": {
"value": "{{data.channel_id}}"
},
"repo_name": {
"value": "{{data.repo_name}}"
},
"status": {
"value": "{{data.status}}"
},
"event_url": {
"value": "{{data.event_url}}"
}
}
}
+141
View File
@@ -0,0 +1,141 @@
# Resources during testing and development
For a quick development experience and fast testing setup, the app needed with
this action is configured using the [app manifest][manifest] and can be used in
experiments with the [Slack CLI][cli].
## Overview
This app showcases all three techniques of sending data into Slack and follows
patterns found in the integration tests.
- **Technique 1** Slack Workflow Builder: Use a Slack webhook trigger to start a
workflow in Slack Workflow Builder.
- **Technique 2** Slack API method: Call a Slack API method using a token and
data provided through the GitHub workflow.
- **Technique 3** Incoming webhook: Post a message to a Slack channel using an
incoming webhook.
Configurations for the Slack app and workflow, and the GitHub Actions workflow
are found in the following files:
- Slack app setup: [`.github/resources/.slack/manifest.json`][slacktion]
- GitHub Actions steps: [`.github/workflows/develop.yml`][develop]
Either the techniques or app setup and workflow steps can be adjusted during
testing and development. For experimenting with new changes, we recommend using
the [steps for development](#experimenting-for-development) while the
[steps for CI](#testing-in-ci) is useful when using this app in an actual GitHub
Actions workflow.
### Getting started
Start by gathering credentials of an application equipped for action:
```sh
$ slack install
$ slack trigger create # SLACK_WEBHOOK_TRIGGER
$ slack deploy # SLACK_BOT_TOKEN and SLACK_INCOMING_WEBHOOK
```
Gather the **webhook trigger** from the output of the `trigger create` command
and collect the **bot token** and **incoming webhook** from app settings.
Where these are stored will depend on the configurations to run and will follow
in the next sections.
Also be sure to add the new bot to a channel, and while keeping track of that
**channel ID** for posting messages or errors happen.
## Running the workflows
Both setups share the same starting test suite to make sure the similar examples
as in the `README.md` are correct, but changes while testing are encouraged!
The app uses the values stored in `.slack` and can also be adjusted for changing
scopes or workflows.
### Testing in CI
Run the workflow as a complete CI check for the changes upstream.
**Requirements**:
- The credentials collected above
- Access to secrets for the repo
The saved `test.yml` include common workflows from the `README.md` and other
code checks including linting and tests.
Add gathered credentials and [secrets to save][secrets] to the repository being
tested. Required values include:
- `SLACK_BOT_TOKEN`: xoxb-01010101-example
- `SLACK_CHANNEL_ID`: C0123456789
- `SLACK_INCOMING_WEBHOOK`:
https://hooks.slack.com/services/T0123456789/B0123456789/abcdefghijklmnopqrstuvwxyz
- `SLACK_WEBHOOK_TRIGGER`:
https://hooks.slack.com/triggers/T0123456789/00000000000/abcdefghijklmnopqrstuvwxyz
### Experimenting for development
Run the workflow to post messages without pushing changes upstream.
**Requirements**:
- The credentials collected above
- An installation of [`nektos/act`](https://github.com/nektos/act)
- A running instance of [Docker](https://www.docker.com)
The saved `develop.yml` workflows use the same `webhook` and `method` examples
but skip tests that happen during upstream checks:
```sh
$ cp .env.example .env # Create credentials
$ vim .env # Update credentials
$ cat .env # Reveal credentials
export SLACK_BOT_TOKEN=xoxb-01010101-example
export SLACK_CHANNEL_ID=C0123456789
export SLACK_INCOMING_WEBHOOK=https://hooks.slack.com/services/T0123456789/B0123456789/abcdefghijklmnopqrstuvwxyz
export SLACK_WEBHOOK_TRIGGER=https://hooks.slack.com/triggers/T0123456789/00000000000/abcdefghijklmnopqrstuvwxyz
```
Environment variables and credentials should be set in the created `.env` file
for use in workflows and actions.
Once credentials are configured and workflows updated, the following command
runs the workflow using `act` and the above settings:
```sh
$ npm run dev # Test techniques
...
[Local run/run] 🏁 Job succeeded
```
## Configuring different things
### Updating the workflow
The `develop.yml` file contains the workflow used for testing. Updates to these
steps can be made to test various functionalities and edge cases.
The same applies to test workflows in the `test.yml` file! However, some hope of
keeping examples in the `README.md` similar to the tests could be neat.
### Changing secrets
To use `${{ secrets.* }}` in the workflow, add more environment variable values
to the `.env` file or update the secrets saved to the repository.
### Mocking event payloads
Different event payloads can be mocked in development with changes to the values
of the `.github/resources/.actions/event.json` file.
Reference: https://docs.github.com/en/webhooks/webhook-events-and-payloads
[cli]: https://api.slack.com/automation/cli/commands
[develop]: ../workflows/develop.yml
[manifest]: https://api.slack.com/concepts/manifests
[secrets]: https://github.com/slackapi/slack-github-action/settings/secrets/actions
[slacktion]: ./.slack/manifest.json
@@ -1,27 +0,0 @@
{
"text": "A GitHub Action Event ${{ github.eventName }} has ${{ env.JOB_STATUS }}",
"attachments": [{
"color": "${{ env.ATTACHMENT_COLOR }}",
"fields": [{
"title": "Repository",
"short": false,
"value": "<${{ github.payload.repository.html_url }}|${{ github.payload.repository.full_name }}>"
}, {
"title": "Ref",
"short": false,
"value": "${{ github.ref }}"
}, {
"title": "Commit",
"short": false,
"value": "<${{ github.payload.repository.html_url }}/commit/${{ github.sha }}|${{ github.sha }}>"
}, {
"title": "Author",
"short": false,
"value": "<https://github.com/${{ github.actor }}|${{ github.actor }}>"
}, {
"title": "Workflow",
"short": false,
"value": "<${{ github.payload.repository.html_url }}/actions/runs/${{ github.runId }}|${{ github.workflow }}>"
}]
}]
}
+11
View File
@@ -0,0 +1,11 @@
{
"runtime": "actions",
"hooks": {
"deploy": "open https://api.slack.com/apps || true",
"get-manifest": "cat ./.slack/manifest.json #"
},
"config": {
"protocol-version": ["default"],
"trigger-paths": ["./.slack/webhook-trigger.json"]
}
}
+132
View File
@@ -0,0 +1,132 @@
name: Development run
# Requires mocking the "public" event to begin this workflow and avoid actual runs
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows
on:
public:
jobs:
run:
runs-on: ubuntu-latest
steps:
- name: Checkout action
uses: actions/checkout@v4
- name: Setup the Node runtime for this project
uses: actions/setup-node@v4
with:
cache: npm
cache-dependency-path: package-lock.json
node-version-file: .nvmrc
- name: Install dependencies
run: npm install
- name: Package the build
run: npm run build
- name: Start a workflow with a webhook trigger
id: wfb
uses: ./
with:
errors: true
webhook: ${{ secrets.SLACK_WEBHOOK_TRIGGER }}
webhook-type: webhook-trigger
payload: |
author: ${{ github.event.sender.login }}
channel_id: ${{ secrets.SLACK_CHANNEL_ID }}
event_url: ${{ github.event.pull_request.html_url }}
repo_name: ${{ github.event.repository.full_name }}
status: ${{ job.status }}
- name: Post a token message into channel
id: api
uses: ./
with:
errors: true
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
text: ":checkered_flag: Action happens at <https://github.com/${{ github.repository }}>"
- name: Initiate the deployment sequence
id: slack
uses: ./
with:
errors: true
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
text: ":eyes: Testing started..."
attachments:
- color: "dbab09"
fields:
- title: "Status"
short: true
value: "In Progress"
- name: Write starting statistics
id: stats
uses: ./
with:
errors: true
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ steps.slack.outputs.channel_id }}
text: "Started at `${{ steps.slack.outputs.time }}`"
thread_ts: "${{ steps.slack.outputs.ts }}"
- name: Countdown
run: sleep 3
- name: Launch time is now
id: finished
uses: ./
with:
errors: true
method: chat.update
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
ts: "${{ steps.slack.outputs.ts }}"
text: ":microscope: Testing finished!"
attachments:
- color: "28a745"
fields:
- title: "Status"
short: true
value: "Completed"
- name: Include ending statistics
uses: ./
with:
errors: true
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ steps.slack.outputs.channel_id }}
text: "Finished at `${{ steps.finished.outputs.time }}`"
thread_ts: "${{ steps.stats.outputs.thread_ts }}"
- name: Celebrate wins
uses: ./
with:
errors: true
method: reactions.add
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
timestamp: ${{ steps.slack.outputs.ts }}
name: "eye-in-speech-bubble"
- name: Upload this workflow file
id: file
uses: ./
with:
errors: true
method: files.uploadV2
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel_id: ${{ secrets.SLACK_CHANNEL_ID }}
initial_comment: ":robot_face: The codes exists here"
file: .github/workflows/develop.yml
filename: action.yml
- name: Write one final webhook
uses: ./
with:
errors: true
webhook: ${{ secrets.SLACK_INCOMING_WEBHOOK }}
webhook-type: incoming-webhook
payload: |
text: "end."
-4
View File
@@ -1,4 +0,0 @@
# Rename this file to .env and update any variables to get started
SLACK_BOT_TOKEN=xoxb-01010101-abcdefgh
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T0123456789/B0123456789/abcdefghijklmnopqrstuvwxyz
-2
View File
@@ -1,2 +0,0 @@
# Secret variables
.env
-25
View File
@@ -1,25 +0,0 @@
# Local runs
For a simple development experience, a local version of this action can be used in experiments.
**Requirements**:
- An installation of [nektos/act](https://github.com/nektos/act)
## Configuring secrets
To use `${{ secrets.* }}` in the workflow, move `.env.example` to `.env` and update any variables.
## Mocking event payloads
Different event payloads can be mocked directly with changes to the `event.json` file.
Reference: https://docs.github.com/en/webhooks/webhook-events-and-payloads
## Updating the workflow
The `local.yml` file contains the workflow used for testing. Updates to these steps can be made to test various functionalities.
## Running an experiment
Run the workflow using `npm run local`. The above configurations will be used to simulate an actual workflow run.
-28
View File
@@ -1,28 +0,0 @@
name: Local run
# Requires mocking the "public" event to begin this workflow and avoid actual runs
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows
on:
public:
jobs:
run:
runs-on: ubuntu-latest
steps:
- name: Checkout action
uses: actions/checkout@v4
- name: Build action
run: npm install && npm run build
- name: Send a message into channel
id: slack
uses: ./.
with:
payload: |
{
"repository": "${{ github.repository }}",
"pull_request_username": "${{ github.event.pull_request.user.login }}",
"pull_request_title": ${{ toJSON(github.event.pull_request.title) }},
"pull_request_url": "${{ github.event.pull_request.html_url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
-126
View File
@@ -1,126 +0,0 @@
name: Tests
on:
pull_request_target:
push:
branches:
- main
jobs:
tests:
name: Run tests
runs-on: ubuntu-latest
environment: staging
steps:
- name: "build: checkout the latest changes"
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: "build: install the required dependencies"
run: npm ci
- name: "build: package the latest changes"
run: npm run build
- name: "unit(test): perform unit test checks"
run: npm test
- name: "unit(test): check unit test coverage"
run: npm run test:gen-cov
- name: "unit(test): upload coverage to CodeCov"
uses: codecov/[email protected]
with:
directory: ./coverage
token: ${{ secrets.CODECOV_TOKEN }}
- name: "integration(botToken): post a message to channel"
id: slackToken
uses: ./
with:
channel-id: ${{ secrets.SLACK_CHANNEL_ID }}
slack-message: 'CI Post from slack-send GitHub Action! Succeeded!!'
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
- name: "integration(botToken): confirm a message was posted"
run: test -n "${{ steps.slackToken.outputs.ts }}"
- name: "integration(botToken): post a threaded response"
id: slackThreadResponse
uses: ./
with:
channel-id: ${{ secrets.SLACK_CHANNEL_ID }}
payload: |
{
"text": "This message should be posted as a response in thread",
"thread_ts": "${{ steps.slackToken.outputs.thread_ts }}"
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
- name: "integration(botToken): confirm a response was posted"
run: test -n "${{ steps.slackThreadResponse.outputs.ts }}"
- name: "integration(wfb): save the push event trigger commit URL"
if: "contains(github.event_name, 'push')"
run: |
url=${{ github.event.head_commit.url }}
echo "URL=$url" >> "$GITHUB_ENV"
- name: "integration(wfb): save the pull request event trigger commit URL"
if: "contains(github.event_name, 'pull_request')"
run: |
url=${{ github.event.pull_request.url }}
echo "URL=$url" >> "$GITHUB_ENV"
- name: "integration(wfb): send a payload via workflow builder webhook"
id: slackWorkflow
uses: ./
with:
# Workflow builder webhooks need to know the name of the keys in the payload in advance. Without normalizing, the github context payload keys can differ based on the GitHub trigger event type
# Normalized payload with info pulled out from GitHub trigger event
payload: "{\"author\":\"${{ github.event.sender.login }}\",\"url\":\"${{ env.URL}}\", \"repoName\":\"${{ github.event.repository.full_name }}\", \"status\":\"${{ job.status }}\"}"
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: "integration(wfb): confirm a payload was sent"
run: test -n "${{ steps.slackWorkflow.outputs.time }}"
- name: "integration(incoming): post a message via incoming webhook"
id: slackIncoming
uses: ./
with:
payload: "{\"text\":\"Incoming Webhook test for slack send\", \"blocks\":[{\"type\":\"section\",\"text\":{\"type\":\"plain_text\",\"text\":\"A post by Slack Send GitHub Action. Testing Incoming webhooks\",\"emoji\":true}}]}"
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_INCOMING_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: "integration(incoming): confirm a webhook was posted"
run: test -n "${{ steps.slackIncoming.outputs.time }}"
- name: "integration(incoming): reveal contents of the github payload"
run: echo $JSON
env:
JSON: ${{ toJSON(github) }}
- name: "integration(incoming): post a message via payload file"
id: slackPayloadFile
uses: ./
with:
payload-file-path: ./.github/resources/payload-notification.json
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_INCOMING_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
JOB_STATUS: ${{ job.status }}
ATTACHMENT_COLOR: ${{ (job.status == 'success' && 'good') || (job.status == 'failure' && 'danger') || 'warning' }}
- name: "integration(incoming): confirm a payload file was posted"
run: test -n "${{ steps.slackPayloadFile.outputs.time }}"
- name: "chore(health): check up on recent changes to the health score"
uses: slackapi/[email protected]
with:
codecov_token: ${{ secrets.CODECOV_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
extension: js
-21
View File
@@ -1,21 +0,0 @@
name: Publish
on:
release:
types: [published, edited]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
- name: Install deps and build
run: npm ci && npm run build
- uses: JasonEtco/build-and-tag-action@v2
env:
GITHUB_TOKEN: ${{ github.token }}
with:
tag_name: ${{ github.event.release.tag_name }}
+35
View File
@@ -0,0 +1,35 @@
name: Publish
on:
release:
types: [published, edited]
jobs:
build:
name: Build and tag a new version
runs-on: ubuntu-latest
steps:
- name: Checkout the current code
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name }}
- name: Configure the runtime node
uses: actions/setup-node@v4
with:
cache: npm
cache-dependency-path: package-lock.json
node-version-file: .nvmrc
- name: Install project dependencies
run: npm ci
- name: Build a production release
run: npm run build
- name: Distribute the latest tagged release
uses: teunmooij/[email protected]
with:
template: javascript-action
env:
GITHUB_TOKEN: ${{ github.token }}
+232
View File
@@ -0,0 +1,232 @@
name: Tests
on:
pull_request_target:
push:
branches:
- main
workflow_dispatch:
jobs:
tests:
name: Run tests
runs-on: ubuntu-latest
environment: staging
steps:
- name: "build: checkout the latest changes"
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: "build: setup the node runtime"
uses: actions/setup-node@v4
with:
cache: npm
cache-dependency-path: package-lock.json
node-version-file: .nvmrc
- name: "build: install the required dependencies"
run: npm ci
- name: "unit(test): perform lints and formatting checks"
run: npm run lint
- name: "unit(test): perform check of typings"
run: npm run check
- name: "unit(test): perform unit test checks"
run: npm test
- name: "build: package code for distribution"
run: npm run build
- name: "unit(test): upload coverage to CodeCov"
uses: codecov/[email protected]
with:
directory: ./coverage
token: ${{ secrets.CODECOV_TOKEN }}
- name: "pretest(inputs): save the push event trigger commit URL"
if: "contains(github.event_name, 'push')"
run: |
url=${{ github.event.head_commit.url }}
echo "EVENT_URL=$url" >> "$GITHUB_ENV"
- name: "pretest(inputs): save the pull request event trigger commit URL"
if: "contains(github.event_name, 'pull_request')"
run: |
url=${{ github.event.pull_request.html_url }}
echo "EVENT_URL=$url" >> "$GITHUB_ENV"
- name: "integration(wfb): send a payload to workflow builder via webhook trigger"
id: wfb
uses: ./
with:
errors: true
webhook: ${{ secrets.SLACK_WEBHOOK_TRIGGER }}
webhook-type: webhook-trigger
payload: |
author: ${{ github.event.sender.login }}
channel_id: ${{ secrets.SLACK_CHANNEL_ID }}
event_url: ${{ env.EVENT_URL}}
repo_name: ${{ github.event.repository.full_name }}
status: ${{ job.status }}
- name: "integration(wfb): confirm a payload was sent"
run: test -n "${{ steps.wfb.outputs.time }}"
- name: "integration(botToken): post a message to channel"
id: message
uses: ./
with:
errors: true
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
text: ":checkered_flag: Action happens at <https://github.com/${{ github.repository }}>"
- name: "integration(method): confirm a message was posted"
run: test -n "${{ steps.message.outputs.ts }}"
- name: "integration(method): post a message with blocks"
id: blocks
uses: ./
with:
errors: true
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
text: ":eyes: Event received..."
attachments:
- color: "dbab09"
fields:
- title: "Status"
short: true
value: "Processing"
- name: "integration(method): confirm the blocks were posted"
run: test -n "${{ steps.blocks.outputs.ts }}"
- name: "integration(method): post a threaded message"
id: timer
uses: ./
with:
errors: true
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
text: "Started at `${{ steps.blocks.outputs.time }}`"
thread_ts: "${{ steps.blocks.outputs.ts }}"
- name: "integration(incoming): confirm the thread started"
run: test -n "${{ steps.timer.outputs.time }}"
- name: "integration(method): wait to mock event processing"
run: sleep 3
- name: "integration(method): update the original message"
id: finished
uses: ./
with:
errors: true
method: chat.update
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
ts: "${{ steps.blocks.outputs.ts }}"
text: ":gear: Event processed!"
attachments:
- color: "28a745"
fields:
- title: "Status"
short: true
value: "Completed"
- name: "integration(method): post another threaded message"
id: done
uses: ./
with:
errors: true
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ steps.blocks.outputs.channel_id }}
text: "Finished at `${{ steps.finished.outputs.time }}`"
thread_ts: "${{ steps.timer.outputs.thread_ts }}"
- name: "integration(method): post a file into a channel"
id: file
uses: ./
with:
errors: true
method: files.uploadV2
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel_id: ${{ secrets.SLACK_CHANNEL_ID }}
initial_comment: ":robot_face: The codes exists here"
file: .github/workflows/test.yml
filename: action.yml
- name: "integration(method): react to the completed update message"
uses: ./
with:
errors: true
method: reactions.add
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
timestamp: ${{ steps.blocks.outputs.ts }}
name: "tada"
- name: "integration(method): confirm the thread ended"
run: test -n "${{ steps.done.outputs.time }}"
- name: "integration(incoming): post a message via incoming webhook"
id: incoming
uses: ./
with:
errors: true
webhook: ${{ secrets.SLACK_INCOMING_WEBHOOK }}
webhook-type: incoming-webhook
payload: |
text: "Incoming webhook test for slack send"
blocks:
- type: section
text:
type: plain_text
text: ":link: A message was received via incoming webhook"
emoji: true
- name: "integration(incoming): confirm a webhook was posted"
run: test -n "${{ steps.incoming.outputs.time }}"
- name: "integration(incoming): reveal contents of the github payload"
run: echo $JSON
env:
JSON: ${{ toJSON(github) }}
- name: "integration(incoming): post a message via payload file"
id: payload_file
uses: ./
with:
errors: true
payload-file-path: ./.github/resources/.slack/incoming-webhook.json
payload-templated: true
webhook: ${{ secrets.SLACK_INCOMING_WEBHOOK }}
webhook-type: incoming-webhook
env:
JOB_STATUS: ${{ job.status }}
ATTACHMENT_COLOR: ${{ (job.status == 'success' && 'good') || (job.status == 'failure' && 'danger') || 'warning' }}
- name: "integration(incoming): confirm a payload file was posted"
run: test -n "${{ steps.payload_file.outputs.time }}"
- name: "chore(health): check up on recent changes to the health score"
uses: slackapi/[email protected]
with:
codecov_token: ${{ secrets.CODECOV_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
extension: js
include: src
+9 -3
View File
@@ -1,5 +1,11 @@
node_modules
.DS_Store
.nyc_output
# Build artifacts
dist
# Development dependencies
node_modules
# Extra files
.DS_Store
# Testing remnants
coverage
+12
View File
@@ -1,3 +1,15 @@
{
"require": [
"@actions/core",
"@actions/github",
"@slack/web-api",
"axios",
"axios-retry",
"flat",
"https-proxy-agent",
"markup-js",
"mocha-suppress-logs"
],
"recursive": true,
"timeout": 3000
}
+1
View File
@@ -0,0 +1 @@
20
+436 -218
View File
@@ -1,328 +1,546 @@
# Slack Send GitHub Action
> the GitHub Action for sending data to Slack
[![codecov](https://codecov.io/gh/slackapi/slack-github-action/graph/badge.svg?token=OZNX7FHN78)](https://codecov.io/gh/slackapi/slack-github-action)
Send data into Slack using this GitHub Action!
## Example workflows
## Example Workflows
For examples on how to leverage this Action in workflows, check out
[example workflows we have][examples] available.
For examples on how to leverage this in your workflows, check out the [example workflows we have](https://github.com/slackapi/slack-github-action/tree/main/example-workflows).
## Sending variables
## Sending Variables
There are different [techniques to send data](#sending-techniques) into Slack
and whichever one is chosen will require a certain set of customized inputs, as
described later.
You can provide data to send with this GitHub Action from various sources:
You can provide data to send to Slack from this GitHub Action and either source:
* The default event [context][event-context] with a [payload][event-payload] matching the GitHub event.
* A custom JSON payload with optional [variables][variables] provided in the GitHub Action step.
- The default event [context][event-context] with a [payload][event-payload]
matching the GitHub event.
- A custom payload with optional [variables][variables] provided in the GitHub
Action step.
The custom payload can be provided inline in your GitHub workflow or from a file, [detailed with technique 1](#technique-1-slack-workflow-builder), and will be used instead of the default event context if included.
These input options are valid for all techniques, but some techniques require
specific constraints with certain requirements for valid inputs.
## How to Send Data to Slack
Additional [configurations](#additional-configurations) and other details are
also available for more customizations to the provided payload.
This package has three different techniques to send data to Slack:
## Sending techniques
1) Send data to Slack's Workflow Builder (requires a paid Slack instance).
2) Send data via a Slack app to post to a specific channel (use an existing custom app or create a new one).
3) Send data via a Slack Incoming Webhook URL (use an existing custom app or create a new one).
This Action offers three different techniques to send data to Slack:
The recommended way to use this action is with Slack's Workflow Builder (if you're on a paid Slack plan).
1. [**Technique 1**](#technique-1-slack-workflow-builder): Send data with a
webhook to start a workflow in [Workflow Builder][wfb].
2. [**Technique 2**](#technique-2-slack-api-method): Send data using
[a Slack API method][methods] and a secret token with required scopes.
3. [**Technique 3**](#technique-3-slack-incoming-webhook): Send data as a
message with a Slack [incoming webhook][incoming-webhook] URL.
### Technique 1: Slack Workflow Builder
> ❗️ This approach requires a paid Slack plan; it also doesn't support any text formatting
> :memo: This technique requires [a Slack paid plan][plans] to use Workflow
> Builder.
This technique sends data into Slack via a webhook URL created using [Slack's Workflow builder](https://slack.com/features/workflow-automation). Follow [these steps to create a Slack workflow using webhooks][create-webhook]. The Slack workflow webhook URL will be in the form `https://hooks.slack.com/workflows/....`.
As part of the [workflow setup](https://slack.com/help/articles/360041352714-Create-more-advanced-workflows-using-webhooks#workflow-setup),
you will need to define expected variables in the payload the webhook will receive (described in the "Create custom variables" section of the docs). If these variables are missing in the payload, an error is returned.
To match the webhook input format expected by Workflow Builder, the payload will be flattened and stringified (all nested keys are moved to the top level) before being sent. The default delimiter used to flatten payloads is a period (".") but should be changed to an underscore ("_") using the `payload-delimiter` parameter if you're using nested payloads as input values in your own workflows.
This technique sends data to Slack using a webhook to start a workflow created
using Slack [Workflow Builder][wfb].
#### Setup
* [Create a Slack workflow webhook][create-webhook].
* Copy the webhook URL (`https://hooks.slack.com/workflows/....`) and [add it as a secret in your repo settings][repo-secret] named `SLACK_WEBHOOK_URL`.
* Add a step to your GitHub action to send data to your Webhook.
* Configure your Slack workflow to use variables from the incoming payload from the GitHub Action. You can select where you want to post the data and how you want to format it in Slack's workflow builder interface.
Start in Slack to create a Slack workflow:
1. [Create a Slack workflow][wfb-create] that starts from a webhook.
2. Copy the webhook URL and [add it as a repository secret][repo-secret] called
`SLACK_WEBHOOK_URL`.
3. [Add this Action as a step][job-step] to your GitHub workflow and provide an
input payload to send to the webhook.
4. Configure your Slack workflow to use the payload variables sent from the
GitHub Action. You can then update the steps of the Slack workflow to use
these values in creative and clever ways.
The webhook URL will resemble something like so:
```txt
https://hooks.slack.com/triggers/T0123456789/3141592653589/c6e6c0d868b3054ca0f4611a5dbadaf
```
#### Usage
Add this Action as a [step][job-step] to your project's GitHub Action Workflow file:
Update the input payloads sent from this GitHub Action to your Slack workflow
using the following options:
##### Sending values from the default GitHub event context
In the example below, the default GitHub event [context][event-context] and
event [payload][event-payload] associated with the job that started the GitHub
workflow are sent to the provided webhook URL:
```yaml
- name: Send GitHub Action trigger data to Slack workflow
id: slack
uses: slackapi/[email protected]
- name: Send GitHub Action data to a Slack workflow
uses: slackapi/slack-github-action@v2-development
with:
payload-delimiter: "_"
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
```
or
Accessing variables sent to [Workflow Builder][wfb] with a webhook require that
the payload variables are flattened with stringified values. Nested variables in
the provided payload can be both flattened and also stringified with the
`payload-delimiter` option or changed with other
[configurations](#additional-configurations) to match this format expected from
Workflow Builder.
##### Providing parsed payload information as strings
Provided input values for payload information are sent to the webhook URL after
the job is started:
```yaml
- name: Send custom JSON data to Slack workflow
id: slack
uses: slackapi/[email protected]
- name: Send custom event details to a Slack workflow
uses: slackapi/slack-github-action@v2-development
with:
# This data can be any valid JSON from a previous step in the GitHub Action
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
payload: |
{
"key": "value",
"foo": "bar"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
status: "${{ job.status }}"
option: "false"
```
or
##### Gathering details of the payload from a saved file
> If the `payload` is provided it will take preference over `payload-file-path`
Input values for the payload to be sent can also be provided in a file, either
in JSON or YAML format:
```yaml
- name: Send custom JSON data to Slack workflow
id: slack
uses: slackapi/[email protected]
- name: Send a saved artifact to a Slack workflow
uses: slackapi/slack-github-action@v2-development
with:
payload-file-path: "./payload-slack-content.json"
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
payload-file-path: "./artifacts.json"
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
```
> To send the payload file JSON as is, without replacing templated values with
> `github.context` or `github.env`, set `payload-file-path-parsed` to `false`.
> Default: `true`.
### Technique 2: Slack API method
```yaml
- name: Send custom JSON data to Slack workflow
id: slack
uses: slackapi/[email protected]
with:
payload-file-path: "./payload-slack-content.json"
payload-file-path-parsed: false
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
```
### Technique 2: Slack App
By creating a new Slack app or using an existing one, this approach allows your GitHub Actions job to post a message in a Slack channel or direct message by utilizing the [chat.postMessage](https://api.slack.com/methods/chat.postMessage) API method. Using this approach you can instantly post a message without setting up Slack workflows.
A bot token or user token or [token of some other kind][tokens] must be used to
call one of [the Slack API methods][methods] with this technique.
#### Setup
* [Create a Slack App][apps] for your workspace (alternatively use an existing app you have already created and installed).
* Add the [`chat:write`](https://api.slack.com/scopes/chat:write) bot scope under **OAuth & Permissions**.
* Install the app to your workspace.
* Copy the app's Bot Token from the **OAuth & Permissions** page and [add it as a secret in your repo settings][repo-secret] named `SLACK_BOT_TOKEN`.
* Invite the bot user into the channel you wish to post messages to (`/invite @bot_user_name`).
Different Slack API [methods][methods] require different [scopes][scopes], but
setup should be similar for all methods:
1. [Create a Slack app][apps-new] for your workspace or use an existing app.
2. Depending on the Slack API [method][methods] you wish to call, add the
required **scopes** to your app under the **OAuth & Permissions** page on
[app settings][apps].
3. Install the app to your workspace using the **Install App** page.
4. Once your app is installed to a workspace, a new [token][tokens] with your
app's specified scopes will be minted for that workspace. It is worth noting
that tokens are only valid for a single workspace! Find the token on the
**OAuth & Permissions** page.
5. Add the token as [a repository secret][repo-secret] called `SLACK_BOT_TOKEN`
or something similar and memorable.
6. [Add this Action as a step][job-step] to your GitHub workflow and provide an
input payload to send to the method.
Methods that require an app configuration token should gather this token from
the [app configuration token][config-tokens] settings instead of from a specific
app since this token is associated with the workspace.
#### Usage
Add this Action as a [step][job-step] to your project's GitHub Action Workflow file:
Choosing inputs for these steps is left as an exercise for the actioneer since
each of the Slack API methods requires certain values and specific parameters,
but these snippets might be helpful when starting.
##### Posting a message with text
Posting a message with the [`chat.postMessage`][chat.postMessage] method can be
achieved by adding this step to a job in your GitHub workflow and inviting the
bot associated with your app to the channel for posting:
```yaml
- name: Post to a Slack channel
id: slack
uses: slackapi/[email protected]
uses: slackapi/slack-github-action@v2-development
with:
# Slack channel id, channel name, or user id to post message.
# See also: https://api.slack.com/methods/chat.postMessage#channels
# You can pass in multiple channels to post to by providing a comma-delimited list of channel IDs.
channel-id: 'CHANNEL_ID,ANOTHER_CHANNEL_ID'
# For posting a simple plain text message
slack-message: "GitHub build result: ${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head_commit.url }}"
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
text: "howdy <@channel>!"
```
Using JSON payload for constructing a message is also available:
##### Posting a message with blocks
More complex message layouts, such as messages made with [Block Kit][block-kit]
blocks, can also be sent with one of the Slack API methods:
```yaml
- name: Post to a Slack channel
uses: slackapi/slack-github-action@v2-development
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
text: "GitHub Action build result: ${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head_commit.url }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "GitHub Action build result: ${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head_commit.url }}"
```
##### Updating a message
Updating a message after it's posted can be done with the
[`chat.update`][chat.update] method and chaining multiple steps together using
outputs from past steps as inputs to current ones:
```yaml
- name: Initiate the deployment launch sequence
id: slack
uses: slackapi/slack-github-action@v1.27.1
uses: slackapi/slack-github-action@v2-development
with:
# Slack channel id, channel name, or user id to post message.
# See also: https://api.slack.com/methods/chat.postMessage#channels
channel-id: 'CHANNEL_ID'
# For posting a rich message using Block Kit
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
{
"text": "GitHub Action build result: ${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head_commit.url }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "GitHub Action build result: ${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head_commit.url }}"
}
}
]
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
channel: ${{ secrets.SLACK_CHANNEL_ID }}
text: "Deployment started :eyes:"
attachments:
- color: "dbab09"
fields:
- title: "Status"
short: true
value: "In Progress"
- name: Countdown until launch
run: sleep 10
- name: Update the original message with success
uses: slackapi/slack-github-action@v2-development
with:
method: chat.update
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
ts: "${{ steps.slack.outputs.ts }}"
text: "Deployment finished! :rocket:"
attachments:
- color: "28a745"
fields:
- title: "Status"
short: true
value: "Completed"
```
#### Update the message
##### Replying to a message
If you would like to notify the real-time updates on a build status, you can modify the message your build job posted in the subsequent steps. In order to do this, the steps after the first message posting can have `update-ts: ${{ steps.slack.outputs.ts }}` in their settings. With this, the step updates the already posted channel message instead of posting a new one.
Please note that **the message update step does not accept a channel name.** Set a channel ID for the steps for the actions that update messages.
Posting [threaded replies to a message][messaging-threads] from a past job can
be done by including the `thread_ts` attribute of the parent message in the
`payload`:
```yaml
- id: slack
uses: slackapi/slack-github-action@v1.27.1
- name: Initiate a deployment
uses: slackapi/slack-github-action@v2-development
id: deployment_message
with:
# The following message update step does not accept a channel name.
# Setting a channel ID here for consistency is highly recommended.
channel-id: "CHANNEL_ID"
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
{
"text": "Deployment started (In Progress)",
"attachments": [
{
"pretext": "Deployment started",
"color": "dbab09",
"fields": [
{
"title": "Status",
"short": true,
"value": "In Progress"
}
]
}
]
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
- uses: slackapi/[email protected]
channel: ${{ secrets.SLACK_CHANNEL_ID }}
text: "Deployment started :eyes:"
- name: Conclude the deployment
uses: slackapi/slack-github-action@v2-development
with:
# Unlike the step posting a new message, this step does not accept a channel name.
# Please use a channel ID, not a name here.
channel-id: "CHANNEL_ID"
update-ts: ${{ steps.slack.outputs.ts }}
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
{
"text": "Deployment finished (Completed)",
"attachments": [
{
"pretext": "Deployment finished",
"color": "28a745",
"fields": [
{
"title": "Status",
"short": true,
"value": "Completed"
}
]
}
]
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
channel: ${{ secrets.SLACK_CHANNEL_ID }}
thread_ts: "${{ steps.deployment_message.outputs.ts }}"
text: "Deployment finished! :rocket:"
```
#### Reply to a message
##### Uploading a file
If you want to post a message as a threaded reply, you can populate the `payload` with a `thread_ts` field. This field should equal the `ts` value of the parent message of the thread. If you want to reply to a message previously posted by this Action, you can use the `ts` output provided as the `thread_ts` of a consequent threaded reply, e.g. `"thread_ts": "${{ steps.deployment_message.outputs.ts }}"`.
Please note that **reply to a message does not accept a channel name.** Set a channel ID for the actions that reply to messages in thread.
Calling [a Slack API method][methods] with [`@slack/web-api`][slack-web-api]
makes [uploading a file][files.upload] just another API call with all of the
convenience of the [`files.uploadV2`][files.uploadV2] method:
```yaml
- id: deployment_message
uses: slackapi/slack-github-action@v1.27.1
- name: Share a file to that channel
uses: slackapi/slack-github-action@v2-development
with:
channel-id: "CHANNEL_ID"
method: files.uploadV2
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
{
"text": "Deployment started (In Progress)"
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
- uses: slackapi/[email protected]
with:
# Unlike the step posting a new message, this step does not accept a channel name.
# Please use a channel ID, not a name here.
channel-id: "CHANNEL_ID"
payload: |
{
"thread_ts": "${{ steps.deployment_message.outputs.ts }}",
"text": "Deployment finished (Completed)"
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
channel: ${{ secrets.SLACK_CHANNEL_ID }}
initial_comment: "the results are in!"
file: "results.out"
filename: "results-${{ github.sha }}.out"
```
### Technique 3: Slack Incoming Webhook
### Technique 3: Slack incoming webhook
This approach allows your GitHub Actions job to post a message to a Slack channel or direct message by utilizing [Incoming Webhooks](https://api.slack.com/messaging/webhooks).
This technique uses this Action to post a message to a channel or direct message
with [incoming webhooks][incoming-webhook] and a Slack app.
Incoming Webhooks conform to the same rules and functionality as any of Slack's other messaging APIs. You can make your posted messages as simple as a single line of text, or make them really useful with [interactive components](https://api.slack.com/messaging/interactivity). To make the message more expressive and useful use [Block Kit](https://api.slack.com/block-kit) to build and test visual components.
Incoming webhooks follow the same [formatting][formatting] patterns as other
Slack messaging APIs. Posted messages can be as short as a single line of text,
include additional interactivity with [interactive components][interactivity],
or be formatted with [Block Kit][block-kit] to build visual components.
#### Setup
* [Create a Slack App][apps] for your workspace (alternatively use an existing app you have already created and installed).
* Add the [`incoming-webhook`](https://api.slack.com/scopes/incoming-webhook) bot scope under **OAuth & Permissions**.
* Install the app to your workspace (you will select a channel to notify).
* Activate and create a new webhook under **Incoming Webhooks**.
* Copy the Webhook URL from the Webhook you just generated [add it as a secret in your repo settings][repo-secret] named `SLACK_WEBHOOK_URL`.
Gather a Slack incoming webhook URL:
1. [Create a Slack app][apps-new] for your workspace or use an existing app.
2. Add the [`incoming-webhook`][incoming-webhook-scope] bot scope under **OAuth
& Permissions** page on [app settings][apps].
3. Install the app to your workspace and select a channel to notify from the
**Install App** page.
4. Create additional webhooks from the **Incoming Webhooks** page.
5. Add the generated incoming webhook URL as [a repository secret][repo-secret]
called `SLACK_WEBHOOK_URL`.
6. [Add this Action as a step][job-step] to your GitHub workflow and provide an
input payload to send as a message.
#### Usage
Add the collected webhook from above to a GitHub workflow and configure the step
using [`mrkdwn`][mrkdwn] formatting values for a message or
[Block Kit][block-kit] blocks:
```yaml
- name: Post a message in a channel
uses: slackapi/slack-github-action@v2-development
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
text: "*GitHub Action build result*: ${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head_commit.url }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "GitHub Action build result: ${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head_commit.url }}"
```
## Additional configurations
Not all of the above settings serve every customization of a workflow, so these
options might be useful.
### Exiting with errors
Invalid API requests or unexpected webhook payloads cause a failing response
that can be used to fail the GitHub Actions step with the `errors` option.
The `errors` option defaults to `false` so failed requests do not cause the step
to fail. This result can still be gathered from the `ok` output.
```yaml
- name: Send GitHub Action data to a Slack workflow
uses: slackapi/slack-github-action@v2-development
with:
errors: true
method: chat.reverse
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
text: "palindrome"
```
Invalid inputs to the Action, such as not including a payload, will always cause
the GitHub step to fail.
### Flattening nested payloads
Variables and data provided in the payload might contain nested fields that need
to be flattened before being sent with a
[webhook trigger](#technique-1-slack-workflow-builder) to match the expected
input format of [Workflow Builder][wfb].
The `payload-delimiter` option will flatten the input payload using the provided
delimiter and will also make values stringified:
```yaml
- name: Send GitHub Action data to a Slack workflow
uses: slackapi/slack-github-action@v2-development
with:
payload-delimiter: "_"
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
```
Reference to the flattening implementation is available for exploration from
within the [`flat`][flat] package.
### Parsing templated variables
Additional variables provided in the Github event [context][event-context] and
event [payload][event-payload] can be used to replace templated variables in the
input payload with the `payload-templated` option:
```yaml
- name: Send custom JSON data to Slack workflow
id: slack
uses: slackapi/slack-github-action@v1.27.1
uses: slackapi/slack-github-action@v2-development
with:
# For posting a rich message using Block Kit
payload: |
{
"text": "GitHub Action build result: ${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head_commit.url }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "GitHub Action build result: ${{ job.status }}\n${{ github.event.pull_request.html_url || github.event.head_commit.url }}"
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
payload-file-path: "./payload-slack-content.json"
payload-templated: true
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
```
### HTTPS Proxy
This replaces variables templated as `${{ github.payload.repository.html_url }}`
with the values found in the GitHub Action event [payload][event-payload].
If you need to use a proxy to connect with Slack, you can use the `HTTPS_PROXY` (or `https_proxy`) environment variable. In this example we use the Slack App technique, but configuring a proxy works the same way for all of them:
### Proxying HTTPS requests
If you need to use a proxy to connect to Slack, you can use the `proxy` option.
In this example we use the technique that calls a Slack API method, but
configuring a proxy is the same for all techniques:
```yaml
- name: Post to a Slack channel via a proxy
id: slack
uses: slackapi/[email protected]
uses: slackapi/slack-github-action@v2-development
with:
channel-id: 'CHANNEL_ID'
slack-message: 'This message was sent through a proxy'
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
# Set the HTTPS_PROXY environment variable to whatever your policy requires
HTTPS_PROXY: 'http://proxy.example.org:8080'
method: chat.postMessage
proxy: "http://proxy.example.org:8080" # Change this to a custom value
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
text: "This message was sent through a proxy"
```
## Contributing
The `proxy` option can also be provided with the `HTTPS_PROXY` or `https_proxy`
[environment variable][github-environment] from within the GitHub Actions step.
See [CONTRIBUTING](.github/contributing.md).
### Retrying failed requests
Sometimes outgoing requests fail due to [rate limits][rate-limits] or similar
[HTTP responses][retry-after] and can be retried later.
The `retries` option can be configured to the needs of your workflow with one of
these values:
- `0`: No retries, just hope that things go alright.
- `5`: Five retries in five minutes. **Default**.
- `10`: Ten retries in about thirty minutes.
- `RAPID`: A burst of retries to keep things running fast.
```yaml
- name: Attempt a burst of requests
uses: slackapi/slack-github-action@v2-development
with:
method: chat.postMessage
retries: RAPID
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
text: "status: all things are going good"
```
Behind the scenes, [automatic retries][retries] are handled with the
[`@slack/web-api`][slack-web-api] package for Slack API methods, and
[`axios-retry`][axios-retry] when sending with a webhook.
## Expected outputs
Each technique above [outputs values][github-outputs] that can be used as inputs
in following steps of a GitHub workflow.
The following outputs are returned with each of the techniques:
- `time`: `number`. The Unix [epoch time][epoch] that the step completed.
- `ok`: `boolean`. If the request completed with success.
- `response`: `string`. The [response][response] from the request as stringified
JSON.
While these outputs are returned with certain Slack API methods:
- `channel_id`: `string`. The [channel ID][conversation] included in the
response.
- `ts`: `string`. The [timestamp][messaging-timestamp] of the Slack event or
message.
- `thread_ts`: `string`. The [timestamp][messaging-timestamp] of a parent Slack
message with [threaded replies][messaging-parents].
### Example responses
The following snippet shows how multiple steps can be chained together to create
a Slack channel before posting a message:
```yaml
- name: Create a new Slack channel for recent changes
id: conversation
uses: slackapi/slack-github-action@v2-development
with:
method: conversations.create
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
name: pull-request-review-${{ github.sha }}
- name: Send the pull request link into the Slack channel
if: ${{ steps.conversation.outputs.ok }}
uses: slackapi/slack-github-action@v2-development
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ steps.conversation.outputs.channel_id }}
text: "A PR was created <!date^${{ steps.conversation.outputs.time }}^{date_num} at {time_secs}|just now>: ${{ github.event.pull_request.html_url }}"
```
## License
See [LICENSE](LICENSE).
This project is licensed under the [MIT license](LICENSE).
## Contributing
All contributions are encouraged! Check out the
[contributor's guide][contributing] to learn more.
[apps]: https://api.slack.com/apps
[create-webhook]: https://slack.com/intl/en-ca/help/articles/360041352714-Create-more-advanced-workflows-using-webhooks
[apps-new]: https://api.slack.com/apps/new
[axios-retry]: https://www.npmjs.com/package/axios-retry
[block-kit]: https://api.slack.com/surfaces/messages#complex_layouts
[chat.postMessage]: https://api.slack.com/methods/chat.postMessage
[chat.update]: https://api.slack.com/methods/chat.update
[chat:write]: https://api.slack.com/scopes/chat:write
[config-tokens]: https://api.slack.com/reference/manifests#config-tokens
[contributing]: .github/contributing.md
[conversation]: https://api.slack.com/types/conversation
[epoch]: https://en.wikipedia.org/wiki/Unix_time
[event-context]: https://github.com/actions/toolkit/blob/main/packages/github/src/context.ts#L6
[event-payload]: https://docs.github.com/en/webhooks/webhook-events-and-payloads
[examples]: https://github.com/slackapi/slack-github-action/tree/main/example-workflows
[files.upload]: https://api.slack.com/messaging/files#upload
[files.uploadV2]: https://tools.slack.dev/node-slack-sdk/web-api/#upload-a-file
[flat]: https://www.npmjs.com/package/flat
[formatting]: https://api.slack.com/reference/surfaces/formatting
[github-environment]: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables
[github-outputs]: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/passing-information-between-jobs
[github-variables]: https://docs.github.com/en/actions/learn-github-actions/variables
[incoming-webhook]: https://api.slack.com/messaging/webhooks
[incoming-webhook-scope]: https://api.slack.com/scopes/incoming-webhook
[interactivity]: https://api.slack.com/messaging/interactivity
[job-step]: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idsteps
[repo-secret]: https://docs.github.com/en/free-pro-team@latest/actions/reference/encrypted-secrets#creating-encrypted-secrets-for-a-repository
[messaging-parents]: https://api.slack.com/messaging/retrieving#finding_threads
[messaging-threads]: https://api.slack.com/messaging/sending#threading
[messaging-timestamp]: https://api.slack.com/messaging/retrieving#individual_messages
[methods]: https://api.slack.com/methods
[mrkdwn]: https://api.slack.com/reference/surfaces/formatting
[plans]: https://slack.com/pricing
[rate-limits]: https://api.slack.com/apis/rate-limits
[repo-secret]: https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-a-repository
[response]: https://api.slack.com/web#responses
[retries]: https://tools.slack.dev/node-slack-sdk/web-api/#automatic-retries
[retry-after]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
[scopes]: https://api.slack.com/scopes
[slack-web-api]: https://tools.slack.dev/node-slack-sdk/web-api
[tokens]: https://api.slack.com/concepts/token-types
[variables]: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables
[wfb]: https://slack.com/features/workflow-automation
[wfb-create]: https://slack.com/help/articles/360041352714-Build-a-workflow--Create-a-workflow-that-starts-outside-of-Slack
+45 -27
View File
@@ -1,37 +1,55 @@
name: 'slack-send'
description: 'Publish a message in a channel or send a JSON payload to the Slack Workflow Builder'
name: "Slack: Send to Slack"
author: "slackapi"
description: "Send data to Slack to start a Slack workflow in Workflow Builder, call a Slack API method, or post a message into a channel"
inputs:
channel-id: # channel id to post message when using bot token
description: 'Slack channel ID where message will be posted. Needed if using bot token'
errors:
default: "false"
description: "If the step exits with an error on errors or continues."
required: false
slack-message: # message to post when using bot token
description: 'Message to post into Slack. Needed if using bot token'
method:
description: "The Slack API method to call."
required: false
payload: # JSON payload to send to Slack via webhook
description: 'JSON payload to send to Slack if webhook route. If not supplied, json from GitHub event will be sent instead'
payload:
description: "Attributes that create the content of the request using JSON or YAML."
required: false
payload-delimiter: # custom delimiter used to flatten nested values in the JSON payload
description: 'Custom delimiter used to flatten nested values in the JSON payload. If not supplied, defaults to a period (".").'
payload-delimiter:
description: "Field seperator for nested attributes in the input payload."
required: false
payload-file-path: # path to JSON payload to send to Slack via webhook
description: 'path to JSON payload to send to Slack if webhook route. If not supplied, json from GitHub event will be sent instead. If payload is provided, it will take preference over this field'
payload-file-path:
description: "Path to a file containing a valid input payload made of JSON or YAML."
required: false
payload-file-path-parsed:
description: 'Replace templated variables in the JSON payload file with values from the GitHub context and environment variables'
default: true
payload-templated:
default: "false"
description: "If templated variables in input payloads should be replaced."
required: false
update-ts: # The timestamp of a previous message posted to update it instead of posting a new message
description: 'The timestamp of a previous message posted. It will update the existing message instead of posting a new message'
proxy:
description: "An optional proxied route for HTTPS connections."
required: false
retries:
default: "5"
description: "The strategy to use when performing retried requests."
token:
description: "The authentication value used with the Slack API."
required: false
webhook:
description: "A location for posting request payloads."
required: false
webhook-type:
description: "Option to use either an incoming webhook or webhook trigger."
required: false
outputs:
time: # id of output
description: 'The time'
thread_ts: # threaded timestamp on the message that was posted when using bot token
description: 'The timestamp on the message that was posted into Slack when using bot token'
ts: # timestamp of message posted
description: 'The timestamp on the message that was posted into Slack when using bot token'
channel_id: # Id of the channel. If channel name was used as input we can get ID from output for later use when updating message or posting to thread.
description: 'The channel id of the message that was posted into Slack when using bot token'
ok:
description: "If the request completed without returning errors."
response:
description: "A JSON stringified version of the Slack API response."
time:
description: "The Unix epoch time that the step completed."
channel_id:
description: "The channel ID returned with some of the Slack API methods."
thread_ts:
description: "The timestamp of a parent Slack message with threaded replies."
ts:
description: "The timestamp of a Slack message or event in the response."
runs:
using: 'node20'
main: 'dist/index.js'
using: "node20"
main: "dist/index.js"
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"files": {
"ignore": ["*.json"],
"ignoreUnknown": true
},
"formatter": {
"indentStyle": "space"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"organizeImports": {
"enabled": true
},
"vcs": {
"enabled": true,
"clientKind": "git"
}
}
@@ -5,14 +5,12 @@ jobs:
runs-on: ubuntu-latest
name: New push to repo
steps:
- name: Send GitHub trigger payload to Slack Workflow Builder
id: slack
uses: slackapi/slack-github-action@v1.27.1
with:
payload: |
{
"key": "value",
"foo": "bar"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Send GitHub trigger payload to Slack Workflow Builder
id: slack
uses: slackapi/slack-github-action@v2-development
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
payload: |
key: value
foo: bar
@@ -5,10 +5,10 @@ jobs:
runs-on: ubuntu-latest
name: New push to repo
steps:
- name: Send GitHub trigger payload to Slack Workflow Builder
id: slack
uses: slackapi/slack-github-action@v1.27.1
with:
payload-file-path: "./example-workflows/Technique_1_Slack_Workflow_Builder/payloads/example.json"
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Send GitHub trigger payload to Slack Workflow Builder
id: slack
uses: slackapi/slack-github-action@v2-development
with:
payload-file-path: "./example-workflows/Technique_1_Slack_Workflow_Builder/payloads/example.json"
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
@@ -5,8 +5,10 @@ jobs:
runs-on: ubuntu-latest
name: New push to repo
steps:
- name: Send GitHub trigger payload to Slack Workflow Builder
id: slack
uses: slackapi/slack-github-action@v1.27.1
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Send GitHub trigger payload to Slack Workflow Builder
id: slack
uses: slackapi/slack-github-action@v2-development
with:
payload-delimiter: "_"
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
@@ -5,40 +5,28 @@ jobs:
runs-on: ubuntu-latest
name: New push to repo
steps:
- name: Send GitHub trigger payload to Slack Workflow Builder
id: slack
uses: slackapi/slack-github-action@v1.27.1
with:
channel-id: 'SLACK_CHANNEL_ID' # ID of Slack Channel you want to post to
payload: |
{
"blocks": [
{ "type": "divider" },
{
"type": "image",
"title": {
"type": "plain_text",
"text": "Slack Slack Slack",
"emoji": true
},
"image_url": "https://media.makeameme.org/created/a-slack-this.jpg",
"alt_text": "marg"
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "${{ github.sha }}"
},
"url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
]
}
],
"channel-id": "NEW_TARGET_CHANNEL_ID"
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
- name: Send GitHub trigger payload to Slack Workflow Builder
id: slack
uses: slackapi/slack-github-action@v2-development
with:
token: ${{ secrets.SLACK_BOT_TOKEN }}
method: chat.postMessage
payload: |
channel: "C0123456789"
text: "messages make meeting"
blocks:
- type: "divider"
type: "image"
title:
type: "plain_text"
text: "Slack Slack Slack"
emoji: true
image_url: "https://media.makeameme.org/created/a-slack-this.jpg"
alt_text: "marg"
- type: "actions"
elements:
- type": "button"
text:
type: "plain_text"
text: "${{ github.sha }}"
url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
@@ -5,11 +5,12 @@ jobs:
runs-on: ubuntu-latest
name: New push to repo
steps:
- name: Send GitHub trigger payload to Slack Workflow Builder
id: slack
uses: slackapi/slack-github-action@v1.27.1
with:
channel-id: 'SLACK_CHANNEL_ID' # ID of Slack Channel you want to post to
payload: "{\"text\": \"posting from a github action\"}"
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
- name: Send GitHub trigger payload to Slack Workflow Builder
id: slack
uses: slackapi/slack-github-action@v2-development
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
text: "posting from a github action"
channel: "C0123456789"
@@ -5,11 +5,12 @@ jobs:
runs-on: ubuntu-latest
name: New push to repo
steps:
- name: Publish to slack channel via bot token
id: slack
uses: slackapi/slack-github-action@v1.27.1
with:
channel-id: 'SLACK_CHANNEL_ID' # ID of Slack Channel you want to post to
slack-message: 'posting from a github action!' # The message you want to post
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
- name: Publish to slack channel via bot token
id: slack
uses: slackapi/slack-github-action@v2-development
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: "C0123456789"
text: "posting from a github action!"
@@ -5,47 +5,30 @@ jobs:
runs-on: ubuntu-latest
name: New push to repo
steps:
- name: Send GitHub trigger payload to Slack Workflow Builder
id: slack
uses: slackapi/slack-github-action@v1.27.1
with:
payload: |
{
"text": "Danny Torrence left a 1 star review for your property.",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Danny Torrence left the following review for your property:"
}
},
{
"type": "section",
"block_id": "section567",
"text": {
"type": "mrkdwn",
"text": "<https://example.com|Overlook Hotel> \\n :star: \\n Doors had too many axe holes, guest in room 237 was far too rowdy, whole place felt stuck in the 1920s."
},
"accessory": {
"type": "image",
"image_url": "https://is5-ssl.mzstatic.com/image/thumb/Purple3/v4/d3/72/5c/d3725c8f-c642-5d69-1904-aa36e4297885/source/256x256bb.jpg",
"alt_text": "Haunted hotel image"
}
},
{
"type": "section",
"block_id": "section789",
"fields": [
{
"type": "mrkdwn",
"text": "*Average Rating*\\n1.0"
}
]
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: Send GitHub trigger payload to Slack Workflow Builder
id: slack
uses: slackapi/slack-github-action@v2-development
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
payload: |
"text": "Danny Torrence left a 1 star review for your property."
"blocks":
- type: "section"
text:
type: "mrkdwn"
text: "Danny Torrence left the following review for your property:"
- type: "section"
block_id: "section567"
text: {
type: "mrkdwn"
text: "<https://example.com|Overlook Hotel> \\n :star: \\n Doors had too many axe holes, guest in room 237 was far too rowdy, whole place felt stuck in the 1920s."
accessory:
type: "image"
image_url: "https://is5-ssl.mzstatic.com/image/thumb/Purple3/v4/d3/72/5c/d3725c8f-c642-5d69-1904-aa36e4297885/source/256x256bb.jpg"
alt_text: "Haunted hotel image"
- type: "section"
block_id: "section789"
fields:
- type: "mrkdwn"
text: "*Average Rating*\\n1.0"
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"checkJs": true,
"esModuleInterop": true,
"lib": [
"ES2023"
],
"module": "node16",
"moduleResolution": "node",
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"target": "ES2022"
},
"exclude": [
"node_modules"
],
"include": [
"src/**/*.js"
],
"jsdoc": {
"out": "support/jsdoc",
"access": "public"
}
}
+756 -5264
View File
File diff suppressed because it is too large Load Diff
+27 -21
View File
@@ -1,15 +1,16 @@
{
"name": "slack-github-action",
"version": "1.27.1",
"description": "The official slack github action. Use this to send data into your Slack workspace",
"version": "2.0.0-development",
"description": "The official Slack Github Action. Use this to send data into your Slack workspace",
"main": "dist/index.js",
"type": "module",
"scripts": {
"lint": "eslint .",
"local": "act public --eventpath .github/workflows/local/event.json --secret-file .github/workflows/local/.env --platform ubuntu-latest=node:20-buster",
"test:mocha": "mocha --config .mocharc.json test/*-test.js",
"test:gen-cov": "nyc --reporter=lcov npm run test:mocha",
"test": "npm run lint && nyc npm run test:mocha",
"build": "npx @vercel/ncc build src/index.js --license licenses.txt"
"build": "ncc build src/index.js --license licenses.txt --source-map",
"check": "tsc --noemit --module es2022 --project ./jsconfig.json",
"dev": "act public --eventpath .github/resources/.actions/event.json --secret-file .github/resources/.env --platform ubuntu-latest=node:20-buster --container-architecture linux/amd64",
"lint:fix": "biome check --write",
"lint": "biome check",
"test": "c8 mocha test/*.spec.js"
},
"repository": {
"type": "git",
@@ -36,22 +37,27 @@
"@actions/github": "^6.0.0",
"@slack/web-api": "^7.7.0",
"axios": "^1.7.7",
"flat": "^5.0.2",
"axios-retry": "^4.5.0",
"flat": "^6.0.1",
"https-proxy-agent": "^7.0.5",
"markup-js": "^1.5.21",
"whatwg-url": "^14.0.0"
"js-yaml": "^4.1.0",
"markup-js": "^1.5.21"
},
"devDependencies": {
"@vercel/ncc": "^0.38.1",
"chai": "^4.5.0",
"eslint": "^8.57.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsdoc": "^50.4.3",
"eslint-plugin-node": "^11.1.0",
"@biomejs/biome": "^1.9.4",
"@types/chai": "^5.0.1",
"@types/flat": "^5.0.5",
"@types/js-yaml": "^4.0.9",
"@types/markup-js": "^1.5.0",
"@types/mocha": "^10.0.9",
"@types/node": "^20.17.6",
"@types/sinon": "^17.0.3",
"@vercel/ncc": "^0.38.2",
"c8": "^10.1.2",
"chai": "^5.1.2",
"mocha": "^10.8.2",
"nyc": "^17.1.0",
"rewiremock": "^3.14.5",
"sinon": "^19.0.2"
"mocha-suppress-logs": "^0.5.1",
"sinon": "^19.0.2",
"typescript": "^5.6.3"
}
}
+124
View File
@@ -0,0 +1,124 @@
import webapi, { LogLevel } from "@slack/web-api";
import { HttpsProxyAgent } from "https-proxy-agent";
import Config from "./config.js";
import SlackError from "./errors.js";
/**
* The Client class creates a WebClient from @slack/web-api for use when calling
* various Slack API methods.
*
* @see {@link https://tools.slack.dev/node-slack-sdk/web-api/}
* @see {@link https://api.slack.com/methods/}
*/
export default class Client {
/**
* Perform the API call configured with the input payload.
* @param {Config} config
*/
async post(config) {
if (!config.inputs.method) {
throw new SlackError(config.core, "No API method was provided for use");
}
if (!config.inputs.token) {
throw new SlackError(config.core, "No token was provided to post with");
}
const client = new config.webapi.WebClient(config.inputs.token, {
agent: this.proxies(config)?.httpsAgent,
retryConfig: this.retries(config.inputs.retries),
logger: {
debug: config.core.debug,
info: config.core.info,
warn: config.core.warning,
error: config.core.error,
getLevel: () => {
return config.core.isDebug() ? LogLevel.DEBUG : LogLevel.INFO;
},
setLevel: (_level) => {},
setName: (_name) => {},
},
});
try {
/**
* @type {webapi.WebAPICallResult & import("@slack/web-api").ChatPostMessageResponse & import("@slack/web-api").ConversationsCreateResponse}
*/
const response = await client.apiCall(
config.inputs.method,
config.content.values,
);
config.core.setOutput("ok", response.ok);
config.core.setOutput("response", JSON.stringify(response));
if (response.channel?.id ?? response.channel) {
config.core.setOutput(
"channel_id",
response.channel?.id ?? response.channel,
);
}
if (response.message?.thread_ts) {
config.core.setOutput("thread_ts", response.message.thread_ts);
}
if (response.ts) {
config.core.setOutput("ts", response.ts);
}
} catch (/** @type {any} */ err) {
const slackErr = /** @type {webapi.WebAPICallError} */ (err);
config.core.setOutput("ok", false);
switch (slackErr.code) {
case webapi.ErrorCode.RequestError:
config.core.setOutput("response", JSON.stringify(slackErr.original));
break;
case webapi.ErrorCode.HTTPError:
config.core.setOutput("response", JSON.stringify(slackErr));
break;
case webapi.ErrorCode.PlatformError:
config.core.setOutput("response", JSON.stringify(slackErr.data));
break;
case webapi.ErrorCode.RateLimitedError:
config.core.setOutput("response", JSON.stringify(slackErr));
break;
}
throw new SlackError(config.core, err);
}
}
/**
* Return configurations for https proxy options if these are set.
* @param {Config} config
* @returns {import("axios").AxiosRequestConfig | undefined}
* @see {@link https://github.com/slackapi/slack-github-action/pull/205}
*/
proxies(config) {
const proxy = config.inputs.proxy;
try {
if (!proxy) {
return undefined;
}
return {
httpsAgent: new HttpsProxyAgent(proxy),
};
} catch (/** @type {any} */ err) {
throw new SlackError(config.core, "Failed to configure the HTTPS proxy", {
cause: err,
});
}
}
/**
* Return configurations for retry options with different delays.
* @param {string} option
* @returns {import("@slack/web-api").RetryOptions}
*/
retries(option) {
switch (option?.trim().toUpperCase()) {
case "0":
return { retries: 0 };
case "5":
return webapi.retryPolicies.fiveRetriesInFiveMinutes;
case "10":
return webapi.retryPolicies.tenRetriesInAboutThirtyMinutes;
case "RAPID":
return webapi.retryPolicies.rapidRetryPolicy;
default:
return webapi.retryPolicies.fiveRetriesInFiveMinutes;
}
}
}
+182
View File
@@ -0,0 +1,182 @@
import core from "@actions/core";
import webapi from "@slack/web-api";
import axios from "axios";
import Content from "./content.js";
import SlackError from "./errors.js";
/**
* Options and settings set as inputs to this action.
*
* @see {@link ../action.yml}
*/
export default class Config {
/**
* Options of retries for failed requests.
* @readonly
* @enum {string} The option for retries.
*/
Retries = {
/** No retries, just hope that things go alright.
* @readonly
*/
ZERO: "0",
/**
* Five retries in five minutes.
* @readonly
*/
FIVE: "5",
/**
* Ten retries in about thirty minutes.
* @readonly
*/
TEN: "10",
/**
* A burst of retries to keep things running fast.
* @readonly
*/
RAPID: "RAPID",
};
/**
* @typedef Inputs - Values provided to this job.
* @property {boolean} errors - If the job should exit after errors or succeed.
* @property {string?} method - The Slack API method to call.
* @property {string?} payload - Request contents from the provided input.
* @property {string?} payloadDelimiter - Seperators of nested attributes.
* @property {string?} payloadFilePath - Location of a JSON request payload.
* @property {boolean} payloadTemplated - If templated values are replaced.
* @property {string?} proxy - An optional proxied connection for requests.
* @property {Retries} retries - The retries method to use for failed requests.
* @property {string?} token - The authentication value used with the Slack API.
* @property {string?} webhook - A location for posting request payloads.
* @property {string?} webhookType - Posting method to use with the webhook.
*/
/**
* @type {Inputs} - The actual action input values.
*/
inputs;
/**
* @type {import("axios").AxiosStatic} - The axios client.
*/
axios;
/**
* @type {Content} - The parsed payload data to send.
*/
content;
/**
* Shared utilities specific to the GitHub action workflow.
* @type {import("@actions/core")}
*/
core;
/**
* @type {import("@slack/web-api")} - Slack API client.
*/
webapi;
/**
* Gather values from the job inputs and use defaults or error for the missing
* ones.
*
* The content of the payload is also parsed, proxies set, and a shared "core"
* kept for later use.
*
* @constructor
* @param {core} core - GitHub Actions core utilities.
*/
constructor(core) {
this.axios = axios;
this.core = core;
this.webapi = webapi;
this.inputs = {
errors: core.getBooleanInput("errors"),
method: core.getInput("method"),
payload: core.getInput("payload"),
payloadDelimiter: core.getInput("payload-delimiter"),
payloadFilePath: core.getInput("payload-file-path"),
payloadTemplated: core.getBooleanInput("payload-templated") || false,
proxy:
core.getInput("proxy") ||
process.env.HTTPS_PROXY ||
process.env.https_proxy ||
null,
retries: core.getInput("retries") || this.Retries.FIVE,
token: core.getInput("token") || process.env.SLACK_TOKEN || null,
webhook:
core.getInput("webhook") || process.env.SLACK_WEBHOOK_URL || null,
webhookType: core.getInput("webhook-type"),
};
this.validate();
core.debug(`Gathered action inputs: ${JSON.stringify(this.inputs)}`);
this.content = new Content().get(this);
core.debug(`Parsed request content: ${JSON.stringify(this.content)}`);
}
/**
* Confirm the configurations are correct enough to continue.
*/
validate() {
switch (this.inputs.retries.trim().toUpperCase()) {
case this.Retries.ZERO:
case this.Retries.FIVE:
case this.Retries.TEN:
case this.Retries.RAPID:
break;
default:
throw new SlackError(
core,
`Invalid input! An unknown "retries" value was used: ${this.inputs.retries}`,
);
}
switch (true) {
case !!this.inputs.token && !!this.inputs.webhook:
core.debug(
"Setting the provided token and webhook as secret variables.",
);
core.setSecret(this.inputs.token);
core.setSecret(this.inputs.webhook);
throw new SlackError(
core,
"Invalid input! Either the token or webhook is required - not both.",
);
case !!this.inputs.token:
core.debug("Setting the provided token as a secret variable.");
core.setSecret(this.inputs.token);
if (!this.inputs.method) {
throw new SlackError(
core,
"Missing input! A method must be decided to use the token provided.",
);
}
break;
case !!this.inputs.webhook:
core.debug("Setting the provided webhook as a secret variable.");
core.setSecret(this.inputs.webhook);
if (!this.inputs.webhookType) {
throw new SlackError(
core,
"Missing input! The webhook type must be 'incoming-webhook' or 'webhook-trigger'.",
);
}
if (
this.inputs.webhookType !== "incoming-webhook" &&
this.inputs.webhookType !== "webhook-trigger"
) {
throw new SlackError(
core,
"Invalid input! The webhook type must be 'incoming-webhook' or 'webhook-trigger'.",
);
}
break;
default:
throw new SlackError(
core,
"Missing input! Either a token or webhook is required to take action.",
);
}
}
}
+166
View File
@@ -0,0 +1,166 @@
import fs from "node:fs";
import path from "node:path";
import github from "@actions/github";
import { flatten } from "flat";
import yaml from "js-yaml";
import markup from "markup-js";
import Config from "./config.js";
import SlackError from "./errors.js";
/**
* The parsed payload provided to the action and passed to the preferred method
* of sending a payload.
*/
export default class Content {
/**
* Gather content from the provided payload or payload file path with parsings.
* @param {Config} config
* @returns {this} - An instance of this Content class.
*/
get(config) {
switch (true) {
case !!config.inputs.payload && !!config.inputs.payloadFilePath:
throw new SlackError(
config.core,
"Invalid input! Just the payload or payload file path is required.",
);
case !!config.inputs.payload:
this.values = this.getContentPayload(config);
break;
case !!config.inputs.payloadFilePath:
this.values = this.getContentPayloadFilePath(config);
break;
default:
config.core.debug(
"Missing payload so gathering inputs from action context.",
);
this.values = github.context;
break;
}
if (config.inputs.payloadDelimiter) {
this.values = flatten(this.values, {
delimiter: config.inputs.payloadDelimiter,
});
for (const key of Object.keys(this.values)) {
this.values[key] = `${this.values[key]}`;
}
}
return this;
}
/**
* Format request content from payload values for use in the request.
* @param {Config} config
* @throws if the input payload or payload file path is invalid JSON.
* @returns {Content} - the parsed JSON payload to use in requests.
*/
getContentPayload(config) {
if (!config.inputs.payload) {
throw new SlackError(
config.core,
"Invalid input! No payload content was provided",
);
}
try {
const input = this.templatize(config, config.inputs.payload);
const content = /** @type {Content} */ (
yaml.load(input, {
schema: yaml.JSON_SCHEMA,
})
);
return /** @type {Content} */ (content);
} catch (error) {
if (error instanceof Error) {
config.core.debug("Failed to parse input payload as YAML");
config.core.debug(error.message);
}
}
try {
const trimmed = config.inputs.payload.trim();
if (
!config.inputs.payload.startsWith("{") &&
!config.inputs.payload.endsWith("}")
) {
config.core.debug(
"Wrapping input payload in braces to create valid JSON",
);
const comma = trimmed.replace(/,$/, ""); // remove trailing comma
const wrap = `{${comma}}`;
return JSON.parse(wrap);
}
return JSON.parse(trimmed);
} catch (/** @type {any} */ error) {
throw new SlackError(
config.core,
"Invalid input! Failed to parse contents of the provided payload",
{
cause: error,
},
);
}
}
/**
* Format request content from the payload file path for use in the request.
* @param {Config} config
* @throws if the input payload or payload file path is invalid JSON.
* @returns {Content} - the parsed JSON payload to use in requests.
*/
getContentPayloadFilePath(config) {
if (!config.inputs.payloadFilePath) {
throw new SlackError(
config.core,
"Invalid input! No payload found for content",
);
}
try {
const input = fs.readFileSync(
path.resolve(config.inputs.payloadFilePath),
"utf-8",
);
const content = this.templatize(config, input);
if (
config.inputs.payloadFilePath.endsWith("yaml") ||
config.inputs.payloadFilePath.endsWith("yml")
) {
const load = yaml.load(content, {
schema: yaml.JSON_SCHEMA,
});
return /** @type {Content} */ (load);
}
if (config.inputs.payloadFilePath.endsWith("json")) {
return JSON.parse(content);
}
throw new SlackError(
config.core,
`Invalid input! Failed to parse file extension ${config.inputs.payloadFilePath}`,
);
} catch (/** @type {any} */ error) {
throw new SlackError(
config.core,
"Invalid input! Failed to parse contents of the provided payload file",
{
cause: error,
},
);
}
}
/**
* Replace templated variables in the provided content if requested.
* @param {Config} config
* @param {string} input - The initial value of the content.
* @returns {string} Content with templatized variables replaced.
*/
templatize(config, input) {
if (!config.inputs.payloadTemplated) {
return input;
}
const template = input.replace(/\$\{\{/g, "{{"); // swap ${{ for {{
const context = {
env: process.env,
github: github.context,
};
return markup.up(template, context);
}
}
+28
View File
@@ -0,0 +1,28 @@
import core from "@actions/core";
/**
* SlackError is a custom error wrapper for known errors of Slack GitHub Action.
*/
export default class SlackError extends Error {
/**
* @typedef Options
* @property {Error} [cause] - thrown exception of this error.
*/
/**
* @param {core} _core - GitHub Actions core utilities.
* @param {any} error - The error message to throw.
* @param {Options} options - configurations of erroring.
*/
constructor(_core, error, options = {}) {
if (error instanceof Error) {
super(error.message, { cause: options.cause });
} else {
super(error, { cause: options.cause });
}
this.name = "SlackError";
if (error.stack) {
this.stack = error.stack;
}
}
}
+17 -3
View File
@@ -1,4 +1,18 @@
const core = require('@actions/core');
const slackSend = require('./slack-send');
import core from "@actions/core";
import send from "./send.js";
slackSend(core);
/**
* Invoke the Slack GitHub Action job from this file but export actual logic
* from the send.js file for testing purposes.
*/
try {
send(core);
} catch (error) {
if (error instanceof Error) {
core.error(error.message);
core.debug(`${error.name} cause: ${error.cause}`);
core.debug(`${error.name} stack: ${error.stack}`);
} else {
core.error(`${error}`);
}
}
+39
View File
@@ -0,0 +1,39 @@
import core from "@actions/core";
import Client from "./client.js";
import Config from "./config.js";
import SlackError from "./errors.js";
import Webhook from "./webhook.js";
/**
* Orchestrate the action job happenings from inputs to logic to outputs.
* @param {core} core - GitHub Actions core utilities.
* @throws if an error happens but might not cause the job to fail.
*/
export default async function send(core) {
const config = new Config(core);
try {
await post(config);
config.core.setOutput("time", Math.floor(new Date().valueOf() / 1000));
} catch (/** @type {any} */ error) {
config.core.setOutput("time", Math.floor(new Date().valueOf() / 1000));
if (config.inputs.errors) {
core.setFailed(error);
throw new SlackError(core, error);
}
}
}
/**
* Perform the posting action of this workflow with configured settings.
* @param {Config} config
*/
async function post(config) {
switch (true) {
case !!config.inputs.token:
return await new Client().post(config);
case !!config.inputs.webhook:
return await new Webhook().post(config);
default:
throw new SlackError(config.core, "No technique given to post content");
}
}
-165
View File
@@ -1,165 +0,0 @@
const { promises: fs } = require('fs');
const path = require('path');
const github = require('@actions/github');
const flatten = require('flat');
const axios = require('axios');
const markup = require('markup-js');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { parseURL } = require('whatwg-url');
const { createWebClient } = require('./web-client');
const SLACK_WEBHOOK_TYPES = {
WORKFLOW_TRIGGER: 'WORKFLOW_TRIGGER',
INCOMING_WEBHOOK: 'INCOMING_WEBHOOK',
};
module.exports = async function slackSend(core) {
try {
const botToken = process.env.SLACK_BOT_TOKEN;
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
let webhookType = SLACK_WEBHOOK_TYPES.WORKFLOW_TRIGGER;
if (process.env.SLACK_WEBHOOK_TYPE) {
// The default type is for Workflow Builder triggers. If you want to use this action for Incoming Webhooks, use
// the corresponding type instead.
webhookType = process.env.SLACK_WEBHOOK_TYPE.toUpperCase();
}
if ((botToken === undefined || botToken.length <= 0) && (webhookUrl === undefined || webhookUrl.length <= 0)) {
throw new Error('Need to provide at least one botToken or webhookUrl');
}
let payload = core.getInput('payload');
const payloadFilePath = core.getInput('payload-file-path');
/** Option to replace templated context variables in the payload file JSON */
const payloadFilePathParsed = core.getBooleanInput('payload-file-path-parsed');
let webResponse;
if (payloadFilePath && !payload) {
try {
payload = await fs.readFile(path.resolve(payloadFilePath), 'utf-8');
if (payloadFilePathParsed) {
const context = { github: github.context, env: process.env };
const payloadString = payload.replaceAll('${{', '{{');
payload = markup.up(payloadString, context);
}
} catch (error) {
// passed in payload file path was invalid
console.error(error);
throw new Error(`The payload-file-path may be incorrect. Failed to load the file: ${payloadFilePath}`);
}
}
if (payload) {
try {
// confirm it is valid json
payload = JSON.parse(payload);
} catch (e) {
// passed in payload wasn't valid json
console.error('passed in payload was invalid JSON');
throw new Error('Need to provide valid JSON payload');
}
}
if (typeof botToken !== 'undefined' && botToken.length > 0) {
const message = core.getInput('slack-message') || '';
const channelIds = core.getInput('channel-id') || '';
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy || '';
const web = createWebClient(botToken, httpsProxy);
if (channelIds.length <= 0) {
console.log('Channel ID is required to run this action. An empty one has been provided');
throw new Error('Channel ID is required to run this action. An empty one has been provided');
}
if (message.length > 0 || payload) {
const ts = core.getInput('update-ts');
await Promise.all(channelIds.split(',').map(async (channelId) => {
if (ts) {
// update message
webResponse = await web.chat.update({ ts, channel: channelId.trim(), text: message, ...(payload || {}) });
} else {
// post message
webResponse = await web.chat.postMessage({ channel: channelId.trim(), text: message, ...(payload || {}) });
}
}));
} else {
console.log('Missing slack-message or payload! Did not send a message via chat.postMessage with botToken', { channel: channelIds, text: message, ...(payload) });
throw new Error('Missing message content, please input a valid payload or message to send. No Message has been send.');
}
}
if (typeof webhookUrl !== 'undefined' && webhookUrl.length > 0) {
if (!payload) {
// No Payload was passed in
console.log('no custom payload was passed in, using default payload that triggered the GitHub Action');
// Get the JSON webhook payload for the event that triggered the workflow
payload = github.context.payload;
}
if (webhookType === SLACK_WEBHOOK_TYPES.WORKFLOW_TRIGGER) {
// flatten JSON payload (no nested attributes).
const payloadDelimiter = core.getInput('payload-delimiter') || '.';
const flatPayload = flatten(payload, { delimiter: payloadDelimiter });
// workflow builder requires values to be strings
// iterate over every value and convert it to string
Object.keys(flatPayload).forEach((key) => {
flatPayload[key] = `${flatPayload[key]}`;
});
payload = flatPayload;
}
const axiosOpts = {};
try {
if (parseURL(webhookUrl).scheme === 'https') {
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy || '';
if (httpsProxy && parseURL(httpsProxy).scheme === 'http') {
const httpsProxyAgent = new HttpsProxyAgent(httpsProxy);
axiosOpts.httpsAgent = httpsProxyAgent;
// Use configured tunnel above instead of default axios proxy setup from env vars
axiosOpts.proxy = false;
}
}
} catch (err) {
console.log('failed to configure https proxy agent for http proxy. Using default axios configuration', err);
}
try {
await axios.post(webhookUrl, payload, axiosOpts);
} catch (err) {
console.log('axios post failed, double check the payload being sent includes the keys Slack expects');
if ('toJSON' in err) {
console.error(JSON.stringify(err.toJSON()));
}
console.error(`Attempted to POST payload: ${JSON.stringify(payload)}`);
if (err.response) {
core.setFailed(err.response.data);
} else {
core.setFailed(err.message);
}
return;
}
}
if (webResponse && webResponse.ok) {
core.setOutput('ts', webResponse.ts);
// return the thread_ts if it exists, if not return the ts
const thread_ts = webResponse.thread_ts ? webResponse.thread_ts : webResponse.ts;
core.setOutput('thread_ts', thread_ts);
// return id of the channel from the response
core.setOutput('channel_id', webResponse.channel);
}
const time = (new Date()).toTimeString();
core.setOutput('time', time);
} catch (error) {
core.setFailed(error);
}
};
-20
View File
@@ -1,20 +0,0 @@
const { WebClient } = require('@slack/web-api');
const { HttpsProxyAgent } = require('https-proxy-agent');
/**
*
* @param {string} botToken token used to authenticate as the Slack Bot user
* @param {string?} httpsProxy (optional) URL for the proxy to use for HTTPS requests
* @returns { WebClient } a WebClient configured with the bot token and proxy agent (if needed)
*/
function createWebClient(botToken, httpsProxy) {
if (httpsProxy) {
const httpsProxyAgent = new HttpsProxyAgent(httpsProxy);
return new WebClient(botToken, { agent: httpsProxyAgent });
}
return new WebClient(botToken);
}
module.exports = {
createWebClient,
};
+123
View File
@@ -0,0 +1,123 @@
import axiosRetry, { exponentialDelay, linearDelay } from "axios-retry";
import { HttpsProxyAgent } from "https-proxy-agent";
import Config from "./config.js";
import SlackError from "./errors.js";
/**
* This Webhook class posts the configured payload to the provided webhook, with
* whatever additional settings set.
*/
export default class Webhook {
/**
* @param {Config} config
*/
async post(config) {
if (!config.inputs.webhook) {
throw new SlackError(config.core, "No webhook was provided to post to");
}
/**
* @type {import("axios-retry").IAxiosRetryConfig}
* @see {@link https://www.npmjs.com/package/axios-retry}
*/
const retries = this.retries(config.inputs.retries);
axiosRetry(config.axios, retries);
try {
const response = await config.axios.post(
config.inputs.webhook,
config.content.values,
{
...this.proxies(config),
},
);
config.core.setOutput("ok", response.status === 200);
config.core.setOutput("response", JSON.stringify(response.data));
config.core.debug(JSON.stringify(response.data));
} catch (/** @type {any} */ err) {
const response = err.toJSON();
config.core.setOutput("ok", response.status === 200);
config.core.setOutput("response", JSON.stringify(response.message));
config.core.debug(response);
throw new SlackError(config.core, response.message);
}
}
/**
* Return configurations for http proxy options if these are set.
* @param {Config} config
* @returns {import("axios").AxiosRequestConfig | undefined}
* @see {@link https://github.com/slackapi/slack-github-action/pull/132}
*/
proxies(config) {
const { webhook, proxy } = config.inputs;
if (!webhook) {
throw new SlackError(config.core, "No webhook was provided to proxy to");
}
if (!proxy) {
return undefined;
}
try {
if (new URL(webhook).protocol !== "https:") {
config.core.debug(
"The webhook destination is not HTTPS so skipping the HTTPS proxy",
);
return undefined;
}
switch (new URL(proxy).protocol) {
case "https:":
return {
httpsAgent: new HttpsProxyAgent(proxy),
};
case "http:":
return {
httpsAgent: new HttpsProxyAgent(proxy),
proxy: false,
};
default:
throw new SlackError(
config.core,
`Unsupported URL protocol: ${proxy}`,
);
}
} catch (/** @type {any} */ err) {
throw new SlackError(config.core, "Failed to configure the HTTPS proxy", {
cause: err,
});
}
}
/**
* Return configurations for retry options with different delays.
* @param {string} option
* @returns {import("axios-retry").IAxiosRetryConfig}
*/
retries(option) {
switch (option?.trim().toUpperCase()) {
case "0":
return { retries: 0 };
case "5":
return {
retryCondition: axiosRetry.isRetryableError,
retries: 5,
retryDelay: linearDelay(60 * 1000), // 5 minutes
};
case "10":
return {
retryCondition: axiosRetry.isRetryableError,
retries: 10,
retryDelay: (count, err) => exponentialDelay(count, err, 2 * 1000), // 34.12 minutes
};
case "RAPID":
return {
retryCondition: axiosRetry.isRetryableError,
retries: 12,
retryDelay: linearDelay(1 * 1000), // 12 seconds
};
default:
return {
retryCondition: axiosRetry.isRetryableError,
retries: 5,
retryDelay: linearDelay(60 * 1000), // 5 minutes
};
}
}
}
-15
View File
@@ -1,15 +0,0 @@
module.exports = {
// These environments contain lists of global variables which are allowed to be accessed
env: {
// According to https://node.green, the target node version (v10) supports all important ES2018 features. But es2018
// is not an option since it presumably doesn't introduce any new globals over ES2017.
es2017: true,
node: true,
mocha: true,
},
rules: {
// These rules dont like the use of devDependencies - which test code uses often.
'node/no-unpublished-require': 0,
'node/no-missing-require': 0,
},
};
+442
View File
@@ -0,0 +1,442 @@
import core from "@actions/core";
import webapi from "@slack/web-api";
import errors from "@slack/web-api/dist/errors.js";
import { assert } from "chai";
import Client from "../src/client.js";
import Config from "../src/config.js";
import SlackError from "../src/errors.js";
import send from "../src/send.js";
import { mocks } from "./index.spec.js";
describe("client", () => {
beforeEach(() => {
mocks.reset();
});
describe("inputs", () => {
it("requires a method is provided in inputs", async () => {
/**
* @type {Config}
*/
const config = {
core: core,
inputs: {
token: "xoxb-example",
},
};
try {
await new Client().post(config);
assert.fail("Failed to throw for missing input");
} catch (err) {
if (err instanceof SlackError) {
assert.include(err.message, "No API method was provided for use");
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
it("requires a token is provided in inputs", async () => {
/**
* @type {Config}
*/
const config = {
core: core,
inputs: {
method: "chat.postMessage",
},
};
mocks.core.getInput.withArgs("token").returns("xoxb-example-001");
try {
await new Client().post(config);
assert.fail("Failed to throw for missing input");
} catch (err) {
if (err instanceof SlackError) {
assert.include(err.message, "No token was provided to post with");
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
});
describe("success", () => {
it("calls 'chat.postMessage' with the given token and content", async () => {
try {
const args = {
channel: "C0123456789",
text: "hello",
thread_ts: "1234567890.000001",
};
const response = {
ok: true,
channel: "C0123456789",
ts: "1234567890.000002",
message: {
thread_ts: "1234567890.000001",
},
};
mocks.core.getInput.withArgs("method").returns("chat.postMessage");
mocks.core.getInput.withArgs("token").returns("xoxb-example");
mocks.core.getInput.withArgs("payload").returns(JSON.stringify(args));
mocks.api.resolves(response);
await send(mocks.core);
assert.deepEqual(mocks.api.getCall(0).firstArg, "chat.postMessage");
assert.deepEqual(mocks.api.getCall(0).lastArg, args);
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, true);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
assert.equal(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify(response),
);
assert.equal(mocks.core.setOutput.getCall(2).firstArg, "channel_id");
assert.equal(mocks.core.setOutput.getCall(2).lastArg, "C0123456789");
assert.equal(mocks.core.setOutput.getCall(3).firstArg, "thread_ts");
assert.equal(
mocks.core.setOutput.getCall(3).lastArg,
"1234567890.000001",
);
assert.equal(mocks.core.setOutput.getCall(4).firstArg, "ts");
assert.equal(
mocks.core.setOutput.getCall(4).lastArg,
"1234567890.000002",
);
assert.equal(mocks.core.setOutput.getCall(5).firstArg, "time");
assert.equal(mocks.core.setOutput.getCalls().length, 6);
} catch (error) {
console.error(error);
assert.fail("Unexpected error when calling the method");
}
});
it("calls 'conversations.create' with the given token and content", async () => {
try {
const args = {
name: "pull-request-review-010101",
};
const response = {
ok: true,
channel: {
id: "C0101010101",
name: "pull-request-review-010101",
is_channel: true,
created: 1730425428,
},
};
mocks.core.getInput.withArgs("method").returns("chat.postMessage");
mocks.core.getInput.withArgs("token").returns("xoxb-example");
mocks.core.getInput.withArgs("payload").returns(JSON.stringify(args));
mocks.api.resolves(response);
await send(mocks.core);
assert.deepEqual(mocks.api.getCall(0).firstArg, "chat.postMessage");
assert.deepEqual(mocks.api.getCall(0).lastArg, args);
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, true);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
assert.equal(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify(response),
);
assert.equal(mocks.core.setOutput.getCall(2).firstArg, "channel_id");
assert.equal(mocks.core.setOutput.getCall(2).lastArg, "C0101010101");
assert.equal(mocks.core.setOutput.getCall(3).firstArg, "time");
assert.equal(mocks.core.setOutput.getCalls().length, 4);
} catch (error) {
console.error(error);
assert.fail("Unexpected error when calling the method");
}
});
it("calls 'files.uploadV2' with the provided token and content", async () => {
try {
const args = {
channel: "C0000000001",
initial_comment: "the results are in!",
file: "results.out",
filename: "results-888888.out",
};
const response = {
ok: true,
files: [{ id: "F0000000001", created: 1234567890 }],
};
mocks.core.getInput.withArgs("method").returns("files.uploadV2");
mocks.core.getInput.withArgs("token").returns("xoxp-example");
mocks.core.getInput.withArgs("payload").returns(JSON.stringify(args));
mocks.api.resolves(response);
await send(mocks.core);
assert.deepEqual(mocks.api.getCall(0).lastArg, args);
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, true);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
assert.equal(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify(response),
);
assert.equal(mocks.core.setOutput.getCall(2).firstArg, "time");
assert.equal(mocks.core.setOutput.getCalls().length, 3);
} catch (error) {
console.error(error);
assert.fail("Unexpected error when calling the method");
}
});
});
describe("failure", () => {
it("errors when the request to the api cannot be sent correct", async () => {
/**
* @type {webapi.WebAPICallError}
*/
const response = {
code: "slack_webapi_request_error",
data: {
error: "unexpected_request_failure",
message: "Something bad happened!",
},
};
try {
mocks.core.getInput.reset();
mocks.core.getBooleanInput.withArgs("errors").returns(true);
mocks.core.getInput.withArgs("method").returns("chat.postMessage");
mocks.core.getInput.withArgs("token").returns("xoxb-example");
mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`);
mocks.api.rejects(errors.requestErrorWithOriginal(response, true));
await send(mocks.core);
assert.fail("Expected an error but none was found");
} catch (error) {
assert.isTrue(mocks.core.setFailed.called);
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, false);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
assert.deepEqual(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify(response),
);
assert.equal(mocks.core.setOutput.getCall(2).firstArg, "time");
assert.equal(mocks.core.setOutput.getCalls().length, 3);
}
});
it("errors when the http portion of the request fails to send", async () => {
/**
* @type {import("axios").AxiosResponse}
*/
const response = {
code: "slack_webapi_http_error",
headers: {
authorization: "none",
},
data: {
ok: false,
error: "unknown_http_method",
},
};
try {
mocks.core.getInput.withArgs("method").returns("chat.postMessage");
mocks.core.getInput.withArgs("token").returns("xoxb-example");
mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`);
mocks.api.rejects(errors.httpErrorFromResponse(response));
await send(mocks.core);
assert.fail("Expected an error but none was found");
} catch (error) {
assert.isFalse(mocks.core.setFailed.called);
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, false);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
response.body = response.data;
response.data = undefined;
assert.deepEqual(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify(response),
);
assert.equal(mocks.core.setOutput.getCall(2).firstArg, "time");
assert.equal(mocks.core.setOutput.getCalls().length, 3);
}
});
it("errors when the payload arguments are invalid for the api", async () => {
/**
* @type {webapi.WebAPICallError}
*/
const response = {
code: "slack_webapi_platform_error",
data: {
ok: false,
error: "missing_channel",
},
};
try {
mocks.core.getInput.reset();
mocks.core.getBooleanInput.withArgs("errors").returns(true);
mocks.core.getInput.withArgs("method").returns("chat.postMessage");
mocks.core.getInput.withArgs("token").returns("xoxb-example");
mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`);
mocks.api.rejects(errors.platformErrorFromResult(response));
await send(mocks.core);
assert.fail("Expected an error but none was found");
} catch (error) {
assert.isTrue(mocks.core.setFailed.called);
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, false);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
assert.deepEqual(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify(response),
);
assert.equal(mocks.core.setOutput.getCall(2).firstArg, "time");
assert.equal(mocks.core.setOutput.getCalls().length, 3);
}
});
it("returns the api error and details without a exit failing", async () => {
const response = {
code: "slack_webapi_platform_error",
data: {
ok: false,
error: "missing_channel",
},
};
try {
mocks.core.getInput.withArgs("method").returns("chat.postMessage");
mocks.core.getInput.withArgs("token").returns("xoxb-example");
mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`);
mocks.api.rejects(errors.platformErrorFromResult(response));
await send(mocks.core);
assert.fail("Expected an error but none was found");
} catch (error) {
assert.isFalse(mocks.core.setFailed.called);
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, false);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
assert.deepEqual(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify(response),
);
assert.equal(mocks.core.setOutput.getCall(2).firstArg, "time");
assert.equal(mocks.core.setOutput.getCalls().length, 3);
}
});
it("errors if rate limit responses are returned after retries", async () => {
const response = {
code: "slack_webapi_rate_limited_error",
retryAfter: 12,
};
try {
mocks.core.getInput.withArgs("method").returns("chat.postMessage");
mocks.core.getInput.withArgs("token").returns("xoxb-example");
mocks.core.getInput.withArgs("payload").returns(`"text": "hello"`);
mocks.api.rejects(errors.rateLimitedErrorWithDelay(12));
await send(mocks.core);
assert.fail("Expected an error but none was found");
} catch (error) {
assert.isFalse(mocks.core.setFailed.called);
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, false);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
assert.deepEqual(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify(response),
);
assert.equal(mocks.core.setOutput.getCall(2).firstArg, "time");
assert.equal(mocks.core.setOutput.getCalls().length, 3);
}
});
});
describe("proxies", () => {
it("sets up the proxy agent for the provided https proxy", async () => {
const proxy = "https://example.com";
mocks.core.getInput.withArgs("method").returns("chat.postMessage");
mocks.core.getInput.withArgs("proxy").returns(proxy);
mocks.core.getInput.withArgs("token").returns("xoxb-example");
const config = new Config(mocks.core);
const client = new Client();
const { httpsAgent, proxy: proxying } = client.proxies(config);
assert.deepEqual(httpsAgent.proxy, new URL(proxy));
assert.isNotFalse(proxying);
});
it("fails to configure proxies with an invalid proxied url", async () => {
const proxy = "https://";
mocks.core.getInput.withArgs("method").returns("chat.postMessage");
mocks.core.getInput.withArgs("proxy").returns(proxy);
mocks.core.getInput.withArgs("token").returns("xoxb-example");
try {
const config = new Config(mocks.core);
const client = new Client();
client.proxies(config);
assert.fail("An invalid proxy URL was not thrown as error!");
} catch (err) {
if (err instanceof SlackError) {
assert.include(err.message, "Failed to configure the HTTPS proxy");
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
});
describe("retries", () => {
it("uses a default of five retries in requests", async () => {
const client = new Client();
const result = client.retries();
assert.equal(
result.retries,
webapi.retryPolicies.fiveRetriesInFiveMinutes.retries,
);
});
it('does not attempt retries when "0" is set', async () => {
const webhook = new Client();
const result = webhook.retries("0");
assert.equal(result.retries, 0);
});
it('attempts a default amount of "5" retries', async () => {
const webhook = new Client();
const result = webhook.retries("5");
assert.equal(
result.retries,
webapi.retryPolicies.fiveRetriesInFiveMinutes.retries,
);
assert.equal(
result.factor,
webapi.retryPolicies.fiveRetriesInFiveMinutes.factor,
);
});
it('attempts "10" retries in around "30" minutes', async () => {
const webhook = new Client();
const result = webhook.retries("10");
assert.equal(
result.retries,
webapi.retryPolicies.tenRetriesInAboutThirtyMinutes.retries,
);
assert.equal(
result.factor,
webapi.retryPolicies.tenRetriesInAboutThirtyMinutes.factor,
);
});
it('attempts a "rapid " burst of "12" retries in seconds', async () => {
const webhook = new Client();
const result = webhook.retries("rapid ");
assert.equal(
result.retries,
webapi.retryPolicies.rapidRetryPolicy.retries,
);
assert.equal(result.factor, webapi.retryPolicies.rapidRetryPolicy.factor);
});
it('attempts a "RAPID" burst of "12" retries in seconds', async () => {
const webhook = new Client();
const result = webhook.retries("RAPID");
assert.equal(
result.retries,
webapi.retryPolicies.rapidRetryPolicy.retries,
);
assert.equal(result.factor, webapi.retryPolicies.rapidRetryPolicy.factor);
});
});
});
+180
View File
@@ -0,0 +1,180 @@
import { assert } from "chai";
import Config from "../src/config.js";
import SlackError from "../src/errors.js";
import send from "../src/send.js";
import { mocks } from "./index.spec.js";
/**
* Confirm values from the action input or environment variables are gathered
* or errors are thrown for invalid inputs.
*
* An assumption is made around these same checks and parsings being done for
* each collection of configurations, but only the edge cases of checks are done
* here.
*/
describe("config", () => {
beforeEach(() => {
mocks.reset();
});
describe("inputs", () => {
it("valid values are collected from the action inputs", async () => {
mocks.core.getBooleanInput.withArgs("errors").returns(true);
mocks.core.getInput.withArgs("method").returns("chat.postMessage");
mocks.core.getInput.withArgs("payload").returns('"hello": "world"');
mocks.core.getInput.withArgs("proxy").returns("https://example.com");
mocks.core.getInput.withArgs("retries").returns("0");
mocks.core.getInput.withArgs("token").returns("xoxb-example");
const config = new Config(mocks.core);
assert.equal(config.inputs.errors, true);
assert.equal(config.inputs.method, "chat.postMessage");
assert.equal(config.inputs.payload, '"hello": "world"');
assert.equal(config.inputs.proxy, "https://example.com");
assert.equal(config.inputs.retries, config.Retries.ZERO);
assert.equal(config.inputs.token, "xoxb-example");
});
it("errors when both the token and webhook is provided", async () => {
mocks.core.getInput.withArgs("token").returns("xoxb-example");
mocks.core.getInput.withArgs("webhook").returns("https://example.com");
mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook");
try {
new Config(mocks.core);
assert.fail("Failed to error when invalid inputs are provided");
} catch (err) {
if (err instanceof SlackError) {
assert.include(
err.message,
"Invalid input! Either the token or webhook is required - not both.",
);
assert.isTrue(mocks.core.setSecret.withArgs("xoxb-example").called);
assert.isTrue(
mocks.core.setSecret.withArgs("https://example.com").called,
);
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
it("errors if neither the token or webhook is provided", async () => {
try {
new Config(mocks.core);
assert.fail("Failed to error when invalid inputs are provided");
} catch (err) {
if (err instanceof SlackError) {
assert.include(
err.message,
"Missing input! Either a token or webhook is required to take action.",
);
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
it("errors if a webhook is provided without the type", async () => {
mocks.core.getInput.withArgs("webhook").returns("https://example.com");
try {
new Config(mocks.core);
assert.fail("Failed to error when invalid inputs are provided");
} catch (err) {
if (err instanceof SlackError) {
assert.include(
err.message,
"Missing input! The webhook type must be 'incoming-webhook' or 'webhook-trigger'.",
);
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
it("errors if the webhook type does not match techniques", async () => {
mocks.core.getInput.withArgs("webhook").returns("https://example.com");
mocks.core.getInput.withArgs("webhook-type").returns("post");
try {
new Config(mocks.core);
assert.fail("Failed to error when invalid inputs are provided");
} catch (err) {
if (err instanceof SlackError) {
assert.include(
err.message,
"Invalid input! The webhook type must be 'incoming-webhook' or 'webhook-trigger'.",
);
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
});
describe("secrets", async () => {
it("treats the provided token as a secret", async () => {
mocks.core.getInput.withArgs("token").returns("xoxb-example");
try {
await send(mocks.core);
assert.fail("Failed to error for incomplete inputs while testing");
} catch {
assert.isTrue(mocks.core.setSecret.withArgs("xoxb-example").called);
}
});
it("treats the provided webhook as a secret", async () => {
mocks.core.getInput.withArgs("webhook").returns("https://slack.com");
mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook");
try {
await send(mocks.core);
assert.fail("Failed to error for incomplete inputs while testing");
} catch {
assert.isTrue(
mocks.core.setSecret.withArgs("https://slack.com").called,
);
}
});
});
describe("validate", () => {
it('allow the "retries" option with lowercased space', async () => {
mocks.axios.post.returns(Promise.resolve("LGTM"));
mocks.core.getInput.withArgs("retries").returns(" rapid ");
mocks.core.getInput
.withArgs("webhook")
.returns("https://hooks.slack.com");
mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook");
try {
await send(mocks.core);
} catch (err) {
if (err instanceof SlackError) {
assert.include(
err.message,
'Invalid input! An unknown "retries" value was used: FOREVER',
);
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
it("errors if an invalid retries option is provided", async () => {
mocks.axios.post.returns(Promise.resolve("LGTM"));
mocks.core.getInput.withArgs("retries").returns("FOREVER");
mocks.core.getInput
.withArgs("webhook")
.returns("https://hooks.slack.com");
mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook");
try {
await send(mocks.core);
} catch (err) {
if (err instanceof SlackError) {
assert.include(
err.message,
'Invalid input! An unknown "retries" value was used: FOREVER',
);
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
});
});
+347
View File
@@ -0,0 +1,347 @@
import path from "node:path";
import core from "@actions/core";
import { assert } from "chai";
import Config from "../src/config.js";
import Content from "../src/content.js";
import SlackError from "../src/errors.js";
import send from "../src/send.js";
import { mocks } from "./index.spec.js";
/**
* Confirm values from the action input or environment variables are gathered
*/
describe("content", () => {
beforeEach(() => {
mocks.reset();
mocks.core.getInput.withArgs("method").returns("chat.postMessage");
mocks.core.getInput.withArgs("token").returns("xoxb-example");
});
describe("flatten", () => {
it("flattens nested payloads provided with delimiter", async () => {
mocks.core.getInput.withArgs("payload").returns(`
"apples": "tree",
"bananas": {
"truthiness": true
}
`);
mocks.core.getInput.withArgs("payload-delimiter").returns("_");
const config = new Config(mocks.core);
const expected = {
apples: "tree",
bananas_truthiness: "true",
};
assert.deepEqual(config.content.values, expected);
});
});
describe("get", () => {
it("errors if both a payload and file path are provided", async () => {
mocks.core.getInput.withArgs("payload").returns(`"message"="hello"`);
mocks.core.getInput.withArgs("payload-file-path").returns("example.json");
try {
await send(mocks.core);
assert.fail("Failed to throw for invalid input");
} catch (err) {
if (err instanceof SlackError) {
assert.include(
err.message,
"Invalid input! Just the payload or payload file path is required.",
);
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
});
describe("payload", async () => {
it("parses complete YAML from the input payload", async () => {
mocks.core.getInput.withArgs("payload").returns(`
message: "this is wrapped"
channel: "C0123456789"
`);
const config = new Config(mocks.core);
const expected = {
message: "this is wrapped",
channel: "C0123456789",
};
assert.deepEqual(config.content.values, expected);
});
it("parses complete JSON from the input payload", async () => {
mocks.core.getInput.withArgs("payload").returns(`{
"message": "this is wrapped",
"channel": "C0123456789"
}
`);
const config = new Config(mocks.core);
const expected = {
message: "this is wrapped",
channel: "C0123456789",
};
assert.deepEqual(config.content.values, expected);
});
it("templatizes variables with matching variables", async () => {
mocks.core.getInput
.withArgs("payload")
.returns("message: Served ${{ env.NUMBER }} from ${{ github.apiUrl }}");
mocks.core.getBooleanInput.withArgs("payload-templated").returns(true);
process.env.NUMBER = 12;
const config = new Config(mocks.core);
process.env.NUMBER = undefined;
const expected = {
message: "Served 12 from https://api.github.com",
};
assert.deepEqual(config.content.values, expected);
});
/**
* @see {@link https://github.com/slackapi/slack-github-action/issues/203}
*/
it("templatizes variables with missing variables", async () => {
mocks.core.getInput
.withArgs("payload")
.returns("message: What makes ${{ env.TREASURE }} a secret");
mocks.core.getBooleanInput.withArgs("payload-templated").returns(true);
const config = new Config(mocks.core);
const expected = {
message: "What makes ??? a secret",
};
assert.deepEqual(config.content.values, expected);
});
it("trims last comma JSON with the input payload", async () => {
mocks.core.getInput.withArgs("payload").returns(`
"message": "LGTM!",
"channel": "C0123456789",
`);
const config = new Config(mocks.core);
const expected = {
message: "LGTM!",
channel: "C0123456789",
};
assert.deepEqual(config.content.values, expected);
});
it("wraps incomplete JSON from the input payload", async () => {
mocks.core.getInput.withArgs("payload").returns(`
"message": "LGTM!",
"channel": "C0123456789",
"blocks": [
{
"type": "section",
"text": {
"text": "LGTM! :+1:"
}
}
]
`);
const config = new Config(mocks.core);
const expected = {
message: "LGTM!",
channel: "C0123456789",
blocks: [
{
type: "section",
text: {
text: "LGTM! :+1:",
},
},
],
};
assert.deepEqual(config.content.values, expected);
});
it("fails if no payload content is provided in input", async () => {
/**
* @type {Config}
*/
const config = {
core: core,
inputs: {
payloadFilePath: "unknown.json",
},
};
try {
new Content().getContentPayload(config);
assert.fail("Failed to throw for missing payload content");
} catch (err) {
if (err instanceof SlackError) {
assert.include(
err.message,
"Invalid input! No payload content was provided",
);
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
it("fails if invalid JSON exists in the input payload", async () => {
mocks.core.getInput.withArgs("payload").returns("{");
try {
await send(mocks.core);
assert.fail("Failed to throw for invalid JSON");
} catch (err) {
if (err instanceof SlackError) {
assert.include(
err.message,
"Invalid input! Failed to parse contents of the provided payload",
);
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
});
describe("payload file", async () => {
it("parses complete YAML from the input payload file", async () => {
mocks.core.getInput.withArgs("payload-file-path").returns("example.yaml");
mocks.fs.readFileSync
.withArgs(path.resolve("example.yaml"), "utf-8")
.returns(`
message: "drink water"
channel: "C6H12O6H2O2"
`);
const config = new Config(mocks.core);
const expected = {
message: "drink water",
channel: "C6H12O6H2O2",
};
assert.deepEqual(config.content.values, expected);
});
it("parses complete YML from the input payload file", async () => {
mocks.core.getInput.withArgs("payload-file-path").returns("example.yml");
mocks.fs.readFileSync
.withArgs(path.resolve("example.yml"), "utf-8")
.returns(`
message: "drink coffee"
channel: "C0FFEEEEEEEE"
`);
const config = new Config(mocks.core);
const expected = {
message: "drink coffee",
channel: "C0FFEEEEEEEE",
};
assert.deepEqual(config.content.values, expected);
});
it("parses complete JSON from the input payload file", async () => {
mocks.core.getInput.withArgs("payload-file-path").returns("example.json");
mocks.fs.readFileSync
.withArgs(path.resolve("example.json"), "utf-8")
.returns(`{
"message": "drink water",
"channel": "C6H12O6H2O2"
}`);
const config = new Config(mocks.core);
const expected = {
message: "drink water",
channel: "C6H12O6H2O2",
};
assert.deepEqual(config.content.values, expected);
});
it("templatizes variables with matching variables", async () => {
mocks.core.getInput.withArgs("payload-file-path").returns("example.json");
mocks.fs.readFileSync
.withArgs(path.resolve("example.json"), "utf-8")
.returns(`{
"message": "Served $\{\{ env.NUMBER }} from $\{\{ github.apiUrl }}"
}`);
mocks.core.getBooleanInput.withArgs("payload-templated").returns(true);
process.env.NUMBER = 12;
const config = new Config(mocks.core);
process.env.NUMBER = undefined;
const expected = {
message: "Served 12 from https://api.github.com",
};
assert.deepEqual(config.content.values, expected);
});
/**
* @see {@link https://github.com/slackapi/slack-github-action/issues/203}
*/
it("templatizes variables with missing variables", async () => {
mocks.core.getInput.withArgs("payload-file-path").returns("example.json");
mocks.fs.readFileSync
.withArgs(path.resolve("example.json"), "utf-8")
.returns(`{
"message": "What makes $\{\{ env.TREASURE }} a secret"
}`);
mocks.core.getBooleanInput.withArgs("payload-templated").returns(true);
const config = new Config(mocks.core);
const expected = {
message: "What makes ??? a secret",
};
assert.deepEqual(config.content.values, expected);
});
it("fails if no payload file is provided in the input", async () => {
/**
* @type {Config}
*/
const config = {
core: core,
inputs: {
payload: "LGTM",
},
};
try {
new Content().getContentPayloadFilePath(config);
assert.fail("Failed to throw for the wrong payload type");
} catch (err) {
if (err instanceof SlackError) {
assert.include(
err.message,
"Invalid input! No payload found for content",
);
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
it("fails to parse a file path that does not exist", async () => {
mocks.core.getInput.withArgs("payload-file-path").returns("unknown.json");
try {
await send(mocks.core);
assert.fail("Failed to throw for nonexistent files");
} catch (err) {
if (err instanceof SlackError) {
assert.include(
err.message,
"Invalid input! Failed to parse contents of the provided payload file",
);
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
it("fails to parse a file with an unknown extension", async () => {
mocks.core.getInput.withArgs("payload-file-path").returns("unknown.md");
try {
await send(mocks.core);
assert.fail("Failed to throw for an unknown extension");
} catch (err) {
if (err instanceof SlackError) {
assert.include(
err.message,
"Invalid input! Failed to parse contents of the provided payload file",
);
assert.include(
err.cause.message,
"Invalid input! Failed to parse file extension unknown.md",
);
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
});
});
+67
View File
@@ -0,0 +1,67 @@
import fs from "node:fs";
import core from "@actions/core";
import webapi from "@slack/web-api";
import axios, { AxiosError } from "axios";
import sinon from "sinon";
/**
* Hello experimenter! These tests are here to confirm that the happy paths keep
* working and error cases are thrown.
*
* Tests are grouped with related related code using a file *.spec.js extension.
* These test related functionalities of units but a full suite of integration
* tests confirm correctness of the actual workflows in .github/workflows/*.yml.
*
* Actual tests begin in send.spec.js which has integration tests. Other modules
* test the edges of this action.
*/
/**
* The Mock class sets expected behaviors and test listeners for dependencies.
*/
export class Mock {
/**
* @typedef Errors - A collection of mocked errors to use in tests.
* @prop {Object.<string, AxiosError>} axios - The mocked axios errors.
*/
/**
* The mocked errors.
* @type {Errors}
*/
errors = {
axios: {
network_failed: new AxiosError("network_failed"),
},
};
/**
* Setup stubbed dependencies and configure default input arguments for all
* tests.
*
* @see {@link ../action.yml}
*/
constructor() {
this.sandbox = sinon.createSandbox();
this.api = sinon.stub(webapi.WebClient.prototype, "apiCall");
this.axios = this.sandbox.stub(axios);
this.core = this.sandbox.stub(core);
this.fs = this.sandbox.stub(fs);
this.core.getInput.withArgs("errors").returns("false");
this.core.getInput.withArgs("retries").returns("5");
}
/**
* Testing interface that removes internal state from existing stubs.
*/
reset() {
this.sandbox.reset();
this.api.resetHistory();
this.axios.post.resetHistory();
this.core.getInput.reset();
this.core.getInput.withArgs("errors").returns("false");
this.core.getInput.withArgs("retries").returns("5");
}
}
export const mocks = new Mock();
-1
View File
@@ -1 +0,0 @@
{not-valid-json
-5
View File
@@ -1,5 +0,0 @@
{
"oliver": "benji",
"bonny": "clyde",
"actor":"${{github.actor}}"
}
+77
View File
@@ -0,0 +1,77 @@
import { assert } from "chai";
import send from "../src/send.js";
import { mocks } from "./index.spec.js";
/**
* This is a collection of integration tests that make sure modules are doing
* whatever's expected.
*
* Or at least that's planned...
*
* Edge cases for inputs are checked in separate modules including the config
* specifications.
*/
describe("send", () => {
beforeEach(() => {
mocks.reset();
});
describe("techniques", async () => {
it("webhook trigger", async () => {
mocks.core.getInput
.withArgs("webhook")
.returns("https://hooks.slack.com");
mocks.core.getInput.withArgs("webhook-type").returns("webhook-trigger");
mocks.core.getInput.withArgs("payload").returns('"greetings": "hello"');
mocks.axios.post.returns(
Promise.resolve({ status: 200, data: { ok: true } }),
);
await send(mocks.core);
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, true);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
assert.equal(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify({ ok: true }),
);
assert.equal(mocks.core.setOutput.getCall(2).firstArg, "time");
assert.isAtLeast(mocks.core.setOutput.getCall(2).lastArg, 0);
});
it("token", async () => {
mocks.api.resolves({ ok: true });
mocks.core.getInput.withArgs("method").returns("chat.postMessage");
mocks.core.getInput.withArgs("token").returns("xoxb-example");
mocks.core.getInput.withArgs("payload").returns('"text": "hello"');
await send(mocks.core);
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, true);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
assert.equal(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify({ ok: true }),
);
assert.equal(mocks.core.setOutput.getCall(2).firstArg, "time");
assert.isAtLeast(mocks.core.setOutput.getCall(2).lastArg, 0);
});
it("incoming webhook", async () => {
mocks.core.getInput
.withArgs("webhook")
.returns("https://hooks.slack.com");
mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook");
mocks.core.getInput.withArgs("payload").returns('"text": "hello"');
mocks.axios.post.returns(Promise.resolve({ status: 200, data: "ok" }));
await send(mocks.core);
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, true);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
assert.equal(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify("ok"),
);
assert.equal(mocks.core.setOutput.getCall(2).firstArg, "time");
assert.isAtLeast(mocks.core.setOutput.getCall(2).lastArg, 0);
});
});
});
-328
View File
@@ -1,328 +0,0 @@
const { assert } = require('chai');
const sinon = require('sinon');
const core = require('@actions/core');
const github = require('@actions/github');
const rewiremock = require('rewiremock/node');
const ChatStub = {
postMessage: sinon.fake.resolves({ ok: true, ts: '1503435957.111111', thread_ts: '1503435956.000247' }),
update: sinon.fake.resolves({ ok: true, thread_ts: '1503435956.000247' }),
};
/* eslint-disable-next-line global-require */
rewiremock(() => require('@slack/web-api')).with({
WebClient: class {
constructor(token) {
this.token = token;
this.chat = ChatStub;
}
},
});
const AxiosMock = {
post: sinon.stub().resolves(),
};
/* eslint-disable-next-line global-require */
rewiremock(() => require('axios')).with(AxiosMock);
rewiremock.enable();
const slackSend = require('../src/slack-send');
rewiremock.disable();
const ORIG_TOKEN_VAR = process.env.SLACK_BOT_TOKEN;
const ORIG_WEBHOOK_VAR = process.env.SLACK_WEBHOOK_URL;
describe('slack-send', () => {
const fakeCore = sinon.stub(core);
const fakeGithub = sinon.stub(github);
beforeEach(() => {
sinon.reset();
});
after(() => {
process.env.SLACK_BOT_TOKEN = ORIG_TOKEN_VAR;
process.env.SLACK_WEBHOOK_URL = ORIG_WEBHOOK_VAR;
});
it('should set an error if no webhook URL or token is provided', async () => {
delete process.env.SLACK_BOT_TOKEN;
delete process.env.SLACK_WEBHOOK_URL;
await slackSend(fakeCore);
assert.include(fakeCore.setFailed.lastCall.firstArg.message, 'Need to provide at least one botToken or webhook', 'Error set specifying what env vars need to be set.');
});
describe('using a bot token', () => {
beforeEach(() => {
process.env.SLACK_BOT_TOKEN = 'xoxb-xxxxx';
delete process.env.SLACK_WEBHOOK_URL;
});
describe('happy path', () => {
it('should send a message using the postMessage API', async () => {
fakeCore.getInput.withArgs('slack-message').returns('who let the dogs out?');
fakeCore.getInput.withArgs('channel-id').returns('C123456');
await slackSend(fakeCore);
assert.equal(fakeCore.setOutput.firstCall.firstArg, 'ts', 'Output name set to ts');
assert.equal(fakeCore.setOutput.secondCall.firstArg, 'thread_ts', 'Output name set to thread_ts');
assert(fakeCore.setOutput.secondCall.lastArg.length > 0, 'Time output a non-zero-length string');
assert.equal(fakeCore.setOutput.lastCall.firstArg, 'time', 'Output name set to time');
assert(fakeCore.setOutput.lastCall.lastArg.length > 0, 'Time output a non-zero-length string');
const chatArgs = ChatStub.postMessage.lastCall.firstArg;
assert.equal(chatArgs.channel, 'C123456', 'Correct channel provided to postMessage');
assert.equal(chatArgs.text, 'who let the dogs out?', 'Correct message provided to postMessage');
});
it('should send a message using the update API', async () => {
fakeCore.getInput.withArgs('slack-message').returns('who let the dogs out?');
fakeCore.getInput.withArgs('channel-id').returns('C123456');
fakeCore.getInput.withArgs('update-ts').returns('123456');
await slackSend(fakeCore);
assert.equal(fakeCore.setOutput.firstCall.firstArg, 'ts', 'Output name set to ts');
assert.equal(fakeCore.setOutput.secondCall.firstArg, 'thread_ts', 'Output name set to thread_ts');
assert(fakeCore.setOutput.secondCall.lastArg.length > 0, 'Time output a non-zero-length string');
assert.equal(fakeCore.setOutput.lastCall.firstArg, 'time', 'Output name set to time');
assert(fakeCore.setOutput.lastCall.lastArg.length > 0, 'Time output a non-zero-length string');
const chatArgs = ChatStub.update.lastCall.firstArg;
assert.equal(chatArgs.channel, 'C123456', 'Correct channel provided to postMessage');
assert.equal(chatArgs.text, 'who let the dogs out?', 'Correct message provided to postMessage');
});
it('should send payload-file-path values with replaced context variables', async () => {
// Prepare
fakeCore.getInput.withArgs('channel-id').returns('C123456');
fakeCore.getInput.withArgs('payload-file-path').returns('./test/resources/valid-payload.json');
fakeCore.getBooleanInput.withArgs('payload-file-path-parsed').returns(true);
fakeGithub.context.actor = 'user123';
// Run
await slackSend(fakeCore);
// Assert
assert.equal(fakeCore.setOutput.firstCall.firstArg, 'ts', 'Output name set to ts');
assert.equal(fakeCore.setOutput.secondCall.firstArg, 'thread_ts', 'Output name set to thread_ts');
assert(fakeCore.setOutput.secondCall.lastArg.length > 0, 'Time output a non-zero-length string');
assert.equal(fakeCore.setOutput.lastCall.firstArg, 'time', 'Output name set to time');
assert(fakeCore.setOutput.lastCall.lastArg.length > 0, 'Time output a non-zero-length string');
const chatArgs = ChatStub.postMessage.lastCall.firstArg;
assert.equal(chatArgs.channel, 'C123456', 'Correct channel provided to postMessage');
assert.equal(chatArgs.text, '', 'Correct message provided to postMessage');
assert.equal(chatArgs.bonny, 'clyde', 'Correct message provided to postMessage');
assert.equal(chatArgs.oliver, 'benji', 'Correct message provided to postMessage');
assert.equal(chatArgs.actor, 'user123', 'Correct message provided to postMessage');
});
it('should send payload-file-path values without replacing context variables', async () => {
// Prepare
fakeCore.getInput.withArgs('channel-id').returns('C123456');
fakeCore.getInput.withArgs('payload-file-path').returns('./test/resources/valid-payload.json');
fakeCore.getBooleanInput.withArgs('payload-file-path-parsed').returns(false);
fakeGithub.context.actor = 'user123';
// Run
await slackSend(fakeCore);
// Assert
assert.equal(fakeCore.setOutput.firstCall.firstArg, 'ts', 'Output name set to ts');
assert.equal(fakeCore.setOutput.secondCall.firstArg, 'thread_ts', 'Output name set to thread_ts');
assert(fakeCore.setOutput.secondCall.lastArg.length > 0, 'Time output a non-zero-length string');
assert.equal(fakeCore.setOutput.lastCall.firstArg, 'time', 'Output name set to time');
assert(fakeCore.setOutput.lastCall.lastArg.length > 0, 'Time output a non-zero-length string');
const chatArgs = ChatStub.postMessage.lastCall.firstArg;
assert.equal(chatArgs.channel, 'C123456', 'Correct channel provided to postMessage');
assert.equal(chatArgs.text, '', 'Correct message provided to postMessage');
assert.equal(chatArgs.bonny, 'clyde', 'Correct message provided to postMessage');
assert.equal(chatArgs.oliver, 'benji', 'Correct message provided to postMessage');
/* eslint-disable-next-line no-template-curly-in-string */
assert.equal(chatArgs.actor, '${{github.actor}}', 'Correct message provided to postMessage');
});
it('should send the same message to multiple channels', async () => {
fakeCore.getInput.withArgs('slack-message').returns('who let the dogs out?');
fakeCore.getInput.withArgs('channel-id').returns('C123456,C987654');
await slackSend(fakeCore);
const firstChatArgs = ChatStub.postMessage.firstCall.firstArg;
const secondChatArgs = ChatStub.postMessage.lastCall.firstArg;
assert.oneOf('C123456', [firstChatArgs.channel, secondChatArgs.channel], 'First comma-separated channel provided to postMessage');
assert.oneOf('C987654', [firstChatArgs.channel, secondChatArgs.channel], 'Second comma-separated channel provided to postMessage');
assert.equal(firstChatArgs.text, 'who let the dogs out?', 'Correct message provided to postMessage with first comma-separated channel');
assert.equal(secondChatArgs.text, 'who let the dogs out?', 'Correct message provided to postMessage with second comma-separated channel');
});
it("should send a reply-message using the postMessage API if thread_ts payload field is used'", async () => {
fakeCore.getInput
.withArgs('payload')
.returns('{"thread_ts":"123456","text":"who let the dogs out?"}');
fakeCore.getInput.withArgs('channel-id').returns('C123456');
await slackSend(fakeCore);
assert.equal(fakeCore.setOutput.firstCall.firstArg, 'ts', 'Output name set to ts');
assert.equal(fakeCore.setOutput.secondCall.firstArg, 'thread_ts', 'Output name set to thread_ts');
assert(fakeCore.setOutput.secondCall.lastArg.length > 0, 'Time output a non-zero-length string');
assert.equal(fakeCore.setOutput.lastCall.firstArg, 'time', 'Output name set to time');
assert(fakeCore.setOutput.lastCall.lastArg.length > 0, 'Time output a non-zero-length string');
const chatArgs = ChatStub.postMessage.lastCall.firstArg;
assert.equal(chatArgs.channel, 'C123456', 'Correct channel provided to postMessage');
assert.equal(chatArgs.thread_ts, '123456', 'Correct thread_ts provided to postMessage');
assert.equal(chatArgs.text, 'who let the dogs out?', 'Correct message provided to postMessage');
});
});
describe('sad path', () => {
it('should set an error if payload cannot be JSON parsed', async () => {
fakeCore.getInput.withArgs('payload').returns('{not-valid-json');
await slackSend(fakeCore);
assert.include(fakeCore.setFailed.lastCall.firstArg.message, 'Need to provide valid JSON', 'Error set specifying JSON was invalid.');
});
it('should fail if an invalid payload-file-path is provided', async () => {
// Prepare
fakeCore.getInput.withArgs('channel-id').returns('C123456');
fakeCore.getInput.withArgs('payload-file-path').returns('non-existing-path.json');
// Run
await slackSend(fakeCore);
// Assert
assert.include(fakeCore.setFailed.lastCall.firstArg.message, 'The payload-file-path may be incorrect. Failed to load the file: non-existing-path.json', 'Error set specifying JSON was invalid.');
});
it('should fail if a valid payload-file-path with an invalid JSON is provided', async () => {
// Prepare
fakeCore.getInput.withArgs('channel-id').returns('C123456');
fakeCore.getInput.withArgs('payload-file-path').returns('./test/resources/invalid-payload.json');
// Run
await slackSend(fakeCore);
// Assert
assert.include(fakeCore.setFailed.lastCall.firstArg.message, 'Need to provide valid JSON payload', 'Error set specifying JSON was invalid.');
});
it('should fail if Channel ID is missing', async () => {
// Run
await slackSend(fakeCore);
// Assert
assert.include(fakeCore.setFailed.lastCall.firstArg.message, 'Channel ID is required to run this action. An empty one has been provided', 'Error set specifying JSON was invalid.');
});
it('should fail if payload is missing or empty', async () => {
// Prepare
fakeCore.getInput.withArgs('channel-id').returns('C123456');
// Run
await slackSend(fakeCore);
// Assert
assert.include(fakeCore.setFailed.lastCall.firstArg.message, 'Missing message content, please input a valid payload or message to send. No Message has been send.', 'Error set specifying JSON was invalid.');
});
});
});
describe('using a webhook URL', () => {
beforeEach(() => {
process.env.SLACK_WEBHOOK_URL = 'https://someurl';
delete process.env.SLACK_BOT_TOKEN;
});
describe('happy path', () => {
const payload = {
batman: 'robin',
thor: 'loki',
};
beforeEach(() => {
fakeCore.getInput.withArgs('payload').returns(JSON.stringify(payload));
});
it('should post the payload to the webhook URL', async () => {
await slackSend(fakeCore);
assert(AxiosMock.post.calledWith('https://someurl', payload));
});
describe('proxy config', () => {
beforeEach(() => {
delete process.env.https_proxy;
delete process.env.HTTPS_PROXY;
});
it('should use https proxy agent when proxy uses HTTP', async () => {
process.env.HTTPS_PROXY = 'http://test.proxy:8080/';
await slackSend(fakeCore);
assert(AxiosMock.post.calledWith('https://someurl', payload, sinon.match.has('httpsAgent').and(sinon.match.has('proxy'))));
});
it('should use default axios config when no proxy set', async () => {
await slackSend(fakeCore);
assert(AxiosMock.post.calledWithExactly('https://someurl', payload, {}));
});
it('should use default axios config when proxy uses HTTPS', async () => {
process.env.HTTPS_PROXY = 'https://test.proxy:8080/';
await slackSend(fakeCore);
assert(AxiosMock.post.calledWithExactly('https://someurl', payload, {}));
});
it('should use default axios config when proxy URL is invalid', async () => {
process.env.HTTPS_PROXY = 'invalid string';
const consoleSpy = sinon.spy(console, 'log');
await slackSend(fakeCore);
assert(consoleSpy.calledWith('failed to configure https proxy agent for http proxy. Using default axios configuration'));
assert(AxiosMock.post.calledWithExactly('https://someurl', payload, {}));
});
});
describe('flatten', () => {
const mockPayload = {
apples: 'tree',
bananas: { truthiness: true },
};
beforeEach(() => {
fakeCore.getInput.withArgs('payload').returns(JSON.stringify(mockPayload));
});
afterEach(() => {
delete process.env.SLACK_WEBHOOK_TYPE;
});
it('defaults to using a period to flatten nested paylods', async () => {
process.env.SLACK_WEBHOOK_TYPE = 'WORKFLOW_TRIGGER';
await slackSend(fakeCore);
const expected = {
apples: 'tree',
'bananas.truthiness': 'true',
};
const count = AxiosMock.post.callCount;
assert.equal(count, 1);
const post = AxiosMock.post.getCall(0);
const [url, actual] = post.args;
assert.equal(url, 'https://someurl');
assert.deepEqual(actual, expected);
});
it('replaces delimiter with provided payload settings', async () => {
fakeCore.getInput.withArgs('payload-delimiter').returns('_');
process.env.SLACK_WEBHOOK_TYPE = 'WORKFLOW_TRIGGER';
await slackSend(fakeCore);
const expected = {
apples: 'tree',
bananas_truthiness: 'true',
};
const count = AxiosMock.post.callCount;
assert.equal(count, 1);
const post = AxiosMock.post.getCall(0);
const [url, actual] = post.args;
assert.equal(url, 'https://someurl');
assert.deepEqual(actual, expected);
});
it('does not flatten nested values of incoming webhook', async () => {
process.env.SLACK_WEBHOOK_TYPE = 'INCOMING_WEBHOOK';
await slackSend(fakeCore);
const expected = {
apples: 'tree',
bananas: { truthiness: true },
};
const count = AxiosMock.post.callCount;
assert.equal(count, 1);
const post = AxiosMock.post.getCall(0);
const [url, actual] = post.args;
assert.equal(url, 'https://someurl');
assert.deepEqual(actual, expected);
});
});
});
describe('sad path', () => {
it('should set an error if the POST to the webhook fails without a response', async () => {
AxiosMock.post.rejects(new Error('boom'));
await slackSend(fakeCore);
assert.include(fakeCore.setFailed.lastCall.firstArg, 'boom', 'Error set to whatever axios reports as error.');
});
});
});
});
-33
View File
@@ -1,33 +0,0 @@
const { assert } = require('chai');
const sinon = require('sinon');
const rewiremock = require('rewiremock/node');
/* eslint-disable-next-line global-require */
rewiremock(() => require('@slack/web-api')).with({
WebClient: class {
constructor(token, options) {
this.token = token;
this.options = options || {};
}
},
});
rewiremock.enable();
const { createWebClient } = require('../src/web-client');
rewiremock.disable();
describe('web-client', () => {
beforeEach(() => {
sinon.reset();
});
it('should create WebClient with an https proxy agent if given a proxy URL', async () => {
const web = createWebClient('xoxb-xxxxx', 'http://test.proxy:8080/');
assert.equal(typeof web.options.agent, 'object');
});
it('should create WebClient with default settings if not given a proxy URL', async () => {
const web = createWebClient('xoxb-xxxxx');
assert.equal(web.options.agent, undefined);
});
});
+338
View File
@@ -0,0 +1,338 @@
import core from "@actions/core";
import { AxiosError } from "axios";
import { assert } from "chai";
import Config from "../src/config.js";
import SlackError from "../src/errors.js";
import send from "../src/send.js";
import Webhook from "../src/webhook.js";
import { mocks } from "./index.spec.js";
describe("webhook", () => {
beforeEach(() => {
mocks.reset();
});
describe("success", () => {
it("sends the parsed payload to the provided webhook trigger", async () => {
mocks.core.getInput
.withArgs("webhook")
.returns("https://hooks.slack.com");
mocks.core.getInput.withArgs("webhook-type").returns("webhook-trigger");
mocks.core.getInput.withArgs("payload").returns("drinks: coffee");
mocks.axios.post.returns(
Promise.resolve({ status: 200, data: { ok: true } }),
);
try {
await send(mocks.core);
assert.equal(mocks.axios.post.getCalls().length, 1);
const [url, payload, options] = mocks.axios.post.getCall(0).args;
assert.equal(url, "https://hooks.slack.com");
assert.deepEqual(payload, { drinks: "coffee" });
assert.deepEqual(options, {});
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, true);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
assert.equal(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify({ ok: true }),
);
} catch (err) {
console.error(err);
assert.fail("Failed to send the webhook");
}
});
it("sends the parsed payload to the provided incoming webhook", async () => {
mocks.core.getInput
.withArgs("webhook")
.returns("https://hooks.slack.com");
mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook");
mocks.core.getInput.withArgs("payload").returns("text: greetings");
mocks.axios.post.returns(Promise.resolve({ status: 200, data: "ok" }));
try {
await send(mocks.core);
assert.equal(mocks.axios.post.getCalls().length, 1);
const [url, payload, options] = mocks.axios.post.getCall(0).args;
assert.equal(url, "https://hooks.slack.com");
assert.deepEqual(payload, { text: "greetings" });
assert.deepEqual(options, {});
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, true);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
assert.equal(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify("ok"),
);
} catch (err) {
console.error(err);
assert.fail("Failed to send the webhook");
}
});
});
describe("failure", () => {
it("requires that a webhook is provided in inputs", async () => {
/**
* @type {Config}
*/
const config = {
core: core,
inputs: {},
};
try {
await new Webhook().post(config);
assert.fail("Failed to throw for missing input");
} catch (err) {
if (err instanceof SlackError) {
assert.include(err.message, "No webhook was provided to post to");
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
it("returns the failures from a webhook trigger", async () => {
mocks.core.getInput
.withArgs("webhook")
.returns("https://hooks.slack.com");
mocks.core.getInput.withArgs("webhook-type").returns("webhook-trigger");
mocks.core.getInput.withArgs("payload").returns("drinks: coffee");
const response = new AxiosError(
"Request failed with status code 400",
"ERR_BAD_REQUEST",
{},
{},
{ status: 400 },
);
mocks.axios.post.resolves(Promise.reject(response));
try {
await send(mocks.core);
} catch (err) {
if (err instanceof SlackError) {
assert.include(err.message, "Request failed with status code 400");
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
assert.equal(mocks.axios.post.getCalls().length, 1);
const [url, payload, options] = mocks.axios.post.getCall(0).args;
assert.equal(url, "https://hooks.slack.com");
assert.deepEqual(payload, { drinks: "coffee" });
assert.deepEqual(options, {});
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, false);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
});
it("returns the failures from an incoming webhook", async () => {
mocks.core.getInput
.withArgs("webhook")
.returns("https://hooks.slack.com");
mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook");
mocks.core.getInput.withArgs("payload").returns("textt: oops");
const response = new AxiosError(
"Request failed with status code 400",
"ERR_BAD_REQUEST",
{},
{},
{ status: 400 },
);
mocks.axios.post.resolves(Promise.reject(response));
try {
await send(mocks.core);
} catch (err) {
if (err instanceof SlackError) {
assert.include(err.message, "Request failed with status code 400");
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
assert.equal(mocks.axios.post.getCalls().length, 1);
const [url, payload, options] = mocks.axios.post.getCall(0).args;
assert.equal(url, "https://hooks.slack.com");
assert.deepEqual(payload, { textt: "oops" });
assert.deepEqual(options, {});
assert.equal(mocks.core.setOutput.getCall(0).firstArg, "ok");
assert.equal(mocks.core.setOutput.getCall(0).lastArg, false);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
});
});
describe("proxies", () => {
it("requires a webhook is included in the inputs", async () => {
/**
* @type {Config}
*/
const config = {
core: core,
inputs: {},
};
try {
new Webhook().proxies(config);
assert.fail("Failed to throw for missing input");
} catch (err) {
if (err instanceof SlackError) {
assert.include(err.message, "No webhook was provided to proxy to");
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
it("skips proxying an http webhook url altogether", async () => {
mocks.core.getInput.withArgs("webhook").returns("http://hooks.slack.com");
mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook");
mocks.core.getInput.withArgs("proxy").returns("https://example.com");
const config = new Config(mocks.core);
const webhook = new Webhook();
const request = webhook.proxies(config);
assert.isUndefined(request);
});
it("sets up the proxy agent for the provided https proxy", async () => {
const proxy = "https://example.com";
mocks.core.getInput
.withArgs("webhook")
.returns("https://hooks.slack.com");
mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook");
mocks.core.getInput.withArgs("proxy").returns(proxy);
const config = new Config(mocks.core);
const webhook = new Webhook();
const { httpsAgent, proxy: proxying } = webhook.proxies(config);
assert.deepEqual(httpsAgent.proxy, new URL(proxy));
assert.isNotFalse(proxying);
});
it("sets up the agent without proxy for http proxies", async () => {
const proxy = "http://example.com";
mocks.core.getInput
.withArgs("webhook")
.returns("https://hooks.slack.com");
mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook");
mocks.core.getInput.withArgs("proxy").returns(proxy);
const config = new Config(mocks.core);
const webhook = new Webhook();
const { httpsAgent, proxy: proxying } = webhook.proxies(config);
assert.deepEqual(httpsAgent.proxy, new URL(proxy));
assert.isFalse(proxying);
});
it("fails to configure proxies with an invalid proxied url", async () => {
const proxy = "https://";
mocks.core.getInput
.withArgs("webhook")
.returns("https://hooks.slack.com");
mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook");
mocks.core.getInput.withArgs("proxy").returns(proxy);
try {
const config = new Config(mocks.core);
const webhook = new Webhook();
webhook.proxies(config);
assert.fail("An invalid proxy URL was not thrown as error!");
} catch (err) {
if (err instanceof SlackError) {
assert.include(err.message, "Failed to configure the HTTPS proxy");
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
it("fails to configure proxies with an unknown url protocol", async () => {
const proxy = "ssh://";
mocks.core.getInput
.withArgs("webhook")
.returns("https://hooks.slack.com");
mocks.core.getInput.withArgs("webhook-type").returns("incoming-webhook");
mocks.core.getInput.withArgs("proxy").returns(proxy);
try {
const config = new Config(mocks.core);
const webhook = new Webhook();
webhook.proxies(config);
assert.fail("An unknown URL protocol was not thrown as error!");
} catch (err) {
if (err instanceof SlackError) {
assert.include(err.message, "Failed to configure the HTTPS proxy");
assert.include(err.cause.message, "Unsupported URL protocol");
} else {
assert.fail("Failed to throw a SlackError", err);
}
}
});
});
describe("retries", () => {
it("uses a default of five retries in requests", async () => {
const webhook = new Webhook();
const result = webhook.retries();
assert.equal(result.retries, 5);
});
it('does not attempt retries when "0" is set', async () => {
const webhook = new Webhook();
const result = webhook.retries("0");
assert.equal(result.retries, 0);
});
it('attempts a default amount of "5" retries', async () => {
const webhook = new Webhook();
const result = webhook.retries("5");
assert.equal(result.retries, 5);
if (!result.retryDelay) {
assert.fail("No retry delay found!");
}
assert.equal(
result.retryDelay(5, mocks.errors.axios.network_failed),
300000,
"5th retry after 5 seconds",
);
});
it('attempts "10" retries in around "30" minutes', async () => {
const webhook = new Webhook();
const result = webhook.retries("10");
assert.equal(result.retries, 10);
if (!result.retryDelay) {
assert.fail("No retry delay found!");
}
assert.isAtLeast(
result.retryDelay(10, mocks.errors.axios.network_failed),
1800000,
"last attempt is around 30 minutes after starting",
);
assert.isAtMost(
result.retryDelay(10, mocks.errors.axios.network_failed),
3600000,
"last attempt is no more than an hour later",
);
});
it('attempts a " rapid" burst of "12" retries in seconds', async () => {
const webhook = new Webhook();
const result = webhook.retries(" rapid");
assert.equal(result.retries, 12);
if (!result.retryDelay) {
assert.fail("No retry delay found!");
}
assert.equal(
result.retryDelay(12, mocks.errors.axios.network_failed),
12000,
"12th retry after 12 seconds",
);
});
it('attempts a "RAPID" burst of "12" retries in seconds', async () => {
const webhook = new Webhook();
const result = webhook.retries("RAPID");
assert.equal(result.retries, 12);
if (!result.retryDelay) {
assert.fail("No retry delay found!");
}
assert.equal(
result.retryDelay(12, mocks.errors.axios.network_failed),
12000,
"12th retry after 12 seconds",
);
});
});
});