Retire Planka: os-backlog reworked to git-issues-only (ADR-0042)
Executes OpenSpec change retire-planka-git-issues-only (archived, 25/25 tasks): - os-backlog v0.3.0: Planka lib/CLI/agents deleted; issue-create/issues helpers (tea/gh/repo-file dispatch); ten-label taxonomy, human-only next - planka: rejected fail-soft citing ADR-0042 in tracker grammar, config-write, and the os-status check - Skills, SessionStart note (~687 -> ~444 tokens), /to-issues, os-status fix rewritten to the single-tracker model - All boards snapshotted + pg-dumped (ovh-vps ~/planka-final-snapshot-2026-07-16); cards migrated to jared/cc-os#61-70, jared/ops#1-21, jared/llf-schema#7-10 - Planka server decommissioned; planka gem repo archived read-only - Delta specs synced to openspec/specs/ (issue-backlog, issue-state-labels) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014HWNfGWoWnhrub4EJa1M1Y
This commit is contained in:
parent
3f76755e85
commit
0dd9bc1bee
|
|
@ -92,6 +92,10 @@ before modifying any of these:
|
||||||
find skills; Eval A/B/C harnesses. cc-os retrofit done 2026-07-12 (promoted ahead of
|
find skills; Eval A/B/C harnesses. cc-os retrofit done 2026-07-12 (promoted ahead of
|
||||||
pilots — ADR-020 amendment / `docs/adr/0020`); remaining rollout: pilot projects one at a
|
pilots — ADR-020 amendment / `docs/adr/0020`); remaining rollout: pilot projects one at a
|
||||||
time, then wider.
|
time, then wider.
|
||||||
|
- **os-backlog** (`plugins/os-backlog/`) — git-issues backlog surface (ADR-0042 retired
|
||||||
|
Planka 2026-07-16): tracker routing (`forgejo:`/`github:`/`repo:` in `.cc-os/config`),
|
||||||
|
`issue-create`/`issues` CLI, capture/list/route skills, ten-label taxonomy with
|
||||||
|
human-only `next`; non-repo/ops work lives in the private `jared/ops` Forgejo repo.
|
||||||
- **Graphify** v0.8.31 (`~/.local/bin/graphify`; PyPI package is `graphifyy`, double-y) —
|
- **Graphify** v0.8.31 (`~/.local/bin/graphify`; PyPI package is `graphifyy`, double-y) —
|
||||||
vault graph at `~/Documents/SecondBrain/graphify-out/`, per-project graphs at
|
vault graph at `~/Documents/SecondBrain/graphify-out/`, per-project graphs at
|
||||||
`<project-root>/graphify-out/` (gitignored); both disposable/rebuildable.
|
`<project-root>/graphify-out/` (gitignored); both disposable/rebuildable.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
---
|
---
|
||||||
id: "0033"
|
id: "0033"
|
||||||
date: 2026-07-13
|
date: 2026-07-13
|
||||||
status: Accepted
|
status: Superseded
|
||||||
supersedes:
|
supersedes:
|
||||||
superseded-by:
|
superseded-by: "0042"
|
||||||
affected-paths: [plugins/os-backlog/skills/route/SKILL.md, plugins/os-backlog/hooks/session_start.py, plugins/os-backlog/bin/os-backlog, .cc-os/config]
|
affected-paths: [plugins/os-backlog/skills/route/SKILL.md, plugins/os-backlog/hooks/session_start.py, plugins/os-backlog/bin/os-backlog, .cc-os/config]
|
||||||
affected-components: [os-backlog, to-issues]
|
affected-components: [os-backlog, to-issues]
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
id: "0042"
|
||||||
|
date: 2026-07-16
|
||||||
|
status: Accepted
|
||||||
|
supersedes: "0033"
|
||||||
|
superseded-by:
|
||||||
|
affected-paths: [plugins/os-backlog/, .cc-os/config, docs/adr/0033-tracker-routing-planka-is-state-git-issues-are-specs.md, docs/adr/0029-os-backlog-card-move-with-cli-enforced-column-ownership-afk-ready-cards-skip-review.md, docs/implementation-status/os-backlog.md]
|
||||||
|
affected-components: [os-backlog, to-issues, os-status, planka-api]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0042 — Retire Planka: git issues are the single tracker for state and specs
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0033 split tracking across two surfaces: Planka for task state, git issues for durable specs, conceding up front the cost of 'two tracking surfaces per code project.' A reassessment on 2026-07-16 (four-perspective subagent review + live board snapshot) found the cost never earned itself back. Empirically: since os-backlog finished building (2026-07-13), the only Planka board with any activity is cc-os itself — the plugin tracking its own construction; the one client board (philly-search-engine-marketing) froze 2026-07-09 with no card ever moved past Backlog/Waiting; all other boards hold 0-2 onboarding-era cards. All real work flowed through Forgejo/GitHub issues. Structurally: the issue-wakeup automation (ADR-0035/0036) already polls git issues, not Planka, so the surface wired into the loop that drives sessions was always the issue tracker; and the Planka integration rests on a reverse-engineered undocumented API (see vault note reference/planka-v2-api-gotchas.md: direct Postgres workarounds, no scoped tokens per upstream issue #945, an unpublished lockstep gem) — a standing maintenance tax with no team to absorb it. The one workload Planka was designed for (non-repo, human-curated work) went untested, but the user has independently concluded client management belongs in dedicated tools (Invoice Ninja et al.), making that use case untestable by construction.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Retire Planka from the operating layer. Git issues (Forgejo via tea, GitHub via gh, or in-repo via repo:<path>) become the single tracker for BOTH task state and durable specs. The tracker key grammar in .cc-os/config drops planka:<board>; forgejo:/github:/repo: remain. Task state is modeled with issue labels: priority (P0-P3) and autonomy (hitl/semi/afk-ready) labels carry over unchanged; column semantics collapse to open/closed plus a small state-label set, with human curation expressed as a human-applied 'next' label (AI never applies or removes it — preserving ADR-0029's human-curation intent without its Planka mechanics). Non-repo/process/ops work gets a home as issues in a private ops repo (repo:- or forgejo:-tracked) rather than a kanban board. ADR-0029's CLI-enforced column-ownership rules governed a surface this decision removes and are retired with it; its durable content (autonomy-label semantics, human-owned curation gate) is re-expressed in the label scheme above. The Planka server decommission, planka-api gem archival, and os-backlog plugin rework are executed via an OpenSpec change; existing live cards are migrated to issues or closed with human sign-off on the client board.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Easier: one tracking surface per project (the card-as-pointer and promotion rules of ADR-0033 become unnecessary and are dropped); the wakeup path and the tracking surface are now the same system; the os-backlog plugin sheds the planka-api gem dependency, board_ensurer/board_resolver/board_spec and most of cards.rb, the card-triage and board-audit agents, bot-credential lifecycle, and the name-only board-lookup defect; no self-hosted tracker server to patch and back up; the SessionStart process-rules note shrinks. Harder: no kanban/WIP visualization — a unified dashboard over git issues is explicitly deferred until it is a felt pain point; cross-column state expressiveness is reduced to labels; the untested non-repo/human-curated workload loses its designated surface and must prove out in an ops repo (revisit if that chafes); the retirement itself costs a migration pass over ~30 live non-Done cards and a plugin rework.
|
||||||
|
|
||||||
|
## Alternatives rejected
|
||||||
|
|
||||||
|
1) Keep ADR-0033 as-is — rejected: one week of maximally favorable live use produced zero organic pull toward the second surface, while the conceded two-surface cost (plus server, gem, credentials) kept accruing; the design's own automation already bypassed Planka. 2) Keep Planka dormant-but-installed as a hedge — rejected unanimously by all four review perspectives: dormant infra keeps the maintenance surface of 'kept' (unpatched server holding live bot credentials, gem rot, session-note tokens) while returning the value of 'removed'; if a board is ever wanted again, rebuild against the pain point that actually materializes. 3) Keep a single scoped Planka board for ops/recurring work only — rejected: the entire server+gem+credential stack would persist to serve one recurring card that an ops-repo issue models fine. 4) Columns-as-labels inside Planka — rejected: changes vocabulary, removes none of the infrastructure; the valid kernel (label-modeled state) is adopted on the git-issues side instead.
|
||||||
|
|
@ -39,7 +39,7 @@ One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`.
|
||||||
| 0030 | [Read-only work uses Claude Code plan mode; no /readonly shortcut command](0030-read-only-work-uses-claude-code-plan-mode-no-readonly-shortcut-command.md) | Accepted | 2026-07-13 |
|
| 0030 | [Read-only work uses Claude Code plan mode; no /readonly shortcut command](0030-read-only-work-uses-claude-code-plan-mode-no-readonly-shortcut-command.md) | Accepted | 2026-07-13 |
|
||||||
| 0031 | [os-context prompt-composer plugin supersedes single-file os-orchestration](0031-os-context-prompt-composer-plugin-supersedes-single-file-os-orchestration.md) | Accepted | 2026-07-13 |
|
| 0031 | [os-context prompt-composer plugin supersedes single-file os-orchestration](0031-os-context-prompt-composer-plugin-supersedes-single-file-os-orchestration.md) | Accepted | 2026-07-13 |
|
||||||
| 0032 | [AI-maintained knowledge files are indexes with progressive disclosure](0032-ai-maintained-knowledge-files-are-indexes-with-progressive-disclosure.md) | Accepted | 2026-07-13 |
|
| 0032 | [AI-maintained knowledge files are indexes with progressive disclosure](0032-ai-maintained-knowledge-files-are-indexes-with-progressive-disclosure.md) | Accepted | 2026-07-13 |
|
||||||
| 0033 | [Tracker routing: Planka is state, git issues are specs](0033-tracker-routing-planka-is-state-git-issues-are-specs.md) | Accepted | 2026-07-13 |
|
| 0033 | [Tracker routing: Planka is state, git issues are specs](0033-tracker-routing-planka-is-state-git-issues-are-specs.md) | Superseded | 2026-07-13 |
|
||||||
| 0034 | [Cross-project filing: file-dont-fix with a derived global project index](0034-cross-project-filing-file-dont-fix-with-a-derived-global-project-index.md) | Accepted | 2026-07-13 |
|
| 0034 | [Cross-project filing: file-dont-fix with a derived global project index](0034-cross-project-filing-file-dont-fix-with-a-derived-global-project-index.md) | Accepted | 2026-07-13 |
|
||||||
| 0035 | [Issue-triggered project AI wakeup via polling, not webhooks](0035-issue-triggered-project-ai-wakeup-via-polling-not-webhooks.md) | Accepted | 2026-07-13 |
|
| 0035 | [Issue-triggered project AI wakeup via polling, not webhooks](0035-issue-triggered-project-ai-wakeup-via-polling-not-webhooks.md) | Accepted | 2026-07-13 |
|
||||||
| 0036 | [tmux session convention for AI-run sessions: cc-<project>-<purpose>](0036-tmux-session-convention-for-ai-run-sessions-cc-project-purpose.md) | Accepted | 2026-07-13 |
|
| 0036 | [tmux session convention for AI-run sessions: cc-<project>-<purpose>](0036-tmux-session-convention-for-ai-run-sessions-cc-project-purpose.md) | Accepted | 2026-07-13 |
|
||||||
|
|
@ -48,4 +48,5 @@ One file per decision, `NNNN-kebab-title.md`, created via `/os-adr:create`.
|
||||||
| 0039 | [Doc-hygiene deletion autonomy is tiered on evidence quality and recoverability, not file type](0039-doc-hygiene-deletion-autonomy-is-tiered-on-evidence-quality-and-recoverability-not-file-type.md) | Accepted | 2026-07-14 |
|
| 0039 | [Doc-hygiene deletion autonomy is tiered on evidence quality and recoverability, not file type](0039-doc-hygiene-deletion-autonomy-is-tiered-on-evidence-quality-and-recoverability-not-file-type.md) | Accepted | 2026-07-14 |
|
||||||
| 0040 | [Doc-hygiene rules never write other tools' ignore surfaces (no propagate_ignore)](0040-doc-hygiene-rules-never-write-other-tools-ignore-surfaces-no-propagate-ignore.md) | Accepted | 2026-07-14 |
|
| 0040 | [Doc-hygiene rules never write other tools' ignore surfaces (no propagate_ignore)](0040-doc-hygiene-rules-never-write-other-tools-ignore-surfaces-no-propagate-ignore.md) | Accepted | 2026-07-14 |
|
||||||
| 0041 | [Determinism promotion: hygiene nudges projects toward structurally-obvious completion conventions](0041-determinism-promotion-hygiene-nudges-projects-toward-structurally-obvious-completion-conventions.md) | Accepted | 2026-07-14 |
|
| 0041 | [Determinism promotion: hygiene nudges projects toward structurally-obvious completion conventions](0041-determinism-promotion-hygiene-nudges-projects-toward-structurally-obvious-completion-conventions.md) | Accepted | 2026-07-14 |
|
||||||
|
| 0042 | [Retire Planka: git issues are the single tracker for state and specs](0042-retire-planka-git-issues-are-the-single-tracker-for-state-and-specs.md) | Accepted | 2026-07-16 |
|
||||||
<!-- adr-index:end -->
|
<!-- adr-index:end -->
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,14 @@ entry is in the leaf file named.
|
||||||
- **2026-07-15** — os-doc-hygiene lifecycle-aware hygiene shipped (ADR-0038–0041): rulebook
|
- **2026-07-15** — os-doc-hygiene lifecycle-aware hygiene shipped (ADR-0038–0041): rulebook
|
||||||
layer, lifetime taxonomy + tier matrix, no-ignore-propagation, conventions.json promotion,
|
layer, lifetime taxonomy + tier matrix, no-ignore-propagation, conventions.json promotion,
|
||||||
new `:calibrate` skill; calibration pass #1 (cc-os) passed. Detail: os-doc-hygiene leaf.
|
new `:calibrate` skill; calibration pass #1 (cc-os) passed. Detail: os-doc-hygiene leaf.
|
||||||
|
- **2026-07-16** — Planka retired (ADR-0042 supersedes ADR-0033): os-backlog reworked to
|
||||||
|
git-issues-only via OpenSpec change `retire-planka-git-issues-only` — Planka lib/CLI/agents
|
||||||
|
deleted, issue-create/issues helpers added, skills + SessionStart note rewritten, cards
|
||||||
|
migrated (cc-os → jared/cc-os issues; philly board → jared/ops + jared/llf-schema;
|
||||||
|
strays → new private jared/ops repo), boards snapshotted + pg-dumped (archive:
|
||||||
|
ovh-vps `~/planka-final-snapshot-2026-07-16/`). Server decommissioned same day
|
||||||
|
(containers/cron/creds removed); planka gem repo archived read-only. Detail:
|
||||||
|
os-backlog leaf.
|
||||||
|
|
||||||
**Remaining optional items:** additional project onboarding (one at a time, per ADR-0013);
|
**Remaining optional items:** additional project onboarding (one at a time, per ADR-0013);
|
||||||
bulk vault migration; os-adr rollout to pilot projects; os-backlog routing rollout (#14);
|
bulk vault migration; os-adr rollout to pilot projects; os-backlog routing rollout (#14);
|
||||||
|
|
@ -101,8 +109,9 @@ Read the leaf file before modifying a component.
|
||||||
- **os-adr** — ADR system (Ruby `lib/adr/` + CLIs; create/init/migrate/find skills; Eval
|
- **os-adr** — ADR system (Ruby `lib/adr/` + CLIs; create/init/migrate/find skills; Eval
|
||||||
A/B/C harnesses; cc-os retrofit done) →
|
A/B/C harnesses; cc-os retrofit done) →
|
||||||
[implementation-status/os-adr.md](implementation-status/os-adr.md)
|
[implementation-status/os-adr.md](implementation-status/os-adr.md)
|
||||||
- **os-backlog** — Planka process surface: board lifecycle, capture/list/route skills,
|
- **os-backlog** — git-issues backlog surface (ADR-0042 retired Planka 2026-07-16):
|
||||||
card CLI with column-ownership rules, triage/audit agents →
|
tracker routing (`forgejo:`/`github:`/`repo:`), issue-create/issues CLI,
|
||||||
|
capture/list/route skills, ten-label taxonomy with human-only `next` →
|
||||||
[implementation-status/os-backlog.md](implementation-status/os-backlog.md)
|
[implementation-status/os-backlog.md](implementation-status/os-backlog.md)
|
||||||
- **os-shortcuts** — user-invoked QOL commands, skills only, never hooks (ADR-0028);
|
- **os-shortcuts** — user-invoked QOL commands, skills only, never hooks (ADR-0028);
|
||||||
resident: `/os-shortcuts:wrap` session-close ritual (shipped 2026-07-12). No leaf file
|
resident: `/os-shortcuts:wrap` session-close ritual (shipped 2026-07-12). No leaf file
|
||||||
|
|
|
||||||
|
|
@ -3,42 +3,41 @@
|
||||||
_Leaf file of [../implementation-status.md](../implementation-status.md). Read on demand._
|
_Leaf file of [../implementation-status.md](../implementation-status.md). Read on demand._
|
||||||
|
|
||||||
**Global os-backlog plugin** — `cc-os/plugins/os-backlog/` (git-tracked, 2026-07-10);
|
**Global os-backlog plugin** — `cc-os/plugins/os-backlog/` (git-tracked, 2026-07-10);
|
||||||
symlinked into `~/.claude/plugins/os-backlog`; PRD Forgejo issue #9 (state pointer: Planka
|
symlinked into `~/.claude/plugins/os-backlog`; PRD Forgejo issue #9, slices #10–#17.
|
||||||
card `1816234194716592051`), slices #10–#17. Decision records: ADR-0023 (own plugin, not an
|
Decision records: ADR-0023 (own plugin, not an os-vault extension), ADR-0025 (standard Ruby
|
||||||
os-vault extension), ADR-0025 (standard Ruby plugin structure), ADR-0029 (card-move +
|
plugin structure), ADR-0029 (column ownership — subject removed by ADR-0042), **ADR-0042
|
||||||
column ownership).
|
(2026-07-16, supersedes ADR-0033): Planka retired; git issues are the single tracker for
|
||||||
|
state and specs** (OpenSpec change `retire-planka-git-issues-only`).
|
||||||
|
|
||||||
## Component inventory
|
## Component inventory (post-ADR-0042, v0.3.0)
|
||||||
|
|
||||||
- Purpose: the AI's process-management surface over Planka — deterministic tracker routing
|
- Purpose: the AI's process-management surface over git issues — deterministic tracker
|
||||||
(Planka = task state by default; git issues = implementation specs only), mid-session
|
routing (`forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`; `planka:`
|
||||||
capture, pull-only listing, board lifecycle, and the three-value autonomy convention
|
rejected fail-soft citing ADR-0042), mid-session capture as labeled issues, pull-only
|
||||||
(`hitl`/`semi`/`afk-ready`; amended per ADR-0029: afk-ready ships straight to Done, semi
|
listing, cross-project filing via the global project index.
|
||||||
stops at Review).
|
- State model: open/closed + labels. Ten canonical labels: priority `P0`–`P3`; autonomy
|
||||||
- Lib (Ruby, os-adr `lib/`+`bin/` pattern, installed planka-api gem only — no source
|
`hitl`/`semi`/`afk-ready` (semi ships to `review` label and stays open; afk-ready closes
|
||||||
coupling): `board_spec.rb` (uniform lists/labels contract), `board_ensurer.rb`
|
after verification); state `next` (human-only — AI never applies/removes), `waiting`
|
||||||
(idempotent create/repair; Planka 2.1.1 `type` fields, shared-project + owner-manager
|
(+ blocker comment), `review`. No `doing`/`done` labels: in-progress = assignment/branch
|
||||||
visibility fix, label color candidates with fallback; `archived--` rename convention),
|
activity, done = closed. Recurring-convention issues (e.g. `jared/ops#1` biweekly audit)
|
||||||
`config.rb` (`.cc-os/config` parser), `resolver.rb` (pure repo→board decision:
|
are commented per occurrence and never closed by the AI.
|
||||||
`use`/`activate`/`stop-and-discuss`; no network), `cards.rb` (add-at-Backlog, snapshot,
|
- Lib (Ruby): `tracker.rb` (grammar validation + `issues_destination`: `:git_issues` /
|
||||||
attach_label, comment, move with ownership rules), `tracker.rb` + `inspector.rb`
|
`:repo_files` / `:unrouted`), `issues.rb` (2026-07-16 — capture/list dispatch: `tea` for
|
||||||
(2026-07-12, issue #14 — tracker-format validation/remote classification + in-repo
|
forgejo, `gh` for github, `NNN-<slug>.md` frontmatter files for `repo:`; injected runner,
|
||||||
issue-file discovery; `Config.merge` preserves other keys).
|
no state labels reachable through `create`), `inspector.rb` (issue-shape classification),
|
||||||
- CLI: `bin/os-backlog` — `board-ensure`, `activate`, `archive`, `resolve`, `card-add`,
|
`config.rb`, `resolver.rb` (reduced to tracker-presence decision), `project_index.rb`,
|
||||||
`cards`, `snapshot`, `card-label`, `card-comment`, `card-move` (2026-07-12, ADR-0029),
|
`wakeup.rb`.
|
||||||
`inspect`, `config-write`; every path fails soft (one-line error, exit 1) when the
|
- CLI: `bin/os-backlog` — `resolve`, `inspect`, `config-write`, `projects`, `issue-create`
|
||||||
gem/Planka is unavailable.
|
(`--title/--body/--priority`), `issues` (grouped next/waiting/review/other). Fail-soft
|
||||||
- Skills: `/os-backlog:capture`, `/os-backlog:list` (pull-only per notification policy v2),
|
one-liners throughout.
|
||||||
`/os-backlog:route` (2026-07-12, issue #14 — tracker onboarding conversation; destination
|
- Skills: `/os-backlog:capture` (issue-create; never state labels, never `next`),
|
||||||
choice + live-history migration are named human gates) — carry the column-ownership rules
|
`/os-backlog:list` (pull-only), `/os-backlog:route` (destination proposal + idempotent
|
||||||
(AI creates at Backlog, Doing→Review at most, never Next, never self-assigns `hitl`;
|
ten-label ensure via tea/gh). SessionStart note rewritten 2026-07-16 (~444 tokens,
|
||||||
"working a card means moving it" contract added 2026-07-12).
|
down from ~687 measured): capture / routing / cross-project / human-only-`next` /
|
||||||
- Agents: `agents/card-triage.md` (priority + autonomy labels only; ambiguity → `hitl`),
|
working-state / autonomy / recurring rules.
|
||||||
`agents/board-audit.md` (four drift classes; writes at most comments).
|
- Tests: 88 runs / 203 assertions (2026-07-16) with fake CLI runners; no live API.
|
||||||
- Tests: `tests/` — 68 runs/153 assertions as of 2026-07-12 (35 → 54 → 58 → 68 across the
|
Companion os-status `tracker-configured` check validates the reduced grammar and rejects
|
||||||
slices) against an in-memory `FakePlankaClient`; no live API. Companion os-status check
|
`planka:` with the ADR-0042 advisory.
|
||||||
`tracker-configured` (issue #11) validates the `tracker` key grammar and nudges
|
|
||||||
unconfigured git projects (daily-snoozed).
|
|
||||||
|
|
||||||
## Build and verification history
|
## Build and verification history
|
||||||
|
|
||||||
|
|
@ -135,5 +134,28 @@ column ownership).
|
||||||
Known residual (captured as a Backlog card): board lookup is name-only across ALL Planka
|
Known residual (captured as a Backlog card): board lookup is name-only across ALL Planka
|
||||||
projects — duplicate board names in different projects can resolve to the wrong board.
|
projects — duplicate board names in different projects can resolve to the wrong board.
|
||||||
Suite 138 runs / 295 assertions / 0 failures.
|
Suite 138 runs / 295 assertions / 0 failures.
|
||||||
|
- **Planka retirement rework (2026-07-16, ADR-0042, OpenSpec change
|
||||||
|
`retire-planka-git-issues-only`):** after a week live, all real work flowed through git
|
||||||
|
issues while boards held only the plugin's own build cards — Planka retired. Deleted:
|
||||||
|
`board_ensurer`/`board_resolver`/`board_spec`/`cards`/`triage_check` (+ triage hook),
|
||||||
|
both agents, planka-api gem wiring, `FakePlankaClient`, nine card/board CLI subcommands.
|
||||||
|
Added: `issues.rb` + `issue-create`/`issues` CLI (live-verified; fixed tea field
|
||||||
|
`assignees` — `assignee` is not a valid tea list field). Skills + SessionStart note +
|
||||||
|
`/to-issues` + os-status fix skill rewritten to the single-tracker model. Migration:
|
||||||
|
all 9 boards snapshotted (JSON + comments) + Postgres dump on ovh-vps; snapshot archived
|
||||||
|
in new private `jared/ops` Forgejo repo (canonical labels; recurring audit issue
|
||||||
|
`jared/ops#1`); cc-os non-Done cards → issues jared/cc-os#61–70 (3 skipped: pointer to
|
||||||
|
closed #25, duplicate of open #8, obsoleted board-lookup card); ovh-prod + ruby-gems
|
||||||
|
strays → jared/ops#2–3; `planka` board's 18 cards obsolete (gem build history, covered
|
||||||
|
by snapshot); `tracker=` rewritten in all 6 indexed projects (no `planka:` values
|
||||||
|
remain). Suite 88 runs / 203 assertions; os-status 80 tests.
|
||||||
|
- **Human gates closed (2026-07-16):** philly per-card pass done — 4 repo cards →
|
||||||
|
jared/llf-schema#7–10, 18 client cards → jared/ops#4–21, guardrails pin closed (vault
|
||||||
|
note is the record). Decommission done same day: bot creds deleted, Planka
|
||||||
|
containers/cron/service dir removed from ovh-vps (snapshot + pg dump + service configs
|
||||||
|
kept at `~/planka-final-snapshot-2026-07-16/`), github.com/jaredswanson/planka archived
|
||||||
|
read-only with an ADR-0042 README banner.
|
||||||
- **Outstanding:** #14 residual (onboard one more project — operational hitl); wakeup
|
- **Outstanding:** #14 residual (onboard one more project — operational hitl); wakeup
|
||||||
rollout (first opt-in project + poller machine + cadence).
|
rollout (first opt-in project + poller machine + cadence); jared/cc-os#74 — fold
|
||||||
|
ADR-0025 compliance into os-backlog (bin/wakeup-poll second entry point, README gem
|
||||||
|
deps), filed by the standards audit.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-16
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
# Design: retire-planka-git-issues-only
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
os-backlog today (post-slice-8, 2026-07-13): Ruby lib + CLI over both a Planka board model
|
||||||
|
(board-ensure/resolve/card-*) and tracker inspection (`inspect`, `tracker.rb`,
|
||||||
|
`project_index.rb`), skills (capture/list/route), two Planka agents, a SessionStart
|
||||||
|
rules note (~447 tokens), an os-status grammar check, and a wakeup poller that already
|
||||||
|
reads git issues. ADR-0042 retires the Planka half. The issue-side plumbing (`tea` for
|
||||||
|
Forgejo, `gh` for GitHub, in-repo files for `repo:`) already exists and is what real work
|
||||||
|
has used. Constraint: the plugin is globally installed and ambient on every machine — the
|
||||||
|
rework must land whole (no half-migrated grammar), and source edits require
|
||||||
|
`bin/refresh-plugins` to reach sessions.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- One tracker per project holds both state and specs; `planka:` gone from the grammar.
|
||||||
|
- Preserve the durable process contracts: mid-session capture, pull-only listing,
|
||||||
|
priority + autonomy labels, human-only curation gate, cross-project filing.
|
||||||
|
- Leave zero dormant Planka infrastructure: cards migrated, server down, gem archived,
|
||||||
|
credentials revoked.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- A unified dashboard over git issues (explicitly deferred until it is a felt pain point).
|
||||||
|
- Reworking the wakeup poller (already issue-based; only its label reads are re-checked).
|
||||||
|
- Bulk re-onboarding of projects beyond rewriting their `tracker=` values.
|
||||||
|
- Any new home for client management (Invoice Ninja et al. own that; out of scope).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **State model = open/closed + labels, no status-label state machine.** Columns
|
||||||
|
Backlog/Next/Doing/Waiting/Review/Done collapse to: open issue (backlog), `next` label
|
||||||
|
(human-curated), assignee/linked-branch activity (doing — no label to maintain),
|
||||||
|
`waiting` label + a blocker comment, `review` label (semi work shipped), closed (done).
|
||||||
|
Rationale: every label the AI must maintain is a compliance cost the incentives review
|
||||||
|
flagged; keep only labels that carry information a human filters on. Alternative
|
||||||
|
rejected: full column-equivalent label set (`doing`, `done`) — duplicates what issue
|
||||||
|
state and assignment already express.
|
||||||
|
2. **Curation gate: `next` is human-only in skill/hook prose, not CLI-enforced.**
|
||||||
|
ADR-0029's CLI enforcement existed because Planka moves were CLI-mediated; issue labels
|
||||||
|
are set via tea/gh which os-backlog does not proxy. The rule lives in the SessionStart
|
||||||
|
note + skills, and the session-audit rubric (os-context category 8) measures compliance.
|
||||||
|
Alternative rejected: wrapping tea/gh in an enforcing CLI — rebuilds the proxy layer the
|
||||||
|
retirement is deleting.
|
||||||
|
3. **Non-repo/ops work: a private `ops` Forgejo repo, `tracker=forgejo:jared/ops`.**
|
||||||
|
The recurring Operations card becomes a recurring-convention issue there (comment +
|
||||||
|
reopen/leave-open, never close — mirrors the existing recurrence contract). Alternative
|
||||||
|
rejected: `repo:<path>` files — loses labels and the wakeup/query surface for the one
|
||||||
|
place recurring process work lives.
|
||||||
|
4. **Migration before demolition, human gate on client data.** Card migration runs while
|
||||||
|
Planka is still up; philly-search-engine-marketing (23 cards, real client backlog) is
|
||||||
|
migrated only after per-card human sign-off (destination: the client project's tracker
|
||||||
|
or the ops repo). cc-os non-Done cards → Forgejo `jared/cc-os` issues. Done cards are
|
||||||
|
not migrated (history stays in a final JSON snapshot archived to the ops repo before
|
||||||
|
decommission). Rollback: the snapshot + a Postgres dump taken before server teardown.
|
||||||
|
5. **Code removal is deletion, not deprecation.** `board_ensurer/board_resolver/
|
||||||
|
board_spec/cards` (Planka transport), both agents, and gem wiring are deleted with their
|
||||||
|
tests; `tracker.rb`/`inspector.rb`/`config.rb`/`project_index.rb`/`wakeup.rb` survive.
|
||||||
|
`Config#planka_board` and `Resolver` board logic go. Grammar validation rejects
|
||||||
|
`planka:` with a pointer to ADR-0042. Alternative rejected: feature-flagging Planka off
|
||||||
|
— dormant-code variant of the dormant-server trap.
|
||||||
|
6. **planka-api gem: archive the repo, keep no dependency.** The gem is user-authored and
|
||||||
|
unpublished; archival (read-only, README pointer to ADR-0042) preserves the
|
||||||
|
reverse-engineering knowledge without implying maintenance. The vault gotchas note
|
||||||
|
stays — it documents the API, not our use of it.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Untested non-repo workload loses its designated surface] → ops-repo issues are the
|
||||||
|
cheap test ADR-0042 calls for; revisit via a new ADR if it chafes in practice.
|
||||||
|
- [AI label discipline unenforced] → session-audit rubric already measures
|
||||||
|
tracker-routing compliance; the label contract is smaller than the column contract it
|
||||||
|
replaces (fewer states, fewer rules).
|
||||||
|
- [Client board migration loses nuance (comments, label history)] → per-card human gate;
|
||||||
|
full JSON snapshot archived first.
|
||||||
|
- [Stragglers: `.cc-os/config` files on other machines still saying `planka:`] →
|
||||||
|
config-write/os-status check rejects the value with a one-line re-route instruction;
|
||||||
|
fail-soft, never blocks a session.
|
||||||
|
- [Forgejo becomes a single point of failure for process state] → it already was for
|
||||||
|
specs and wakeup; repos sync to it from every machine and it is backed up with the VPS.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Land the plugin rework + spec deltas (tests green, `bin/refresh-plugins`).
|
||||||
|
2. Snapshot all boards to JSON; archive snapshot in the ops repo; Postgres dump on ovh-vps.
|
||||||
|
3. Create `jared/ops` repo + label set; move the recurring audit issue there.
|
||||||
|
4. Migrate cc-os non-Done cards → `jared/cc-os` issues (labels carried over).
|
||||||
|
5. Human-gated pass over philly-search-engine-marketing cards.
|
||||||
|
6. Rewrite `tracker=` in onboarded projects' `.cc-os/config`.
|
||||||
|
7. Revoke bot credentials; decommission Planka on ovh-vps; archive the gem repo.
|
||||||
|
8. Update docs: implementation-status leaf + index line, CLAUDE.md inventory line,
|
||||||
|
backlog-pilot vault note.
|
||||||
|
|
||||||
|
Rollback (any step before 7): Planka is still running; restore config values. After 7:
|
||||||
|
restore from Postgres dump — accepted as effectively one-way once step 7 runs.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Whether `philly-search-engine-marketing` cards land in a client repo tracker or the ops
|
||||||
|
repo (human decision during step 5).
|
||||||
|
- Which machine's cron (if any) hosts the wakeup poller — pre-existing open item, not
|
||||||
|
blocked by this change.
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Proposal: retire-planka-git-issues-only
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
ADR-0042 (2026-07-16, supersedes ADR-0033) retires Planka: after a week of live use, all
|
||||||
|
real work flowed through git issues while Planka boards held only the plugin's own build
|
||||||
|
cards; the wakeup automation already polls issues, not Planka; and the integration rests on
|
||||||
|
a reverse-engineered API plus an unpublished lockstep gem — a standing maintenance tax a
|
||||||
|
solo operator shouldn't carry for an unused surface. os-backlog must be reworked so git
|
||||||
|
issues are the single tracker for both task state and durable specs.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **BREAKING**: `planka:<board>` is removed from the `.cc-os/config` tracker grammar;
|
||||||
|
`forgejo:` / `github:` / `repo:` remain. Existing `planka:` configs must be re-routed.
|
||||||
|
- os-backlog capture/list retarget to git issues: `/os-backlog:capture` files an issue with
|
||||||
|
priority (P0–P3) and autonomy (hitl/semi/afk-ready) labels; `/os-backlog:list` queries
|
||||||
|
issues instead of card snapshots.
|
||||||
|
- Column state collapses to open/closed plus labels; human curation becomes a human-only
|
||||||
|
`next` label (AI never applies or removes it — carries ADR-0029's curation gate forward
|
||||||
|
without its Planka mechanics). ADR-0029's CLI column-ownership enforcement retires with
|
||||||
|
the surface it governed.
|
||||||
|
- Planka-specific code retires: `board_ensurer.rb`, `board_resolver.rb`, `board_spec.rb`,
|
||||||
|
the Planka transport in `cards.rb`, `card-triage` and `board-audit` agents, the
|
||||||
|
`planka-api` gem dependency, and bot-credential plumbing.
|
||||||
|
- SessionStart process-rules note rewritten for the single-tracker model (smaller; drops
|
||||||
|
column-ownership and promotion rules; keeps capture/routing/cross-project/autonomy rules).
|
||||||
|
- `/os-backlog:route`, `/to-issues`, os-status `tracker-configured`, and the project index
|
||||||
|
updated to the reduced grammar (the spec/state boundary and card-as-pointer rules vanish —
|
||||||
|
one destination holds both).
|
||||||
|
- Live-card migration: ~30 non-Done cards migrated to issues or closed (client board
|
||||||
|
philly-search-engine-marketing gated on human sign-off); recurring Operations card moves
|
||||||
|
to an ops home. Then: Planka server decommissioned on ovh-vps, `planka-api` gem archived.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `issue-backlog`: single-tracker backlog management over git issues — tracker key grammar
|
||||||
|
(`forgejo:`/`github:`/`repo:`), deterministic routing, mid-session capture as labeled
|
||||||
|
issues, pull-only listing, cross-project filing via the project index.
|
||||||
|
- `issue-state-labels`: the label taxonomy that replaces board columns — priority and
|
||||||
|
autonomy label semantics, the human-only `next` curation label, and the AI
|
||||||
|
working-state contract (what the AI may set/clear on an issue and when).
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
_None — os-backlog predates OpenSpec coverage; no existing spec governs it._
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `plugins/os-backlog/` (lib, bin, skills, agents, hooks, tests — major rework; ~700–900
|
||||||
|
lib lines and both agents removed).
|
||||||
|
- `~/.agents/skills/to-issues/SKILL.md` (destination step simplifies: no pointer card).
|
||||||
|
- os-status `tracker-configured` check (grammar change).
|
||||||
|
- `.cc-os/config` in every onboarded project currently set to `planka:` (cc-os itself plus
|
||||||
|
boards created 2026-07-13).
|
||||||
|
- External systems: Planka server on ovh-vps (decommission), `planka-api` gem repo
|
||||||
|
(archive), Planka bot credentials (revoke; closes the open credvault-import card).
|
||||||
|
- Decision records: ADR-0042 (recorded), ADR-0033 (superseded), ADR-0029 (subject removed),
|
||||||
|
ADR-0034 (project index survives, tracker values change), ADR-0035/0036 (unaffected —
|
||||||
|
already issue-based).
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
# issue-backlog — delta spec
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Tracker key grammar excludes Planka
|
||||||
|
The `.cc-os/config` `tracker` key SHALL accept exactly `forgejo:<owner>/<repo>`,
|
||||||
|
`github:<owner>/<repo>`, or `repo:<path>`. `config-write` and the os-status
|
||||||
|
`tracker-configured` check SHALL reject any other value, including `planka:<board>`, with a
|
||||||
|
one-line error naming ADR-0042 and pointing to `/os-backlog:route`; rejection SHALL never
|
||||||
|
block or fail a session (fail-soft).
|
||||||
|
|
||||||
|
#### Scenario: Legacy planka tracker value encountered
|
||||||
|
- **WHEN** a config containing `tracker=planka:cc-os` is read by config-write validation or
|
||||||
|
the os-status check
|
||||||
|
- **THEN** the value is rejected with a one-line message citing ADR-0042 and suggesting
|
||||||
|
`/os-backlog:route`, and the session continues normally
|
||||||
|
|
||||||
|
#### Scenario: Valid git tracker accepted
|
||||||
|
- **WHEN** `config-write` is invoked with `tracker=forgejo:jared/cc-os`
|
||||||
|
- **THEN** the key is written, other config keys and comments are preserved, and the
|
||||||
|
project index is upserted
|
||||||
|
|
||||||
|
### Requirement: Capture files a labeled issue on the configured tracker
|
||||||
|
`/os-backlog:capture` SHALL create an issue on the project's configured tracker (via `tea`
|
||||||
|
for forgejo, `gh` for github, an issue file for `repo:`) carrying a priority label (P0–P3)
|
||||||
|
when known and no state labels; capture SHALL never apply the `next` label. When no tracker
|
||||||
|
key is configured in a git project, capture SHALL suggest `/os-backlog:route` once and
|
||||||
|
otherwise degrade to reporting what it could not do.
|
||||||
|
|
||||||
|
#### Scenario: Mid-session capture on a Forgejo-tracked project
|
||||||
|
- **WHEN** deferred work surfaces in a project with `tracker=forgejo:jared/cc-os`
|
||||||
|
- **THEN** a new open issue is created on that repo via `tea` with the title, a body noting
|
||||||
|
the originating context, and any known priority label — and nothing else
|
||||||
|
|
||||||
|
#### Scenario: Capture with no tracker configured
|
||||||
|
- **WHEN** capture is invoked in a git project whose config has no tracker key
|
||||||
|
- **THEN** no issue is created; the skill suggests `/os-backlog:route` once and exits
|
||||||
|
without error
|
||||||
|
|
||||||
|
### Requirement: Listing is pull-only over issues
|
||||||
|
`/os-backlog:list` SHALL query the configured tracker's open issues (title, number, labels,
|
||||||
|
assignee) and present them grouped by state (next / waiting / review / other open). It
|
||||||
|
SHALL run only on explicit user request; no hook or skill SHALL inject issue lists into a
|
||||||
|
session unasked.
|
||||||
|
|
||||||
|
#### Scenario: User asks what's on the backlog
|
||||||
|
- **WHEN** the user explicitly asks what is on the backlog or what's next
|
||||||
|
- **THEN** open issues from the configured tracker are listed grouped by state labels, with
|
||||||
|
`next`-labeled issues shown first
|
||||||
|
|
||||||
|
### Requirement: Cross-project filing targets the other project's tracker
|
||||||
|
WHEN work belonging to a different project surfaces, the system SHALL resolve that
|
||||||
|
project's tracker via the global project index (`os-backlog projects`) and file a labeled
|
||||||
|
issue there using the Discoverer template, rather than editing the other project or filing
|
||||||
|
locally. Priority/autonomy labeling of the filed issue remains the receiving project's job.
|
||||||
|
|
||||||
|
#### Scenario: Discovered defect in another indexed project
|
||||||
|
- **WHEN** a session in project A finds a bug belonging to indexed project B
|
||||||
|
- **THEN** an issue is created on B's configured tracker with the Discoverer template, no
|
||||||
|
autonomy or priority labels are applied by A, and no local copy is kept
|
||||||
|
|
||||||
|
### Requirement: One tracker holds both state and specs
|
||||||
|
A project SHALL have exactly one configured tracker destination; durable specs
|
||||||
|
(tracer-bullet slices from /to-issues, PRDs) and task-state items are issues on that same
|
||||||
|
tracker. The system SHALL NOT create pointer artifacts on a second tracking surface.
|
||||||
|
|
||||||
|
#### Scenario: /to-issues publishes a slice chain
|
||||||
|
- **WHEN** /to-issues publishes tracer-bullet slices for a project with a configured git
|
||||||
|
tracker
|
||||||
|
- **THEN** the slices become issues on that tracker and no pointer card or secondary
|
||||||
|
tracking artifact is created anywhere
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
# issue-state-labels — delta spec
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Canonical label taxonomy
|
||||||
|
The backlog label set on every routed tracker SHALL be: priority `P0` `P1` `P2` `P3`;
|
||||||
|
autonomy `hitl` `semi` `afk-ready`; state `next` `waiting` `review`. `/os-backlog:route`
|
||||||
|
SHALL ensure these labels exist (idempotently) on forgejo/github trackers when onboarding.
|
||||||
|
No `doing` or `done` label exists: in-progress is expressed by assignment/branch activity,
|
||||||
|
completion by closing the issue.
|
||||||
|
|
||||||
|
#### Scenario: Route onboards a Forgejo repo
|
||||||
|
- **WHEN** `/os-backlog:route` configures `tracker=forgejo:<owner>/<repo>`
|
||||||
|
- **THEN** all ten canonical labels exist on the repo afterwards, and re-running route
|
||||||
|
creates nothing new
|
||||||
|
|
||||||
|
### Requirement: The next label is human-only
|
||||||
|
The `next` label marks human-curated priority. The AI SHALL never apply or remove `next`
|
||||||
|
on any issue, in any project, under any instruction short of an explicit direct user
|
||||||
|
request naming the specific issue.
|
||||||
|
|
||||||
|
#### Scenario: AI triages a backlog
|
||||||
|
- **WHEN** an AI session labels or files issues during triage or capture
|
||||||
|
- **THEN** `next` is neither added to nor removed from any issue
|
||||||
|
|
||||||
|
### Requirement: Autonomy label semantics
|
||||||
|
`hitl` issues are human-owned and SHALL never be picked up autonomously. `semi` issues MAY
|
||||||
|
be worked autonomously through their mechanical parts, SHALL stop at named decision gates,
|
||||||
|
and on shipping SHALL receive the `review` label instead of being closed. `afk-ready`
|
||||||
|
issues, once shipped AND verified, SHALL be closed directly. Assigning autonomy labels is
|
||||||
|
the responsible project's job; ambiguity resolves to `hitl`.
|
||||||
|
|
||||||
|
#### Scenario: Semi-autonomy work ships
|
||||||
|
- **WHEN** an AI session completes the mechanical work of a `semi` issue
|
||||||
|
- **THEN** the issue receives the `review` label, a comment summarizing what shipped, and
|
||||||
|
remains open for human sign-off
|
||||||
|
|
||||||
|
#### Scenario: Afk-ready work ships and verifies
|
||||||
|
- **WHEN** an AI session completes and verifies work on an `afk-ready` issue
|
||||||
|
- **THEN** the issue is closed with a closing comment linking the verification evidence
|
||||||
|
|
||||||
|
### Requirement: Working an issue means recording state on it
|
||||||
|
An AI session working an issue SHALL record state transitions on the issue itself: a
|
||||||
|
comment when work starts, the `waiting` label plus a blocker comment when blocked, and the
|
||||||
|
shipping transition per its autonomy label. Blocked issues SHALL have `waiting` removed
|
||||||
|
when work resumes.
|
||||||
|
|
||||||
|
#### Scenario: Work blocks on an external dependency
|
||||||
|
- **WHEN** in-progress issue work hits a blocker the session cannot resolve
|
||||||
|
- **THEN** the issue gains the `waiting` label and a comment naming the blocker, and the
|
||||||
|
session moves on
|
||||||
|
|
||||||
|
### Requirement: Recurring process issues are never closed
|
||||||
|
An issue representing a recurring process (e.g. the biweekly orchestration audit in the
|
||||||
|
ops repo) SHALL be worked by commenting each occurrence's outcome on it and leaving it
|
||||||
|
open; closing it is a human-only action.
|
||||||
|
|
||||||
|
#### Scenario: Recurring audit completes an occurrence
|
||||||
|
- **WHEN** an AI session completes one occurrence of a recurring-convention issue
|
||||||
|
- **THEN** the outcome is added as a comment and the issue stays open with its labels
|
||||||
|
unchanged
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Tasks: retire-planka-git-issues-only
|
||||||
|
|
||||||
|
## 1. Plugin rework — grammar and lib (TDD throughout)
|
||||||
|
|
||||||
|
- [x] 1.1 Reject `planka:` in tracker grammar: `Backlog::Tracker` validation + `config-write`
|
||||||
|
+ os-status `tracker-configured` check emit the one-line ADR-0042 message, fail-soft
|
||||||
|
(tests first)
|
||||||
|
- [x] 1.2 Delete Planka lib: `board_ensurer.rb`, `board_resolver.rb`, `board_spec.rb`,
|
||||||
|
Planka transport in `cards.rb`, `Config#planka_board`, board logic in `resolver.rb`,
|
||||||
|
planka-api gem wiring; delete their tests and the `FakePlankaClient`
|
||||||
|
- [x] 1.3 Remove CLI subcommands `board-ensure`, `activate`, `archive`, `card-add`, `cards`,
|
||||||
|
`snapshot`, `card-label`, `card-comment`, `card-move`; keep `resolve`, `inspect`,
|
||||||
|
`config-write`, `projects`; suite green
|
||||||
|
- [x] 1.4 Add issue-side capture/list helpers (tea/gh/repo-file dispatch per issue-backlog
|
||||||
|
spec) with tests against fake CLI outputs
|
||||||
|
|
||||||
|
## 2. Plugin rework — skills, agents, hooks
|
||||||
|
|
||||||
|
- [x] 2.1 Rewrite `/os-backlog:capture` and `/os-backlog:list` to the issue model
|
||||||
|
(labeled-issue create; pull-only grouped listing)
|
||||||
|
- [x] 2.2 Rewrite `/os-backlog:route` for the reduced grammar: destination proposal,
|
||||||
|
canonical ten-label ensure (idempotent), migration of in-repo items; drop
|
||||||
|
spec/state-boundary and pointer-card prose
|
||||||
|
- [x] 2.3 Delete `agents/card-triage.md` and `agents/board-audit.md`
|
||||||
|
- [x] 2.4 Rewrite `hooks/session_start.py` note per ADR-0042: keep capture / routing /
|
||||||
|
cross-project / autonomy-label / recurring-issue rules; add the human-only `next`
|
||||||
|
rule; drop column-ownership and promotion rules; verify token count shrank
|
||||||
|
- [x] 2.5 Update `/to-issues` (global, `~/.agents/skills/to-issues/`): destination step
|
||||||
|
reads the reduced grammar; remove the Planka pointer-card branch
|
||||||
|
- [x] 2.6 Confirm wakeup poller label reads match the new taxonomy (no `doing`/`done`
|
||||||
|
labels); adjust tests if needed
|
||||||
|
- [x] 2.7 Run `bin/refresh-plugins`; smoke-test capture + list live in cc-os
|
||||||
|
|
||||||
|
## 3. Data migration (Planka still up; order matters)
|
||||||
|
|
||||||
|
- [x] 3.1 Snapshot all boards to JSON via the API; take a Postgres dump on ovh-vps
|
||||||
|
- [x] 3.2 Create private `jared/ops` Forgejo repo with the canonical label set; archive the
|
||||||
|
board snapshot JSON in it
|
||||||
|
- [x] 3.3 Recreate the recurring "Biweekly orchestration IRL audit" as a
|
||||||
|
recurring-convention issue in `jared/ops`; update the os-context audit skill's
|
||||||
|
pointer to it
|
||||||
|
- [x] 3.4 Migrate cc-os non-Done cards (9 Backlog, 2 Waiting, 2 Review) to `jared/cc-os`
|
||||||
|
Forgejo issues with labels carried over; note card-comment context in issue bodies
|
||||||
|
- [x] 3.5 HUMAN GATE: per-card pass over philly-search-engine-marketing (17 Backlog,
|
||||||
|
6 Waiting) — user decides destination (client tracker vs ops repo vs close) per card
|
||||||
|
(2026-07-16: 4 repo cards → jared/llf-schema#7–10, 18 client cards → jared/ops#4–21,
|
||||||
|
guardrails pin closed — vault note is the durable record)
|
||||||
|
- [x] 3.6 Close/ignore remaining onboarding-era boards (infrastructure, ovh-prod,
|
||||||
|
ruby-gems, planka, apprise-api) — contents are Done or stale; covered by snapshot
|
||||||
|
- [x] 3.7 Rewrite `tracker=` in `.cc-os/config` of all onboarded projects (query
|
||||||
|
`os-backlog projects` for the list); verify project index rows update
|
||||||
|
|
||||||
|
## 4. Decommission (one-way; only after 3.x verified)
|
||||||
|
|
||||||
|
- [x] 4.1 HUMAN GATE: confirm migration complete and snapshot/dump archived (user confirmed
|
||||||
|
2026-07-16: decommission right after philly pass)
|
||||||
|
- [x] 4.2 Revoke/delete the Planka bot credentials; close the open credvault-import card's
|
||||||
|
successor issue as obsolete (bot-credentials.env deleted on ovh-vps; no successor
|
||||||
|
issue existed — the credvault-import card sat in Done and was never migrated; the
|
||||||
|
import is moot with the bot account gone)
|
||||||
|
- [x] 4.3 Stop and remove the Planka service on ovh-vps (compose down, remove from
|
||||||
|
backups/monitoring); keep the Postgres dump archived (containers/cron/service dir
|
||||||
|
removed; no external backup/monitoring hooks found; dump + board JSON + service
|
||||||
|
configs preserved at ovh-vps `~/planka-final-snapshot-2026-07-16/`)
|
||||||
|
- [x] 4.4 Archive the `planka-api` gem repo (read-only, README pointing at ADR-0042)
|
||||||
|
(github.com/jaredswanson/planka archived; README banner commit e0472c4)
|
||||||
|
|
||||||
|
## 5. Records
|
||||||
|
|
||||||
|
- [x] 5.1 Update `docs/implementation-status/os-backlog.md` (rework entry) + one-line index
|
||||||
|
headline; update the CLAUDE.md inventory bullet for os-backlog
|
||||||
|
- [x] 5.2 Update the backlog-pilot vault note (pilot outcome: Planka retired per ADR-0042)
|
||||||
|
and the auto-memory `backlog-pilot-status` entry
|
||||||
|
- [x] 5.3 Verify ADR index shows 0033 Superseded by 0042; add a status note to ADR-0029
|
||||||
|
body is NOT needed (subject removed — covered by 0042 text); confirm via
|
||||||
|
`/os-adr:find` on plugins/os-backlog/
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
# issue-backlog
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Tracker key grammar excludes Planka
|
||||||
|
The `.cc-os/config` `tracker` key SHALL accept exactly `forgejo:<owner>/<repo>`,
|
||||||
|
`github:<owner>/<repo>`, or `repo:<path>`. `config-write` and the os-status
|
||||||
|
`tracker-configured` check SHALL reject any other value, including `planka:<board>`, with a
|
||||||
|
one-line error naming ADR-0042 and pointing to `/os-backlog:route`; rejection SHALL never
|
||||||
|
block or fail a session (fail-soft).
|
||||||
|
|
||||||
|
#### Scenario: Legacy planka tracker value encountered
|
||||||
|
- **WHEN** a config containing `tracker=planka:cc-os` is read by config-write validation or
|
||||||
|
the os-status check
|
||||||
|
- **THEN** the value is rejected with a one-line message citing ADR-0042 and suggesting
|
||||||
|
`/os-backlog:route`, and the session continues normally
|
||||||
|
|
||||||
|
#### Scenario: Valid git tracker accepted
|
||||||
|
- **WHEN** `config-write` is invoked with `tracker=forgejo:jared/cc-os`
|
||||||
|
- **THEN** the key is written, other config keys and comments are preserved, and the
|
||||||
|
project index is upserted
|
||||||
|
|
||||||
|
### Requirement: Capture files a labeled issue on the configured tracker
|
||||||
|
`/os-backlog:capture` SHALL create an issue on the project's configured tracker (via `tea`
|
||||||
|
for forgejo, `gh` for github, an issue file for `repo:`) carrying a priority label (P0–P3)
|
||||||
|
when known and no state labels; capture SHALL never apply the `next` label. When no tracker
|
||||||
|
key is configured in a git project, capture SHALL suggest `/os-backlog:route` once and
|
||||||
|
otherwise degrade to reporting what it could not do.
|
||||||
|
|
||||||
|
#### Scenario: Mid-session capture on a Forgejo-tracked project
|
||||||
|
- **WHEN** deferred work surfaces in a project with `tracker=forgejo:jared/cc-os`
|
||||||
|
- **THEN** a new open issue is created on that repo via `tea` with the title, a body noting
|
||||||
|
the originating context, and any known priority label — and nothing else
|
||||||
|
|
||||||
|
#### Scenario: Capture with no tracker configured
|
||||||
|
- **WHEN** capture is invoked in a git project whose config has no tracker key
|
||||||
|
- **THEN** no issue is created; the skill suggests `/os-backlog:route` once and exits
|
||||||
|
without error
|
||||||
|
|
||||||
|
### Requirement: Listing is pull-only over issues
|
||||||
|
`/os-backlog:list` SHALL query the configured tracker's open issues (title, number, labels,
|
||||||
|
assignee) and present them grouped by state (next / waiting / review / other open). It
|
||||||
|
SHALL run only on explicit user request; no hook or skill SHALL inject issue lists into a
|
||||||
|
session unasked.
|
||||||
|
|
||||||
|
#### Scenario: User asks what's on the backlog
|
||||||
|
- **WHEN** the user explicitly asks what is on the backlog or what's next
|
||||||
|
- **THEN** open issues from the configured tracker are listed grouped by state labels, with
|
||||||
|
`next`-labeled issues shown first
|
||||||
|
|
||||||
|
### Requirement: Cross-project filing targets the other project's tracker
|
||||||
|
WHEN work belonging to a different project surfaces, the system SHALL resolve that
|
||||||
|
project's tracker via the global project index (`os-backlog projects`) and file a labeled
|
||||||
|
issue there using the Discoverer template, rather than editing the other project or filing
|
||||||
|
locally. Priority/autonomy labeling of the filed issue remains the receiving project's job.
|
||||||
|
|
||||||
|
#### Scenario: Discovered defect in another indexed project
|
||||||
|
- **WHEN** a session in project A finds a bug belonging to indexed project B
|
||||||
|
- **THEN** an issue is created on B's configured tracker with the Discoverer template, no
|
||||||
|
autonomy or priority labels are applied by A, and no local copy is kept
|
||||||
|
|
||||||
|
### Requirement: One tracker holds both state and specs
|
||||||
|
A project SHALL have exactly one configured tracker destination; durable specs
|
||||||
|
(tracer-bullet slices from /to-issues, PRDs) and task-state items are issues on that same
|
||||||
|
tracker. The system SHALL NOT create pointer artifacts on a second tracking surface.
|
||||||
|
|
||||||
|
#### Scenario: /to-issues publishes a slice chain
|
||||||
|
- **WHEN** /to-issues publishes tracer-bullet slices for a project with a configured git
|
||||||
|
tracker
|
||||||
|
- **THEN** the slices become issues on that tracker and no pointer card or secondary
|
||||||
|
tracking artifact is created anywhere
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
# issue-state-labels
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Canonical label taxonomy
|
||||||
|
The backlog label set on every routed tracker SHALL be: priority `P0` `P1` `P2` `P3`;
|
||||||
|
autonomy `hitl` `semi` `afk-ready`; state `next` `waiting` `review`. `/os-backlog:route`
|
||||||
|
SHALL ensure these labels exist (idempotently) on forgejo/github trackers when onboarding.
|
||||||
|
No `doing` or `done` label exists: in-progress is expressed by assignment/branch activity,
|
||||||
|
completion by closing the issue.
|
||||||
|
|
||||||
|
#### Scenario: Route onboards a Forgejo repo
|
||||||
|
- **WHEN** `/os-backlog:route` configures `tracker=forgejo:<owner>/<repo>`
|
||||||
|
- **THEN** all ten canonical labels exist on the repo afterwards, and re-running route
|
||||||
|
creates nothing new
|
||||||
|
|
||||||
|
### Requirement: The next label is human-only
|
||||||
|
The `next` label marks human-curated priority. The AI SHALL never apply or remove `next`
|
||||||
|
on any issue, in any project, under any instruction short of an explicit direct user
|
||||||
|
request naming the specific issue.
|
||||||
|
|
||||||
|
#### Scenario: AI triages a backlog
|
||||||
|
- **WHEN** an AI session labels or files issues during triage or capture
|
||||||
|
- **THEN** `next` is neither added to nor removed from any issue
|
||||||
|
|
||||||
|
### Requirement: Autonomy label semantics
|
||||||
|
`hitl` issues are human-owned and SHALL never be picked up autonomously. `semi` issues MAY
|
||||||
|
be worked autonomously through their mechanical parts, SHALL stop at named decision gates,
|
||||||
|
and on shipping SHALL receive the `review` label instead of being closed. `afk-ready`
|
||||||
|
issues, once shipped AND verified, SHALL be closed directly. Assigning autonomy labels is
|
||||||
|
the responsible project's job; ambiguity resolves to `hitl`.
|
||||||
|
|
||||||
|
#### Scenario: Semi-autonomy work ships
|
||||||
|
- **WHEN** an AI session completes the mechanical work of a `semi` issue
|
||||||
|
- **THEN** the issue receives the `review` label, a comment summarizing what shipped, and
|
||||||
|
remains open for human sign-off
|
||||||
|
|
||||||
|
#### Scenario: Afk-ready work ships and verifies
|
||||||
|
- **WHEN** an AI session completes and verifies work on an `afk-ready` issue
|
||||||
|
- **THEN** the issue is closed with a closing comment linking the verification evidence
|
||||||
|
|
||||||
|
### Requirement: Working an issue means recording state on it
|
||||||
|
An AI session working an issue SHALL record state transitions on the issue itself: a
|
||||||
|
comment when work starts, the `waiting` label plus a blocker comment when blocked, and the
|
||||||
|
shipping transition per its autonomy label. Blocked issues SHALL have `waiting` removed
|
||||||
|
when work resumes.
|
||||||
|
|
||||||
|
#### Scenario: Work blocks on an external dependency
|
||||||
|
- **WHEN** in-progress issue work hits a blocker the session cannot resolve
|
||||||
|
- **THEN** the issue gains the `waiting` label and a comment naming the blocker, and the
|
||||||
|
session moves on
|
||||||
|
|
||||||
|
### Requirement: Recurring process issues are never closed
|
||||||
|
An issue representing a recurring process (e.g. the biweekly orchestration audit in the
|
||||||
|
ops repo) SHALL be worked by commenting each occurrence's outcome on it and leaving it
|
||||||
|
open; closing it is a human-only action.
|
||||||
|
|
||||||
|
#### Scenario: Recurring audit completes an occurrence
|
||||||
|
- **WHEN** an AI session completes one occurrence of a recurring-convention issue
|
||||||
|
- **THEN** the outcome is added as a comment and the issue stays open with its labels
|
||||||
|
unchanged
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "os-backlog",
|
"name": "os-backlog",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "Uniform Planka backlog boards across projects: idempotent board-ensure (lists, labels, archive convention, Planka 2.1.1 quirk handling) and a deterministic repo-to-board routing resolver."
|
"description": "Git-issues backlog across projects: deterministic tracker routing (forgejo/github/repo), mid-session capture as labeled issues, pull-only listing (ADR-0042)."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
---
|
|
||||||
name: board-audit
|
|
||||||
description: Audits a Planka board for process drift — stale Doing cards, WIP over cap, Waiting cards whose blockers cleared, aging Review cards — and reports it. Writes at most comments on cards; never moves anything.
|
|
||||||
model: sonnet
|
|
||||||
tools: Bash, Read
|
|
||||||
---
|
|
||||||
|
|
||||||
You are the board-audit agent for the os-backlog plugin. Your job: read a board, detect drift, and report it so the process stays honest without human policing.
|
|
||||||
|
|
||||||
## Hard constraints (never violate)
|
|
||||||
|
|
||||||
- **Read-mostly: your only permitted write is adding a comment to a card.** You never move a card between columns — never pull Next, never move anything past Review, never touch Done — and never change labels, titles, lists, or boards, and never delete anything.
|
|
||||||
- You never start executing the work a card describes.
|
|
||||||
- One comment per drifting card per audit run, maximum. If a card already carries a recent audit comment for the same finding, do not repeat it — mention it only in your report.
|
|
||||||
|
|
||||||
## The four drift classes
|
|
||||||
|
|
||||||
1. **Stale Doing** — a card sitting in Doing with no movement for a long time (default threshold: 3+ days via `listChangedAt`; use a threshold from your task prompt if given).
|
|
||||||
2. **WIP over cap** — more cards in Doing than the cap (default cap: 3, or per task prompt).
|
|
||||||
3. **Waiting unblocked** — a card in Waiting whose stated blocker (in its description/comments) appears to have cleared.
|
|
||||||
4. **Aging Review** — a card in Review awaiting human sign-off for too long (default: 2+ days). Flag it for the human; you may NOT move it to Done yourself.
|
|
||||||
|
|
||||||
## Procedure
|
|
||||||
|
|
||||||
All commands use `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog` (board name from your task prompt).
|
|
||||||
|
|
||||||
1. Fetch the board state (read-only):
|
|
||||||
```bash
|
|
||||||
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog snapshot --board <board>
|
|
||||||
```
|
|
||||||
2. Evaluate all four drift classes against the snapshot.
|
|
||||||
3. Optionally annotate drifting cards (the only write you may perform):
|
|
||||||
```bash
|
|
||||||
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-comment --card <id> --text "board-audit <date>: <finding, one sentence>"
|
|
||||||
```
|
|
||||||
4. Produce the drift report: one section per drift class (state "none" explicitly when clean), each finding with card title, column, evidence (age/count/blocker), and a suggested human action. Suggestions are for the human to execute — you never execute column moves yourself.
|
|
||||||
|
|
||||||
If the CLI fails (gem/Planka unavailable), report the exact error and stop.
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
---
|
|
||||||
name: card-triage
|
|
||||||
description: Batch-labels raw Backlog cards on a Planka board with priority (P0-P3) and autonomy (hitl/semi/afk-ready) labels so capture stays cheap and triage stays batched. Label changes only — never moves cards between columns.
|
|
||||||
model: sonnet
|
|
||||||
tools: Bash, Read
|
|
||||||
---
|
|
||||||
|
|
||||||
You are the card-triage agent for the os-backlog plugin. Your one job: take the raw, unlabeled cards sitting in a board's Backlog column and give each one exactly one priority label and exactly one autonomy label.
|
|
||||||
|
|
||||||
## Hard constraints (never violate)
|
|
||||||
|
|
||||||
- **Writes are label changes only.** You never move a card between columns — not into Next, not out of Backlog, not past Review, and never anything into or out of Done. Next and Done are exclusively human-owned.
|
|
||||||
- You never create, delete, rename, or reorder cards, lists, or boards.
|
|
||||||
- You never self-assign work or start executing a card's task — you only classify.
|
|
||||||
- If a card is ambiguous, label it `hitl` rather than guessing an autonomy level that would let an agent run with it.
|
|
||||||
|
|
||||||
## Label vocabulary
|
|
||||||
|
|
||||||
- Priority (pick one per card): `P0` (urgent, drop-everything) > `P1` > `P2` > `P3` (someday).
|
|
||||||
- Autonomy (pick one per card):
|
|
||||||
- `afk-ready` — fully specified, an agent can complete it end-to-end unattended.
|
|
||||||
- `semi` — an agent can start, but there are judgment points needing a human check-in.
|
|
||||||
- `hitl` — human-in-the-loop required; agents must not self-assign these.
|
|
||||||
|
|
||||||
## Procedure
|
|
||||||
|
|
||||||
All commands use `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog` (read the board name from your task prompt).
|
|
||||||
|
|
||||||
1. Fetch the board state (read-only):
|
|
||||||
```bash
|
|
||||||
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog snapshot --board <board>
|
|
||||||
```
|
|
||||||
2. Collect the Backlog-column cards whose `labels` array is missing a priority label or an autonomy label. Cards in other columns are out of scope.
|
|
||||||
3. For each raw card, decide priority + autonomy from its title/description. Then apply:
|
|
||||||
```bash
|
|
||||||
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-label --board <board> --card <id> --label <P0|P1|P2|P3>
|
|
||||||
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-label --board <board> --card <id> --label <hitl|semi|afk-ready>
|
|
||||||
```
|
|
||||||
Skip a dimension the card already has — never double-label.
|
|
||||||
4. Report a compact table: card title, priority assigned, autonomy assigned, one-clause rationale. Note any cards you left for the human (and why).
|
|
||||||
|
|
||||||
If the CLI fails (gem/Planka unavailable), report the exact error and stop — no retries, no partial guessing.
|
|
||||||
|
|
@ -1,34 +1,30 @@
|
||||||
#!/usr/bin/env ruby
|
#!/usr/bin/env ruby
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
#
|
#
|
||||||
# os-backlog CLI: board-ensure, activate, archive, resolve, card-add,
|
# os-backlog CLI: resolve, inspect, config-write, projects, issue-create,
|
||||||
# cards, snapshot, inspect, config-write.
|
# issues. Post-ADR-0042 (Planka retired): git issues (forgejo/github/repo)
|
||||||
|
# are the single tracker for both state and specs.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# os-backlog board-ensure NAME [--project PROJECT] (any Planka project name;
|
|
||||||
# defaults to Backlog::Resolver.project_for(Dir.pwd) — Dev/Clients/Servers
|
|
||||||
# by path, else "Dev"; creates the project if it doesn't exist yet)
|
|
||||||
# os-backlog activate NAME
|
|
||||||
# os-backlog archive NAME --yes
|
|
||||||
# os-backlog resolve < input.json
|
# os-backlog resolve < input.json
|
||||||
# input.json: {"repo_path": "...", "config": "..."|null, "boards": ["...", ...]}
|
# input.json: {"repo_path": "...", "config": "..."|null}
|
||||||
# os-backlog card-add --board NAME --title "..." [--description "..."]
|
|
||||||
# os-backlog card-move --board NAME --card ID --to COLUMN
|
|
||||||
# os-backlog cards --board NAME (human-readable per-list card listing)
|
|
||||||
# os-backlog snapshot --board NAME (full board state as JSON; read-only)
|
|
||||||
# os-backlog inspect (detect existing tracking for cwd repo, JSON)
|
# os-backlog inspect (detect existing tracking for cwd repo, JSON)
|
||||||
# os-backlog config-write <tracker-value> (write tracker key to .cc-os/config
|
# os-backlog config-write <tracker-value> (write tracker key to .cc-os/config
|
||||||
# + upsert the global project index)
|
# + upsert the global project index)
|
||||||
# os-backlog projects [NAME-FILTER] (print ~/.cc-os/projects.json as JSON)
|
# os-backlog projects [NAME-FILTER] (print ~/.cc-os/projects.json as JSON)
|
||||||
|
# os-backlog issue-create --title "..." --body "..." [--priority P0-P3]
|
||||||
|
# (creates an issue on the cwd repo's configured tracker; never applies
|
||||||
|
# a state label)
|
||||||
|
# os-backlog issues (open issues on the cwd repo's
|
||||||
|
# configured tracker, grouped by
|
||||||
|
# state label, JSON)
|
||||||
#
|
#
|
||||||
# board-ensure/activate/archive talk to Planka over the network (the
|
# resolve, config-write are pure — no network access at all. inspect,
|
||||||
# installed planka-api gem, credentials from PLANKA_BASE_URL/USERNAME/
|
# issue-create, and issues touch the network/CLIs (tea/gh) but fail soft
|
||||||
# PASSWORD). resolve, config-write are pure — no network access at all.
|
# field-by-field (inspect) or with a one-line error (issue-create/issues)
|
||||||
# inspect touches the network/CLIs opportunistically but fails soft
|
# rather than leaving a stack trace.
|
||||||
# field-by-field rather than aborting.
|
|
||||||
#
|
#
|
||||||
# Fails soft: if the gem or Planka credentials are unavailable, prints one
|
# Fails soft: never destructive.
|
||||||
# clear error and exits nonzero. Never destructive.
|
|
||||||
|
|
||||||
require "json"
|
require "json"
|
||||||
require "fileutils"
|
require "fileutils"
|
||||||
|
|
@ -40,58 +36,6 @@ def fail_soft(message)
|
||||||
exit 1
|
exit 1
|
||||||
end
|
end
|
||||||
|
|
||||||
# Guards board-ensure/activate/archive's positional NAME argument against a
|
|
||||||
# mistaken flag-style invocation (e.g. `board-ensure --board apprise-api`,
|
|
||||||
# where `--board` is swallowed as the name and `apprise-api` is dropped
|
|
||||||
# silently). Called immediately after each command shifts its name arg,
|
|
||||||
# before build_client, so a bad name never reaches Planka.
|
|
||||||
def validate_board_name!(name, command)
|
|
||||||
return unless name.start_with?("--")
|
|
||||||
|
|
||||||
fail_soft("#{command}: board name #{name.inspect} must not start with \"--\" " \
|
|
||||||
"(looks like a flag was passed positionally — check your quoting/flags)")
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_client
|
|
||||||
require "planka_api"
|
|
||||||
client = Planka::Client.new
|
|
||||||
client.login
|
|
||||||
client
|
|
||||||
rescue LoadError
|
|
||||||
fail_soft("the planka-api gem is not installed/loadable; cannot reach Planka")
|
|
||||||
rescue StandardError => e
|
|
||||||
fail_soft("could not authenticate to Planka: #{e.message}")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Cards wired to the cwd repo's .cc-os/config board_id cache (issue #22):
|
|
||||||
# the BoardResolver gets injected read/write lambdas; any cache failure
|
|
||||||
# degrades to the plain name lookup inside the resolver itself.
|
|
||||||
def build_cards(client)
|
|
||||||
config_path = File.join(Dir.pwd, ".cc-os", "config")
|
|
||||||
read_config = -> { File.exist?(config_path) ? File.read(config_path) : nil }
|
|
||||||
write_config = lambda do |contents|
|
|
||||||
FileUtils.mkdir_p(File.dirname(config_path))
|
|
||||||
File.write(config_path, contents)
|
|
||||||
end
|
|
||||||
resolver = Backlog::BoardResolver.new(client: client, read_config: read_config,
|
|
||||||
write_config: write_config)
|
|
||||||
Backlog::Cards.new(client: client, board_resolver: resolver)
|
|
||||||
end
|
|
||||||
|
|
||||||
# --project accepts any Planka project name (Dev/Clients/Servers, or a
|
|
||||||
# fresh one board-ensure will create). When --project is omitted, the
|
|
||||||
# default is derived from the repo's path via
|
|
||||||
# Backlog::Resolver.project_for(Dir.pwd), falling back to "Dev" for paths
|
|
||||||
# outside every known root.
|
|
||||||
def parse_project_flag(args)
|
|
||||||
idx = args.index("--project")
|
|
||||||
return Backlog::Resolver.project_for(Dir.pwd) || "Dev" unless idx
|
|
||||||
|
|
||||||
args.delete_at(idx)
|
|
||||||
project = args.delete_at(idx)
|
|
||||||
project || fail_soft("--project requires a value (a Planka project name)")
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_flag(args, flag)
|
def parse_flag(args, flag)
|
||||||
idx = args.index(flag)
|
idx = args.index(flag)
|
||||||
return nil unless idx
|
return nil unless idx
|
||||||
|
|
@ -104,22 +48,6 @@ def require_flag(args, flag, command)
|
||||||
parse_flag(args, flag) || fail_soft("#{command} requires #{flag} <value>")
|
parse_flag(args, flag) || fail_soft("#{command} requires #{flag} <value>")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Best-effort Planka check for `inspect`: does an existing board already
|
|
||||||
# match this repo per the resolver's own conventions. Never fails the whole
|
|
||||||
# `inspect` command — reports unavailability instead.
|
|
||||||
def inspect_planka(repo_path)
|
|
||||||
require "planka_api"
|
|
||||||
client = Planka::Client.new
|
|
||||||
client.login
|
|
||||||
boards = client.projects.list.flat_map { |project| client.boards.list(project.id).map(&:name) }
|
|
||||||
resolver = Backlog::Resolver.new(repo_path: repo_path, config_contents: nil, boards: boards)
|
|
||||||
{ "checked" => true, "decision" => resolver.resolve_string }
|
|
||||||
rescue LoadError
|
|
||||||
{ "checked" => false, "reason" => "planka-api gem not installed" }
|
|
||||||
rescue StandardError => e
|
|
||||||
{ "checked" => false, "reason" => e.message }
|
|
||||||
end
|
|
||||||
|
|
||||||
# Best-effort open-issue count + lightweight per-issue metadata via tea
|
# Best-effort open-issue count + lightweight per-issue metadata via tea
|
||||||
# (Forgejo) or gh (GitHub), whichever the detected remote implies. Fails
|
# (Forgejo) or gh (GitHub), whichever the detected remote implies. Fails
|
||||||
# soft per-field: any fetch/parse failure means metadata nil. Returns
|
# soft per-field: any fetch/parse failure means metadata nil. Returns
|
||||||
|
|
@ -195,86 +123,32 @@ rescue StandardError => e
|
||||||
warn "os-backlog: (non-fatal) could not update project index: #{e.message}"
|
warn "os-backlog: (non-fatal) could not update project index: #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The cwd repo's configured tracker, or nil (no .cc-os/config, or no
|
||||||
|
# tracker key set). Shared by issue-create/issues.
|
||||||
|
def cwd_tracker
|
||||||
|
config_path = File.join(Dir.pwd, ".cc-os", "config")
|
||||||
|
contents = File.exist?(config_path) ? File.read(config_path) : nil
|
||||||
|
Backlog::Config.new(contents).tracker
|
||||||
|
end
|
||||||
|
|
||||||
|
def require_cwd_tracker(command)
|
||||||
|
tracker = cwd_tracker
|
||||||
|
return tracker if tracker && Backlog::Tracker.valid?(tracker)
|
||||||
|
|
||||||
|
fail_soft("#{command}: no valid tracker configured for #{Dir.pwd} — run /os-backlog:route first")
|
||||||
|
end
|
||||||
|
|
||||||
command, *rest = ARGV
|
command, *rest = ARGV
|
||||||
|
|
||||||
begin
|
begin
|
||||||
case command
|
case command
|
||||||
when "board-ensure"
|
|
||||||
name = rest.shift
|
|
||||||
fail_soft("board-ensure requires a board name") unless name
|
|
||||||
validate_board_name!(name, "board-ensure")
|
|
||||||
project = parse_project_flag(rest)
|
|
||||||
client = build_client
|
|
||||||
result = Backlog::BoardEnsurer.new(client: client).ensure(name, project_name: project)
|
|
||||||
puts "board #{result.board.name} (#{result.created ? 'created' : 'existing'}) " \
|
|
||||||
"in project #{project}; lists created: #{result.lists_created.join(', ')}; " \
|
|
||||||
"labels created: #{result.labels_created.join(', ')}"
|
|
||||||
when "activate"
|
|
||||||
name = rest.shift
|
|
||||||
fail_soft("activate requires a board name") unless name
|
|
||||||
validate_board_name!(name, "activate")
|
|
||||||
client = build_client
|
|
||||||
board = Backlog::BoardEnsurer.new(client: client).activate(name)
|
|
||||||
puts "activated: #{board.name}"
|
|
||||||
when "archive"
|
|
||||||
name = rest.shift
|
|
||||||
fail_soft("archive requires a board name") unless name
|
|
||||||
validate_board_name!(name, "archive")
|
|
||||||
fail_soft("archive requires explicit --yes (human go-ahead only)") unless rest.include?("--yes")
|
|
||||||
|
|
||||||
client = build_client
|
|
||||||
board = Backlog::BoardEnsurer.new(client: client).archive(name)
|
|
||||||
puts "archived: #{board.name}"
|
|
||||||
when "resolve"
|
when "resolve"
|
||||||
input = JSON.parse($stdin.read)
|
input = JSON.parse($stdin.read)
|
||||||
resolver = Backlog::Resolver.new(
|
resolver = Backlog::Resolver.new(
|
||||||
repo_path: input.fetch("repo_path"),
|
repo_path: input.fetch("repo_path"),
|
||||||
config_contents: input["config"],
|
config_contents: input["config"]
|
||||||
boards: input.fetch("boards", [])
|
|
||||||
)
|
)
|
||||||
puts resolver.resolve_string
|
puts resolver.resolve_string
|
||||||
when "card-add"
|
|
||||||
board = require_flag(rest, "--board", "card-add")
|
|
||||||
title = require_flag(rest, "--title", "card-add")
|
|
||||||
description = parse_flag(rest, "--description")
|
|
||||||
client = build_client
|
|
||||||
card = build_cards(client).add(board_name: board, title: title, description: description)
|
|
||||||
puts "created card ##{card.id} at Backlog on #{board}: #{card.name}"
|
|
||||||
when "card-move"
|
|
||||||
board = require_flag(rest, "--board", "card-move")
|
|
||||||
card_id = require_flag(rest, "--card", "card-move")
|
|
||||||
to = require_flag(rest, "--to", "card-move")
|
|
||||||
client = build_client
|
|
||||||
card = build_cards(client).move(board_name: board, card_id: card_id, to: to)
|
|
||||||
puts "moved card ##{card.id} to #{to} on #{board}"
|
|
||||||
when "cards"
|
|
||||||
board = require_flag(rest, "--board", "cards")
|
|
||||||
client = build_client
|
|
||||||
snapshot = build_cards(client).snapshot(board_name: board)
|
|
||||||
snapshot["lists"].each do |list|
|
|
||||||
puts "#{list['name']} (#{list['cards'].size})"
|
|
||||||
list["cards"].each do |card|
|
|
||||||
labels = card["labels"].empty? ? "" : " [#{card['labels'].join(', ')}]"
|
|
||||||
puts " - #{card['name']}#{labels}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
when "snapshot"
|
|
||||||
board = require_flag(rest, "--board", "snapshot")
|
|
||||||
client = build_client
|
|
||||||
puts JSON.pretty_generate(build_cards(client).snapshot(board_name: board))
|
|
||||||
when "card-label"
|
|
||||||
board = require_flag(rest, "--board", "card-label")
|
|
||||||
card_id = require_flag(rest, "--card", "card-label")
|
|
||||||
label = require_flag(rest, "--label", "card-label")
|
|
||||||
client = build_client
|
|
||||||
build_cards(client).attach_label(board_name: board, card_id: card_id, label: label)
|
|
||||||
puts "labeled card ##{card_id} with #{label}"
|
|
||||||
when "card-comment"
|
|
||||||
card_id = require_flag(rest, "--card", "card-comment")
|
|
||||||
text = require_flag(rest, "--text", "card-comment")
|
|
||||||
client = build_client
|
|
||||||
comment = build_cards(client).comment(card_id: card_id, text: text)
|
|
||||||
puts "commented on card ##{card_id} (comment ##{comment.id})"
|
|
||||||
when "inspect"
|
when "inspect"
|
||||||
repo_path = Dir.pwd
|
repo_path = Dir.pwd
|
||||||
config_path = File.join(repo_path, ".cc-os", "config")
|
config_path = File.join(repo_path, ".cc-os", "config")
|
||||||
|
|
@ -288,7 +162,6 @@ when "inspect"
|
||||||
findings = {
|
findings = {
|
||||||
"repo_path" => repo_path,
|
"repo_path" => repo_path,
|
||||||
"tracker_configured" => config.tracker,
|
"tracker_configured" => config.tracker,
|
||||||
"planka" => inspect_planka(repo_path),
|
|
||||||
"git_remote" => remote_info ? remote_info.transform_keys(&:to_s) : nil,
|
"git_remote" => remote_info ? remote_info.transform_keys(&:to_s) : nil,
|
||||||
"issue_cli" => issue_cli,
|
"issue_cli" => issue_cli,
|
||||||
"issue_shape" => Backlog::Inspector.classify_issue_shape(issue_metadata),
|
"issue_shape" => Backlog::Inspector.classify_issue_shape(issue_metadata),
|
||||||
|
|
@ -298,9 +171,13 @@ when "inspect"
|
||||||
when "config-write"
|
when "config-write"
|
||||||
value = rest.shift
|
value = rest.shift
|
||||||
fail_soft("config-write requires a tracker value, e.g. forgejo:owner/repo") unless value
|
fail_soft("config-write requires a tracker value, e.g. forgejo:owner/repo") unless value
|
||||||
|
if value.start_with?("planka:")
|
||||||
|
fail_soft("planka: trackers are retired (ADR-0042) — run /os-backlog:route to " \
|
||||||
|
"choose forgejo:/github:/repo: instead")
|
||||||
|
end
|
||||||
unless Backlog::Tracker.valid?(value)
|
unless Backlog::Tracker.valid?(value)
|
||||||
fail_soft("invalid tracker value #{value.inspect}; expected one of " \
|
fail_soft("invalid tracker value #{value.inspect}; expected one of " \
|
||||||
"planka:<board> | forgejo:<owner>/<repo> | github:<owner>/<repo> | repo:<path>")
|
"forgejo:<owner>/<repo> | github:<owner>/<repo> | repo:<path>")
|
||||||
end
|
end
|
||||||
|
|
||||||
config_dir = File.join(Dir.pwd, ".cc-os")
|
config_dir = File.join(Dir.pwd, ".cc-os")
|
||||||
|
|
@ -320,25 +197,40 @@ when "projects"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
puts JSON.pretty_generate({ "projects" => projects })
|
puts JSON.pretty_generate({ "projects" => projects })
|
||||||
|
when "issue-create"
|
||||||
|
title = require_flag(rest, "--title", "issue-create")
|
||||||
|
body = require_flag(rest, "--body", "issue-create") || ""
|
||||||
|
priority = parse_flag(rest, "--priority")
|
||||||
|
tracker = require_cwd_tracker("issue-create")
|
||||||
|
issues = Backlog::Issues.new(runner: Backlog::Issues::ShellRunner.new, repo_root: Dir.pwd)
|
||||||
|
begin
|
||||||
|
result = issues.create(tracker: tracker, title: title, body: body, priority: priority)
|
||||||
|
rescue ArgumentError, Backlog::Issues::CommandFailed => e
|
||||||
|
fail_soft("issue-create: #{e.message}")
|
||||||
|
end
|
||||||
|
puts JSON.pretty_generate(result.transform_keys(&:to_s))
|
||||||
|
when "issues"
|
||||||
|
tracker = require_cwd_tracker("issues")
|
||||||
|
issues = Backlog::Issues.new(runner: Backlog::Issues::ShellRunner.new, repo_root: Dir.pwd)
|
||||||
|
begin
|
||||||
|
grouped = issues.list(tracker: tracker)
|
||||||
|
rescue ArgumentError, Backlog::Issues::CommandFailed => e
|
||||||
|
fail_soft("issues: #{e.message}")
|
||||||
|
end
|
||||||
|
puts JSON.pretty_generate(grouped.transform_keys(&:to_s).transform_values { |v| v.map { |i| i.transform_keys(&:to_s) } })
|
||||||
when nil, "-h", "--help"
|
when nil, "-h", "--help"
|
||||||
puts <<~USAGE
|
puts <<~USAGE
|
||||||
usage: os-backlog <command> [options]
|
usage: os-backlog <command> [options]
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
board-ensure NAME [--project PROJECT] (any project name; creates it if missing;
|
resolve (reads {"repo_path","config"} JSON from stdin)
|
||||||
default derived from cwd path)
|
|
||||||
activate NAME
|
|
||||||
archive NAME --yes
|
|
||||||
resolve (reads {"repo_path","config","boards"} JSON from stdin)
|
|
||||||
card-add --board NAME --title "..." [--description "..."]
|
|
||||||
card-move --board NAME --card ID --to COLUMN
|
|
||||||
cards --board NAME
|
|
||||||
snapshot --board NAME
|
|
||||||
card-label --board NAME --card ID --label NAME
|
|
||||||
card-comment --card ID --text "..."
|
|
||||||
inspect (detect existing tracking for cwd repo, JSON)
|
inspect (detect existing tracking for cwd repo, JSON)
|
||||||
config-write <tracker-value> (write tracker key to .cc-os/config + global project index)
|
config-write <tracker-value> (write tracker key to .cc-os/config + global project index)
|
||||||
projects [NAME-FILTER] (print the global project index at ~/.cc-os/projects.json as JSON)
|
projects [NAME-FILTER] (print the global project index at ~/.cc-os/projects.json as JSON)
|
||||||
|
issue-create --title "..." --body "..." [--priority P0-P3]
|
||||||
|
(create an issue on the cwd repo's configured tracker)
|
||||||
|
issues (list open issues on the cwd repo's configured tracker,
|
||||||
|
grouped by state label, JSON)
|
||||||
USAGE
|
USAGE
|
||||||
exit(command.nil? ? 1 : 0)
|
exit(command.nil? ? 1 : 0)
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,6 @@
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py",
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session_start.py",
|
||||||
"timeout": 5
|
"timeout": 5
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "ruby ${CLAUDE_PLUGIN_ROOT}/hooks/triage_check.rb",
|
|
||||||
"timeout": 15
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,29 +3,25 @@
|
||||||
|
|
||||||
Modeled on os-adr's session_start.py (the wording-source-of-record pattern).
|
Modeled on os-adr's session_start.py (the wording-source-of-record pattern).
|
||||||
Injects the backlog process rules — WHEN->THEN mechanical triggers only.
|
Injects the backlog process rules — WHEN->THEN mechanical triggers only.
|
||||||
Deliberately contains ZERO board state and no briefs (notification policy v2:
|
Deliberately contains ZERO issue state and no briefs (notification policy v2:
|
||||||
pull beats push — board state is fetched on demand via /os-backlog:list, never
|
pull beats push — issue state is fetched on demand via /os-backlog:list, never
|
||||||
pushed into a session).
|
pushed into a session).
|
||||||
|
|
||||||
Sibling hook triage_check.rb (card 1818330518584820864) is a narrow, sanctioned
|
Per ADR-0042 (2026-07-16), Planka is retired: git issues (Forgejo via tea,
|
||||||
exception to that policy: it makes one bounded Planka read to decide whether to
|
GitHub via gh, or in-repo via repo:<path>) are the single tracker for both
|
||||||
push a single mechanical dispatch directive — not a brief, not board contents —
|
task state and durable specs. State is modeled with labels, not columns —
|
||||||
when the current project's own Backlog has unlabeled cards. The only board-
|
there is no card-move, no board, no triage-dispatch hook to mention here.
|
||||||
derived fact it ever surfaces is the unlabeled count that justifies dispatching
|
|
||||||
card-triage; it never lists card titles or otherwise renders board state. Kept
|
|
||||||
as a separate script precisely so this file can stay a pure, network-free
|
|
||||||
function of filesystem state.
|
|
||||||
|
|
||||||
git project -> additionalContext note (rules only, near-zero tokens)
|
git project -> additionalContext note (rules only, near-zero tokens)
|
||||||
not a git repo -> silent (process rules only apply inside real projects)
|
not a git repo -> silent (process rules only apply inside real projects)
|
||||||
|
|
||||||
Autonomy-label semantics in NOTE are the ADR-029 contract (which supersedes
|
Autonomy-label semantics in NOTE are the ADR-0042 contract (successor to
|
||||||
the wording in issue #16's body): hitl never picked up autonomously; semi
|
ADR-029's Planka-mechanics version): hitl never picked up autonomously; semi
|
||||||
stops at Review for human sign-off; afk-ready goes straight to Done after
|
stops at named decision gates and ships to the review label; afk-ready ships
|
||||||
verification, skipping Review.
|
and, once verified, closes directly.
|
||||||
|
|
||||||
Pure function of the project's filesystem state; no model, no network, no
|
Pure function of the project's filesystem state; no model, no network, no
|
||||||
Ruby, no Planka call. Always exits 0 — the hook must never block a session.
|
Ruby, no tracker call. Always exits 0 — the hook must never block a session.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -36,14 +32,14 @@ from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
NOTE = """\
|
NOTE = """\
|
||||||
[os-backlog] Backlog process rules (rules only — this note never carries board state; board state is pull-only via /os-backlog:list when the user asks).
|
[os-backlog] Backlog process rules (rules only — never carries board state; state is pull-only via /os-backlog:list when the user asks).
|
||||||
- CAPTURE: WHEN a concrete follow-up task, bug, or piece of deferred work surfaces mid-session that will NOT be done this session -> capture it as a Backlog card via /os-backlog:capture. Never a TODO comment in code, never only a mention in chat.
|
- CAPTURE: WHEN deferred work surfaces mid-session that will NOT be done this session -> capture it as an issue via /os-backlog:capture. Never a TODO comment in code, never only a mention in chat.
|
||||||
- ROUTING: WHEN process/backlog/spec-tracking work surfaces -> read the tracker key from .cc-os/config (`tracker=planka:<board>` | `forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`) and send work where it says. WHEN this is a git project and no tracker key is configured -> suggest /os-backlog:route once; do not nag. A subdirectory's own .cc-os/config overrides the repo root's for any session started there — umbrella repos route each subproject to its own board/tracker this way; do not assume one repo means one board.
|
- ROUTING: read the tracker key from .cc-os/config (`tracker=forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`; `planka:` is retired per ADR-0042 and rejected). WHEN no tracker key is configured in a git project -> suggest /os-backlog:route once; do not nag. A subdirectory's own .cc-os/config overrides the repo root's — umbrella repos route each subproject to its own tracker this way.
|
||||||
- PROMOTION: WHEN a Planka-tracked effort accretes significant code or design decisions in card comments -> flag promotion to the user (never silently): carve out a repo, move the durable spec into git issues via /to-issues, and keep the Planka card as a pointer (title + link). Never duplicate spec text into a card description.
|
- NEXT IS HUMAN-ONLY: never apply or remove the `next` label on any issue, any project, absent an explicit direct user request naming that issue.
|
||||||
- COLUMN OWNERSHIP (CLI-enforced per ADR-029; a violating card-move fails with the rule named): create cards at Backlog only. Never move a card into or out of Next — Next is human-curated in both directions. Never move a card labeled hitl; never move a card out of Done. Working a card MEANS moving it via card-move: Doing when work starts, Waiting + a blocker comment when blocked, and on shipping: Review (semi) or Done (afk-ready, only after verification).
|
- WORKING AN ISSUE: comment when work starts. Blocked -> add `waiting` + a comment naming the blocker; remove `waiting` on resume. Shipping: `semi` -> add `review` label + a summary comment, stays open for sign-off; `afk-ready` -> close directly once verified, with a comment linking the evidence.
|
||||||
- LABELING/TRIAGE: priority (P0-P3) and autonomy (hitl/semi/afk-ready) labeling is the RESPONSIBLE project's job (it knows its own priorities) — a parent umbrella may label its own and its child boards, but never label cards on a board that belongs to a different project you're just filing onto. Triage is initiated by this project's own SessionStart check (a separate hook), not by capture — if it reports unlabeled Backlog cards, dispatch the card-triage agent (haiku, background) to clear them.
|
- AUTONOMY LABELS: `hitl` -> human-owned, never picked up autonomously. `semi` -> run the mechanical parts, stop at named decision gates. `afk-ready` -> run to completion and verify before closing.
|
||||||
- CROSS-PROJECT: WHEN a needed change belongs to a DIFFERENT project THEN do not edit it — query `os-backlog projects`, file an issue/card with that project's tracker using the Discoverer template (see os-backlog route references/cross-project-filing.md).
|
- RECURRING ISSUES: never closed by the AI — comment each occurrence's outcome and leave it open.
|
||||||
- AUTONOMY LABELS (ADR-029): hitl -> human-owned; never picked up autonomously. semi -> run the mechanical parts, stop at named decision gates; shipped work goes to Review for human sign-off. afk-ready -> shipped + verified goes straight to Done, skipping Review.
|
- CROSS-PROJECT: WHEN a needed change belongs to a DIFFERENT project THEN do not edit it — query `os-backlog projects`, file an issue on that project's tracker using the Discoverer template (see os-backlog route references/cross-project-filing.md); no autonomy/priority labels applied by the filer.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
#!/usr/bin/env ruby
|
|
||||||
# frozen_string_literal: true
|
|
||||||
#
|
|
||||||
# triage_check.rb — SessionStart hook (card 1818330518584820864): checks
|
|
||||||
# whether the CURRENT project's own resolved board has unlabeled Backlog
|
|
||||||
# cards and, if so, emits additionalContext instructing dispatch of the
|
|
||||||
# card-triage agent (haiku, background).
|
|
||||||
#
|
|
||||||
# Design intent: labeling is the RESPONSIBLE project's job, not capture's
|
|
||||||
# and not whichever project happened to file a card cross-project. This
|
|
||||||
# hook only ever looks at the board the CURRENT session's project resolves
|
|
||||||
# to (the same board /os-backlog:capture would file onto from here) — a
|
|
||||||
# session run from the owning project, or a parent umbrella, naturally
|
|
||||||
# only ever triggers triage for a board it owns; it never inspects or
|
|
||||||
# labels a board belonging to a different project.
|
|
||||||
#
|
|
||||||
# This is deliberately separate from session_start.py (which stays a pure,
|
|
||||||
# network-free function of filesystem state per its own docstring): this
|
|
||||||
# script does one bounded Planka read to decide whether to fire, then
|
|
||||||
# hands off the actual labeling to the card-triage agent — it never
|
|
||||||
# labels anything itself.
|
|
||||||
#
|
|
||||||
# Fails soft everywhere: no git project, no board name derivable, no
|
|
||||||
# network/credentials, any Planka error, or a board that doesn't exist yet
|
|
||||||
# -> silent, exit 0. Never blocks a session.
|
|
||||||
|
|
||||||
require "json"
|
|
||||||
require_relative "../lib/backlog"
|
|
||||||
|
|
||||||
def find_project_root(cwd)
|
|
||||||
dir = File.expand_path(cwd)
|
|
||||||
loop do
|
|
||||||
return dir if Dir.exist?(File.join(dir, ".git"))
|
|
||||||
|
|
||||||
parent = File.dirname(dir)
|
|
||||||
return nil if parent == dir
|
|
||||||
|
|
||||||
dir = parent
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Same config semantics as the CLI: the session cwd's own .cc-os/config
|
|
||||||
# wins (umbrella subdir configs override the repo root's for sessions
|
|
||||||
# started there), then the root config, then the path heuristic — and
|
|
||||||
# `tracker=planka:<board>` counts, not just the explicit `board` key.
|
|
||||||
# The decision itself is pure (TriageCheck.board_name_for); this only
|
|
||||||
# reads the two candidate config files.
|
|
||||||
def resolve_board_name(root)
|
|
||||||
read = lambda do |dir|
|
|
||||||
path = File.join(dir, ".cc-os", "config")
|
|
||||||
File.exist?(path) ? File.read(path) : nil
|
|
||||||
end
|
|
||||||
cwd = File.expand_path(Dir.pwd)
|
|
||||||
Backlog::TriageCheck.board_name_for(cwd: cwd, root: root,
|
|
||||||
cwd_config: read.call(cwd),
|
|
||||||
root_config: read.call(root))
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_client
|
|
||||||
require "planka_api"
|
|
||||||
client = Planka::Client.new
|
|
||||||
client.login
|
|
||||||
client
|
|
||||||
end
|
|
||||||
|
|
||||||
def main
|
|
||||||
root = find_project_root(Dir.pwd)
|
|
||||||
return unless root
|
|
||||||
|
|
||||||
board_name = resolve_board_name(root)
|
|
||||||
return unless board_name
|
|
||||||
|
|
||||||
client = build_client
|
|
||||||
snapshot = Backlog::Cards.new(client: client).snapshot(board_name: board_name)
|
|
||||||
backlog_list = snapshot["lists"].find { |l| l["name"] == "Backlog" }
|
|
||||||
return unless backlog_list
|
|
||||||
|
|
||||||
labels = backlog_list["cards"].map { |c| c["labels"] }
|
|
||||||
note = Backlog::TriageCheck.note_for(board_name, labels)
|
|
||||||
return unless note
|
|
||||||
|
|
||||||
puts JSON.generate(
|
|
||||||
hookSpecificOutput: {
|
|
||||||
hookEventName: "SessionStart",
|
|
||||||
additionalContext: note
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
|
||||||
main
|
|
||||||
rescue Exception => e # rubocop:disable Lint/RescueException -- a hook must never block a session
|
|
||||||
warn "os-backlog triage_check: #{e.class}: #{e.message}" if ENV["CC_OS_DEBUG"]
|
|
||||||
end
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
require_relative "backlog/board_spec"
|
|
||||||
require_relative "backlog/board_ensurer"
|
|
||||||
require_relative "backlog/board_resolver"
|
|
||||||
require_relative "backlog/cards"
|
|
||||||
require_relative "backlog/config"
|
require_relative "backlog/config"
|
||||||
require_relative "backlog/inspector"
|
require_relative "backlog/inspector"
|
||||||
|
require_relative "backlog/issues"
|
||||||
require_relative "backlog/project_index"
|
require_relative "backlog/project_index"
|
||||||
require_relative "backlog/resolver"
|
require_relative "backlog/resolver"
|
||||||
require_relative "backlog/tracker"
|
require_relative "backlog/tracker"
|
||||||
require_relative "backlog/triage_check"
|
|
||||||
require_relative "backlog/wakeup"
|
require_relative "backlog/wakeup"
|
||||||
|
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
require_relative "board_spec"
|
|
||||||
|
|
||||||
module Backlog
|
|
||||||
# Idempotently creates/repairs a uniform Planka board: the project it
|
|
||||||
# lives under, its lists, and its label set — plus the Planka 2.1.1
|
|
||||||
# quirks the planka-api gem does not handle for us:
|
|
||||||
#
|
|
||||||
# 1. undocumented `type` fields on create (project: "shared" so a
|
|
||||||
# manager can be added later; list: "active"; the gem already
|
|
||||||
# requires callers to pass these — it does not default them)
|
|
||||||
# 2. bot-created projects are invisible to the human: a `"private"`
|
|
||||||
# project auto-assigns its creator as sole ownerProjectManagerId and
|
|
||||||
# rejects additional managers (422). We create projects as
|
|
||||||
# `type: "shared"` instead, then explicitly add the human as a
|
|
||||||
# project manager and null out ownerProjectManagerId.
|
|
||||||
# 3. label colors must come from Planka's (undocumented) color-name
|
|
||||||
# whitelist — we try an ordered candidate list per label, same
|
|
||||||
# pattern the planka-api gem uses for its own priority labels.
|
|
||||||
#
|
|
||||||
# This class never archives a board — archiving only happens on explicit
|
|
||||||
# human request (see BoardSpec / the resolver). It does provide the raw
|
|
||||||
# rename primitives (+activate+ / +archive+) that a caller may invoke.
|
|
||||||
#
|
|
||||||
# project_name accepts ANY Planka project name, not just "Dev"/"Clients"
|
|
||||||
# — #ensure creates the project on demand (find_or_create_project) if it
|
|
||||||
# doesn't exist yet. There is deliberately no move-a-board-between-
|
|
||||||
# projects command: Planka's API doesn't expose one cleanly and the need
|
|
||||||
# is rare enough that it isn't worth building — do it by hand in the
|
|
||||||
# Planka UI (drag the board card into the target project) instead.
|
|
||||||
class BoardEnsurer
|
|
||||||
Result = Struct.new(:board, :created, :project, :lists_created, :labels_created, keyword_init: true)
|
|
||||||
|
|
||||||
# Raised instead of silently creating a bot-only-visible Planka project.
|
|
||||||
class MissingHumanUserIdError < StandardError; end
|
|
||||||
|
|
||||||
# @param client [Planka::Client] an already-authenticated client
|
|
||||||
# @param human_user_id [String, Integer, nil] Planka user id of the
|
|
||||||
# human who must be able to see bot-created projects. Falls back to
|
|
||||||
# +ENV["PLANKA_HUMAN_USER_ID"]+. Only needed when a project doesn't
|
|
||||||
# already exist.
|
|
||||||
def initialize(client:, human_user_id: nil)
|
|
||||||
@client = client
|
|
||||||
@human_user_id = human_user_id || ENV["PLANKA_HUMAN_USER_ID"]
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param name [String] board name (never an archived---prefixed name;
|
|
||||||
# this method only ever targets the active board)
|
|
||||||
# @param project_name [String] any Planka project name — created on demand
|
|
||||||
# if it doesn't already exist
|
|
||||||
# @return [Result]
|
|
||||||
def ensure(name, project_name:)
|
|
||||||
raise ArgumentError, "board name must not use the archived-- prefix" if BoardSpec.archived?(name)
|
|
||||||
|
|
||||||
project = find_or_create_project(project_name)
|
|
||||||
board, created = find_or_create_board(project, name)
|
|
||||||
|
|
||||||
lists_created = ensure_lists(board)
|
|
||||||
labels_created = ensure_labels(board)
|
|
||||||
|
|
||||||
Result.new(board: board, created: created, project: project,
|
|
||||||
lists_created: lists_created, labels_created: labels_created)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Rename an archived board back to its active name. Raises if no
|
|
||||||
# archived board by that name exists.
|
|
||||||
#
|
|
||||||
# @param name [String] the active (non-prefixed) name to activate
|
|
||||||
# @return [Types::Board] the updated board
|
|
||||||
def activate(name)
|
|
||||||
board = find_board_by_name(BoardSpec.archived_name(name))
|
|
||||||
raise "no archived board named #{BoardSpec.archived_name(name).inspect} found" unless board
|
|
||||||
|
|
||||||
@client.boards.update(board.id, name: name)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Rename an active board to its archived form. Only ever called on
|
|
||||||
# explicit human request — never invoked by +ensure+.
|
|
||||||
#
|
|
||||||
# @param name [String] the active (non-prefixed) name to archive
|
|
||||||
# @return [Types::Board] the updated board
|
|
||||||
def archive(name)
|
|
||||||
board = find_board_by_name(name)
|
|
||||||
raise "no active board named #{name.inspect} found" unless board
|
|
||||||
|
|
||||||
@client.boards.update(board.id, name: BoardSpec.archived_name(name))
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def find_or_create_project(project_name)
|
|
||||||
existing = @client.projects.list.find { |p| p.name == project_name }
|
|
||||||
return existing if existing
|
|
||||||
|
|
||||||
project = @client.projects.create(name: project_name, type: "shared")
|
|
||||||
fix_visibility(project)
|
|
||||||
project
|
|
||||||
end
|
|
||||||
|
|
||||||
# Quirk 2: null the bot's sole ownerProjectManagerId and add the human
|
|
||||||
# as a project manager, so the project shows up for them. Only reached
|
|
||||||
# right after find_or_create_project creates a brand-new project — an
|
|
||||||
# already-existing project never calls this and so never requires
|
|
||||||
# human_user_id.
|
|
||||||
def fix_visibility(project)
|
|
||||||
unless @human_user_id
|
|
||||||
# The Planka API has no atomic create-with-manager call, so the
|
|
||||||
# project already exists (bot-only-visible) by the time we can
|
|
||||||
# detect the missing id — we deliberately don't try to roll it
|
|
||||||
# back/delete it here; raise loudly instead and let a human fix
|
|
||||||
# visibility (env var + rerun, or the Planka UI) after the fact.
|
|
||||||
raise MissingHumanUserIdError,
|
|
||||||
"cannot make new Planka project #{project.name.inspect} visible to a human: " \
|
|
||||||
"no human_user_id given and PLANKA_HUMAN_USER_ID is unset — the project was " \
|
|
||||||
"created bot-only; set the env var and fix visibility manually or via the " \
|
|
||||||
"Planka UI"
|
|
||||||
end
|
|
||||||
|
|
||||||
@client.projects.add_manager(project.id, @human_user_id)
|
|
||||||
@client.projects.update(project.id, ownerProjectManagerId: nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_or_create_board(project, name)
|
|
||||||
board = find_board_by_name(name, project: project)
|
|
||||||
return [board, false] if board
|
|
||||||
|
|
||||||
created = @client.boards.create(project.id, name: name, position: 65_536)
|
|
||||||
[created, true]
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_board_by_name(name, project: nil)
|
|
||||||
projects = project ? [project] : @client.projects.list
|
|
||||||
projects.each do |p|
|
|
||||||
board = @client.boards.list(p.id).find { |b| b.name == name }
|
|
||||||
return board if board
|
|
||||||
end
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def ensure_lists(board)
|
|
||||||
existing = @client.lists.list(board.id).filter_map(&:name)
|
|
||||||
created = []
|
|
||||||
BoardSpec::LISTS.each_with_index do |list_name, index|
|
|
||||||
next if existing.include?(list_name)
|
|
||||||
|
|
||||||
@client.lists.create(board.id, name: list_name, type: "active", position: (index + 1) * 65_536)
|
|
||||||
created << list_name
|
|
||||||
end
|
|
||||||
created
|
|
||||||
end
|
|
||||||
|
|
||||||
def ensure_labels(board)
|
|
||||||
existing = @client.labels.list(board.id).filter_map(&:name)
|
|
||||||
created = []
|
|
||||||
BoardSpec::LABEL_NAMES.each do |label_name|
|
|
||||||
next if existing.include?(label_name)
|
|
||||||
|
|
||||||
create_label_with_fallback_color(board, label_name)
|
|
||||||
created << label_name
|
|
||||||
end
|
|
||||||
created
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_label_with_fallback_color(board, label_name)
|
|
||||||
candidates = BoardSpec::LABEL_COLOR_CANDIDATES.fetch(label_name)
|
|
||||||
last_error = nil
|
|
||||||
candidates.each do |color|
|
|
||||||
return @client.labels.create(board.id, name: label_name, color: color, position: 65_536)
|
|
||||||
rescue Planka::ValidationError, Planka::BadRequestError => e
|
|
||||||
last_error = e
|
|
||||||
next
|
|
||||||
end
|
|
||||||
raise last_error || RuntimeError, "could not create label #{label_name.inspect}: no candidate color accepted"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
require_relative "config"
|
|
||||||
|
|
||||||
module Backlog
|
|
||||||
# Resolves a board name to its Planka board record, caching the resolved
|
|
||||||
# id as `board_id=<id>` in the repo's `.cc-os/config` (issue #22).
|
|
||||||
#
|
|
||||||
# The tracker's board *name* stays the source of truth; the id is only a
|
|
||||||
# cache. The cached id is honored only when the config's tracker is
|
|
||||||
# exactly `planka:<board_name>`, and a cached `boards.get(id)` hit counts
|
|
||||||
# only if the returned board's name still matches — a 404 or a rename
|
|
||||||
# falls back to the projects+boards name lookup and rewrites the cache.
|
|
||||||
# Any cache failure (unreadable config, unwritable config, stale id)
|
|
||||||
# degrades to the name lookup; it never fails the command.
|
|
||||||
#
|
|
||||||
# Filesystem access is injected: `read_config` returns the raw
|
|
||||||
# `.cc-os/config` contents (or nil), `write_config` persists updated
|
|
||||||
# contents. Cache writes go through Config.set, the comment-preserving
|
|
||||||
# writer — never Config.merge.
|
|
||||||
class BoardResolver
|
|
||||||
# @param client [Planka::Client] an already-authenticated client
|
|
||||||
# @param read_config [#call] -> String, nil — raw .cc-os/config contents
|
|
||||||
# @param write_config [#call] (String) -> void — persist updated contents
|
|
||||||
def initialize(client:, read_config:, write_config:)
|
|
||||||
@client = client
|
|
||||||
@read_config = read_config
|
|
||||||
@write_config = write_config
|
|
||||||
end
|
|
||||||
|
|
||||||
# A resolver with no config access: always name-lookup, never caches.
|
|
||||||
def self.null(client:)
|
|
||||||
new(client: client, read_config: -> {}, write_config: ->(_) {})
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param name [String] the board name (source of truth)
|
|
||||||
# @return [Planka::Types::Board]
|
|
||||||
# @raise [RuntimeError] if no board with that name exists
|
|
||||||
def board_for(name)
|
|
||||||
cached = cached_board(name)
|
|
||||||
return cached if cached
|
|
||||||
|
|
||||||
board = lookup_by_name!(name)
|
|
||||||
cache_id(name, board.id)
|
|
||||||
board
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def cached_board(name)
|
|
||||||
config = safe_config
|
|
||||||
return nil unless config && config.tracker == "planka:#{name}"
|
|
||||||
|
|
||||||
id = config.board_id
|
|
||||||
return nil unless id
|
|
||||||
|
|
||||||
board = @client.boards.get(id).board
|
|
||||||
board && board.name == name ? board : nil
|
|
||||||
rescue StandardError
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def lookup_by_name!(name)
|
|
||||||
@client.projects.list.each do |project|
|
|
||||||
board = @client.boards.list(project.id).find { |b| b.name == name }
|
|
||||||
return board if board
|
|
||||||
end
|
|
||||||
raise "no board named #{name.inspect} found (run board-ensure first)"
|
|
||||||
end
|
|
||||||
|
|
||||||
def cache_id(name, id)
|
|
||||||
contents = @read_config.call
|
|
||||||
config = Config.new(contents)
|
|
||||||
return unless config.tracker == "planka:#{name}"
|
|
||||||
return if config.board_id == id.to_s
|
|
||||||
|
|
||||||
@write_config.call(Config.set(contents, "board_id", id))
|
|
||||||
rescue StandardError
|
|
||||||
nil # cache write failure never fails the command
|
|
||||||
end
|
|
||||||
|
|
||||||
def safe_config
|
|
||||||
Config.new(@read_config.call)
|
|
||||||
rescue StandardError
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
module Backlog
|
|
||||||
# The uniform board contract every os-backlog board must have: list order
|
|
||||||
# and the enforced label set. Values only — no client/network code.
|
|
||||||
module BoardSpec
|
|
||||||
# Lists in required left-to-right order.
|
|
||||||
LISTS = %w[Backlog Next Doing Waiting Review Done].freeze
|
|
||||||
|
|
||||||
# Label name => ordered candidate colors, tried in turn until the server
|
|
||||||
# accepts one (Planka's palette-name whitelist is not documented, so we
|
|
||||||
# can't assert it in advance — cf. planka-api gem's Priority::COLOR_CANDIDATES
|
|
||||||
# pattern). The first candidate in each list is the verified-good default.
|
|
||||||
LABEL_COLOR_CANDIDATES = {
|
|
||||||
"P0" => %w[berry-red red-burgundy salsa-red],
|
|
||||||
"P1" => %w[pumpkin-orange orange-peel sunset-orange],
|
|
||||||
"P2" => %w[egg-yellow sunny-grass bright-moss],
|
|
||||||
"P3" => %w[steel-grey morning-sky light-cocoa],
|
|
||||||
"hitl" => %w[tank-green bright-moss lagoon-blue],
|
|
||||||
"semi" => %w[lagoon-blue summer-sky navy-blue],
|
|
||||||
"afk-ready" => %w[antique-blue bright-moss slate-blue]
|
|
||||||
}.freeze
|
|
||||||
|
|
||||||
LABEL_NAMES = LABEL_COLOR_CANDIDATES.keys.freeze
|
|
||||||
|
|
||||||
ARCHIVE_PREFIX = "archived--"
|
|
||||||
|
|
||||||
class << self
|
|
||||||
def archived_name(name) = "#{ARCHIVE_PREFIX}#{name}"
|
|
||||||
|
|
||||||
def archived?(name) = name.to_s.start_with?(ARCHIVE_PREFIX)
|
|
||||||
|
|
||||||
def active_name(name) = archived?(name) ? name.sub(ARCHIVE_PREFIX, "") : name
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
require_relative "board_spec"
|
|
||||||
require_relative "board_resolver"
|
|
||||||
|
|
||||||
module Backlog
|
|
||||||
# Card-level operations over an authenticated Planka client: create a
|
|
||||||
# card at Backlog (the only column the AI ever creates into), build a
|
|
||||||
# read-only board snapshot (lists with their cards, labels, card-label
|
|
||||||
# assignments) for the list skill and the triage/audit agents, and move
|
|
||||||
# cards between columns under the deterministic policy guardrails
|
|
||||||
# enforced by #move (Next is human-curated, hitl cards are human-owned,
|
|
||||||
# Done is a one-way autonomous door gated on the afk-ready label).
|
|
||||||
#
|
|
||||||
# Never deletes anything.
|
|
||||||
class Cards
|
|
||||||
# @param client [Planka::Client] an already-authenticated client
|
|
||||||
# @param board_resolver [BoardResolver, nil] resolves board names to
|
|
||||||
# board records (with the .cc-os/config board_id cache — issue #22);
|
|
||||||
# defaults to a cache-less name-only resolver. Cards never touches
|
|
||||||
# the filesystem itself — all config I/O lives in the resolver.
|
|
||||||
def initialize(client:, board_resolver: nil)
|
|
||||||
@client = client
|
|
||||||
@board_resolver = board_resolver || BoardResolver.null(client: client)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create a card at the board's Backlog list. Raises if the board or
|
|
||||||
# its Backlog list doesn't exist (callers run board-ensure first).
|
|
||||||
#
|
|
||||||
# @return [Planka::Types::Card] the created card
|
|
||||||
def add(board_name:, title:, description: nil)
|
|
||||||
board = find_board!(board_name)
|
|
||||||
backlog = find_list!(board, "Backlog")
|
|
||||||
attrs = { name: title, type: "project", position: next_position(board, backlog) }
|
|
||||||
attrs[:description] = description if description
|
|
||||||
@client.cards.create(backlog.id, attrs)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Read-only snapshot of a whole board: every list in BoardSpec order
|
|
||||||
# (plus any extras), each with its cards and their label names.
|
|
||||||
#
|
|
||||||
# @return [Hash] { "board" => name, "labels" => [names],
|
|
||||||
# "lists" => [{ "name", "cards" => [{"id","name","labels",...card fields}] }] }
|
|
||||||
def snapshot(board_name:)
|
|
||||||
board = find_board!(board_name)
|
|
||||||
detail = @client.boards.get(board.id)
|
|
||||||
labels_by_id = detail.labels.to_h { |l| [l.id, l.name] }
|
|
||||||
card_labels = detail.card_labels.group_by(&:card_id)
|
|
||||||
cards_by_list = detail.cards.group_by(&:list_id)
|
|
||||||
|
|
||||||
lists = detail.lists
|
|
||||||
.select(&:name) # skip built-in archive/trash lists (name: nil)
|
|
||||||
.sort_by { |l| l.position || Float::INFINITY }
|
|
||||||
.map do |list|
|
|
||||||
cards = (cards_by_list[list.id] || []).map do |card|
|
|
||||||
label_names = (card_labels[card.id] || []).filter_map { |cl| labels_by_id[cl.label_id] }
|
|
||||||
card_hash(card, label_names)
|
|
||||||
end
|
|
||||||
{ "name" => list.name, "cards" => cards }
|
|
||||||
end
|
|
||||||
|
|
||||||
{ "board" => board.name, "labels" => labels_by_id.values, "lists" => lists }
|
|
||||||
end
|
|
||||||
|
|
||||||
# Attach a board label (by name) to a card. The only card mutation the
|
|
||||||
# triage agent is allowed — label changes, never column moves.
|
|
||||||
#
|
|
||||||
# @return [Planka::Types::CardLabel] the card-label join record
|
|
||||||
def attach_label(board_name:, card_id:, label:)
|
|
||||||
board = find_board!(board_name)
|
|
||||||
record = @client.labels.list(board.id).find { |l| l.name == label }
|
|
||||||
raise "board #{board_name.inspect} has no label #{label.inspect} (run board-ensure)" unless record
|
|
||||||
|
|
||||||
@client.labels.attach_to_card(card_id, record.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Add a comment to a card. The only card mutation the audit agent is
|
|
||||||
# allowed.
|
|
||||||
#
|
|
||||||
# @return [Planka::Types::Comment] the created comment
|
|
||||||
def comment(card_id:, text:)
|
|
||||||
@client.comments.create(card_id, text: text)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Move a card to another list on the same board, enforcing the
|
|
||||||
# deterministic column-ownership policy (this is code, not skill
|
|
||||||
# prose, precisely so it can't be talked around):
|
|
||||||
#
|
|
||||||
# 1. Any move into or out of Next is refused — Next is human-curated.
|
|
||||||
# 2. Any move of a card labeled hitl is refused — hitl cards are
|
|
||||||
# human-owned.
|
|
||||||
# 3. Moving a card out of Done is refused.
|
|
||||||
# 4. Moving a card to Done is allowed only if it carries the
|
|
||||||
# afk-ready label (afk-ready work that's shipped+verified goes
|
|
||||||
# straight to Done; semi work stops at Review for human sign-off).
|
|
||||||
# 5. Everything else among Backlog/Doing/Waiting/Review is allowed.
|
|
||||||
#
|
|
||||||
# Lands the card at the end of the destination list (max existing
|
|
||||||
# position + 65536, or 65536 if the list is empty).
|
|
||||||
#
|
|
||||||
# @param board_name [String]
|
|
||||||
# @param card_id [String, Integer]
|
|
||||||
# @param to [String] destination list name (one of BoardSpec::LISTS)
|
|
||||||
# @return [Planka::Types::Card] the moved card
|
|
||||||
def move(board_name:, card_id:, to:)
|
|
||||||
unless BoardSpec::LISTS.include?(to)
|
|
||||||
raise "unknown list #{to.inspect} (expected one of #{BoardSpec::LISTS.join(', ')})"
|
|
||||||
end
|
|
||||||
|
|
||||||
board = find_board!(board_name)
|
|
||||||
detail = @client.boards.get(board.id)
|
|
||||||
card = detail.cards.find { |c| c.id == card_id }
|
|
||||||
raise "no card ##{card_id} found on board #{board_name.inspect}" unless card
|
|
||||||
|
|
||||||
from_name = detail.lists.find { |l| l.id == card.list_id }&.name
|
|
||||||
to_list = detail.lists.find { |l| l.name == to }
|
|
||||||
raise "board #{board_name.inspect} has no #{to.inspect} list (run board-ensure)" unless to_list
|
|
||||||
|
|
||||||
enforce_move_policy!(from_name: from_name, to_name: to, card: card, detail: detail)
|
|
||||||
|
|
||||||
position = next_position(board, to_list)
|
|
||||||
@client.cards.move(card_id, list_id: to_list.id, position: position)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def enforce_move_policy!(from_name:, to_name:, card:, detail:)
|
|
||||||
raise "Next is human-curated" if from_name == "Next" || to_name == "Next"
|
|
||||||
|
|
||||||
label_names = detail.card_labels
|
|
||||||
.select { |cl| cl.card_id == card.id }
|
|
||||||
.filter_map { |cl| detail.labels.find { |l| l.id == cl.label_id }&.name }
|
|
||||||
raise "hitl cards are human-owned" if label_names.include?("hitl")
|
|
||||||
|
|
||||||
raise "cannot move a card out of Done" if from_name == "Done" && to_name != "Done"
|
|
||||||
|
|
||||||
return unless to_name == "Done" && !label_names.include?("afk-ready")
|
|
||||||
|
|
||||||
raise "move to Done requires the afk-ready label"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Build the plain-hash card representation the CLI/JSON output emits,
|
|
||||||
# keeping the output shape byte-compatible with the 0.1.x snapshot
|
|
||||||
# (string keys, "labels" merged in) even though Types::Card is an
|
|
||||||
# immutable Data object we can no longer +merge+. Emits every
|
|
||||||
# Types::Card field — dropping fields here silently breaks consumers
|
|
||||||
# like board-audit, which reads "listChangedAt".
|
|
||||||
def card_hash(card, label_names)
|
|
||||||
{
|
|
||||||
"id" => card.id,
|
|
||||||
"createdAt" => card.created_at,
|
|
||||||
"updatedAt" => card.updated_at,
|
|
||||||
"type" => card.type,
|
|
||||||
"position" => card.position,
|
|
||||||
"name" => card.name,
|
|
||||||
"description" => card.description,
|
|
||||||
"dueDate" => card.due_date,
|
|
||||||
"isDueCompleted" => card.is_due_completed,
|
|
||||||
"stopwatch" => card.stopwatch,
|
|
||||||
"commentsTotal" => card.comments_total,
|
|
||||||
"isClosed" => card.is_closed,
|
|
||||||
"listChangedAt" => card.list_changed_at,
|
|
||||||
"boardId" => card.board_id,
|
|
||||||
"listId" => card.list_id,
|
|
||||||
"creatorUserId" => card.creator_user_id,
|
|
||||||
"prevListId" => card.prev_list_id,
|
|
||||||
"coverAttachmentId" => card.cover_attachment_id,
|
|
||||||
"isSubscribed" => card.is_subscribed,
|
|
||||||
"labels" => label_names
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_board!(name)
|
|
||||||
@board_resolver.board_for(name)
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_list!(board, list_name)
|
|
||||||
list = @client.lists.list(board.id).find { |l| l.name == list_name }
|
|
||||||
raise "board #{board.name.inspect} has no #{list_name.inspect} list (run board-ensure)" unless list
|
|
||||||
|
|
||||||
list
|
|
||||||
end
|
|
||||||
|
|
||||||
def next_position(board, list)
|
|
||||||
detail = @client.boards.get(board.id)
|
|
||||||
positions = detail.cards
|
|
||||||
.select { |c| c.list_id == list.id }
|
|
||||||
.filter_map(&:position)
|
|
||||||
(positions.max || 0) + 65_536
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -35,20 +35,9 @@ module Backlog
|
||||||
@data["tracker"]
|
@data["tracker"]
|
||||||
end
|
end
|
||||||
|
|
||||||
# @return [String, nil] the effective Planka board name: the explicit
|
# @return [String, nil] the cached Planka board id, if present (legacy;
|
||||||
# `board` key when present, else the board named by a
|
# Planka is retired per ADR-0042 — kept only so old .cc-os/config
|
||||||
# `tracker=planka:<board>` key (the form config-write actually
|
# files with this key still parse without error).
|
||||||
# writes). Non-planka trackers contribute nothing.
|
|
||||||
def planka_board
|
|
||||||
return board if board
|
|
||||||
return nil unless tracker&.start_with?("planka:")
|
|
||||||
|
|
||||||
tracker.delete_prefix("planka:")
|
|
||||||
end
|
|
||||||
|
|
||||||
# @return [String, nil] the cached Planka board id, if present. Only a
|
|
||||||
# cache — the tracker's board *name* stays the source of truth
|
|
||||||
# (issue #22); BoardResolver decides whether to honor it.
|
|
||||||
def board_id
|
def board_id
|
||||||
@data["board_id"]
|
@data["board_id"]
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
require "json"
|
||||||
|
require "time"
|
||||||
|
require "fileutils"
|
||||||
|
|
||||||
|
module Backlog
|
||||||
|
# Issue-side capture/list helpers (ADR-0042: one tracker holds both state
|
||||||
|
# and specs, git issues are the single tracker). Dispatches on the
|
||||||
|
# tracker's kind: `tea` for forgejo:, `gh` for github:, in-repo markdown
|
||||||
|
# files for repo:<path>.
|
||||||
|
#
|
||||||
|
# The git-host CLIs are shelled out to via an injected #capture(*cmd)
|
||||||
|
# runner — [stdout, success?], mirroring the tail of Open3.capture3 — so
|
||||||
|
# tests stub the runner object directly instead of hacking PATH
|
||||||
|
# (ShellRunner below is the only piece that actually shells out). The
|
||||||
|
# repo: file mode does real filesystem IO directly (same style as
|
||||||
|
# Inspector), exercised in tests via tmpdir.
|
||||||
|
class Issues
|
||||||
|
# Priority labels capture may attach. Deliberately excludes every state
|
||||||
|
# label (`next`/`waiting`/`review`) — there is no parameter through
|
||||||
|
# which a state label can reach #create, so it can never apply `next`
|
||||||
|
# (issue-state-labels spec: next is human-only).
|
||||||
|
PRIORITY_LABELS = %w[P0 P1 P2 P3].freeze
|
||||||
|
STATE_LABELS = %w[next waiting review].freeze
|
||||||
|
|
||||||
|
CommandFailed = Class.new(StandardError)
|
||||||
|
|
||||||
|
# Default runner: actually shells out via Open3. The only non-pure
|
||||||
|
# piece of this file; production CLI wiring uses it, tests never do.
|
||||||
|
class ShellRunner
|
||||||
|
def capture(*cmd)
|
||||||
|
require "open3"
|
||||||
|
out, _err, status = Open3.capture3(*cmd)
|
||||||
|
[out, status.success?]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param runner [#capture] responds to #capture(*cmd) -> [stdout, success?]
|
||||||
|
# @param repo_root [String] base directory a repo:<path> tracker's path is relative to
|
||||||
|
# @param clock [#call] returns the current Time; injected for repo: issue dating
|
||||||
|
def initialize(runner:, repo_root: Dir.pwd, clock: -> { Time.now })
|
||||||
|
@runner = runner
|
||||||
|
@repo_root = repo_root
|
||||||
|
@clock = clock
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create an issue with a title, body, and optional priority label
|
||||||
|
# (P0-P3). Never applies a state label.
|
||||||
|
#
|
||||||
|
# @return [Hash] {number:, url:} for forgejo:/github:, {number:, path:} for repo:
|
||||||
|
def create(tracker:, title:, body:, priority: nil)
|
||||||
|
validate_priority!(priority)
|
||||||
|
case Tracker.kind(tracker)
|
||||||
|
when :forgejo then create_tea(slug(tracker), title: title, body: body, priority: priority)
|
||||||
|
when :github then create_gh(slug(tracker), title: title, body: body, priority: priority)
|
||||||
|
when :repo then create_file(rel_path(tracker), title: title, body: body, priority: priority)
|
||||||
|
else raise ArgumentError, "cannot create an issue on unroutable tracker #{tracker.inspect}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Open issues, grouped by state label.
|
||||||
|
#
|
||||||
|
# @return [Hash{Symbol=>Array<Hash>}] :next / :waiting / :review / :other
|
||||||
|
# => arrays of {number:, title:, labels:, assignee:}; :next first is
|
||||||
|
# the caller's job to render, not this method's (it returns a plain
|
||||||
|
# Hash, insertion-ordered next/waiting/review/other).
|
||||||
|
def list(tracker:)
|
||||||
|
issues =
|
||||||
|
case Tracker.kind(tracker)
|
||||||
|
when :forgejo then list_tea(slug(tracker))
|
||||||
|
when :github then list_gh(slug(tracker))
|
||||||
|
when :repo then list_files(rel_path(tracker))
|
||||||
|
else raise ArgumentError, "cannot list issues on unroutable tracker #{tracker.inspect}"
|
||||||
|
end
|
||||||
|
group_by_state(issues)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_priority!(priority)
|
||||||
|
return if priority.nil? || PRIORITY_LABELS.include?(priority)
|
||||||
|
|
||||||
|
raise ArgumentError, "invalid priority #{priority.inspect}; expected one of #{PRIORITY_LABELS.join(', ')}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def slug(tracker) = tracker.split(":", 2).last
|
||||||
|
def rel_path(tracker) = tracker.split(":", 2).last
|
||||||
|
|
||||||
|
def group_by_state(issues)
|
||||||
|
buckets = { next: [], waiting: [], review: [], other: [] }
|
||||||
|
issues.each do |issue|
|
||||||
|
labels = Array(issue[:labels])
|
||||||
|
key = STATE_LABELS.find { |label| labels.include?(label) }&.to_sym || :other
|
||||||
|
buckets[key] << issue
|
||||||
|
end
|
||||||
|
buckets
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- forgejo (tea) ---------------------------------------------------
|
||||||
|
|
||||||
|
def create_tea(repo_slug, title:, body:, priority:)
|
||||||
|
cmd = ["tea", "issue", "create", "--repo", repo_slug, "--title", title, "--description", body]
|
||||||
|
cmd += ["--labels", priority] if priority
|
||||||
|
out, ok = @runner.capture(*cmd)
|
||||||
|
raise CommandFailed, "tea issue create failed: #{out}" unless ok
|
||||||
|
|
||||||
|
{ number: extract_number(out), url: extract_url(out) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_tea(repo_slug)
|
||||||
|
cmd = ["tea", "issues", "list", "--repo", repo_slug, "--state", "open",
|
||||||
|
"--fields", "index,title,labels,assignees", "--output", "json"]
|
||||||
|
out, ok = @runner.capture(*cmd)
|
||||||
|
raise CommandFailed, "tea issues list failed: #{out}" unless ok
|
||||||
|
|
||||||
|
JSON.parse(out).map do |issue|
|
||||||
|
assignee = issue["assignees"].to_s.split(",").map(&:strip).reject(&:empty?).first
|
||||||
|
{ number: issue["index"].to_i, title: issue["title"],
|
||||||
|
labels: issue["labels"].to_s.split(",").map(&:strip).reject(&:empty?),
|
||||||
|
assignee: assignee }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- github (gh) ------------------------------------------------------
|
||||||
|
|
||||||
|
def create_gh(repo_slug, title:, body:, priority:)
|
||||||
|
cmd = ["gh", "issue", "create", "--repo", repo_slug, "--title", title, "--body", body]
|
||||||
|
cmd += ["--label", priority] if priority
|
||||||
|
out, ok = @runner.capture(*cmd)
|
||||||
|
raise CommandFailed, "gh issue create failed: #{out}" unless ok
|
||||||
|
|
||||||
|
{ number: extract_number(out), url: extract_url(out) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_gh(repo_slug)
|
||||||
|
cmd = ["gh", "issue", "list", "--repo", repo_slug, "--state", "open",
|
||||||
|
"--json", "number,title,labels,assignees"]
|
||||||
|
out, ok = @runner.capture(*cmd)
|
||||||
|
raise CommandFailed, "gh issue list failed: #{out}" unless ok
|
||||||
|
|
||||||
|
JSON.parse(out).map do |issue|
|
||||||
|
{ number: issue["number"].to_i, title: issue["title"],
|
||||||
|
labels: Array(issue["labels"]).map { |label| label["name"] },
|
||||||
|
assignee: Array(issue["assignees"]).map { |a| a["login"] }.first }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_number(text)
|
||||||
|
match = text[/#(\d+)/, 1] || text[%r{/issues/(\d+)}, 1]
|
||||||
|
match&.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_url(text)
|
||||||
|
text.to_s[%r{https?://\S+}] || text.to_s.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- repo: (in-repo markdown issue files) ------------------------------
|
||||||
|
#
|
||||||
|
# `<path>/NNN-<slug>.md`, front matter + body. No existing in-repo
|
||||||
|
# convention for individual issue files predates this (Inspector only
|
||||||
|
# detects the presence of a docs/issues dir or ISSUES.md, not a file
|
||||||
|
# shape) — this is a new, deliberately simple convention.
|
||||||
|
|
||||||
|
def create_file(rel_dir, title:, body:, priority:)
|
||||||
|
dir = File.join(@repo_root, rel_dir)
|
||||||
|
FileUtils.mkdir_p(dir)
|
||||||
|
number = next_file_number(dir)
|
||||||
|
path = File.join(dir, format("%03d-%s.md", number, slugify(title)))
|
||||||
|
File.write(path, file_contents(number: number, title: title, body: body, priority: priority))
|
||||||
|
{ number: number, path: path }
|
||||||
|
end
|
||||||
|
|
||||||
|
def slugify(title)
|
||||||
|
title.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
def file_contents(number:, title:, body:, priority:)
|
||||||
|
front = {
|
||||||
|
"number" => number.to_s,
|
||||||
|
"title" => title.to_s,
|
||||||
|
"labels" => [priority].compact.join(","),
|
||||||
|
"state" => "open",
|
||||||
|
"created" => @clock.call.strftime("%Y-%m-%d")
|
||||||
|
}
|
||||||
|
lines = ["---"] + front.map { |k, v| "#{k}: #{v}" } + ["---", ""]
|
||||||
|
"#{lines.join("\n")}\n#{body}\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_file_number(dir)
|
||||||
|
existing = Dir.glob(File.join(dir, "[0-9][0-9][0-9]-*.md"))
|
||||||
|
existing.map { |f| File.basename(f)[/\A(\d+)-/, 1].to_i }.max.to_i + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_files(rel_dir)
|
||||||
|
dir = File.join(@repo_root, rel_dir)
|
||||||
|
return [] unless Dir.exist?(dir)
|
||||||
|
|
||||||
|
Dir.glob(File.join(dir, "*.md")).sort.filter_map { |path| parse_issue_file(path) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_issue_file(path)
|
||||||
|
contents = File.read(path)
|
||||||
|
return nil unless contents.start_with?("---\n")
|
||||||
|
|
||||||
|
_, front_matter, = contents.split("---\n", 3)
|
||||||
|
data = {}
|
||||||
|
front_matter.to_s.each_line do |line|
|
||||||
|
key, value = line.split(":", 2)
|
||||||
|
next unless key && value
|
||||||
|
|
||||||
|
data[key.strip] = value.strip
|
||||||
|
end
|
||||||
|
return nil unless data["state"].to_s.empty? || data["state"] == "open"
|
||||||
|
|
||||||
|
{ number: data["number"].to_i, title: data["title"],
|
||||||
|
labels: data["labels"].to_s.split(",").map(&:strip).reject(&:empty?),
|
||||||
|
assignee: data["assignee"] }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,91 +1,44 @@
|
||||||
require_relative "board_spec"
|
|
||||||
require_relative "config"
|
require_relative "config"
|
||||||
|
|
||||||
module Backlog
|
module Backlog
|
||||||
# Pure repo -> board routing decision. No filesystem, no network: every
|
# Pure decision over a project's configured tracker. No filesystem, no
|
||||||
# input (repo path, config contents, board inventory) is passed in.
|
# network: every input (repo path, config contents) is passed in.
|
||||||
#
|
#
|
||||||
# Decision, exactly one of:
|
# Post-ADR-0042 (Planka retired), there is no board inventory to resolve
|
||||||
# "use <board>" - an active board to work against
|
# against — a project either has a `tracker` key in `.cc-os/config` or it
|
||||||
# "activate <archived board>" - an archived board matches; rename it back
|
# doesn't. Decision, exactly one of:
|
||||||
# "stop-and-discuss" - no confident match; ask the human
|
# "use <tracker>" - a tracker key is configured; use it
|
||||||
|
# "stop-and-discuss" - no tracker configured; ask the human
|
||||||
|
# (see /os-backlog:route)
|
||||||
class Resolver
|
class Resolver
|
||||||
Decision = Struct.new(:action, :board) do
|
Decision = Struct.new(:action, :tracker) do
|
||||||
def to_s
|
def to_s
|
||||||
action == :use || action == :activate ? "#{action} #{board}" : "stop-and-discuss"
|
action == :use ? "use #{tracker}" : "stop-and-discuss"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
DEV_ROOT = File.expand_path("~/dev")
|
# @param repo_path [String] absolute path to the repo (kept for callers
|
||||||
CLIENTS_ROOT = File.expand_path("~/clients")
|
# that pass it through; not otherwise used since board heuristics
|
||||||
SERVERS_ROOT = File.expand_path("~/servers")
|
# were retired with Planka)
|
||||||
|
|
||||||
# Path-based Planka project heuristic, shared by the resolver's own
|
|
||||||
# ambiguity gate and the `board-ensure` CLI's --project default: "Dev"
|
|
||||||
# under ~/dev/*, "Clients" under ~/clients/*, "Servers" under
|
|
||||||
# ~/servers/* (the servers umbrella repo — ADR: infrastructure is a
|
|
||||||
# distinct domain from Dev), nil (ambiguous) otherwise. Board-ensure
|
|
||||||
# always accepts an arbitrary --project name too — this heuristic only
|
|
||||||
# supplies the default when none is given.
|
|
||||||
#
|
|
||||||
# @param repo_path [String]
|
|
||||||
# @return [String, nil]
|
|
||||||
def self.project_for(repo_path)
|
|
||||||
path = File.expand_path(repo_path)
|
|
||||||
return "Dev" if under_root?(path, DEV_ROOT)
|
|
||||||
return "Clients" if under_root?(path, CLIENTS_ROOT)
|
|
||||||
return "Servers" if under_root?(path, SERVERS_ROOT)
|
|
||||||
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.under_root?(path, root)
|
|
||||||
path == root || path.start_with?("#{root}/")
|
|
||||||
end
|
|
||||||
private_class_method :under_root?
|
|
||||||
|
|
||||||
# @param repo_path [String] absolute path to the repo
|
|
||||||
# @param config_contents [String, nil] raw contents of .cc-os/config, or nil/absent
|
# @param config_contents [String, nil] raw contents of .cc-os/config, or nil/absent
|
||||||
# @param boards [Array<String>] board names known to exist on the Planka instance
|
def initialize(repo_path:, config_contents:, **)
|
||||||
# (both active and archived--prefixed names)
|
|
||||||
def initialize(repo_path:, config_contents:, boards:)
|
|
||||||
@repo_path = File.expand_path(repo_path)
|
@repo_path = File.expand_path(repo_path)
|
||||||
@config = Config.new(config_contents)
|
@config = Config.new(config_contents)
|
||||||
@boards = boards
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# @return [Decision]
|
# @return [Decision]
|
||||||
def resolve
|
def resolve
|
||||||
configured_board = @config.planka_board
|
return use(@config.tracker) if @config.tracker_configured?
|
||||||
return decision_for_name(configured_board) || stop_and_discuss if configured_board
|
|
||||||
|
|
||||||
derived_decision || stop_and_discuss
|
stop_and_discuss
|
||||||
end
|
end
|
||||||
|
|
||||||
# @return [String] one of "use <board>" / "activate <board>" / "stop-and-discuss"
|
# @return [String] "use <tracker>" or "stop-and-discuss"
|
||||||
def resolve_string = resolve.to_s
|
def resolve_string = resolve.to_s
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def derived_decision
|
def use(tracker) = Decision.new(:use, tracker)
|
||||||
return nil if project.nil?
|
|
||||||
|
|
||||||
decision_for_name(board_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
def decision_for_name(name)
|
|
||||||
return use(name) if @boards.include?(name)
|
|
||||||
return activate(name) if @boards.include?(BoardSpec.archived_name(name))
|
|
||||||
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def stop_and_discuss = Decision.new(:stop_and_discuss, nil)
|
def stop_and_discuss = Decision.new(:stop_and_discuss, nil)
|
||||||
def use(name) = Decision.new(:use, name)
|
|
||||||
def activate(name) = Decision.new(:activate, BoardSpec.archived_name(name))
|
|
||||||
|
|
||||||
def board_name = File.basename(@repo_path)
|
|
||||||
|
|
||||||
def project = self.class.project_for(@repo_path)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,21 @@ module Backlog
|
||||||
class Tracker
|
class Tracker
|
||||||
DEFAULT_FORGEJO_HOST = "forgejo.swansoncloud.com"
|
DEFAULT_FORGEJO_HOST = "forgejo.swansoncloud.com"
|
||||||
|
|
||||||
|
# ADR-0042 retired Planka: the tracker grammar is exactly these three
|
||||||
|
# forms. `planka:` is deliberately absent — it is rejected, not merely
|
||||||
|
# unmatched (see config-write and os-status's tracker-configured check).
|
||||||
FORMATS = {
|
FORMATS = {
|
||||||
planka: %r{\Aplanka:[\w.-]+\z},
|
|
||||||
forgejo: %r{\Aforgejo:[\w.-]+/[\w.-]+\z},
|
forgejo: %r{\Aforgejo:[\w.-]+/[\w.-]+\z},
|
||||||
github: %r{\Agithub:[\w.-]+/[\w.-]+\z},
|
github: %r{\Agithub:[\w.-]+/[\w.-]+\z},
|
||||||
repo: /\Arepo:.+\z/
|
repo: /\Arepo:.+\z/
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
# @return [Boolean] true if value matches one of the four valid formats
|
# @return [Boolean] true if value matches one of the three valid formats
|
||||||
def self.valid?(value)
|
def self.valid?(value)
|
||||||
!kind(value).nil?
|
!kind(value).nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
# @return [Symbol, nil] :planka / :forgejo / :github / :repo, or nil if invalid
|
# @return [Symbol, nil] :forgejo / :github / :repo, or nil if invalid
|
||||||
def self.kind(value)
|
def self.kind(value)
|
||||||
return nil unless value.is_a?(String)
|
return nil unless value.is_a?(String)
|
||||||
|
|
||||||
|
|
@ -26,18 +28,17 @@ module Backlog
|
||||||
end
|
end
|
||||||
|
|
||||||
# Where /to-issues output (spec slices) should be published, given the
|
# Where /to-issues output (spec slices) should be published, given the
|
||||||
# repo's tracker key. The ADR'd boundary: git issues = specs, Planka =
|
# repo's tracker key. One tracker holds both state and specs (ADR-0042):
|
||||||
# state. So forgejo:/github: keys route spec slices to that git issue
|
# forgejo:/github: keys route spec slices to that git issue tracker;
|
||||||
# tracker. `planka:` / `repo:` / nil deliberately map to
|
# repo:<path> routes to the in-repo issue-file convention; nil or any
|
||||||
# :planka_default — what that MEANS for spec-shaped output is a named
|
# invalid key (including retired `planka:` values) is :unrouted.
|
||||||
# decision gate at wording review (issue #16), not settled here.
|
|
||||||
#
|
#
|
||||||
# @return [Symbol] :git_issues | :planka_default | :unrouted (invalid key)
|
# @return [Symbol] :git_issues | :repo_files | :unrouted
|
||||||
def self.issues_destination(value)
|
def self.issues_destination(value)
|
||||||
case kind(value)
|
case kind(value)
|
||||||
when :forgejo, :github then :git_issues
|
when :forgejo, :github then :git_issues
|
||||||
when :planka, :repo then :planka_default
|
when :repo then :repo_files
|
||||||
else value.nil? ? :planka_default : :unrouted
|
else :unrouted
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
module Backlog
|
|
||||||
# Pure decision for the triage-trigger SessionStart hook (card
|
|
||||||
# 1818330518584820864): given the label arrays already sitting on a
|
|
||||||
# board's Backlog-column cards, does the hook need to instruct dispatch
|
|
||||||
# of the card-triage agent? Network/board-fetch stays in the hook
|
|
||||||
# script (hooks/triage_check.rb) — this class only classifies +
|
|
||||||
# composes the note text, so the decision is unit-testable without
|
|
||||||
# Planka.
|
|
||||||
#
|
|
||||||
# "Unlabeled" mirrors card-triage's own definition (agents/card-triage.md):
|
|
||||||
# a card is raw if it is missing a priority label (P0-P3) OR an
|
|
||||||
# autonomy label (hitl/semi/afk-ready) — not just totally bare.
|
|
||||||
class TriageCheck
|
|
||||||
PRIORITY_LABELS = %w[P0 P1 P2 P3].freeze
|
|
||||||
AUTONOMY_LABELS = %w[hitl semi afk-ready].freeze
|
|
||||||
|
|
||||||
# Which board should this session's triage check inspect? Mirrors the
|
|
||||||
# CLI's config semantics (the session cwd's own .cc-os/config wins —
|
|
||||||
# umbrella subdir configs override the repo root's), falling back to
|
|
||||||
# the root config, then to the path heuristic. Honors both the
|
|
||||||
# explicit `board` key and `tracker=planka:<board>` (via
|
|
||||||
# Config#planka_board). Pure: the hook script reads the files and
|
|
||||||
# passes contents (or nil) in.
|
|
||||||
#
|
|
||||||
# @param cwd [String] the session's working directory
|
|
||||||
# @param root [String] the enclosing git toplevel (== cwd outside umbrellas)
|
|
||||||
# @param cwd_config [String, nil] contents of <cwd>/.cc-os/config
|
|
||||||
# @param root_config [String, nil] contents of <root>/.cc-os/config
|
|
||||||
# @return [String, nil] board name, or nil (don't guess) when nothing
|
|
||||||
# is configured and the path heuristic doesn't recognize the path
|
|
||||||
def self.board_name_for(cwd:, root:, cwd_config:, root_config:)
|
|
||||||
config_home = cwd_config ? cwd : root
|
|
||||||
config = Config.new(cwd_config || root_config)
|
|
||||||
return config.planka_board if config.planka_board
|
|
||||||
return nil unless Resolver.project_for(config_home)
|
|
||||||
|
|
||||||
File.basename(config_home)
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param labels [Array<String>] the label names on one card
|
|
||||||
# @return [Boolean]
|
|
||||||
def self.needs_triage?(labels)
|
|
||||||
(labels & PRIORITY_LABELS).empty? || (labels & AUTONOMY_LABELS).empty?
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param backlog_cards_labels [Array<Array<String>>] label arrays,
|
|
||||||
# one per card currently in the board's Backlog list
|
|
||||||
# @return [Array<Array<String>>] the subset needing triage
|
|
||||||
def self.unlabeled(backlog_cards_labels)
|
|
||||||
backlog_cards_labels.select { |labels| needs_triage?(labels) }
|
|
||||||
end
|
|
||||||
|
|
||||||
# @param board_name [String]
|
|
||||||
# @param backlog_cards_labels [Array<Array<String>>]
|
|
||||||
# @return [String, nil] the SessionStart additionalContext note, or
|
|
||||||
# nil when there is nothing to triage
|
|
||||||
def self.note_for(board_name, backlog_cards_labels)
|
|
||||||
count = unlabeled(backlog_cards_labels).size
|
|
||||||
return nil if count.zero?
|
|
||||||
|
|
||||||
card_word = count == 1 ? "card" : "cards"
|
|
||||||
"[os-backlog] #{count} unlabeled Backlog #{card_word} on board #{board_name} -> " \
|
|
||||||
"dispatch the card-triage agent now (model: haiku, run in background) to label " \
|
|
||||||
"them. Labeling is this project's job for its own board (a parent umbrella may " \
|
|
||||||
"also label its child boards) — never label cards on a board that belongs to a " \
|
|
||||||
"different project."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,59 +1,43 @@
|
||||||
---
|
---
|
||||||
description: Capture work discovered mid-session as a Backlog card on the current repo's Planka board — resolves the board deterministically from the repo path, creates a missing board transparently via board-ensure, and always lands the card at Backlog. Use unprompted WHEN a concrete follow-up task, bug, or piece of deferred work surfaces mid-session that will not be done in this session. Invoked by `/os-backlog:capture`.
|
description: Capture work discovered mid-session as an issue on the current repo's configured tracker — creates it with a priority label when known and no state labels. Use unprompted WHEN a concrete follow-up task, bug, or piece of deferred work surfaces mid-session that will not be done in this session. Invoked by `/os-backlog:capture`.
|
||||||
---
|
---
|
||||||
|
|
||||||
Capture a piece of work as a card on the right Planka board, cheaply, without interrupting the current task.
|
Capture a piece of work as an issue on the current repo's configured tracker, cheaply, without interrupting the current task.
|
||||||
|
|
||||||
## Column-ownership rules (non-negotiable)
|
## Rules (non-negotiable)
|
||||||
|
|
||||||
- **The AI creates cards at Backlog only.** Never create a card in any other list.
|
- **Capture never applies state labels, and never applies `next`.** `next` is human-curated
|
||||||
- **Working a card means moving it**, via `card-move` (see below): Doing when work starts, Waiting plus a blocker comment when blocked, Review when a semi card ships, Done when an afk-ready card ships and is verified.
|
(ADR-0042) — the AI never adds or removes it, under any instruction short of an explicit
|
||||||
- **Next stays human-curated in both directions** — never move a card into or out of Next.
|
direct user request naming the specific issue.
|
||||||
- **Never move a card labeled `hitl`** — hitl cards are human-owned, never self-assigned or moved by the AI.
|
- Apply a priority label (`P0`–`P3`) only when it's actually known from context; otherwise
|
||||||
- These rules are enforced deterministically by the CLI (`card-move`), not just documented here — a violating move fails with a one-line reason naming the rule.
|
leave priority unset — don't guess one to fill the field.
|
||||||
- Priority/autonomy labeling is not capture's job — leave new cards unlabeled; the card-triage agent batches that later. Triage is initiated by the SessionStart hook of the board's OWNING project (the project the card was filed onto, or a parent umbrella labeling its own/child boards) — never by whichever project happened to file the card. A cross-project filer (see CROSS-PROJECT below) must never label the destination board's cards itself.
|
- Never a TODO comment in code, never only a mention in chat — always a real issue.
|
||||||
|
|
||||||
## Procedure
|
## Procedure
|
||||||
|
|
||||||
All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`.
|
All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`.
|
||||||
|
|
||||||
1. **Resolve the board** for the current repo. Gather the inputs and run the pure resolver (no network):
|
1. Create the issue on the cwd repo's configured tracker:
|
||||||
```bash
|
```bash
|
||||||
# config: contents of .cc-os/config if present, else null
|
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog issue-create --title "<short imperative title>" \
|
||||||
# boards: names from Planka — every project's boards, including archived--* ones
|
--body "<context: what surfaced, why it's deferred, repo/file paths>" \
|
||||||
echo '{"repo_path": "<repo root>", "config": <config-or-null>, "boards": [...]}' \
|
[--priority P0|P1|P2|P3]
|
||||||
| ${CLAUDE_PLUGIN_ROOT}/bin/os-backlog resolve
|
|
||||||
```
|
```
|
||||||
To build the `boards` inventory, list boards via `snapshot`/the Planka client; if Planka is unreachable, stop here and report the error — do not guess.
|
This creates the issue via `tea` (forgejo), `gh` (github), or a `NNN-<slug>.md`
|
||||||
2. **Act on the decision:**
|
frontmatter file (`repo:`) — whichever the project's tracker key names.
|
||||||
- `use <board>` → proceed to step 3.
|
`issue-create` is structurally incapable of applying state labels; there is no flag for
|
||||||
- `activate <archived board>` → run `os-backlog activate <name>` (activation is automatic — an archived board matching this repo is simply renamed back), then proceed.
|
one, by design.
|
||||||
- `stop-and-discuss` → do NOT create anything. Tell the user the repo doesn't map to a board and ask where the card should go.
|
2. Confirm to the user in one line: tracker, title, priority if set. Then return to the
|
||||||
3. **Ensure the board exists** (idempotent — safe to run every time):
|
interrupted task.
|
||||||
```bash
|
|
||||||
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog board-ensure <board>
|
|
||||||
```
|
|
||||||
Omit `--project` and it derives the right Planka project from the repo path: `Dev` under
|
|
||||||
`~/dev/`, `Clients` under `~/clients/`, `Servers` under `~/servers/`. Pass `--project <name>`
|
|
||||||
to target (or create) any other project explicitly — board-ensure creates the project too if
|
|
||||||
it doesn't exist yet. There's no command to move an existing board between projects; that's a
|
|
||||||
manual step in the Planka UI. Creating a NEW Planka project requires `PLANKA_HUMAN_USER_ID`
|
|
||||||
(or an explicit human user id) — board-ensure fails loudly rather than silently creating a
|
|
||||||
bot-only project.
|
|
||||||
4. **Create the card at Backlog:**
|
|
||||||
```bash
|
|
||||||
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-add --board <board> --title "<short imperative title>" [--description "<context: repo, file paths, why>"]
|
|
||||||
```
|
|
||||||
5. Confirm to the user in one line: board, title. Then return to the interrupted task.
|
|
||||||
|
|
||||||
## Moving a card
|
## No tracker configured
|
||||||
|
|
||||||
When work on a card starts, blocks, or ships, move it with:
|
If `issue-create` fails because the project has no `tracker` key in `.cc-os/config`,
|
||||||
```bash
|
suggest `/os-backlog:route` once — do not nag or retry — and otherwise degrade gracefully:
|
||||||
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog card-move --board <board> --card <ID> --to Backlog|Doing|Waiting|Review|Done
|
tell the user what could not be captured and let them decide where it should go for now.
|
||||||
```
|
|
||||||
The CLI refuses (one-line reason, exit 1) any move into/out of Next, any move of a `hitl` card, any move out of Done, or a move to Done without the `afk-ready` label.
|
|
||||||
|
|
||||||
## Failure behavior
|
## Failure behavior
|
||||||
|
|
||||||
Fail soft. If the planka-api gem or the Planka instance is unavailable, the CLI prints one clear error and exits nonzero — report that error to the user verbatim and offer to note the task elsewhere. Never retry destructively, never leave partial state unmentioned.
|
Fail soft. If the CLI or the underlying tracker (`tea`/`gh`) is unavailable, it prints one
|
||||||
|
clear error and exits nonzero — report that error to the user verbatim and offer to note the
|
||||||
|
task elsewhere. Never retry destructively, never leave partial state unmentioned.
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,44 @@
|
||||||
---
|
---
|
||||||
description: Show the Planka backlog cards relevant to the current repo, on demand. Pull-only — ONLY use when the user explicitly asks what's on the board / backlog / what's next; never inject board state into a session unasked. Invoked by `/os-backlog:list`.
|
description: Show the open issues relevant to the current repo's configured tracker, on demand. Pull-only — ONLY use when the user explicitly asks what's on the backlog / what's next; never inject issue state into a session unasked. Invoked by `/os-backlog:list`.
|
||||||
---
|
---
|
||||||
|
|
||||||
Show the current repo's board state when — and only when — the user asks for it.
|
Show the current repo's tracker state when — and only when — the user asks for it.
|
||||||
|
|
||||||
## Pull-only rule (notification policy v2)
|
## Pull-only rule (notification policy v2)
|
||||||
|
|
||||||
This skill is strictly pull-based. **Never volunteer board state**: not at session start, not after a capture, not "while you're here". The only trigger is an explicit user request ("what's on the backlog?", "/os-backlog:list", "show the board").
|
This skill is strictly pull-based. **Never volunteer issue state**: not at session start,
|
||||||
|
not after a capture, not "while you're here". The only trigger is an explicit user request
|
||||||
## Column-ownership rules (also apply here)
|
("what's on the backlog?", "/os-backlog:list", "what's next?").
|
||||||
|
|
||||||
Listing is read-only — this skill never moves cards itself. While discussing the listed cards:
|
|
||||||
- Working a card means moving it (via `card-move`, see `/os-backlog:capture`): Doing when started, Waiting plus a blocker comment when blocked, Review when a semi card ships, Done when an afk-ready card ships and is verified.
|
|
||||||
- Next stays human-curated in both directions — never move a card into or out of Next.
|
|
||||||
- If the user asks you to start work from the list, cards labeled `hitl` are off-limits — never moved by the AI, flag them back to the human instead.
|
|
||||||
- These rules are enforced deterministically by the CLI (`card-move`), not just documented here.
|
|
||||||
|
|
||||||
## Procedure
|
## Procedure
|
||||||
|
|
||||||
1. Resolve the board for the current repo the same way capture does (`resolve` — see `/os-backlog:capture`). On `stop-and-discuss`, ask the user which board they mean instead of guessing.
|
1. List the open issues on the cwd repo's configured tracker:
|
||||||
2. Show the cards:
|
|
||||||
```bash
|
```bash
|
||||||
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog cards --board <board>
|
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog issues
|
||||||
```
|
```
|
||||||
For a structured view (filtering, counting), use JSON instead:
|
This returns open issues (number, title, labels, assignee) grouped by state: `next` /
|
||||||
```bash
|
`waiting` / `review` / other open.
|
||||||
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog snapshot --board <board>
|
2. Relay the listing compactly, `next`-labeled issues first, then `waiting`, then `review`,
|
||||||
```
|
then the rest. If the user asked a narrower question ("what's in review?"), answer only
|
||||||
3. Relay the listing compactly, in board-column order (Backlog, Next, Doing, Waiting, Review, Done). If the user asked a narrower question ("what's in review?"), answer only that.
|
that.
|
||||||
|
|
||||||
|
## While discussing listed issues
|
||||||
|
|
||||||
|
Listing is read-only — this skill never changes issue state itself. If the user asks you to
|
||||||
|
start work from the list:
|
||||||
|
- `hitl`-labeled issues are off-limits — never picked up by the AI; flag them back to the
|
||||||
|
human instead.
|
||||||
|
- Comment on the issue when work starts; if it blocks, add the `waiting` label plus a
|
||||||
|
comment naming the blocker (see `/os-backlog:capture`'s home hook note for the full
|
||||||
|
working-state contract).
|
||||||
|
- Never touch the `next` label — human-curated in both directions, always.
|
||||||
|
|
||||||
|
## No tracker configured
|
||||||
|
|
||||||
|
If `issues` fails because the project has no `tracker` key in `.cc-os/config`, suggest
|
||||||
|
`/os-backlog:route` once and stop — there's nothing to list.
|
||||||
|
|
||||||
## Failure behavior
|
## Failure behavior
|
||||||
|
|
||||||
Fail soft: if the planka-api gem or Planka is unreachable, the CLI prints one clear error and exits nonzero — relay it and stop. Never fabricate board state.
|
Fail soft: if the CLI or the underlying tracker (`tea`/`gh`) is unreachable, it prints one
|
||||||
|
clear error and exits nonzero — relay it and stop. Never fabricate issue state.
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
---
|
---
|
||||||
description: Onboard a project's issue tracker — inspect what tracking already exists, propose a destination per the Planka-state/git-issues-spec boundary rule, migrate existing open items with back-links, and write the tracker key to .cc-os/config. Use unprompted WHEN a project has no tracker key configured and process/backlog work surfaces, or when the user explicitly asks to set up or change how a repo's issues/backlog are tracked. Invoked by `/os-backlog:route`.
|
description: Onboard a project's issue tracker — inspect what tracking already exists, propose a destination (forgejo/github/repo), write it to .cc-os/config, and ensure the ten canonical labels exist. Use unprompted WHEN a project has no tracker key configured and process/backlog work surfaces, or when the user explicitly asks to set up or change how a repo's issues are tracked. Invoked by `/os-backlog:route`.
|
||||||
---
|
---
|
||||||
|
|
||||||
Register a project's issue tracker: figure out what already exists, propose where issue/backlog tracking should live, and — with the human's confirmation at each decision gate — migrate and record it.
|
Register a project's issue tracker: figure out what already exists, propose the single
|
||||||
|
destination where its state and specs both live, and — with the human's confirmation —
|
||||||
|
write it and ensure its labels exist.
|
||||||
|
|
||||||
## The boundary rule (what you're deciding between)
|
## One tracker, both jobs
|
||||||
|
|
||||||
Planka = state, git issues = specs (ADR-0033, `docs/adr/0033-tracker-routing-planka-is-state-git-issues-are-specs.md`) — the ADR is the canonical statement; the operative routing rules are:
|
Per ADR-0042 there is exactly one tracker per project. It holds both task state (labels)
|
||||||
|
and durable specs (issue bodies, `/to-issues` slices, PRDs) — there is no second surface.
|
||||||
- **WHEN** the project's work is mostly ephemeral tasks, small fixes, or process/coordination (state: backlog/next/doing/review/done, nothing needing a durable written spec) → `planka:<board>`.
|
Valid tracker key formats (exactly one, written to `.cc-os/config`'s `tracker` key):
|
||||||
- **WHEN** work is specified as text that must survive and be referenced (features, migrations, architecture-level changes, tracer-bullet slices from `/to-issues`, PRDs from `/to-prd`) → `forgejo:<owner>/<repo>` or `github:<owner>/<repo>`.
|
`forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`. `planka:<board>` is
|
||||||
- **WHEN** a project already tracks issues in-repo (`docs/issues/`, `ISSUES.md`) and wants to stay there → `repo:<path>` (the escape hatch).
|
rejected — `config-write` and the os-status `tracker-configured` check both reject it with
|
||||||
- **WHEN** a code effort needs both state tracking and a durable spec → card-as-pointer + issue-chain-as-spec: the Planka card is a pointer (title + link) into the git issue chain holding the spec; never duplicate spec text into the card description.
|
a one-line message citing ADR-0042, fail-soft.
|
||||||
- **WHEN** a Planka-tracked effort accretes significant code/design decisions in card comments → flag promotion to the user (open a git issue chain for the spec, turn the card into a pointer); never promote silently.
|
|
||||||
|
|
||||||
Valid tracker key formats (exactly one, written to `.cc-os/config`'s `tracker` key): `planka:<board>` | `forgejo:<owner>/<repo>` | `github:<owner>/<repo>` | `repo:<path>`.
|
|
||||||
|
|
||||||
## Procedure
|
## Procedure
|
||||||
|
|
||||||
|
|
@ -24,38 +23,77 @@ All commands use the plugin CLI at `${CLAUDE_PLUGIN_ROOT}/bin/os-backlog`.
|
||||||
```bash
|
```bash
|
||||||
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog inspect
|
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog inspect
|
||||||
```
|
```
|
||||||
This is read-only and safe to run without asking. It reports, as JSON:
|
Read-only, safe without asking. It reports `tracker_configured` (existing key, if any —
|
||||||
- `tracker_configured` — an existing `tracker` key in `.cc-os/config`, if any. If this is already set, tell the user the project is already routed to it and confirm they want to re-route before continuing (re-routing is itself a decision gate — treat it as step 2).
|
if set, confirm re-routing with the user before continuing; re-routing is itself a
|
||||||
- `planka` — whether a Planka board already resolves for this repo (via the same resolver `/os-backlog:capture` uses), or why that check couldn't run (gem/credentials unavailable — fail-soft, not blocking).
|
decision gate), `git_remote` (parsed `git remote -v`, classified `forgejo`/`github`/
|
||||||
- `git_remote` — the parsed `git remote -v`, classified as `forgejo` (the user's self-hosted instance) / `github` / `unknown`, plus the open-issue count via `tea` or `gh` if that CLI is available (`null` with a `reason` if not — fail-soft, not blocking).
|
`unknown`, plus open-issue count via `tea`/`gh` if available — fail-soft, not blocking),
|
||||||
- `issue_shape` — a classification of the open git issues' shape: `sequential-chain` (most issues carry dependency labels or blocked-by/depends-on cross-references), `flat-adhoc` (no dependency structure), `mixed` (some), or `null` (no metadata, or fewer than 3 open issues — no evidence either way).
|
and `in_repo_issue_files` (`docs/issues/` and/or `ISSUES.md` if present).
|
||||||
- `in_repo_issue_files` — `docs/issues/` and/or `ISSUES.md` if present.
|
|
||||||
|
|
||||||
2. **Synthesize and propose (NAMED DECISION GATE).** Exactly ONE tracker key is ever written to `.cc-os/config` — the branches below differ in *which* key and *what happens to existing issues*, never in how many keys. Branch on the findings:
|
2. **Propose a destination (NAMED DECISION GATE).** Pick one:
|
||||||
- **(a) No existing tracker found** (no open git issues, no in-repo issue files, no live board): propose one destination key with a short rationale grounded in the boundary rule — e.g. "no existing tracking found, work here is small ad hoc fixes; `planka:<repo-name>` fits Planka-as-state; a board will be created on first capture, not by this skill."
|
- Git remote is `forgejo` → propose `forgejo:<owner>/<repo>`.
|
||||||
- **(b) Existing git issues that are spec-shaped** (`issue_shape` is `sequential-chain` or `mixed`): recommend the **SPLIT as the default**. Write `planka:<board>` as the single declared tracker — it governs new/ephemeral capture only. The existing git issues are specs per ADR-0033: they stay exactly where they are, unmigrated, and remain the durable spec surface; skip migration entirely (step 3 does not apply to them). This is still exactly one tracker key — the split is a division of labor (state vs specs), not two destinations.
|
- Git remote is `github` → propose `github:<owner>/<repo>`.
|
||||||
- **(c) Existing git issues that are flat-adhoc** (`issue_shape` is `flat-adhoc`): migration to Planka is on the table — propose the destination key, and if confirmed, existing open issues go through the step-3 migration gate as usual. If `issue_shape` is `null` (no metadata or too few issues to tell), treat shape as unknown: ask the user which shape fits rather than assuming.
|
- No git-host remote, but `in_repo_issue_files` exist → propose `repo:<path>` naming
|
||||||
**Stop here and wait for the user to confirm or override the destination before writing anything or moving anything.** If findings are ambiguous (e.g. both a live Planka board AND an active issue tracker with open items, and it's unclear which is authoritative), say so plainly and ask rather than guessing.
|
that location.
|
||||||
|
- Neither → ask the user which of the three they want; do not guess.
|
||||||
|
**Stop here and wait for the user to confirm or override before writing or migrating
|
||||||
|
anything.**
|
||||||
|
|
||||||
3. **If migration is needed (SECOND NAMED DECISION GATE).** Only applies when the confirmed destination differs from where open items currently live (e.g. moving from ad hoc `ISSUES.md` entries to Planka, or from an unrouted Planka board to git issues). **Exception — never re-trigger on the split:** a `planka:` tracker alongside by-design git spec issues (branch 2b, or any project whose git issues are specs per ADR-0033) is the intended end state, not a discrepancy; do not propose migrating those issues, now or on any later re-run of this skill. Before touching any live project history:
|
3. **Migrate existing items if the tracker type is changing (SECOND NAMED DECISION GATE).**
|
||||||
- Tell the user exactly what will move (list the items) and ask for explicit go-ahead. This gate is separate from the destination gate in step 2 — confirming the destination is not confirming the migration.
|
Only applies when open items currently live somewhere other than the confirmed
|
||||||
- Once confirmed, migrate mechanically using **existing machinery, not new code paths**: `card-add`/`cards` (via `/os-backlog:capture`'s CLI calls) for items moving into Planka; `tea`/`gh` issue-create commands for items moving into git issues. Do not invent bespoke migration scripts.
|
destination (e.g. `ISSUES.md` entries moving to Forgejo, or switching from GitHub to
|
||||||
- **Back-link both ways so nothing is double-tracked**: the old item (closed Planka card comment, closed in-repo issue entry, or a note in the git-host issue if migrating away from it) gets a pointer to its new home; the new item (card description or issue body) links back to the source. Close/archive the old item once the back-link is in place — don't leave both open.
|
Forgejo). Before touching any live project history:
|
||||||
|
- Tell the user exactly what will move (list the items) and get explicit go-ahead —
|
||||||
|
separate from the destination confirmation in step 2.
|
||||||
|
- Once confirmed, migrate mechanically using existing machinery: `tea issues create` /
|
||||||
|
`gh issue create` for items moving onto a git-host tracker; append to the target file
|
||||||
|
for items moving into `repo:<path>`. Do not invent bespoke migration scripts.
|
||||||
|
- Back-link both ways: the old item gets a pointer to its new home, the new item's body
|
||||||
|
links back to the source. Close/archive the old item once the back-link is in place.
|
||||||
|
|
||||||
4. **Write the tracker key (autonomous, mechanical, once destination is confirmed).**
|
4. **Write the tracker key (autonomous, mechanical, once destination is confirmed).**
|
||||||
```bash
|
```bash
|
||||||
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog config-write <tracker-value>
|
${CLAUDE_PLUGIN_ROOT}/bin/os-backlog config-write <tracker-value>
|
||||||
```
|
```
|
||||||
The CLI validates the format and rejects anything malformed — if it fails, relay the error verbatim and re-ask rather than hand-editing `.cc-os/config`.
|
This validates the format, rejects anything malformed (including `planka:`, citing
|
||||||
|
ADR-0042), writes `.cc-os/config`, and upserts the global project index
|
||||||
|
(`~/.cc-os/projects.json`) so cross-project filing can find this project later.
|
||||||
|
|
||||||
5. **Close out.** Tell the user the tracker is set and that the os-status tracker warning (ADR-022/ADR-026) goes silent starting next session.
|
5. **Ensure the ten canonical labels exist (forgejo/github only, idempotent).** Skip for
|
||||||
|
`repo:` trackers — they carry no label mechanism. List what's already there, then create
|
||||||
|
only what's missing:
|
||||||
|
- Forgejo:
|
||||||
|
```bash
|
||||||
|
tea labels list --repo <owner>/<repo> -o json
|
||||||
|
tea labels create --repo <owner>/<repo> --name <label> --color <hex>
|
||||||
|
```
|
||||||
|
- GitHub:
|
||||||
|
```bash
|
||||||
|
gh label list --repo <owner>/<repo> --json name
|
||||||
|
gh label create <label> --repo <owner>/<repo> --color <hex>
|
||||||
|
```
|
||||||
|
The ten labels: priority `P0` `P1` `P2` `P3`; autonomy `hitl` `semi` `afk-ready`; state
|
||||||
|
`next` `waiting` `review`. Pick any consistent color scheme (e.g. red-to-green for
|
||||||
|
priority) — the color itself isn't contractual, only that all ten exist. Re-running
|
||||||
|
`/os-backlog:route` on an already-labeled repo creates nothing new.
|
||||||
|
|
||||||
|
6. **Close out.** Tell the user the tracker is set, which labels were created (if any), and
|
||||||
|
that the os-status tracker warning goes silent starting next session.
|
||||||
|
|
||||||
## Decision gates (non-negotiable)
|
## Decision gates (non-negotiable)
|
||||||
|
|
||||||
- **Destination gate (step 2):** never call `config-write` or move anything before the human has confirmed which tracker to use.
|
- **Destination gate (step 2):** never call `config-write` before the human has confirmed
|
||||||
- **Migration gate (step 3):** even after the destination is confirmed, migrating *existing open items* (live project history) needs its own explicit go-ahead — a destination choice for new work is not consent to move old work.
|
which tracker to use.
|
||||||
- Inspection (step 1) and the final `config-write` (step 4) are mechanical and run without a gate — they don't change or move anything you haven't already been told to.
|
- **Migration gate (step 3):** even after the destination is confirmed, migrating existing
|
||||||
|
open items needs its own explicit go-ahead — a destination choice for new work is not
|
||||||
|
consent to move old work.
|
||||||
|
- Inspection (step 1), `config-write` (step 4), and label creation (step 5) are mechanical
|
||||||
|
and run without a gate — they don't change or move anything you haven't already been told
|
||||||
|
to.
|
||||||
|
|
||||||
## Failure behavior
|
## Failure behavior
|
||||||
|
|
||||||
Fail soft, per the plugin convention. `inspect`'s individual checks (Planka, tea/gh) degrade gracefully and are reported as unavailable rather than aborting the whole command — relay those as informational, not blocking, unless the missing signal is the only way to make the destination decision (in which case say what's missing and ask the human directly). `config-write` fails loudly on an invalid tracker value — relay the CLI's error verbatim.
|
Fail soft. `inspect`'s individual checks (`tea`/`gh`) degrade gracefully and are reported as
|
||||||
|
unavailable rather than aborting the whole command — relay those as informational unless
|
||||||
|
the missing signal is the only way to make the destination decision, in which case say
|
||||||
|
what's missing and ask the human directly. `config-write` fails loudly on an invalid
|
||||||
|
tracker value — relay the CLI's error verbatim.
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,13 @@ project has no row, it hasn't been routed yet — tell the user rather than gues
|
||||||
|
|
||||||
- `forgejo:<owner>/<repo>` — `tea issues create --repo <owner>/<repo> --title "..." --description "..."`
|
- `forgejo:<owner>/<repo>` — `tea issues create --repo <owner>/<repo> --title "..." --description "..."`
|
||||||
- `github:<owner>/<repo>` — `gh issue create --repo <owner>/<repo> --title "..." --body "..."`
|
- `github:<owner>/<repo>` — `gh issue create --repo <owner>/<repo> --title "..." --body "..."`
|
||||||
- `planka:<board>` (planka-only targets) — `os-backlog card-add --board <board> --title "..." [--description "..."]`
|
- `repo:<path>` — append an `NNN-<slug>.md` frontmatter file to the target's `<path>`.
|
||||||
|
|
||||||
Routing inside the target project still follows ITS boundary rule (ADR-0033: Planka is
|
No autonomy or priority label is applied by the filer — that's the receiving project's job,
|
||||||
state, git issues are specs) — a durable spec goes to the git-issue tracker even if the
|
per its own triage. The one tracker holds both state and specs for the target project too
|
||||||
project also has a board.
|
(ADR-0042) — there is no second surface to route within.
|
||||||
|
|
||||||
## Discoverer block (append verbatim to the issue/card description)
|
## Discoverer block (append verbatim to the issue description/body)
|
||||||
|
|
||||||
```
|
```
|
||||||
--------
|
--------
|
||||||
|
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
require_relative "test_helper"
|
|
||||||
|
|
||||||
class BoardEnsurerTest < Minitest::Test
|
|
||||||
include BacklogTestHelpers
|
|
||||||
|
|
||||||
def test_creates_missing_project_board_lists_and_labels
|
|
||||||
client = FakePlankaClient.new
|
|
||||||
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
|
|
||||||
|
|
||||||
result = ensurer.ensure("llf-schema", project_name: "Dev")
|
|
||||||
|
|
||||||
assert result.created
|
|
||||||
assert_equal "llf-schema", result.board.name
|
|
||||||
assert_equal Backlog::BoardSpec::LISTS, result.lists_created
|
|
||||||
assert_equal Backlog::BoardSpec::LABEL_NAMES, result.labels_created
|
|
||||||
|
|
||||||
project = client.projects_store.first
|
|
||||||
assert_equal "Dev", project.name
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_rerun_is_a_noop
|
|
||||||
client = FakePlankaClient.new
|
|
||||||
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
|
|
||||||
ensurer.ensure("llf-schema", project_name: "Dev")
|
|
||||||
|
|
||||||
result = ensurer.ensure("llf-schema", project_name: "Dev")
|
|
||||||
|
|
||||||
refute result.created
|
|
||||||
assert_empty result.lists_created
|
|
||||||
assert_empty result.labels_created
|
|
||||||
assert_equal 1, client.boards_store.size
|
|
||||||
assert_equal Backlog::BoardSpec::LISTS.size, client.lists_store.size
|
|
||||||
assert_equal Backlog::BoardSpec::LABEL_NAMES.size, client.labels_store.size
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_reuses_existing_project
|
|
||||||
client = FakePlankaClient.new
|
|
||||||
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
|
|
||||||
ensurer.ensure("board-a", project_name: "Dev")
|
|
||||||
|
|
||||||
ensurer.ensure("board-b", project_name: "Dev")
|
|
||||||
|
|
||||||
assert_equal 1, client.projects_store.size
|
|
||||||
assert_equal 2, client.boards_store.size
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_applies_owner_manager_visibility_fix_on_new_project
|
|
||||||
client = FakePlankaClient.new
|
|
||||||
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
|
|
||||||
|
|
||||||
ensurer.ensure("llf-schema", project_name: "Dev")
|
|
||||||
|
|
||||||
project = client.projects_store.first
|
|
||||||
assert_includes client.managers_for(project.id), "human-1"
|
|
||||||
assert_nil project.owner_project_manager_id
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_semi_label_is_part_of_enforced_set
|
|
||||||
assert_includes Backlog::BoardSpec::LABEL_NAMES, "semi"
|
|
||||||
assert_includes Backlog::BoardSpec::LABEL_NAMES, "hitl"
|
|
||||||
assert_includes Backlog::BoardSpec::LABEL_NAMES, "afk-ready"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_label_color_falls_back_to_next_candidate_on_rejection
|
|
||||||
first_choice = Backlog::BoardSpec::LABEL_COLOR_CANDIDATES.fetch("P0").first
|
|
||||||
client = FakePlankaClient.new(reject_colors: [first_choice])
|
|
||||||
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
|
|
||||||
|
|
||||||
ensurer.ensure("llf-schema", project_name: "Dev")
|
|
||||||
|
|
||||||
p0 = client.labels_store.find { |l| l.name == "P0" }
|
|
||||||
refute_equal first_choice, p0.color
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_label_color_falls_back_when_server_rejects_with_400
|
|
||||||
first_choice = Backlog::BoardSpec::LABEL_COLOR_CANDIDATES.fetch("P3").first
|
|
||||||
client = FakePlankaClient.new(reject_colors_with_400: [first_choice])
|
|
||||||
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
|
|
||||||
|
|
||||||
ensurer.ensure("llf-schema", project_name: "Dev")
|
|
||||||
|
|
||||||
p3 = client.labels_store.find { |l| l.name == "P3" }
|
|
||||||
refute_equal first_choice, p3.color
|
|
||||||
end
|
|
||||||
|
|
||||||
|
|
||||||
def test_activate_renames_archived_board_back
|
|
||||||
client = FakePlankaClient.new
|
|
||||||
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
|
|
||||||
ensurer.ensure("llf-schema", project_name: "Dev")
|
|
||||||
board = client.boards_store.first
|
|
||||||
client.boards.update(board.id, name: "archived--llf-schema")
|
|
||||||
|
|
||||||
result = ensurer.activate("llf-schema")
|
|
||||||
|
|
||||||
assert_equal "llf-schema", result.name
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_archive_renames_active_board
|
|
||||||
client = FakePlankaClient.new
|
|
||||||
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
|
|
||||||
ensurer.ensure("llf-schema", project_name: "Dev")
|
|
||||||
|
|
||||||
result = ensurer.archive("llf-schema")
|
|
||||||
|
|
||||||
assert_equal "archived--llf-schema", result.name
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_ensure_never_touches_archived_boards
|
|
||||||
client = FakePlankaClient.new
|
|
||||||
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
|
|
||||||
ensurer.ensure("llf-schema", project_name: "Dev")
|
|
||||||
ensurer.archive("llf-schema")
|
|
||||||
|
|
||||||
result = ensurer.ensure("llf-schema", project_name: "Dev")
|
|
||||||
|
|
||||||
assert result.created
|
|
||||||
assert_equal 2, client.boards_store.size
|
|
||||||
archived = client.boards_store.find { |b| b.name == "archived--llf-schema" }
|
|
||||||
refute_nil archived
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_ensure_creates_an_arbitrary_project_name_on_demand
|
|
||||||
client = FakePlankaClient.new
|
|
||||||
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
|
|
||||||
|
|
||||||
result = ensurer.ensure("ovh-prod", project_name: "Servers")
|
|
||||||
|
|
||||||
assert result.created
|
|
||||||
project = client.projects_store.first
|
|
||||||
assert_equal "Servers", project.name
|
|
||||||
assert_equal "Servers", result.project.name
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_raises_missing_human_user_id_error_when_creating_new_project_without_one
|
|
||||||
original = ENV.delete("PLANKA_HUMAN_USER_ID")
|
|
||||||
client = FakePlankaClient.new
|
|
||||||
ensurer = Backlog::BoardEnsurer.new(client: client)
|
|
||||||
|
|
||||||
error = assert_raises(Backlog::BoardEnsurer::MissingHumanUserIdError) do
|
|
||||||
ensurer.ensure("llf-schema", project_name: "Dev")
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_includes error.message, "PLANKA_HUMAN_USER_ID"
|
|
||||||
ensure
|
|
||||||
ENV["PLANKA_HUMAN_USER_ID"] = original
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_ensure_against_existing_project_does_not_raise_without_human_user_id
|
|
||||||
original = ENV.delete("PLANKA_HUMAN_USER_ID")
|
|
||||||
client = FakePlankaClient.new
|
|
||||||
seeding_ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
|
|
||||||
seeding_ensurer.ensure("board-a", project_name: "Dev")
|
|
||||||
|
|
||||||
ensurer = Backlog::BoardEnsurer.new(client: client)
|
|
||||||
result = ensurer.ensure("board-b", project_name: "Dev")
|
|
||||||
|
|
||||||
refute result.project.nil?
|
|
||||||
ensure
|
|
||||||
ENV["PLANKA_HUMAN_USER_ID"] = original
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_ensure_rejects_archived_prefixed_name
|
|
||||||
client = FakePlankaClient.new
|
|
||||||
ensurer = Backlog::BoardEnsurer.new(client: client, human_user_id: "human-1")
|
|
||||||
|
|
||||||
assert_raises(ArgumentError) { ensurer.ensure("archived--x", project_name: "Dev") }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
require_relative "test_helper"
|
|
||||||
|
|
||||||
# Card #1818501985909868001: a mistaken flag-style invocation like
|
|
||||||
# `os-backlog board-ensure --board apprise-api` must fail loudly with a
|
|
||||||
# usage error instead of silently creating a Planka board literally named
|
|
||||||
# "--board". These are CLI-contract tests (shell out to bin/os-backlog)
|
|
||||||
# because the bug is about what the positional NAME arg parsing accepts
|
|
||||||
# BEFORE any Planka client/network call is made — no PLANKA_* env vars are
|
|
||||||
# set, so if validation didn't happen first, these would instead fail on
|
|
||||||
# network auth with a different message.
|
|
||||||
class BoardNameValidationCliTest < Minitest::Test
|
|
||||||
BIN = File.expand_path("../bin/os-backlog", __dir__)
|
|
||||||
|
|
||||||
# Strip any live Planka credentials from the child's env: these tests
|
|
||||||
# assert that flag-like names are rejected BEFORE build_client ever runs,
|
|
||||||
# so no test here may be allowed to reach the network even accidentally.
|
|
||||||
def run_cli(*args)
|
|
||||||
env = { "PLANKA_BASE_URL" => "", "PLANKA_USERNAME" => "", "PLANKA_PASSWORD" => "" }
|
|
||||||
out = IO.popen(env, [RbConfig.ruby, BIN, *args], err: [:child, :out], &:read)
|
|
||||||
[out, $?.exitstatus]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_board_ensure_rejects_flag_like_name
|
|
||||||
out, status = run_cli("board-ensure", "--board", "apprise-api")
|
|
||||||
|
|
||||||
refute_equal 0, status
|
|
||||||
assert_match(/board name.*must not start with/i, out)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_activate_rejects_flag_like_name
|
|
||||||
out, status = run_cli("activate", "--board")
|
|
||||||
|
|
||||||
refute_equal 0, status
|
|
||||||
assert_match(/board name.*must not start with/i, out)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_archive_rejects_flag_like_name
|
|
||||||
out, status = run_cli("archive", "--board", "--yes")
|
|
||||||
|
|
||||||
refute_equal 0, status
|
|
||||||
assert_match(/board name.*must not start with/i, out)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_board_ensure_accepts_normal_name_and_fails_later_on_network
|
|
||||||
out, status = run_cli("board-ensure", "apprise-api")
|
|
||||||
|
|
||||||
refute_equal 0, status
|
|
||||||
refute_match(/board name.*must not start with/i, out)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
require_relative "test_helper"
|
|
||||||
|
|
||||||
class BoardResolverTest < Minitest::Test
|
|
||||||
include BacklogTestHelpers
|
|
||||||
|
|
||||||
def setup
|
|
||||||
@client = FakePlankaClient.new
|
|
||||||
ensurer = Backlog::BoardEnsurer.new(client: @client, human_user_id: "human-1")
|
|
||||||
@board = ensurer.ensure("llf-schema", project_name: "Dev").board
|
|
||||||
@writes = []
|
|
||||||
end
|
|
||||||
|
|
||||||
def resolver_with(config)
|
|
||||||
@config = config
|
|
||||||
Backlog::BoardResolver.new(
|
|
||||||
client: @client,
|
|
||||||
read_config: -> { @config },
|
|
||||||
write_config: ->(contents) { @writes << contents }
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def config_with_cache(id = @board.id)
|
|
||||||
"# repo config\ntracker=planka:llf-schema\nboard_id=#{id}\nversion=3\n"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_cache_hit_uses_boards_get_and_skips_name_listing
|
|
||||||
@client.projects.define_singleton_method(:list) { raise "projects.list must not be called on a cache hit" }
|
|
||||||
resolver = resolver_with(config_with_cache)
|
|
||||||
|
|
||||||
board = resolver.board_for("llf-schema")
|
|
||||||
|
|
||||||
assert_equal @board.id, board.id
|
|
||||||
assert_empty @writes
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_cache_miss_resolves_by_name_and_writes_board_id_preserving_comments
|
|
||||||
resolver = resolver_with("# repo config\ntracker=planka:llf-schema\nversion=3\n")
|
|
||||||
|
|
||||||
board = resolver.board_for("llf-schema")
|
|
||||||
|
|
||||||
assert_equal @board.id, board.id
|
|
||||||
assert_equal 1, @writes.size
|
|
||||||
assert_equal "# repo config\ntracker=planka:llf-schema\nversion=3\nboard_id=#{@board.id}\n",
|
|
||||||
@writes.last
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_stale_id_404_falls_back_to_name_lookup_and_rewrites_cache
|
|
||||||
resolver = resolver_with(config_with_cache("no-such-id"))
|
|
||||||
|
|
||||||
board = resolver.board_for("llf-schema")
|
|
||||||
|
|
||||||
assert_equal @board.id, board.id
|
|
||||||
assert_includes @writes.last, "board_id=#{@board.id}\n"
|
|
||||||
assert_includes @writes.last, "# repo config"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_renamed_board_name_mismatch_falls_back_and_rewrites_cache
|
|
||||||
stale = @board
|
|
||||||
@client.boards.update(stale.id, name: "something-else")
|
|
||||||
current = @client.boards.create(stale.project_id, name: "llf-schema", position: 2)
|
|
||||||
resolver = resolver_with(config_with_cache(stale.id))
|
|
||||||
|
|
||||||
board = resolver.board_for("llf-schema")
|
|
||||||
|
|
||||||
assert_equal current.id, board.id
|
|
||||||
assert_includes @writes.last, "board_id=#{current.id}\n"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_non_planka_tracker_ignores_board_id_and_never_writes
|
|
||||||
resolver = resolver_with("tracker=forgejo:jared/cc-os\nboard_id=bogus-id\n")
|
|
||||||
|
|
||||||
board = resolver.board_for("llf-schema")
|
|
||||||
|
|
||||||
assert_equal @board.id, board.id
|
|
||||||
assert_empty @writes
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_board_id_for_different_planka_board_name_is_ignored
|
|
||||||
resolver = resolver_with("tracker=planka:other-board\nboard_id=bogus-id\n")
|
|
||||||
|
|
||||||
board = resolver.board_for("llf-schema")
|
|
||||||
|
|
||||||
assert_equal @board.id, board.id
|
|
||||||
assert_empty @writes # tracker doesn't match this board name; no cache write either
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_unreadable_config_degrades_to_name_lookup
|
|
||||||
resolver = Backlog::BoardResolver.new(
|
|
||||||
client: @client,
|
|
||||||
read_config: -> { raise Errno::EACCES, "config" },
|
|
||||||
write_config: ->(contents) { @writes << contents }
|
|
||||||
)
|
|
||||||
|
|
||||||
board = resolver.board_for("llf-schema")
|
|
||||||
|
|
||||||
assert_equal @board.id, board.id
|
|
||||||
assert_empty @writes
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_unwritable_config_never_fails_the_command
|
|
||||||
resolver = Backlog::BoardResolver.new(
|
|
||||||
client: @client,
|
|
||||||
read_config: -> { "tracker=planka:llf-schema\n" },
|
|
||||||
write_config: ->(_) { raise Errno::EACCES, "config" }
|
|
||||||
)
|
|
||||||
|
|
||||||
board = resolver.board_for("llf-schema")
|
|
||||||
|
|
||||||
assert_equal @board.id, board.id
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_matching_cached_id_is_not_rewritten
|
|
||||||
resolver = resolver_with(config_with_cache)
|
|
||||||
resolver.board_for("llf-schema")
|
|
||||||
|
|
||||||
assert_empty @writes
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_unknown_board_still_raises
|
|
||||||
resolver = resolver_with(nil)
|
|
||||||
error = assert_raises(RuntimeError) { resolver.board_for("nope") }
|
|
||||||
assert_match(/no board named "nope"/, error.message)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_cards_add_through_resolver_writes_cache_once
|
|
||||||
resolver = resolver_with("tracker=planka:llf-schema\n")
|
|
||||||
cards = Backlog::Cards.new(client: @client, board_resolver: resolver)
|
|
||||||
|
|
||||||
card = cards.add(board_name: "llf-schema", title: "Cache me")
|
|
||||||
|
|
||||||
assert_equal "Cache me", card.name
|
|
||||||
assert_equal 1, @writes.size
|
|
||||||
assert_includes @writes.last, "board_id=#{@board.id}\n"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
require_relative "test_helper"
|
|
||||||
|
|
||||||
class CardsTest < Minitest::Test
|
|
||||||
include BacklogTestHelpers
|
|
||||||
|
|
||||||
def setup
|
|
||||||
@client = FakePlankaClient.new
|
|
||||||
@ensurer = Backlog::BoardEnsurer.new(client: @client, human_user_id: "human-1")
|
|
||||||
@ensurer.ensure("llf-schema", project_name: "Dev")
|
|
||||||
@cards = Backlog::Cards.new(client: @client)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_add_creates_card_at_backlog_list
|
|
||||||
card = @cards.add(board_name: "llf-schema", title: "Fix the flaky import")
|
|
||||||
|
|
||||||
backlog = @client.lists_store.find { |l| l.name == "Backlog" }
|
|
||||||
assert_equal backlog.id, card.list_id
|
|
||||||
assert_equal "Fix the flaky import", card.name
|
|
||||||
assert_equal "project", card.type
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_add_positions_new_card_after_existing_backlog_cards
|
|
||||||
first = @cards.add(board_name: "llf-schema", title: "one")
|
|
||||||
second = @cards.add(board_name: "llf-schema", title: "two")
|
|
||||||
|
|
||||||
assert_operator second.position, :>, first.position
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_add_with_description
|
|
||||||
card = @cards.add(board_name: "llf-schema", title: "t", description: "details here")
|
|
||||||
|
|
||||||
assert_equal "details here", card.description
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_add_raises_for_missing_board
|
|
||||||
error = assert_raises(RuntimeError) { @cards.add(board_name: "nope", title: "t") }
|
|
||||||
assert_match(/no board named/, error.message)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_snapshot_shape_and_list_order
|
|
||||||
@cards.add(board_name: "llf-schema", title: "captured task")
|
|
||||||
|
|
||||||
snapshot = @cards.snapshot(board_name: "llf-schema")
|
|
||||||
|
|
||||||
assert_equal "llf-schema", snapshot["board"]
|
|
||||||
assert_equal Backlog::BoardSpec::LISTS, snapshot["lists"].map { |l| l["name"] }
|
|
||||||
backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" }
|
|
||||||
assert_equal ["captured task"], backlog["cards"].map { |c| c["name"] }
|
|
||||||
assert_equal Backlog::BoardSpec::LABEL_NAMES.sort, snapshot["labels"].sort
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_snapshot_card_emits_complete_key_set
|
|
||||||
@cards.add(board_name: "llf-schema", title: "captured task")
|
|
||||||
|
|
||||||
snapshot = @cards.snapshot(board_name: "llf-schema")
|
|
||||||
backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" }
|
|
||||||
card = backlog["cards"].first
|
|
||||||
|
|
||||||
expected_keys = %w[
|
|
||||||
id createdAt updatedAt type position name description dueDate
|
|
||||||
isDueCompleted stopwatch commentsTotal isClosed listChangedAt
|
|
||||||
boardId listId creatorUserId prevListId coverAttachmentId
|
|
||||||
isSubscribed labels
|
|
||||||
]
|
|
||||||
assert_equal expected_keys.sort, card.keys.sort
|
|
||||||
assert card.key?("listChangedAt"), "snapshot card must carry listChangedAt for board-audit"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_snapshot_includes_card_label_names
|
|
||||||
card = @cards.add(board_name: "llf-schema", title: "labeled task")
|
|
||||||
p1 = @client.labels_store.find { |l| l.name == "P1" }
|
|
||||||
@client.attach_label(card.id, p1.id)
|
|
||||||
|
|
||||||
snapshot = @cards.snapshot(board_name: "llf-schema")
|
|
||||||
|
|
||||||
backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" }
|
|
||||||
assert_equal ["P1"], backlog["cards"].first["labels"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_snapshot_raises_for_missing_board
|
|
||||||
assert_raises(RuntimeError) { @cards.snapshot(board_name: "nope") }
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_attach_label_by_name
|
|
||||||
card = @cards.add(board_name: "llf-schema", title: "raw card")
|
|
||||||
|
|
||||||
@cards.attach_label(board_name: "llf-schema", card_id: card.id, label: "afk-ready")
|
|
||||||
|
|
||||||
snapshot = @cards.snapshot(board_name: "llf-schema")
|
|
||||||
backlog = snapshot["lists"].find { |l| l["name"] == "Backlog" }
|
|
||||||
assert_equal ["afk-ready"], backlog["cards"].first["labels"]
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_attach_label_raises_for_unknown_label
|
|
||||||
card = @cards.add(board_name: "llf-schema", title: "raw card")
|
|
||||||
|
|
||||||
error = assert_raises(RuntimeError) do
|
|
||||||
@cards.attach_label(board_name: "llf-schema", card_id: card.id, label: "nonexistent")
|
|
||||||
end
|
|
||||||
assert_match(/no label/, error.message)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_comment_adds_text_to_card
|
|
||||||
card = @cards.add(board_name: "llf-schema", title: "stale card")
|
|
||||||
|
|
||||||
comment = @cards.comment(card_id: card.id, text: "audit: in Doing 9 days")
|
|
||||||
|
|
||||||
assert_equal card.id, comment.card_id
|
|
||||||
assert_equal "audit: in Doing 9 days", comment.text
|
|
||||||
assert_equal 1, @client.comments.store.size
|
|
||||||
end
|
|
||||||
|
|
||||||
# -- move -----------------------------------------------------------
|
|
||||||
|
|
||||||
def list_named(name)
|
|
||||||
@client.lists_store.find { |l| l.name == name }
|
|
||||||
end
|
|
||||||
|
|
||||||
def move_card_to(list_name, card = nil)
|
|
||||||
card ||= @cards.add(board_name: "llf-schema", title: "moving card")
|
|
||||||
@client.cards.move(card.id, list_id: list_named(list_name).id, position: 65_536) unless list_name == "Backlog"
|
|
||||||
card
|
|
||||||
end
|
|
||||||
|
|
||||||
def label!(card, name)
|
|
||||||
label = @client.labels_store.find { |l| l.name == name }
|
|
||||||
@client.attach_label(card.id, label.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_move_backlog_to_doing
|
|
||||||
card = @cards.add(board_name: "llf-schema", title: "task")
|
|
||||||
|
|
||||||
moved = @cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
|
|
||||||
|
|
||||||
assert_equal list_named("Doing").id, moved.list_id
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_move_doing_to_review
|
|
||||||
card = move_card_to("Doing")
|
|
||||||
|
|
||||||
moved = @cards.move(board_name: "llf-schema", card_id: card.id, to: "Review")
|
|
||||||
|
|
||||||
assert_equal list_named("Review").id, moved.list_id
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_move_afk_ready_doing_to_done
|
|
||||||
card = move_card_to("Doing")
|
|
||||||
label!(card, "afk-ready")
|
|
||||||
|
|
||||||
moved = @cards.move(board_name: "llf-schema", card_id: card.id, to: "Done")
|
|
||||||
|
|
||||||
assert_equal list_named("Done").id, moved.list_id
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_move_positions_card_at_end_of_destination_list
|
|
||||||
first = move_card_to("Doing")
|
|
||||||
second = @cards.add(board_name: "llf-schema", title: "second")
|
|
||||||
|
|
||||||
@cards.move(board_name: "llf-schema", card_id: first.id, to: "Waiting")
|
|
||||||
moved_second = @cards.move(board_name: "llf-schema", card_id: second.id, to: "Waiting")
|
|
||||||
|
|
||||||
waiting_first = @client.cards.all_for_board(list_named("Waiting").board_id)
|
|
||||||
.find { |c| c.id == first.id }
|
|
||||||
assert_operator moved_second.position, :>, waiting_first.position
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_move_into_next_refused
|
|
||||||
card = @cards.add(board_name: "llf-schema", title: "task")
|
|
||||||
|
|
||||||
error = assert_raises(RuntimeError) do
|
|
||||||
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Next")
|
|
||||||
end
|
|
||||||
assert_match(/Next is human-curated/, error.message)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_move_out_of_next_refused
|
|
||||||
card = move_card_to("Next")
|
|
||||||
|
|
||||||
error = assert_raises(RuntimeError) do
|
|
||||||
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
|
|
||||||
end
|
|
||||||
assert_match(/Next is human-curated/, error.message)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_move_hitl_card_refused
|
|
||||||
card = @cards.add(board_name: "llf-schema", title: "task")
|
|
||||||
label!(card, "hitl")
|
|
||||||
|
|
||||||
error = assert_raises(RuntimeError) do
|
|
||||||
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
|
|
||||||
end
|
|
||||||
assert_match(/hitl cards are human-owned/, error.message)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_move_to_done_without_afk_ready_refused
|
|
||||||
card = move_card_to("Doing")
|
|
||||||
|
|
||||||
error = assert_raises(RuntimeError) do
|
|
||||||
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Done")
|
|
||||||
end
|
|
||||||
assert_match(/afk-ready/, error.message)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_move_out_of_done_refused
|
|
||||||
card = move_card_to("Doing")
|
|
||||||
label!(card, "afk-ready")
|
|
||||||
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Done")
|
|
||||||
|
|
||||||
error = assert_raises(RuntimeError) do
|
|
||||||
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Doing")
|
|
||||||
end
|
|
||||||
assert_match(/cannot move a card out of Done/, error.message)
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_move_unknown_column_refused
|
|
||||||
card = @cards.add(board_name: "llf-schema", title: "task")
|
|
||||||
|
|
||||||
error = assert_raises(RuntimeError) do
|
|
||||||
@cards.move(board_name: "llf-schema", card_id: card.id, to: "Someplace")
|
|
||||||
end
|
|
||||||
assert_match(/unknown list/, error.message)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -122,19 +122,4 @@ class ConfigTest < Minitest::Test
|
||||||
def test_set_on_nil_contents
|
def test_set_on_nil_contents
|
||||||
assert_equal "board_id=42\n", Backlog::Config.set(nil, "board_id", "42")
|
assert_equal "board_id=42\n", Backlog::Config.set(nil, "board_id", "42")
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_planka_board_prefers_explicit_board_key
|
|
||||||
config = Backlog::Config.new("board=explicit\ntracker=planka:from-tracker\n")
|
|
||||||
assert_equal "explicit", config.planka_board
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_planka_board_derives_from_planka_tracker
|
|
||||||
config = Backlog::Config.new("tracker=planka:my-board\n")
|
|
||||||
assert_equal "my-board", config.planka_board
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_planka_board_nil_for_non_planka_tracker
|
|
||||||
assert_nil Backlog::Config.new("tracker=forgejo:me/repo\n").planka_board
|
|
||||||
assert_nil Backlog::Config.new(nil).planka_board
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# /to-issues routing wiring (issue #16): spec-slice output honors the
|
# /to-issues routing wiring (issue #16): spec-slice output honors the
|
||||||
# tracker key per the ADR'd boundary (git issues = specs, Planka = state).
|
# tracker key. Post-ADR-0042, one tracker holds both state and specs — no
|
||||||
|
# second "planka default" destination exists anymore.
|
||||||
require_relative "test_helper"
|
require_relative "test_helper"
|
||||||
|
|
||||||
class IssuesRoutingTest < Minitest::Test
|
class IssuesRoutingTest < Minitest::Test
|
||||||
|
|
@ -11,16 +12,16 @@ class IssuesRoutingTest < Minitest::Test
|
||||||
assert_equal :git_issues, Backlog::Tracker.issues_destination("github:jared/some-repo")
|
assert_equal :git_issues, Backlog::Tracker.issues_destination("github:jared/some-repo")
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_planka_tracker_is_the_gated_default
|
def test_repo_tracker_routes_spec_slices_to_repo_files
|
||||||
assert_equal :planka_default, Backlog::Tracker.issues_destination("planka:cc-os")
|
assert_equal :repo_files, Backlog::Tracker.issues_destination("repo:docs/issues")
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_repo_tracker_is_the_gated_default
|
def test_absent_tracker_is_unrouted
|
||||||
assert_equal :planka_default, Backlog::Tracker.issues_destination("repo:docs/issues")
|
assert_equal :unrouted, Backlog::Tracker.issues_destination(nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_absent_tracker_is_the_gated_default
|
def test_retired_planka_tracker_is_unrouted
|
||||||
assert_equal :planka_default, Backlog::Tracker.issues_destination(nil)
|
assert_equal :unrouted, Backlog::Tracker.issues_destination("planka:cc-os")
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_invalid_tracker_is_unrouted
|
def test_invalid_tracker_is_unrouted
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
require_relative "test_helper"
|
||||||
|
require "tmpdir"
|
||||||
|
|
||||||
|
class FakeIssuesRunner
|
||||||
|
attr_reader :calls
|
||||||
|
|
||||||
|
def initialize(responses)
|
||||||
|
@responses = responses.dup
|
||||||
|
@calls = []
|
||||||
|
end
|
||||||
|
|
||||||
|
def capture(*cmd)
|
||||||
|
@calls << cmd
|
||||||
|
@responses.shift || ["", true]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class IssuesCreateTest < Minitest::Test
|
||||||
|
def test_forgejo_create_shells_out_to_tea_with_priority_label
|
||||||
|
runner = FakeIssuesRunner.new([["Created issue #7: https://forgejo.example/jared/cc-os/issues/7\n", true]])
|
||||||
|
issues = Backlog::Issues.new(runner: runner)
|
||||||
|
|
||||||
|
result = issues.create(tracker: "forgejo:jared/cc-os", title: "Fix the thing", body: "context here", priority: "P1")
|
||||||
|
|
||||||
|
assert_equal 7, result[:number]
|
||||||
|
assert_equal "https://forgejo.example/jared/cc-os/issues/7", result[:url]
|
||||||
|
cmd = runner.calls.first
|
||||||
|
assert_equal "tea", cmd[0]
|
||||||
|
assert_includes cmd, "--repo"
|
||||||
|
assert_includes cmd, "jared/cc-os"
|
||||||
|
assert_includes cmd, "--title"
|
||||||
|
assert_includes cmd, "Fix the thing"
|
||||||
|
assert_includes cmd, "--labels"
|
||||||
|
assert_includes cmd, "P1"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_forgejo_create_omits_labels_flag_without_priority
|
||||||
|
runner = FakeIssuesRunner.new([["Created issue #1: https://x/issues/1\n", true]])
|
||||||
|
Backlog::Issues.new(runner: runner).create(tracker: "forgejo:jared/cc-os", title: "T", body: "B")
|
||||||
|
|
||||||
|
refute_includes runner.calls.first, "--labels"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_github_create_shells_out_to_gh
|
||||||
|
runner = FakeIssuesRunner.new([["https://github.com/acme/widget/issues/42\n", true]])
|
||||||
|
issues = Backlog::Issues.new(runner: runner)
|
||||||
|
|
||||||
|
result = issues.create(tracker: "github:acme/widget", title: "Fix", body: "ctx", priority: "P0")
|
||||||
|
|
||||||
|
assert_equal 42, result[:number]
|
||||||
|
assert_equal "https://github.com/acme/widget/issues/42", result[:url]
|
||||||
|
cmd = runner.calls.first
|
||||||
|
assert_equal "gh", cmd[0]
|
||||||
|
assert_includes cmd, "--label"
|
||||||
|
assert_includes cmd, "P0"
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_create_raises_command_failed_on_nonzero_exit
|
||||||
|
runner = FakeIssuesRunner.new([["auth error", false]])
|
||||||
|
issues = Backlog::Issues.new(runner: runner)
|
||||||
|
|
||||||
|
assert_raises(Backlog::Issues::CommandFailed) do
|
||||||
|
issues.create(tracker: "forgejo:jared/cc-os", title: "T", body: "B")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_create_rejects_invalid_priority
|
||||||
|
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]))
|
||||||
|
|
||||||
|
assert_raises(ArgumentError) do
|
||||||
|
issues.create(tracker: "forgejo:jared/cc-os", title: "T", body: "B", priority: "P9")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_create_can_never_apply_the_next_label
|
||||||
|
# There is no `labels:`/state-label parameter on #create at all — the
|
||||||
|
# only label surface is `priority:`, restricted to P0-P3. This test
|
||||||
|
# documents that constraint as executable spec.
|
||||||
|
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]))
|
||||||
|
|
||||||
|
assert_raises(ArgumentError) do
|
||||||
|
issues.create(tracker: "forgejo:jared/cc-os", title: "T", body: "B", priority: "next")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_repo_create_writes_a_frontmattered_markdown_file
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]), repo_root: dir,
|
||||||
|
clock: -> { Time.new(2026, 7, 16) })
|
||||||
|
result = issues.create(tracker: "repo:docs/issues", title: "Fix the Thing!", body: "details here",
|
||||||
|
priority: "P2")
|
||||||
|
|
||||||
|
assert_equal 1, result[:number]
|
||||||
|
assert_equal File.join(dir, "docs/issues/001-fix-the-thing.md"), result[:path]
|
||||||
|
contents = File.read(result[:path])
|
||||||
|
assert_includes contents, "number: 1"
|
||||||
|
assert_includes contents, "title: Fix the Thing!"
|
||||||
|
assert_includes contents, "labels: P2"
|
||||||
|
assert_includes contents, "state: open"
|
||||||
|
assert_includes contents, "created: 2026-07-16"
|
||||||
|
assert_includes contents, "details here"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_repo_create_numbers_sequentially
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]), repo_root: dir)
|
||||||
|
issues.create(tracker: "repo:docs/issues", title: "First", body: "b")
|
||||||
|
second = issues.create(tracker: "repo:docs/issues", title: "Second", body: "b")
|
||||||
|
|
||||||
|
assert_equal 2, second[:number]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class IssuesListTest < Minitest::Test
|
||||||
|
def test_forgejo_list_groups_by_state_label
|
||||||
|
tea_json = JSON.generate([
|
||||||
|
{ "index" => 1, "title" => "Next thing", "labels" => "P1,next", "assignees" => "jared" },
|
||||||
|
{ "index" => 2, "title" => "Blocked thing", "labels" => "waiting", "assignees" => "" },
|
||||||
|
{ "index" => 3, "title" => "Shipped semi", "labels" => "review", "assignees" => "" },
|
||||||
|
{ "index" => 4, "title" => "Ad hoc", "labels" => "P3", "assignees" => "" }
|
||||||
|
])
|
||||||
|
runner = FakeIssuesRunner.new([[tea_json, true]])
|
||||||
|
grouped = Backlog::Issues.new(runner: runner).list(tracker: "forgejo:jared/cc-os")
|
||||||
|
|
||||||
|
assert_equal [1], grouped[:next].map { |i| i[:number] }
|
||||||
|
assert_equal [2], grouped[:waiting].map { |i| i[:number] }
|
||||||
|
assert_equal [3], grouped[:review].map { |i| i[:number] }
|
||||||
|
assert_equal [4], grouped[:other].map { |i| i[:number] }
|
||||||
|
assert_equal "jared", grouped[:next].first[:assignee]
|
||||||
|
cmd = runner.calls.first
|
||||||
|
assert_equal "tea", cmd[0]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_github_list_groups_by_state_label
|
||||||
|
gh_json = JSON.generate([
|
||||||
|
{ "number" => 5, "title" => "Ship it", "labels" => [{ "name" => "review" }],
|
||||||
|
"assignees" => [{ "login" => "jared" }] }
|
||||||
|
])
|
||||||
|
runner = FakeIssuesRunner.new([[gh_json, true]])
|
||||||
|
grouped = Backlog::Issues.new(runner: runner).list(tracker: "github:acme/widget")
|
||||||
|
|
||||||
|
assert_equal [5], grouped[:review].map { |i| i[:number] }
|
||||||
|
assert_equal "jared", grouped[:review].first[:assignee]
|
||||||
|
assert_equal "gh", runner.calls.first[0]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_list_raises_command_failed_on_nonzero_exit
|
||||||
|
runner = FakeIssuesRunner.new([["boom", false]])
|
||||||
|
assert_raises(Backlog::Issues::CommandFailed) do
|
||||||
|
Backlog::Issues.new(runner: runner).list(tracker: "forgejo:jared/cc-os")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_repo_list_reads_open_issue_files_and_groups_them
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]), repo_root: dir)
|
||||||
|
issues.create(tracker: "repo:docs/issues", title: "Waiting one", body: "b", priority: "P1")
|
||||||
|
waiting_path = Dir.glob(File.join(dir, "docs/issues/*.md")).first
|
||||||
|
File.write(waiting_path, File.read(waiting_path).sub("labels: P1", "labels: P1,waiting"))
|
||||||
|
|
||||||
|
grouped = issues.list(tracker: "repo:docs/issues")
|
||||||
|
assert_equal ["Waiting one"], grouped[:waiting].map { |i| i[:title] }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_repo_list_is_empty_when_directory_absent
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
grouped = Backlog::Issues.new(runner: FakeIssuesRunner.new([]), repo_root: dir)
|
||||||
|
.list(tracker: "repo:docs/issues")
|
||||||
|
assert_equal [], grouped[:other]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_unroutable_tracker_raises
|
||||||
|
issues = Backlog::Issues.new(runner: FakeIssuesRunner.new([]))
|
||||||
|
assert_raises(ArgumentError) { issues.list(tracker: "planka:board") }
|
||||||
|
assert_raises(ArgumentError) { issues.create(tracker: nil, title: "T", body: "B") }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -92,18 +92,18 @@ class ProjectsCliContractTest < Minitest::Test
|
||||||
out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` }
|
out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` }
|
||||||
assert_equal({ "projects" => {} }, JSON.parse(out))
|
assert_equal({ "projects" => {} }, JSON.parse(out))
|
||||||
|
|
||||||
Dir.chdir(repo) { `#{env} ruby #{BIN} config-write planka:demo 2>/dev/null` }
|
Dir.chdir(repo) { `#{env} ruby #{BIN} config-write forgejo:jared/demo 2>/dev/null` }
|
||||||
out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` }
|
out = Dir.chdir(repo) { `#{env} ruby #{BIN} projects 2>/dev/null` }
|
||||||
projects = JSON.parse(out)["projects"]
|
projects = JSON.parse(out)["projects"]
|
||||||
assert_equal 1, projects.size
|
assert_equal 1, projects.size
|
||||||
row = projects.values.first
|
row = projects.values.first
|
||||||
assert_equal "planka:demo", row["tracker"]
|
assert_equal "forgejo:jared/demo", row["tracker"]
|
||||||
assert_equal File.basename(File.realpath(repo)), row["name"]
|
assert_equal File.basename(File.realpath(repo)), row["name"]
|
||||||
|
|
||||||
filtered = Dir.chdir(repo) { `#{env} ruby #{BIN} projects no-such-name 2>/dev/null` }
|
filtered = Dir.chdir(repo) { `#{env} ruby #{BIN} projects no-such-name 2>/dev/null` }
|
||||||
assert_equal({}, JSON.parse(filtered)["projects"])
|
assert_equal({}, JSON.parse(filtered)["projects"])
|
||||||
|
|
||||||
by_tracker = Dir.chdir(repo) { `#{env} ruby #{BIN} projects planka:demo 2>/dev/null` }
|
by_tracker = Dir.chdir(repo) { `#{env} ruby #{BIN} projects forgejo:jared/demo 2>/dev/null` }
|
||||||
assert_equal 1, JSON.parse(by_tracker)["projects"].size
|
assert_equal 1, JSON.parse(by_tracker)["projects"].size
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -121,7 +121,7 @@ class ProjectsCliContractTest < Minitest::Test
|
||||||
FileUtils.mkdir_p(subdir)
|
FileUtils.mkdir_p(subdir)
|
||||||
env = "CC_OS_HOME=#{home} HOME=#{home}"
|
env = "CC_OS_HOME=#{home} HOME=#{home}"
|
||||||
|
|
||||||
Dir.chdir(subdir) { `#{env} ruby #{BIN} config-write planka:child 2>/dev/null` }
|
Dir.chdir(subdir) { `#{env} ruby #{BIN} config-write forgejo:jared/child 2>/dev/null` }
|
||||||
|
|
||||||
projects = Dir.chdir(subdir) { JSON.parse(`#{env} ruby #{BIN} projects 2>/dev/null`)["projects"] }
|
projects = Dir.chdir(subdir) { JSON.parse(`#{env} ruby #{BIN} projects 2>/dev/null`)["projects"] }
|
||||||
assert_equal [File.realpath(subdir)], projects.keys
|
assert_equal [File.realpath(subdir)], projects.keys
|
||||||
|
|
|
||||||
|
|
@ -1,121 +1,40 @@
|
||||||
require_relative "test_helper"
|
require_relative "test_helper"
|
||||||
|
|
||||||
class ResolverTest < Minitest::Test
|
class ResolverTest < Minitest::Test
|
||||||
def test_configured_project_uses_configured_board
|
def test_configured_tracker_is_used
|
||||||
resolver = Backlog::Resolver.new(
|
resolver = Backlog::Resolver.new(
|
||||||
repo_path: "/home/jared/dev/some-repo",
|
repo_path: "/home/jared/dev/some-repo",
|
||||||
config_contents: "board: llf-schema\n",
|
config_contents: "tracker=forgejo:jared/some-repo\n"
|
||||||
boards: %w[llf-schema other-board]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert_equal "use llf-schema", resolver.resolve_string
|
assert_equal "use forgejo:jared/some-repo", resolver.resolve_string
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_unconfigured_dev_repo_uses_board_named_after_repo
|
def test_unconfigured_project_stops_and_discusses
|
||||||
resolver = Backlog::Resolver.new(
|
|
||||||
repo_path: "/home/jared/dev/llf-schema",
|
|
||||||
config_contents: nil,
|
|
||||||
boards: %w[llf-schema]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal "use llf-schema", resolver.resolve_string
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_unconfigured_client_repo_uses_board_named_after_repo
|
|
||||||
resolver = Backlog::Resolver.new(
|
|
||||||
repo_path: "/home/jared/clients/acme-co",
|
|
||||||
config_contents: nil,
|
|
||||||
boards: %w[acme-co]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal "use acme-co", resolver.resolve_string
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_matching_archived_board_activates
|
|
||||||
resolver = Backlog::Resolver.new(
|
|
||||||
repo_path: "/home/jared/dev/old-project",
|
|
||||||
config_contents: nil,
|
|
||||||
boards: %w[archived--old-project]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal "activate archived--old-project", resolver.resolve_string
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_configured_archived_board_activates
|
|
||||||
resolver = Backlog::Resolver.new(
|
resolver = Backlog::Resolver.new(
|
||||||
repo_path: "/home/jared/dev/some-repo",
|
repo_path: "/home/jared/dev/some-repo",
|
||||||
config_contents: "board: old-project\n",
|
config_contents: nil
|
||||||
boards: %w[archived--old-project]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert_equal "activate archived--old-project", resolver.resolve_string
|
assert_equal "stop-and-discuss", resolver.resolve_string
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_no_match_stops_and_discusses
|
def test_blank_config_contents_stops_and_discusses
|
||||||
resolver = Backlog::Resolver.new(
|
resolver = Backlog::Resolver.new(
|
||||||
repo_path: "/home/jared/dev/brand-new-repo",
|
repo_path: "/home/jared/dev/some-repo",
|
||||||
config_contents: nil,
|
config_contents: ""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal "stop-and-discuss", resolver.resolve_string
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_tolerates_legacy_boards_keyword_for_backward_compatibility
|
||||||
|
resolver = Backlog::Resolver.new(
|
||||||
|
repo_path: "/home/jared/dev/some-repo",
|
||||||
|
config_contents: "tracker=github:jared/some-repo\n",
|
||||||
boards: %w[unrelated-board]
|
boards: %w[unrelated-board]
|
||||||
)
|
)
|
||||||
|
|
||||||
assert_equal "stop-and-discuss", resolver.resolve_string
|
assert_equal "use github:jared/some-repo", resolver.resolve_string
|
||||||
end
|
|
||||||
|
|
||||||
def test_path_outside_dev_and_clients_is_ambiguous
|
|
||||||
resolver = Backlog::Resolver.new(
|
|
||||||
repo_path: "/home/jared/scratch/some-repo",
|
|
||||||
config_contents: nil,
|
|
||||||
boards: %w[some-repo]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal "stop-and-discuss", resolver.resolve_string
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_configured_board_with_no_match_stops_and_discusses
|
|
||||||
resolver = Backlog::Resolver.new(
|
|
||||||
repo_path: "/home/jared/dev/some-repo",
|
|
||||||
config_contents: "board: nonexistent\n",
|
|
||||||
boards: %w[llf-schema]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal "stop-and-discuss", resolver.resolve_string
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_empty_config_contents_is_treated_as_unconfigured
|
|
||||||
resolver = Backlog::Resolver.new(
|
|
||||||
repo_path: "/home/jared/dev/llf-schema",
|
|
||||||
config_contents: "",
|
|
||||||
boards: %w[llf-schema]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal "use llf-schema", resolver.resolve_string
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_unconfigured_servers_repo_uses_board_named_after_repo
|
|
||||||
resolver = Backlog::Resolver.new(
|
|
||||||
repo_path: "/home/jared/servers/proxmox-ubuntu",
|
|
||||||
config_contents: nil,
|
|
||||||
boards: %w[proxmox-ubuntu]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal "use proxmox-ubuntu", resolver.resolve_string
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_planka_tracker_key_resolves_to_its_board
|
|
||||||
resolver = Backlog::Resolver.new(
|
|
||||||
repo_path: "/somewhere/unrecognized/repo",
|
|
||||||
config_contents: "tracker=planka:my-board\n",
|
|
||||||
boards: %w[my-board]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal "use my-board", resolver.resolve_string
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_project_for_returns_dev_clients_servers_or_nil
|
|
||||||
assert_equal "Dev", Backlog::Resolver.project_for("/home/jared/dev/some-repo")
|
|
||||||
assert_equal "Clients", Backlog::Resolver.project_for("/home/jared/clients/acme-co")
|
|
||||||
assert_equal "Servers", Backlog::Resolver.project_for("/home/jared/servers/ovh-prod")
|
|
||||||
assert_equal "Servers", Backlog::Resolver.project_for("/home/jared/servers")
|
|
||||||
assert_nil Backlog::Resolver.project_for("/home/jared/scratch/some-repo")
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,234 +1,2 @@
|
||||||
require "minitest/autorun"
|
require "minitest/autorun"
|
||||||
require "planka_api"
|
|
||||||
require_relative "../lib/backlog"
|
require_relative "../lib/backlog"
|
||||||
|
|
||||||
module BacklogTestHelpers
|
|
||||||
# A hand-rolled fake of the subset of Planka::Client's resource surface
|
|
||||||
# BoardEnsurer/Cards touches: projects, boards, lists, labels, cards,
|
|
||||||
# comments. In-memory only, no network. Returns the real planka-api 0.2.0
|
|
||||||
# typed records (Planka::Types::*) so these tests exercise the same
|
|
||||||
# accessor shapes production code does.
|
|
||||||
class FakePlankaClient
|
|
||||||
attr_reader :projects, :boards, :lists, :labels, :cards, :comments
|
|
||||||
|
|
||||||
def initialize(reject_colors: [], reject_colors_with_400: [])
|
|
||||||
@next_id = 0
|
|
||||||
@projects_store = []
|
|
||||||
@boards_store = []
|
|
||||||
@lists_store = []
|
|
||||||
@labels_store = []
|
|
||||||
@cards_store = []
|
|
||||||
@card_labels_store = []
|
|
||||||
@comments_store = []
|
|
||||||
|
|
||||||
@projects = ProjectsResource.new(self)
|
|
||||||
@boards = BoardsResource.new(self)
|
|
||||||
@lists = ListsResource.new(self)
|
|
||||||
@labels = LabelsResource.new(self, reject_colors: reject_colors,
|
|
||||||
reject_colors_with_400: reject_colors_with_400)
|
|
||||||
@cards = CardsResource.new(self)
|
|
||||||
@comments = CommentsResource.new(self)
|
|
||||||
end
|
|
||||||
|
|
||||||
def next_id = (@next_id += 1).to_s
|
|
||||||
|
|
||||||
attr_reader :projects_store, :boards_store, :lists_store, :labels_store,
|
|
||||||
:cards_store, :card_labels_store, :comments_store
|
|
||||||
|
|
||||||
# Project managers aren't part of Types::Project (they're their own
|
|
||||||
# join record, Types::ProjectManager) — tracked here so tests can
|
|
||||||
# assert on the visibility-fix quirk (BoardEnsurer#fix_visibility).
|
|
||||||
def managers_for(project_id)
|
|
||||||
@managers_store ||= {}
|
|
||||||
@managers_store[project_id] ||= []
|
|
||||||
end
|
|
||||||
|
|
||||||
def attach_label(card_id, label_id)
|
|
||||||
record = Planka::Types::CardLabel.new(
|
|
||||||
id: next_id, created_at: nil, updated_at: nil, card_id: card_id, label_id: label_id
|
|
||||||
)
|
|
||||||
@card_labels_store << record
|
|
||||||
record
|
|
||||||
end
|
|
||||||
|
|
||||||
class ProjectsResource
|
|
||||||
def initialize(client) = @client = client
|
|
||||||
|
|
||||||
def list = @client.projects_store.dup
|
|
||||||
|
|
||||||
def create(attrs)
|
|
||||||
project = Planka::Types::Project.new(
|
|
||||||
id: @client.next_id, created_at: nil, updated_at: nil, name: attrs[:name],
|
|
||||||
description: nil, background_type: nil, background_gradient: nil, is_hidden: nil,
|
|
||||||
owner_project_manager_id: @client.next_id, background_image_id: nil, is_favorite: nil
|
|
||||||
)
|
|
||||||
@client.projects_store << project
|
|
||||||
project
|
|
||||||
end
|
|
||||||
|
|
||||||
def update(id, attrs)
|
|
||||||
project = @client.projects_store.find { |p| p.id == id }
|
|
||||||
idx = @client.projects_store.index(project)
|
|
||||||
updated = project
|
|
||||||
updated = updated.with(owner_project_manager_id: attrs[:ownerProjectManagerId]) if attrs.key?(:ownerProjectManagerId)
|
|
||||||
@client.projects_store[idx] = updated
|
|
||||||
updated
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_manager(project_id, user_id)
|
|
||||||
@client.managers_for(project_id) << user_id
|
|
||||||
Planka::Types::ProjectManager.new(id: @client.next_id, created_at: nil, updated_at: nil,
|
|
||||||
project_id: project_id, user_id: user_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class BoardsResource
|
|
||||||
def initialize(client) = @client = client
|
|
||||||
|
|
||||||
def list(project_id)
|
|
||||||
@client.boards_store.select { |b| b.project_id == project_id }
|
|
||||||
end
|
|
||||||
|
|
||||||
def create(project_id, attrs)
|
|
||||||
board = Planka::Types::Board.new(
|
|
||||||
id: @client.next_id, created_at: nil, updated_at: nil, position: attrs[:position],
|
|
||||||
name: attrs[:name], default_view: nil, default_card_type: nil,
|
|
||||||
limit_card_types_to_default_one: nil, always_display_card_creator: nil,
|
|
||||||
display_card_ages: nil, expand_task_lists_by_default: nil,
|
|
||||||
project_id: project_id, is_subscribed: nil
|
|
||||||
)
|
|
||||||
@client.boards_store << board
|
|
||||||
board
|
|
||||||
end
|
|
||||||
|
|
||||||
def update(id, attrs)
|
|
||||||
board = @client.boards_store.find { |b| b.id == id }
|
|
||||||
idx = @client.boards_store.index(board)
|
|
||||||
updated = board
|
|
||||||
updated = updated.with(name: attrs[:name]) if attrs.key?(:name)
|
|
||||||
@client.boards_store[idx] = updated
|
|
||||||
updated
|
|
||||||
end
|
|
||||||
|
|
||||||
def get(id)
|
|
||||||
board = @client.boards_store.find { |b| b.id == id }
|
|
||||||
unless board
|
|
||||||
raise Planka::NotFoundError.new(status: 404,
|
|
||||||
body: { "message" => "board #{id} not found" })
|
|
||||||
end
|
|
||||||
|
|
||||||
Planka::Types::BoardDetail.new(
|
|
||||||
board: board,
|
|
||||||
lists: @client.lists.list(id),
|
|
||||||
labels: @client.labels.list(id),
|
|
||||||
cards: @client.cards.all_for_board(id),
|
|
||||||
card_labels: @client.card_labels_store.dup,
|
|
||||||
board_memberships: []
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class ListsResource
|
|
||||||
def initialize(client) = @client = client
|
|
||||||
|
|
||||||
def list(board_id)
|
|
||||||
@client.lists_store.select { |l| l.board_id == board_id }
|
|
||||||
end
|
|
||||||
|
|
||||||
def create(board_id, attrs)
|
|
||||||
raise ArgumentError, "list create requires type" unless attrs[:type]
|
|
||||||
|
|
||||||
list = Planka::Types::List.new(
|
|
||||||
id: @client.next_id, created_at: nil, updated_at: nil, type: attrs[:type],
|
|
||||||
position: attrs[:position], name: attrs[:name], color: nil, board_id: board_id
|
|
||||||
)
|
|
||||||
@client.lists_store << list
|
|
||||||
list
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class CardsResource
|
|
||||||
def initialize(client) = @client = client
|
|
||||||
|
|
||||||
def create(list_id, attrs)
|
|
||||||
raise ArgumentError, "card create requires type" unless attrs[:type]
|
|
||||||
|
|
||||||
list = @client.lists_store.find { |l| l.id == list_id }
|
|
||||||
card = Planka::Types::Card.new(
|
|
||||||
id: @client.next_id, created_at: nil, updated_at: nil, type: attrs[:type],
|
|
||||||
position: attrs[:position], name: attrs[:name], description: attrs[:description],
|
|
||||||
due_date: nil, is_due_completed: nil, stopwatch: nil, comments_total: nil,
|
|
||||||
is_closed: nil, list_changed_at: nil, board_id: list.board_id, list_id: list_id,
|
|
||||||
creator_user_id: nil, prev_list_id: nil, cover_attachment_id: nil, is_subscribed: nil
|
|
||||||
)
|
|
||||||
@client.cards_store << card
|
|
||||||
card
|
|
||||||
end
|
|
||||||
|
|
||||||
def all_for_board(board_id)
|
|
||||||
@client.cards_store.select { |c| c.board_id == board_id }
|
|
||||||
end
|
|
||||||
|
|
||||||
def move(id, list_id:, position:, board_id: nil)
|
|
||||||
card = @client.cards_store.find { |c| c.id == id }
|
|
||||||
idx = @client.cards_store.index(card)
|
|
||||||
updated = card.with(list_id: list_id, position: position)
|
|
||||||
updated = updated.with(board_id: board_id) if board_id
|
|
||||||
@client.cards_store[idx] = updated
|
|
||||||
updated
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class LabelsResource
|
|
||||||
def initialize(client, reject_colors: [], reject_colors_with_400: [])
|
|
||||||
@client = client
|
|
||||||
@reject_colors = reject_colors
|
|
||||||
@reject_colors_with_400 = reject_colors_with_400
|
|
||||||
end
|
|
||||||
|
|
||||||
def list(board_id)
|
|
||||||
@client.labels_store.select { |l| l.board_id == board_id }
|
|
||||||
end
|
|
||||||
|
|
||||||
def create(board_id, attrs)
|
|
||||||
if @reject_colors_with_400.include?(attrs[:color])
|
|
||||||
# Planka 2.x rejects unknown color names as E_MISSING_OR_INVALID_PARAMS
|
|
||||||
raise Planka::BadRequestError.new(status: 400,
|
|
||||||
body: { "message" => "color #{attrs[:color]} rejected" })
|
|
||||||
end
|
|
||||||
if @reject_colors.include?(attrs[:color])
|
|
||||||
raise Planka::ValidationError.new(status: 422,
|
|
||||||
body: { "message" => "color #{attrs[:color]} rejected" })
|
|
||||||
end
|
|
||||||
|
|
||||||
label = Planka::Types::Label.new(
|
|
||||||
id: @client.next_id, created_at: nil, updated_at: nil, position: attrs[:position],
|
|
||||||
name: attrs[:name], color: attrs[:color], board_id: board_id
|
|
||||||
)
|
|
||||||
@client.labels_store << label
|
|
||||||
label
|
|
||||||
end
|
|
||||||
|
|
||||||
def attach_to_card(card_id, label_id)
|
|
||||||
@client.attach_label(card_id, label_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class CommentsResource
|
|
||||||
def initialize(client)
|
|
||||||
@client = client
|
|
||||||
end
|
|
||||||
|
|
||||||
def store = @client.comments_store
|
|
||||||
|
|
||||||
def create(card_id, attrs)
|
|
||||||
comment = Planka::Types::Comment.new(
|
|
||||||
id: @client.next_id, created_at: nil, updated_at: nil, text: attrs[:text],
|
|
||||||
card_id: card_id, user_id: nil
|
|
||||||
)
|
|
||||||
@client.comments_store << comment
|
|
||||||
comment
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
require_relative "test_helper"
|
require_relative "test_helper"
|
||||||
|
|
||||||
class TrackerTest < Minitest::Test
|
class TrackerTest < Minitest::Test
|
||||||
def test_accepts_planka_format
|
def test_rejects_planka_format
|
||||||
assert Backlog::Tracker.valid?("planka:llf-schema")
|
refute Backlog::Tracker.valid?("planka:llf-schema")
|
||||||
assert_equal :planka, Backlog::Tracker.kind("planka:llf-schema")
|
assert_nil Backlog::Tracker.kind("planka:llf-schema")
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_accepts_forgejo_format
|
def test_accepts_forgejo_format
|
||||||
|
|
@ -32,6 +32,21 @@ class TrackerTest < Minitest::Test
|
||||||
refute Backlog::Tracker.valid?("jira:PROJ-123")
|
refute Backlog::Tracker.valid?("jira:PROJ-123")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_issues_destination_routes_git_hosts_to_git_issues
|
||||||
|
assert_equal :git_issues, Backlog::Tracker.issues_destination("forgejo:jared/cc-os")
|
||||||
|
assert_equal :git_issues, Backlog::Tracker.issues_destination("github:jared/cc-os")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_issues_destination_routes_repo_to_repo_files
|
||||||
|
assert_equal :repo_files, Backlog::Tracker.issues_destination("repo:docs/issues")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_issues_destination_is_unrouted_for_planka_nil_or_invalid
|
||||||
|
assert_equal :unrouted, Backlog::Tracker.issues_destination("planka:cc-os")
|
||||||
|
assert_equal :unrouted, Backlog::Tracker.issues_destination(nil)
|
||||||
|
assert_equal :unrouted, Backlog::Tracker.issues_destination("jira:whatever")
|
||||||
|
end
|
||||||
|
|
||||||
def test_parse_remote_detects_forgejo_ssh_url
|
def test_parse_remote_detects_forgejo_ssh_url
|
||||||
output = "origin\tssh://git@forgejo.swansoncloud.com:2222/jared/cc-os.git (fetch)\n" \
|
output = "origin\tssh://git@forgejo.swansoncloud.com:2222/jared/cc-os.git (fetch)\n" \
|
||||||
"origin\tssh://git@forgejo.swansoncloud.com:2222/jared/cc-os.git (push)\n"
|
"origin\tssh://git@forgejo.swansoncloud.com:2222/jared/cc-os.git (push)\n"
|
||||||
|
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
require_relative "test_helper"
|
|
||||||
|
|
||||||
class TriageCheckTest < Minitest::Test
|
|
||||||
def test_card_missing_both_labels_needs_triage
|
|
||||||
assert Backlog::TriageCheck.needs_triage?([])
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_card_missing_priority_label_needs_triage
|
|
||||||
assert Backlog::TriageCheck.needs_triage?(["semi"])
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_card_missing_autonomy_label_needs_triage
|
|
||||||
assert Backlog::TriageCheck.needs_triage?(["P1"])
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_fully_labeled_card_does_not_need_triage
|
|
||||||
refute Backlog::TriageCheck.needs_triage?(%w[P1 semi])
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_note_for_no_unlabeled_cards_is_nil
|
|
||||||
assert_nil Backlog::TriageCheck.note_for("cc-os", [%w[P1 semi], %w[P0 hitl]])
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_note_for_counts_only_unlabeled_cards
|
|
||||||
labels = [%w[P1 semi], [], ["P2"]]
|
|
||||||
note = Backlog::TriageCheck.note_for("cc-os", labels)
|
|
||||||
|
|
||||||
refute_nil note
|
|
||||||
assert_includes note, "2 unlabeled Backlog cards on board cc-os"
|
|
||||||
assert_includes note, "card-triage"
|
|
||||||
assert_includes note, "haiku"
|
|
||||||
assert_includes note, "background"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_note_for_singular_card_uses_singular_wording
|
|
||||||
note = Backlog::TriageCheck.note_for("cc-os", [[]])
|
|
||||||
|
|
||||||
assert_includes note, "1 unlabeled Backlog card on board cc-os"
|
|
||||||
refute_includes note, "1 unlabeled Backlog cards"
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_board_name_for_subdir_config_overrides_root_config
|
|
||||||
name = Backlog::TriageCheck.board_name_for(
|
|
||||||
cwd: "/home/jared/servers/proxmox", root: "/home/jared/servers",
|
|
||||||
cwd_config: "tracker=planka:proxmox-board\n", root_config: "board=servers\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal "proxmox-board", name
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_board_name_for_falls_back_to_root_config
|
|
||||||
name = Backlog::TriageCheck.board_name_for(
|
|
||||||
cwd: "/home/jared/dev/cc-os/plugins", root: "/home/jared/dev/cc-os",
|
|
||||||
cwd_config: nil, root_config: "tracker=planka:cc-os\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal "cc-os", name
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_board_name_for_subdir_config_without_board_names_the_subdir
|
|
||||||
name = Backlog::TriageCheck.board_name_for(
|
|
||||||
cwd: "/home/jared/servers/proxmox", root: "/home/jared/servers",
|
|
||||||
cwd_config: "version=3\n", root_config: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal "proxmox", name
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_board_name_for_no_config_uses_heuristic_repo_basename
|
|
||||||
name = Backlog::TriageCheck.board_name_for(
|
|
||||||
cwd: "/home/jared/dev/cc-os", root: "/home/jared/dev/cc-os",
|
|
||||||
cwd_config: nil, root_config: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal "cc-os", name
|
|
||||||
end
|
|
||||||
|
|
||||||
def test_board_name_for_unrecognized_path_without_config_is_nil
|
|
||||||
name = Backlog::TriageCheck.board_name_for(
|
|
||||||
cwd: "/home/jared/scratch/x", root: "/home/jared/scratch/x",
|
|
||||||
cwd_config: nil, root_config: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_nil name
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
description: Run the biweekly orchestration IRL audit - deterministic transcript stats precompute, then auditor fan-out judging flagged regions against the shipped `prompts/session-start/10-orchestration.md` rules, then a tiered tune-up report. Works from any directory. Trigger via the os-status due nudge or the Planka recurrence card.
|
description: Run the biweekly orchestration IRL audit - deterministic transcript stats precompute, then auditor fan-out judging flagged regions against the shipped `prompts/session-start/10-orchestration.md` rules, then a tiered tune-up report. Works from any directory. Trigger via the os-status due nudge or the recurring issue jared/ops#1 on Forgejo.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Audit production sessions against the shipped orchestration rules
|
# Audit production sessions against the shipped orchestration rules
|
||||||
|
|
@ -65,5 +65,6 @@ later: edit
|
||||||
`~/dev/cc-os/plugins/os-context/prompts/session-start/10-orchestration.md`, run `~/dev/cc-os/bin/refresh-plugins`,
|
`~/dev/cc-os/plugins/os-context/prompts/session-start/10-orchestration.md`, run `~/dev/cc-os/bin/refresh-plugins`,
|
||||||
and record the change in `~/dev/cc-os/docs/implementation-status.md`.
|
and record the change in `~/dev/cc-os/docs/implementation-status.md`.
|
||||||
|
|
||||||
Finally, if a Planka card for this audit exists on the backlog board, move it to
|
Finally, comment the audit date + report location on the recurring issue
|
||||||
Review (never to Done).
|
`jared/ops#1` (Forgejo, via `tea`) and leave it open — recurring-convention
|
||||||
|
issues are never closed by the AI (ADR-0042).
|
||||||
|
|
|
||||||
|
|
@ -198,10 +198,16 @@ def orchestration_audit_due(ctx: Ctx) -> CheckResult:
|
||||||
TRACKER_ABSENT_NOTE = (
|
TRACKER_ABSENT_NOTE = (
|
||||||
"No 'tracker' key set in .cc-os/config — this project's issue tracker is"
|
"No 'tracker' key set in .cc-os/config — this project's issue tracker is"
|
||||||
" unregistered. Run /os-backlog:route to pick a tracker and"
|
" unregistered. Run /os-backlog:route to pick a tracker and"
|
||||||
" record it (planka:<board> | forgejo:<owner>/<repo> |"
|
" record it (forgejo:<owner>/<repo> |"
|
||||||
" github:<owner>/<repo> | repo:<path>)."
|
" github:<owner>/<repo> | repo:<path>)."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
TRACKER_PLANKA_RETIRED_NOTE = (
|
||||||
|
"planka: trackers are retired (ADR-0042 — git issues are the single"
|
||||||
|
" tracker for state and specs). Run /os-backlog:route to re-route to"
|
||||||
|
" forgejo:/github:/repo:."
|
||||||
|
)
|
||||||
|
|
||||||
# --- config version (ADR-026) ---------------------------------------------
|
# --- config version (ADR-026) ---------------------------------------------
|
||||||
|
|
||||||
CURRENT_CONFIG_VERSION = 1
|
CURRENT_CONFIG_VERSION = 1
|
||||||
|
|
@ -238,7 +244,6 @@ def project_graph_present(ctx: Ctx) -> CheckResult:
|
||||||
)
|
)
|
||||||
|
|
||||||
_TRACKER_PATTERNS = {
|
_TRACKER_PATTERNS = {
|
||||||
"planka": re.compile(r"^[^/\s]+$"),
|
|
||||||
"forgejo": re.compile(r"^[^/\s]+/[^/\s]+$"),
|
"forgejo": re.compile(r"^[^/\s]+/[^/\s]+$"),
|
||||||
"github": re.compile(r"^[^/\s]+/[^/\s]+$"),
|
"github": re.compile(r"^[^/\s]+/[^/\s]+$"),
|
||||||
"repo": re.compile(r"^\S+$"),
|
"repo": re.compile(r"^\S+$"),
|
||||||
|
|
@ -248,17 +253,20 @@ _TRACKER_PATTERNS = {
|
||||||
def tracker_configured(ctx: Ctx) -> CheckResult:
|
def tracker_configured(ctx: Ctx) -> CheckResult:
|
||||||
"""Read the 'tracker' key from .cc-os/config. Present + valid -> silent
|
"""Read the 'tracker' key from .cc-os/config. Present + valid -> silent
|
||||||
(configured projects cost nothing at session start); present + malformed
|
(configured projects cost nothing at session start); present + malformed
|
||||||
-> one-line warn, never a crash; absent -> daily-snoozed nudge toward the
|
-> one-line warn, never a crash; a retired `planka:` value -> one-line
|
||||||
os-backlog routing skill."""
|
warn naming ADR-0042; absent -> daily-snoozed nudge toward the
|
||||||
|
os-backlog routing skill. Always fail-soft: never blocks the session."""
|
||||||
raw = str(ctx.config.get("tracker", "")).strip()
|
raw = str(ctx.config.get("tracker", "")).strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return warn(TRACKER_ABSENT_NOTE)
|
return warn(TRACKER_ABSENT_NOTE)
|
||||||
scheme, sep, rest = raw.partition(":")
|
scheme, sep, rest = raw.partition(":")
|
||||||
|
if scheme == "planka":
|
||||||
|
return warn(TRACKER_PLANKA_RETIRED_NOTE)
|
||||||
pattern = _TRACKER_PATTERNS.get(scheme)
|
pattern = _TRACKER_PATTERNS.get(scheme)
|
||||||
if not sep or not pattern or not pattern.match(rest):
|
if not sep or not pattern or not pattern.match(rest):
|
||||||
return warn(
|
return warn(
|
||||||
f".cc-os/config sets tracker = '{raw}', which does not match the"
|
f".cc-os/config sets tracker = '{raw}', which does not match the"
|
||||||
" expected grammar (planka:<board> | forgejo:<owner>/<repo> |"
|
" expected grammar (forgejo:<owner>/<repo> |"
|
||||||
" github:<owner>/<repo> | repo:<path>) — fix the value."
|
" github:<owner>/<repo> | repo:<path>) — fix the value."
|
||||||
)
|
)
|
||||||
return OK
|
return OK
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,14 @@ like a `.gitignore` addition or a config stamp.
|
||||||
c. **`project-graph-present`** → invoke `/os-vault:onboard-project`.
|
c. **`project-graph-present`** → invoke `/os-vault:onboard-project`.
|
||||||
|
|
||||||
d. **`tracker-configured`** → invoke `/os-backlog:route` directly and let it run
|
d. **`tracker-configured`** → invoke `/os-backlog:route` directly and let it run
|
||||||
its own inspect → propose → confirm flow. Do NOT pre-ask the user "forgejo or
|
its own inspect → propose → confirm flow. Per ADR-0042, one tracker holds both
|
||||||
planka?" — that framing is wrong: the boundary rule uses BOTH (Planka = state,
|
task state and durable specs — there is no second surface to weigh, so don't
|
||||||
git issues = specs; a project routinely keeps its git-issue spec chains AND a
|
pre-ask the user "forgejo or github or repo?" either; the route skill owns that
|
||||||
`planka:` tracker key for new ad hoc state). The route skill owns that
|
decision conversation and its gates. A `planka:` value found in an existing
|
||||||
decision conversation and its gates. If `/os-backlog:route` is not installed,
|
`.cc-os/config` is rejected fail-soft (one line citing ADR-0042) by both
|
||||||
tell the user and skip — do not fabricate a `.cc-os/config` tracker value
|
`config-write` and this check — the fix is the same `/os-backlog:route` call.
|
||||||
yourself.
|
If `/os-backlog:route` is not installed, tell the user and skip — do not
|
||||||
|
fabricate a `.cc-os/config` tracker value yourself.
|
||||||
|
|
||||||
e. **`subagent-model-env-override`** → **human gate, and typically out of scope
|
e. **`subagent-model-env-override`** → **human gate, and typically out of scope
|
||||||
for a project-level fix.** This is an environment/settings.json condition, not
|
for a project-level fix.** This is an environment/settings.json condition, not
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from checks import ( # noqa: E402
|
||||||
ENV_VAR,
|
ENV_VAR,
|
||||||
PRESENT_NOTE,
|
PRESENT_NOTE,
|
||||||
TRACKER_ABSENT_NOTE,
|
TRACKER_ABSENT_NOTE,
|
||||||
|
TRACKER_PLANKA_RETIRED_NOTE,
|
||||||
Check,
|
Check,
|
||||||
CheckResult,
|
CheckResult,
|
||||||
Ctx,
|
Ctx,
|
||||||
|
|
@ -250,11 +251,14 @@ class TrackerCheckTest(unittest.TestCase):
|
||||||
self.assertEqual(CheckResult("warn", TRACKER_ABSENT_NOTE), result)
|
self.assertEqual(CheckResult("warn", TRACKER_ABSENT_NOTE), result)
|
||||||
self.assertIn("/os-backlog:route", result.message)
|
self.assertIn("/os-backlog:route", result.message)
|
||||||
|
|
||||||
def test_planka_value_is_ok(self):
|
def test_planka_value_is_rejected_citing_adr_0042(self):
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
root = git_project(tmp)
|
root = git_project(tmp)
|
||||||
ctx = make_ctx(project_root=root, config={"tracker": "planka:my-board"})
|
ctx = make_ctx(project_root=root, config={"tracker": "planka:my-board"})
|
||||||
self.assertEqual("ok", tracker_configured(ctx).status)
|
result = tracker_configured(ctx)
|
||||||
|
self.assertEqual(CheckResult("warn", TRACKER_PLANKA_RETIRED_NOTE), result)
|
||||||
|
self.assertIn("ADR-0042", result.message)
|
||||||
|
self.assertIn("/os-backlog:route", result.message)
|
||||||
|
|
||||||
def test_forgejo_value_is_ok(self):
|
def test_forgejo_value_is_ok(self):
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
|
|
@ -285,7 +289,7 @@ class TrackerCheckTest(unittest.TestCase):
|
||||||
def test_missing_separator_warns_without_crashing(self):
|
def test_missing_separator_warns_without_crashing(self):
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
root = git_project(tmp)
|
root = git_project(tmp)
|
||||||
ctx = make_ctx(project_root=root, config={"tracker": "planka-my-board"})
|
ctx = make_ctx(project_root=root, config={"tracker": "bogus-my-board"})
|
||||||
result = tracker_configured(ctx)
|
result = tracker_configured(ctx)
|
||||||
self.assertEqual("warn", result.status)
|
self.assertEqual("warn", result.status)
|
||||||
|
|
||||||
|
|
@ -417,10 +421,10 @@ class WriteConfigValueTest(unittest.TestCase):
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
(root / ".cc-os").mkdir()
|
(root / ".cc-os").mkdir()
|
||||||
(root / ".cc-os" / "config").write_text("hub = my-hub\ntracker = planka:board\n")
|
(root / ".cc-os" / "config").write_text("hub = my-hub\ntracker = forgejo:jared/x\n")
|
||||||
write_config_value(root, "version", "1")
|
write_config_value(root, "version", "1")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{"hub": "my-hub", "tracker": "planka:board", "version": "1"},
|
{"hub": "my-hub", "tracker": "forgejo:jared/x", "version": "1"},
|
||||||
read_config(root),
|
read_config(root),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue