planka-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/Makefile +6 -0
- data/README.md +161 -0
- data/bin/planka-branch-name +23 -0
- data/bin/planka-claim +30 -0
- data/bin/planka-comment +20 -0
- data/bin/planka-link +24 -0
- data/bin/planka-loop-lock +30 -0
- data/bin/planka-mcp +6 -0
- data/bin/planka-next-card +31 -0
- data/bin/planka-op +3 -0
- data/bin/planka-spec-sweep +30 -0
- data/bin/planka-unticked +20 -0
- data/lib/planka/blocker.rb +33 -0
- data/lib/planka/blocking.rb +41 -0
- data/lib/planka/board.rb +55 -0
- data/lib/planka/branch_name.rb +25 -0
- data/lib/planka/card.rb +48 -0
- data/lib/planka/client.rb +118 -0
- data/lib/planka/handoff.rb +14 -0
- data/lib/planka/loop_lock.rb +14 -0
- data/lib/planka/next_card.rb +113 -0
- data/lib/planka/pull_request.rb +17 -0
- data/lib/planka/spec_sweep.rb +15 -0
- data/lib/planka/version.rb +3 -0
- data/lib/planka.rb +25 -0
- data/libexec/planka-mcp +5 -0
- data/libexec/planka-op +46 -0
- data/test/fixtures/files/planka/board.json +693 -0
- data/test/lib/planka/blocker_test.rb +51 -0
- data/test/lib/planka/blocking_test.rb +76 -0
- data/test/lib/planka/board_test.rb +77 -0
- data/test/lib/planka/branch_name_test.rb +36 -0
- data/test/lib/planka/configuration_test.rb +83 -0
- data/test/lib/planka/handoff_test.rb +45 -0
- data/test/lib/planka/loop_lock_test.rb +49 -0
- data/test/lib/planka/next_card_test.rb +158 -0
- data/test/lib/planka/planka_test_helper.rb +63 -0
- data/test/lib/planka/spec_sweep_test.rb +35 -0
- metadata +140 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dabd1d040fd4dede722ec5c62b8e60d37f2a344b80e825862c8dd7c9843c0d79
|
|
4
|
+
data.tar.gz: 7a900110053223770ce56dda103e5b47c99809b98ba682618d1c1dcb042cbb39
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a2042e943919152542795ddc968803f7274c17bb3e114d40b55b774ed7c7e6d024befffce847bf17cf8867b524b25d1764cae049240ecf2d9b8f7be15c6e923b
|
|
7
|
+
data.tar.gz: f8491ffec2927b09ebda03abfe934e8eafdac147ca8ddf41497c8b3b7360976079cd401b0bf257b0d068d43fc9f5c07e9a7d38f338ccd8f486d1af112c986ad1
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.4.9
|
data/Makefile
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# planka-cli
|
|
2
|
+
|
|
3
|
+
Ruby command-line tools and workflow utilities for Planka's REST API. Extracted
|
|
4
|
+
from Lucenta into this standalone local repository; no remote has been created
|
|
5
|
+
and the gem has not been published. `work-next` and `pr_assets` remain in Lucenta.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Use Ruby 3.4 or newer (development uses 3.4.9), Bundler, and Bash. From this
|
|
10
|
+
repository:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
bundle install
|
|
14
|
+
gem build planka-cli.gemspec
|
|
15
|
+
gem install ./planka-cli-0.1.0.gem
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Put RubyGems' executable directory on `PATH`. Invoke `planka-*` commands from
|
|
19
|
+
the checkout whose configuration you want to use, not from this gem's directory.
|
|
20
|
+
Ruby launchers package the shell implementations of `planka-op` and `planka-mcp`
|
|
21
|
+
so the commands also work through RubyGems' installed executable wrappers.
|
|
22
|
+
|
|
23
|
+
For the current sibling-directory extraction, Lucenta's Gemfile uses:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
source "https://rubygems.org"
|
|
27
|
+
gem "planka-cli", path: "../planka-cli"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Run `bundle install` in Lucenta, then use `bundle exec planka-next-card` and the
|
|
31
|
+
other installed commands. Relocating the checkout requires updating that path.
|
|
32
|
+
After deciding on a remote and publishing the gem to RubyGems, replace the path
|
|
33
|
+
with a released version constraint and update the bundle. No remote or
|
|
34
|
+
publication is assumed by the current setup.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
The command's **current working directory** owns `.mcp.json` and `.env.op`.
|
|
39
|
+
Nothing is loaded from the installed gem, Lucenta, or a homelab checkout.
|
|
40
|
+
|
|
41
|
+
| Variable | Purpose |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `PLANKA_BASE_URL` | Planka instance URL, for example `http://localhost:3000`. Also used to render card links. |
|
|
44
|
+
| `PLANKA_AGENT_EMAIL` | User email or username for API sign-in. |
|
|
45
|
+
| `PLANKA_AGENT_PASSWORD` | Password for API sign-in. |
|
|
46
|
+
| `PLANKA_BOARD_ID` | Board selected by `planka-next-card` and `Planka.board_id`. No default board exists. |
|
|
47
|
+
| `PLANKA_MCP_CONFIG` | Optional MCP config filename; relative paths resolve from the current working directory. Defaults to `.mcp.json` if present. An explicitly selected missing file is an error. |
|
|
48
|
+
| `OP_SERVICE_ACCOUNT_TOKEN` | Exported 1Password service-account token; no env file is needed when already set. |
|
|
49
|
+
| `OP_ENV_FILE` | Optional file to source when no token is exported; relative to the current working directory. Otherwise `.env.op` is used. |
|
|
50
|
+
| `XDG_STATE_HOME` | Optional state root for 1Password's isolated home; defaults to `$HOME/.local/state`. |
|
|
51
|
+
|
|
52
|
+
Explicit environment variables override values in the selected MCP config's
|
|
53
|
+
`mcpServers.planka.env`. Plain credentials need neither an MCP config nor
|
|
54
|
+
1Password:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
export PLANKA_BASE_URL=http://localhost:3000
|
|
58
|
+
export PLANKA_AGENT_EMAIL=agent@example.test
|
|
59
|
+
export PLANKA_AGENT_PASSWORD='your-password'
|
|
60
|
+
export PLANKA_BOARD_ID='your-board-id'
|
|
61
|
+
planka-next-card
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Alternatively, create a caller-owned MCP config (substitute your URL, board,
|
|
65
|
+
and secret references):
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"mcpServers": {
|
|
70
|
+
"planka": {
|
|
71
|
+
"command": "planka-mcp",
|
|
72
|
+
"env": {
|
|
73
|
+
"PLANKA_BASE_URL": "http://localhost:3000",
|
|
74
|
+
"PLANKA_BOARD_ID": "your-board-id",
|
|
75
|
+
"PLANKA_AGENT_EMAIL": "op://vault/planka-agent/username",
|
|
76
|
+
"PLANKA_AGENT_PASSWORD": "op://vault/planka-agent/password"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
When `PLANKA_*` settings contain `op://` references, the command re-runs through
|
|
84
|
+
its packaged `planka-op` wrapper. Install the 1Password `op` CLI and export a
|
|
85
|
+
service-account token or create `.env.op` containing
|
|
86
|
+
`OP_SERVICE_ACCOUNT_TOKEN='your-token'`. Treat the file as a shell file; it is
|
|
87
|
+
sourced, not parsed as dotenv. Keep credential files out of version control.
|
|
88
|
+
The wrapper isolates 1Password state under `planka-cli/op-home`, restores the
|
|
89
|
+
real `HOME` for the child command, and uses `op run --no-masking` to preserve JSON
|
|
90
|
+
and MCP streams. Do not print secret values from commands run through it.
|
|
91
|
+
|
|
92
|
+
`planka-mcp` uses Node.js/npm's `npx` to launch
|
|
93
|
+
`@navyatec/planka-v2-mcp@1.3.4`. It applies the same config and credential rules;
|
|
94
|
+
plain values do not invoke `op`. In an MCP client using the local path bundle,
|
|
95
|
+
use `"command": "bundle", "args": ["exec", "planka-mcp"]` instead.
|
|
96
|
+
|
|
97
|
+
## Commands
|
|
98
|
+
|
|
99
|
+
A `<card>` argument accepts a numeric ID or a card URL. Commands preserve their
|
|
100
|
+
line-oriented workflow output; there is no implicit board or instance.
|
|
101
|
+
|
|
102
|
+
| Command | Behavior |
|
|
103
|
+
| --- | --- |
|
|
104
|
+
| `planka-next-card [feature-label\|effort-label]` | Read-only selection on `PLANKA_BOARD_ID`. With no label, chooses the first takeable ticket by list position. Feature selection uses creation order and reports spec, ticket number, blockers and parent branch. Effort selection reports a wayfinder map and frontier. |
|
|
105
|
+
| `planka-branch-name <card>` | Prints the workflow branch name; read-only. |
|
|
106
|
+
| `planka-claim <card>` | Adds the signed-in user as a member if needed and moves the card to its board's `in-progress` list. Safe to repeat. |
|
|
107
|
+
| `planka-comment <card> <text>` | Adds a comment as the signed-in user. Quote multiline text as one argument. |
|
|
108
|
+
| `planka-link <blocked-card> <blocker-card>...` | Adds linked blocking tasks in a `Blocked by` task list. Existing edges are skipped; self-blocking is rejected. |
|
|
109
|
+
| `planka-unticked <card>` | Prints incomplete tasks in `Acceptance criteria`, one per line; read-only. |
|
|
110
|
+
| `planka-loop-lock` | Searches all visible boards for an open claimed card without a PR handoff, printing `free` or `held`, claim time and age; read-only. |
|
|
111
|
+
| `planka-spec-sweep` | Searches all visible boards, comments on completed feature specs in `in-progress`, and moves them to `done`. |
|
|
112
|
+
| `planka-op <command> [args...]` | Resolves secret references in a command's environment using `op run`; requires a service-account token. |
|
|
113
|
+
| `planka-mcp` | Starts the pinned Planka MCP server using `npx`. |
|
|
114
|
+
|
|
115
|
+
The Ruby utilities are available with `require "planka"` and
|
|
116
|
+
`require "planka/client"`. `Planka::Client.configure!` loads caller-owned MCP
|
|
117
|
+
settings into unset environment variables; `resolve_credentials!(script, argv)`
|
|
118
|
+
additionally validates API credentials and resolves secret references by
|
|
119
|
+
re-executing the calling script. `Client.session` signs in and signs out around
|
|
120
|
+
the supplied block. `Planka.board_id` fetches `PLANKA_BOARD_ID` and
|
|
121
|
+
`Planka.card_url(id)` joins the configured base URL with `/cards/<id>`.
|
|
122
|
+
|
|
123
|
+
## Workflow conventions and dependencies
|
|
124
|
+
|
|
125
|
+
These tools retain the extracted workflow conventions rather than imposing a
|
|
126
|
+
general-purpose Planka schema:
|
|
127
|
+
|
|
128
|
+
- Tickets have an `Acceptance criteria` task list; specs do not. Selection
|
|
129
|
+
requires an unclaimed card in `ready-for-agent` with no incomplete linked
|
|
130
|
+
blocking tasks. Lists have Planka `active` or `closed` types.
|
|
131
|
+
- Claiming uses `in-progress`; completed specs move to `done`.
|
|
132
|
+
- `feature:<slug>` associates specs and tickets. `effort:<slug>` groups wayfinder
|
|
133
|
+
cards; `wayfinder:map` identifies their map.
|
|
134
|
+
- Blocking edges are linked tasks. Planka completes those tasks when their
|
|
135
|
+
linked blocker cards close.
|
|
136
|
+
- Handoff comments contain `Branch: <branch>` and optionally `PR: <url>` on
|
|
137
|
+
separate lines. PR inspection uses an authenticated GitHub `gh` CLI when a
|
|
138
|
+
handoff references a PR; run it in the relevant project checkout.
|
|
139
|
+
- Branches are `feature/<feature>-<title-slug>` or `card/<title-slug>`. The
|
|
140
|
+
historical 55-character limit is preserved, reserving eight characters for
|
|
141
|
+
the Lucenta workflow's `lucenta-` DNS/Compose prefix. Names cut at whole words;
|
|
142
|
+
this extraction does not redesign that convention.
|
|
143
|
+
|
|
144
|
+
Only secret-reference resolution requires `op`. MCP additionally needs `npx`
|
|
145
|
+
and network access for its pinned package. API commands need network access to
|
|
146
|
+
the configured Planka instance; PR-dependent selection needs `gh`.
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
```sh
|
|
151
|
+
bundle install
|
|
152
|
+
make test
|
|
153
|
+
make ci
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Both targets run the standalone Minitest suite. Moved fixtures retain their
|
|
157
|
+
`test/fixtures/files/planka` layout. Tests and their helper are included in the
|
|
158
|
+
gem so Lucenta's remaining workflow tests can share the extracted board fixture.
|
|
159
|
+
Gem artifacts, Bundler state, coverage, and local credential/config files are
|
|
160
|
+
ignored. This README records the standalone extraction and configuration
|
|
161
|
+
cutover; the former hardcoded instance and board constants have been removed.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Prints the branch a card is worked on, e.g.
|
|
3
|
+
# feature/tracer-1.2-an-account-signs-in-the-first-admin. Read-only.
|
|
4
|
+
#
|
|
5
|
+
# bin/planka-branch-name <card> an id or card URL
|
|
6
|
+
#
|
|
7
|
+
# The logic lives in lib/planka/branch_name.rb.
|
|
8
|
+
require_relative "../lib/planka"
|
|
9
|
+
require_relative "../lib/planka/client"
|
|
10
|
+
|
|
11
|
+
abort "usage: planka-branch-name <card>" unless ARGV.size == 1
|
|
12
|
+
|
|
13
|
+
begin
|
|
14
|
+
card_id = Planka.card_id(ARGV.first)
|
|
15
|
+
Planka::Client.resolve_credentials!(__FILE__, ARGV)
|
|
16
|
+
|
|
17
|
+
Planka::Client.session do |client|
|
|
18
|
+
board = Planka::Board.new(client.board(client.card(card_id).dig("item", "boardId")))
|
|
19
|
+
puts Planka::BranchName.for(board.card(card_id))
|
|
20
|
+
end
|
|
21
|
+
rescue Planka::Error, KeyError => e
|
|
22
|
+
abort "planka-branch-name: #{e.message}"
|
|
23
|
+
end
|
data/bin/planka-claim
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Claims a card for the signed-in user: makes it a card member, then moves the
|
|
3
|
+
# card to its board's in-progress list. Safe to re-run.
|
|
4
|
+
#
|
|
5
|
+
# bin/planka-claim <card> an id or card URL
|
|
6
|
+
#
|
|
7
|
+
# The work-next loop runs it as its own user
|
|
8
|
+
# (PLANKA_MCP_CONFIG=workflows/work-next/mcp.json).
|
|
9
|
+
require_relative "../lib/planka"
|
|
10
|
+
require_relative "../lib/planka/client"
|
|
11
|
+
|
|
12
|
+
abort "usage: planka-claim <card>" unless ARGV.size == 1
|
|
13
|
+
|
|
14
|
+
begin
|
|
15
|
+
card_id = Planka.card_id(ARGV.first)
|
|
16
|
+
Planka::Client.resolve_credentials!(__FILE__, ARGV)
|
|
17
|
+
|
|
18
|
+
Planka::Client.session do |client|
|
|
19
|
+
me = client.me.fetch("id")
|
|
20
|
+
included = client.board(client.card(card_id).dig("item", "boardId"))
|
|
21
|
+
in_progress = included.fetch("lists").find { |list| list["name"] == "in-progress" } or raise Planka::Error, "no in-progress list"
|
|
22
|
+
member = included.fetch("cardMemberships").any? { |m| m["cardId"] == card_id && m["userId"] == me }
|
|
23
|
+
|
|
24
|
+
client.add_card_member(card_id, me) unless member
|
|
25
|
+
client.move_card(card_id, in_progress.fetch("id"))
|
|
26
|
+
puts "claimed: #{Planka::Board.new(included).card(card_id)}"
|
|
27
|
+
end
|
|
28
|
+
rescue Planka::Error, KeyError => e
|
|
29
|
+
abort "planka-claim: #{e.message}"
|
|
30
|
+
end
|
data/bin/planka-comment
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Comments on a card as the signed-in user.
|
|
3
|
+
#
|
|
4
|
+
# bin/planka-comment <card> <text> the card as an id or card URL
|
|
5
|
+
#
|
|
6
|
+
# The work-next loop runs it as its own user
|
|
7
|
+
# (PLANKA_MCP_CONFIG=workflows/work-next/mcp.json).
|
|
8
|
+
require_relative "../lib/planka"
|
|
9
|
+
require_relative "../lib/planka/client"
|
|
10
|
+
|
|
11
|
+
abort "usage: planka-comment <card> <text>" unless ARGV.size == 2
|
|
12
|
+
|
|
13
|
+
begin
|
|
14
|
+
card_id = Planka.card_id(ARGV.first)
|
|
15
|
+
Planka::Client.resolve_credentials!(__FILE__, ARGV)
|
|
16
|
+
|
|
17
|
+
Planka::Client.session { |client| client.comment(card_id, ARGV.last) }
|
|
18
|
+
rescue Planka::Error, KeyError => e
|
|
19
|
+
abort "planka-comment: #{e.message}"
|
|
20
|
+
end
|
data/bin/planka-link
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Records blocking edges between Planka cards as linked tasks, which the
|
|
3
|
+
# planka MCP server cannot create.
|
|
4
|
+
#
|
|
5
|
+
# bin/planka-link <blocked-card> <blocker-card> [<blocker-card>...]
|
|
6
|
+
#
|
|
7
|
+
# Cards are ids or card URLs. Each blocker becomes a task linked to that card
|
|
8
|
+
# in a "Blocked by" task list on the blocked card. Re-running is safe: blockers
|
|
9
|
+
# already linked are skipped. The logic lives in lib/planka/blocking.rb.
|
|
10
|
+
require_relative "../lib/planka"
|
|
11
|
+
require_relative "../lib/planka/client"
|
|
12
|
+
|
|
13
|
+
abort "usage: planka-link <blocked-card> <blocker-card> [<blocker-card>...]" if ARGV.size < 2
|
|
14
|
+
|
|
15
|
+
begin
|
|
16
|
+
blocked, *blockers = ARGV.map { |arg| Planka.card_id(arg) }
|
|
17
|
+
Planka::Client.resolve_credentials!(__FILE__, ARGV)
|
|
18
|
+
|
|
19
|
+
Planka::Client.session do |client|
|
|
20
|
+
puts Planka::Blocking.new(client).link(blocked, blockers)
|
|
21
|
+
end
|
|
22
|
+
rescue Planka::Error, KeyError => e
|
|
23
|
+
abort "planka-link: #{e.message}"
|
|
24
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Reports whether the signed-in user holds the work-next loop's lock on
|
|
3
|
+
# Planka: a card on any board it can see that it has claimed, that is still
|
|
4
|
+
# open, and that has no PR handed off yet. Read-only.
|
|
5
|
+
#
|
|
6
|
+
# bin/planka-loop-lock prints "free", or "held: <card>" and then
|
|
7
|
+
# "claimed: <ISO 8601 time>" and "age: <seconds>"
|
|
8
|
+
#
|
|
9
|
+
# Run it as the loop's user (PLANKA_MCP_CONFIG=workflows/work-next/mcp.json).
|
|
10
|
+
# The logic lives in lib/planka/loop_lock.rb.
|
|
11
|
+
require_relative "../lib/planka"
|
|
12
|
+
require_relative "../lib/planka/client"
|
|
13
|
+
|
|
14
|
+
Planka::Client.resolve_credentials!(__FILE__, ARGV)
|
|
15
|
+
|
|
16
|
+
begin
|
|
17
|
+
Planka::Client.session do |client|
|
|
18
|
+
boards = client.board_ids.map { |id| Planka::Board.new(client.board(id)) }
|
|
19
|
+
me = client.me.fetch("id")
|
|
20
|
+
card = Planka::LoopLock.held(boards:, user_id: me, comments: client)
|
|
21
|
+
if card
|
|
22
|
+
claimed = card.claimed_at(me)
|
|
23
|
+
puts "held: #{card}", "claimed: #{claimed.iso8601}", "age: #{(Time.now - claimed).to_i}"
|
|
24
|
+
else
|
|
25
|
+
puts "free"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
rescue Planka::Error, KeyError => e
|
|
29
|
+
abort "planka-loop-lock: #{e.message}"
|
|
30
|
+
end
|
data/bin/planka-mcp
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# RubyGems loads executables as Ruby; keep the shell implementation packaged.
|
|
3
|
+
require_relative "../lib/planka/client"
|
|
4
|
+
|
|
5
|
+
Planka::Client.resolve_credentials!(__FILE__, ARGV)
|
|
6
|
+
exec "bash", File.expand_path("../libexec/planka-mcp", __dir__), *ARGV
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Picks the next card to work on the configured Planka board.
|
|
3
|
+
# Read-only: it never changes the board or git.
|
|
4
|
+
#
|
|
5
|
+
# planka-next-card the top ticket in ready-for-agent
|
|
6
|
+
# planka-next-card <feature-label> e.g. feature:workspaces
|
|
7
|
+
# planka-next-card <effort-label> e.g. effort:search
|
|
8
|
+
#
|
|
9
|
+
# With no label it prints the highest-positioned unclaimed, unblocked ticket
|
|
10
|
+
# in ready-for-agent (the human orders the list by priority), its spec and
|
|
11
|
+
# the parent branch. A feature label prints the next unclaimed, unblocked ticket in
|
|
12
|
+
# ready-for-agent, its spec, its two-digit place among the feature's tickets
|
|
13
|
+
# (nn) and the parent branch to stack it on, or "none:" and what holds each
|
|
14
|
+
# waiting ticket. Neither ever picks a spec card. An effort label prints the wayfinder frontier and its map.
|
|
15
|
+
#
|
|
16
|
+
# The planka MCP server drops card labels and card memberships, so this reads
|
|
17
|
+
# the board from Planka's API instead. The logic lives in lib/planka.
|
|
18
|
+
require_relative "../lib/planka"
|
|
19
|
+
require_relative "../lib/planka/client"
|
|
20
|
+
|
|
21
|
+
abort "usage: planka-next-card [feature-label|effort-label]" if ARGV.size > 1
|
|
22
|
+
Planka::Client.resolve_credentials!(__FILE__, ARGV)
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
Planka::Client.session do |client|
|
|
26
|
+
board = Planka::Board.new(client.board(Planka.board_id))
|
|
27
|
+
puts Planka::NextCard.for(ARGV.first, board:, comments: client, pull_requests: Planka::PullRequest)
|
|
28
|
+
end
|
|
29
|
+
rescue Planka::Error, KeyError => e
|
|
30
|
+
abort "planka-next-card: #{e.message}"
|
|
31
|
+
end
|
data/bin/planka-op
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Moves finished spec cards to done, with a comment: a spec in in-progress
|
|
3
|
+
# whose tickets (the cards sharing its feature label that have acceptance
|
|
4
|
+
# criteria) are all in closed-type lists. Runs on every board the signed-in
|
|
5
|
+
# user can see, and prints each spec it moves.
|
|
6
|
+
#
|
|
7
|
+
# bin/planka-spec-sweep
|
|
8
|
+
#
|
|
9
|
+
# The work-next loop runs it as its own user on every tick
|
|
10
|
+
# (PLANKA_MCP_CONFIG=workflows/work-next/mcp.json). The logic lives in
|
|
11
|
+
# lib/planka/spec_sweep.rb.
|
|
12
|
+
require_relative "../lib/planka"
|
|
13
|
+
require_relative "../lib/planka/client"
|
|
14
|
+
|
|
15
|
+
Planka::Client.resolve_credentials!(__FILE__, ARGV)
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
Planka::Client.session do |client|
|
|
19
|
+
client.board_ids.each do |board_id|
|
|
20
|
+
board = Planka::Board.new(client.board(board_id))
|
|
21
|
+
Planka::SpecSweep.finished(board).each do |spec|
|
|
22
|
+
client.comment(spec.id, "Every ticket for this spec is closed, so the work-next loop's housekeeping moves it to done.")
|
|
23
|
+
client.move_card(spec.id, board.list_id("done"))
|
|
24
|
+
puts "done: #{spec}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
rescue Planka::Error, KeyError => e
|
|
29
|
+
abort "planka-spec-sweep: #{e.message}"
|
|
30
|
+
end
|
data/bin/planka-unticked
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Prints a card's unticked acceptance criteria, one per line. Read-only.
|
|
3
|
+
#
|
|
4
|
+
# bin/planka-unticked <card> an id or card URL
|
|
5
|
+
require_relative "../lib/planka"
|
|
6
|
+
require_relative "../lib/planka/client"
|
|
7
|
+
|
|
8
|
+
abort "usage: planka-unticked <card>" unless ARGV.size == 1
|
|
9
|
+
|
|
10
|
+
begin
|
|
11
|
+
card_id = Planka.card_id(ARGV.first)
|
|
12
|
+
Planka::Client.resolve_credentials!(__FILE__, ARGV)
|
|
13
|
+
|
|
14
|
+
Planka::Client.session do |client|
|
|
15
|
+
board = Planka::Board.new(client.board(client.card(card_id).dig("item", "boardId")))
|
|
16
|
+
puts board.card(card_id).unticked_criteria
|
|
17
|
+
end
|
|
18
|
+
rescue Planka::Error, KeyError => e
|
|
19
|
+
abort "planka-unticked: #{e.message}"
|
|
20
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Planka
|
|
2
|
+
# A card the picked ticket is blocked by, with where its work lives: the
|
|
3
|
+
# handoff comment it left and the state of that PR.
|
|
4
|
+
Blocker = Data.define(:card, :handoff, :pr) do
|
|
5
|
+
# comments answers #comments(card_id); pull_requests answers #find(url).
|
|
6
|
+
def self.for(card, comments:, pull_requests:)
|
|
7
|
+
handoff = Handoff.latest(comments.comments(card.id))
|
|
8
|
+
new(card:, handoff:, pr: handoff&.pr_url && pull_requests.find(handoff.pr_url))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# The branch to stack a ticket on: main once every blocker has merged, the
|
|
12
|
+
# one unmerged blocker's branch, or AMBIGUOUS when that can't be decided.
|
|
13
|
+
def self.parent_branch(blockers)
|
|
14
|
+
unrecorded = blockers.reject(&:recorded?)
|
|
15
|
+
return "AMBIGUOUS: no Branch: comment on #{unrecorded.map { |b| b.card.name }.join(", ")}" if unrecorded.any?
|
|
16
|
+
|
|
17
|
+
unmerged = blockers.reject(&:merged?)
|
|
18
|
+
return "AMBIGUOUS: unmerged blockers #{unmerged.map(&:branch).join(", ")}" if unmerged.size > 1
|
|
19
|
+
|
|
20
|
+
unmerged.first&.branch || "main"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def recorded? = !handoff.nil?
|
|
24
|
+
def merged? = pr&.merged? || false
|
|
25
|
+
def branch = pr&.head || handoff&.branch
|
|
26
|
+
|
|
27
|
+
def to_s
|
|
28
|
+
return "- #{card}: no Branch: comment" unless recorded?
|
|
29
|
+
|
|
30
|
+
"- #{card}: branch #{handoff.branch}, PR #{handoff.pr_url || "none"} (#{pr&.state&.downcase || "unknown"})"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Planka
|
|
2
|
+
# Records that a card is blocked by others, as tasks linked to the blocker
|
|
3
|
+
# cards in a "Blocked by" task list. Planka completes a linked task when its
|
|
4
|
+
# card closes, so the list is fully ticked once the card is unblocked.
|
|
5
|
+
# Linking is idempotent: blockers already linked are skipped.
|
|
6
|
+
class Blocking
|
|
7
|
+
TASK_LIST_NAME = "Blocked by"
|
|
8
|
+
POSITION_GAP = 65_536
|
|
9
|
+
|
|
10
|
+
# client answers #card(id), #create_task_list and #create_task.
|
|
11
|
+
def initialize(client)
|
|
12
|
+
@client = client
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns one line per blocker saying what happened.
|
|
16
|
+
def link(blocked, blockers)
|
|
17
|
+
raise Error, "a card cannot block itself" if blockers.include?(blocked)
|
|
18
|
+
|
|
19
|
+
included = @client.card(blocked).fetch("included")
|
|
20
|
+
task_list = blocked_by_list(blocked, included.fetch("taskLists"))
|
|
21
|
+
linked = included.fetch("tasks").select { |task| task["taskListId"] == task_list["id"] }
|
|
22
|
+
|
|
23
|
+
blockers.uniq.map do |blocker|
|
|
24
|
+
next "already linked: #{blocker}" if linked.any? { |task| task["linkedCardId"] == blocker }
|
|
25
|
+
|
|
26
|
+
task = @client.create_task(task_list["id"], linkedCardId: blocker, position: next_position(linked))
|
|
27
|
+
linked << task
|
|
28
|
+
"linked: #{blocker} (#{task["isCompleted"] ? "closed" : "open"})"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def blocked_by_list(card_id, task_lists)
|
|
35
|
+
task_lists.find { |task_list| task_list["name"] == TASK_LIST_NAME } ||
|
|
36
|
+
@client.create_task_list(card_id, name: TASK_LIST_NAME, position: next_position(task_lists), showOnFrontOfCard: true)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def next_position(records) = (records.map { |record| record["position"].to_f }.max || 0) + POSITION_GAP
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/planka/board.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Planka
|
|
2
|
+
# A board as GET /api/boards/:id returns it. Planka sends each record type
|
|
3
|
+
# as a flat list under "included", joined by id; this indexes them once so
|
|
4
|
+
# a Card can answer questions about itself.
|
|
5
|
+
class Board
|
|
6
|
+
def initialize(included)
|
|
7
|
+
@included = included
|
|
8
|
+
@cards = included.fetch("cards").to_h { |attrs| [ attrs["id"], Card.new(self, attrs) ] }
|
|
9
|
+
@lists = included.fetch("lists").to_h { |list| [ list["id"], list["name"] ] }
|
|
10
|
+
@labels = included.fetch("labels").to_h { |label| [ label["id"], label["name"] ] }
|
|
11
|
+
@task_lists = included.fetch("taskLists").group_by { |task_list| task_list["cardId"] }
|
|
12
|
+
@tasks = included.fetch("tasks").group_by { |task| task["taskListId"] }
|
|
13
|
+
@list_types = included.fetch("lists").to_h { |list| [ list["id"], list["type"] ] }
|
|
14
|
+
@memberships = included.fetch("cardMemberships").group_by { |membership| membership["cardId"] }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def card(id) = @cards.fetch(id)
|
|
18
|
+
|
|
19
|
+
def cards = @cards.values
|
|
20
|
+
|
|
21
|
+
def cards_labelled(name)
|
|
22
|
+
raise Error, "no label #{name}" unless @labels.value?(name)
|
|
23
|
+
|
|
24
|
+
@included.fetch("cardLabels").filter_map { |cl| @cards[cl["cardId"]] if @labels[cl["labelId"]] == name }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def list_name(list_id) = @lists[list_id]
|
|
28
|
+
|
|
29
|
+
def label_names(card_id)
|
|
30
|
+
@included.fetch("cardLabels").filter_map { |cl| @labels[cl["labelId"]] if cl["cardId"] == card_id }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def task_list_names(card_id) = task_lists(card_id).map { |task_list| task_list["name"] }
|
|
34
|
+
|
|
35
|
+
def tasks(card_id) = task_lists(card_id).flat_map { |task_list| @tasks.fetch(task_list["id"], []) }
|
|
36
|
+
|
|
37
|
+
def members(card_id) = memberships(card_id).map { |membership| membership["userId"] }
|
|
38
|
+
|
|
39
|
+
def memberships(card_id) = @memberships.fetch(card_id, [])
|
|
40
|
+
|
|
41
|
+
def tasks_in(card_id, list_name)
|
|
42
|
+
task_lists(card_id).select { |task_list| task_list["name"] == list_name }.flat_map { |task_list| @tasks.fetch(task_list["id"], []) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def list_type(list_id) = @list_types[list_id]
|
|
46
|
+
|
|
47
|
+
def list_id(name)
|
|
48
|
+
@lists.key(name) or raise Error, "no list #{name}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def task_lists(card_id) = @task_lists.fetch(card_id, [])
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Planka
|
|
2
|
+
# The branch a card is worked on: feature/<slug>-<ticket#>-<title> for a
|
|
3
|
+
# card with a feature label, card/<title> without one. It also names the
|
|
4
|
+
# checkout's Compose project and preview host as lucenta-<branch-label>, a
|
|
5
|
+
# DNS label of at most 63 characters, so it is cut on a word to fit.
|
|
6
|
+
module BranchName
|
|
7
|
+
MAX = 63 - "lucenta-".length
|
|
8
|
+
|
|
9
|
+
def self.for(card)
|
|
10
|
+
feature = card.features.first&.delete_prefix("feature:")
|
|
11
|
+
name = feature ? "feature/#{feature}-#{slug(card.name)}" : "card/#{slug(card.name)}"
|
|
12
|
+
fit(name)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.slug(text) = text.downcase.gsub(/[^a-z0-9.]+/, "-").gsub(/\A-|-\z/, "")
|
|
16
|
+
|
|
17
|
+
def self.fit(name)
|
|
18
|
+
return name if name.length <= MAX
|
|
19
|
+
|
|
20
|
+
cut = name[0, MAX]
|
|
21
|
+
cut = cut[0, cut.rindex("-")] unless name[MAX] == "-" || cut.end_with?("-")
|
|
22
|
+
cut.chomp("-")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/planka/card.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Planka
|
|
2
|
+
class Card
|
|
3
|
+
READY_LIST = "ready-for-agent"
|
|
4
|
+
IN_PROGRESS_LIST = "in-progress"
|
|
5
|
+
CRITERIA_LIST = "Acceptance criteria"
|
|
6
|
+
|
|
7
|
+
attr_reader :id, :name, :position, :created_at
|
|
8
|
+
|
|
9
|
+
def initialize(board, attrs)
|
|
10
|
+
@board = board
|
|
11
|
+
@id = attrs.fetch("id")
|
|
12
|
+
@name = attrs.fetch("name")
|
|
13
|
+
@list_id = attrs.fetch("listId")
|
|
14
|
+
@position = attrs.fetch("position")
|
|
15
|
+
@created_at = attrs.fetch("createdAt")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def ready? = @board.list_name(@list_id) == READY_LIST
|
|
19
|
+
def claimed? = @board.members(id).any?
|
|
20
|
+
def claimed_by?(user_id) = @board.members(id).include?(user_id)
|
|
21
|
+
|
|
22
|
+
def claimed_at(user_id)
|
|
23
|
+
membership = @board.memberships(id).find { |m| m["userId"] == user_id }
|
|
24
|
+
membership && Time.iso8601(membership.fetch("createdAt"))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def unticked_criteria = @board.tasks_in(id, CRITERIA_LIST).reject { |task| task["isCompleted"] }.map { |task| task["name"] }
|
|
28
|
+
def open? = @board.list_type(@list_id) == "active"
|
|
29
|
+
def closed? = @board.list_type(@list_id) == "closed"
|
|
30
|
+
def in_progress? = @board.list_name(@list_id) == IN_PROGRESS_LIST
|
|
31
|
+
def ticket? = @board.task_list_names(id).include?(CRITERIA_LIST)
|
|
32
|
+
def labelled?(name) = @board.label_names(id).include?(name)
|
|
33
|
+
def features = @board.label_names(id).select { |name| name.start_with?("feature:") }
|
|
34
|
+
def takeable? = ready? && !claimed? && open_blockers.empty?
|
|
35
|
+
|
|
36
|
+
# Blocking is a task linked to the blocker card; Planka completes it when
|
|
37
|
+
# the blocker closes. Linked tasks are used for nothing else.
|
|
38
|
+
def blockers = blocking_tasks.map { |task| @board.card(task["linkedCardId"]) }
|
|
39
|
+
def open_blockers = blocking_tasks.reject { |task| task["isCompleted"] }.map { |task| @board.card(task["linkedCardId"]) }
|
|
40
|
+
|
|
41
|
+
def url = Planka.card_url(id)
|
|
42
|
+
def to_s = "#{name} (#{url})"
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def blocking_tasks = @board.tasks(id).select { |task| task["linkedCardId"] }
|
|
47
|
+
end
|
|
48
|
+
end
|