From 0e5b04b9dae935073292f2d9b7c63d3c52c452df Mon Sep 17 00:00:00 2001 From: Victor Palade Date: Sat, 3 Aug 2024 13:41:27 +0200 Subject: [PATCH 1/5] chore: update readme. Signed-off-by: Victor Palade --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index b60ecc1..2cf7224 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # oscar +NOTE: This project is in development, is not a priority and values functionality above +anything else, and as such it's not high quality code. + Oscar is a bot inspired by [Prow](https://docs.prow.k8s.io/docs/). And just like Prow, Oscar is a bot that helps you manage your GitHub repositories. It can answer to commands in the comments of your PRs and Issues such From b82f27e4475579e692eea691c65802451b681f60 Mon Sep 17 00:00:00 2001 From: Victor Palade Date: Sat, 3 Aug 2024 13:43:16 +0200 Subject: [PATCH 2/5] chore: remove gitea and gitlab mention for now. Signed-off-by: Victor Palade --- src/index.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4b1ac70..7510659 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,20 +18,12 @@ export default { return new Response(`429 Failure – rate limit exceeded for ${pathname}`, { status: 429 }); } - const source = request.headers.get('X-GitHub-Event') ? 'github' : - request.headers.get('X-Gitlab-Event') ? 'gitlab' : - request.headers.get('X-Gitea-Event') ? 'gitea' : 'unknown'; + const source = request.headers.get('X-GitHub-Event') ? 'github' : 'unknown'; switch (source) { case 'github': console.log('Request is from GitHub'); break; - case 'gitlab': - console.log('Request is from GitLab'); - return new Response('Not implemented', { status: 501, headers: { 'Content-Type': 'application/json' } }); - case 'gitea': - console.log('Request is from Gitea'); - return new Response('Not implemented', { status: 501, headers: { 'Content-Type': 'application/json' } }); default: console.log('Unknown source'); return new Response('Unsupported webhook', { status: 400 }); From 9bd8e08ef05163afb4d0706cd9aa9a8e30dfdee1 Mon Sep 17 00:00:00 2001 From: Victor Palade Date: Sat, 3 Aug 2024 14:23:33 +0200 Subject: [PATCH 3/5] chore: update readme with unlabel command. Signed-off-by: Victor Palade --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2cf7224..a6e2cd8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ as: - `/close`: Closes the PR or Issue. - `/reopen`: Reopens the PR or Issue. - `/label`: Adds a label to the PR or Issue. +- '/label-remove': Removes a label from the PR or Issue. - `/triage`: Remove the `needs-triage` label from the PR or Issue. - `/rename`: Renames the PR or Issue. - `/reviewers`: Adds reviewers to the PR. @@ -33,7 +34,6 @@ as: Oscar can be run as a GitHub app. This is the recommended way to run Oscar as it is easier to set up and maintain. -Integrating with GitLab and Gitea is planned for the near future. ## Cloudflare workers From cdffea52b404d7326b32c51f9a4b540c43b416a1 Mon Sep 17 00:00:00 2001 From: Victor Palade Date: Tue, 6 Aug 2024 21:43:59 +0200 Subject: [PATCH 4/5] wip Signed-off-by: Victor Palade --- package.json | 4 +- src/commands/github.ts | 205 +++++++++++++++++++++++--------- src/common.ts | 72 ++++++++--- src/github/githubEvents.ts | 236 +++++++++++++++++++++++++++++-------- src/github/httpHandler.ts | 14 ++- src/index.ts | 10 +- test/index.spec.ts | 37 ------ tsconfig.json | 20 ++-- yarn.lock | 149 ++++++++++++++++++++++- 9 files changed, 572 insertions(+), 175 deletions(-) diff --git a/package.json b/package.json index 76b52d3..7552a5a 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ }, "dependencies": { "@ltd/j-toml": "^1.38.0", + "@octokit/webhooks-types": "^7.5.1", "@tsndr/cloudflare-worker-router": "^3.2.4", - "octokit": "^4.0.2" + "octokit": "^4.0.2", + "pino": "^9.3.2" } } diff --git a/src/commands/github.ts b/src/commands/github.ts index c1723bf..07763d0 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -1,24 +1,26 @@ import { Octokit } from 'octokit'; -import { sleep } from '../common'; +import { sleep, Config } from '../common'; +import { addLabels, WorkflowRunStatus, convertConclusionToState } from '../github/githubEvents'; +type CommandHandler = (command: string, app: Octokit, payload: any, config: Config) => Promise; + class CommandRegistry { - private handlers: { [key: string]: (command: string, app: Octokit, payload: any) => Promise; } = {}; + private handlers: { [key: string]: CommandHandler; } = {}; registerCommand( - commandPrefix: string, - handler: (command: string, app: Octokit, payload: any) - => Promise) { + commandPrefix: string, handler: CommandHandler) { this.handlers[commandPrefix] = handler; } - async processCommand(command: string, app: Octokit, payload: any): Promise { + + // TODO: this function is all over the place, return bool, doesn't throw. + async processCommand(command: string, app: Octokit, payload: any, config: Config): Promise { const commands = command.split('\n').map(c => c.trim().replace(/\r/g, '')).filter(c => c !== ''); for (const command of commands) { if (!command.startsWith('/')) { - console.log('Invalid command:', command); continue; } @@ -27,9 +29,9 @@ class CommandRegistry { if (commandPrefix) { try { - await this.handlers[commandPrefix](command, app, payload); + await this.handlers[commandPrefix](command, app, payload, config); } catch (error: any) { - console.error(`Error while processing command: ${command}`, error.message); + console.error(`Error while processing command ${command}: ${error.message}`); } } else { console.log(`No handler found for command: ${command}`); @@ -45,10 +47,11 @@ function newCommandRegistry(): CommandRegistry { const commandRegistry = new CommandRegistry(); // Command handlers for workflow actions and jobs - commandRegistry.registerCommand('/restart-workflow', handleRestartWorkflowCommand); - commandRegistry.registerCommand('/stop-workflow', handleStopWorkflowCommand); - commandRegistry.registerCommand('/cancel-workflow', handleCancelWorkflowCommand); - commandRegistry.registerCommand('/restart-job', handleRestartWorkflowJobCommand); + commandRegistry.registerCommand('/test', handleRestartWorkflowCommand); + commandRegistry.registerCommand('/stop', handleStopWorkflowCommand); + commandRegistry.registerCommand('/cancel', handleCancelWorkflowCommand); + commandRegistry.registerCommand('/retest', handleRestartWorkflowJobCommand); + commandRegistry.registerCommand('/ok-to-test', handleOkToTestCommand); // Command handlers for issues and pull requests commandRegistry.registerCommand('/label', handleLabelCommand); @@ -77,30 +80,37 @@ function newCommandRegistry(): CommandRegistry { return commandRegistry; } -async function handleLabelCommand(command: string, app: Octokit, payload: any): Promise { +async function handleLabelCommand(command: string, app: Octokit, payload: any, config: Config): Promise { const match = command.match(/^\/label(?:\s+(.*))?$/); const labelsStr = match ? (match[1] || '').trim() : ''; const labels = labelsStr ? labelsStr.split(' ').map(label => label.trim()) : []; - - const issue_number = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; if (labels.length > 0) { - await app.rest.issues.addLabels({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number, - labels, - }); + try { + await addLabels( + app, + payload.repository.owner.login, + payload.repository.name, + issue_number, + labels, + config.labels + ); + } catch (error: any) { + throw new Error(`Error while adding labels to issue #${issue_number}: ${error.message}`); + } console.log(`Added labels "${labels.join(', ')}" to issue #${issue_number}`); } else { - await app.rest.issues.removeAllLabels({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number, - }); - console.log(`Cleared all labels from issue #${issue_number}`); + try { + await app.rest.issues.removeAllLabels({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number, + }); + } catch (error: any) { + throw new Error(`Error while clearing labels from issue #${issue_number}: ${error.message}`); + } } } @@ -119,29 +129,35 @@ async function handleLabelRemoveCommand(command: string, app: Octokit, payload: }); } -async function handleTriageCommand(command: string, app: Octokit, payload: any): Promise { - +async function handleTriageCommand(command: string, app: Octokit, payload: any, config: Config): Promise { const issue_number = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + try { + await app.rest.issues.removeLabel({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number, + name: 'needs-triage', + }); + } catch (error: any) { + console.log(`Error while removing "needs-triage" label from issue #${issue_number}: ${error.message}`); + } - await app.rest.issues.removeLabel({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number, - name: 'needs-triage', - }); - - await app.rest.issues.addLabels({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number, - labels: ['triage/accepted'], - }); + try { + await addLabels( + app, + payload.repository.owner.login, + payload.repository.name, + issue_number, + ['triage/accepted'], + config.labels, + ); - console.log(`Added "needs-triage" label to issue #${issue_number}`); + } catch (error: any) { + throw new Error(`Error while adding "triage/accepted" label to issue #${issue_number}: ${error.message}`); + } } async function handleRestartWorkflowCommand(command: string, app: Octokit, payload: any): Promise { - const actionId = command.slice('/restart-workflow'.length).trim(); const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; const pullRequest = await app.rest.pulls.get({ @@ -150,6 +166,23 @@ async function handleRestartWorkflowCommand(command: string, app: Octokit, paylo pull_number: pullNumber, }); + const workflows = await app.rest.actions.listRepoWorkflows({ + owner: payload.repository.owner.login, + repo: payload.repository + }); + + if (workflows.data.workflows.length === 0) { + console.log(`No workflows found for the repository: ${payload.repository.name}`); + return; + } + + console.log(`Found ${workflows.data.workflows.length} workflows for the repository: ${payload.repository.name}`); + + for (const workflow of workflows.data.workflows) { + console.log(`Restarting workflow: ${workflow.name}`); + } + + // TODO: fix this, rust.yml is hardcoded const actions = await app.rest.actions.listWorkflowRunsForRepo({ owner: payload.repository.owner.login, repo: payload.repository.name, @@ -173,12 +206,12 @@ async function handleRestartWorkflowCommand(command: string, app: Octokit, paylo owner: payload.repository.owner.login, repo: payload.repository.name, issue_number: pullNumber, - body: `Restarted worflow: ["${lastAction.name}"](${lastAction.html_url})`, + body: `Restarted workflow: ["${lastAction.name}"](${lastAction.html_url})`, }); } async function handleStopWorkflowCommand(command: string, app: Octokit, payload: any): Promise { - const actionId = command.slice('/stop-workflow'.length).trim(); + const actionId = command.slice('/stop'.length).trim(); const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; const pullRequest = await app.rest.pulls.get({ @@ -210,13 +243,13 @@ async function handleStopWorkflowCommand(command: string, app: Octokit, payload: owner: payload.repository.owner.login, repo: payload.repository.name, issue_number: pullNumber, - body: `Stopped worflow: ["${lastAction.name}"](${lastAction.html_url})`, + body: `Stopped workflow: ["${lastAction.name}"](${lastAction.html_url})`, }); } async function handleCancelWorkflowCommand(command: string, app: Octokit, payload: any): Promise { - const actionId = command.slice('/cancel-workflow'.length).trim(); + const actionId = command.slice('/cancel'.length).trim(); const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; const pullRequest = await app.rest.pulls.get({ @@ -255,7 +288,7 @@ async function handleCancelWorkflowCommand(command: string, app: Octokit, payloa async function handleRestartWorkflowJobCommand(command: string, app: Octokit, payload: any): Promise { - const match = command.match(/^\/restart-job(?:\s+(.*))?$/); + const match = command.match(/^\/retest(?:\s+(.*))?$/); const jobCommand = match ? (match[1] || '').trim() : ''; const [workflowName, jobName] = jobCommand.split(' '); const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; @@ -305,6 +338,72 @@ async function handleRestartWorkflowJobCommand(command: string, app: Octokit, pa } } +async function handleOkToTestCommand(_: string, app: Octokit, payload: any, config: Config): Promise { + const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + try { + await addLabels( + app, + payload.repository.owner.login, + payload.repository.name, + pullNumber, + ['ok-to-test'], + config.labels, + ); + + await app.rest.issues.removeLabel({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: pullNumber, + name: 'needs-ok-to-test', + }); + + const workflows = await app.rest.actions.listRepoWorkflows({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + }); + + for (const workflow of workflows.data.workflows) { + try { + await app.rest.actions.createWorkflowDispatch({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + workflow_id: workflow.id, + ref: payload.pull_request.head.ref, + }); + + const runningWorkflows = await app.rest.actions.listWorkflowRuns({ + owner: payload.repository.owner.login, + status: 'in_progress', + repo: payload.repository.name, + workflow_id: workflow.id, + branch: payload.pull_request.head.ref, + }); + + for (const runningWorkflow of runningWorkflows.data.workflow_runs) { + const resp = await app.rest.repos.createCommitStatus({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + sha: payload.pull_request.head.sha, + state: convertConclusionToState(runningWorkflow.conclusion as WorkflowRunStatus), + target_url: runningWorkflow.html_url, + description: runningWorkflow.status, + context: workflow.name, + }); + } + } catch (error: any) { + console.log(`Error while rerunning workflow: ${error.message}`); + continue; + } + } + + } catch (error: any) { + throw new Error(`Error while adding "ok-to-test" label to issue #${pullNumber}: ${error.message}`); + } + + + console.log(`Commented "Ok to test" on pull request #${pullNumber}`); +} + async function handleRetitleCommand(command: string, app: Octokit, payload: any): Promise { const match = command.match(/^\/retitle(?:\s+(.*))?$/); const title = match ? (match[1] || '').trim() : ''; @@ -458,7 +557,7 @@ async function handlePrMergeCommand(command: string, app: Octokit, payload: any) owner: payload.repository.owner.login, repo: payload.repository.name, issue_number: issueNumber, - body: 'Skipping merge due to missing "approved" label', + body: 'Cannot merge due to missing "approved" label', }); return; } @@ -470,12 +569,12 @@ async function handlePrMergeCommand(command: string, app: Octokit, payload: any) }); if (pullRequest.data.merged) { - console.log(`Skipping merge for pull request #${payload.issue.number} due to already merged`); + console.log(`PR: #${payload.issue.number} already merged`); await app.rest.issues.createComment({ owner: payload.repository.owner.login, repo: payload.repository.name, issue_number: issueNumber, - body: 'Skipping merge due to already merged', + body: 'PR already merged', }); return; } diff --git a/src/common.ts b/src/common.ts index 01e57d1..eb7f62c 100644 --- a/src/common.ts +++ b/src/common.ts @@ -15,33 +15,73 @@ export type Env = { export type Handler = RouterHandler; -export type Config = { - admin: { - name: string; - }, +export type Admin = { + name: string; +}; + +export type Label = { + name: string; + description: string; + color: string; +}; +export type Config = { + admin: Admin; + labels: Label[]; checkPermissions(user: string): boolean; + checkLabels(labels: Label): boolean; }; // NOTE: can't find this union type in the codebase for octokit export type ReactionContent = '+1' | '-1' | 'laugh' | 'confused' | 'heart' | 'hooray' | 'rocket' | 'eyes'; const parseTomlConfig = - async (uri: string): Promise => { - const response = await fetch(uri); - const parsedToml = TOML.parse(await response.text()); - // TODO: This would need to be addressed in the new version of the - // permissions config file. - const admin = parsedToml.admin; - if (!admin || !admin.name) { - throw new Error('Error while parsing the config file, config file is invalid'); + async (uri: string): Promise => { + try { + const response = await fetch(uri); + const parsedToml = TOML.parse(await response.text()); + if (parsedToml.admin === undefined) { + throw new Error('undefined admin in toml file'); + } + const admin = parsedToml.admin as Admin; + if (!admin.name) { + throw new Error('admin name is undefined in toml file'); + } + const tomlLabels = parsedToml.Labels; + if (!tomlLabels) { + throw new Error('labels are not defined'); + } + + let labels: Label[] = []; + + for (const label of tomlLabels) { + if (!label.name || !label.description || !label.color) { + console.log(`${label.name} is missing a field`); + continue; + } + if (label.color.length !== 7) { + console.log('label color is not valid'); + continue; + } + + labels.push(label as Label); + } + + return { + admin: { name: admin.name }, + labels, + checkPermissions: (user: string) => admin.name === user, + checkLabels: (label: Label) => tomlLabels.some(label => labels.includes(label)), + }; + } catch (error: any) { + throw new Error(`Error while parsing the config file: ${error.message}`); } - return { - admin: { name: admin.name }, - checkPermissions: (user: string) => admin.name === user, - }; }; +const checkLabels = (labels: Label[], tomlLabels: string[]) => { + return labels.some(label => labels.includes(label)); +}; + export { parseTomlConfig }; export const sleep = (ms: number) => { diff --git a/src/github/githubEvents.ts b/src/github/githubEvents.ts index 2d0c3ac..e4bf3ab 100644 --- a/src/github/githubEvents.ts +++ b/src/github/githubEvents.ts @@ -1,10 +1,11 @@ import { App, Octokit } from 'octokit'; -import { Config, Env, parseTomlConfig, ReactionContent } from '../common'; +import { Config, Env, parseTomlConfig, ReactionContent, Label } from '../common'; import { newCommandRegistry } from '../commands/github'; -export default async (env: Env, installationId: number): Promise => { + +export const newApp = async (env: Env, installationId: number): Promise => { const app = new App({ appId: env.GITHUB_APP_ID, privateKey: atob(env.GITHUB_PRIVATE_KEY), @@ -20,7 +21,7 @@ export default async (env: Env, installationId: number): Promise => { try { authApp = await app.getInstallationOctokit(installationId); } catch (error: any) { - console.error(`Error while authenticating: ${error.message}`); + throw new Error(`Error while authenticating: ${error.message}`); } const commandRegistry = newCommandRegistry(); @@ -29,15 +30,11 @@ export default async (env: Env, installationId: number): Promise => { try { const resp = await parseTomlConfig(env.OSCAR_ACCESS_CONFIG_URI); - if (!resp) { - throw new Error('Error while parsing the config file'); - } config = resp; } catch (error: any) { - console.error(`Error while parsing the config file: ${error.message}`); + throw new Error(`Error while parsing the config file: ${error.message}`); } - app.webhooks.on('issues.opened', async ({ payload }) => { try { await addLabels( @@ -46,6 +43,7 @@ export default async (env: Env, installationId: number): Promise => { payload.repository.name, payload.issue.number, ['needs-triage'], + config.labels ); if (payload.issue.body) { @@ -53,6 +51,7 @@ export default async (env: Env, installationId: number): Promise => { payload.issue.body, authApp, payload, + config, ); } @@ -63,18 +62,46 @@ export default async (env: Env, installationId: number): Promise => { content: 'eyes', }); } catch (error: any) { - console.error(`Error while executing commands for issues.opened: ${error.message}`); + throw new Error(`Error while executing commands for issues.opened: ${error.message}`); } }); - app.webhooks.on('pull_request.opened', async ({ payload }) => { + + + // TODO: retrieve the ongoing check, update the status of it with the workflow_run status + app.webhooks.on(['workflow_run.completed', 'workflow_run.in_progress', 'workflow_run.requested'], async ({ payload }) => { + const owner = payload.repository.owner.login; + const repo = payload.repository.name; + const { workflow_run } = payload; + + await authApp.rest.repos.createCommitStatus({ + owner, + repo, + sha: workflow_run.head_sha, + state: convertConclusionToState(workflow_run.conclusion as WorkflowRunStatus), + target_url: workflow_run.html_url, + description: workflow_run.status, + context: workflow_run.name || 'Unknown workflow', + }); + }); + + app.webhooks.on(['pull_request.opened', 'pull_request.reopened'], async ({ payload }) => { + let labels = ['needs-triage']; + + if (payload.pull_request.user.login === config.admin.name) { + labels.push('ok-to-test'); + } else { + labels.push('needs-ok-to-test'); + } + try { await addLabels( authApp, payload.repository.owner.login, payload.repository.name, payload.pull_request.number, - ['needs-triage'] + labels, + config.labels ); const reactions: ReactionContent[] = ['+1', 'rocket', 'heart']; @@ -87,27 +114,65 @@ export default async (env: Env, installationId: number): Promise => { content: reaction, }); } + if (payload.pull_request.body) { await commandRegistry.processCommand( payload.pull_request.body, authApp, payload, + config ); } + + const { data: { workflows } } = await authApp.rest.actions.listRepoWorkflows({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + }); + + for (const workflow of workflows) { + try { + await authApp.rest.actions.createWorkflowDispatch({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + workflow_id: workflow.id, + ref: payload.pull_request.head.ref, + }); + + const { data: { workflow_runs } } = await authApp.rest.actions.listWorkflowRuns({ + owner: payload.repository.owner.login, + status: 'in_progress', + repo: payload.repository.name, + workflow_id: workflow.id, + branch: payload.pull_request.head.ref, + }); + + for (const runningWorkflow of workflow_runs) { + await authApp.rest.repos.createCommitStatus({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + sha: payload.pull_request.head.sha, + state: convertConclusionToState(runningWorkflow.conclusion as WorkflowRunStatus), + target_url: runningWorkflow.html_url, + description: runningWorkflow.status, + context: workflow.name, + }); + } + } catch (error: any) { + console.log(`Error while rerunning workflow: ${error.message}`); + } + } } catch (error: any) { - console.error(`Error while executing commands for pull_request.opened: ${error.message}`); + throw new Error(`Error while executing commands for pull_request.opened: ${error.message}`); } }); - const handleComment = async (payload: any) => { - // NOTE: This should be an error if there's no user. + + app.webhooks.on(['issue_comment.created', 'issue_comment.edited'], async ({ payload }) => { const user = payload.comment.user?.login; - if (!user) { - return; - } + if (!user) throw new Error('User is undefined'); if (!config.checkPermissions(user)) { - console.log('User does not have permissions:', user); + console.log('User does not have permission to run commands'); return; } @@ -116,21 +181,14 @@ export default async (env: Env, installationId: number): Promise => { payload.comment.body, authApp, payload, + config ); } catch (error: any) { - console.error(`Error while processing command on issue_comment: ${error.message}`); + throw new Error(`Error while processing command on issue_comment: ${error.message}`); } - }; - - app.webhooks.on('issue_comment.created', async ({ payload }) => { - await handleComment(payload); - }); - - app.webhooks.on('issue_comment.edited', async ({ payload }) => { - await handleComment(payload); }); - app.webhooks.on('workflow_run', async ({ payload }) => { + app.webhooks.on('workflow_run.completed', async ({ payload }) => { const owner = payload.repository.owner.login; const repo = payload.repository.name; const mainBranch = payload.repository.default_branch; @@ -148,44 +206,124 @@ export default async (env: Env, installationId: number): Promise => { owner, repo, title: 'Workflow failed', - body: `The main branch workflow failed. Please check the logs and fix the issue. ${payload.workflow_run.html_url}`, + body: `The main branch workflow failed.Please check the logs and fix the issue. ${payload.workflow_run.html_url}`, }); - await addLabels(authApp, owner, repo, resp.data.number, ['ci-failure']); + await addLabels(authApp, owner, repo, resp.data.number, ['kind/failing-test'], config.labels); } catch (error: any) { - console.error(`Error while creating issue: ${error.message}`); + throw new Error(`Error while creating issue: ${error.message}`); } } }); + app.webhooks.on('pull_request.labeled', async ({ payload }) => { + if (payload.label?.name === 'ok-to-test') { + try { + await authApp.rest.issues.removeLabel({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: payload.pull_request.number, + name: 'needs-ok-to-test', + }); + + } catch (error: any) { + console.log(`Error while removing label: ${error.message}`); + } + } + }); + + app.webhooks.on('pull_request.synchronize', async ({ payload }) => { + console.log('Push event received:', payload); + }); + return app; }; -async function checkIfLabelsExist(authApp: Octokit, owner: string, repo: string, labels: string[]): Promise { - const existingLabels = await authApp.rest.issues.listLabelsForRepo({ - owner, - repo, - }); +async function addLabels(app: Octokit, owner: string, repo: string, issueNumber: number, labels: string[], configLabels: Label[]) { + const validLabels = configLabels.filter(configLabel => labels.includes(configLabel.name)); + console.log(validLabels); + + if (validLabels.length === 0) { + return; + } + + const repoLabels = await app.rest.issues.listLabelsForRepo({ owner, repo }); + const existingRepoLabels = validLabels.filter(label => + repoLabels.data.some(repoLabel => repoLabel.name === label.name) + ); + + if (existingRepoLabels.length > 0) { + for (const existingLabel of existingRepoLabels) { + const label = configLabels.find(label => label.name === existingLabel.name); + if (!label) { + continue; + } + try { + await app.rest.issues.updateLabel({ + owner, + repo, + name: label.name, + color: label.color.replace(/^#/, ''), + description: label.description, + }); + } catch (error: any) { + throw new Error(`Error while updating labels: ${error.message}`); + } + } + } - for (const label of labels) { - if (!existingLabels.data.some(l => l.name === label)) { - await authApp.rest.issues.createLabel({ + const newLabels = validLabels.filter(label => + !existingRepoLabels.some(existingLabel => existingLabel.name === label.name) + ); + console.log(newLabels); + + for (const label of newLabels) { + try { + await app.rest.issues.createLabel({ owner, repo, - name: label, - color: '000000', + name: label.name, + color: label.color.replace(/^#/, ''), + description: label.description, }); + } catch (error: any) { + throw new Error(`Error while adding labels: ${error.message}`); } } + + try { + await app.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: validLabels.map(label => label.name), + }); + } catch (error: any) { + throw new Error(`Error while adding labels to issue: ${error.message}`); + } } -async function addLabels(app: Octokit, owner: string, repo: string, issue_number: number, labels: string[]) { - await checkIfLabelsExist(app, owner, repo, labels); +type WorkflowRunStatus = 'success' | 'failure' | 'cancelled' | 'action_required' | 'neutral' | 'skipped' | 'stale' | 'timed_out' | 'startup_failure' | null; - await app.rest.issues.addLabels({ - owner, - repo, - issue_number, - labels, - }); +function convertConclusionToState(status: WorkflowRunStatus): "success" | "error" | "failure" | "pending" { + switch (status) { + case "success": + return "success"; + case "failure": + return "failure"; + case "cancelled": + case "action_required": + case "neutral": + case "stale": + case "timed_out": + case "startup_failure": + return "error"; + case "skipped": + return "success"; + default: + return "pending"; + } } +export type { WorkflowRunStatus }; + +export { addLabels, convertConclusionToState }; \ No newline at end of file diff --git a/src/github/httpHandler.ts b/src/github/httpHandler.ts index 1bbf89c..8cf6e43 100644 --- a/src/github/httpHandler.ts +++ b/src/github/httpHandler.ts @@ -1,5 +1,5 @@ import { Handler } from '../common'; -import githubEvents from './githubEvents'; +import { newApp } from './githubEvents'; const githubHandler: Handler = @@ -8,20 +8,24 @@ const githubHandler: Handler = const payload = await req.text(); const { installation } = JSON.parse(payload); const app = - await githubEvents(env, installation.id); + await newApp(env, installation.id); + if (!app) { + console.log('Error while creating new app, undefined'); + return new Response('Internal error', { status: 500 }); + } const id = req.headers.get('x-github-delivery') || ''; const event = req.headers.get('x-github-event') || ''; - const sig = req.headers.get('x-hub-signature-256') || ''; + const signature = req.headers.get('x-hub-signature-256') || ''; await app.webhooks.verifyAndReceive({ id, name: event as any, payload, - signature: sig, + signature, }); } catch (error: any) { - console.error('Error:', error.message); + console.error('Error in GitHub handler:', error.message); return new Response('Error while processing the request', { status: 500 }); } diff --git a/src/index.ts b/src/index.ts index 7510659..7e0ec25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,15 @@ import { Router } from '@tsndr/cloudflare-worker-router'; +import pino from 'pino'; import { githubHandler } from './github/httpHandler'; -import { Env, } from './common'; +import { Env } from './common'; const router = new Router(); +const logger = pino( + { level: 'info' }, +); + router.debug(); router.post('/webhooks/github', githubHandler); @@ -18,6 +23,9 @@ export default { return new Response(`429 Failure – rate limit exceeded for ${pathname}`, { status: 429 }); } + const githubEvent = request.headers.get('X-GitHub-Event') || 'unknown'; + logger.debug(`The GitHub event is: ${githubEvent}`); + const source = request.headers.get('X-GitHub-Event') ? 'github' : 'unknown'; switch (source) { diff --git a/test/index.spec.ts b/test/index.spec.ts index 2f3c8ba..c8a39ed 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -34,19 +34,6 @@ beforeAll(() => { }); describe('Verify invalid header', () => { - it('should fail with Unsupported webhook', async () => { - const request = new IncomingRequest('http://example.com', { - method: 'POST', - }); - // Create an empty context to pass to `worker.fetch()`. - const ctx = createExecutionContext(); - - const response = await worker.fetch(request, globalThis.env, ctx); - // Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions - await waitOnExecutionContext(ctx); - expect(await response.status).toBe(400); - }); - it('should fail with Invalid signature', async () => { const request = new IncomingRequest('http://example.com/webhooks/github', { headers: { @@ -61,28 +48,4 @@ describe('Verify invalid header', () => { await waitOnExecutionContext(ctx); expect(await response.status).toBe(500); }); - - it('should fail with Not implemented', async () => { - const headerValues = [ - 'X-Gitlab-Event', - 'X-Gitea-Event', - ]; - - for (const headerEntry of headerValues) { - const headers = { - [headerEntry]: 'test', - }; - - const request = new IncomingRequest('http://example.com', { - headers, - method: 'POST', - }); - - // Create an empty context to pass to `worker.fetch()`. - const ctx = createExecutionContext(); - const response = await worker.fetch(request, globalThis.env, ctx); - await waitOnExecutionContext(ctx); - expect(await response.status).toBe(501); - } - }); }); diff --git a/tsconfig.json b/tsconfig.json index 9192490..2af7360 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { /* Visit https://aka.ms/tsconfig.json to read more about this file */ - /* Projects */ // "incremental": true, /* Enable incremental compilation */ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ @@ -9,10 +8,11 @@ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - /* Language and Environment */ "target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, - "lib": ["es2021"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, + "lib": [ + "es2021" + ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, "jsx": "react" /* Specify what JSX code is generated. */, // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ @@ -22,7 +22,6 @@ // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - /* Modules */ "module": "es2022" /* Specify what module code is generated. */, // "rootDir": "./", /* Specify the root folder within your source files. */ @@ -37,12 +36,10 @@ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ "resolveJsonModule": true /* Enable importing .json files */, // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ - /* JavaScript Support */ "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, "checkJs": false /* Enable error reporting in type-checked JavaScript files. */, // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ - /* Emit */ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ // "declarationMap": true, /* Create sourcemaps for d.ts files. */ @@ -67,18 +64,16 @@ // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - /* Interop Constraints */ "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, // "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, - /* Type Checking */ "strict": true /* Enable all strict type-checking options. */, // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ - // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ + "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ @@ -95,10 +90,11 @@ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, - "exclude": ["test"] -} + "exclude": [ + "test" + ] +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 2010dc5..591c7d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -570,6 +570,11 @@ resolved "https://registry.yarnpkg.com/@octokit/webhooks-methods/-/webhooks-methods-5.1.0.tgz#13b6c08f89902c1ab0ddf31c6eeeec9c2772cfe6" integrity sha512-yFZa3UH11VIxYnnoOYCVoJ3q4ChuSOk2IVBBQ0O3xtKX4x9bmKb/1t+Mxixv2iUhzMdOl1qeWJqEhouXXzB3rQ== +"@octokit/webhooks-types@^7.5.1": + version "7.5.1" + resolved "https://registry.yarnpkg.com/@octokit/webhooks-types/-/webhooks-types-7.5.1.tgz#e05399ab6bbbef8b78eb6bfc1a2cb138ea861104" + integrity sha512-1dozxWEP8lKGbtEu7HkRbK1F/nIPuJXNfT0gd96y6d3LcHZTtRtlf8xz3nicSJfesADxJyDh+mWBOsdLkqgzYw== + "@octokit/webhooks@^13.0.0": version "13.2.7" resolved "https://registry.yarnpkg.com/@octokit/webhooks/-/webhooks-13.2.7.tgz#03f89b278cd63f271eba3062f0b75ddd18a82252" @@ -743,6 +748,13 @@ loupe "^2.3.7" pretty-format "^29.7.0" +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + acorn-walk@^8.2.0, acorn-walk@^8.3.2: version "8.3.3" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.3.tgz#9caeac29eefaa0c41e3d4c65137de4d6f34df43e" @@ -788,11 +800,21 @@ assertion-error@^1.1.0: resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== +atomic-sleep@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" + integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + before-after-hook@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-3.0.2.tgz#d5665a5fa8b62294a5aa0a499f933f4a1016195d" @@ -832,6 +854,14 @@ braces@~3.0.2: dependencies: fill-range "^7.1.1" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + cac@^6.7.14: version "6.7.14" resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" @@ -1034,6 +1064,16 @@ estree-walker@^3.0.3: dependencies: "@types/estree" "^1.0.0" +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + execa@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/execa/-/execa-8.0.1.tgz#51f6a5943b580f963c3ca9c6321796db8cc39b8c" @@ -1054,6 +1094,11 @@ exit-hook@^2.2.1: resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-2.2.1.tgz#007b2d92c6428eda2b76e7016a34351586934593" integrity sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw== +fast-redact@^3.1.1: + version "3.5.0" + resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.5.0.tgz#e9ea02f7e57d0cd8438180083e93077e496285e4" + integrity sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A== + fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" @@ -1129,6 +1174,11 @@ human-signals@^5.0.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-5.0.0.tgz#42665a284f9ae0dade3ba41ebc37eb4b852f3a28" integrity sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ== +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + indent-string@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-5.0.0.tgz#4fd2980fccaf8622d14c64d694f4cf33c81951a5" @@ -1353,6 +1403,11 @@ octokit@^4.0.2: "@octokit/request-error" "^6.0.0" "@octokit/types" "^13.0.0" +on-exit-leak-free@^2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" + integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== + once@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -1414,6 +1469,36 @@ picomatch@^2.0.4, picomatch@^2.2.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +pino-abstract-transport@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz#97f9f2631931e242da531b5c66d3079c12c9d1b5" + integrity sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q== + dependencies: + readable-stream "^4.0.0" + split2 "^4.0.0" + +pino-std-serializers@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b" + integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== + +pino@^9.3.2: + version "9.3.2" + resolved "https://registry.yarnpkg.com/pino/-/pino-9.3.2.tgz#a530d6d28f1d954b6f54416a218cbb616f52f901" + integrity sha512-WtARBjgZ7LNEkrGWxMBN/jvlFiE17LTbBoH0konmBU684Kd0uIiDwBXlcTCW7iJnA6HfIKwUssS/2AC6cDEanw== + dependencies: + atomic-sleep "^1.0.0" + fast-redact "^3.1.1" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^1.2.0" + pino-std-serializers "^7.0.0" + process-warning "^4.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + sonic-boom "^4.0.1" + thread-stream "^3.0.0" + pkg-types@^1.0.3, pkg-types@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.1.1.tgz#07b626880749beb607b0c817af63aac1845a73f2" @@ -1446,11 +1531,37 @@ printable-characters@^1.0.42: resolved "https://registry.yarnpkg.com/printable-characters/-/printable-characters-1.0.42.tgz#3f18e977a9bd8eb37fcc4ff5659d7be90868b3d8" integrity sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ== +process-warning@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-4.0.0.tgz#581e3a7a1fb456c5f4fd239f76bce75897682d5a" + integrity sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw== + +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + +quick-format-unescaped@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz#93ef6dd8d3453cbc7970dd614fad4c5954d6b5a7" + integrity sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg== + react-is@^18.0.0: version "18.3.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== +readable-stream@^4.0.0: + version "4.5.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.5.2.tgz#9e7fc4c45099baeed934bff6eb97ba6cf2729e09" + integrity sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + string_decoder "^1.3.0" + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -1458,6 +1569,11 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +real-require@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" + integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== + resolve.exports@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.2.tgz#f8c934b8e6a13f539e38b7098e2e36134f01e800" @@ -1520,7 +1636,12 @@ rollup@^4.13.0: "@rollup/rollup-win32-x64-msvc" "4.18.0" fsevents "~2.3.2" -safe-stable-stringify@^2.4.3: +safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-stable-stringify@^2.3.1, safe-stable-stringify@^2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz#138c84b6f6edb3db5f8ef3ef7115b8f55ccbf886" integrity sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g== @@ -1555,6 +1676,13 @@ signal-exit@^4.1.0: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +sonic-boom@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-4.0.1.tgz#515b7cef2c9290cb362c4536388ddeece07aed30" + integrity sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ== + dependencies: + atomic-sleep "^1.0.0" + source-map-js@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" @@ -1570,6 +1698,11 @@ sourcemap-codec@^1.4.8: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== +split2@^4.0.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" + integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== + stackback@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" @@ -1593,6 +1726,13 @@ stoppable@^1.1.0: resolved "https://registry.yarnpkg.com/stoppable/-/stoppable-1.1.0.tgz#32da568e83ea488b08e4d7ea2c3bcc9d75015d5b" integrity sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw== +string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + strip-final-newline@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" @@ -1610,6 +1750,13 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +thread-stream@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-3.1.0.tgz#4b2ef252a7c215064507d4ef70c05a5e2d34c4f1" + integrity sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A== + dependencies: + real-require "^0.2.0" + tinybench@^2.5.1: version "2.8.0" resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.8.0.tgz#30e19ae3a27508ee18273ffed9ac7018949acd7b" From e40c548f08cc6bb72b08f10dcd66bea531a62e87 Mon Sep 17 00:00:00 2001 From: Victor Palade Date: Sun, 15 Jun 2025 23:08:47 +0200 Subject: [PATCH 5/5] wip Signed-off-by: Victor Palade --- README.md | 4 +- src/commands/github.ts | 1275 ++++++++++++++++++++-------------------- wrangler.toml | 2 +- 3 files changed, 641 insertions(+), 640 deletions(-) diff --git a/README.md b/README.md index a6e2cd8..2ca9b34 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # oscar NOTE: This project is in development, is not a priority and values functionality above -anything else, and as such it's not high quality code. +anything else. Oscar is a bot inspired by [Prow](https://docs.prow.k8s.io/docs/). And just like Prow, Oscar is a bot that helps you manage your GitHub repositories. @@ -40,5 +40,5 @@ easier to set up and maintain. Oscar's advantage is that it can be deployed and used for free on cloudflare workers. The free tier of cloudflare workers is enough to run Oscar for most repositories (100k requests/day). -And in extreme cases, you can always pay for the pro plan or enable load balancing to +And in extreme cases, you can always pay for the pro plan or enable load balancing to spread requests across multiple workers. diff --git a/src/commands/github.ts b/src/commands/github.ts index 07763d0..4aad7eb 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -1,762 +1,762 @@ import { Octokit } from 'octokit'; -import { sleep, Config } from '../common'; -import { addLabels, WorkflowRunStatus, convertConclusionToState } from '../github/githubEvents'; +import { Config, sleep } from '../common'; +import { addLabels, convertConclusionToState, WorkflowRunStatus } from '../github/githubEvents'; type CommandHandler = (command: string, app: Octokit, payload: any, config: Config) => Promise; class CommandRegistry { - private handlers: { [key: string]: CommandHandler; } = {}; - - registerCommand( - commandPrefix: string, handler: CommandHandler) { - this.handlers[commandPrefix] = handler; - } + private handlers: { [key: string]: CommandHandler; } = {}; + registerCommand( + commandPrefix: string, handler: CommandHandler) { + this.handlers[commandPrefix] = handler; + } - // TODO: this function is all over the place, return bool, doesn't throw. - async processCommand(command: string, app: Octokit, payload: any, config: Config): Promise { - const commands = command.split('\n').map(c => c.trim().replace(/\r/g, '')).filter(c => c !== ''); - for (const command of commands) { - if (!command.startsWith('/')) { - continue; - } + // TODO: this function is all over the place, return bool, doesn't throw. + async processCommand(command: string, app: Octokit, payload: any, config: Config): Promise { + const commands = command.split('\n').map(c => c.trim().replace(/\r/g, '')).filter(c => c !== ''); - const commandPrefix = - Object.keys(this.handlers).find(prefix => command.startsWith(prefix)); + for (const command of commands) { + if (!command.startsWith('/')) { + continue; + } - if (commandPrefix) { - try { - await this.handlers[commandPrefix](command, app, payload, config); - } catch (error: any) { - console.error(`Error while processing command ${command}: ${error.message}`); - } - } else { - console.log(`No handler found for command: ${command}`); - } + const commandPrefix = + Object.keys(this.handlers).find(prefix => command.startsWith(prefix)); - await sleep(1000); + if (commandPrefix) { + try { + await this.handlers[commandPrefix](command, app, payload, config); + } catch (error: any) { + console.error(`Error while processing command ${command}: ${error.message}`); } - return true; + } else { + console.log(`No handler found for command: ${command}`); + } + + await sleep(1000); } + return true; + } } function newCommandRegistry(): CommandRegistry { - const commandRegistry = new CommandRegistry(); - - // Command handlers for workflow actions and jobs - commandRegistry.registerCommand('/test', handleRestartWorkflowCommand); - commandRegistry.registerCommand('/stop', handleStopWorkflowCommand); - commandRegistry.registerCommand('/cancel', handleCancelWorkflowCommand); - commandRegistry.registerCommand('/retest', handleRestartWorkflowJobCommand); - commandRegistry.registerCommand('/ok-to-test', handleOkToTestCommand); - - // Command handlers for issues and pull requests - commandRegistry.registerCommand('/label', handleLabelCommand); - commandRegistry.registerCommand('/label-remove', handleLabelRemoveCommand); - commandRegistry.registerCommand('/assign', handleAssigneesCommand); - commandRegistry.registerCommand('/triage', handleTriageCommand); - commandRegistry.registerCommand('/unassign', handleUnassigneesCommand); - commandRegistry.registerCommand('/lock', handleIssueLockCommand); - commandRegistry.registerCommand('/unlock', handleIssueUnlockCommand); - commandRegistry.registerCommand('/milestone', handleMilestoneCommand); - commandRegistry.registerCommand('/pin', handleIssuePin); - commandRegistry.registerCommand('/unpin', handleIssueUnpin); - - commandRegistry.registerCommand('/close', handleCloseCommand); - commandRegistry.registerCommand('/reviewers', handleReviewersCommand); - commandRegistry.registerCommand('/reopen', handleReopenCommand); - commandRegistry.registerCommand('/merge', handlePrMergeCommand); - commandRegistry.registerCommand('/retitle', handleRetitleCommand); - commandRegistry.registerCommand('/hold', handlePrHoldCommand); - commandRegistry.registerCommand('/unhold', handleUnholdCommand); - commandRegistry.registerCommand('/draft', handlePrDraftCommand); - commandRegistry.registerCommand('/approve', handlePrApproveCommand); - commandRegistry.registerCommand('/unapprove', handlePrUnapproveCommand); - - - return commandRegistry; + const commandRegistry = new CommandRegistry(); + + // Command handlers for workflow actions and jobs + commandRegistry.registerCommand('/test', handleRestartWorkflowCommand); + commandRegistry.registerCommand('/stop', handleStopWorkflowCommand); + commandRegistry.registerCommand('/cancel', handleCancelWorkflowCommand); + commandRegistry.registerCommand('/retest', handleRestartWorkflowJobCommand); + commandRegistry.registerCommand('/ok-to-test', handleOkToTestCommand); + + // Command handlers for issues and pull requests + commandRegistry.registerCommand('/label', handleLabelCommand); + commandRegistry.registerCommand('/label-remove', handleLabelRemoveCommand); + commandRegistry.registerCommand('/assign', handleAssigneesCommand); + commandRegistry.registerCommand('/triage', handleTriageCommand); + commandRegistry.registerCommand('/unassign', handleUnassigneesCommand); + commandRegistry.registerCommand('/lock', handleIssueLockCommand); + commandRegistry.registerCommand('/unlock', handleIssueUnlockCommand); + commandRegistry.registerCommand('/milestone', handleMilestoneCommand); + commandRegistry.registerCommand('/pin', handleIssuePin); + commandRegistry.registerCommand('/unpin', handleIssueUnpin); + + commandRegistry.registerCommand('/close', handleCloseCommand); + commandRegistry.registerCommand('/reviewers', handleReviewersCommand); + commandRegistry.registerCommand('/reopen', handleReopenCommand); + commandRegistry.registerCommand('/merge', handlePrMergeCommand); + commandRegistry.registerCommand('/retitle', handleRetitleCommand); + commandRegistry.registerCommand('/hold', handlePrHoldCommand); + commandRegistry.registerCommand('/unhold', handleUnholdCommand); + commandRegistry.registerCommand('/draft', handlePrDraftCommand); + commandRegistry.registerCommand('/approve', handlePrApproveCommand); + commandRegistry.registerCommand('/unapprove', handlePrUnapproveCommand); + + + return commandRegistry; } async function handleLabelCommand(command: string, app: Octokit, payload: any, config: Config): Promise { - const match = command.match(/^\/label(?:\s+(.*))?$/); - const labelsStr = match ? (match[1] || '').trim() : ''; - const labels = labelsStr ? labelsStr.split(' ').map(label => label.trim()) : []; - const issue_number = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + const match = command.match(/^\/label(?:\s+(.*))?$/); + const labelsStr = match ? (match[1] || '').trim() : ''; + const labels = labelsStr ? labelsStr.split(' ').map(label => label.trim()) : []; + const issue_number = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - if (labels.length > 0) { - try { - await addLabels( - app, - payload.repository.owner.login, - payload.repository.name, - issue_number, - labels, - config.labels - ); - } catch (error: any) { - throw new Error(`Error while adding labels to issue #${issue_number}: ${error.message}`); - } + if (labels.length > 0) { + try { + await addLabels( + app, + payload.repository.owner.login, + payload.repository.name, + issue_number, + labels, + config.labels + ); + } catch (error: any) { + throw new Error(`Error while adding labels to issue #${issue_number}: ${error.message}`); + } - console.log(`Added labels "${labels.join(', ')}" to issue #${issue_number}`); - } else { - try { - await app.rest.issues.removeAllLabels({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number, - }); - } catch (error: any) { - throw new Error(`Error while clearing labels from issue #${issue_number}: ${error.message}`); - } + console.log(`Added labels "${labels.join(', ')}" to issue #${issue_number}`); + } else { + try { + await app.rest.issues.removeAllLabels({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number, + }); + } catch (error: any) { + throw new Error(`Error while clearing labels from issue #${issue_number}: ${error.message}`); } + } } async function handleLabelRemoveCommand(command: string, app: Octokit, payload: any): Promise { - const match = command.match(/^\/label-remove(?:\s+(.*))?$/); - const label = match ? (match[1] || '').trim() : ''; + const match = command.match(/^\/label-remove(?:\s+(.*))?$/); + const label = match ? (match[1] || '').trim() : ''; - const issue_number = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + const issue_number = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - await app.rest.issues.removeLabel({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number, - name: label, - }); + await app.rest.issues.removeLabel({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number, + name: label, + }); } async function handleTriageCommand(command: string, app: Octokit, payload: any, config: Config): Promise { - const issue_number = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - try { - await app.rest.issues.removeLabel({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number, - name: 'needs-triage', - }); - } catch (error: any) { - console.log(`Error while removing "needs-triage" label from issue #${issue_number}: ${error.message}`); - } - - try { - await addLabels( - app, - payload.repository.owner.login, - payload.repository.name, - issue_number, - ['triage/accepted'], - config.labels, - ); - - } catch (error: any) { - throw new Error(`Error while adding "triage/accepted" label to issue #${issue_number}: ${error.message}`); - } + const issue_number = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + try { + await app.rest.issues.removeLabel({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number, + name: 'needs-triage', + }); + } catch (error: any) { + console.log(`Error while removing "needs-triage" label from issue #${issue_number}: ${error.message}`); + } + + try { + await addLabels( + app, + payload.repository.owner.login, + payload.repository.name, + issue_number, + ['triage/accepted'], + config.labels, + ); + + } catch (error: any) { + throw new Error(`Error while adding "triage/accepted" label to issue #${issue_number}: ${error.message}`); + } } async function handleRestartWorkflowCommand(command: string, app: Octokit, payload: any): Promise { - const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - - const pullRequest = await app.rest.pulls.get({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - pull_number: pullNumber, - }); + const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + + const pullRequest = await app.rest.pulls.get({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + pull_number: pullNumber, + }); + + const workflows = await app.rest.actions.listRepoWorkflows({ + owner: payload.repository.owner.login, + repo: payload.repository + }); + + if (workflows.data.workflows.length === 0) { + console.log(`No workflows found for the repository: ${payload.repository.name}`); + return; + } + + console.log(`Found ${workflows.data.workflows.length} workflows for the repository: ${payload.repository.name}`); + + for (const workflow of workflows.data.workflows) { + console.log(`Restarting workflow: ${workflow.name}`); + } + + // TODO: fix this, rust.yml is hardcoded + const actions = await app.rest.actions.listWorkflowRunsForRepo({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + workflow_id: 'rust.yml', + status: 'completed', + branch: pullRequest.data.head.ref, + }); + + const lastAction = actions.data.workflow_runs[0]; + if (!lastAction) { + console.log(`No action found for the pull request #${pullNumber}`); + return; + } + await app.rest.actions.reRunWorkflow({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + run_id: lastAction.id, + }); + + await app.rest.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: pullNumber, + body: `Restarted workflow: ["${lastAction.name}"](${lastAction.html_url})`, + }); +} - const workflows = await app.rest.actions.listRepoWorkflows({ - owner: payload.repository.owner.login, - repo: payload.repository - }); +async function handleStopWorkflowCommand(command: string, app: Octokit, payload: any): Promise { + const actionId = command.slice('/stop'.length).trim(); + const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + + const pullRequest = await app.rest.pulls.get({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + pull_number: pullNumber, + }); + + const actions = await app.rest.actions.listWorkflowRunsForRepo({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + workflow_id: 'rust.yml', + status: 'in_progress', + branch: pullRequest.data.head.ref, + }); + + const lastAction = actions.data.workflow_runs[0]; + if (!lastAction) { + console.log(`No action found for the pull request #${payload.issue.number}`); + return; + } + await app.rest.actions.cancelWorkflowRun({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + run_id: lastAction.id, + }); + + await app.rest.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: pullNumber, + body: `Stopped workflow: ["${lastAction.name}"](${lastAction.html_url})`, + }); +} - if (workflows.data.workflows.length === 0) { - console.log(`No workflows found for the repository: ${payload.repository.name}`); - return; - } - console.log(`Found ${workflows.data.workflows.length} workflows for the repository: ${payload.repository.name}`); +async function handleCancelWorkflowCommand(command: string, app: Octokit, payload: any): Promise { + const actionId = command.slice('/cancel'.length).trim(); + const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + + const pullRequest = await app.rest.pulls.get({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + pull_number: pullNumber, + }); + + const actions = await app.rest.actions.listWorkflowRunsForRepo({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + workflow_id: 'rust.yml', + status: 'in_progress', + branch: pullRequest.data.head.ref, + }); + + const lastAction = actions.data.workflow_runs[0]; + if (!lastAction) { + console.log(`No action found for the pull request #${payload.issue.number}`); + return; + } + + await app.rest.actions.forceCancelWorkflowRun({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + run_id: lastAction.id, + }); + + await app.rest.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: pullNumber, + body: `Force canceled workflow: ["${lastAction.name}"](${lastAction.html_url})`, + }); +} - for (const workflow of workflows.data.workflows) { - console.log(`Restarting workflow: ${workflow.name}`); - } - // TODO: fix this, rust.yml is hardcoded - const actions = await app.rest.actions.listWorkflowRunsForRepo({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - workflow_id: 'rust.yml', - status: 'completed', - branch: pullRequest.data.head.ref, - }); +async function handleRestartWorkflowJobCommand(command: string, app: Octokit, payload: any): Promise { + const match = command.match(/^\/retest(?:\s+(.*))?$/); + const jobCommand = match ? (match[1] || '').trim() : ''; + const [workflowName, jobName] = jobCommand.split(' '); + const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - const lastAction = actions.data.workflow_runs[0]; - if (!lastAction) { - console.log(`No action found for the pull request #${pullNumber}`); - return; - } - await app.rest.actions.reRunWorkflow({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - run_id: lastAction.id, + if (jobName) { + const pullRequest = await app.rest.pulls.get({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + pull_number: pullNumber, }); - await app.rest.issues.createComment({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: pullNumber, - body: `Restarted workflow: ["${lastAction.name}"](${lastAction.html_url})`, + const workflow = await app.rest.actions.listWorkflowRunsForRepo({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + workflow_id: workflowName, + branch: pullRequest.data.head.ref, }); -} - -async function handleStopWorkflowCommand(command: string, app: Octokit, payload: any): Promise { - const actionId = command.slice('/stop'.length).trim(); - const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + const workflowId = workflow.data.workflow_runs[0].id; - const pullRequest = await app.rest.pulls.get({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - pull_number: pullNumber, + const jobs = await app.rest.actions.listJobsForWorkflowRun({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + run_id: workflow.data.workflow_runs[0].id, }); - const actions = await app.rest.actions.listWorkflowRunsForRepo({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - workflow_id: 'rust.yml', - status: 'in_progress', - branch: pullRequest.data.head.ref, - }); + const job = jobs.data.jobs.find(j => j.name === jobName); - const lastAction = actions.data.workflow_runs[0]; - if (!lastAction) { - console.log(`No action found for the pull request #${payload.issue.number}`); - return; + if (!job) { + console.log(`No job found for the pull request #${pullNumber}`); + return; } - await app.rest.actions.cancelWorkflowRun({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - run_id: lastAction.id, + + await app.rest.actions.reRunJobForWorkflowRun({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + job_id: job.id, }); await app.rest.issues.createComment({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: pullNumber, - body: `Stopped workflow: ["${lastAction.name}"](${lastAction.html_url})`, - }); + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: pullNumber, + body: `Restarted job: ${job.name} for workflow: ${workflowName}`, + }); + } else { + console.log(`No job name specified in the command: ${command}`); + } } +async function handleOkToTestCommand(_: string, app: Octokit, payload: any, config: Config): Promise { + const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + try { + await addLabels( + app, + payload.repository.owner.login, + payload.repository.name, + pullNumber, + ['ok-to-test'], + config.labels, + ); -async function handleCancelWorkflowCommand(command: string, app: Octokit, payload: any): Promise { - const actionId = command.slice('/cancel'.length).trim(); - const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - - const pullRequest = await app.rest.pulls.get({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - pull_number: pullNumber, - }); - - const actions = await app.rest.actions.listWorkflowRunsForRepo({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - workflow_id: 'rust.yml', - status: 'in_progress', - branch: pullRequest.data.head.ref, - }); - - const lastAction = actions.data.workflow_runs[0]; - if (!lastAction) { - console.log(`No action found for the pull request #${payload.issue.number}`); - return; - } - - await app.rest.actions.forceCancelWorkflowRun({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - run_id: lastAction.id, + await app.rest.issues.removeLabel({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: pullNumber, + name: 'needs-ok-to-test', }); - await app.rest.issues.createComment({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: pullNumber, - body: `Force canceled workflow: ["${lastAction.name}"](${lastAction.html_url})`, + const workflows = await app.rest.actions.listRepoWorkflows({ + owner: payload.repository.owner.login, + repo: payload.repository.name, }); -} - -async function handleRestartWorkflowJobCommand(command: string, app: Octokit, payload: any): Promise { - const match = command.match(/^\/retest(?:\s+(.*))?$/); - const jobCommand = match ? (match[1] || '').trim() : ''; - const [workflowName, jobName] = jobCommand.split(' '); - const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - - if (jobName) { - const pullRequest = await app.rest.pulls.get({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - pull_number: pullNumber, + for (const workflow of workflows.data.workflows) { + try { + await app.rest.actions.createWorkflowDispatch({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + workflow_id: workflow.id, + ref: payload.pull_request.head.ref, }); - const workflow = await app.rest.actions.listWorkflowRunsForRepo({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - workflow_id: workflowName, - branch: pullRequest.data.head.ref, + const runningWorkflows = await app.rest.actions.listWorkflowRuns({ + owner: payload.repository.owner.login, + status: 'in_progress', + repo: payload.repository.name, + workflow_id: workflow.id, + branch: payload.pull_request.head.ref, }); - const workflowId = workflow.data.workflow_runs[0].id; - const jobs = await app.rest.actions.listJobsForWorkflowRun({ + for (const runningWorkflow of runningWorkflows.data.workflow_runs) { + const resp = await app.rest.repos.createCommitStatus({ owner: payload.repository.owner.login, repo: payload.repository.name, - run_id: workflow.data.workflow_runs[0].id, - }); - - const job = jobs.data.jobs.find(j => j.name === jobName); - - if (!job) { - console.log(`No job found for the pull request #${pullNumber}`); - return; + sha: payload.pull_request.head.sha, + state: convertConclusionToState(runningWorkflow.conclusion as WorkflowRunStatus), + target_url: runningWorkflow.html_url, + description: runningWorkflow.status, + context: workflow.name, + }); } - - await app.rest.actions.reRunJobForWorkflowRun({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - job_id: job.id, - }); - - await app.rest.issues.createComment({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: pullNumber, - body: `Restarted job: ${job.name} for workflow: ${workflowName}`, - }); - } else { - console.log(`No job name specified in the command: ${command}`); + } catch (error: any) { + console.log(`Error while rerunning workflow: ${error.message}`); + continue; + } } -} -async function handleOkToTestCommand(_: string, app: Octokit, payload: any, config: Config): Promise { - const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - try { - await addLabels( - app, - payload.repository.owner.login, - payload.repository.name, - pullNumber, - ['ok-to-test'], - config.labels, - ); - - await app.rest.issues.removeLabel({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: pullNumber, - name: 'needs-ok-to-test', - }); - - const workflows = await app.rest.actions.listRepoWorkflows({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - }); - - for (const workflow of workflows.data.workflows) { - try { - await app.rest.actions.createWorkflowDispatch({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - workflow_id: workflow.id, - ref: payload.pull_request.head.ref, - }); - - const runningWorkflows = await app.rest.actions.listWorkflowRuns({ - owner: payload.repository.owner.login, - status: 'in_progress', - repo: payload.repository.name, - workflow_id: workflow.id, - branch: payload.pull_request.head.ref, - }); - - for (const runningWorkflow of runningWorkflows.data.workflow_runs) { - const resp = await app.rest.repos.createCommitStatus({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - sha: payload.pull_request.head.sha, - state: convertConclusionToState(runningWorkflow.conclusion as WorkflowRunStatus), - target_url: runningWorkflow.html_url, - description: runningWorkflow.status, - context: workflow.name, - }); - } - } catch (error: any) { - console.log(`Error while rerunning workflow: ${error.message}`); - continue; - } - } - - } catch (error: any) { - throw new Error(`Error while adding "ok-to-test" label to issue #${pullNumber}: ${error.message}`); - } + } catch (error: any) { + throw new Error(`Error while adding "ok-to-test" label to issue #${pullNumber}: ${error.message}`); + } - console.log(`Commented "Ok to test" on pull request #${pullNumber}`); + console.log(`Commented "Ok to test" on pull request #${pullNumber}`); } async function handleRetitleCommand(command: string, app: Octokit, payload: any): Promise { - const match = command.match(/^\/retitle(?:\s+(.*))?$/); - const title = match ? (match[1] || '').trim() : ''; - const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + const match = command.match(/^\/retitle(?:\s+(.*))?$/); + const title = match ? (match[1] || '').trim() : ''; + const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - if (title) { - await app.rest.issues.update({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - title, - }); + if (title) { + await app.rest.issues.update({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + title, + }); - console.log(`Retitled issue #${payload.issue.number} to "${title}"`); - } else { - console.log(`No title specified in the command: ${command}`); - } + console.log(`Retitled issue #${payload.issue.number} to "${title}"`); + } else { + console.log(`No title specified in the command: ${command}`); + } } async function handleAssigneesCommand(command: string, app: Octokit, payload: any) { - const match = command.match(/^\/assign(?:\s+(.*))?$/) || ''; - let assignees: string[] = []; - - if (match[1]) { - const assigneesStr = match ? match[1].trim() : ''; - assignees = assigneesStr ? assigneesStr.split(' ').map(assignee => assignee.trim().replace(/^@/, '')) : []; - } else { - assignees = [payload.sender.login]; - } - - const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - - try { - await app.rest.issues.addAssignees({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - assignees, - }); - } catch (error: any) { - console.log(`Failed to assign issue #${issueNumber} to ${assignees.join(', ')} due to: ${error.message}`); - } - - console.log(`Assigning issue #${issueNumber} to ${assignees.join(', ')}`); + const match = command.match(/^\/assign(?:\s+(.*))?$/) || ''; + let assignees: string[] = []; + + if (match[1]) { + const assigneesStr = match ? match[1].trim() : ''; + assignees = assigneesStr ? assigneesStr.split(' ').map(assignee => assignee.trim().replace(/^@/, '')) : []; + } else { + assignees = [payload.sender.login]; + } + + const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + + try { + await app.rest.issues.addAssignees({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + assignees, + }); + } catch (error: any) { + console.log(`Failed to assign issue #${issueNumber} to ${assignees.join(', ')} due to: ${error.message}`); + } + + console.log(`Assigning issue #${issueNumber} to ${assignees.join(', ')}`); } async function handleUnassigneesCommand(command: string, app: Octokit, payload: any) { - const user = payload.comment.user.login; - const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + const user = payload.comment.user.login; + const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - await app.rest.issues.removeAssignees({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - assignees: [user], - }); + await app.rest.issues.removeAssignees({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + assignees: [user], + }); - console.log(`Unassigned issue #${issueNumber} from ${user}`); + console.log(`Unassigned issue #${issueNumber} from ${user}`); } async function handleReopenCommand(command: string, app: Octokit, payload: any) { - const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - await app.rest.issues.update({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - state: 'open', - }); + await app.rest.issues.update({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + state: 'open', + }); - console.log(`Reopened issue #${issueNumber}`); + console.log(`Reopened issue #${issueNumber}`); } async function handleCloseCommand(command: string, app: Octokit, payload: any) { - const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - await app.rest.issues.update({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - state: 'closed', - }); - - console.log(`Closed issue #${issueNumber}`); + const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + await app.rest.issues.update({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + state: 'closed', + }); + + console.log(`Closed issue #${issueNumber}`); } async function handlePrHoldCommand(command: string, app: Octokit, payload: any) { - const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - await app.rest.issues.addLabels({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - labels: ['do-not-merge'], - }); + const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + await app.rest.issues.addLabels({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + labels: ['do-not-merge'], + }); } async function handleUnholdCommand(command: string, app: Octokit, payload: any) { - const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - await app.rest.issues.removeLabel({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - name: 'do-not-merge', - }); + const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + await app.rest.issues.removeLabel({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + name: 'do-not-merge', + }); } async function handlePrMergeCommand(command: string, app: Octokit, payload: any) { - const match = command.match(/^\/merge(?:\s+(.*))?$/); - const override = match?.[1]?.trim() || ''; - const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + const match = command.match(/^\/merge(?:\s+(.*))?$/); + const override = match?.[1]?.trim() || ''; + const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - // TODO: Force should only be allowed for specific users, admins more specifically. - if (override === 'force') { - console.log(`Force merging pull request #${issueNumber}`); + // TODO: Force should only be allowed for specific users, admins more specifically. + if (override === 'force') { + console.log(`Force merging pull request #${issueNumber}`); - await app.rest.issues.createComment({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - body: 'Force merging pull request', - }); - - await app.rest.pulls.merge({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - pull_number: issueNumber, - }); - return; - } - - const labels = await app.rest.issues.listLabelsOnIssue({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, + await app.rest.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + body: 'Force merging pull request', }); - if (labels.data.some(label => label.name === 'do-not-merge')) { - console.log(`Skipping merge for pull request #${issueNumber} due to "do-not-merge" label`); - await app.rest.issues.createComment({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - body: 'Skipping merge due to "do-not-merge" label', - }); - return; - } - - if (!labels.data.some(label => label.name === 'approved')) { - console.log(`Skipping merge for pull request #${issueNumber} due to missing "approved" label`); - await app.rest.issues.createComment({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - body: 'Cannot merge due to missing "approved" label', - }); - return; - } - - const pullRequest = await app.rest.pulls.get({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - pull_number: issueNumber, + await app.rest.pulls.merge({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + pull_number: issueNumber, }); + return; + } - if (pullRequest.data.merged) { - console.log(`PR: #${payload.issue.number} already merged`); - await app.rest.issues.createComment({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - body: 'PR already merged', - }); - return; - } + const labels = await app.rest.issues.listLabelsOnIssue({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + }); - const jobs = await app.rest.checks.listForRef({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - ref: pullRequest.data.head.sha, + if (labels.data.some(label => label.name === 'do-not-merge')) { + console.log(`Skipping merge for pull request #${issueNumber} due to "do-not-merge" label`); + await app.rest.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + body: 'Skipping merge due to "do-not-merge" label', }); + return; + } - for (const job of jobs.data.check_runs) { - switch (job.status) { - case 'queued': - case 'pending': - case 'requested': - case 'waiting': - case 'in_progress': - console.log(`Skipping merge for pull request #${issueNumber} due to pending checks`); - await app.rest.issues.createComment({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - body: 'Skipping merge due to pending checks', - }); - return; - case 'completed': - if (job.conclusion === 'failure') { - console.log(`Skipping merge for pull request #${issueNumber} due to failed checks`); - await app.rest.issues.createComment({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - body: 'Merge not possible due to failed checks', - }); - return; - } - break; - } - } - - if (pullRequest.data.draft) { - console.log(`Skipping merge for pull request #${issueNumber} due to draft status`); + if (!labels.data.some(label => label.name === 'approved')) { + console.log(`Skipping merge for pull request #${issueNumber} due to missing "approved" label`); + await app.rest.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + body: 'Cannot merge due to missing "approved" label', + }); + return; + } + + const pullRequest = await app.rest.pulls.get({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + pull_number: issueNumber, + }); + + if (pullRequest.data.merged) { + console.log(`PR: #${payload.issue.number} already merged`); + await app.rest.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + body: 'PR already merged', + }); + return; + } + + const jobs = await app.rest.checks.listForRef({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + ref: pullRequest.data.head.sha, + }); + + for (const job of jobs.data.check_runs) { + switch (job.status) { + case 'queued': + case 'pending': + case 'requested': + case 'waiting': + case 'in_progress': + console.log(`Skipping merge for pull request #${issueNumber} due to pending checks`); await app.rest.issues.createComment({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - body: 'Skipping merge due to draft status', + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + body: 'Skipping merge due to pending checks', }); return; - } - - if (pullRequest.data.mergeable === false) { - console.log(`Skipping merge for pull request #${issueNumber} due to conflicts`); - await app.rest.issues.createComment({ + case 'completed': + if (job.conclusion === 'failure') { + console.log(`Skipping merge for pull request #${issueNumber} due to failed checks`); + await app.rest.issues.createComment({ owner: payload.repository.owner.login, repo: payload.repository.name, issue_number: issueNumber, - body: 'Skipping merge due to conflicts', - }); - return; + body: 'Merge not possible due to failed checks', + }); + return; + } + break; } + } - await app.rest.pulls.merge({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - pull_number: issueNumber, + if (pullRequest.data.draft) { + console.log(`Skipping merge for pull request #${issueNumber} due to draft status`); + await app.rest.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + body: 'Skipping merge due to draft status', + }); + return; + } + + if (pullRequest.data.mergeable === false) { + console.log(`Skipping merge for pull request #${issueNumber} due to conflicts`); + await app.rest.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + body: 'Skipping merge due to conflicts', }); + return; + } + + await app.rest.pulls.merge({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + pull_number: issueNumber, + }); - console.log(`Merged pull request #${issueNumber}`); + console.log(`Merged pull request #${issueNumber}`); } async function handlePrDraftCommand(command: string, app: Octokit, payload: any) { - const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - await app.rest.pulls.update({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - pull_number: pullNumber, - draft: true, - }); - - console.log(`Marked pull request #${payload.issue.number} as draft`); + const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + await app.rest.pulls.update({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + pull_number: pullNumber, + draft: true, + }); + + console.log(`Marked pull request #${payload.issue.number} as draft`); } async function handlePrApproveCommand(command: string, app: Octokit, payload: any) { - const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - await app.rest.issues.addLabels({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - labels: ['approved'], - }); - - console.log(`Approved pull request #${issueNumber}`); + const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + await app.rest.issues.addLabels({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + labels: ['approved'], + }); + + console.log(`Approved pull request #${issueNumber}`); } async function handlePrUnapproveCommand(command: string, app: Octokit, payload: any) { - const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - await app.rest.pulls.createReview({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - pull_number: pullNumber, - }); - - await app.rest.issues.removeLabel({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: pullNumber, - name: 'approved', - }); - - console.log(`Unapproved pull request #${pullNumber}`); + const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + await app.rest.pulls.createReview({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + pull_number: pullNumber, + }); + + await app.rest.issues.removeLabel({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: pullNumber, + name: 'approved', + }); + + console.log(`Unapproved pull request #${pullNumber}`); } async function handleIssueLockCommand(command: string, app: Octokit, payload: any): Promise { - // TODO: should be limited to admins - const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - await app.rest.issues.lock({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - }); - - console.log(`Locked issue #${issueNumber}`); + // TODO: should be limited to admins + const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + await app.rest.issues.lock({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + }); + + console.log(`Locked issue #${issueNumber}`); } async function handleIssueUnlockCommand(command: string, app: Octokit, payload: any): Promise { - const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - // TODO: should be limited to admins - await app.rest.issues.unlock({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - }); - - console.log(`Unlocked issue #${issueNumber}`); + const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + // TODO: should be limited to admins + await app.rest.issues.unlock({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + }); + + console.log(`Unlocked issue #${issueNumber}`); } async function handleMilestoneCommand(command: string, app: Octokit, payload: any): Promise { - const match = command.match(/^\/milestone(?:\s+(.*))?$/); - const milestone = match ? (match[1] || '').trim() : ''; - const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + const match = command.match(/^\/milestone(?:\s+(.*))?$/); + const milestone = match ? (match[1] || '').trim() : ''; + const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - if (milestone === 'clear') { - await app.rest.issues.update({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - milestone: null, - }); + if (milestone === 'clear') { + await app.rest.issues.update({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + milestone: null, + }); - console.log(`Cleared milestone for issue #${payload.issue.number}`); - return; - } + console.log(`Cleared milestone for issue #${payload.issue.number}`); + return; + } - if (milestone) { - const milestones = await app.rest.issues.listMilestones({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - }); - - const milestoneId = milestones.data.find(m => m.title === milestone)?.number; - if (!milestoneId) { - console.log(`No milestone found for the title: ${milestone}`); - return; - } + if (milestone) { + const milestones = await app.rest.issues.listMilestones({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + }); - await app.rest.issues.update({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number: issueNumber, - milestone: milestoneId, - }); - console.log(`Set milestone for issue #${issueNumber} to ${milestone}`); - } else { - console.log(`No milestone specified in the command: ${command}`); + const milestoneId = milestones.data.find(m => m.title === milestone)?.number; + if (!milestoneId) { + console.log(`No milestone found for the title: ${milestone}`); + return; } + + await app.rest.issues.update({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + milestone: milestoneId, + }); + console.log(`Set milestone for issue #${issueNumber} to ${milestone}`); + } else { + console.log(`No milestone specified in the command: ${command}`); + } } async function handleIssuePin(command: string, app: Octokit, payload: any): Promise { - // NOTE: seems that rest does not have a valid way to update the pinned status of an issue - const issueId = payload.issue.node_id; - const mutation = ` + // NOTE: seems that rest does not have a valid way to update the pinned status of an issue + const issueId = payload.issue.node_id; + const mutation = ` mutation($issueId: ID!) { pinIssue(input: { issueId: $issueId }) { issue { @@ -766,17 +766,17 @@ async function handleIssuePin(command: string, app: Octokit, payload: any): Prom } `; - await app.graphql(mutation, { - issueId: payload.issue.node_id, - }); + await app.graphql(mutation, { + issueId: payload.issue.node_id, + }); - console.log(`Pinned issue #${payload.issue.number}`); + console.log(`Pinned issue #${payload.issue.number}`); } async function handleIssueUnpin(command: string, app: Octokit, payload: any): Promise { - // NOTE: seems that rest does not have a valid way to update the pinned status of an issue - const issueId = payload.issue.node_id; - const mutation = ` + // NOTE: seems that rest does not have a valid way to update the pinned status of an issue + const issueId = payload.issue.node_id; + const mutation = ` mutation($issueId: ID!) { unpinIssue(input: { issueId: $issueId }) { issue { @@ -786,27 +786,28 @@ async function handleIssueUnpin(command: string, app: Octokit, payload: any): Pr } `; - await app.graphql(mutation, { - issueId: payload.issue.node_id, - }); + await app.graphql(mutation, { + issueId: payload.issue.node_id, + }); - console.log(`Unpinned issue #${payload.issue.number}`); + console.log(`Unpinned issue #${payload.issue.number}`); } async function handleReviewersCommand(command: string, app: Octokit, payload: any): Promise { - const match = command.match(/^\/reviewers(?:\s+(.*))?$/); - const reviewersStr = match ? match[1].trim() : ''; - const reviewers = reviewersStr ? reviewersStr.split(' ').map(reviewer => reviewer.trim().replace(/^@/, '')) : []; - const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; - - await app.rest.pulls.requestReviewers({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - pull_number: issueNumber, - reviewers, - }); - - console.log(`Request review for pull request #${issueNumber} from ${reviewers.join(', ')}`); + const match = command.match(/^\/reviewers(?:\s+(.*))?$/); + const reviewersStr = match ? match[1].trim() : ''; + const reviewers = reviewersStr ? reviewersStr.split(' ').map(reviewer => reviewer.trim().replace(/^@/, '')) : []; + const issueNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + + await app.rest.pulls.requestReviewers({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + pull_number: issueNumber, + reviewers, + }); + + console.log(`Request review for pull request #${issueNumber} from ${reviewers.join(', ')}`); } -export { newCommandRegistry }; \ No newline at end of file +export { newCommandRegistry }; + diff --git a/wrangler.toml b/wrangler.toml index ec9ee14..dbe66f3 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -7,7 +7,7 @@ workers_dev = false [env.prd] routes = [ - { pattern = "oscar.svc.cloudflavor.dev", zone_name = "cloudflavor.dev", custom_domain = true }, + { pattern = "oscar.svc.cloudflavor.dev", zone_name = "cloudflavor.dev", custom_domain = true }, ] [[env.prd.unsafe.bindings]] namespace_id = "1505"