feat: agent ops scripts (claim/register/prioritize/file-miss/promote) + roadmap issues seeded
This commit is contained in:
parent
3120d0b8fa
commit
c362b63adb
7 changed files with 484 additions and 0 deletions
|
|
@ -32,6 +32,11 @@
|
|||
"db:seed:projecao": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-projecao.mjs",
|
||||
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",
|
||||
"db:refresh:stage": "node scripts/refresh-stage.mjs",
|
||||
"agent:claim": "node scripts/agent-claim.mjs",
|
||||
"agent:register": "node scripts/agent-register.mjs",
|
||||
"agent:prioritize": "node scripts/agent-prioritize.mjs",
|
||||
"agent:file-miss": "node scripts/agent-file-miss.mjs",
|
||||
"agent:promote": "node scripts/agent-promote.mjs",
|
||||
"build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs",
|
||||
"build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs",
|
||||
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
|
||||
|
|
|
|||
134
scripts/agent-claim.mjs
Normal file
134
scripts/agent-claim.mjs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* `pnpm agent:claim` — claim the next trackable issue for this agent run.
|
||||
*
|
||||
* Queue: open issues labeled `ready`, filtered to UNBLOCKED (every frontmatter
|
||||
* `depends` id is closed or labeled `done`/`in-prod`), ordered by `prio:P0..P3`
|
||||
* then oldest first. `--dry-run` prints the queue without claiming.
|
||||
*
|
||||
* Lock otimista: re-reads the issue right before flipping labels, refuses if
|
||||
* someone else already took it (`in-progress` present / `ready` gone), then
|
||||
* swaps ready→in-progress and leaves a claim comment with the run timestamp.
|
||||
*
|
||||
* Output: the brief on stdout (issue, id, priority, plan/spec body) — the
|
||||
* agent reads it and starts; it does NOT create the branch (Cursor worktrees
|
||||
* own that) and it never merges anywhere. PRs go `--base stage`.
|
||||
*/
|
||||
|
||||
import {
|
||||
dieAgent,
|
||||
gh,
|
||||
ghJson,
|
||||
issuesById,
|
||||
labelNames,
|
||||
parseArgs,
|
||||
parseFrontmatter,
|
||||
priorityRank,
|
||||
setLabels,
|
||||
} from './lib/agent-github.mjs'
|
||||
|
||||
const die = dieAgent('claim')
|
||||
const { flags } = parseArgs(process.argv.slice(2), new Set(['issue']))
|
||||
|
||||
const openReady = ghJson([
|
||||
'issue',
|
||||
'list',
|
||||
'--state',
|
||||
'open',
|
||||
'--label',
|
||||
'ready',
|
||||
'--limit',
|
||||
'200',
|
||||
'--json',
|
||||
'number,title,body,labels,createdAt',
|
||||
])
|
||||
|
||||
const byId = issuesById()
|
||||
const doneIds = new Set(
|
||||
[...byId.entries()]
|
||||
.filter(([, issue]) => {
|
||||
const labels = labelNames(issue)
|
||||
return issue.state === 'CLOSED' || labels.includes('done') || labels.includes('in-prod')
|
||||
})
|
||||
.map(([id]) => id),
|
||||
)
|
||||
|
||||
const queue = openReady
|
||||
.map((issue) => {
|
||||
const { meta } = parseFrontmatter(issue.body)
|
||||
const depends = Array.isArray(meta.depends) ? meta.depends : []
|
||||
// A dep without an issue is a delivered roadmap item (B43/B47/B59…): they
|
||||
// predate the Issues era and are never reopened, so they are satisfied —
|
||||
// surfaced as a warning in the brief, never silently dropped.
|
||||
const satisfiedWithoutIssue = depends.filter((id) => !byId.has(id))
|
||||
const blockedBy = depends.filter((id) => byId.has(id) && !doneIds.has(id))
|
||||
return {
|
||||
issue,
|
||||
meta,
|
||||
priority: labelNames(issue).find((label) => /^prio:P[0-3]$/.test(label)) ?? 'prio:P2',
|
||||
satisfiedWithoutIssue,
|
||||
blockedBy,
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry.blockedBy.length === 0)
|
||||
.sort((a, b) => {
|
||||
const rank = priorityRank(a.priority.replace('prio:', '')) - priorityRank(b.priority.replace('prio:', ''))
|
||||
return rank !== 0 ? rank : a.issue.createdAt.localeCompare(b.issue.createdAt)
|
||||
})
|
||||
|
||||
if (flags['dry-run'] || flags['dryrun']) {
|
||||
console.log(`[agent:claim] ${queue.length} unblocked ready issue(s):`)
|
||||
for (const entry of queue) {
|
||||
console.log(` #${entry.issue.number} [${entry.priority}] ${entry.issue.title}`)
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const pick = flags.issue
|
||||
? queue.find((entry) => entry.issue.number === Number(flags.issue))
|
||||
: queue[0]
|
||||
|
||||
if (!pick) {
|
||||
die(
|
||||
flags.issue
|
||||
? `Issue #${flags.issue} is not claimable (missing, not ready, or blocked by deps).`
|
||||
: 'Nothing to claim — no unblocked issues labeled `ready`. Run with --dry-run to inspect.',
|
||||
)
|
||||
}
|
||||
|
||||
// Optimistic lock: re-read right before flipping.
|
||||
const fresh = ghJson([
|
||||
'issue',
|
||||
'view',
|
||||
String(pick.issue.number),
|
||||
'--json',
|
||||
'number,labels,state',
|
||||
])
|
||||
const freshLabels = labelNames(fresh)
|
||||
if (fresh.state !== 'OPEN' || !freshLabels.includes('ready') || freshLabels.includes('in-progress')) {
|
||||
die(`Issue #${pick.issue.number} was just claimed or closed by someone else. Re-run claim.`)
|
||||
}
|
||||
|
||||
setLabels(pick.issue.number, { add: ['in-progress'], remove: ['ready'] })
|
||||
gh([
|
||||
'issue',
|
||||
'comment',
|
||||
String(pick.issue.number),
|
||||
'--body',
|
||||
`Claimed by agent run at ${new Date().toISOString()}. Lock otimista: outro claim deve falhar e re-rodar \`pnpm agent:claim\`.`,
|
||||
])
|
||||
|
||||
const { rest } = parseFrontmatter(pick.issue.body)
|
||||
console.log(`\n[agent:claim] Claimed #${pick.issue.number} — ${pick.issue.title}`)
|
||||
console.log(` id: ${pick.meta.id ?? '(none)'} priority: ${pick.priority}`)
|
||||
if (pick.satisfiedWithoutIssue.length > 0) {
|
||||
console.log(
|
||||
` deps sem issue (roadmap entregue, satisfeitas): ${pick.satisfiedWithoutIssue.join(', ')}`,
|
||||
)
|
||||
}
|
||||
console.log(` url: https://github.com/fsolla/teqo/issues/${pick.issue.number}`)
|
||||
console.log('\n--- spec ---\n')
|
||||
console.log(rest.trim() || '(empty body — see linked plan)')
|
||||
console.log(
|
||||
'\n[agent:claim] Fluxo: implementar → fast gate → gh pr create --base stage (Closes #' +
|
||||
`${pick.issue.number}) → PARAR. Promote a main é humano (pnpm agent:promote --i-am-human).`,
|
||||
)
|
||||
41
scripts/agent-file-miss.mjs
Normal file
41
scripts/agent-file-miss.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* `pnpm agent:file-miss` — file an agent-miss issue (guardrail pipeline).
|
||||
*
|
||||
* Misses feed the progressive-guardrail harvest: each miss becomes an issue
|
||||
* with `kind:agent-miss`; periodically a human/agent harvests them into a
|
||||
* programmatic guardrail (codebaseConventions spec, ESLint rule, CI check).
|
||||
*
|
||||
* pnpm agent:file-miss -- --title "migration merged sem atualizar seed:minimal" \
|
||||
* [--body "contexto..."] [--kind agent-miss|defect]
|
||||
*/
|
||||
|
||||
import { dieAgent, gh, parseArgs } from './lib/agent-github.mjs'
|
||||
|
||||
const die = dieAgent('file-miss')
|
||||
const { flags } = parseArgs(process.argv.slice(2), new Set(['title', 'body', 'kind']))
|
||||
|
||||
if (!flags.title) {
|
||||
die('Usage: pnpm agent:file-miss -- --title <what went wrong> [--body details] [--kind agent-miss|defect]')
|
||||
}
|
||||
|
||||
const kind = flags.kind === 'defect' ? 'defect' : 'agent-miss'
|
||||
const body = [
|
||||
flags.body ?? '',
|
||||
'',
|
||||
'_Registrado por `pnpm agent:file-miss`. Harvest: avaliar guardrail programático (teste de convenção, ESLint, check de CI) e registrar em `docs/GUARDRAILS.md`._',
|
||||
].join('\n')
|
||||
|
||||
const url = gh([
|
||||
'issue',
|
||||
'create',
|
||||
'--title',
|
||||
flags.title,
|
||||
'--body',
|
||||
body,
|
||||
'--label',
|
||||
`kind:${kind}`,
|
||||
'--label',
|
||||
'prio:P2',
|
||||
])
|
||||
|
||||
console.log(`[agent:file-miss] filed ${url}`)
|
||||
22
scripts/agent-prioritize.mjs
Normal file
22
scripts/agent-prioritize.mjs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* `pnpm agent:prioritize` — re-prioritize a trackable issue.
|
||||
*
|
||||
* pnpm agent:prioritize -- <issue-number> P1
|
||||
*/
|
||||
|
||||
import { dieAgent, ghJson, labelNames, parseArgs, setLabels } from './lib/agent-github.mjs'
|
||||
|
||||
const die = dieAgent('prioritize')
|
||||
const { positional } = parseArgs(process.argv.slice(2), new Set())
|
||||
|
||||
const [number, priority] = positional
|
||||
if (!number || !/^P[0-3]$/.test(priority ?? '')) {
|
||||
die('Usage: pnpm agent:prioritize -- <issue-number> <P0|P1|P2|P3>')
|
||||
}
|
||||
|
||||
const issue = ghJson(['issue', 'view', number, '--json', 'number,title,labels'])
|
||||
const current = labelNames(issue).filter((label) => /^prio:P[0-3]$/.test(label))
|
||||
setLabels(number, { add: [`prio:${priority}`], remove: current })
|
||||
console.log(
|
||||
`[agent:prioritize] #${issue.number} — ${issue.title}: ${current.join(',') || '(none)'} → prio:${priority}`,
|
||||
)
|
||||
114
scripts/agent-promote.mjs
Normal file
114
scripts/agent-promote.mjs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* `pnpm agent:promote -- --i-am-human` — promote `stage` → `main` (production).
|
||||
*
|
||||
* HUMAN-ONLY. The flag is the review: branch protection on `main` requires a
|
||||
* human review, and this script is it. It refuses to run without the flag and
|
||||
* never runs from Cursor Automations.
|
||||
*
|
||||
* 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 requires the PR checks
|
||||
* (ci-pr: checks + migration-lock) green before merging.
|
||||
* 4. Merges. Labels on done issues gain `in-prod` for the promoted range.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
import { dieAgent, gh, ghJson, parseArgs } from './lib/agent-github.mjs'
|
||||
|
||||
const die = dieAgent('promote')
|
||||
const { flags } = parseArgs(process.argv.slice(2), new Set())
|
||||
|
||||
if (flags['i-am-human'] !== true) {
|
||||
die(
|
||||
'Refused: promote is human-only. Re-run as a human with:\n' +
|
||||
' pnpm agent:promote -- --i-am-human\n' +
|
||||
'Agents stop at merge-to-stage. Promote moves stage → main → produção (Vercel).',
|
||||
)
|
||||
}
|
||||
|
||||
const git = (args) => execFileSync('git', args, { encoding: 'utf8' }).trim()
|
||||
git(['fetch', 'origin', 'main', 'stage'])
|
||||
|
||||
// 1. Divergence: main must be an ancestor of stage.
|
||||
try {
|
||||
git(['merge-base', '--is-ancestor', 'origin/main', 'origin/stage'])
|
||||
} catch {
|
||||
die(
|
||||
'`main` divergiu de `stage` (hotfix direto em main?). Antes do promote:\n' +
|
||||
' git checkout stage && git merge origin/main && git push origin stage\n' +
|
||||
'…e espere o CI stage ficar verde de novo.',
|
||||
)
|
||||
}
|
||||
|
||||
// 2. CI stage green on the stage head.
|
||||
const stageSha = git(['rev-parse', 'origin/stage'])
|
||||
const stageRuns = ghJson([
|
||||
'run',
|
||||
'list',
|
||||
'--branch',
|
||||
'stage',
|
||||
'--workflow',
|
||||
'ci-stage.yml',
|
||||
'--limit',
|
||||
'5',
|
||||
'--json',
|
||||
'headSha,status,conclusion,url',
|
||||
])
|
||||
const latestStageRun = stageRuns.find((run) => run.headSha === stageSha)
|
||||
if (!latestStageRun || latestStageRun.status !== 'completed' || latestStageRun.conclusion !== 'success') {
|
||||
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'}`,
|
||||
)
|
||||
}
|
||||
console.log('[agent:promote] CI stage green ✓')
|
||||
|
||||
// 3. PR stage → main.
|
||||
let pr = ghJson([
|
||||
'pr',
|
||||
'list',
|
||||
'--state',
|
||||
'open',
|
||||
'--base',
|
||||
'main',
|
||||
'--head',
|
||||
'stage',
|
||||
'--json',
|
||||
'number,url',
|
||||
])[0]
|
||||
if (!pr) {
|
||||
const url = gh([
|
||||
'pr',
|
||||
'create',
|
||||
'--base',
|
||||
'main',
|
||||
'--head',
|
||||
'stage',
|
||||
'--title',
|
||||
`Promote stage → main (${new Date().toISOString().slice(0, 10)})`,
|
||||
'--body',
|
||||
'Promote humano via `pnpm agent:promote --i-am-human`. CI stage green verificado antes da criação.',
|
||||
])
|
||||
pr = { number: url.split('/').pop(), url }
|
||||
console.log(`[agent:promote] created PR ${pr.url}`)
|
||||
} else {
|
||||
console.log(`[agent:promote] reusing open PR ${pr.url}`)
|
||||
}
|
||||
|
||||
// 4. PR checks green, then merge (the human running this script IS the review
|
||||
// required by main's branch protection; --admin applies that judgment).
|
||||
const checks = ghJson(['pr', 'checks', String(pr.number), '--json', 'name,state'])
|
||||
const notGreen = checks.filter((check) => check.state !== 'SUCCESS')
|
||||
if (notGreen.length > 0) {
|
||||
die(
|
||||
`PR #${pr.number} ainda tem checks não-verdes: ${notGreen
|
||||
.map((check) => `${check.name}=${check.state}`)
|
||||
.join(', ')}. Espere o ci-pr terminar e re-rode o promote.`,
|
||||
)
|
||||
}
|
||||
|
||||
gh(['pr', 'merge', String(pr.number), '--admin', '--merge'])
|
||||
console.log(`[agent:promote] merged stage → main via PR #${pr.number}. Deploy Vercel a caminho.`)
|
||||
57
scripts/agent-register.mjs
Normal file
57
scripts/agent-register.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* `pnpm agent:register` — create a trackable issue (spec + prio + deps).
|
||||
*
|
||||
* pnpm agent:register -- --id B91 --title "Ações rápidas — X" \
|
||||
* --prio P1 --depends B79 --kind feature --plan docs/plans/x.md [--body "..."]
|
||||
*
|
||||
* Body = frontmatter (id/depends/priority) + spec (linked plan + --body text).
|
||||
* Starts life labeled `ready` (or `blocked` when --blocked).
|
||||
*/
|
||||
|
||||
import {
|
||||
dieAgent,
|
||||
gh,
|
||||
issuesById,
|
||||
parseArgs,
|
||||
serializeFrontmatter,
|
||||
} from './lib/agent-github.mjs'
|
||||
|
||||
const die = dieAgent('register')
|
||||
const { flags } = parseArgs(
|
||||
process.argv.slice(2),
|
||||
new Set(['id', 'title', 'prio', 'depends', 'kind', 'plan', 'body', 'labels']),
|
||||
)
|
||||
|
||||
if (!flags.id || !flags.title) die('Usage: pnpm agent:register -- --id <ID> --title <title> [--prio P2] [--depends A,B] [--kind feature] [--plan path] [--body text] [--labels extra,labels] [--blocked]')
|
||||
|
||||
const priority = /^P[0-3]$/.test(flags.prio ?? 'P2') ? (flags.prio ?? 'P2') : die(`Invalid --prio "${flags.prio}" (P0..P3).`)
|
||||
const depends = (flags.depends ?? '').split(',').map((v) => v.trim()).filter(Boolean)
|
||||
|
||||
const existing = issuesById().get(flags.id)
|
||||
if (existing) die(`An issue with id ${flags.id} already exists: #${existing.number} — ${existing.title}`)
|
||||
|
||||
const body = serializeFrontmatter(
|
||||
{ id: flags.id, depends, serializes: [], priority },
|
||||
[flags.plan ? `Plano: [\`${flags.plan}\`](${flags.plan})` : null, flags.body ?? '']
|
||||
.filter((chunk) => chunk !== null)
|
||||
.join('\n\n'),
|
||||
)
|
||||
|
||||
const labels = [
|
||||
flags.blocked ? 'blocked' : 'ready',
|
||||
`prio:${priority}`,
|
||||
`kind:${flags.kind ?? 'feature'}`,
|
||||
...(flags.labels ?? '').split(',').map((v) => v.trim()).filter(Boolean),
|
||||
]
|
||||
|
||||
const url = gh([
|
||||
'issue',
|
||||
'create',
|
||||
'--title',
|
||||
`${flags.id} — ${flags.title}`,
|
||||
'--body',
|
||||
body,
|
||||
...labels.flatMap((label) => ['--label', label]),
|
||||
])
|
||||
|
||||
console.log(`[agent:register] created ${url}`)
|
||||
111
scripts/lib/agent-github.mjs
Normal file
111
scripts/lib/agent-github.mjs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* Shared helpers for the agent ops scripts (`pnpm agent:*`). Everything goes
|
||||
* through the authenticated `gh` CLI — agents never get a GitHub token of
|
||||
* their own, and these scripts never touch stage/prod database URLs.
|
||||
*
|
||||
* Issue contract (spec + status + deps in GitHub Issues, per the parallel
|
||||
* paradigm plan): each trackable issue carries a YAML-ish frontmatter block
|
||||
* at the top of the body:
|
||||
*
|
||||
* ---
|
||||
* id: B73
|
||||
* depends: [B43, B65]
|
||||
* serializes: [migrations]
|
||||
* priority: P1
|
||||
* ---
|
||||
*
|
||||
* State lives in labels: ready | in-progress | blocked | done (+ in-prod).
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
import { dieWithLabel } from './cli.mjs'
|
||||
|
||||
export const STATE_LABELS = ['ready', 'in-progress', 'blocked', 'done', 'in-prod']
|
||||
export const PRIORITIES = ['P0', 'P1', 'P2', 'P3']
|
||||
export const priorityRank = (priority) => PRIORITIES.indexOf(priority)
|
||||
|
||||
export const dieAgent = (script) => dieWithLabel(`agent:${script}`)
|
||||
|
||||
/** Run gh and return stdout (throws with the gh stderr on failure). */
|
||||
export const gh = (args) =>
|
||||
execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).trim()
|
||||
|
||||
export const ghJson = (args) => JSON.parse(gh(args))
|
||||
|
||||
export const parseFrontmatter = (body) => {
|
||||
const match = /^---\n([\s\S]*?)\n---\n?/.exec(body ?? '')
|
||||
if (!match) return { meta: {}, rest: body ?? '' }
|
||||
const meta = {}
|
||||
for (const line of match[1].split('\n')) {
|
||||
const kv = /^([a-zA-Z]+):\s*(.*)$/.exec(line.trim())
|
||||
if (!kv) continue
|
||||
const [, key, raw] = kv
|
||||
meta[key] = raw.startsWith('[')
|
||||
? raw
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
: raw
|
||||
}
|
||||
return { meta, rest: body.slice(match[0].length) }
|
||||
}
|
||||
|
||||
export const serializeFrontmatter = (meta, rest) => {
|
||||
const lines = ['---']
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
lines.push(`${key}: ${Array.isArray(value) ? `[${value.join(', ')}]` : value}`)
|
||||
}
|
||||
lines.push('---', '')
|
||||
return lines.join('\n') + (rest ?? '')
|
||||
}
|
||||
|
||||
/** All issues (open or closed) that carry a frontmatter `id`, keyed by id. */
|
||||
export const issuesById = () => {
|
||||
const issues = ghJson([
|
||||
'issue',
|
||||
'list',
|
||||
'--state',
|
||||
'all',
|
||||
'--limit',
|
||||
'500',
|
||||
'--json',
|
||||
'number,title,body,state,labels',
|
||||
])
|
||||
const byId = new Map()
|
||||
for (const issue of issues) {
|
||||
const { meta } = parseFrontmatter(issue.body)
|
||||
if (typeof meta.id === 'string' && meta.id.length > 0) byId.set(meta.id, issue)
|
||||
}
|
||||
return byId
|
||||
}
|
||||
|
||||
export const labelNames = (issue) => issue.labels.map((label) => label.name)
|
||||
|
||||
export const setLabels = (number, { add = [], remove = [] }) => {
|
||||
const args = ['issue', 'edit', String(number)]
|
||||
for (const label of add) args.push('--add-label', label)
|
||||
for (const label of remove) args.push('--remove-label', label)
|
||||
if (add.length + remove.length > 0) gh(args)
|
||||
}
|
||||
|
||||
export const parseArgs = (argv, flagsWithValue) => {
|
||||
const flags = {}
|
||||
const positional = []
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (arg.startsWith('--')) {
|
||||
const name = arg.slice(2)
|
||||
if (flagsWithValue.has(name)) {
|
||||
flags[name] = argv[index + 1]
|
||||
index += 1
|
||||
} else {
|
||||
flags[name] = true
|
||||
}
|
||||
} else {
|
||||
positional.push(arg)
|
||||
}
|
||||
}
|
||||
return { flags, positional }
|
||||
}
|
||||
Loading…
Reference in a new issue