Merge pull request #408 from fsolla/agent/worktree-issue-deterministic
feat(scripts): deterministic worktree + issue CLI
This commit is contained in:
commit
8c2ec2bddc
10 changed files with 512 additions and 1 deletions
49
.agents/shell/worktree.sh
Normal file
49
.agents/shell/worktree.sh
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# worktree — CLI determinístico de worktrees em torno da fila de claim, com um
|
||||
# `--go` que realmente troca de diretório.
|
||||
#
|
||||
# A lógica determinística (fila → branch → worktree) vive em scripts/worktree.mjs;
|
||||
# esta função é só a camada de shell que APLICA o `cd <dir>` que o script imprime,
|
||||
# porque um processo filho (node) não consegue mudar o cwd do shell que o chamou.
|
||||
#
|
||||
# Instalação (uma linha no profile):
|
||||
# source <repo>/.agents/shell/worktree.sh
|
||||
#
|
||||
# Uso (terminal interativo):
|
||||
# worktree next [--go] cria/reutiliza o worktree da próxima Issue claimável;
|
||||
# com --go, cd para dentro dele
|
||||
# worktree kill [--force]
|
||||
#
|
||||
# Read-only no GitHub — claim continua sendo `pnpm agent:claim`.
|
||||
|
||||
worktree() {
|
||||
local src="${BASH_SOURCE[0]:-$0}"
|
||||
local root
|
||||
root="$(cd "$(dirname "$src")/../.." && pwd)"
|
||||
|
||||
local has_go=0
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--go" ]; then
|
||||
has_go=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$has_go" -eq 0 ]; then
|
||||
node "$root/scripts/worktree.mjs" "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
local out
|
||||
out="$(node "$root/scripts/worktree.mjs" "$@")" || return $?
|
||||
printf '%s\n' "$out"
|
||||
|
||||
local dir
|
||||
dir="$(printf '%s\n' "$out" | sed -n 's/^cd //p' | tail -n 1)"
|
||||
if [ -n "$dir" ] && [ -d "$dir" ]; then
|
||||
cd "$dir" || return $?
|
||||
printf '→ %s\n' "$PWD"
|
||||
else
|
||||
printf 'worktree: não consegui aplicar o `cd` da saída do script.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
48
.agents/skills/worktree-next-issue/SKILL.md
Normal file
48
.agents/skills/worktree-next-issue/SKILL.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
name: worktree-next-issue
|
||||
description: >-
|
||||
Cria um worktree git a partir de main para a próxima Issue claimável da fila
|
||||
do projeto, com branch legível no formato <código>-<slug> (ex.
|
||||
C15-fullcalendar-em-campanha-agenda, slug pt-BR do título sem acentos).
|
||||
Read-only no GitHub — NÃO claima, NÃO altera Issues. Usar quando o usuário
|
||||
pedir "worktree da próxima issue", "cria o worktree para o próximo da fila",
|
||||
"prepara o ambiente do próximo item", ou quiser começar a trabalhar a próxima
|
||||
Issue sem abrir o pool/Cursor.
|
||||
---
|
||||
|
||||
# Worktree da próxima Issue claimável
|
||||
|
||||
Prepara o ambiente git para a próxima Issue da fila de claim, **sem** tocá-la no GitHub (read-only por contrato — claim continua sendo `pnpm agent:claim`, feito à parte).
|
||||
|
||||
## Fonte canônica: `pnpm worktree` (script)
|
||||
|
||||
A lógica determinística (fila → código+slug → branch → worktree) vive em **`scripts/worktree.mjs`** — fonte única, coberta por `tests/unit/worktree.unit.spec.ts`. Não a reescreva à mão aqui.
|
||||
|
||||
```bash
|
||||
pnpm worktree next # cria worktree de origin/main para a próxima claimável
|
||||
pnpm worktree kill [--force] # destrói o worktree em que o shell atual está
|
||||
```
|
||||
|
||||
No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.opencode/commands/worktree.md`), que só repassa `$ARGUMENTS` para o script.
|
||||
|
||||
No terminal interativo, para `--go` trocar de diretório de verdade, use a função `worktree()` de **`.agents/shell/worktree.sh`** (uma linha de `source` no profile): o script imprime `cd <dir>` e a função o aplica no shell que te chamou — node não consegue mudar o cwd do shell pai.
|
||||
|
||||
## Fluxo quando invocado como skill (agentes que não têm opencode)
|
||||
|
||||
1. Rode `pnpm worktree next` e leia a saída inteira. Se a fila estiver vazia, ele para sozinho — não crie worktree sem Issue.
|
||||
2. Se a saída reclamar de conflito (worktree/branch já existentes), o script já reporta o que reutilizar.
|
||||
3. Reporte ao usuário: código, `#<N>`, branch (`<code>-<slug>`), path — e que a Issue **não** foi claimada.
|
||||
|
||||
## O que o script garante (contrato)
|
||||
|
||||
- **Fila = `pnpm agent:claim --dry-run`** (ready + desbloqueadas, por `prio:P*` e mais antiga primeiro — mesma ordenação do pool e de `project-status`).
|
||||
- **Branch `<code>-<slug>`**: `code` = `id` do frontmatter; `slug` = título pt-BR via `src/lib/slug.ts` (acentos fora, não-alfanumérico → hífen); truncamento só no slug, nunca no código; valida com `git check-ref-format --allow-onelevel`.
|
||||
- **Base `origin/main`** (com `git fetch` antes); dir em `~/.cursor/worktrees/teqo/<branch>`.
|
||||
- **`kill`**: recusa destruir o worktree principal (main); recusa worktree sujo sem `--force`; remove e apaga o branch.
|
||||
- **NUNCA usa `--force` no `add`**, não inventa código de Issue, e é **read-only no GitHub**.
|
||||
|
||||
## NÃO faz
|
||||
|
||||
- Não roda `pnpm agent:claim` (mudaria labels no GitHub).
|
||||
- Não usa `git worktree add --force` nem destrói worktrees existentes (isso é `kill`, explícito).
|
||||
- Não duplica a lógica: se o script falhar ou faltar (gh fora do ar), reporte e pare.
|
||||
7
.opencode/commands/issue.md
Normal file
7
.opencode/commands/issue.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
description: Mostra a próxima Issue claimável (next) ou overview das Issues abertas (all)
|
||||
---
|
||||
|
||||
Rode o script determinístico abaixo (não improvise a lógica) e reporte o resultado de forma concisa.
|
||||
|
||||
!`node scripts/issue.mjs $ARGUMENTS`
|
||||
10
.opencode/commands/worktree.md
Normal file
10
.opencode/commands/worktree.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
description: Cria o worktree da próxima Issue claimável (next [--go]) ou destrói o worktree atual (kill [--force])
|
||||
---
|
||||
|
||||
Rode o script determinístico abaixo (não improvise a lógica) e reporte o resultado de forma concisa.
|
||||
|
||||
- `next [--go]`: cria (ou reutiliza) o worktree da próxima Issue claimável. Com `--go`, o script imprime `cd <dir>` na última linha — aplique-o na sessão de shell atual (`cd <dir>`) e reporte o novo diretório.
|
||||
- `kill [--force]`: destrói o worktree em que você está.
|
||||
|
||||
!`node scripts/worktree.mjs $ARGUMENTS`
|
||||
|
|
@ -72,7 +72,9 @@
|
|||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:tsc6": "tsc6 --noEmit",
|
||||
"prepare": "husky",
|
||||
"agent:status": "node scripts/agent-status.mjs"
|
||||
"agent:status": "node scripts/agent-status.mjs",
|
||||
"worktree": "node scripts/worktree.mjs",
|
||||
"issue": "node scripts/issue.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/deepseek": "^3.0.19",
|
||||
|
|
|
|||
116
scripts/issue.mjs
Normal file
116
scripts/issue.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* `pnpm issue` — Issues da fila de claim, determinístico e read-only (nunca
|
||||
* altera o GitHub; claim continua `pnpm agent:claim`).
|
||||
*
|
||||
* pnpm issue next imprime a próxima Issue claimável (topo da fila do claim)
|
||||
* pnpm issue all overview compacto das Issues abertas (contadores + lista)
|
||||
*/
|
||||
|
||||
import {
|
||||
dieAgent,
|
||||
ghJson,
|
||||
labelNames,
|
||||
nextClaimableIssue,
|
||||
parseArgs,
|
||||
parseFrontmatter,
|
||||
priorityRank,
|
||||
} from './lib/agent-github.mjs'
|
||||
|
||||
const die = dieAgent('issue')
|
||||
|
||||
const cmdNext = () => {
|
||||
const pick = nextClaimableIssue()
|
||||
if (!pick) {
|
||||
die('Fila vazia — nada `ready` desbloqueado. Rode `pnpm issue all` para a overview.')
|
||||
}
|
||||
const { issue, meta, priority, satisfiedWithoutIssue } = pick
|
||||
const id = typeof meta.id === 'string' ? meta.id : null
|
||||
const prefix = id ? `${id} — ` : null
|
||||
const subject =
|
||||
prefix && issue.title.startsWith(prefix) ? issue.title.slice(prefix.length) : issue.title
|
||||
|
||||
console.log(`#${issue.number} ${id ?? ''} [${priority}]`.trim())
|
||||
console.log(subject)
|
||||
if (meta.model) console.log(`model: ${meta.model}`)
|
||||
if (satisfiedWithoutIssue.length > 0) {
|
||||
console.log(`deps satisfeitas (roadmap, sem Issue): ${satisfiedWithoutIssue.join(', ')}`)
|
||||
}
|
||||
console.log(`url: https://github.com/fsolla/teqo/issues/${issue.number}`)
|
||||
}
|
||||
|
||||
const count = (map, key) => map.set(key, (map.get(key) ?? 0) + 1)
|
||||
|
||||
const cmdAll = () => {
|
||||
const issues = ghJson([
|
||||
'issue',
|
||||
'list',
|
||||
'--state',
|
||||
'open',
|
||||
'--limit',
|
||||
'200',
|
||||
'--json',
|
||||
'number,title,body,labels,createdAt,state',
|
||||
])
|
||||
|
||||
const rows = []
|
||||
for (const issue of issues) {
|
||||
const { meta } = parseFrontmatter(issue.body)
|
||||
if (typeof meta.id !== 'string' || meta.id.length === 0) continue
|
||||
const labels = labelNames(issue)
|
||||
rows.push({
|
||||
issue,
|
||||
id: meta.id,
|
||||
state: labels.find((label) => ['ready', 'in-progress', 'blocked'].includes(label)) ?? 'open',
|
||||
priority: labels.find((label) => /^prio:P[0-3]$/.test(label))?.replace('prio:', '') ?? 'P2',
|
||||
kind: labels.find((label) => label.startsWith('kind:'))?.replace('kind:', '') ?? 'feature',
|
||||
})
|
||||
}
|
||||
|
||||
const byState = new Map()
|
||||
const byPrio = new Map()
|
||||
const byKind = new Map()
|
||||
for (const row of rows) {
|
||||
count(byState, row.state)
|
||||
count(byPrio, row.priority)
|
||||
count(byKind, row.kind)
|
||||
}
|
||||
const fmt = (map) =>
|
||||
[...map.entries()]
|
||||
.sort(([a], [b]) => String(a).localeCompare(String(b)))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(' ')
|
||||
|
||||
console.log(`Issues abertas: ${rows.length}`)
|
||||
console.log(` por estado: ${fmt(byState) || '(—)'}`)
|
||||
console.log(` por prio: ${fmt(byPrio) || '(—)'}`)
|
||||
console.log(` por kind: ${fmt(byKind) || '(—)'}`)
|
||||
console.log('')
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const rank = priorityRank(a.priority) - priorityRank(b.priority)
|
||||
return rank !== 0 ? rank : a.issue.number - b.issue.number
|
||||
})
|
||||
for (const row of sorted) {
|
||||
console.log(
|
||||
` #${row.issue.number} ${row.id} [${row.state}/${row.priority}] ${row.issue.title}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { positional } = parseArgs(process.argv.slice(2), new Set())
|
||||
const subcommand = positional[0]
|
||||
|
||||
if (!subcommand) {
|
||||
console.log('Uso: pnpm issue next | all')
|
||||
console.log(' next imprime a próxima Issue claimável (topo da fila do claim)')
|
||||
console.log(' all overview compacto das Issues abertas')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
if (subcommand === 'next') cmdNext()
|
||||
else if (subcommand === 'all') cmdAll()
|
||||
else die(`subcomando desconhecido: ${subcommand} (esperado: next | all)`)
|
||||
} catch (error) {
|
||||
if (error?.stderr) die(error.stderr.toString().trim())
|
||||
die(error?.message ?? String(error))
|
||||
}
|
||||
|
|
@ -136,6 +136,22 @@ export const setLabels = (number, { add = [], remove = [] }) => {
|
|||
if (add.length + remove.length > 0) gh(args)
|
||||
}
|
||||
|
||||
export const nextClaimableIssue = () => {
|
||||
const openReady = ghJson([
|
||||
'issue',
|
||||
'list',
|
||||
'--state',
|
||||
'open',
|
||||
'--label',
|
||||
'ready',
|
||||
'--limit',
|
||||
'200',
|
||||
'--json',
|
||||
'number,title,body,labels,createdAt,state',
|
||||
])
|
||||
return buildClaimQueue(openReady, issuesById())[0] ?? null
|
||||
}
|
||||
|
||||
export const parseArgs = (argv, flagsWithValue) => {
|
||||
const flags = {}
|
||||
const positional = []
|
||||
|
|
|
|||
44
scripts/lib/worktree.mjs
Normal file
44
scripts/lib/worktree.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Pure helpers for `scripts/worktree.mjs` — branch naming derived from the
|
||||
* claim-queue issue, shared with the unit tests. Naming: `<code>-<slug>`
|
||||
* where `code` is the frontmatter `id` (ex. `C15`) and `slug` is the pt-BR
|
||||
* title slugified with the repo's `src/lib/slug.ts` (accents stripped,
|
||||
* non-alphanumeric → `-`).
|
||||
*/
|
||||
|
||||
import { slugify } from '../../src/lib/slug.ts'
|
||||
|
||||
/** Strip the leading `<code> — ` (or any dash variant) off a title. */
|
||||
const stripCodePrefix = (title, code) => {
|
||||
const escaped = code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return title.replace(new RegExp(`^${escaped}\\s*[—–:-]?\\s*`), '')
|
||||
}
|
||||
|
||||
/**
|
||||
* `code` = frontmatter `id` (falls back to the leading `ID — ` token of the
|
||||
* title); `subject` = the title with that prefix removed.
|
||||
*/
|
||||
export const issueCodeAndSubject = (issue) => {
|
||||
const title = issue.title ?? ''
|
||||
const code = typeof issue.meta?.id === 'string' && issue.meta.id.length > 0 ? issue.meta.id : null
|
||||
const subject = code
|
||||
? stripCodePrefix(title, code)
|
||||
: title.replace(/^[A-Za-z0-9+]+\s*[—–:-]?\s*/, '')
|
||||
return { code, subject }
|
||||
}
|
||||
|
||||
/**
|
||||
* `<code>-<slug>` truncated to `maxLength` total characters (always keeping
|
||||
* the code). Throws when the issue has no id — fail loudly, never invent.
|
||||
*/
|
||||
export const branchNameForIssue = (issue, maxLength = 60) => {
|
||||
const { code, subject } = issueCodeAndSubject(issue)
|
||||
if (!code) {
|
||||
throw new Error(`Issue sem frontmatter id: #${issue.number} ${issue.title}`)
|
||||
}
|
||||
const slug = slugify(subject) || 'issue'
|
||||
const full = `${code}-${slug}`
|
||||
if (full.length <= maxLength) return full
|
||||
const keep = Math.max(1, maxLength - code.length - 1)
|
||||
return `${code}-${slug.slice(0, keep)}`
|
||||
}
|
||||
150
scripts/worktree.mjs
Normal file
150
scripts/worktree.mjs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* `pnpm worktree` — worktree management determinístico em torno da fila de
|
||||
* claim do projeto (mesma fila de `agent:claim` / agent pool).
|
||||
*
|
||||
* pnpm worktree next [--go] cria worktree a partir de origin/main para a
|
||||
* próxima Issue claimável; branch `<code>-<slug>`.
|
||||
* Com `--go`, imprime `cd <dir>` no fim — node
|
||||
* não muda o cwd do shell pai; o shell chamador
|
||||
* aplica (opencode/CDP usa o opencode command;
|
||||
* terminal interativo: função `worktree()` em
|
||||
* `.agents/shell/worktree.sh`, sourced no profile).
|
||||
* pnpm worktree kill [--force] destrói o worktree em que o shell atual está
|
||||
* (recusa worktree sujo sem `--force`)
|
||||
*
|
||||
* Read-only no GitHub: `next` NUNCA claima (claim = `pnpm agent:claim`).
|
||||
* Dir raiz: `~/.cursor/worktrees/teqo/` (mesma casa dos worktrees do Cursor).
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { homedir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
import { dieAgent, nextClaimableIssue, parseArgs } from './lib/agent-github.mjs'
|
||||
import { branchNameForIssue } from './lib/worktree.mjs'
|
||||
|
||||
const die = dieAgent('worktree')
|
||||
const WORKTREES_ROOT = join(homedir(), '.cursor', 'worktrees', 'teqo')
|
||||
|
||||
/** `git` wrapper; `okIfFails` swallows the failure and returns null instead. */
|
||||
const git = (args, { okIfFails = false } = {}) => {
|
||||
try {
|
||||
return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).trim()
|
||||
} catch (error) {
|
||||
if (okIfFails) return null
|
||||
const stderr = (error.stderr?.toString() ?? '').trim()
|
||||
die(stderr || error.message || `git ${args.join(' ')} falhou`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Parse `git worktree list --porcelain` into [{ path, branch }]. */
|
||||
const parseWorktreeList = (porcelain) => {
|
||||
const entries = []
|
||||
let current = null
|
||||
for (const raw of porcelain.split('\n')) {
|
||||
const line = raw.trimEnd()
|
||||
if (line.startsWith('worktree ')) {
|
||||
current = { path: line.slice('worktree '.length), branch: null }
|
||||
entries.push(current)
|
||||
} else if (line.startsWith('branch refs/heads/')) {
|
||||
if (current) current.branch = line.slice('branch refs/heads/'.length)
|
||||
} else if (line === '') {
|
||||
current = null
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
const cmdNext = (go) => {
|
||||
const pick = nextClaimableIssue()
|
||||
if (!pick) {
|
||||
die('Fila vazia — nada `ready` desbloqueado. Rode `pnpm agent:status` para ver a fila.')
|
||||
}
|
||||
|
||||
const issue = pick.issue
|
||||
const branch = branchNameForIssue({ ...issue, meta: pick.meta })
|
||||
if (git(['check-ref-format', '--allow-onelevel', branch], { okIfFails: true }) === null) {
|
||||
die(`Branch derivado inválido para refname: ${branch}`)
|
||||
}
|
||||
const dir = join(WORKTREES_ROOT, branch)
|
||||
|
||||
console.log(`Próxima da fila: #${issue.number} ${issue.title}`)
|
||||
|
||||
git(['fetch', 'origin'])
|
||||
|
||||
const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain']))
|
||||
if (entries.some((entry) => resolve(entry.path) === resolve(dir))) {
|
||||
console.log(`Worktree já existe em ${dir} — reutilizando, sem duplicar.`)
|
||||
} else {
|
||||
const branchExists =
|
||||
git(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { okIfFails: true }) !== null
|
||||
const flag = branchExists ? '-B' : '-b'
|
||||
git(['worktree', 'add', flag, branch, dir, 'origin/main'])
|
||||
|
||||
console.log('Worktree criado:')
|
||||
console.log(` branch: ${branch}`)
|
||||
console.log(` path: ${dir}`)
|
||||
console.log(' origem: origin/main')
|
||||
console.log('Issue NÃO claimada — claim continua sendo `pnpm agent:claim`.')
|
||||
}
|
||||
|
||||
if (go) console.log(`cd ${dir}`)
|
||||
}
|
||||
|
||||
const cmdKill = (force) => {
|
||||
const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain']))
|
||||
const mainRoot = entries[0]?.path
|
||||
if (!mainRoot) die('Não consegui ler os worktrees deste repo.')
|
||||
const top = git(['rev-parse', '--show-toplevel'])
|
||||
if (resolve(top) === resolve(mainRoot)) {
|
||||
die(
|
||||
'Você está no worktree principal (main) — não dá para destruí-lo. Rode `git worktree list`.',
|
||||
)
|
||||
}
|
||||
|
||||
const entry = entries.find((candidate) => resolve(candidate.path) === resolve(top))
|
||||
const branch = entry?.branch ?? null
|
||||
|
||||
const dirty = git(['-C', top, 'status', '--porcelain'])
|
||||
if (dirty && !force) {
|
||||
const sample = dirty
|
||||
.split('\n')
|
||||
.slice(0, 10)
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n')
|
||||
die(
|
||||
`Worktree ${top} está sujo. Commite/stacke ou confirme com \`pnpm worktree kill --force\`.\n${sample}`,
|
||||
)
|
||||
}
|
||||
|
||||
git(['-C', mainRoot, 'worktree', 'remove', '--force', top])
|
||||
if (branch) git(['-C', mainRoot, 'branch', '-D', branch])
|
||||
|
||||
console.log(`Worktree destruído: ${top}`)
|
||||
console.log(` branch removido: ${branch ?? '(detached — nada a remover)'}`)
|
||||
console.log(`Volte ao main: cd ${mainRoot}`)
|
||||
}
|
||||
|
||||
const { flags, positional } = parseArgs(process.argv.slice(2), new Set())
|
||||
const subcommand = positional[0]
|
||||
|
||||
if (!subcommand) {
|
||||
console.log('Uso: pnpm worktree next [--go] | kill [--force]')
|
||||
console.log(' next [--go] cria worktree da próxima Issue claimável (branch <code>-<slug>);')
|
||||
console.log(' com --go imprime `cd <dir>` no fim (quem aplica o cd: opencode')
|
||||
console.log(' command, ou a função `worktree()` de .agents/shell/worktree.sh)')
|
||||
console.log(' kill [--force] destrói o worktree em que você está (recusa sujo sem --force)')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
if (subcommand === 'next') cmdNext(Boolean(flags.go))
|
||||
else if (subcommand === 'kill') {
|
||||
if (flags.go) die('`--go` só faz sentido com `next`.')
|
||||
cmdKill(Boolean(flags.force))
|
||||
} else die(`subcomando desconhecido: ${subcommand} (esperado: next | kill)`)
|
||||
} catch (error) {
|
||||
if (error?.stderr) die(error.stderr.toString().trim())
|
||||
die(error?.message ?? String(error))
|
||||
}
|
||||
69
tests/unit/worktree.unit.spec.ts
Normal file
69
tests/unit/worktree.unit.spec.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { branchNameForIssue, issueCodeAndSubject } from '../../scripts/lib/worktree.mjs'
|
||||
|
||||
type TestIssue = {
|
||||
number: number
|
||||
title: string
|
||||
meta?: { id?: string }
|
||||
}
|
||||
|
||||
const issue = (over: Partial<TestIssue> = {}): TestIssue => ({
|
||||
number: 1,
|
||||
title: 'C15 — FullCalendar em /campanha/agenda',
|
||||
meta: { id: 'C15' },
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('issueCodeAndSubject', () => {
|
||||
it('extracts the frontmatter id and strips the em-dash prefix', () => {
|
||||
expect(issueCodeAndSubject(issue())).toEqual({
|
||||
code: 'C15',
|
||||
subject: 'FullCalendar em /campanha/agenda',
|
||||
})
|
||||
})
|
||||
|
||||
it('tolerates dash variants after the code', () => {
|
||||
for (const title of ['C15- FullCalendar', 'C15: FullCalendar', 'C15 — FullCalendar']) {
|
||||
expect(issueCodeAndSubject(issue({ title })).subject).toBe('FullCalendar')
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the leading title token when the frontmatter id is missing', () => {
|
||||
expect(issueCodeAndSubject(issue({ meta: {}, title: 'B164 — Barra de nav' }))).toEqual({
|
||||
code: null,
|
||||
subject: 'Barra de nav',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('branchNameForIssue', () => {
|
||||
it('builds <code>-<slug> with the repo slugify', () => {
|
||||
expect(branchNameForIssue(issue({ title: 'C15 — FullCalendar em /campanha/agenda' }))).toBe(
|
||||
'C15-fullcalendar-em-campanha-agenda',
|
||||
)
|
||||
})
|
||||
|
||||
it('strips accents from the pt-BR title', () => {
|
||||
expect(
|
||||
branchNameForIssue(
|
||||
issue({ title: 'B164 — Barra de navegação inferior no mobile', meta: { id: 'B164' } }),
|
||||
),
|
||||
).toBe('B164-barra-de-navegacao-inferior-no-mobile')
|
||||
})
|
||||
|
||||
it('truncates long slugs but keeps the code', () => {
|
||||
const title = 'X9 — ' + 'palavra '.repeat(20).trim()
|
||||
const branch = branchNameForIssue(issue({ title, meta: { id: 'X9' } }), 30)
|
||||
expect(branch.startsWith('X9-')).toBe(true)
|
||||
expect(branch.length).toBeLessThanOrEqual(30)
|
||||
})
|
||||
|
||||
it('throws when the issue has no code — never invents', () => {
|
||||
expect(() => branchNameForIssue(issue({ meta: {}, title: 'Sem código' }))).toThrow(
|
||||
/frontmatter/,
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue