mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
- deploy.yml: trigger push em main + preflight guard (pula se há run queued/in_progress; waiting não conta) e job hosted requeue após deploy-staging (fora do grupo deploy-homeserver) - scripts/lib/deploy-trigger.mjs (decidePreflight/decideRequeue) + CLIs deploy-preflight/deploy-requeue; github-api ganha listWorkflowRuns/getBranchHead - pins: ciSkipInvariants reescrito (push + guard + dispatch + requeue fora do grupo), deployTrigger/githubApi novos; deployScript intocado - docs realinhados (AGENTS*, AGENT-OPS, README, runbook, rules/skills) + changelog Closes #980
396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { createApi } from '../../scripts/lib/github-api.mjs'
|
|
|
|
type FetchCall = {
|
|
url: string
|
|
method?: string
|
|
init?: { headers?: Record<string, string>; method?: string; body?: string }
|
|
}
|
|
|
|
const ok = (body: unknown, status = 200) =>
|
|
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
|
|
const issue = (over: Record<string, unknown> = {}) => ({
|
|
number: 1,
|
|
title: 'x',
|
|
body: '---\nid: T1\n---\n',
|
|
state: 'open',
|
|
created_at: '2026-08-20T10:00:00Z',
|
|
labels: [{ name: 'ready', color: '0e8a16' }],
|
|
...over,
|
|
})
|
|
|
|
describe('github-api (issue tracker layer)', () => {
|
|
afterEach(() => vi.restoreAllMocks())
|
|
|
|
it('listIssues filters out pull-request entries and normalizes to the contract', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
base: 'https://api.github.com',
|
|
token: 'tok',
|
|
repository: 'fsolla/teqo',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), init })
|
|
return ok([issue({ number: 1 }), issue({ number: 2, pull_request: { url: 'x' } })])
|
|
},
|
|
})
|
|
const issues = await api.listIssues({ state: 'open' })
|
|
expect(issues).toHaveLength(1)
|
|
expect(issues[0].number).toBe(1)
|
|
expect(issues[0].state).toBe('OPEN')
|
|
expect(issues[0].createdAt).toBe('2026-08-20T10:00:00Z')
|
|
expect(issues[0].labels).toEqual([{ name: 'ready', color: '0e8a16' }])
|
|
expect(calls[0].url).toContain('/repos/fsolla/teqo/issues?state=open&per_page=100&page=1')
|
|
})
|
|
|
|
it('getIssue normalizes a single issue', async () => {
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async () => ok(issue({ number: 42 })),
|
|
})
|
|
const got = await api.getIssue(42)
|
|
expect(got?.number).toBe(42)
|
|
expect(got?.state).toBe('OPEN')
|
|
})
|
|
|
|
it('createIssue POSTs title+body and returns the normalized issue', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), init })
|
|
return ok(issue({ number: 7 }))
|
|
},
|
|
})
|
|
const created = await api.createIssue({ title: 'T', body: 'B' })
|
|
expect(created.number).toBe(7)
|
|
expect(calls[0].init?.method).toBe('POST')
|
|
expect(JSON.parse(calls[0].init?.body ?? '{}')).toEqual({ title: 'T', body: 'B' })
|
|
})
|
|
|
|
it('setLabels removes by name then adds', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), init })
|
|
return ok([])
|
|
},
|
|
})
|
|
await api.setLabels(5, { add: ['done'], remove: ['in-progress'] })
|
|
expect(calls.map((c) => c.url)).toEqual([
|
|
'https://api.github.com/repos/fsolla/teqo/issues/5/labels/in-progress',
|
|
'https://api.github.com/repos/fsolla/teqo/issues/5/labels',
|
|
])
|
|
expect(calls[0].init?.method).toBe('DELETE')
|
|
expect(calls[1].init?.method).toBe('POST')
|
|
expect(JSON.parse(calls[1].init?.body ?? '{}')).toEqual({ labels: ['done'] })
|
|
})
|
|
|
|
it('closeIssue PATCHes state=closed', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), init })
|
|
return ok(issue({ state: 'closed' }))
|
|
},
|
|
})
|
|
await api.closeIssue(9)
|
|
expect(calls[0].url).toContain('/issues/9')
|
|
expect(calls[0].init?.method).toBe('PATCH')
|
|
expect(JSON.parse(calls[0].init?.body ?? '{}')).toEqual({ state: 'closed' })
|
|
})
|
|
|
|
it('workflowDispatch posts inputs and ref', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), init })
|
|
return ok({})
|
|
},
|
|
})
|
|
await api.workflowDispatch('agent-pool.yml', { ref: 'main', inputs: { action: 'start' } })
|
|
expect(calls[0].url).toContain('/actions/workflows/agent-pool.yml/dispatches')
|
|
expect(JSON.parse(calls[0].init?.body ?? '{}')).toEqual({
|
|
ref: 'main',
|
|
inputs: { action: 'start' },
|
|
})
|
|
})
|
|
|
|
it('listWorkflowRuns queries the workflow runs endpoint and normalizes the payload', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), init })
|
|
return ok({
|
|
workflow_runs: [
|
|
{
|
|
id: 55,
|
|
status: 'in_progress',
|
|
conclusion: null,
|
|
event: 'push',
|
|
head_sha: 'abc',
|
|
head_branch: 'main',
|
|
created_at: '2026-09-14T10:00:00Z',
|
|
html_url: 'https://github.com/fsolla/teqo/actions/runs/55',
|
|
},
|
|
],
|
|
})
|
|
},
|
|
})
|
|
const runs = await api.listWorkflowRuns('deploy.yml', { status: 'in_progress', branch: 'main' })
|
|
expect(runs).toEqual([
|
|
{
|
|
id: 55,
|
|
status: 'in_progress',
|
|
conclusion: null,
|
|
event: 'push',
|
|
headSha: 'abc',
|
|
headBranch: 'main',
|
|
createdAt: '2026-09-14T10:00:00Z',
|
|
htmlUrl: 'https://github.com/fsolla/teqo/actions/runs/55',
|
|
},
|
|
])
|
|
expect(calls[0].url).toContain(
|
|
'/actions/workflows/deploy.yml/runs?status=in_progress&branch=main&per_page=100',
|
|
)
|
|
})
|
|
|
|
it('listWorkflowRuns tolerates a missing workflow_runs array', async () => {
|
|
const api = createApi({ token: 'tok', fetchImpl: async () => ok({}) })
|
|
await expect(api.listWorkflowRuns('deploy.yml')).resolves.toEqual([])
|
|
})
|
|
|
|
it('getBranchHead reads and normalizes the branch ref', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), init })
|
|
return ok({ ref: 'refs/heads/main', object: { sha: 'deadbeef' } })
|
|
},
|
|
})
|
|
const head = await api.getBranchHead('main')
|
|
expect(head).toEqual({ ref: 'refs/heads/main', sha: 'deadbeef' })
|
|
expect(calls[0].url).toContain('/git/ref/heads/main')
|
|
})
|
|
|
|
it('getBranchHead returns null on a missing branch (404)', async () => {
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async () => ok({ message: 'Not Found' }, 404),
|
|
retries: 0,
|
|
})
|
|
await expect(api.getBranchHead('nope')).resolves.toBeNull()
|
|
})
|
|
|
|
it('getFileContents base64-decodes content', async () => {
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async () => ok({ content: Buffer.from('olá mundo').toString('base64'), sha: 's' }),
|
|
})
|
|
const file = await api.getFileContents('a/b.md', 'main')
|
|
expect(file?.content).toBe('olá mundo')
|
|
expect(file?.sha).toBe('s')
|
|
})
|
|
|
|
it('getPullRequest normalizes auto_merge into autoMerge', async () => {
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async () =>
|
|
ok({ number: 5, state: 'open', draft: false, auto_merge: { merge_method: 'rebase' } }),
|
|
})
|
|
const pr = await api.getPullRequest(5)
|
|
expect(pr?.autoMerge?.mergeMethod).toBe('rebase')
|
|
})
|
|
|
|
it('disableAutoMerge sends the GraphQL disable mutation with the node id', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), init })
|
|
return ok({ data: { disablePullRequestAutoMerge: { pullRequest: { number: 7 } } } })
|
|
},
|
|
})
|
|
await api.disableAutoMerge('PR_node7')
|
|
expect(calls[0].url).toContain('/graphql')
|
|
expect(calls[0].init?.method).toBe('POST')
|
|
const payload = JSON.parse(calls[0].init?.body ?? '{}')
|
|
expect(payload.query).toContain('disablePullRequestAutoMerge')
|
|
expect(payload.variables).toEqual({ id: 'PR_node7' })
|
|
})
|
|
|
|
it('ensureAutoMergeDisabled throws fail-closed on an unknown PR', async () => {
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async () => ok({ message: 'Not Found' }, 404),
|
|
retries: 0,
|
|
})
|
|
await expect(api.ensureAutoMergeDisabled(13)).rejects.toThrow(/não encontrado/)
|
|
})
|
|
|
|
it('ensureAutoMergeDisabled converges once a poll sees null', async () => {
|
|
let poll = 0
|
|
let disabledCalls = 0
|
|
const sleeps: number[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url) => {
|
|
if (String(url).includes('/graphql')) {
|
|
disabledCalls += 1
|
|
return ok({ data: { disablePullRequestAutoMerge: { pullRequest: { number: 9 } } } })
|
|
}
|
|
poll += 1
|
|
// Armed on the first two polls, disarmed on the third.
|
|
return ok({
|
|
number: 9,
|
|
state: 'open',
|
|
draft: false,
|
|
node_id: 'PR_node9',
|
|
...(poll < 3 ? { auto_merge: { merge_method: 'rebase' } } : {}),
|
|
})
|
|
},
|
|
sleepImpl: async (ms) => {
|
|
sleeps.push(ms)
|
|
},
|
|
})
|
|
await expect(api.ensureAutoMergeDisabled(9)).resolves.toBe(true)
|
|
expect(disabledCalls).toBe(2)
|
|
expect(sleeps.length).toBe(2)
|
|
})
|
|
|
|
it('ensureAutoMergeDisabled throws after exhausting attempts while still armed', async () => {
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url) => {
|
|
if (String(url).includes('/graphql')) {
|
|
return ok({ data: { disablePullRequestAutoMerge: { pullRequest: { number: 11 } } } })
|
|
}
|
|
return ok({
|
|
number: 11,
|
|
state: 'open',
|
|
draft: false,
|
|
node_id: 'PR_node11',
|
|
auto_merge: { merge_method: 'rebase' },
|
|
})
|
|
},
|
|
sleepImpl: async () => {},
|
|
retries: 0,
|
|
})
|
|
await expect(api.ensureAutoMergeDisabled(11, { attempts: 3 })).rejects.toThrow(/auto-merge/i)
|
|
})
|
|
|
|
it('ensureAutoMergeDisabled is idempotent — a null auto_merge gets no mutation', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), init })
|
|
return ok({ number: 21, state: 'open', draft: false })
|
|
},
|
|
})
|
|
await expect(api.ensureAutoMergeDisabled(21)).resolves.toBe(true)
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0].url).not.toContain('/graphql')
|
|
})
|
|
|
|
it('getPullRequestAutoMergeStatus reads the GraphQL status and normalizes it', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), init })
|
|
return ok({
|
|
data: {
|
|
repository: {
|
|
pullRequest: {
|
|
id: 'PR_node33',
|
|
isDraft: false,
|
|
mergeable: 'MERGEABLE',
|
|
mergeStateStatus: 'CLEAN',
|
|
autoMergeRequest: {
|
|
mergeMethod: 'REBASE',
|
|
enabledBy: { login: 'github-actions[bot]' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
},
|
|
})
|
|
const status = await api.getPullRequestAutoMergeStatus(33)
|
|
expect(status).toEqual({
|
|
number: 33,
|
|
nodeId: 'PR_node33',
|
|
isDraft: false,
|
|
mergeable: 'MERGEABLE',
|
|
mergeStateStatus: 'CLEAN',
|
|
autoMergeRequest: { mergeMethod: 'REBASE', enabledBy: 'github-actions[bot]' },
|
|
})
|
|
expect(calls[0].url).toContain('/graphql')
|
|
expect(calls[0].init?.method).toBe('POST')
|
|
const payload = JSON.parse(calls[0].init?.body ?? '{}')
|
|
expect(payload.query).toContain('query PullRequestAutoMergeStatus')
|
|
expect(payload.query).toContain('autoMergeRequest')
|
|
expect(payload.variables).toEqual({ owner: 'fsolla', name: 'teqo', number: 33 })
|
|
})
|
|
|
|
it('getPullRequestAutoMergeStatus normalizes a disarmed PR to autoMergeRequest null', async () => {
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async () =>
|
|
ok({
|
|
data: {
|
|
repository: {
|
|
pullRequest: {
|
|
id: 'PR_node34',
|
|
isDraft: false,
|
|
mergeable: 'MERGEABLE',
|
|
mergeStateStatus: 'BLOCKED',
|
|
autoMergeRequest: null,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
})
|
|
const status = await api.getPullRequestAutoMergeStatus(34)
|
|
expect(status.autoMergeRequest).toBeNull()
|
|
expect(status.isDraft).toBe(false)
|
|
})
|
|
|
|
it('getPullRequestAutoMergeStatus throws fail-closed on an unknown PR', async () => {
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async () => ok({ data: { repository: { pullRequest: null } } }),
|
|
retries: 0,
|
|
})
|
|
await expect(api.getPullRequestAutoMergeStatus(99)).rejects.toThrow(/não encontrado/)
|
|
})
|
|
|
|
it('convertPullRequestToDraft sends the GraphQL draft mutation with the node id', async () => {
|
|
const calls: FetchCall[] = []
|
|
const api = createApi({
|
|
token: 'tok',
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url: String(url), init })
|
|
return ok({
|
|
data: { convertPullRequestToDraft: { pullRequest: { number: 41, isDraft: true } } },
|
|
})
|
|
},
|
|
})
|
|
await api.convertPullRequestToDraft('PR_node41')
|
|
expect(calls[0].url).toContain('/graphql')
|
|
expect(calls[0].init?.method).toBe('POST')
|
|
const payload = JSON.parse(calls[0].init?.body ?? '{}')
|
|
expect(payload.query).toContain('convertPullRequestToDraft')
|
|
expect(payload.variables).toEqual({ id: 'PR_node41' })
|
|
})
|
|
})
|