[ENG-1342] Prompt for tagline and category when creating release (#1664)

* modal + prompt for tagline and category + refactor

* move createSlashCommand to utils
This commit is contained in:
Brendan Allan 2023-10-23 22:19:34 +08:00 committed by GitHub
parent f1bb69324f
commit d12ee7e678
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 403 additions and 216 deletions

View file

@ -0,0 +1,33 @@
import { env } from '~/env';
export async function isValidSlackRequest(
headers: Headers,
body: string
): Promise<{ valid: true } | { valid: false; error: string }> {
const signature = headers.get('x-slack-signature');
if (!signature) return { valid: false, error: 'No signature' };
const timestamp = headers.get('x-slack-request-timestamp');
if (!timestamp) return { valid: false, error: 'No timestamp' };
// todo: prevent replay attack
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(env.SLACK_SIGNING_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const valid = await crypto.subtle.verify(
'HMAC',
key,
Buffer.from(signature.substring(3), 'hex'),
new TextEncoder().encode(`v0:${timestamp}:${body}`)
);
if (!valid) return { valid: false, error: 'Invalid signature' };
return { valid: true };
}

View file

@ -0,0 +1,276 @@
import { z } from 'zod';
import { env } from '~/env';
import * as github from './github';
import { createSlashCommand, createViewSubmission, USER_REF } from './utils';
export const callbackId = 'createReleaseModal' as const;
export const fields = {
category: {
blockId: 'category',
actionId: 'value'
},
tagline: {
blockId: 'tagline',
actionId: 'value'
}
} as const;
export const COMMAND_NAME = '/release' as const;
export const EVENT_SCHEMAS = [createSlashCommand(COMMAND_NAME), createViewSubmission()] as const;
export async function createModal(
trigger_id: string,
tag: string,
commit: string,
commitMessage: string,
responseUrl: string
) {
return await fetch(`https://slack.com/api/views.open`, {
method: 'POST',
body: JSON.stringify({
trigger_id,
view: {
type: 'modal',
callback_id: callbackId,
private_metadata: JSON.stringify({
tag,
commit,
responseUrl
}),
title: {
type: 'plain_text',
text: `Release ${tag}`
},
submit: {
type: 'plain_text',
text: 'Create'
},
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: 'Commit'
}
},
{
type: 'section',
accessory: {
type: 'button',
text: {
type: 'plain_text',
text: 'View Commit'
},
url: `${github.REPO_API}/commit/${commit}`
},
text: {
type: 'mrkdwn',
text: `> ${commitMessage}`
}
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `${commit}`
}
]
},
{
type: 'header',
text: {
type: 'plain_text',
text: 'Make Sure You Have'
}
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: ['- Bumped versions of `sd-core` and `sd-desktop`'].join('\n')
}
},
{
type: 'header',
text: {
type: 'plain_text',
text: 'Frontmatter'
}
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `These values can be edited later in the release`
}
]
},
{
type: 'input',
block_id: fields.category.blockId,
element: {
type: 'static_select',
action_id: fields.category.actionId,
placeholder: {
type: 'plain_text',
text: 'Category'
},
options: [
{
text: {
type: 'plain_text',
text: 'Alpha'
},
value: 'alpha'
}
]
},
label: {
type: 'plain_text',
text: 'Category'
}
},
{
type: 'input',
block_id: fields.tagline.blockId,
element: {
type: 'plain_text_input',
action_id: fields.tagline.actionId,
placeholder: {
type: 'plain_text',
text: 'Features A, B, and C await you!'
}
},
label: {
type: 'plain_text',
text: 'Tagline'
}
},
{
type: 'context',
elements: [
{
type: 'plain_text',
text: `Show in the 'Update Available' toast`
}
]
}
]
}
}),
headers: {
'Authorization': `Bearer ${env.SLACK_BOT_TOKEN}`,
'Content-Type': 'application/json'
}
});
}
export async function handleSubmission(
values: Record<string, Record<string, any>>,
user: z.infer<typeof USER_REF>,
privateMetadata: string
) {
console.log(values);
const category =
values[fields.category.blockId][fields.category.actionId].selected_option.value;
const tagline = values[fields.tagline.blockId][fields.tagline.actionId].value;
const { tag, commit, responseUrl } = JSON.parse(privateMetadata);
await fetch(`${github.REPO_API}/git/refs`, {
method: 'POST',
body: JSON.stringify({
ref: `refs/tags/${tag}`,
sha: commit
}),
headers: github.HEADERS
}).then((r) => r.json());
const createRelease = fetch(`${github.REPO_API}/releases`, {
method: 'POST',
body: JSON.stringify({
tag_name: tag,
name: tag,
target_commitish: commit,
draft: true,
generate_release_notes: true,
body: [
'<!-- frontmatter',
'---',
`category: ${category}`,
`tagline: ${tagline}`,
'---',
'-->'
].join('\n')
}),
headers: github.HEADERS
}).then((r) => r.json());
const dispatchWorkflowRun = fetch(
`${github.REPO_API}/actions/workflows/release.yml/dispatches`,
{
method: 'POST',
body: JSON.stringify({ ref: tag }),
headers: github.HEADERS
}
);
const [release] = await Promise.all([createRelease, dispatchWorkflowRun]);
await fetch(responseUrl, {
method: 'POST',
body: JSON.stringify({
replace_original: 'true',
blocks: [
{
type: 'section',
block_id: '0',
text: {
type: 'mrkdwn',
text: [
`*Release \`${tag}\` created!*`,
`Go give it some release notes`,
`*Created By* <@${user.id}>`
].join('\n')
}
},
{
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'View Release'
},
url: release.html_url
},
{
type: 'button',
text: {
type: 'plain_text',
text: 'View Workflow Runs'
},
url: `https://github.com/${env.GITHUB_ORG}/${env.GITHUB_REPO}/actions/workflows/release.yml`
},
{
type: 'button',
text: {
type: 'plain_text',
text: 'View Commit'
},
url: `https://github.com/${env.GITHUB_ORG}/${env.GITHUB_REPO}/commit/${commit}`
}
]
}
]
}),
headers: {
'Content-Type': 'application/json'
}
});
}

View file

@ -0,0 +1,9 @@
import { env } from '~/env';
export const API = `https://api.github.com`;
export const REPO_API = `${API}/repos/${env.GITHUB_ORG}/${env.GITHUB_REPO}`;
export const HEADERS = {
'Authorization': `Bearer ${env.GITHUB_PAT}`,
'Accept': 'application/vnd.github+json',
'Content-Type': 'application/json'
};

View file

@ -1,83 +1,13 @@
import { z } from 'zod';
import { env } from '~/env';
import { isValidSlackRequest } from './auth';
import * as createRelease from './createRelease';
import * as github from './github';
export const runtime = 'edge';
async function isValidSlackRequest(
headers: Headers,
body: string
): Promise<{ valid: true } | { valid: false; error: string }> {
const signature = headers.get('x-slack-signature');
if (!signature) return { valid: false, error: 'No signature' };
const timestamp = headers.get('x-slack-request-timestamp');
if (!timestamp) return { valid: false, error: 'No timestamp' };
// todo: prevent replay attack
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(env.SLACK_SIGNING_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const valid = await crypto.subtle.verify(
'HMAC',
key,
Buffer.from(signature.substring(3), 'hex'),
new TextEncoder().encode(`v0:${timestamp}:${body}`)
);
if (!valid) return { valid: false, error: 'Invalid signature' };
return { valid: true };
}
const BODY = z.union([
// https://api.slack.com/interactivity/slash-commands#app_command_handling
z.discriminatedUnion('command', [
z.object({
command: z.literal('/release'),
channel_id: z.string(),
text: z.string().transform((s) => s.split(' ')),
user_id: z.string()
})
]),
// https://api.slack.com/reference/interaction-payloads/block-actions
z.object({
payload: z
.string()
.transform((v) => JSON.parse(v))
.pipe(
z.object({
type: z.literal('block_actions'),
actions: z.tuple([
z.object({
action_id: z.literal('createRelease'),
value: z
.string()
.transform((v) => JSON.parse(v))
.pipe(z.object({ tag: z.string(), commit: z.string() }))
})
]),
user: z.object({
id: z.string()
}),
response_url: z.string()
})
)
})
]);
const GITHUB_API = `https://api.github.com`;
const GITHUB_REPO_API = `${GITHUB_API}/repos/${env.GITHUB_ORG}/${env.GITHUB_REPO}`;
const GITHUB_HEADERS = {
'Authorization': `Bearer ${env.GITHUB_PAT}`,
'Accept': 'application/vnd.github+json',
'Content-Type': 'application/json'
};
const BODY = z.union([...createRelease.EVENT_SCHEMAS]);
export async function POST(req: Request) {
const body = await req.text();
@ -92,9 +22,9 @@ export async function POST(req: Request) {
}
if ('command' in parsedBody.data) {
const { command, text, channel_id, user_id } = parsedBody.data;
const { command, text, channel_id, user_id, trigger_id, response_url } = parsedBody.data;
switch (command) {
case '/release': {
case createRelease.COMMAND_NAME: {
if (channel_id !== env.SLACK_RELEASES_CHANNEL) {
return Response.json({
response_type: 'ephemeral',
@ -104,8 +34,8 @@ export async function POST(req: Request) {
const [tag, commitSha] = text;
const existingBranch = await fetch(`${GITHUB_REPO_API}/branches/${tag}`, {
headers: GITHUB_HEADERS
const existingBranch = await fetch(`${github.REPO_API}/branches/${tag}`, {
headers: github.HEADERS
});
if (existingBranch.status !== 404)
@ -123,153 +53,38 @@ export async function POST(req: Request) {
});
const commitData = await fetch(
`${GITHUB_REPO_API}/commits/${commitSha ?? 'heads/main'}`,
{ headers: GITHUB_HEADERS }
`${github.REPO_API}/commits/${commitSha ?? 'heads/main'}`,
{ headers: github.HEADERS }
).then((r) => r.json());
return Response.json({
response_type: 'in_channel',
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: [
`<@${user_id}> Are you sure you want to create this release?`,
`*Make sure you've bumped the versions of sd-core and sd-desktop*`,
`*Version:* \`${tag}\``,
`*Commit:* \`${commitData.sha}\``,
`> ${commitData.commit.message.split('\n')[0]}`
].join('\n')
}
},
{
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'View Commit'
},
url: `https://github.com/${env.GITHUB_ORG}/${env.GITHUB_REPO}/commit/${commitData.sha}`
},
{
type: 'button',
text: {
type: 'plain_text',
text: 'Create Release'
},
value: JSON.stringify({ tag, commit: commitData.sha }),
action_id: 'createRelease'
}
]
}
]
});
await createRelease.createModal(
trigger_id,
tag,
commitData.sha,
commitData.commit.message,
response_url
);
break;
}
}
} else if (parsedBody.data.payload) {
const { payload } = parsedBody.data;
switch (payload.type) {
case 'block_actions': {
const action = payload.actions[0];
switch (action.action_id) {
case 'createRelease': {
const { value } = action;
await fetch(`${GITHUB_REPO_API}/git/refs`, {
method: 'POST',
body: JSON.stringify({
ref: `refs/tags/${value.tag}`,
sha: value.commit
}),
headers: GITHUB_HEADERS
}).then((r) => r.json());
const createRelease = fetch(`${GITHUB_REPO_API}/releases`, {
method: 'POST',
body: JSON.stringify({
tag_name: value.tag,
name: value.tag,
target_commitish: value.commit,
draft: true,
generate_release_notes: true
}),
headers: GITHUB_HEADERS
}).then((r) => r.json());
const dispatchWorkflowRun = fetch(
`${GITHUB_REPO_API}/actions/workflows/release.yml/dispatches`,
{
method: 'POST',
body: JSON.stringify({
ref: value.tag
}),
headers: GITHUB_HEADERS
}
case 'view_submission': {
switch (payload.view.callback_id) {
case createRelease.callbackId: {
await createRelease.handleSubmission(
payload.view.state.values,
payload.user,
payload.view.private_metadata!
);
const [release] = await Promise.all([createRelease, dispatchWorkflowRun]);
await fetch(payload.response_url, {
method: 'POST',
body: JSON.stringify({
replace_original: 'true',
blocks: [
{
type: 'section',
block_id: '0',
text: {
type: 'mrkdwn',
text: [
`*Release \`${value.tag}\` created!*`,
`Go give it some release notes`,
`*Created By* <@${payload.user.id}>`
].join('\n')
}
},
{
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'View Release'
},
url: release.html_url
},
{
type: 'button',
text: {
type: 'plain_text',
text: 'View Workflow Runs'
},
url: `https://github.com/${env.GITHUB_ORG}/${env.GITHUB_REPO}/actions/workflows/release.yml`
},
{
type: 'button',
text: {
type: 'plain_text',
text: 'View Commit'
},
url: `https://github.com/${env.GITHUB_ORG}/${env.GITHUB_REPO}/commit/${value.commit}`
}
]
}
]
}),
headers: {
'Content-Type': 'application/json'
}
});
return new Response();
}
}
}
}
}
return new Response('');
}

View file

@ -0,0 +1,52 @@
import { z } from 'zod';
export const USER_REF = z.object({
id: z.string(),
name: z.string()
});
function createInteraction<T extends string, TInner extends z.ZodObject<any>>(
type: T,
inner: TInner
) {
return z.object({
payload: z
.string()
.transform((v) => JSON.parse(v))
.pipe(
z
.object({
type: z.literal(type),
user: USER_REF
})
.merge(inner)
)
});
}
const VIEW_SUBMISSION_INNER = z.object({
view: z.object({
id: z.string(),
type: z.literal('modal'),
callback_id: z.string(),
state: z.object({
values: z.record(z.record(z.any()))
}),
private_metadata: z.string().optional()
})
});
export function createViewSubmission() {
return createInteraction('view_submission', VIEW_SUBMISSION_INNER);
}
export function createSlashCommand<T extends string>(command: T) {
return z.object({
command: z.literal(command),
channel_id: z.string(),
text: z.string().transform((s) => s.split(' ')),
user_id: z.string(),
trigger_id: z.string(),
response_url: z.string()
});
}

View file

@ -16,7 +16,8 @@ export const env = createEnv({
GITHUB_ORG: z.string().default('spacedriveapp'),
GITHUB_REPO: z.string().default('spacedrive'),
SLACK_SIGNING_SECRET: z.string(),
SLACK_RELEASES_CHANNEL: z.string()
SLACK_RELEASES_CHANNEL: z.string(),
SLACK_BOT_TOKEN: z.string()
},
client: {},
runtimeEnv: {
@ -33,7 +34,8 @@ export const env = createEnv({
GITHUB_ORG: process.env.GITHUB_ORG,
GITHUB_REPO: process.env.GITHUB_REPO,
SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET,
SLACK_RELEASES_CHANNEL: process.env.SLACK_RELEASES_CHANNEL
SLACK_RELEASES_CHANNEL: process.env.SLACK_RELEASES_CHANNEL,
SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN
},
// In dev or in eslint disable checking.
// Kinda sucks for in dev but you don't need the whole setup to change the docs.