|
1 | 1 | import type { Octokit } from 'octokit'; |
2 | 2 | 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'; |
4 | 11 |
|
5 | 12 | async function isPRGreen(octokit: Octokit, owner: string, repo: string, pullNumber: number): Promise<boolean> { |
6 | 13 | // First, get the PR data to get the head SHA |
@@ -59,67 +66,230 @@ async function isPRGreen(octokit: Octokit, owner: string, repo: string, pullNumb |
59 | 66 | return isGreen; |
60 | 67 | } |
61 | 68 |
|
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', |
68 | 145 | }, |
69 | 146 | }); |
70 | 147 |
|
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, |
74 | 158 | }); |
75 | 159 |
|
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 | + ); |
78 | 164 |
|
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 | + } |
83 | 169 |
|
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 }); |
88 | 178 |
|
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 | +} |
93 | 181 |
|
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({ |
97 | 245 | owner: data.payload.repository.owner.login, |
98 | 246 | 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\`\`\``, |
100 | 249 | }); |
101 | 250 |
|
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 | + }) |
106 | 269 |
|
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'); |
116 | 273 | } |
| 274 | + } catch (error) { |
| 275 | + logger.error({ err: error }, 'Failed to post comment'); |
| 276 | + } |
| 277 | +} |
117 | 278 |
|
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 | + }); |
120 | 287 |
|
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)); |
123 | 293 |
|
124 | 294 | return app; |
125 | 295 | } |
0 commit comments