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,51 @@
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
@@ -0,0 +1,76 @@
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
@@ -0,0 +1,77 @@
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
@@ -0,0 +1,36 @@
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
@@ -0,0 +1,83 @@
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
@@ -0,0 +1,45 @@
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
@@ -0,0 +1,49 @@
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
@@ -0,0 +1,158 @@
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
@@ -0,0 +1,63 @@
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
@@ -0,0 +1,35 @@
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