diff --git a/README.md b/README.md index b60ecc1..2ca9b34 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. + 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 @@ -15,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. @@ -30,12 +34,11 @@ 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 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/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..4aad7eb 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -1,663 +1,762 @@ import { Octokit } from 'octokit'; -import { sleep } from '../common'; +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]: (command: string, app: Octokit, payload: any) => Promise; } = {}; + private handlers: { [key: string]: CommandHandler; } = {}; - registerCommand( - commandPrefix: string, - handler: (command: string, app: Octokit, payload: any) - => Promise) { - this.handlers[commandPrefix] = handler; - } + registerCommand( + commandPrefix: string, handler: CommandHandler) { + this.handlers[commandPrefix] = handler; + } - async processCommand(command: string, app: Octokit, payload: any): 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; - } - - const commandPrefix = - Object.keys(this.handlers).find(prefix => command.startsWith(prefix)); - - if (commandPrefix) { - try { - await this.handlers[commandPrefix](command, app, payload); - } catch (error: any) { - console.error(`Error while processing command: ${command}`, error.message); - } - } 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('/restart-workflow', handleRestartWorkflowCommand); - commandRegistry.registerCommand('/stop-workflow', handleStopWorkflowCommand); - commandRegistry.registerCommand('/cancel-workflow', handleCancelWorkflowCommand); - commandRegistry.registerCommand('/restart-job', handleRestartWorkflowJobCommand); - - // 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): Promise { - const match = command.match(/^\/label(?:\s+(.*))?$/); - const labelsStr = match ? (match[1] || '').trim() : ''; - const labels = labelsStr ? labelsStr.split(' ').map(label => label.trim()) : []; + // 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; + } - const issue_number = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; + const commandPrefix = + Object.keys(this.handlers).find(prefix => command.startsWith(prefix)); - if (labels.length > 0) { - await app.rest.issues.addLabels({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number, - labels, - }); + 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}`); + } - 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}`); + await sleep(1000); } + return true; + } } -async function handleLabelRemoveCommand(command: string, app: Octokit, payload: any): Promise { - const match = command.match(/^\/label-remove(?:\s+(.*))?$/); - const label = match ? (match[1] || '').trim() : ''; +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; +} +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 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}`); + } - await app.rest.issues.removeLabel({ + 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, - name: label, - }); + }); + } catch (error: any) { + throw new Error(`Error while clearing labels from issue #${issue_number}: ${error.message}`); + } + } } -async function handleTriageCommand(command: string, app: Octokit, payload: any): Promise { +async function handleLabelRemoveCommand(command: string, app: Octokit, payload: any): Promise { + 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; - await app.rest.issues.removeLabel({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - issue_number, - name: 'needs-triage', - }); + const issue_number = 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, - labels: ['triage/accepted'], - }); + await app.rest.issues.removeLabel({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number, + name: label, + }); +} - console.log(`Added "needs-triage" label to issue #${issue_number}`); +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}`); + } } 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({ - 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: '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 worflow: ["${lastAction.name}"](${lastAction.html_url})`, - }); + 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})`, + }); } async function handleStopWorkflowCommand(command: string, app: Octokit, payload: any): Promise { - const actionId = command.slice('/stop-workflow'.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 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})`, + }); +} - 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 worflow: ["${lastAction.name}"](${lastAction.html_url})`, - }); +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})`, + }); } -async function handleCancelWorkflowCommand(command: string, app: Octokit, payload: any): Promise { - const actionId = command.slice('/cancel-workflow'.length).trim(); - const pullNumber = payload.issue?.number ? payload.issue.number : payload.pull_request?.number; +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, + 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 workflow = await app.rest.actions.listWorkflowRunsForRepo({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + workflow_id: workflowName, + branch: pullRequest.data.head.ref, }); + const workflowId = workflow.data.workflow_runs[0].id; - const lastAction = actions.data.workflow_runs[0]; - if (!lastAction) { - console.log(`No action found for the pull request #${payload.issue.number}`); - return; + 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 job = jobs.data.jobs.find(j => j.name === jobName); + + if (!job) { + console.log(`No job found for the pull request #${pullNumber}`); + return; } - await app.rest.actions.forceCancelWorkflowRun({ - 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: `Force canceled 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 handleRestartWorkflowJobCommand(command: string, app: Octokit, payload: any): Promise { - const match = command.match(/^\/restart-job(?:\s+(.*))?$/); - const jobCommand = match ? (match[1] || '').trim() : ''; - const [workflowName, jobName] = jobCommand.split(' '); - const pullNumber = 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: pullNumber, + name: 'needs-ok-to-test', + }); - if (jobName) { - 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.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 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, + }); } + } catch (error: any) { + console.log(`Error while rerunning workflow: ${error.message}`); + continue; + } + } - await app.rest.actions.reRunJobForWorkflowRun({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - job_id: job.id, - }); + } catch (error: any) { + throw new Error(`Error while adding "ok-to-test" label to issue #${pullNumber}: ${error.message}`); + } - 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}`); - } + + 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: 'Skipping 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(`Skipping merge for pull request #${payload.issue.number} due to 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', - }); - 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; + } - console.log(`Merged pull request #${issueNumber}`); + 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}`); } 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, - }); - - console.log(`Cleared milestone for issue #${payload.issue.number}`); - return; - } + if (milestone === 'clear') { + await app.rest.issues.update({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issueNumber, + milestone: null, + }); - if (milestone) { - const milestones = await app.rest.issues.listMilestones({ - owner: payload.repository.owner.login, - repo: payload.repository.name, - }); + console.log(`Cleared milestone for issue #${payload.issue.number}`); + return; + } - 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 { @@ -667,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 { @@ -687,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/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 4b1ac70..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,20 +23,15 @@ 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 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) { 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 }); 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/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" 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"