Skip to content

Commit 53ca02b

Browse files
committed
feat: user feedback
1 parent 774b152 commit 53ca02b

File tree

4 files changed

+235
-50
lines changed

4 files changed

+235
-50
lines changed

src/app.ts

Lines changed: 215 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import type { Octokit } from 'octokit';
22
import { App } from 'octokit';
3-
import { logger, type Env, type IssueCommentCreatedData, type IssueCommentEditedData } from './util.js';
3+
import {
4+
logger,
5+
type Env,
6+
type IssueCommentCreatedData,
7+
type IssueCommentEditedData,
8+
type PackInfo,
9+
type WorkflowRunCompletedData,
10+
} from './util.js';
411

512
async function isPRGreen(octokit: Octokit, owner: string, repo: string, pullNumber: number): Promise<boolean> {
613
// First, get the PR data to get the head SHA
@@ -59,67 +66,230 @@ async function isPRGreen(octokit: Octokit, owner: string, repo: string, pullNumb
5966
return isGreen;
6067
}
6168

62-
export function getApp(env: Env) {
63-
const app = new App({
64-
appId: env.APP_ID,
65-
privateKey: env.PRIVATE_KEY.replaceAll('\\n', '\n'),
66-
webhooks: {
67-
secret: env.WEBHOOK_SECRET,
69+
async function commentHandler(data: IssueCommentCreatedData | IssueCommentEditedData, env: Env) {
70+
logger.debug('in comment handler');
71+
72+
if (!data.payload.comment.body.startsWith('@discord-js-bot pack this')) {
73+
logger.debug({ body: data.payload.comment.body }, 'Comment does not start with "@discord-js-bot pack this"');
74+
return;
75+
}
76+
77+
if (!data.payload.comment.user) {
78+
logger.debug('Comment does not have a user associated with it');
79+
return;
80+
}
81+
82+
if (!data.payload.issue.pull_request || data.payload.issue.state !== 'open') {
83+
logger.debug('Comment is not on a pull request or pull request is not open');
84+
return;
85+
}
86+
87+
const {
88+
data: { permission },
89+
} = await data.octokit.rest.repos.getCollaboratorPermissionLevel({
90+
owner: data.payload.repository.owner.login,
91+
repo: data.payload.repository.name,
92+
username: data.payload.comment.user.login,
93+
});
94+
95+
if (permission !== 'admin' && permission !== 'write') {
96+
logger.debug(`User ${data.payload.comment.user.login} does not have sufficient permissions to pack.`);
97+
return;
98+
}
99+
100+
if (
101+
!(await isPRGreen(
102+
data.octokit,
103+
data.payload.repository.owner.login,
104+
data.payload.repository.name,
105+
data.payload.issue.number,
106+
))
107+
) {
108+
return;
109+
}
110+
111+
logger.debug('Beginning the pack process...');
112+
113+
// Don't await, we don't mind if reactions fail/even go through, just get started on the workflow
114+
void data.octokit.rest.reactions
115+
.createForIssueComment({
116+
owner: data.payload.repository.owner.login,
117+
repo: data.payload.repository.name,
118+
comment_id: data.payload.comment.id,
119+
content: 'eyes',
120+
})
121+
// eslint-disable-next-line promise/prefer-await-to-then
122+
.catch((error) => logger.warn({ err: error }, 'Failed to update reactions on start'));
123+
124+
const ref = `refs/pull/${data.payload.issue.number}/head`;
125+
const tag = `pr-${data.payload.issue.number}`;
126+
logger.info(
127+
{
128+
ref,
129+
tag,
130+
pr: data.payload.issue.number,
131+
},
132+
'Triggering release workflow',
133+
);
134+
135+
// Trigger the publish-dev workflow
136+
await data.octokit.rest.actions.createWorkflowDispatch({
137+
owner: 'discordjs',
138+
repo: 'discord.js',
139+
workflow_id: 'publish-dev.yml',
140+
ref: 'main',
141+
inputs: {
142+
ref,
143+
tag,
144+
dry_run: 'false',
68145
},
69146
});
70147

71-
// eslint-disable-next-line promise/prefer-await-to-callbacks
72-
app.webhooks.onError((err) => {
73-
logger.error({ err }, 'Webhook error');
148+
// Absurdly, the above API call returns 204 No Content and provides no real output, so we have to resort to this
149+
// eslint-disable-next-line no-promise-executor-return
150+
await new Promise((resolve) => setTimeout(resolve, 5_000));
151+
152+
// Get the workflow runs to find the one we just triggered
153+
const { data: workflowRuns } = await data.octokit.rest.actions.listWorkflowRuns({
154+
owner: 'discordjs',
155+
repo: 'discord.js',
156+
workflow_id: 'publish-dev.yml',
157+
per_page: 5,
74158
});
75159

76-
const commentHandler = async (data: IssueCommentCreatedData | IssueCommentEditedData) => {
77-
logger.debug('in comment handler');
160+
// Find the most recent workflow run that matches our tag
161+
const workflowRun = workflowRuns.workflow_runs.find(
162+
(run) => run.event === 'workflow_dispatch' && run.status !== 'completed',
163+
);
78164

79-
if (!data.payload.comment.body.startsWith('@discord-js-bot pack this')) {
80-
logger.debug({ body: data.payload.comment.body }, 'Comment does not start with "@discord-js-bot pack this"');
81-
return;
82-
}
165+
if (!workflowRun) {
166+
logger.warn('Could not find the workflow run that was just triggered');
167+
return;
168+
}
83169

84-
if (!data.payload.comment.user) {
85-
logger.debug('Comment does not have a user associated with it');
86-
return;
87-
}
170+
// Store pack info with workflow run ID as the key
171+
const packInfo: PackInfo = {
172+
prNumber: data.payload.issue.number,
173+
commentId: data.payload.comment.id,
174+
tag,
175+
workflowRunId: workflowRun.id,
176+
};
177+
await env.PACK_STORAGE.put(`pack-${workflowRun.id}`, JSON.stringify(packInfo), { expirationTtl: 3_600 });
88178

89-
if (!data.payload.issue.pull_request || data.payload.issue.state !== 'open') {
90-
logger.debug('Comment is not on a pull request or pull request is not open');
91-
return;
92-
}
179+
logger.info('Release workflow triggered successfully');
180+
}
93181

94-
const {
95-
data: { permission },
96-
} = await app.octokit.rest.repos.getCollaboratorPermissionLevel({
182+
async function workflowRunHandler(data: WorkflowRunCompletedData, env: Env) {
183+
logger.debug('in workflow_run handler');
184+
185+
// Sanity checks
186+
187+
// Only handle the publish-dev workflow
188+
if (data.payload.workflow_run.name !== 'Publish dev') {
189+
logger.debug({ workflow: data.payload.workflow_run.name }, 'Ignoring non-publish workflow');
190+
return;
191+
}
192+
193+
// Check if workflow was triggered via workflow_dispatch (by the bot or a human)
194+
if (data.payload.workflow_run.event !== 'workflow_dispatch') {
195+
logger.debug({ event: data.payload.workflow_run.event }, 'Workflow not triggered by workflow_dispatch');
196+
return;
197+
}
198+
199+
// Only handle successful workflows
200+
if (data.payload.workflow_run.conclusion !== 'success') {
201+
logger.debug({ conclusion: data.payload.workflow_run.conclusion }, 'Workflow did not succeed');
202+
return;
203+
}
204+
205+
// Check if this was triggered by THIS bot specifically
206+
// Get the authenticated app information
207+
const { data: appInfo } = await data.octokit.rest.apps.getAuthenticated();
208+
if (data.payload.workflow_run.triggering_actor?.login !== `${appInfo!.slug}[bot]`) {
209+
logger.debug(
210+
{
211+
actor: data.payload.workflow_run.triggering_actor?.login,
212+
expected: `${appInfo!.slug}[bot]`,
213+
},
214+
'Workflow not triggered by this bot',
215+
);
216+
return;
217+
}
218+
219+
// Look up pack info by workflow run ID
220+
const packKey = `pack-${data.payload.workflow_run.id}`;
221+
const stored = await env.PACK_STORAGE.get(packKey);
222+
223+
if (!stored) {
224+
logger.debug({ workflowRunId: data.payload.workflow_run.id }, 'No pack info found for this workflow run');
225+
return;
226+
}
227+
228+
const { prNumber, commentId, tag }: PackInfo = JSON.parse(stored);
229+
// Delete the key as we've consumed it
230+
await env.PACK_STORAGE.delete(packKey);
231+
232+
logger.info(
233+
{
234+
pr: prNumber,
235+
tag,
236+
workflow: data.payload.workflow_run.id,
237+
triggering_actor: data.payload.workflow_run.triggering_actor?.login,
238+
},
239+
'Workflow completed, posting comment',
240+
);
241+
242+
// Post a comment on the PR
243+
try {
244+
await data.octokit.rest.issues.createComment({
97245
owner: data.payload.repository.owner.login,
98246
repo: data.payload.repository.name,
99-
username: data.payload.comment.user.login,
247+
issue_number: prNumber,
248+
body: `📦 Packages from this PR have been published with the tag \`${tag}\`.\n\nFor discord.js using npm:\n\`\`\`bash\nnpm install discord.js@${tag}\n\`\`\``,
100249
});
101250

102-
if (permission !== 'admin' && permission !== 'write') {
103-
logger.debug(`User ${data.payload.comment.user.login} does not have sufficient permissions to pack.`);
104-
return;
105-
}
251+
logger.info('Comment posted successfully');
252+
253+
// Remove the eyes reaction from the original trigger comment using stored commentId
254+
const { data: reactions } = await data.octokit.rest.reactions.listForIssueComment({
255+
owner: data.payload.repository.owner.login,
256+
repo: data.payload.repository.name,
257+
comment_id: commentId,
258+
});
259+
260+
const eyesReaction = reactions.find((reaction) => reaction.content === 'eyes');
261+
if (eyesReaction) {
262+
await data.octokit.rest.reactions
263+
.deleteForIssueComment({
264+
owner: data.payload.repository.owner.login,
265+
repo: data.payload.repository.name,
266+
reaction_id: eyesReaction.id,
267+
comment_id: commentId,
268+
})
106269

107-
if (
108-
!(await isPRGreen(
109-
app.octokit,
110-
data.payload.repository.owner.login,
111-
data.payload.repository.name,
112-
data.payload.issue.number,
113-
))
114-
) {
115-
return;
270+
.catch((error) => logger.warn({ err: error }, 'Failed to remove eyes reaction'));
271+
} else {
272+
logger.debug({ reactions }, 'No eyes reaction found to remove');
116273
}
274+
} catch (error) {
275+
logger.error({ err: error }, 'Failed to post comment');
276+
}
277+
}
117278

118-
logger.debug('Beginning the pack process...');
119-
};
279+
export function getApp(env: Env) {
280+
const app = new App({
281+
appId: env.APP_ID,
282+
privateKey: env.PRIVATE_KEY.replaceAll('\\n', '\n'),
283+
webhooks: {
284+
secret: env.WEBHOOK_SECRET,
285+
},
286+
});
120287

121-
app.webhooks.on('issue_comment.created', commentHandler);
122-
app.webhooks.on('issue_comment.edited', commentHandler);
288+
// eslint-disable-next-line promise/prefer-await-to-callbacks
289+
app.webhooks.onError((err) => logger.error(err));
290+
app.webhooks.on('issue_comment.created', async (data) => commentHandler(data, env));
291+
app.webhooks.on('issue_comment.edited', async (data) => commentHandler(data, env));
292+
app.webhooks.on('workflow_run.completed', async (data) => workflowRunHandler(data, env));
123293

124294
return app;
125295
}

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const server = {
2222
try {
2323
await verifyWebhookSignature(payloadString, signature, env.WEBHOOK_SECRET);
2424
} catch (error) {
25-
logger.warn({ error }, 'Invalid webhook signature');
25+
logger.warn({ err: error }, 'Invalid webhook signature');
2626
return new Response('webhook verification failed', {
2727
status: 400,
2828
headers: { 'content-type': 'application/json' },
@@ -44,7 +44,7 @@ const server = {
4444
headers: { 'content-type': 'application/json' },
4545
});
4646
} catch (error) {
47-
logger.error({ err: error }, 'Error processing webhook');
47+
logger.error(error);
4848
return new Response('internal server error', {
4949
status: 500,
5050
headers: { 'content-type': 'application/json' },

src/util.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
1-
import type { HandlerFunction } from '@octokit/webhooks/types';
1+
import type { EmitterWebhookEvent } from '@octokit/webhooks/types';
2+
import type { Octokit } from 'octokit';
23
import { pino } from 'pino';
34

45
export interface Env {
56
APP_ID: string;
7+
PACK_STORAGE: KVNamespace;
68
PRIVATE_KEY: string;
79
WEBHOOK_SECRET: string;
810
}
911

12+
export interface PackInfo {
13+
commentId: number;
14+
prNumber: number;
15+
tag: string;
16+
workflowRunId: number;
17+
}
18+
1019
export const logger = pino({ level: 'debug' });
1120

12-
export type IssueCommentCreatedData = Parameters<HandlerFunction<'issue_comment.created'>>[0];
13-
export type IssueCommentEditedData = Parameters<HandlerFunction<'issue_comment.edited'>>[0];
21+
export type IssueCommentCreatedData = EmitterWebhookEvent<'issue_comment.created'> & { octokit: Octokit };
22+
export type IssueCommentEditedData = EmitterWebhookEvent<'issue_comment.edited'> & { octokit: Octokit };
23+
export type WorkflowRunCompletedData = EmitterWebhookEvent<'workflow_run.completed'> & { octokit: Octokit };

wrangler.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,8 @@ APP_ID = "1631654"
1010

1111
[observability.logs]
1212
enabled = true
13+
14+
[[kv_namespaces]]
15+
binding = "PACK_STORAGE"
16+
id = "2a9b367d29214778ac9aeb6e7a275e9a"
17+
preview_id = "1cc9d482645d43f787bbacdca47cd782"

0 commit comments

Comments
 (0)