Automatic compilation

This commit is contained in:
github-actions[bot]
2024-11-01 03:20:29 +00:00
committed by GitHub
parent a2f063c40e
commit c2dfde8f10
49 changed files with 55062 additions and 6120 deletions
-7
View File
@@ -1,7 +0,0 @@
{
"include": ["src/*.js"],
"exclude": ["**/*.spec.js"],
"reporter": ["lcov", "text"],
"all": false,
"cache": true
}
-59
View File
@@ -1,59 +0,0 @@
# Contributors Guide
Interested in contributing? Awesome! Before you do though, please read our
[Code of Conduct](https://slackhq.github.io/code-of-conduct). We take it very seriously, and expect that you will as
well.
There are many ways you can contribute! :heart:
### Bug Reports and Fixes :bug:
- If you find a bug, please [create a new issue](https://github.com/slackapi/slack-github-action/issues/new). Fill out the "Bug Report" section of the issue template. Even if an Issue is closed, feel free to comment and add details, it will still
be reviewed.
- Issues that have already been identified as a bug (note: able to reproduce) will be labelled `bug`.
- If you'd like to submit a fix for a bug, [send a Pull Request](#creating_a_pull_request) and mention the Issue number.
- Include tests that isolate the bug and verifies that it was fixed.
### New Features :bulb:
- If you'd like to add new functionality to this project, describe the problem you want to solve in a [new Issue](https://github.com/slackapi/slack-github-action/issues/new).
- Issues that have been identified as a feature request will be labelled `enhancement`.
- If you'd like to implement the new feature, please wait for feedback from the project
maintainers before spending too much time writing the code. In some cases, `enhancement`s may
not align well with the project objectives at the time.
### Tests :mag:, Documentation :books:, Miscellaneous :sparkles:
- If you'd like to improve the tests, you want to make the documentation clearer, you have an
alternative implementation of something that may have advantages over the way its currently
done, or you have any other change, we would be happy to hear about it!
- If its a trivial change, go ahead and [send a Pull Request](#creating_a_pull_request) with the changes you have in mind.
- If not, [open an Issue](https://github.com/slackapi/slack-github-actiondcjtfkgitihftbgeungvnhgbvkhjtdbiilknucucrfek/issues/new) to discuss the idea first.
If you're new to our project and looking for some way to make your first contribution, look for
Issues labelled `good first contribution`.
## Requirements
For your contribution to be accepted:
- [x] You must have signed the [Contributor License Agreement (CLA)](https://cla.salesforce.com/sign-cla).
- [x] The test suite must be complete and pass.
- [x] The changes must be approved by code review.
- [x] Commits should be atomic and messages must be descriptive. Related issues should be mentioned by Issue number.
If the contribution doesn't meet the above criteria, you may fail our automated checks or a maintainer will discuss it with you. You can continue to improve a Pull Request by adding commits to the branch from which the PR was created.
[Interested in knowing more about about pull requests at Slack?](https://slack.engineering/on-empathy-pull-requests-979e4257d158#.awxtvmb2z)
## Creating a Pull Request
1. :fork_and_knife: Fork the repository on GitHub.
2. :runner: Clone/fetch your fork to your local development machine. It's a good idea to run the tests just
to make sure everything is in order.
3. :herb: Create a new branch and check it out.
4. :crystal_ball: Make your changes and commit them locally. Magic happens here!
5. :arrow_heading_up: Push your new branch to your fork. (e.g. `git push username fix-issue-16`).
6. :inbox_tray: Open a Pull Request on github.com from your new branch on your fork to `master` in this
repository.
## Maintainers
There are more details about processes and workflow in the [Maintainer's Guide](./maintainers_guide.md).
-11
View File
@@ -1,11 +0,0 @@
version: 2
updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'monthly'
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: 'monthly'
-49
View File
@@ -1,49 +0,0 @@
### Description
Describe your issue here.
### What type of issue is this? (place an `x` in one of the `[ ]`)
- [ ] bug
- [ ] enhancement (feature request)
- [ ] question
- [ ] documentation related
- [ ] example code related
- [ ] testing related
- [ ] discussion
### Requirements (place an `x` in each of the `[ ]`)
* [ ] I've read and understood the [Contributing guidelines](https://github.com/slackapi/slack-github-action/blob/main/.github/contributing.md) and have done my best effort to follow them.
* [ ] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct).
* [ ] I've searched for any related issues and avoided creating a duplicate issue.
---
### Bug Report
Filling out the following details about bugs will help us solve your issue sooner.
#### Reproducible in:
package version:
node version:
OS version(s):
#### Steps to reproduce:
1.
2.
3.
#### Expected result:
What you expected to happen
#### Actual result:
What actually happened
#### Attachments:
Logs, screenshots, screencast, sample project, funny gif, etc.
-93
View File
@@ -1,93 +0,0 @@
# Maintainers Guide
This document describes tools, tasks and workflow that one needs to be familiar with in order to effectively maintain
this project. If you use this package within your own software as is but don't plan on modifying it, this guide is
**not** for you.
## Tools
All you need to work with this project is a supported version of [Node.js](https://nodejs.org/en/)
(see `package.json` field "engines") and npm (which is distributed with Node.js).
## Tasks
### Developing
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
When testing locally, ensure at least linting and unit tests pass by running `npm test`.
Additionally, sending a PR is highly recommended with every change as there are several GitHub
Actions jobs that execute what are effectively integration tests for this GitHub Action.
#### Checks on PRs
Actions that run the integration tests on PRs from a fork will require approval before running.
These checks use stored secrets so the changes should be reviewed before approving the workflow to
avoid accidently leaking tokens!
### Releasing
* Check the status of this project's GitHub Milestone to be released for issues that should be shipped with the release.
- If all issues have been closed, continue with the release.
- If issues are still open, discuss with the team about whether the open issues should be moved to a future release or if the release should be held off until the issues are resolved.
- Take a look at all issues under the Milestone to make sure that the type of issues included aligns with the Milestone name based on [semantic versioning](https://semver.org/). If the issues do not align with the naming of the Milestone (ex: if the issues are all bug fixes, but the Milestone is labeled as a minor release), then you can tweak the Milestone name to reflect the correct versioning.
* Update the version in `package.json`.
* Update all references to versions in the README and in the workflow files under `example-workflows/` to refer to the latest release.
* Run all tests using `npm test` to make sure the tests pass.
* Commit the changes on your `main` branch.
* Create a git tag for the new version. Should be in the format `v1.4.0`. `git tag v1.4.0`.
* Push changes up to GitHub `git push origin main --tags`.
* Create a GitHub Release based on the tag you just pushed up - this will trigger the publishing
GitHub workflow.
* Once released, make sure to close the relevant GitHub Milestone for the version you released.
## Workflow
### Versioning and Tags
This project is versioned using [Semantic Versioning](http://semver.org/), particularly in the
[npm flavor](https://docs.npmjs.com/getting-started/semantic-versioning). Each release is tagged
using git.
### Fork
As a maintainer, the development you do will be almost entirely off of your forked version of this repository. The exception to this rule pertains to multiple collaborators working on the same feature, which is detailed in the **Branches** section below.
### Branches
`main` is where active development occurs.
When developing, branches should be created off of your fork and not directly off of this repository. If working on a long-running feature and in collaboration with others, a corresponding branch of the same name is permitted. This makes collaboration on a single branch possible, as contributors working on the same feature cannot push commits to others' open Pull Requests.
After a major version increment, there also may be maintenance branches created specifically for supporting older major versions.
### Issue Management
Labels are used to run issues through an organized workflow. Here are the basic definitions:
* `bug`: A confirmed bug report. A bug is considered confirmed when reproduction steps have been
documented and the issue has been reproduced.
* `enhancement`: A feature request for something this package might not already do.
* `docs`: An issue that is purely about documentation work.
* `tests`: An issue that is purely about testing work.
* `needs feedback`: An issue that may have claimed to be a bug but was not reproducible, or was otherwise missing some information.
* `discussion`: An issue that is purely meant to hold a discussion. Typically the maintainers are looking for feedback in this issues.
* `question`: An issue that is like a support request because the user's usage was not correct.
* `semver:major|minor|patch`: Metadata about how resolving this issue would affect the version number.
* `security`: An issue that has special consideration for security reasons.
* `good first contribution`: An issue that has a well-defined relatively-small scope, with clear expectations. It helps when the testing approach is also known.
* `duplicate`: An issue that is functionally the same as another issue. Apply this only if you've linked the other issue by number.
**Triage** is the process of taking new issues that aren't yet "seen" and marking them with a basic
level of information with labels. An issue should have **one** of the following labels applied:
`bug`, `enhancement`, `question`, `needs feedback`, `docs`, `tests`, or `discussion`.
Issues are closed when a resolution has been reached. If for any reason a closed issue seems
relevant once again, reopening is great and better than creating a duplicate issue.
## Everything else
When in doubt, find the other maintainers and ask.
-8
View File
@@ -1,8 +0,0 @@
### Summary
Describe the goal of this PR. Mention any related Issue numbers.
### Requirements (place an `x` in each `[ ]`)
* [ ] I've read and understood the [Contributing Guidelines](https://github.com/slackapi/slack-github-action/blob/main/.github/contributing.md) and have done my best effort to follow them.
* [ ] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct).
-12
View File
@@ -1,12 +0,0 @@
{
"pull_request": {
"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
@@ -1,7 +0,0 @@
# 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
@@ -1,6 +0,0 @@
# Application values
.env.*
.env
!.env.example
.slack/apps.json
.slack/apps.*.json
-5
View File
@@ -1,5 +0,0 @@
{
"experiments": ["bolt"],
"project_id": "c4805b41-d1ce-4ea0-b297-ed2f8c64c267"
}
@@ -1,35 +0,0 @@
{
"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
@@ -1,71 +0,0 @@
{
"_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>!"
}
}
]
}
}
}
@@ -1,23 +0,0 @@
{
"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
@@ -1,141 +0,0 @@
# 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
-11
View File
@@ -1,11 +0,0 @@
{
"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
@@ -1,132 +0,0 @@
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."
-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 }}
-237
View File
@@ -1,237 +0,0 @@
name: Tests
on:
pull_request:
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: "build: setup the linter for formatting checks"
uses: biomejs/setup-biome@v2
with:
version: latest
- name: "unit(test): perform lints and formatting checks"
run: biome ci .
- 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
-11
View File
@@ -1,11 +0,0 @@
# Build artifacts
dist
# Development dependencies
node_modules
# Extra files
.DS_Store
# Testing remnants
coverage
-15
View File
@@ -1,15 +0,0 @@
{
"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
@@ -1 +0,0 @@
20
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2019 Slack Technologies, LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-526
View File
@@ -1,526 +0,0 @@
# 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)
## Example workflows
For examples on how to leverage this Action in workflows, check out
[example workflows we have][examples] available.
## 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 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 payload with optional [variables][variables] provided in the GitHub
Action step.
These input options are valid for all techniques, but some techniques require
specific constraints with certain requirements for valid inputs.
Additional [configurations](#additional-configurations) and other details are
also available for more customizations to the provided payload.
## Sending techniques
This Action offers three different techniques to send data to Slack:
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
> :memo: This technique requires [a Slack paid plan][plans] to use Workflow
> Builder.
This technique sends data to Slack using a webhook to start a workflow created
using Slack [Workflow Builder][wfb].
#### Setup
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 set the
input payload to send.
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
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 data to a Slack workflow
uses: slackapi/slack-github-action@v2-development
with:
payload-delimiter: "_"
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
```
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 event details to a Slack workflow
uses: slackapi/slack-github-action@v2-development
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
payload: |
status: "${{ job.status }}"
option: "false"
```
##### Gathering details of the payload from a saved file
Input values for the payload to be sent can also be provided in a file, either
in JSON or YAML format:
```yaml
- name: Send a saved artifact to a Slack workflow
uses: slackapi/slack-github-action@v2-development
with:
payload-file-path: "./artifacts.json"
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
```
### Technique 2: Slack API method
A bot token or user token or [token of some other kind][tokens] can be used to
call one of [the Slack API methods][methods] with this technique.
#### Setup
The exact Slack API [method][methods] used will require setting various sets of
[scopes][scopes], but setup should be similar for all methods:
1. [Create a Slack app][apps] for your workspace or use an existing app.
2. Select a [method][methods] to call and add the required **scopes** to your
app under the **OAuth & Permissions** page on app settings.
3. Install the app to your workspace using the **Install App** page.
4. Find the [token][tokens] minted for the Slack API method being called from
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 set the
input payload to send.
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
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
uses: slackapi/slack-github-action@v2-development
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
text: "howdy <@channel>!"
```
##### 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@v2-development
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
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"
```
##### Replying to a message
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
- name: Initiate a deployment
uses: slackapi/slack-github-action@v2-development
id: deployment_message
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
text: "Deployment started :eyes:"
- name: Conclude the deployment
uses: slackapi/slack-github-action@v2-development
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
thread_ts: "${{ steps.deployment_message.outputs.ts }}"
text: "Deployment finished! :rocket:"
```
##### Uploading a file
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
- name: Share a file to that channel
uses: slackapi/slack-github-action@v2-development
with:
method: files.uploadV2
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_CHANNEL_ID }}
initial_comment: "the results are in!"
file: "results.out"
filename: "results-${{ github.sha }}.out"
```
### Technique 3: Slack incoming webhook
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 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
Gather a Slack incoming webhook URL:
1. [Create a Slack app][apps] 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.
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 set the
input payload to send.
#### 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
```
### 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@v2-development
with:
payload-file-path: "./payload-slack-content.json"
payload-templated: true
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
```
This replaces variables templated as `${{ github.payload.repository.html_url }}`
with the values found in the GitHub Action event [payload][event-payload].
### 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
uses: slackapi/slack-github-action@v2-development
with:
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"
```
The `proxy` option can also be provided with the `HTTPS_PROXY` or `https_proxy`
[environment variable][github-environment] from within the GitHub Actions step.
### 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"
```
## 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 job 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 from the response.
- `ts`: `string`. The timestamp of the Slack event.
- `thread_ts`: `string`. The timestamp of a Slack message.
### 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
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
[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
[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
[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
[messaging-threads]: https://api.slack.com/messaging/sending#threading
[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
[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
-23
View File
@@ -1,23 +0,0 @@
{
"$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"
}
}
+55062
View File
File diff suppressed because one or more lines are too long
@@ -1,16 +0,0 @@
on: [push]
jobs:
new_push_job:
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@v2-development
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
payload: |
key: value
foo: bar
@@ -1,14 +0,0 @@
on: [push]
jobs:
new_push_job:
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@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
@@ -1,14 +0,0 @@
on: [push]
jobs:
new_push_job:
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@v2-development
with:
payload-delimiter: "_"
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: webhook-trigger
@@ -1,3 +0,0 @@
{
"text": "${{ github.workflow }} workflow failed on ${{ env.DEFAULT_BRANCH }} branch in the ${{ github.event.repository.name }} repository!"
}
@@ -1,32 +0,0 @@
on: [push]
jobs:
new_push_job:
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@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 }}"
@@ -1,16 +0,0 @@
on: [push]
jobs:
new_push_job:
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@v2-development
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
text: "posting from a github action"
channel: "C0123456789"
@@ -1,16 +0,0 @@
on: [push]
jobs:
new_push_job:
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@v2-development
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: "C0123456789"
text: "posting from a github action!"
@@ -1,34 +0,0 @@
on: [push]
jobs:
new_push_job:
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@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"
-24
View File
@@ -1,24 +0,0 @@
{
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"checkJs": true,
"esModuleInterop": true,
"module": "es2022",
"moduleResolution": "node",
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"exclude": [
"node_modules"
],
"include": [
"src/**/*.js"
],
"jsdoc": {
"out": "support/jsdoc",
"access": "public"
}
}
-2373
View File
File diff suppressed because it is too large Load Diff
-63
View File
@@ -1,63 +0,0 @@
{
"name": "slack-github-action",
"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": {
"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",
"url": "git+https://github.com/slackapi/slack-github-action.git"
},
"keywords": [
"slack",
"github",
"action",
"workflow"
],
"author": "Slack Technologies, LLC",
"license": "MIT",
"bugs": {
"url": "https://github.com/slackapi/slack-github-action/issues"
},
"engines": {
"node": ">=20.0.0",
"npm": ">=10.2.0"
},
"homepage": "https://github.com/slackapi/slack-github-action#readme",
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/github": "^6.0.0",
"@slack/web-api": "^7.5.0",
"axios": "^1.7.7",
"axios-retry": "^4.5.0",
"flat": "^5.0.2",
"https-proxy-agent": "^7.0.5",
"js-yaml": "^4.1.0",
"markup-js": "^1.5.21"
},
"devDependencies": {
"@biomejs/biome": "1.8.3",
"@types/chai": "^4.3.19",
"@types/flat": "^5.0.5",
"@types/js-yaml": "^4.0.9",
"@types/markup-js": "^1.5.0",
"@types/mocha": "^10.0.7",
"@types/node": "^20.14.10",
"@types/sinon": "^17.0.3",
"@vercel/ncc": "^0.38.1",
"c8": "^10.1.2",
"chai": "^5.1.1",
"mocha": "^10.7.3",
"mocha-suppress-logs": "^0.5.1",
"sinon": "^19.0.2",
"typescript": "^5.5.3"
}
}
-126
View File
@@ -1,126 +0,0 @@
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://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 Error(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 (err) {
config.core.warning(
"Failed to configure the HTTPS proxy agent so using default configurations.",
);
console.error(err);
}
return undefined;
}
/**
* Return configurations for retry options with different delays.
* @param {string} option
* @returns {import("@slack/web-api").RetryOptions}
*/
retries(option) {
switch (option) {
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;
}
}
}
-181
View File
@@ -1,181 +0,0 @@
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) {
case this.Retries.ZERO:
case this.Retries.FIVE:
case this.Retries.TEN:
case this.Retries.RAPID:
break;
default:
core.warning(
`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.",
);
}
}
}
-168
View File
@@ -1,168 +0,0 @@
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 found",
);
}
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 (error) {
if (error instanceof Error) {
config.core.error(error);
}
console.error(error);
throw new SlackError(
config.core,
"Invalid input! Failed to parse contents of the provided payload",
);
}
}
/**
* 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,
`Failed to parse file extension ${config.inputs.payloadFilePath}`,
);
} catch (error) {
if (error instanceof Error) {
config.core.error(error);
}
console.error(error);
throw new SlackError(
config.core,
"Invalid input! Failed to parse contents of the provided payload file",
);
}
}
/**
* 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);
}
}
-23
View File
@@ -1,23 +0,0 @@
import core from "@actions/core";
/**
* SlackError is a custom error wrapper for known errors of Slack GitHub Action.
*/
export default class SlackError extends Error {
/**
* @param {core} core - GitHub Actions core utilities.
* @param {any} error - The error message to throw.
* @param {boolean} fails - if the exit should be forced.
*/
constructor(core, error, fails = true) {
if (error instanceof Error) {
super(error.message);
} else {
super(error);
}
this.name = "SlackError";
if (fails) {
core.setFailed(error);
}
}
}
-17
View File
@@ -1,17 +0,0 @@
import core from "@actions/core";
import send from "./send.js";
/**
* 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} stack: ${error.stack}`);
} else {
core.error(`${error}`);
}
}
-38
View File
@@ -1,38 +0,0 @@
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 (error) {
config.core.setOutput("time", Math.floor(new Date().valueOf() / 1000));
if (config.inputs.errors) {
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");
}
}
-121
View File
@@ -1,121 +0,0 @@
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);
await config.axios
.post(config.inputs.webhook, config.content.values, {
...this.proxies(config),
})
.then((response) => {
config.core.setOutput("ok", response.status === 200);
config.core.setOutput("response", JSON.stringify(response.data));
config.core.debug(JSON.stringify(response.data));
})
.catch((error) => {
const response = error.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;
try {
if (!webhook) {
throw new SlackError(
config.core,
"No webhook was provided to proxy to",
);
}
if (!proxy) {
return undefined;
}
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,
};
}
} catch (err) {
config.core.warning(
"Failed to configure the HTTPS proxy agent so using default configurations.",
);
console.error(err);
}
return undefined;
}
/**
* Return configurations for retry options with different delays.
* @param {string} option
* @returns {import("axios-retry").IAxiosRetryConfig}
*/
retries(option) {
switch (option) {
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
};
}
}
}
-428
View File
@@ -1,428 +0,0 @@
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 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 {
assert.include(
mocks.core.setFailed.lastCall.firstArg,
"No API method was provided for use",
);
}
});
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 {
assert.include(
mocks.core.setFailed.lastCall.firstArg,
"No token was provided to post with",
);
}
});
});
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 {
assert.include(
mocks.core.warning.lastCall.firstArg,
"Failed to configure the HTTPS proxy agent so using default configurations.",
);
}
});
});
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);
});
});
});
-136
View File
@@ -1,136 +0,0 @@
import { assert } from "chai";
import Config from "../src/config.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) {
assert.include(
mocks.core.setFailed.lastCall.firstArg,
"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,
);
}
});
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) {
assert.include(
mocks.core.setFailed.lastCall.firstArg,
"Missing input! Either a token or webhook is required to take action.",
);
}
});
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) {
assert.include(
mocks.core.setFailed.lastCall.firstArg,
"Missing input! The webhook type must be 'incoming-webhook' or 'webhook-trigger'.",
);
}
});
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) {
assert.include(
mocks.core.setFailed.lastCall.firstArg,
"Invalid input! The webhook type must be 'incoming-webhook' or 'webhook-trigger'.",
);
}
});
});
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("warns 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");
await send(mocks.core);
assert.isTrue(
mocks.core.warning.calledWith(
'Invalid input! An unknown "retries" value was used: FOREVER',
),
);
});
});
});
-318
View File
@@ -1,318 +0,0 @@
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 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 {
assert.include(
mocks.core.setFailed.lastCall.firstArg,
"Invalid input! Just the payload or payload file path is required.",
);
}
});
});
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) {
assert.include(
mocks.core.setFailed.lastCall.firstArg,
"Invalid input! No payload content found",
);
}
});
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 {
assert.include(
mocks.core.setFailed.lastCall.firstArg.toString(),
"Invalid input! Failed to parse contents of the provided payload",
);
}
});
});
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) {
assert.include(
mocks.core.setFailed.lastCall.firstArg,
"Invalid input! No payload found for content",
);
}
});
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) {
assert.include(
mocks.core.setFailed.lastCall.firstArg.toString(),
"Invalid input! Failed to parse contents of the provided payload file",
);
}
});
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 nonexistent files");
} catch (err) {
assert.include(
mocks.core.setFailed.lastCall.firstArg.toString(),
"Invalid input! Failed to parse contents of the provided payload file",
);
}
});
});
});
-67
View File
@@ -1,67 +0,0 @@
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();
-77
View File
@@ -1,77 +0,0 @@
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);
});
});
});
-290
View File
@@ -1,290 +0,0 @@
import core from "@actions/core";
import { AxiosError } from "axios";
import { assert } from "chai";
import Config from "../src/config.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 {
assert.include(
mocks.core.setFailed.lastCall.firstArg,
"No webhook was provided to post to",
);
}
});
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));
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, false);
assert.equal(mocks.core.setOutput.getCall(1).firstArg, "response");
assert.equal(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify("Request failed with status code 400"),
);
});
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));
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, { 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");
assert.equal(
mocks.core.setOutput.getCall(1).lastArg,
JSON.stringify("Request failed with status code 400"),
);
});
});
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 {
assert.include(
mocks.core.setFailed.lastCall.firstArg,
"No webhook was provided to proxy to",
);
}
});
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 {
assert.include(
mocks.core.warning.lastCall.firstArg,
"Failed to configure the HTTPS proxy agent so using default configurations.",
);
}
});
});
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",
);
});
});
});