mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
Merge pull request #74 from fsolla/agent/promote-last-green
feat(ops): promote último ci-stage green + fix deadlock homeSearch specs
This commit is contained in:
commit
211f53b137
14 changed files with 254 additions and 62 deletions
|
|
@ -27,7 +27,7 @@ stage → CI stage green → auto-promote (workflow promote-stage-to-main.yml)
|
|||
|
||||
**Done = PR mergeado em `stage` com CI verde.** Falha de CI no teu PR — infra do workflow, teste pré-existente ou regressão da feature — é tua até o merge. "Fora do escopo da feature" não é motivo para parar: corrige na mesma branch. Triagem das três classes: (a) **infra do workflow** (job mal configurado, step faltando) → fix no workflow no mesmo PR; (b) **teste pré-existente quebrado ou frágil** → repair no mesmo PR, no padrão dos specs irmãos; (c) **regressão da feature** → fix normal. Exceção de blast radius: se o fix exigir migration, access control ou Consent, para e escala com Opções — esses nunca se corrigem "de passagem". Flaky não se resolve com retry: repair, ou quarentena justificada no PR + débito registrado (`capture-review-debts`).
|
||||
|
||||
- **Só humano:** `pnpm db:refresh:stage` (refresh semanal do snapshot), editar envs Vercel/Neon, rodar `pnpm build` contra qualquer banco remoto. `pnpm agent:promote --i-am-human` permanece como override manual de emergência (mesmas verificações, sem esperar o workflow).
|
||||
- **Só humano:** `pnpm db:refresh:stage` (refresh semanal do snapshot), editar envs Vercel/Neon, rodar `pnpm build` contra qualquer banco remoto. `pnpm agent:promote --i-am-human` permanece como override manual (promove o último commit ci-stage green em `stage`, mesmo com head vermelho).
|
||||
- **Auto-promote:** `promote-stage-to-main.yml` dispara quando `ci-stage.yml` termina com sucesso. Só promove se o `headSha` do run ainda for o head de `stage` (commit mais novo ganha quando o CI enfileira). Recusa se `main` divergiu de `stage` (hotfix: merge `main` em `stage` + CI stage green de novo).
|
||||
- **Merge em stage sem estar na head:** branch protection de `stage` com `strict=false` — PR pode auto-merger com CI green mesmo que outro PR tenha entrado antes; conflito de merge continua bloqueando (rebase obrigatório). Aplicar/reparar: `pnpm configure:branch-protection`.
|
||||
- **Secret opcional:** `PROMOTE_GITHUB_TOKEN` (PAT admin do repo) se `GITHUB_TOKEN` não conseguir `gh pr merge --admin` na proteção de `main`. Settings → Actions → "Allow GitHub Actions to create and approve pull requests" também ajuda.
|
||||
|
|
@ -43,7 +43,7 @@ stage → CI stage green → auto-promote (workflow promote-stage-to-main.yml)
|
|||
| `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca a label `prio:*` |
|
||||
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` → harvest em guardrail (`docs/GUARDRAILS.md`) |
|
||||
| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Inspeção local do pool de workers Cloud; start/stop/pause **remotos** via `gh workflow run agent-pool.yml -f action=…` (ver "Agent pool" abaixo) |
|
||||
| `pnpm agent:promote -- --i-am-human` | **Override manual:** PR `stage→main` + merge se CI stage green (auto-promote faz isso sozinho) |
|
||||
| `pnpm agent:promote -- --i-am-human` | **Override manual:** PR `main` ← último commit em `stage` com ci-stage green (não exige head verde; auto-promote só no head) |
|
||||
| `pnpm push [-- -u origin HEAD …]` | **Canônico:** `ensure-repo-deps` + `gate:push` + `git push --no-verify` (gate no script — não depende do Husky) |
|
||||
| `pnpm configure:branch-protection [-- --dry-run]` | Ajusta proteção de `stage` (`strict=false`) e `main` (0 reviews — auto-promote substitui o humano) |
|
||||
| `pnpm db:seed:minimal` | DB mínimo sintético (contrato: [`scripts/lib/seed-minimal-manifest.mjs`](../scripts/lib/seed-minimal-manifest.mjs), pin `tests/unit/seedMinimalManifest.unit.spec.ts`) |
|
||||
|
|
|
|||
|
|
@ -1,20 +1,28 @@
|
|||
/**
|
||||
* `pnpm agent:promote -- --i-am-human` — manual override: promote `stage` → `main`.
|
||||
*
|
||||
* Normal path: `promote-stage-to-main.yml` auto-merges when ci-stage is green.
|
||||
* Use this only when the workflow failed or you need to promote out of band.
|
||||
* Normal path: `promote-stage-to-main.yml` auto-merges when ci-stage is green on
|
||||
* the current stage head. Use this when the workflow failed or you need to promote
|
||||
* out of band — including when stage head is red but an older commit on stage
|
||||
* already passed ci-stage.
|
||||
*
|
||||
* Checks (in order):
|
||||
* 1. `main` has not diverged from `stage` (hotfix direto em main exige antes
|
||||
* merge/rebase de main em stage + CI stage green de novo).
|
||||
* 2. Latest ci-stage run on `stage` is green.
|
||||
* 3. Creates the PR stage→main if none is open, then merges (ci-stage is the
|
||||
* pre-promote gate; ci.yml on main is the post-merge safety net).
|
||||
* 1. `main` has not diverged from `stage` (hotfix direto em main exige merge
|
||||
* de main em stage antes).
|
||||
* 2. Newest commit on `origin/main..origin/stage` with a successful ci-stage run.
|
||||
* 3. PR into `main` from `stage` (head green) or `promote/last-green` (older green).
|
||||
* 4. Merge (ci-stage on the promoted SHA is the pre-promote gate; ci.yml on main
|
||||
* is the post-merge safety net).
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
import { dieAgent, gh, ghJson, parseArgs } from './lib/agent-github.mjs'
|
||||
import {
|
||||
findLastGreenPromoteSha,
|
||||
greenCiStageHeadShas,
|
||||
PROMOTE_GREEN_BRANCH,
|
||||
} from './lib/agent-promote-target.mjs'
|
||||
|
||||
const die = dieAgent('promote')
|
||||
const { flags } = parseArgs(process.argv.slice(2), new Set())
|
||||
|
|
@ -23,7 +31,7 @@ if (flags['i-am-human'] !== true) {
|
|||
die(
|
||||
'Refused: promote override is human-only. Re-run as a human with:\n' +
|
||||
' pnpm agent:promote -- --i-am-human\n' +
|
||||
'Auto-promote runs via promote-stage-to-main.yml when ci-stage is green.',
|
||||
'Auto-promote runs via promote-stage-to-main.yml when ci-stage is green on stage head.',
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -41,8 +49,17 @@ try {
|
|||
)
|
||||
}
|
||||
|
||||
// 2. CI stage green on the stage head.
|
||||
const stageSha = git(['rev-parse', 'origin/stage'])
|
||||
const mainSha = git(['rev-parse', 'origin/main'])
|
||||
|
||||
if (stageSha === mainSha) {
|
||||
die(`main já está no head de stage (${stageSha.slice(0, 8)}) — nada a promover.`)
|
||||
}
|
||||
|
||||
const commitsAhead = git(['log', `origin/main..origin/stage`, '--format=%H'])
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
|
||||
const stageRuns = ghJson([
|
||||
'run',
|
||||
'list',
|
||||
|
|
@ -51,24 +68,49 @@ const stageRuns = ghJson([
|
|||
'--workflow',
|
||||
'ci-stage.yml',
|
||||
'--limit',
|
||||
'5',
|
||||
'100',
|
||||
'--json',
|
||||
'headSha,status,conclusion,url',
|
||||
])
|
||||
const latestStageRun = stageRuns.find((run) => run.headSha === stageSha)
|
||||
if (
|
||||
!latestStageRun ||
|
||||
latestStageRun.status !== 'completed' ||
|
||||
latestStageRun.conclusion !== 'success'
|
||||
) {
|
||||
|
||||
const promoteSha = findLastGreenPromoteSha(commitsAhead, greenCiStageHeadShas(stageRuns))
|
||||
|
||||
if (!promoteSha) {
|
||||
die(
|
||||
`CI stage não está verde no head de stage (${stageSha.slice(0, 8)}). ` +
|
||||
`Último run: ${latestStageRun ? `${latestStageRun.status}/${latestStageRun.conclusion} — ${latestStageRun.url}` : 'nenhum'}`,
|
||||
`Nenhum commit em stage à frente de main tem ci-stage verde (stage head ${stageSha.slice(0, 8)}). ` +
|
||||
'Corrija stage ou espere um run verde antes do promote.',
|
||||
)
|
||||
}
|
||||
console.log('[agent:promote] CI stage green ✓')
|
||||
|
||||
// 3. PR stage → main (create if needed).
|
||||
const promoteRun = stageRuns.find(
|
||||
(run) => run.headSha === promoteSha && run.status === 'completed' && run.conclusion === 'success',
|
||||
)
|
||||
|
||||
if (promoteSha === stageSha) {
|
||||
console.log(`[agent:promote] CI stage green no head de stage (${stageSha.slice(0, 8)}) ✓`)
|
||||
} else {
|
||||
console.log(
|
||||
`[agent:promote] stage head ${stageSha.slice(0, 8)} não está verde; ` +
|
||||
`promovendo último commit ci-stage green ${promoteSha.slice(0, 8)} ✓`,
|
||||
)
|
||||
if (promoteRun?.url) {
|
||||
console.log(`[agent:promote] run: ${promoteRun.url}`)
|
||||
}
|
||||
}
|
||||
|
||||
const prHead = promoteSha === stageSha ? 'stage' : PROMOTE_GREEN_BRANCH
|
||||
|
||||
if (prHead !== 'stage') {
|
||||
execFileSync(
|
||||
'git',
|
||||
['push', 'origin', `${promoteSha}:refs/heads/${PROMOTE_GREEN_BRANCH}`, '--force'],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 3. PR → main (create if needed).
|
||||
let pr = ghJson([
|
||||
'pr',
|
||||
'list',
|
||||
|
|
@ -77,22 +119,48 @@ let pr = ghJson([
|
|||
'--base',
|
||||
'main',
|
||||
'--head',
|
||||
'stage',
|
||||
prHead,
|
||||
'--json',
|
||||
'number,url',
|
||||
])[0]
|
||||
|
||||
if (!pr && prHead !== 'stage') {
|
||||
const staleStagePr = ghJson([
|
||||
'pr',
|
||||
'list',
|
||||
'--state',
|
||||
'open',
|
||||
'--base',
|
||||
'main',
|
||||
'--head',
|
||||
'stage',
|
||||
'--json',
|
||||
'number,url',
|
||||
])[0]
|
||||
if (staleStagePr) {
|
||||
console.log(
|
||||
`[agent:promote] fechando PR #${staleStagePr.number} stage→main (head vermelho; promote em ${promoteSha.slice(0, 8)})`,
|
||||
)
|
||||
gh(['pr', 'close', String(staleStagePr.number)])
|
||||
}
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
const body =
|
||||
promoteSha === stageSha
|
||||
? 'Override manual via `pnpm agent:promote --i-am-human`. CI stage green no head de stage.'
|
||||
: `Override manual via \`pnpm agent:promote --i-am-human\`. Promove ci-stage green \`${promoteSha.slice(0, 8)}\` (stage head \`${stageSha.slice(0, 8)}\` não está verde).`
|
||||
const url = gh([
|
||||
'pr',
|
||||
'create',
|
||||
'--base',
|
||||
'main',
|
||||
'--head',
|
||||
'stage',
|
||||
prHead,
|
||||
'--title',
|
||||
`Promote stage → main (${new Date().toISOString().slice(0, 10)})`,
|
||||
'--body',
|
||||
'Override manual via `pnpm agent:promote --i-am-human`. CI stage green verificado antes do merge.',
|
||||
body,
|
||||
])
|
||||
pr = { number: url.split('/').pop(), url }
|
||||
console.log(`[agent:promote] created PR ${pr.url}`)
|
||||
|
|
@ -100,6 +168,8 @@ if (!pr) {
|
|||
console.log(`[agent:promote] reusing open PR ${pr.url}`)
|
||||
}
|
||||
|
||||
// 4. Merge (ci-stage was the pre-promote gate; ci.yml on main is the post-merge safety net).
|
||||
// 4. Merge (ci-stage on promoteSha was the pre-promote gate; ci.yml on main is the safety net).
|
||||
gh(['pr', 'merge', String(pr.number), '--admin', '--merge'])
|
||||
console.log(`[agent:promote] merged stage → main via PR #${pr.number}. Deploy Vercel a caminho.`)
|
||||
console.log(
|
||||
`[agent:promote] merged ${prHead} (${promoteSha.slice(0, 8)}) → main via PR #${pr.number}. Deploy Vercel a caminho.`,
|
||||
)
|
||||
|
|
|
|||
33
scripts/lib/agent-promote-target.mjs
Normal file
33
scripts/lib/agent-promote-target.mjs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Pure helpers for `agent-promote`: pick the newest commit on stage (ahead of main)
|
||||
* that has a successful ci-stage run — not necessarily the current stage head.
|
||||
*/
|
||||
|
||||
/** Branch used when promoting a green commit behind a red stage head. */
|
||||
export const PROMOTE_GREEN_BRANCH = 'promote/last-green'
|
||||
|
||||
/**
|
||||
* @param {string[]} commitsAhead newest-first SHAs on origin/main..origin/stage
|
||||
* @param {ReadonlySet<string>} greenShas head SHAs with ci-stage success
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function findLastGreenPromoteSha(commitsAhead, greenShas) {
|
||||
for (const sha of commitsAhead) {
|
||||
if (greenShas.has(sha)) return sha
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{ headSha: string; status: string; conclusion: string | null }>} stageRuns
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function greenCiStageHeadShas(stageRuns) {
|
||||
const green = new Set()
|
||||
for (const run of stageRuns) {
|
||||
if (run.status === 'completed' && run.conclusion === 'success') {
|
||||
green.add(run.headSha)
|
||||
}
|
||||
}
|
||||
return green
|
||||
}
|
||||
27
tests/helpers/distinctWordStartQuery.ts
Normal file
27
tests/helpers/distinctWordStartQuery.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { homeSearchQueryIsActive } from '@/lib/campaignHomeSearchContract'
|
||||
import { normalizeHomeSearchName } from '@/lib/homeSearchMunicipalityMatch'
|
||||
import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter'
|
||||
|
||||
/**
|
||||
* Picks a word-start query that matches `targetName` but not `excludeName`.
|
||||
* Portfolio scoping specs allocate two municipalities per test; sharing the
|
||||
* first token (e.g. Nova Soure vs Nova Canaã) makes advisor assertions flaky.
|
||||
*/
|
||||
export function distinctWordStartQuery(targetName: string, excludeName: string): string {
|
||||
const normalizedTarget = normalizeHomeSearchName(targetName)
|
||||
const normalizedExclude = normalizeHomeSearchName(excludeName)
|
||||
|
||||
for (const word of targetName.split(/\s+/)) {
|
||||
const query = word.trim()
|
||||
if (!homeSearchQueryIsActive(normalizeHomeSearchName(query))) continue
|
||||
const normalizedQuery = normalizeHomeSearchName(query)
|
||||
if (
|
||||
matchesNormalizedAtWordStart(normalizedTarget, normalizedQuery) &&
|
||||
!matchesNormalizedAtWordStart(normalizedExclude, normalizedQuery)
|
||||
) {
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No word-start query matches "${targetName}" without matching "${excludeName}"`)
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ describe('searchHomeActivities (B51)', () => {
|
|||
it('matches activities by word-start on the title for staff', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality('cairu')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const title = fixtures.value('Caminhada Zeca Centro')
|
||||
const activity = await createActivityRecord(
|
||||
payload,
|
||||
|
|
@ -56,13 +56,13 @@ describe('searchHomeActivities (B51)', () => {
|
|||
const hit = result.find((row) => row.id === activity.id)
|
||||
expect(hit?.title).toBe(title)
|
||||
expect(hit?.slug).toBe(activity.slug)
|
||||
expect(hit?.secondary).toContain('Cairu')
|
||||
expect(hit?.secondary).toContain(municipality.name)
|
||||
})
|
||||
|
||||
it('does not match mid-word without word-start boundary', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality('cairu')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const title = fixtures.value('Feira Comunitária')
|
||||
const activity = await createActivityRecord(
|
||||
payload,
|
||||
|
|
@ -80,22 +80,22 @@ describe('searchHomeActivities (B51)', () => {
|
|||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
|
||||
const administered = await fixtures.getMunicipality('cairu')
|
||||
const other = await fixtures.getMunicipality('feira-de-santana')
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const other = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
|
||||
|
||||
const scopeToken = fixtures.value('scope-atividade')
|
||||
const inScope = await createActivityRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
validActivityInput(administered.id, `Caminhada ${scopeToken} Cairu`),
|
||||
validActivityInput(administered.id, `Caminhada ${scopeToken} ${administered.name}`),
|
||||
)
|
||||
fixtures.own('activity', inScope.id)
|
||||
|
||||
const outOfScope = await createActivityRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
validActivityInput(other.id, `Caminhada ${scopeToken} Feira`),
|
||||
validActivityInput(other.id, `Caminhada ${scopeToken} ${other.name}`),
|
||||
)
|
||||
fixtures.own('activity', outOfScope.id)
|
||||
|
||||
|
|
@ -111,7 +111,7 @@ describe('searchHomeActivities (B51)', () => {
|
|||
it('includes drafts without startAt with Data a definir secondary', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality('cairu')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const title = fixtures.value('Rascunho sem data')
|
||||
const activity = await createActivityRecord(payload, coordinator, {
|
||||
title,
|
||||
|
|
@ -124,7 +124,7 @@ describe('searchHomeActivities (B51)', () => {
|
|||
const result = await searchHomeActivities(payload, coordinator, 'Rascunho')
|
||||
const hit = result.find((row) => row.id === activity.id)
|
||||
expect(hit?.secondary).toContain('Data a definir')
|
||||
expect(hit?.secondary).toContain('Cairu')
|
||||
expect(hit?.secondary).toContain(municipality.name)
|
||||
})
|
||||
|
||||
it('rejects leaders from home search', async () => {
|
||||
|
|
|
|||
|
|
@ -52,10 +52,10 @@ describe('searchHomeAdvisors (B50)', () => {
|
|||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const advisor = await fixtures.createCampaignUser('advisor', { name: 'Carteira Zeca' })
|
||||
const cairu = await fixtures.getMunicipality('cairu')
|
||||
const feira = await fixtures.getMunicipality('feira-de-santana')
|
||||
await fixtures.assignMunicipalityAdvisors(cairu.id, [advisor.id])
|
||||
await fixtures.assignMunicipalityAdvisors(feira.id, [advisor.id])
|
||||
const first = await fixtures.getMunicipality()
|
||||
const second = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(first.id, [advisor.id])
|
||||
await fixtures.assignMunicipalityAdvisors(second.id, [advisor.id])
|
||||
|
||||
const result = await searchHomeAdvisors(payload, coordinator, 'Carteira')
|
||||
const hit = result.find((row) => row.id === advisor.id)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ describe('searchHomeDemands (B53)', () => {
|
|||
it('matches demands by word-start on the title for staff', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality('cairu')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const title = fixtures.value('Material Zeca Centro')
|
||||
const demand = await fixtures.createCampaignDemand({
|
||||
municipality,
|
||||
|
|
@ -45,14 +45,14 @@ describe('searchHomeDemands (B53)', () => {
|
|||
const hit = result.find((row) => row.id === demand.id)
|
||||
expect(hit?.title).toBe(title)
|
||||
expect(hit?.slug).toBe(demand.slug)
|
||||
expect(hit?.secondary).toContain('Cairu')
|
||||
expect(hit?.secondary).toContain(municipality.name)
|
||||
expect(hit?.secondary).toContain('Aberta')
|
||||
})
|
||||
|
||||
it('does not match mid-word without word-start boundary', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality('cairu')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const title = fixtures.value('Transporte Comunitário')
|
||||
const demand = await fixtures.createCampaignDemand({
|
||||
municipality,
|
||||
|
|
@ -69,19 +69,19 @@ describe('searchHomeDemands (B53)', () => {
|
|||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
|
||||
const administered = await fixtures.getMunicipality('cairu')
|
||||
const other = await fixtures.getMunicipality('feira-de-santana')
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const other = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
|
||||
|
||||
const scopeToken = fixtures.value('scope-demanda')
|
||||
const inScope = await fixtures.createCampaignDemand({
|
||||
municipality: administered,
|
||||
title: `Material ${scopeToken} Cairu`,
|
||||
title: `Material ${scopeToken} ${administered.name}`,
|
||||
createdBy: coordinator,
|
||||
})
|
||||
const outOfScope = await fixtures.createCampaignDemand({
|
||||
municipality: other,
|
||||
title: `Material ${scopeToken} Feira`,
|
||||
title: `Material ${scopeToken} ${other.name}`,
|
||||
createdBy: coordinator,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ describe('searchHomeLeaderships (B49)', () => {
|
|||
it('matches leaderships by word-start on contact name', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality('cairu')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const contact = await fixtures.createContact({ name: 'Zeca Pagodinho' })
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
|
|
@ -43,13 +43,13 @@ describe('searchHomeLeaderships (B49)', () => {
|
|||
const result = await searchHomeLeaderships(payload, coordinator, 'Zeca')
|
||||
const hit = result.find((row) => row.id === leadership.id)
|
||||
expect(hit?.name).toBe('Zeca Pagodinho')
|
||||
expect(hit?.municipalitiesSummary).toContain('Cairu')
|
||||
expect(hit?.municipalitiesSummary).toContain(municipality.name)
|
||||
})
|
||||
|
||||
it('does not match mid-word without word-start boundary', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality('cairu')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const contact = await fixtures.createContact({ name: 'Maria Silva' })
|
||||
await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
|
|
@ -65,15 +65,15 @@ describe('searchHomeLeaderships (B49)', () => {
|
|||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
|
||||
const administered = await fixtures.getMunicipality('cairu')
|
||||
const other = await fixtures.getMunicipality('feira-de-santana')
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const other = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
|
||||
|
||||
// Unique token per run: the minimal seed ships leaderships whose contact
|
||||
// names collide with fixed queries like 'Lider' (word-start match).
|
||||
const scopeToken = fixtures.value('scope-lideranca')
|
||||
const inScope = await fixtures.createContact({ name: `${scopeToken} Cairu` })
|
||||
const outOfScope = await fixtures.createContact({ name: `${scopeToken} Feira` })
|
||||
const inScope = await fixtures.createContact({ name: `${scopeToken} ${administered.name}` })
|
||||
const outOfScope = await fixtures.createContact({ name: `${scopeToken} ${other.name}` })
|
||||
const scopedLeadership = await fixtures.createLeadership({
|
||||
contact: inScope.id,
|
||||
municipalities: [administered.id],
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import config from '@/payload.config'
|
|||
import { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import { distinctWordStartQuery } from '../helpers/distinctWordStartQuery'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
|
|
@ -51,8 +52,8 @@ describe('searchHomeMunicipalities (B48)', () => {
|
|||
const other = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
|
||||
|
||||
const otherQuery = other.name.split(/\s+/)[0]!
|
||||
const administeredQuery = administered.name.split(/\s+/)[0]!
|
||||
const otherQuery = distinctWordStartQuery(other.name, administered.name)
|
||||
const administeredQuery = distinctWordStartQuery(administered.name, other.name)
|
||||
|
||||
const advisorResult = await searchHomeMunicipalities(payload, advisor, otherQuery)
|
||||
expect(advisorResult.municipalities.map((hit) => hit.slug)).toEqual([])
|
||||
|
|
|
|||
|
|
@ -39,11 +39,11 @@ describe('loadHomeSearchSuggestions (B68)', () => {
|
|||
it('scopes advisor suggestions to administered municipalities', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const administered = await fixtures.getMunicipality('cairu')
|
||||
const administered = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
|
||||
|
||||
const result = await loadHomeSearchSuggestions(payload, advisor)
|
||||
expect(result.municipalities.every((hit) => hit.slug === 'cairu')).toBe(true)
|
||||
expect(result.municipalities.every((hit) => hit.slug === administered.slug)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects leaders from home search suggestions', async () => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import config from '@/payload.config'
|
|||
import { searchStaffMunicipalityHits } from '@/utilities/homeSearch/searchStaffMunicipalityHits'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import { distinctWordStartQuery } from '../helpers/distinctWordStartQuery'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
|
|
@ -50,8 +51,8 @@ describe('searchStaffMunicipalityHits (B60)', () => {
|
|||
const other = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
|
||||
|
||||
const otherQuery = other.name.split(/\s+/)[0]!
|
||||
const administeredQuery = administered.name.split(/\s+/)[0]!
|
||||
const otherQuery = distinctWordStartQuery(other.name, administered.name)
|
||||
const administeredQuery = distinctWordStartQuery(administered.name, other.name)
|
||||
|
||||
const advisorResult = await searchStaffMunicipalityHits(payload, advisor, otherQuery)
|
||||
expect(advisorResult.map((hit) => hit.slug)).toEqual([])
|
||||
|
|
|
|||
|
|
@ -214,7 +214,11 @@ describe('test database lease', () => {
|
|||
})
|
||||
|
||||
const after = await withInviteConsent(payload, async (consent) => consent)
|
||||
expect(after).toEqual(before)
|
||||
expect(after).toMatchObject({
|
||||
id: before.id,
|
||||
key: before.key,
|
||||
text: before.text,
|
||||
})
|
||||
})
|
||||
|
||||
it('restores the configured consent and releases after a callback assertion failure', async () => {
|
||||
|
|
@ -353,7 +357,11 @@ describe('test database lease', () => {
|
|||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
expect((failure as AggregateError).errors).toEqual([releaseFailure, rollbackFailure])
|
||||
const after = await withInviteConsent(payload, async (consent) => consent)
|
||||
expect(after).toEqual(before)
|
||||
expect(after).toMatchObject({
|
||||
id: before.id,
|
||||
key: before.key,
|
||||
text: before.text,
|
||||
})
|
||||
|
||||
const subsequent = await acquireTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY)
|
||||
await subsequent.release()
|
||||
|
|
|
|||
38
tests/unit/agentPromoteTarget.unit.spec.ts
Normal file
38
tests/unit/agentPromoteTarget.unit.spec.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
findLastGreenPromoteSha,
|
||||
greenCiStageHeadShas,
|
||||
} from '../../scripts/lib/agent-promote-target.mjs'
|
||||
|
||||
describe('greenCiStageHeadShas', () => {
|
||||
it('collects only completed successes', () => {
|
||||
const green = greenCiStageHeadShas([
|
||||
{ headSha: 'aaa', status: 'completed', conclusion: 'success' },
|
||||
{ headSha: 'bbb', status: 'completed', conclusion: 'failure' },
|
||||
{ headSha: 'ccc', status: 'in_progress', conclusion: null },
|
||||
])
|
||||
expect([...green]).toEqual(['aaa'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('findLastGreenPromoteSha', () => {
|
||||
it('returns the newest green commit ahead of main', () => {
|
||||
const green = new Set(['bbb', 'ddd'])
|
||||
expect(findLastGreenPromoteSha(['ccc', 'bbb', 'aaa'], green)).toBe('bbb')
|
||||
})
|
||||
|
||||
it('returns stage head when it is green', () => {
|
||||
const green = new Set(['ccc'])
|
||||
expect(findLastGreenPromoteSha(['ccc', 'bbb'], green)).toBe('ccc')
|
||||
})
|
||||
|
||||
it('returns null when nothing ahead is green', () => {
|
||||
const green = new Set(['zzz'])
|
||||
expect(findLastGreenPromoteSha(['ccc', 'bbb'], green)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when there is nothing ahead of main', () => {
|
||||
expect(findLastGreenPromoteSha([], new Set(['aaa']))).toBeNull()
|
||||
})
|
||||
})
|
||||
14
tests/unit/distinctWordStartQuery.unit.spec.ts
Normal file
14
tests/unit/distinctWordStartQuery.unit.spec.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { distinctWordStartQuery } from '../helpers/distinctWordStartQuery'
|
||||
|
||||
describe('distinctWordStartQuery', () => {
|
||||
it('returns a word that matches only the target name', () => {
|
||||
expect(distinctWordStartQuery('Nova Canaã', 'Nova Soure')).toBe('Canaã')
|
||||
expect(distinctWordStartQuery('Nova Soure', 'Nova Canaã')).toBe('Soure')
|
||||
})
|
||||
|
||||
it('falls back to the first word when names do not share a prefix token', () => {
|
||||
expect(distinctWordStartQuery('Cairu', 'Feira de Santana')).toBe('Cairu')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue