Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 51 additions & 26 deletions aikido-scan/dist/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,51 @@ function mask(value) {
var ok = (value) => ({ ok: true, value });
var err = (error) => ({ ok: false, error });

// lib/slack.ts
var POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage";
function parseTarget(botToken, incomingWebhookUrl, channel) {
if (botToken === "" && incomingWebhookUrl === "") {
return err("Either a bot token or an incoming webhook URL needs to be supplied");
}
if (botToken !== "" && incomingWebhookUrl !== "") {
return err("Can't use both a bot token and an incoming webhook URL");
}
if (botToken !== "" && channel === "") {
return err("A channel needs to be supplied if using a bot token");
}
return botToken !== "" ? ok({ kind: "bot", token: botToken }) : ok({ kind: "webhook", url: incomingWebhookUrl });
}
var buildBody = (payload, channel) => JSON.stringify({ ...payload, ...channel === "" ? {} : { channel } });
var endpoint = (target) => target.kind === "webhook" ? target.url : POST_MESSAGE_URL;
var headers = (target) => ({
...target.kind === "bot" ? { Authorization: `Bearer ${target.token}` } : {},
"Content-Type": "application/json; charset=utf-8"
});
async function post(target, body) {
let response;
try {
response = await fetch(endpoint(target), {
method: "POST",
body,
headers: headers(target)
});
} catch (cause) {
return err(`Failed to reach Slack: ${cause instanceof Error ? cause.message : String(cause)}`);
}
if (!response.ok) {
return err(`Request failed with status ${response.status} ${response.statusText}`);
}
if (target.kind === "webhook")
return ok(undefined);
let result;
try {
result = await response.json();
} catch (cause) {
return err(`Slack returned an unreadable response: ${cause instanceof Error ? cause.message : String(cause)}`);
}
return result.ok ? ok(undefined) : err(`Request failed with error ${result.error ?? "unknown"}`);
}

// aikido-scan/src/scan.ts
var CONTEXT_NAMES = [
"server-url",
Expand Down Expand Up @@ -147,7 +192,6 @@ function buildSlackPayload(repository, commitSha, findings, context) {

// aikido-scan/src/main.ts
var CLIENT_COMMAND = "aikido-api-client";
var SLACK_POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage";
var INPUT_NAMES = [
"apikey",
"repository",
Expand Down Expand Up @@ -196,31 +240,12 @@ function runScan(args) {
});
}
async function postToSlack(payload, inputs) {
let response;
try {
response = await fetch(SLACK_POST_MESSAGE_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${inputs.botToken}`,
"Content-Type": "application/json; charset=utf-8"
},
body: JSON.stringify({ ...payload, channel: inputs.channel })
});
} catch (cause) {
fail(`Failed to post message to Slack: ${cause instanceof Error ? cause.message : String(cause)}`);
}
if (!response.ok) {
fail(`Failed to post message to Slack: ${response.status} ${response.statusText}`);
}
let body;
try {
body = await response.json();
} catch (cause) {
fail(`Slack returned an unreadable response: ${cause instanceof Error ? cause.message : String(cause)}`);
}
if (!body.ok) {
fail(`Slack responded with an error: ${body.error ?? "unknown"}`);
}
const target = parseTarget(inputs.botToken, "", inputs.channel);
if (!target.ok)
fail(target.error);
const sent = await post(target.value, buildBody(payload, inputs.channel));
if (!sent.ok)
fail(sent.error);
}
var argv = inActions ? undefined : valuesFromArgv();
var raw = argv ?? rawFromEnvironment();
Expand Down
38 changes: 6 additions & 32 deletions aikido-scan/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
runningInActions,
writeOutputs,
} from "../../lib/actions.ts"
import { buildBody, parseTarget, post } from "../../lib/slack.ts"
import {
CONTEXT_NAMES,
type Inputs,
Expand All @@ -24,7 +25,6 @@ import {
import { type SlackPayload, buildSlackPayload } from "./slack.ts"

const CLIENT_COMMAND = "aikido-api-client"
const SLACK_POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage"

const INPUT_NAMES = [
"apikey",
Expand Down Expand Up @@ -95,37 +95,11 @@ async function postToSlack(
payload: SlackPayload,
inputs: Inputs,
): Promise<void> {
let response: Response
try {
response = await fetch(SLACK_POST_MESSAGE_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${inputs.botToken}`,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({ ...payload, channel: inputs.channel }),
})
} catch (cause) {
fail(
`Failed to post message to Slack: ${cause instanceof Error ? cause.message : String(cause)}`,
)
}
if (!response.ok) {
fail(
`Failed to post message to Slack: ${response.status} ${response.statusText}`,
)
}
let body: { ok?: boolean; error?: string }
try {
body = (await response.json()) as { ok?: boolean; error?: string }
} catch (cause) {
fail(
`Slack returned an unreadable response: ${cause instanceof Error ? cause.message : String(cause)}`,
)
}
if (!body.ok) {
fail(`Slack responded with an error: ${body.error ?? "unknown"}`)
}
const target = parseTarget(inputs.botToken, "", inputs.channel)
if (!target.ok) fail(target.error)

const sent = await post(target.value, buildBody(payload, inputs.channel))
if (!sent.ok) fail(sent.error)
}

const argv = inActions ? undefined : valuesFromArgv()
Expand Down
82 changes: 82 additions & 0 deletions lib/slack.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test"

import { buildBody, parseTarget } from "./slack.ts"

const error = (result: ReturnType<typeof parseTarget>): string => {
if (result.ok) throw new Error("expected an error, got a target")
return result.error
}

describe("choosing how to reach Slack", () => {
test("uses the app when a bot token and a channel are given", () => {
expect(parseTarget("xoxb-1", "", "#ops")).toEqual({
ok: true,
value: { kind: "bot", token: "xoxb-1" },
})
})

test("uses the webhook, which carries its own destination", () => {
expect(parseTarget("", "https://hooks.slack.test/1", "")).toEqual({
ok: true,
value: { kind: "webhook", url: "https://hooks.slack.test/1" },
})
})

test("refuses when neither is supplied", () => {
expect(error(parseTarget("", "", ""))).toBe(
"Either a bot token or an incoming webhook URL needs to be supplied",
)
})

test("refuses when both are supplied, rather than picking one", () => {
expect(error(parseTarget("xoxb-1", "https://hooks.slack.test/1", "#ops"))).toBe(
"Can't use both a bot token and an incoming webhook URL",
)
})

test("refuses a bot token with nowhere to post", () => {
expect(error(parseTarget("xoxb-1", "", ""))).toBe(
"A channel needs to be supplied if using a bot token",
)
})
})

describe("the request body", () => {
test("carries the caller's payload unchanged", () => {
expect(buildBody({ text: "hello" }, "")).toBe('{"text":"hello"}')
})

test("adds the channel when one is given", () => {
expect(JSON.parse(buildBody({ text: "hello" }, "#ops"))).toEqual({
text: "hello",
channel: "#ops",
})
})

test("adds the channel even for a webhook, as the shell version did", () => {
expect(JSON.parse(buildBody({ text: "x" }, "#ops")).channel).toBe("#ops")
})

test("lets the payload's own channel be overridden by the input", () => {
expect(JSON.parse(buildBody({ channel: "#from-payload" }, "#from-input")).channel).toBe(
"#from-input",
)
})
})

describe("a refusal from Slack", () => {
test("names the error Slack gave, or says so when it gave none", async () => {
const { post } = await import("./slack.ts")
const original = globalThis.fetch
globalThis.fetch = (async () =>
new Response(JSON.stringify({ ok: false }), {
status: 200,
})) as unknown as typeof fetch
try {
const result = await post({ kind: "bot", token: "t" }, "{}")
expect(result).toEqual({ ok: false, error: "Request failed with error unknown" })
} finally {
globalThis.fetch = original
}
})
})
84 changes: 84 additions & 0 deletions lib/slack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { type Result, err, ok } from "./result.ts"

const POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage"

/**
* Slack is reached either as an app, which needs a channel to post into, or
* through an incoming webhook, which carries its own destination.
*/
export type SlackTarget =
| { readonly kind: "bot"; readonly token: string }
| { readonly kind: "webhook"; readonly url: string }

export function parseTarget(
botToken: string,
incomingWebhookUrl: string,
channel: string,
): Result<SlackTarget> {
if (botToken === "" && incomingWebhookUrl === "") {
return err("Either a bot token or an incoming webhook URL needs to be supplied")
}
if (botToken !== "" && incomingWebhookUrl !== "") {
return err("Can't use both a bot token and an incoming webhook URL")
}
if (botToken !== "" && channel === "") {
return err("A channel needs to be supplied if using a bot token")
}
return botToken !== ""
? ok({ kind: "bot", token: botToken })
: ok({ kind: "webhook", url: incomingWebhookUrl })
}

/** The channel rides in the payload, and is left out when none was given. */
export const buildBody = (payload: object, channel: string): string =>
JSON.stringify({ ...payload, ...(channel === "" ? {} : { channel }) })

const endpoint = (target: SlackTarget): string =>
target.kind === "webhook" ? target.url : POST_MESSAGE_URL

const headers = (target: SlackTarget): Record<string, string> => ({
...(target.kind === "bot" ? { Authorization: `Bearer ${target.token}` } : {}),
"Content-Type": "application/json; charset=utf-8",
})

/**
* Posts an already-serialised body, reporting why Slack refused it.
*
* An incoming webhook answers with a plain-text body, so only the Web API
* response is read for the `ok` flag that carries its errors.
* See https://api.slack.com/messaging/webhooks#handling_errors
*/
export async function post(
target: SlackTarget,
body: string,
): Promise<Result<void>> {
let response: Response
try {
response = await fetch(endpoint(target), {
method: "POST",
body,
headers: headers(target),
})
} catch (cause) {
return err(
`Failed to reach Slack: ${cause instanceof Error ? cause.message : String(cause)}`,
)
}

if (!response.ok) {
return err(`Request failed with status ${response.status} ${response.statusText}`)
}
if (target.kind === "webhook") return ok(undefined)

let result: { ok?: boolean; error?: string }
try {
result = (await response.json()) as { ok?: boolean; error?: string }
} catch (cause) {
return err(
`Slack returned an unreadable response: ${cause instanceof Error ? cause.message : String(cause)}`,
)
}
return result.ok
? ok(undefined)
: err(`Request failed with error ${result.error ?? "unknown"}`)
}
63 changes: 0 additions & 63 deletions slack-notify/action.mjs

This file was deleted.

2 changes: 1 addition & 1 deletion slack-notify/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,4 @@ outputs:
description: "The JSON payload that was sent to Slack"
runs:
using: "node24"
main: action.mjs
main: "dist/index.mjs"
Loading
Loading