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.
@@ -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
@@ -0,0 +1,3 @@
1
+ module Planka
2
+ VERSION = "0.1.0"
3
+ 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"
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ # Launches the Planka MCP server after the Ruby launcher resolves credentials.
3
+ set -euo pipefail
4
+
5
+ exec npx -y @navyatec/planka-v2-mcp@1.3.4
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" "$@"