diff --git a/.vscode/launch.json b/.vscode/launch.json index b8d595b..65d3c52 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,7 @@ { "type": "node", "request": "launch", + "console": "integratedTerminal", "name": "Debug Tests", "program": "${workspaceRoot}/node_modules/.bin/jest", "cwd": "${workspaceRoot}", diff --git a/GitGitGadget/index.js b/GitGitGadget/index.js index d5cff66..c26145e 100644 --- a/GitGitGadget/index.js +++ b/GitGitGadget/index.js @@ -10,9 +10,7 @@ */ const { validateGitHubWebHook } = require('./validate-github-webhook'); -const { triggerAzurePipeline } = require('./trigger-azure-pipeline'); - -const { triggerWorkflowDispatch } = require('./trigger-workflow-dispatch') +const { triggerWorkflowDispatch, listWorkflowRuns } = require('./trigger-workflow-dispatch') module.exports = async (context, req) => { try { @@ -29,56 +27,74 @@ module.exports = async (context, req) => { try { /* - * The Azure Pipeline needs to be installed as a PR build on _the very - * same_ repository that triggers this function. That is, when the - * Azure Function triggers GitGitGadget for gitgitgadget/git, it needs - * to know that pipelineId 3 is installed on gitgitgadget/git, and - * trigger that very pipeline. - * - * So whenever we extend GitGitGadget to handle another repository, we - * will have to add an Azure Pipeline, install it on that repository as - * a PR build, and add the information here. + * For various reasons, the GitGitGadget GitHub App can be installed + * on any random repository. However, GitGitGadget only wants to support + * the `gitgitgadget/git` and the `git/git` repository (with the + * `dscho/git` one thrown in for debugging purposes). */ - const pipelines = { - 'dscho': 12, - 'git': 13, - 'gitgitgadget': 3, - }; + const orgs = ['gitgitgadget', 'git', 'dscho'] + const a = [context, undefined, 'gitgitgadget-workflows', 'gitgitgadget-workflows'] const eventType = context.req.headers['x-github-event']; context.log(`Got eventType: ${eventType}`); const repositoryOwner = req.body.repository.owner.login; - if (pipelines[repositoryOwner] === undefined) { + if (!orgs.includes(repositoryOwner)) { context.res = { status: 403, body: 'Refusing to work on a repository other than gitgitgadget/git or git/git' }; + } else if (eventType === 'pull_request') { + if (req.body.action !== 'opened' && req.body.action !== 'synchronize') { + context.res = { + body: `Ignoring pull request action: ${req.body.action}`, + }; + } else { + const run = await triggerWorkflowDispatch(...a, 'handle-pr-push.yml', 'main', { + 'pr-url': req.body.pull_request.html_url + }) + context.res = { body: `Okay, triggered ${run.html_url}!` }; + } } else if ((new Set(['check_run', 'status']).has(eventType))) { context.res = { body: `Ignored event type: ${eventType}`, }; } else if (eventType === 'push') { - if (req.body.repository.full_name !== 'git/git') { + if (req.body.repository.full_name ==='gitgitgadget/git-mailing-list-mirror') { + context.res = { body: `push(${req.body.ref} in ${req.body.repository.full_name}): ` } + if (req.body.ref === 'refs/heads/lore-1') { + const queued = await listWorkflowRuns(...a, 'handle-new-mails.yml', 'queued') + if (queued.length) { + context.res.body += [ + `skip triggering handle-new-emails, ${queued} already queued:`, + queued.map(e => `- ${e.html_url}`) + ].join('\n') + } else { + const run = await triggerWorkflowDispatch(...a, 'handle-new-mails.yml', 'main') + context.res.body += `triggered ${run.html_url}` + } + } else context.res.body += `Ignoring non-default branches` + } else if (req.body.repository.full_name !== 'git/git') { context.res = { body: `Ignoring pushes to ${req.body.repository.full_name}` } } else { const run = await triggerWorkflowDispatch( - context, - undefined, - 'gitgitgadget-workflows', - 'gitgitgadget-workflows', + ...a, 'sync-ref.yml', 'main', { ref: req.body.ref } ) - context.res = { body: `push(${req.body.ref}): triggered ${run.html_url}` } + const extra = [] + if (req.body.ref === 'refs/heads/seen') { + for (const workflow of ['update-prs.yml', 'update-mail-to-commit-notes.yml']) { + if ((await listWorkflowRuns(...a, workflow, 'main', 'queued')).length === 0) { + const run = await triggerWorkflowDispatch(...a, workflow, 'main') + extra.push(` and ${run.html_url}`) + } + } + } + context.res = { body: `push(${req.body.ref}): triggered ${run.html_url}${extra.join('')}` } } } else if (eventType === 'issue_comment') { - const triggerToken = process.env['GITGITGADGET_TRIGGER_TOKEN']; - if (!triggerToken) { - throw new Error('No configured trigger token'); - } - const comment = req.body.comment; const prNumber = req.body.issue.number; if (!comment || !comment.id || !prNumber) { @@ -100,19 +116,13 @@ module.exports = async (context, req) => { return; } - const sourceBranch = `refs/pull/${prNumber}/head`; - const parameters = { - 'pr.comment.id': comment.id, - }; - const pipelineId = pipelines[repositoryOwner]; - if (!pipelineId || pipelineId < 1) - throw new Error(`No pipeline set up for org ${repositoryOwner}`); - context.log(`Queuing with branch ${sourceBranch} and parameters ${JSON.stringify(parameters)}`); - await triggerAzurePipeline(triggerToken, 'gitgitgadget', 'git', pipelineId, sourceBranch, parameters); + const run = await triggerWorkflowDispatch(...a, 'handle-pr-comment.yml', 'main', { + 'pr-comment-url': comment.html_url + }) context.res = { // status: 200, /* Defaults to 200 */ - body: 'Okay!', + body: `Okay, triggered ${run.html_url}!`, }; } else { context.log(`Unhandled request:\n${JSON.stringify(req, null, 4)}`); diff --git a/GitGitGadget/trigger-azure-pipeline.js b/GitGitGadget/trigger-azure-pipeline.js deleted file mode 100644 index 93eebfb..0000000 --- a/GitGitGadget/trigger-azure-pipeline.js +++ /dev/null @@ -1,48 +0,0 @@ -const https = require('https'); - -const triggerAzurePipeline = async (token, organization, project, buildDefinitionId, sourceBranch, parameters) => { - const auth = Buffer.from('PAT:' + token).toString('base64'); - const headers = { - 'Accept': 'application/json; api-version=5.0-preview.5; excludeUrls=true', - 'Authorization': 'Basic ' + auth, - }; - const json = JSON.stringify({ - 'definition': { 'id': buildDefinitionId }, - 'sourceBranch': sourceBranch, - 'parameters': JSON.stringify(parameters), - }); - headers['Content-Type'] = 'application/json'; - headers['Content-Length'] = Buffer.byteLength(json); - - const requestOptions = { - host: 'dev.azure.com', - port: '443', - path: `/${organization}/${project}/_apis/build/builds?ignoreWarnings=false&api-version=5.0-preview.5`, - method: 'POST', - headers: headers - }; - - return new Promise((resolve, reject) => { - const handleResponse = (res) => { - res.setEncoding('utf8'); - var response = ''; - res.on('data', (chunk) => { - response += chunk; - }); - res.on('end', () => { - resolve(JSON.parse(response)); - }); - res.on('error', (err) => { - reject(err); - }) - }; - - const request = https.request(requestOptions, handleResponse); - request.write(json); - request.end(); - }); -} - -module.exports = { - triggerAzurePipeline -} \ No newline at end of file diff --git a/GitGitGadget/trigger-workflow-dispatch.js b/GitGitGadget/trigger-workflow-dispatch.js index 1ee8f13..fd2ba7f 100644 --- a/GitGitGadget/trigger-workflow-dispatch.js +++ b/GitGitGadget/trigger-workflow-dispatch.js @@ -56,7 +56,34 @@ const triggerWorkflowDispatch = async (context, token, owner, repo, workflow_id, return runs[0] } +const listWorkflowRuns = async (context, token, owner, repo, workflow_id, branch, status) => { + if (token === undefined) { + const { getInstallationIdForRepo } = require('./get-installation-id-for-repo') + const installationID = await getInstallationIdForRepo(context, owner, repo) + + const { getInstallationAccessToken } = require('./get-installation-access-token') + token = await getInstallationAccessToken(context, installationID) + } + + const query = [ + branch && `branch=${branch}`, + status && `status=${status}`, + ] + .filter((e) => e) + .map((e, i) => `${i === 0 ? '?' : '&'}${e}`) + .join('') + + const result = await gitHubAPIRequest( + context, + token, + 'GET', + `/repos/${owner}/${repo}/actions/workflows/${workflow_id}/runs${query}`, + ) + return result.workflow_runs +} + module.exports = { triggerWorkflowDispatch, - waitForWorkflowRun + waitForWorkflowRun, + listWorkflowRuns, } diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 31e3059..7db164e 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -1,12 +1,18 @@ -const mockTriggerWorkflowDispatch = jest.fn(async (_context, _token, owner, repo, workflow_id, ref, inputs) => { - expect(`${owner}/${repo}`).toEqual('gitgitgadget-workflows/gitgitgadget-workflows') - expect(workflow_id).toEqual('sync-ref.yml') - expect(ref).toEqual('main') - expect(inputs).toEqual({ ref: 'refs/heads/next' }) - return { html_url: ''} +const mockTriggerWorkflowDispatch = jest.fn(async (_context, _token, _owner, _repo, workflow_id, ref, inputs) => { + return { html_url: `` } }) jest.mock('../GitGitGadget/trigger-workflow-dispatch', () => ({ - triggerWorkflowDispatch: mockTriggerWorkflowDispatch + triggerWorkflowDispatch: mockTriggerWorkflowDispatch, + listWorkflowRuns: jest.fn(async (_context, _token, _owner, _repo, workflow_id, branch, status) => { + if (workflow_id === 'update-prs.yml' && branch === 'main' && status === 'queued') { + // pretend that `update-prs` is clogged up, for whatever reason + return [ + { id: 1, head_branch: 'main', status: 'queued', html_url: '' }, + { id: 2, head_branch: 'main', status: 'queued', html_url: '' } + ] + } + return [] + }) })) const index = require('../GitGitGadget/index') @@ -147,17 +153,10 @@ const testIssueComment = (comment, repoOwner, fn) => { testIssueComment('/test', async (context) => { expect(context.done).toHaveBeenCalledTimes(1) expect(context.res).toEqual({ - body: 'Okay!' - }) - expect(mockRequest.write).toHaveBeenCalledTimes(1) - expect(JSON.parse(mockRequest.write.mock.calls[0][0])).toEqual({ - definition: { - id: 3 - }, - sourceBranch: 'refs/pull/1886743660/head', - parameters: '{"pr.comment.id":27988538471837300}' + body: 'Okay, triggered !' }) - expect(mockRequest.end).toHaveBeenCalledTimes(1) + expect(mockRequest.write).not.toHaveBeenCalled() + expect(mockRequest.end).not.toHaveBeenCalled() }) testIssueComment('/verify-repository', 'nope', (context) => { @@ -197,7 +196,7 @@ testWebhookPayload('react to `next` being pushed to git/git', 'push', { } }, (context) => { expect(context.res).toEqual({ - body: 'push(refs/heads/next): triggered ' + body: 'push(refs/heads/next): triggered ' }) expect(mockTriggerWorkflowDispatch).toHaveBeenCalledTimes(1) expect(mockTriggerWorkflowDispatch.mock.calls[0]).toEqual([ @@ -210,4 +209,100 @@ testWebhookPayload('react to `next` being pushed to git/git', 'push', { ref: 'refs/heads/next' } ]) +}) + +testWebhookPayload('react to `seen` being pushed to git/git', 'push', { + ref: 'refs/heads/seen', + repository: { + full_name: 'git/git', + owner: { + login: 'git' + } + } +}, (context) => { + expect(context.res).toEqual({ + body: [ + 'push(refs/heads/seen):', + 'triggered ', + 'and ' + ].join(' ') + }) + // we expect `update-prs` _not_ to be triggered here because we pretend that it is already queued + expect(mockTriggerWorkflowDispatch).toHaveBeenCalledTimes(2) + expect(mockTriggerWorkflowDispatch.mock.calls[0]).toEqual([ + context, + undefined, + 'gitgitgadget-workflows', + 'gitgitgadget-workflows', + 'sync-ref.yml', + 'main', { + ref: 'refs/heads/seen' + } + ]) + expect(mockTriggerWorkflowDispatch.mock.calls[1]).toEqual([ + context, + undefined, + 'gitgitgadget-workflows', + 'gitgitgadget-workflows', + 'update-mail-to-commit-notes.yml', + 'main', + undefined + ]) +}) + +testWebhookPayload('react to `lore-1` being pushed to https://github.com/gitgitgadget/git-mailing-list-mirror', 'push', { + ref: 'refs/heads/lore-1', + repository: { + full_name: 'gitgitgadget/git-mailing-list-mirror', + owner: { + login: 'gitgitgadget' + } + } +}, (context) => { + expect(context.res).toEqual({ + body: [ + 'push(refs/heads/lore-1 in gitgitgadget/git-mailing-list-mirror):', + 'triggered ' + ].join(' ') + }) + expect(mockTriggerWorkflowDispatch).toHaveBeenCalledTimes(1) + expect(mockTriggerWorkflowDispatch.mock.calls[0]).toEqual([ + context, + undefined, + 'gitgitgadget-workflows', + 'gitgitgadget-workflows', + 'handle-new-mails.yml', + 'main', + undefined + ]) +}) +testWebhookPayload('react to PR push', 'pull_request', { + action: 'synchronize', + pull_request: { + html_url: 'https://github.com/gitgitgadget/git/pull/1956', + }, + repository: { + full_name: 'gitgitgadget/git', + owner: { + login: 'gitgitgadget' + } + } +}, (context) => { + expect(context.res).toEqual({ + body: [ + 'Okay, triggered !' + ].join(' ') + }) + expect(mockTriggerWorkflowDispatch).toHaveBeenCalledTimes(1) + expect(mockTriggerWorkflowDispatch.mock.calls[0]).toEqual([ + context, + undefined, + 'gitgitgadget-workflows', + 'gitgitgadget-workflows', + 'handle-pr-push.yml', + 'main', { + 'pr-url': 'https://github.com/gitgitgadget/git/pull/1956', + } + ]) }) \ No newline at end of file diff --git a/__tests__/trigger-workflow-dispatch.test.js b/__tests__/trigger-workflow-dispatch.test.js index 36c7ebe..64e0221 100644 --- a/__tests__/trigger-workflow-dispatch.test.js +++ b/__tests__/trigger-workflow-dispatch.test.js @@ -16,11 +16,19 @@ const mockHTTPSRequest = jest.fn(async (_context, _hostname, method, requestPath ] } } + if (method === 'GET' && requestPath === '/repos/hello/world/actions/workflows/the-workflow.yml/runs?branch=main&status=queued') { + return { + workflow_runs: [ + { id: 1, head_branch: 'main', status: 'queued' }, + { id: 2, head_branch: 'main', status: 'queued' }, + ] + } + } throw new Error(`Unexpected requestPath: ${method} '${requestPath}'`) }) jest.mock('../GitGitGadget/https-request', () => { return { httpsRequest: mockHTTPSRequest } }) -const { triggerWorkflowDispatch } = require('../GitGitGadget/trigger-workflow-dispatch') +const { triggerWorkflowDispatch, listWorkflowRuns } = require('../GitGitGadget/trigger-workflow-dispatch') const { generateKeyPairSync } = require('crypto') @@ -44,4 +52,10 @@ test('trigger a workflow_dispatch event and wait for workflow run', async () => path: '.github/workflows/the-workflow.yml', breadcrumb: true }) +}) + +test('list workflow runs', async () => { + const context = {} + const runs = await listWorkflowRuns(context, 'my-token', 'hello', 'world', 'the-workflow.yml', 'main', 'queued') + expect(runs.length).toEqual(2) }) \ No newline at end of file