mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-14 04:14:04 +00:00
[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:
parent
f1bb69324f
commit
d12ee7e678
33
apps/landing/src/app/api/slack/webhook/auth.ts
Normal file
33
apps/landing/src/app/api/slack/webhook/auth.ts
Normal 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 };
|
||||
}
|
276
apps/landing/src/app/api/slack/webhook/createRelease.ts
Normal file
276
apps/landing/src/app/api/slack/webhook/createRelease.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
}
|
9
apps/landing/src/app/api/slack/webhook/github.ts
Normal file
9
apps/landing/src/app/api/slack/webhook/github.ts
Normal 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'
|
||||
};
|
|
@ -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('');
|
||||
}
|
||||
|
|
52
apps/landing/src/app/api/slack/webhook/utils.ts
Normal file
52
apps/landing/src/app/api/slack/webhook/utils.ts
Normal 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()
|
||||
});
|
||||
}
|
|
@ -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.
|
||||
|
|
Loading…
Reference in a new issue