planka-cli 0.1.0 → 0.1.1
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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +62 -137
- data/exe/planka +13 -0
- data/{bin → exe}/planka-branch-name +3 -4
- data/{bin → exe}/planka-claim +4 -6
- data/{bin → exe}/planka-comment +4 -6
- data/{bin → exe}/planka-link +3 -4
- data/{bin → exe}/planka-loop-lock +4 -5
- data/{bin → exe}/planka-next-card +3 -4
- data/{bin → exe}/planka-spec-sweep +5 -8
- data/{bin → exe}/planka-unticked +3 -4
- data/lib/planka/board.rb +4 -1
- data/lib/planka/branch_name.rb +10 -8
- data/lib/planka/card.rb +1 -1
- data/lib/planka/client.rb +0 -27
- data/lib/planka/version.rb +1 -1
- data/lib/planka-cli.rb +2 -0
- data/lib/planka.rb +3 -3
- metadata +22 -71
- data/.ruby-version +0 -1
- data/Makefile +0 -6
- data/bin/planka-mcp +0 -6
- data/bin/planka-op +0 -3
- data/libexec/planka-mcp +0 -5
- data/libexec/planka-op +0 -46
- data/test/fixtures/files/planka/board.json +0 -693
- data/test/lib/planka/blocker_test.rb +0 -51
- data/test/lib/planka/blocking_test.rb +0 -76
- data/test/lib/planka/board_test.rb +0 -77
- data/test/lib/planka/branch_name_test.rb +0 -36
- data/test/lib/planka/configuration_test.rb +0 -83
- data/test/lib/planka/handoff_test.rb +0 -45
- data/test/lib/planka/loop_lock_test.rb +0 -49
- data/test/lib/planka/next_card_test.rb +0 -158
- data/test/lib/planka/planka_test_helper.rb +0 -63
- data/test/lib/planka/spec_sweep_test.rb +0 -35
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
require_relative "planka_test_helper"
|
|
2
|
-
|
|
3
|
-
class Planka::BlockerTest < Minitest::Test
|
|
4
|
-
Card = Struct.new(:name) do
|
|
5
|
-
def to_s = "#{name} (https://planka.example/cards/1)"
|
|
6
|
-
end
|
|
7
|
-
|
|
8
|
-
def blocker(name, branch: nil, state: nil, head: branch)
|
|
9
|
-
handoff = Planka::Handoff.new(branch:, pr_url: state && "https://github.com/x/y/pull/#{name}") if branch
|
|
10
|
-
pr = Planka::PullRequest.new(state:, head:) if state
|
|
11
|
-
Planka::Blocker.new(card: Card.new(name), handoff:, pr:)
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def parent(*blockers) = Planka::Blocker.parent_branch(blockers)
|
|
15
|
-
|
|
16
|
-
def test_no_blockers_stack_on_main
|
|
17
|
-
assert_equal "main", parent
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def test_merged_blockers_stack_on_main
|
|
21
|
-
assert_equal "main", parent(blocker("1", branch: "plan-3/01", state: "MERGED"))
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def test_one_open_blocker_stacks_on_its_branch
|
|
25
|
-
assert_equal "plan-3/05", parent(blocker("4", branch: "plan-3/04", state: "MERGED"), blocker("5", branch: "plan-3/05", state: "OPEN"))
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def test_the_pr_head_wins_over_the_recorded_branch
|
|
29
|
-
assert_equal "plan-3/05-renamed", parent(blocker("5", branch: "plan-3/05", state: "OPEN", head: "plan-3/05-renamed"))
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def test_a_blocker_without_a_pr_counts_as_unmerged
|
|
33
|
-
assert_equal "plan-3/05", parent(blocker("5", branch: "plan-3/05"))
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def test_two_open_blockers_are_ambiguous
|
|
37
|
-
result = parent(blocker("5", branch: "plan-3/05", state: "OPEN"), blocker("6", branch: "plan-3/06", state: "OPEN"))
|
|
38
|
-
|
|
39
|
-
assert_equal "AMBIGUOUS: unmerged blockers plan-3/05, plan-3/06", result
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def test_a_blocker_without_a_handoff_comment_is_ambiguous
|
|
43
|
-
assert_equal "AMBIGUOUS: no Branch: comment on 5", parent(blocker("5"))
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def test_describes_itself_for_the_report
|
|
47
|
-
assert_equal "- 5 (https://planka.example/cards/1): branch plan-3/05, PR https://github.com/x/y/pull/5 (open)",
|
|
48
|
-
blocker("5", branch: "plan-3/05", state: "OPEN").to_s
|
|
49
|
-
assert_equal "- 5 (https://planka.example/cards/1): no Branch: comment", blocker("5").to_s
|
|
50
|
-
end
|
|
51
|
-
end
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
require_relative "planka_test_helper"
|
|
2
|
-
|
|
3
|
-
class Planka::BlockingTest < Minitest::Test
|
|
4
|
-
# Stands in for Planka::Client: serves one card's task lists and tasks, and
|
|
5
|
-
# records the writes Blocking asks for.
|
|
6
|
-
class FakeClient
|
|
7
|
-
attr_reader :writes
|
|
8
|
-
|
|
9
|
-
def initialize(task_lists: [], tasks: [])
|
|
10
|
-
@task_lists = task_lists
|
|
11
|
-
@tasks = tasks
|
|
12
|
-
@writes = []
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def card(_id) = { "included" => { "taskLists" => @task_lists, "tasks" => @tasks } }
|
|
16
|
-
|
|
17
|
-
def create_task_list(card_id, **attrs)
|
|
18
|
-
@writes << [ :create_task_list, card_id, attrs ]
|
|
19
|
-
{ "id" => "new-list", "name" => attrs[:name], "position" => attrs[:position] }
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def create_task(task_list_id, **attrs)
|
|
23
|
-
@writes << [ :create_task, task_list_id, attrs ]
|
|
24
|
-
{ "id" => "task-#{@writes.size}", "isCompleted" => attrs[:linkedCardId] == "closed-card" }.merge(attrs.transform_keys(&:to_s))
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def link(client, blockers) = Planka::Blocking.new(client).link("10", blockers)
|
|
29
|
-
|
|
30
|
-
def test_creates_the_blocked_by_list_after_existing_lists
|
|
31
|
-
client = FakeClient.new(task_lists: [ { "id" => "ac", "name" => "Acceptance criteria", "position" => 65_536 } ])
|
|
32
|
-
|
|
33
|
-
link(client, [ "1" ])
|
|
34
|
-
|
|
35
|
-
assert_equal [ :create_task_list, "10", { name: "Blocked by", position: 131_072, showOnFrontOfCard: true } ], client.writes.first
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def test_links_each_blocker_once_in_order
|
|
39
|
-
client = FakeClient.new
|
|
40
|
-
|
|
41
|
-
assert_equal [ "linked: 1 (open)", "linked: 2 (open)" ], link(client, [ "1", "2", "1" ])
|
|
42
|
-
assert_equal [ [ :create_task, "new-list", { linkedCardId: "1", position: 65_536 } ],
|
|
43
|
-
[ :create_task, "new-list", { linkedCardId: "2", position: 131_072 } ] ], client.writes.drop(1)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def test_reuses_the_existing_list_and_skips_blockers_already_linked
|
|
47
|
-
client = FakeClient.new(
|
|
48
|
-
task_lists: [ { "id" => "bb", "name" => "Blocked by", "position" => 65_536 } ],
|
|
49
|
-
tasks: [ { "id" => "t1", "taskListId" => "bb", "linkedCardId" => "1", "position" => 65_536 } ]
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
assert_equal [ "already linked: 1", "linked: 2 (open)" ], link(client, [ "1", "2" ])
|
|
53
|
-
assert_equal [ [ :create_task, "bb", { linkedCardId: "2", position: 131_072 } ] ], client.writes
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def test_reports_a_blocker_that_is_already_closed
|
|
57
|
-
assert_equal [ "linked: closed-card (closed)" ], link(FakeClient.new, [ "closed-card" ])
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def test_a_card_cannot_block_itself
|
|
61
|
-
error = assert_raises(Planka::Error) { link(FakeClient.new, [ "10" ]) }
|
|
62
|
-
|
|
63
|
-
assert_equal "a card cannot block itself", error.message
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def test_card_id_accepts_ids_and_card_urls
|
|
67
|
-
assert_equal "1870510968568546410", Planka.card_id("1870510968568546410")
|
|
68
|
-
assert_equal "1870510968568546410", Planka.card_id("http://planka.test:3000/cards/1870510968568546410/")
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def test_card_id_rejects_anything_else
|
|
72
|
-
error = assert_raises(Planka::Error) { Planka.card_id("Contract edits") }
|
|
73
|
-
|
|
74
|
-
assert_equal "not a card id or URL: Contract edits", error.message
|
|
75
|
-
end
|
|
76
|
-
end
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
require_relative "planka_test_helper"
|
|
2
|
-
|
|
3
|
-
class Planka::BoardTest < Minitest::Test
|
|
4
|
-
include PlankaTestHelper
|
|
5
|
-
|
|
6
|
-
def test_cards_labelled_returns_the_cards_carrying_the_label
|
|
7
|
-
names = board.cards_labelled("feature:workspaces").map(&:name)
|
|
8
|
-
|
|
9
|
-
assert_equal 10, names.size
|
|
10
|
-
assert_includes names, "Spec: Workspaces, membership and roles (Plan 3)"
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def test_cards_labelled_raises_for_an_unknown_label
|
|
14
|
-
error = assert_raises(Planka::Error) { board.cards_labelled("feature:nope") }
|
|
15
|
-
|
|
16
|
-
assert_equal "no label feature:nope", error.message
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def test_a_ticket_has_acceptance_criteria_and_the_spec_does_not
|
|
20
|
-
assert board.card(card_id("Contract edits")).ticket?
|
|
21
|
-
refute board.card(card_id("Spec:")).ticket?
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def test_the_first_ticket_is_takeable
|
|
25
|
-
assert board.card(card_id("Contract edits")).takeable?
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def test_a_ticket_with_an_open_blocker_is_not_takeable
|
|
29
|
-
card = board.card(card_id("Create a workspace"))
|
|
30
|
-
|
|
31
|
-
refute card.takeable?
|
|
32
|
-
assert_equal [ "Contract edits" ], card.open_blockers.map { |b| b.name[0, 14] }
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def test_closing_the_blocker_makes_the_ticket_takeable
|
|
36
|
-
close(card_id("Contract edits"))
|
|
37
|
-
|
|
38
|
-
assert board.card(card_id("Create a workspace")).takeable?
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def test_a_claimed_card_is_not_takeable
|
|
42
|
-
claim(card_id("Contract edits"))
|
|
43
|
-
card = board.card(card_id("Contract edits"))
|
|
44
|
-
|
|
45
|
-
assert card.claimed?
|
|
46
|
-
refute card.takeable?
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def test_a_card_outside_ready_for_agent_is_not_takeable
|
|
50
|
-
move(card_id("Contract edits"), "in-progress")
|
|
51
|
-
|
|
52
|
-
refute board.card(card_id("Contract edits")).takeable?
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def test_blockers_lists_every_linked_card_whether_open_or_closed
|
|
56
|
-
id = card_id("Adding members")
|
|
57
|
-
close(card_id("Documents inside"))
|
|
58
|
-
|
|
59
|
-
assert_equal [ card_id("Documents inside") ], board.card(id).blockers.map(&:id)
|
|
60
|
-
assert_empty board.card(id).open_blockers
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def test_a_card_renders_as_its_name_and_link
|
|
64
|
-
assert_equal "Transferring ownership (http://planka.test:3000/cards/#{card_id("Transferring")})",
|
|
65
|
-
board.card(card_id("Transferring")).to_s
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def test_unticked_criteria_are_the_open_tasks_in_the_acceptance_criteria_list
|
|
69
|
-
card = card_id("Contract edits")
|
|
70
|
-
criteria = payload["taskLists"].find { |tl| tl["cardId"] == card && tl["name"] == "Acceptance criteria" }.fetch("id")
|
|
71
|
-
tasks = payload["tasks"].select { |t| t["taskListId"] == criteria }
|
|
72
|
-
tasks.first["isCompleted"] = true
|
|
73
|
-
|
|
74
|
-
assert_equal tasks.drop(1).map { |t| t["name"] }, board.card(card).unticked_criteria
|
|
75
|
-
refute_empty board.card(card).unticked_criteria
|
|
76
|
-
end
|
|
77
|
-
end
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
require_relative "planka_test_helper"
|
|
2
|
-
|
|
3
|
-
class Planka::BranchNameTest < Minitest::Test
|
|
4
|
-
include PlankaTestHelper
|
|
5
|
-
|
|
6
|
-
def branch(name, features: [])
|
|
7
|
-
card = Struct.new(:name, :features).new(name, features)
|
|
8
|
-
Planka::BranchName.for(card)
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def test_a_feature_ticket_names_its_feature_number_and_title
|
|
12
|
-
assert_equal "feature/tracer-1.1-a-monitor-sees-lucenta-is-up",
|
|
13
|
-
branch("1.1 A monitor sees lucenta is up", features: [ "feature:tracer" ])
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def test_punctuation_becomes_single_hyphens
|
|
17
|
-
assert_equal "feature/work-next-w2-the-top-of-ready-for-agent-is-the",
|
|
18
|
-
branch("W2 The top of ready-for-agent is the priority!", features: [ "feature:work-next" ])
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def test_a_card_without_a_feature_is_named_by_its_title
|
|
22
|
-
assert_equal "card/fix-the-login-redirect", branch("Fix the login redirect")
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def test_the_name_fits_a_dns_label_after_the_lucenta_prefix_and_ends_on_a_whole_word
|
|
26
|
-
name = branch("1.2 An account signs in; the first admin exists", features: [ "feature:tracer" ])
|
|
27
|
-
|
|
28
|
-
assert_equal "feature/tracer-1.2-an-account-signs-in-the-first-admin", name
|
|
29
|
-
assert_operator "lucenta-#{name}".length, :<=, 63
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def test_a_fixture_card_takes_its_feature_from_its_labels
|
|
33
|
-
assert_equal "feature/workspaces-contract-edits-by-direct-request",
|
|
34
|
-
Planka::BranchName.for(board.card(card_id("Contract edits")))
|
|
35
|
-
end
|
|
36
|
-
end
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
require_relative "planka_test_helper"
|
|
2
|
-
require_relative "../../../lib/planka/client"
|
|
3
|
-
require "tmpdir"
|
|
4
|
-
|
|
5
|
-
class Planka::ConfigurationTest < Minitest::Test
|
|
6
|
-
def with_configuration(env = {})
|
|
7
|
-
previous = ENV.to_h
|
|
8
|
-
ENV.keys.grep(/\APLANKA_/).each { |key| ENV.delete(key) }
|
|
9
|
-
ENV.update(env)
|
|
10
|
-
Dir.mktmpdir("planka-config") do |directory|
|
|
11
|
-
Dir.chdir(directory) { yield directory }
|
|
12
|
-
end
|
|
13
|
-
ensure
|
|
14
|
-
ENV.replace(previous)
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def write_config(path, env)
|
|
18
|
-
File.write(path, JSON.generate("mcpServers" => { "planka" => { "env" => env } }))
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def test_default_mcp_configuration_belongs_to_the_caller
|
|
22
|
-
with_configuration do
|
|
23
|
-
write_config(".mcp.json", "PLANKA_BOARD_ID" => "caller-board", "PLANKA_BASE_URL" => "http://localhost:3000/")
|
|
24
|
-
Planka::Client.configure!
|
|
25
|
-
|
|
26
|
-
assert_equal "caller-board", Planka.board_id
|
|
27
|
-
assert_equal "http://localhost:3000/cards/123", Planka.card_url("123")
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def test_custom_mcp_path_is_relative_to_the_caller
|
|
32
|
-
with_configuration("PLANKA_MCP_CONFIG" => "workflow/mcp.json") do
|
|
33
|
-
Dir.mkdir("workflow")
|
|
34
|
-
write_config("workflow/mcp.json", "PLANKA_BOARD_ID" => "workflow-board")
|
|
35
|
-
Planka::Client.configure!
|
|
36
|
-
|
|
37
|
-
assert_equal "workflow-board", Planka.board_id
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def test_explicit_environment_overrides_mcp_settings
|
|
42
|
-
with_configuration("PLANKA_BOARD_ID" => "explicit-board", "PLANKA_AGENT_PASSWORD" => "plain-password") do
|
|
43
|
-
write_config(".mcp.json", "PLANKA_BOARD_ID" => "mcp-board", "PLANKA_AGENT_PASSWORD" => "op://vault/agent/password",
|
|
44
|
-
"PLANKA_AGENT_EMAIL" => "agent@example.test")
|
|
45
|
-
Planka::Client.configure!
|
|
46
|
-
|
|
47
|
-
assert_equal "explicit-board", Planka.board_id
|
|
48
|
-
assert_equal "plain-password", ENV.fetch("PLANKA_AGENT_PASSWORD")
|
|
49
|
-
assert_equal "agent@example.test", ENV.fetch("PLANKA_AGENT_EMAIL")
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def test_explicit_missing_configuration_is_an_error
|
|
54
|
-
with_configuration("PLANKA_MCP_CONFIG" => "missing.json") do
|
|
55
|
-
assert_raises(Errno::ENOENT) { Planka::Client.configure! }
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def test_missing_board_configuration_is_not_replaced_with_an_instance_default
|
|
60
|
-
with_configuration do
|
|
61
|
-
Planka::Client.configure!
|
|
62
|
-
|
|
63
|
-
error = assert_raises(KeyError) { Planka.board_id }
|
|
64
|
-
assert_equal "PLANKA_BOARD_ID", error.key
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def test_missing_credentials_are_reported_without_a_configuration_file
|
|
69
|
-
with_configuration("PLANKA_BASE_URL" => "http://localhost:3000", "PLANKA_AGENT_EMAIL" => "agent@example.test") do
|
|
70
|
-
error = assert_raises(KeyError) { Planka::Client.resolve_credentials!(__FILE__, []) }
|
|
71
|
-
|
|
72
|
-
assert_equal "PLANKA_AGENT_PASSWORD", error.key
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def test_card_urls_follow_the_current_base_url
|
|
77
|
-
with_configuration("PLANKA_BASE_URL" => "http://localhost:3000") do
|
|
78
|
-
assert_equal "http://localhost:3000/cards/123", Planka.card_url("123")
|
|
79
|
-
ENV["PLANKA_BASE_URL"] = "https://planka.example/"
|
|
80
|
-
assert_equal "https://planka.example/cards/456", Planka.card_url("456")
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
require_relative "planka_test_helper"
|
|
2
|
-
|
|
3
|
-
class Planka::HandoffTest < Minitest::Test
|
|
4
|
-
def test_parses_plain_lines
|
|
5
|
-
handoff = Planka::Handoff.parse("Branch: plan-3/01-contract-edits\nPR: https://github.com/marshally/markdocs/pull/8")
|
|
6
|
-
|
|
7
|
-
assert_equal "plan-3/01-contract-edits", handoff.branch
|
|
8
|
-
assert_equal "https://github.com/marshally/markdocs/pull/8", handoff.pr_url
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def test_parses_markdown_decoration
|
|
12
|
-
handoff = Planka::Handoff.parse("Branch: `plan-3/01-contract-edits`\nPR: <https://github.com/marshally/markdocs/pull/8>.")
|
|
13
|
-
|
|
14
|
-
assert_equal "plan-3/01-contract-edits", handoff.branch
|
|
15
|
-
assert_equal "https://github.com/marshally/markdocs/pull/8", handoff.pr_url
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def test_finds_the_lines_among_other_text
|
|
19
|
-
handoff = Planka::Handoff.parse("Opened the PR.\n\nBranch: plan-3/02-create\nPR: https://github.com/x/y/pull/9\n\nAll criteria ticked.")
|
|
20
|
-
|
|
21
|
-
assert_equal "plan-3/02-create", handoff.branch
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def test_a_branch_without_a_pr_line
|
|
25
|
-
assert_nil Planka::Handoff.parse("Branch: plan-3/02-create").pr_url
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def test_text_without_a_branch_line_is_not_a_handoff
|
|
29
|
-
assert_nil Planka::Handoff.parse("Claimed; starting on the migration.")
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def test_latest_takes_the_newest_handoff_comment
|
|
33
|
-
comments = [
|
|
34
|
-
{ "createdAt" => "2026-09-24T10:00:00Z", "text" => "Branch: old\nPR: https://github.com/x/y/pull/1" },
|
|
35
|
-
{ "createdAt" => "2026-09-25T10:00:00Z", "text" => "Branch: new\nPR: https://github.com/x/y/pull/2" },
|
|
36
|
-
{ "createdAt" => "2026-09-26T10:00:00Z", "text" => "Moved to done." }
|
|
37
|
-
]
|
|
38
|
-
|
|
39
|
-
assert_equal "new", Planka::Handoff.latest(comments).branch
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def test_latest_is_nil_without_a_handoff_comment
|
|
43
|
-
assert_nil Planka::Handoff.latest([ { "createdAt" => "2026-09-24T10:00:00Z", "text" => "Claimed." } ])
|
|
44
|
-
end
|
|
45
|
-
end
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
require_relative "planka_test_helper"
|
|
2
|
-
|
|
3
|
-
class Planka::LoopLockTest < Minitest::Test
|
|
4
|
-
include PlankaTestHelper
|
|
5
|
-
|
|
6
|
-
BOT = "bot"
|
|
7
|
-
|
|
8
|
-
Comments = Struct.new(:by_card) do
|
|
9
|
-
def comments(card_id) = by_card.fetch(card_id, [])
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def held(comments: {}) = Planka::LoopLock.held(boards: [ board ], user_id: BOT, comments: Comments.new(comments))
|
|
13
|
-
|
|
14
|
-
def claim_as_bot(id) = payload["cardMemberships"] << { "cardId" => id, "userId" => BOT }
|
|
15
|
-
|
|
16
|
-
def handoff = [ { "createdAt" => "2026-09-29T10:00:00Z", "text" => "Branch: b\nPR: https://github.com/x/y/pull/1" } ]
|
|
17
|
-
|
|
18
|
-
def test_nothing_claimed_by_the_bot_is_free
|
|
19
|
-
claim(card_id("Contract edits"))
|
|
20
|
-
|
|
21
|
-
assert_nil held
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def test_a_bot_claim_without_a_pr_holds_the_lock
|
|
25
|
-
claim_as_bot(card_id("Contract edits"))
|
|
26
|
-
move(card_id("Contract edits"), "in-progress")
|
|
27
|
-
|
|
28
|
-
assert_equal card_id("Contract edits"), held.id
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def test_a_bot_claim_whose_pr_is_handed_off_does_not
|
|
32
|
-
claim_as_bot(card_id("Contract edits"))
|
|
33
|
-
|
|
34
|
-
assert_nil held(comments: { card_id("Contract edits") => handoff })
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def test_a_closed_bot_claim_does_not
|
|
38
|
-
claim_as_bot(card_id("Contract edits"))
|
|
39
|
-
close(card_id("Contract edits"))
|
|
40
|
-
|
|
41
|
-
assert_nil held
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def test_the_card_knows_when_the_bot_claimed_it
|
|
45
|
-
payload["cardMemberships"] << { "cardId" => card_id("Contract edits"), "userId" => BOT, "createdAt" => "2026-09-29T15:00:00.000Z" }
|
|
46
|
-
|
|
47
|
-
assert_equal Time.utc(2026, 9, 29, 15), held.claimed_at(BOT)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
require_relative "planka_test_helper"
|
|
2
|
-
|
|
3
|
-
class Planka::NextCardTest < Minitest::Test
|
|
4
|
-
include PlankaTestHelper
|
|
5
|
-
|
|
6
|
-
Comments = Struct.new(:by_card) do
|
|
7
|
-
def comments(card_id) = by_card.fetch(card_id, [])
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
PullRequests = Struct.new(:by_url) do
|
|
11
|
-
def find(url) = by_url[url]
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def next_card(label, comments: {}, prs: {})
|
|
15
|
-
Planka::NextCard.for(label, board:, comments: Comments.new(comments), pull_requests: PullRequests.new(prs)).to_s
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def handoff(branch, pr) = [ { "createdAt" => "2026-09-24T10:00:00Z", "text" => "Branch: #{branch}\nPR: #{pr}" } ]
|
|
19
|
-
|
|
20
|
-
def pr(state, head) = Planka::PullRequest.new(state:, head:)
|
|
21
|
-
|
|
22
|
-
def line(prefix) = board.card(card_id(prefix)).to_s
|
|
23
|
-
|
|
24
|
-
def test_a_fresh_feature_picks_the_first_ticket_on_main
|
|
25
|
-
assert_equal <<~REPORT.chomp, next_card("feature:workspaces")
|
|
26
|
-
card: #{line("Contract edits")}
|
|
27
|
-
spec: #{line("Spec:")}
|
|
28
|
-
nn: 01
|
|
29
|
-
blockers: none
|
|
30
|
-
parent: main
|
|
31
|
-
REPORT
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def test_an_open_blocker_pr_stacks_on_its_branch
|
|
35
|
-
close(card_id("Contract edits"))
|
|
36
|
-
report = next_card("feature:workspaces",
|
|
37
|
-
comments: { card_id("Contract edits") => handoff("plan-3/01-contract-edits", "https://github.com/x/y/pull/8") },
|
|
38
|
-
prs: { "https://github.com/x/y/pull/8" => pr("OPEN", "plan-3/01-contract-edits") })
|
|
39
|
-
|
|
40
|
-
assert_equal <<~REPORT.chomp, report
|
|
41
|
-
card: #{line("Create a workspace")}
|
|
42
|
-
spec: #{line("Spec:")}
|
|
43
|
-
nn: 02
|
|
44
|
-
blockers:
|
|
45
|
-
- #{line("Contract edits")}: branch plan-3/01-contract-edits, PR https://github.com/x/y/pull/8 (open)
|
|
46
|
-
parent: plan-3/01-contract-edits
|
|
47
|
-
REPORT
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def test_a_merged_blocker_pr_stacks_on_main
|
|
51
|
-
close(card_id("Contract edits"))
|
|
52
|
-
report = next_card("feature:workspaces",
|
|
53
|
-
comments: { card_id("Contract edits") => handoff("plan-3/01-contract-edits", "https://github.com/x/y/pull/8") },
|
|
54
|
-
prs: { "https://github.com/x/y/pull/8" => pr("MERGED", "plan-3/01-contract-edits") })
|
|
55
|
-
|
|
56
|
-
assert_equal "parent: main", report.lines.last
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def test_a_blocker_without_a_handoff_comment_is_ambiguous
|
|
60
|
-
close(card_id("Contract edits"))
|
|
61
|
-
|
|
62
|
-
assert_match(/^parent: AMBIGUOUS: no Branch: comment on Contract edits/, next_card("feature:workspaces"))
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def test_ticket_order_follows_creation_not_position
|
|
66
|
-
close(*ticket_ids.first(5))
|
|
67
|
-
report = next_card("feature:workspaces",
|
|
68
|
-
comments: { card_id("Documents inside") => handoff("plan-3/05-documents", "https://github.com/x/y/pull/12") },
|
|
69
|
-
prs: { "https://github.com/x/y/pull/12" => pr("OPEN", "plan-3/05-documents") })
|
|
70
|
-
|
|
71
|
-
assert_match(/^card: Adding members/, report)
|
|
72
|
-
assert_match(/^nn: 06$/, report)
|
|
73
|
-
assert_equal "parent: plan-3/05-documents", report.lines.last
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def test_nothing_takeable_lists_what_holds_each_waiting_ticket
|
|
77
|
-
claim(card_id("Contract edits"))
|
|
78
|
-
report = next_card("feature:workspaces").lines(chomp: true)
|
|
79
|
-
|
|
80
|
-
assert_equal "none:", report[0]
|
|
81
|
-
assert_equal "- #{line("Contract edits")}: claimed", report[1]
|
|
82
|
-
assert_equal "- #{line("Create a workspace")}: blocked by #{board.card(card_id("Contract edits")).name}", report[2]
|
|
83
|
-
assert_equal 10, report.size
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def position(id, value) = payload["cards"].find { |c| c["id"] == id }["position"] = value
|
|
87
|
-
|
|
88
|
-
def unblock_all = payload["tasks"].each { |task| task["isCompleted"] = true }
|
|
89
|
-
|
|
90
|
-
def test_no_label_picks_the_top_takeable_ticket_and_never_a_spec
|
|
91
|
-
unblock_all
|
|
92
|
-
position(card_id("Spec:"), 1)
|
|
93
|
-
position(card_id("Adding members"), 2)
|
|
94
|
-
|
|
95
|
-
assert_equal <<~REPORT.chomp, next_card(nil)
|
|
96
|
-
card: #{line("Adding members")}
|
|
97
|
-
spec: #{line("Spec:")}
|
|
98
|
-
blockers:
|
|
99
|
-
- #{line("Documents inside")}: no Branch: comment
|
|
100
|
-
parent: AMBIGUOUS: no Branch: comment on #{board.card(card_id("Documents inside")).name}
|
|
101
|
-
REPORT
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def test_no_label_skips_claimed_and_blocked_cards_above
|
|
105
|
-
unblock_all
|
|
106
|
-
claim(card_id("Contract edits"))
|
|
107
|
-
position(card_id("Contract edits"), 1)
|
|
108
|
-
payload["tasks"].each { |task| task["isCompleted"] = false if task["linkedCardId"] == card_id("Contract edits") }
|
|
109
|
-
position(card_id("Create a workspace"), 2)
|
|
110
|
-
position(card_id("Adding members"), 3)
|
|
111
|
-
|
|
112
|
-
assert_equal "card: #{line("Adding members")}", next_card(nil).lines(chomp: true).first
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def test_no_label_on_a_ticket_without_a_feature_prints_no_spec
|
|
116
|
-
payload["cardLabels"].clear
|
|
117
|
-
assert_equal <<~REPORT.chomp, next_card(nil)
|
|
118
|
-
card: #{line("Contract edits")}
|
|
119
|
-
spec: none
|
|
120
|
-
blockers: none
|
|
121
|
-
parent: main
|
|
122
|
-
REPORT
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def test_no_label_with_nothing_takeable_lists_the_ready_tickets
|
|
126
|
-
claim(card_id("Contract edits"))
|
|
127
|
-
report = next_card(nil).lines(chomp: true)
|
|
128
|
-
|
|
129
|
-
assert_equal "none:", report[0]
|
|
130
|
-
assert_includes report, "- #{line("Contract edits")}: claimed"
|
|
131
|
-
assert_equal 10, report.size
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def test_an_effort_label_prints_the_frontier_in_position_order
|
|
135
|
-
map, *children = [ card_id("Spec:"), *ticket_ids.first(4) ]
|
|
136
|
-
add_label("effort:search", map, *children)
|
|
137
|
-
add_label("wayfinder:map", map)
|
|
138
|
-
move(map, "in-progress")
|
|
139
|
-
payload["tasks"].each { |task| task["isCompleted"] = true }
|
|
140
|
-
claim(card_id("Create a workspace"))
|
|
141
|
-
|
|
142
|
-
assert_equal <<~REPORT.chomp, next_card("effort:search")
|
|
143
|
-
card: #{line("Lookup cop")}
|
|
144
|
-
map: #{line("Spec:")}
|
|
145
|
-
frontier:
|
|
146
|
-
- #{line("Lookup cop")}
|
|
147
|
-
- #{line("Open a workspace")}
|
|
148
|
-
- #{line("Contract edits")}
|
|
149
|
-
REPORT
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def test_an_empty_frontier
|
|
153
|
-
add_label("effort:search", card_id("Spec:"))
|
|
154
|
-
add_label("wayfinder:map", card_id("Spec:"))
|
|
155
|
-
|
|
156
|
-
assert_equal "card: none\nmap: #{line("Spec:")}\nfrontier: none", next_card("effort:search")
|
|
157
|
-
end
|
|
158
|
-
end
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
require "json"
|
|
2
|
-
require "minitest/autorun"
|
|
3
|
-
require_relative "../../../lib/planka"
|
|
4
|
-
|
|
5
|
-
# The fixture is the markdocs board as Plan 3 was published: a spec card and
|
|
6
|
-
# nine ticket cards in ready-for-agent, each blocked on its predecessor(s).
|
|
7
|
-
# Helpers edit a fresh copy of it into the state a test needs.
|
|
8
|
-
module PlankaTestHelper
|
|
9
|
-
FIXTURE = File.expand_path("../../fixtures/files/planka/board.json", __dir__)
|
|
10
|
-
|
|
11
|
-
def before_setup
|
|
12
|
-
super
|
|
13
|
-
@planka_environment = %w[PLANKA_BASE_URL PLANKA_BOARD_ID].to_h { |key| [ key, ENV[key] ] }
|
|
14
|
-
ENV["PLANKA_BASE_URL"] = "http://planka.test:3000"
|
|
15
|
-
ENV["PLANKA_BOARD_ID"] = "test-board"
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def after_teardown
|
|
19
|
-
@planka_environment.each { |key, value| value ? ENV[key] = value : ENV.delete(key) }
|
|
20
|
-
super
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def payload
|
|
24
|
-
@payload ||= JSON.parse(File.read(FIXTURE)).fetch("included")
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def board
|
|
28
|
-
Planka::Board.new(payload)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def card_id(name_prefix)
|
|
32
|
-
payload["cards"].find { |c| c["name"].start_with?(name_prefix) }.fetch("id")
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def ticket_ids
|
|
36
|
-
labelled = payload["cardLabels"].map { |cl| cl["cardId"] }
|
|
37
|
-
with_criteria = payload["taskLists"].select { |tl| tl["name"] == "Acceptance criteria" }.map { |tl| tl["cardId"] }
|
|
38
|
-
payload["cards"].select { |c| labelled.include?(c["id"]) && with_criteria.include?(c["id"]) }
|
|
39
|
-
.sort_by { |c| c["createdAt"] }.map { |c| c["id"] }
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# Moves the cards to done, completing the tasks linked to them as Planka does.
|
|
43
|
-
def close(*ids)
|
|
44
|
-
done = payload["lists"].find { |l| l["name"] == "done" }.fetch("id")
|
|
45
|
-
payload["cards"].each { |c| c["listId"] = done if ids.include?(c["id"]) }
|
|
46
|
-
payload["tasks"].each { |t| t["isCompleted"] = true if ids.include?(t["linkedCardId"]) }
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def claim(id)
|
|
50
|
-
payload["cardMemberships"] << { "cardId" => id, "userId" => "1" }
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def add_label(name, *ids)
|
|
54
|
-
label = payload["labels"].find { |l| l["name"] == name } ||
|
|
55
|
-
{ "id" => "label-#{name}", "name" => name }.tap { |l| payload["labels"] << l }
|
|
56
|
-
ids.each { |id| payload["cardLabels"] << { "id" => "cl-#{name}-#{id}", "cardId" => id, "labelId" => label["id"] } }
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def move(id, list_name)
|
|
60
|
-
list = payload["lists"].find { |l| l["name"] == list_name }.fetch("id")
|
|
61
|
-
payload["cards"].find { |c| c["id"] == id }["listId"] = list
|
|
62
|
-
end
|
|
63
|
-
end
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
require_relative "planka_test_helper"
|
|
2
|
-
|
|
3
|
-
class Planka::SpecSweepTest < Minitest::Test
|
|
4
|
-
include PlankaTestHelper
|
|
5
|
-
|
|
6
|
-
def finished = Planka::SpecSweep.finished(board).map(&:id)
|
|
7
|
-
|
|
8
|
-
def test_a_spec_in_progress_whose_tickets_are_all_closed_is_finished
|
|
9
|
-
move(card_id("Spec:"), "in-progress")
|
|
10
|
-
close(*ticket_ids)
|
|
11
|
-
|
|
12
|
-
assert_equal [ card_id("Spec:") ], finished
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def test_one_open_ticket_keeps_it_open
|
|
16
|
-
move(card_id("Spec:"), "in-progress")
|
|
17
|
-
close(*ticket_ids.drop(1))
|
|
18
|
-
|
|
19
|
-
assert_empty finished
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def test_a_spec_with_no_tickets_is_left_alone
|
|
23
|
-
add_label("feature:empty", card_id("Spec:"))
|
|
24
|
-
payload["cardLabels"].reject! { |cl| cl["labelId"] != "label-feature:empty" && cl["cardId"] == card_id("Spec:") }
|
|
25
|
-
move(card_id("Spec:"), "in-progress")
|
|
26
|
-
|
|
27
|
-
assert_empty finished
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def test_a_spec_that_isnt_in_progress_is_left_alone
|
|
31
|
-
close(*ticket_ids)
|
|
32
|
-
|
|
33
|
-
assert_empty finished
|
|
34
|
-
end
|
|
35
|
-
end
|