Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 6 additions & 93 deletions __tests__/bundle/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import fs from 'node:fs'
import process from 'node:process'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

import issueCommentEvent from '../fixtures/issues/issueCommentEvent.json'
import issuesLabeledEvent from '../fixtures/issues/issuesLabeledEvent.json'
import labelFileContents from '../fixtures/labels/labelFileContentsResp.json'
import checkSuiteCompletedEvent from '../fixtures/pullReq/checkSuiteCompletedEvent.json'
Expand All @@ -14,22 +13,14 @@ import pullReqOpenedEvent from '../fixtures/pullReq/pullReqOpenedEvent.json'
import pullReqReviewSubmittedEvent from '../fixtures/pullReq/pullReqReviewSubmittedEvent.json'
import { blobSha, prCommentEvent, pullBody } from '../utils/ownersFixtures'
import { start } from './fakeGithub'
import { comment, configReads, helpersFor, membershipReads, ownersProbe, ownersReads, queueRead, repo, token } from './helpers'
import { bundlePath, runBundle } from './runBundle'

vi.setConfig({ testTimeout: 30_000 })

const repo = '/repos/Codertocat/Hello-World'
const token = { 'github-token': 'some-token' }
// the read every label command makes before it applies a label
const labelsRead = `GET ${repo}/labels?per_page=100`

function comment(body: string, author = issueCommentEvent.issue.user.login) {
const payload = structuredClone(issueCommentEvent)
payload.comment.body = body
payload.issue.user.login = author
return payload
}

function openPr(labels: string[], overrides: Record<string, unknown> = {}) {
const pr = structuredClone(pullReqListPulls[0])
return { ...pr, labels: labels.map(name => ({ name })), ...overrides }
Expand All @@ -45,31 +36,9 @@ function yamlFile(text: string) {
return file
}

const orgConfigRepos = ['.project', '.github']
const repoConfigFiles = [
'.github/prow.yaml',
'.github/prowlabels.yaml',
'prow.yaml',
'.prowlabels.yaml',
'.github/prow.yml',
'.github/prowlabels.yml',
'prow.yml',
'.prowlabels.yml',
]

// the configuration reads the loader makes before it finds `org` and `repo` (or gives up on a tier)
function configReads({ org, repo: file }: { org?: string, repo?: string } = {}): string[] {
const orgReads = orgConfigRepos
.slice(0, org ? orgConfigRepos.indexOf(org) + 1 : orgConfigRepos.length)
.map(name => `GET /repos/Codertocat/${name}/contents/prow.yaml`)
const repoReads = repoConfigFiles
.slice(0, file ? repoConfigFiles.indexOf(file) + 1 : repoConfigFiles.length)
.map(path => `GET ${repo}/contents/${encodeURIComponent(path)}`)
return [...orgReads, ...repoReads]
}

describe('dist/index.js', () => {
let gh: FakeGithub
const { expectCommandThenConfig, expectRequests, routeOwners } = helpersFor(() => gh)

beforeAll(async () => {
gh = await start()
Expand All @@ -79,62 +48,6 @@ describe('dist/index.js', () => {
afterEach(() => gh.reset())
afterAll(() => gh.close())

// the org and repo tiers are probed concurrently, so the reads have no fixed order among themselves
function expectRequests(reads: string[], rest: string[]) {
const calls = gh.requests.map(r => `${r.method} ${r.path}`)
expect(calls.slice(0, reads.length).sort()).toEqual([...reads].sort())
expect(calls.slice(reads.length)).toEqual(rest)
}

// a label command that never reads the configuration, then the sweep that follows it: the configuration
// reads (unordered) for the needs-* re-check and, on a pull request, tide's calls
function expectCommandThenSweep(command: string[], sweep: string[] = []) {
const calls = gh.requests.map(r => `${r.method} ${r.path}`)
expect(calls.slice(0, command.length)).toEqual(command)
const reads = configReads()
expect(calls.slice(command.length, command.length + reads.length).sort()).toEqual([...reads].sort())
expect(calls.slice(command.length + reads.length)).toEqual(sweep)
}

// the pull request, its changed files, the tip of its base branch and the OWNERS files there, as the OWNERS plugins read them
function routeOwners(ownersFiles: Record<string, string>, files: string[], pull: Record<string, unknown> = {}) {
gh.route('GET', `${repo}/pulls/1`, { status: 200, body: { ...pullBody, user: { login: 'Codertocat' }, requested_reviewers: [], assignees: [], ...pull } })
gh.route('GET', `${repo}/pulls/1/files`, {
status: 200,
body: files.map(filename => ({ filename, status: 'modified' })),
})
gh.route('GET', `${repo}/branches/master`, { status: 200, body: { name: 'master', commit: { sha: 'basesha' } } })
gh.route('GET', `${repo}/git/trees/basesha`, {
status: 200,
body: {
sha: 'basesha',
truncated: false,
tree: Object.keys(ownersFiles).map(path => ({ path, type: 'blob', sha: blobSha(path) })),
},
})
for (const [path, contents] of Object.entries(ownersFiles)) {
gh.route('GET', `${repo}/git/blobs/${blobSha(path)}`, {
status: 200,
body: { encoding: 'base64', content: Buffer.from(contents).toString('base64') },
})
}
}

const ownersReads = [
`GET ${repo}/pulls/1`,
`GET ${repo}/pulls/1/files?per_page=100`,
`GET ${repo}/branches/master`,
`GET ${repo}/git/trees/basesha?recursive=true`,
]

// the tide gate learns whether the pull request's base branch (master in every fixture) has OWNERS files from
// its tree, once per branch per run, after the pull request read that names the branch; the fake answers 404
// (an empty repository) unless a test routes it
const ownersProbe = `GET ${repo}/git/trees/master?recursive=true`
// tide asks GraphQL once whether the base branch requires a merge queue: once the gate passes, or when it
// fails on an event (to dequeue the bot's own entry); the fake answers "no queue" unless a test routes it
const queueRead = 'POST /graphql'

it('is a syntactically valid bundle with no unresolved modules', () => {
expect(fs.existsSync(bundlePath)).toBe(true)

Expand Down Expand Up @@ -459,7 +372,7 @@ describe('dist/index.js', () => {
const posts = gh.requestsMatching('POST', /\/issues\/1\/labels$/)
expect(posts).toHaveLength(1)
expect(posts[0].body).toEqual({ labels: ['help wanted'] })
expectCommandThenSweep([labelsRead, `POST ${repo}/issues/1/labels`])
expectCommandThenConfig([labelsRead, `POST ${repo}/issues/1/labels`])
})

it('issue_comment /assign self-assigns an org member', async () => {
Expand Down Expand Up @@ -723,7 +636,7 @@ describe('dist/index.js', () => {
expect(gh.requestsMatching('POST', /\/issues\/1\/labels$/)).toEqual([])
expect(gh.requestsMatching('POST', /\/issues\/1\/comments$/)).toEqual([])
// the membership fallback checks org membership and collaborator status; the sweep then reads the configuration and the pr
expectCommandThenSweep([
expectCommandThenConfig([
...ownersReads,
`GET /orgs/Codertocat/members/bob`,
`GET ${repo}/collaborators/bob`,
Expand Down Expand Up @@ -753,7 +666,7 @@ describe('dist/index.js', () => {
gh.route('POST', `${repo}/issues/comments/492700400/reactions`, { status: 201, body: { content: 'rocket' } })
}

const authReads = [...ownersReads, `GET /orgs/Codertocat/members/Codertocat`, `GET ${repo}/collaborators/Codertocat`]
const authReads = [...ownersReads, ...membershipReads('Codertocat')]
const runsRead = `GET ${repo}/actions/runs?head_sha=headsha&per_page=100`
const rocket = `POST ${repo}/issues/comments/492700400/reactions`

Expand Down Expand Up @@ -818,7 +731,7 @@ describe('dist/index.js', () => {
expect(result.status, result.stdout).toBe(1)
expect(result.errors).toHaveLength(1)
expect(result.errors[0]).toMatch(/could not remove label foo/)
expectCommandThenSweep([
expectCommandThenConfig([
`GET ${repo}/collaborators/Codertocat`,
`GET ${repo}/issues/1`,
`DELETE ${repo}/issues/1/labels/foo`,
Expand Down
32 changes: 8 additions & 24 deletions __tests__/bundle/collaborationCommands.test.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,25 @@
import type { FakeGithub } from './fakeGithub'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'

import issueCommentEvent from '../fixtures/issues/issueCommentEvent.json'
import { start } from './fakeGithub'
import { comment, helpersFor, membershipReads, repo, token } from './helpers'
import { runBundle } from './runBundle'

vi.setConfig({ testTimeout: 30_000 })

// the issue_comment commands that touch assignees, reviewers, title and lock state,
// driven through dist/index.js like the label commands in bundle.test.ts
describe('dist/index.js collaboration commands', () => {
const repo = '/repos/Codertocat/Hello-World'
const token = { 'github-token': 'some-token' }
const collaboratorRead = `GET ${repo}/collaborators/Codertocat`
let gh: FakeGithub
const { calls } = helpersFor(() => gh)

beforeAll(async () => {
gh = await start()
})
afterEach(() => gh.reset())
afterAll(() => gh.close())

function comment(body: string, author = issueCommentEvent.issue.user.login) {
const payload = structuredClone(issueCommentEvent)
payload.comment.body = body
payload.issue.user.login = author
return payload
}

function calls() {
return gh.requests.map(r => `${r.method} ${r.path}`)
}

async function run(body: string, command: string, author?: string) {
const result = await runBundle({
eventName: 'issue_comment',
Expand All @@ -43,12 +31,8 @@ describe('dist/index.js collaboration commands', () => {
}

// the three reads checkCommenterAuth / getOrgCollabCommentUsers make for one user, in order
function membershipReads(user: string) {
return [
`GET /orgs/Codertocat/members/${user}`,
`GET ${repo}/collaborators/${user}`,
`GET ${repo}/issues/1/comments`,
]
function membershipOrCommentReads(user: string) {
return [...membershipReads(user), `GET ${repo}/issues/1/comments`]
}

describe('/cc', () => {
Expand Down Expand Up @@ -87,7 +71,7 @@ describe('dist/index.js collaboration commands', () => {
expect(result.status, result.stdout).toBe(0)
expect(result.errors).toEqual([])
// the two users are checked concurrently, so their reads interleave
expect(calls().slice(0, 6).sort()).toEqual([...membershipReads('octocat'), ...membershipReads('stranger')].sort())
expect(calls().slice(0, 6).sort()).toEqual([...membershipOrCommentReads('octocat'), ...membershipOrCommentReads('stranger')].sort())
expect(calls().slice(6)).toEqual([`POST ${repo}/pulls/1/requested_reviewers`])
expect(gh.requestsMatching('POST', /requested_reviewers$/)[0].body).toEqual({ reviewers: ['octocat'] })
})
Expand Down Expand Up @@ -128,7 +112,7 @@ describe('dist/index.js collaboration commands', () => {

expect(result.status, result.stdout).toBe(0)
expect(result.errors).toEqual([])
expect(calls()).toEqual([...membershipReads('Codertocat'), `DELETE ${repo}/pulls/1/requested_reviewers`])
expect(calls()).toEqual([...membershipOrCommentReads('Codertocat'), `DELETE ${repo}/pulls/1/requested_reviewers`])
expect(gh.requestsMatching('DELETE', /requested_reviewers$/)[0].body).toEqual({ reviewers: ['octocat', 'hubot'] })
})

Expand All @@ -141,7 +125,7 @@ describe('dist/index.js collaboration commands', () => {

expect(result.status, result.stdout).toBe(0)
expect(result.errors).toEqual([])
expect(calls()).toEqual(membershipReads('Codertocat'))
expect(calls()).toEqual(membershipOrCommentReads('Codertocat'))
})
})

Expand All @@ -167,7 +151,7 @@ describe('dist/index.js collaboration commands', () => {

expect(result.status, result.stdout).toBe(0)
expect(result.errors).toEqual([])
expect(calls()).toEqual([...membershipReads('Codertocat'), `DELETE ${repo}/issues/1/assignees`])
expect(calls()).toEqual([...membershipOrCommentReads('Codertocat'), `DELETE ${repo}/issues/1/assignees`])
expect(gh.requestsMatching('DELETE', /assignees$/)[0].body).toEqual({ assignees: ['octocat'] })
})

Expand Down
116 changes: 116 additions & 0 deletions __tests__/bundle/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { FakeGithub } from './fakeGithub'
import { Buffer } from 'node:buffer'
import { expect } from 'vitest'

import issueCommentEvent from '../fixtures/issues/issueCommentEvent.json'
import { blobSha, pullBody } from '../utils/ownersFixtures'

export const repo = '/repos/Codertocat/Hello-World'
export const token = { 'github-token': 'some-token' }

// Codertocat's comment `body` on issue #1 (not a pull request), opened by `author`
export function comment(body: string, author = issueCommentEvent.issue.user.login) {
const payload = structuredClone(issueCommentEvent)
payload.comment.body = body
payload.issue.user.login = author
return payload
}

const orgConfigRepos = ['.project', '.github']
const repoConfigFiles = [
'.github/prow.yaml',
'.github/prowlabels.yaml',
'prow.yaml',
'.prowlabels.yaml',
'.github/prow.yml',
'.github/prowlabels.yml',
'prow.yml',
'.prowlabels.yml',
]

// the configuration reads the loader makes before it finds `org` and `repo` (or gives up on a tier)
export function configReads({ org, repo: file }: { org?: string, repo?: string } = {}): string[] {
const orgReads = orgConfigRepos
.slice(0, org ? orgConfigRepos.indexOf(org) + 1 : orgConfigRepos.length)
.map(name => `GET /repos/Codertocat/${name}/contents/prow.yaml`)
const repoReads = repoConfigFiles
.slice(0, file ? repoConfigFiles.indexOf(file) + 1 : repoConfigFiles.length)
.map(path => `GET ${repo}/contents/${encodeURIComponent(path)}`)
return [...orgReads, ...repoReads]
}

// the OWNERS plugins' reads of what routeOwners serves, before any OWNERS blob
export const ownersReads = [
`GET ${repo}/pulls/1`,
`GET ${repo}/pulls/1/files?per_page=100`,
`GET ${repo}/branches/master`,
`GET ${repo}/git/trees/basesha?recursive=true`,
]

// the tide gate learns whether the pull request's base branch (master in every fixture) has OWNERS files from
// its tree, once per branch per run, after the pull request read that names the branch; the fake answers 404
// (an empty repository) unless a test routes it
export const ownersProbe = `GET ${repo}/git/trees/master?recursive=true`
// tide asks GraphQL once whether the base branch requires a merge queue: once the gate passes, or when it
// fails on an event (to dequeue the bot's own entry); the fake answers "no queue" unless a test routes it
export const queueRead = 'POST /graphql'

// the reads that authorize `login` when no OWNERS file does: its org membership and its collaborator status
export function membershipReads(login: string) {
return [`GET /orgs/Codertocat/members/${login}`, `GET ${repo}/collaborators/${login}`]
}

// the helpers that route or inspect a suite's fake GitHub; the suite starts it in beforeAll, after binding these,
// so `fake` is called on each use
export function helpersFor(fake: () => FakeGithub) {
function calls() {
return fake().requests.map(r => `${r.method} ${r.path}`)
}

// `reads` in any order, then exactly `rest`: the org and repo tiers are probed concurrently, so the reads have no
// fixed order among themselves
function expectRequests(reads: string[], rest: string[]) {
const recorded = calls()
expect(recorded.slice(0, reads.length).sort()).toEqual([...reads].sort())
expect(recorded.slice(reads.length)).toEqual(rest)
}

// `commandReads` in any order, then `command`, then the configuration reads in any order (for the post-command
// sweep's needs-* re-check, or for lgtm.bind_to_commit), then `tail`: on a pull request, tide's calls
function expectCommandThenConfig(command: string[], tail: string[] = [], commandReads: string[] = []) {
const recorded = calls()
expect(recorded.slice(0, commandReads.length).sort()).toEqual([...commandReads].sort())
const afterAuth = recorded.slice(commandReads.length)
expect(afterAuth.slice(0, command.length)).toEqual(command)
const reads = configReads()
expect(afterAuth.slice(command.length, command.length + reads.length).sort()).toEqual([...reads].sort())
expect(afterAuth.slice(command.length + reads.length)).toEqual(tail)
}

// the pull request, its changed files, the tip of its base branch and the OWNERS files there, as the OWNERS plugins read them
function routeOwners(ownersFiles: Record<string, string>, files: string[], pull: Record<string, unknown> = {}) {
const gh = fake()
gh.route('GET', `${repo}/pulls/1`, { status: 200, body: { ...pullBody, user: { login: 'Codertocat' }, requested_reviewers: [], assignees: [], ...pull } })
gh.route('GET', `${repo}/pulls/1/files`, {
status: 200,
body: files.map(filename => ({ filename, status: 'modified' })),
})
gh.route('GET', `${repo}/branches/master`, { status: 200, body: { name: 'master', commit: { sha: 'basesha' } } })
gh.route('GET', `${repo}/git/trees/basesha`, {
status: 200,
body: {
sha: 'basesha',
truncated: false,
tree: Object.keys(ownersFiles).map(path => ({ path, type: 'blob', sha: blobSha(path) })),
},
})
for (const [path, contents] of Object.entries(ownersFiles)) {
gh.route('GET', `${repo}/git/blobs/${blobSha(path)}`, {
status: 200,
body: { encoding: 'base64', content: Buffer.from(contents).toString('base64') },
})
}
}

return { calls, expectCommandThenConfig, expectRequests, routeOwners }
}
Loading
Loading