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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
require_relative "../planka"
|
|
2
|
+
require "rbconfig"
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Planka
|
|
8
|
+
# A signed-in session against Planka's REST API as the board's bot user.
|
|
9
|
+
class Client
|
|
10
|
+
# Configuration belongs to the calling checkout, not the installed gem.
|
|
11
|
+
# Explicit environment values take precedence over its MCP settings.
|
|
12
|
+
def self.configure!
|
|
13
|
+
path = File.expand_path(ENV.fetch("PLANKA_MCP_CONFIG", ".mcp.json"))
|
|
14
|
+
if ENV.key?("PLANKA_MCP_CONFIG") || File.file?(path)
|
|
15
|
+
config = JSON.parse(File.read(path))
|
|
16
|
+
config.fetch("mcpServers").fetch("planka").fetch("env").each do |key, value|
|
|
17
|
+
ENV[key] = value unless ENV.key?(key)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Plain credentials need neither 1Password nor an MCP config. Secret
|
|
23
|
+
# references re-run the command through the gem's packaged shell wrapper.
|
|
24
|
+
def self.resolve_credentials!(script, argv)
|
|
25
|
+
configure!
|
|
26
|
+
%w[PLANKA_BASE_URL PLANKA_AGENT_EMAIL PLANKA_AGENT_PASSWORD].each { |key| ENV.fetch(key) }
|
|
27
|
+
return if ENV["PLANKA_CREDENTIALS_RESOLVED"]
|
|
28
|
+
return unless ENV.any? { |key, value| key.start_with?("PLANKA_") && value.start_with?("op://") }
|
|
29
|
+
|
|
30
|
+
wrapper = File.expand_path("../../bin/planka-op", __dir__)
|
|
31
|
+
exec({ "PLANKA_CREDENTIALS_RESOLVED" => "1" }, RbConfig.ruby, wrapper,
|
|
32
|
+
RbConfig.ruby, File.expand_path(script), *argv)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.session
|
|
36
|
+
client = new(ENV.fetch("PLANKA_BASE_URL"))
|
|
37
|
+
client.sign_in(ENV.fetch("PLANKA_AGENT_EMAIL"), ENV.fetch("PLANKA_AGENT_PASSWORD"))
|
|
38
|
+
yield client
|
|
39
|
+
ensure
|
|
40
|
+
client&.sign_out
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(base_url)
|
|
44
|
+
@base = URI(base_url)
|
|
45
|
+
@token = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def sign_in(email, password)
|
|
49
|
+
@token = request(:post, "/api/access-tokens", emailOrUsername: email, password: password).fetch("item")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def sign_out
|
|
53
|
+
request(:delete, "/api/access-tokens/me") if @token
|
|
54
|
+
rescue Error
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def board(id) = request(:get, "/api/boards/#{id}").fetch("included")
|
|
59
|
+
|
|
60
|
+
def comments(card_id) = request(:get, "/api/cards/#{card_id}/comments").fetch("items")
|
|
61
|
+
|
|
62
|
+
def card(id) = request(:get, "/api/cards/#{id}")
|
|
63
|
+
|
|
64
|
+
def create_task_list(card_id, **attrs) = request(:post, "/api/cards/#{card_id}/task-lists", attrs).fetch("item")
|
|
65
|
+
|
|
66
|
+
def create_task(task_list_id, **attrs) = request(:post, "/api/task-lists/#{task_list_id}/tasks", attrs).fetch("item")
|
|
67
|
+
|
|
68
|
+
def me = request(:get, "/api/users/me").fetch("item")
|
|
69
|
+
|
|
70
|
+
# Every board the signed-in user can see, across projects.
|
|
71
|
+
def board_ids = request(:get, "/api/projects").dig("included", "boards").map { |board| board["id"] }
|
|
72
|
+
|
|
73
|
+
def add_card_member(card_id, user_id) = request(:post, "/api/cards/#{card_id}/card-memberships", userId: user_id)
|
|
74
|
+
|
|
75
|
+
def move_card(card_id, list_id, position: 65_535) = request(:patch, "/api/cards/#{card_id}", listId: list_id, position:)
|
|
76
|
+
|
|
77
|
+
def comment(card_id, text) = request(:post, "/api/cards/#{card_id}/comments", text:)
|
|
78
|
+
|
|
79
|
+
ServerError = Class.new(Error)
|
|
80
|
+
|
|
81
|
+
# Planka and its proxy fail now and then with a 5xx or a dropped
|
|
82
|
+
# connection, so a request gets three tries, one and then two seconds apart.
|
|
83
|
+
ATTEMPTS = 3
|
|
84
|
+
TRANSIENT = [ ServerError, Errno::ECONNREFUSED, Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, EOFError, SocketError ].freeze
|
|
85
|
+
|
|
86
|
+
def request(method, path, body = nil)
|
|
87
|
+
attempt = 0
|
|
88
|
+
begin
|
|
89
|
+
attempt += 1
|
|
90
|
+
send_request(method, path, body)
|
|
91
|
+
rescue *TRANSIENT => e
|
|
92
|
+
raise if attempt == ATTEMPTS
|
|
93
|
+
|
|
94
|
+
warn "planka: #{method.upcase} #{path} failed (#{e.class}), retrying"
|
|
95
|
+
sleep attempt
|
|
96
|
+
retry
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def send_request(method, path, body)
|
|
103
|
+
http = Net::HTTP.new(@base.host, @base.port)
|
|
104
|
+
http.use_ssl = @base.scheme == "https"
|
|
105
|
+
req = Net::HTTP.const_get(method.capitalize).new(path)
|
|
106
|
+
req["Authorization"] = "Bearer #{@token}" if @token
|
|
107
|
+
if body
|
|
108
|
+
req["Content-Type"] = "application/json"
|
|
109
|
+
req.body = JSON.generate(body)
|
|
110
|
+
end
|
|
111
|
+
res = http.request(req)
|
|
112
|
+
raise ServerError, "#{method.upcase} #{path}: #{res.code} #{res.body}" if res.is_a?(Net::HTTPServerError)
|
|
113
|
+
raise Error, "#{method.upcase} #{path}: #{res.code} #{res.body}" unless res.is_a?(Net::HTTPSuccess)
|
|
114
|
+
|
|
115
|
+
JSON.parse(res.body)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Planka
|
|
2
|
+
# The "Branch:" / "PR:" comment an /implement session leaves on its card, so
|
|
3
|
+
# the tickets it blocks know which branch to stack on.
|
|
4
|
+
Handoff = Data.define(:branch, :pr_url) do
|
|
5
|
+
def self.parse(text)
|
|
6
|
+
branch = text[/^Branch:\s*`?([^`\s]+)`?\s*$/, 1] or return
|
|
7
|
+
new(branch:, pr_url: text[%r{^PR:\s*<?(https?://[^\s>]+?)>?[.,;]?\s*$}, 1])
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.latest(comments)
|
|
11
|
+
comments.sort_by { |comment| comment["createdAt"] }.reverse_each.lazy.filter_map { |comment| parse(comment["text"]) }.first
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Planka
|
|
2
|
+
# The Planka half of the work-next loop's global lock: a card the loop's
|
|
3
|
+
# user has claimed that is still open and has no PR handed off yet. The
|
|
4
|
+
# other half, an open agent-loop PR, is GitHub's to answer.
|
|
5
|
+
#
|
|
6
|
+
# comments answers #comments(card_id) with Planka's comment records.
|
|
7
|
+
module LoopLock
|
|
8
|
+
def self.held(boards:, user_id:, comments:)
|
|
9
|
+
boards.lazy.flat_map(&:cards).find do |card|
|
|
10
|
+
card.claimed_by?(user_id) && card.open? && !Handoff.latest(comments.comments(card.id))&.pr_url
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module Planka
|
|
2
|
+
# Picks the next card to work. With no label it picks the top ticket in
|
|
3
|
+
# ready-for-agent, whose order the human sets as priority. A feature label
|
|
4
|
+
# (feature:<slug>) picks the feature's next ticket in creation order and the
|
|
5
|
+
# branch to stack it on; an effort label (effort:<slug>) runs the wayfinder
|
|
6
|
+
# frontier query. Spec cards, which carry no Acceptance criteria list, are
|
|
7
|
+
# never picked.
|
|
8
|
+
#
|
|
9
|
+
# comments answers #comments(card_id) with Planka's comment records;
|
|
10
|
+
# pull_requests answers #find(url) with a PullRequest or nil.
|
|
11
|
+
module NextCard
|
|
12
|
+
def self.for(label, board:, comments:, pull_requests:)
|
|
13
|
+
if label.nil?
|
|
14
|
+
PriorityTicket.new(board, comments:, pull_requests:)
|
|
15
|
+
elsif label.start_with?("effort:")
|
|
16
|
+
Frontier.new(board.cards_labelled(label))
|
|
17
|
+
else
|
|
18
|
+
FeatureTicket.new(board.cards_labelled(label), comments:, pull_requests:)
|
|
19
|
+
end.report
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Tickets go in creation order, the order /to-tickets publishes them in;
|
|
23
|
+
# card positions don't follow it.
|
|
24
|
+
class FeatureTicket
|
|
25
|
+
def initialize(cards, comments:, pull_requests:)
|
|
26
|
+
@spec, @tickets = cards.partition { |card| !card.ticket? }
|
|
27
|
+
@tickets.sort_by!(&:created_at)
|
|
28
|
+
@comments = comments
|
|
29
|
+
@pull_requests = pull_requests
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def report
|
|
33
|
+
pick = @tickets.find(&:takeable?) or return Waiting.new(@tickets.select(&:ready?))
|
|
34
|
+
|
|
35
|
+
Pick.new(card: pick, spec: @spec, nn: @tickets.index(pick) + 1, blockers: pick.blockers.map { |card| blocker(card) })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def blocker(card) = Blocker.for(card, comments: @comments, pull_requests: @pull_requests)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The top takeable ticket in ready-for-agent by position: the human orders
|
|
44
|
+
# the list, top to bottom, by priority.
|
|
45
|
+
class PriorityTicket
|
|
46
|
+
def initialize(board, comments:, pull_requests:)
|
|
47
|
+
@board = board
|
|
48
|
+
@tickets = board.cards.select { |card| card.ticket? && card.ready? }.sort_by(&:position)
|
|
49
|
+
@comments = comments
|
|
50
|
+
@pull_requests = pull_requests
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def report
|
|
54
|
+
pick = @tickets.find(&:takeable?) or return Waiting.new(@tickets)
|
|
55
|
+
|
|
56
|
+
Pick.new(card: pick, spec: specs(pick), nn: nil, blockers: pick.blockers.map { |card| blocker(card) })
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def specs(ticket) = ticket.features.flat_map { |label| @board.cards_labelled(label).reject(&:ticket?) }
|
|
62
|
+
|
|
63
|
+
def blocker(card) = Blocker.for(card, comments: @comments, pull_requests: @pull_requests)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Pick = Data.define(:card, :spec, :nn, :blockers) do
|
|
67
|
+
def to_s
|
|
68
|
+
[
|
|
69
|
+
"card: #{card}",
|
|
70
|
+
"spec: #{spec.empty? ? "none" : spec.join(", ")}",
|
|
71
|
+
*(format("nn: %02d", nn) if nn),
|
|
72
|
+
"blockers:#{" none" if blockers.empty?}",
|
|
73
|
+
*blockers,
|
|
74
|
+
"parent: #{Blocker.parent_branch(blockers)}"
|
|
75
|
+
].join("\n")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Waiting = Data.define(:cards) do
|
|
80
|
+
def to_s = [ "none:", *cards.map { |card| "- #{card}: #{holds(card)}" } ].join("\n")
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def holds(card)
|
|
85
|
+
blocked_by = card.open_blockers.map(&:name)
|
|
86
|
+
[ ("claimed" if card.claimed?), ("blocked by #{blocked_by.join(", ")}" if blocked_by.any?) ].compact.join("; ")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The wayfinder frontier, lowest position first, as the tracker doc orders it.
|
|
91
|
+
class Frontier
|
|
92
|
+
MAP_LABEL = "wayfinder:map"
|
|
93
|
+
|
|
94
|
+
def initialize(cards)
|
|
95
|
+
@maps, children = cards.partition { |card| card.labelled?(MAP_LABEL) }
|
|
96
|
+
@frontier = children.select(&:takeable?).sort_by(&:position)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def report = FrontierReport.new(maps: @maps, frontier: @frontier)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
FrontierReport = Data.define(:maps, :frontier) do
|
|
103
|
+
def to_s
|
|
104
|
+
[
|
|
105
|
+
"card: #{frontier.first || "none"}",
|
|
106
|
+
"map: #{maps.empty? ? "none" : maps.join(", ")}",
|
|
107
|
+
"frontier:#{" none" if frontier.empty?}",
|
|
108
|
+
*frontier.map { |card| "- #{card}" }
|
|
109
|
+
].join("\n")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "open3"
|
|
3
|
+
|
|
4
|
+
module Planka
|
|
5
|
+
PullRequest = Data.define(:state, :head) do
|
|
6
|
+
# Looks a PR up with the gh CLI; nil when gh can't find it.
|
|
7
|
+
def self.find(url)
|
|
8
|
+
out, status = Open3.capture2("gh", "pr", "view", url, "--json", "state,headRefName")
|
|
9
|
+
return unless status.success?
|
|
10
|
+
|
|
11
|
+
json = JSON.parse(out)
|
|
12
|
+
new(state: json.fetch("state"), head: json.fetch("headRefName"))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def merged? = state == "MERGED"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Planka
|
|
2
|
+
# Spec cards whose work is finished: in in-progress, with at least one
|
|
3
|
+
# ticket sharing their feature label, and every such ticket in a
|
|
4
|
+
# closed-type list. The work-next loop moves them to done.
|
|
5
|
+
module SpecSweep
|
|
6
|
+
def self.finished(board)
|
|
7
|
+
board.cards.select do |card|
|
|
8
|
+
next false if card.ticket? || !card.in_progress?
|
|
9
|
+
|
|
10
|
+
tickets = card.features.flat_map { |label| board.cards_labelled(label) }.select(&:ticket?)
|
|
11
|
+
tickets.any? && tickets.all?(&:closed?)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/planka.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
# Planka board tooling shared by the planka-* commands and workflow callers.
|
|
4
|
+
module Planka
|
|
5
|
+
Error = Class.new(StandardError)
|
|
6
|
+
|
|
7
|
+
def self.board_id = ENV.fetch("PLANKA_BOARD_ID")
|
|
8
|
+
def self.card_url(id) = "#{ENV.fetch("PLANKA_BASE_URL").delete_suffix("/")}/cards/#{id}"
|
|
9
|
+
|
|
10
|
+
# A card id, given as an id or a card URL.
|
|
11
|
+
def self.card_id(arg)
|
|
12
|
+
arg[/(\d+)\/?\z/, 1] or raise Error, "not a card id or URL: #{arg}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
require_relative "planka/board"
|
|
17
|
+
require_relative "planka/card"
|
|
18
|
+
require_relative "planka/handoff"
|
|
19
|
+
require_relative "planka/pull_request"
|
|
20
|
+
require_relative "planka/blocker"
|
|
21
|
+
require_relative "planka/next_card"
|
|
22
|
+
require_relative "planka/branch_name"
|
|
23
|
+
require_relative "planka/loop_lock"
|
|
24
|
+
require_relative "planka/spec_sweep"
|
|
25
|
+
require_relative "planka/blocking"
|
data/libexec/planka-mcp
ADDED
data/libexec/planka-op
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Runs a command with the op:// references in its environment resolved by a
|
|
3
|
+
# 1Password service account, so no interactive unlock is needed.
|
|
4
|
+
#
|
|
5
|
+
# planka-op <command> [args...]
|
|
6
|
+
#
|
|
7
|
+
# Uses an existing OP_SERVICE_ACCOUNT_TOKEN, or sources $OP_ENV_FILE or the
|
|
8
|
+
# calling directory's .env.op when no token has been exported.
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
if [ "$#" -eq 0 ]; then
|
|
12
|
+
echo "usage: planka-op <command> [args...]" >&2
|
|
13
|
+
exit 64
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
if [ -z "${OP_SERVICE_ACCOUNT_TOKEN:-}" ]; then
|
|
17
|
+
env_file="${OP_ENV_FILE:-$PWD/.env.op}"
|
|
18
|
+
if [ ! -f "$env_file" ]; then
|
|
19
|
+
echo "planka-op: no OP_SERVICE_ACCOUNT_TOKEN (set it, set OP_ENV_FILE, or create .env.op)" >&2
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
set -a
|
|
24
|
+
# shellcheck disable=SC1090
|
|
25
|
+
. "$env_file"
|
|
26
|
+
set +a
|
|
27
|
+
|
|
28
|
+
if [ -z "${OP_SERVICE_ACCOUNT_TOKEN:-}" ]; then
|
|
29
|
+
echo "planka-op: $env_file did not set OP_SERVICE_ACCOUNT_TOKEN" >&2
|
|
30
|
+
exit 1
|
|
31
|
+
fi
|
|
32
|
+
fi
|
|
33
|
+
export OP_SERVICE_ACCOUNT_TOKEN
|
|
34
|
+
|
|
35
|
+
# op looks under $HOME for the 1Password app's settings in its group container,
|
|
36
|
+
# even with a service account and OP_BIOMETRIC_UNLOCK_ENABLED=false. Under
|
|
37
|
+
# launchd that read makes macOS ask whether the launched process ("ruby") may
|
|
38
|
+
# access data from other apps. So op gets a home of its own, with no app in
|
|
39
|
+
# it, and the command it runs gets the real one back.
|
|
40
|
+
real_home="$HOME"
|
|
41
|
+
op_home="${XDG_STATE_HOME:-$real_home/.local/state}/planka-cli/op-home"
|
|
42
|
+
mkdir -p "$op_home"
|
|
43
|
+
|
|
44
|
+
# --no-masking: op run otherwise rewrites secret values in stdout, which
|
|
45
|
+
# corrupts the MCP JSON-RPC stream and any JSON a script prints.
|
|
46
|
+
HOME="$op_home" exec op run --no-masking -- env HOME="$real_home" "$@"
|