plan_driven 0.1.0 → 0.3.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -1
  3. data/README.md +437 -37
  4. data/app/controllers/plan_driven/wizard/application_controller.rb +59 -0
  5. data/app/controllers/plan_driven/wizard/configuration_controller.rb +62 -0
  6. data/app/controllers/plan_driven/wizard/jobs_controller.rb +16 -0
  7. data/app/controllers/plan_driven/wizard/plans_controller.rb +113 -0
  8. data/app/views/layouts/plan_driven/wizard/application.html.erb +151 -0
  9. data/app/views/plan_driven/wizard/configuration/show.html.erb +104 -0
  10. data/app/views/plan_driven/wizard/plans/_agents.html.erb +44 -0
  11. data/app/views/plan_driven/wizard/plans/_approve.html.erb +40 -0
  12. data/app/views/plan_driven/wizard/plans/_finish.html.erb +31 -0
  13. data/app/views/plan_driven/wizard/plans/_plan.html.erb +52 -0
  14. data/app/views/plan_driven/wizard/plans/_tickets.html.erb +42 -0
  15. data/app/views/plan_driven/wizard/plans/index.html.erb +31 -0
  16. data/app/views/plan_driven/wizard/plans/new.html.erb +21 -0
  17. data/app/views/plan_driven/wizard/plans/show.html.erb +37 -0
  18. data/app/views/plan_driven/wizard/plans/statistics.html.erb +94 -0
  19. data/config/routes.rb +17 -0
  20. data/exe/plan-driven +1 -0
  21. data/lib/generators/plan_driven/install_generator.rb +7 -0
  22. data/lib/generators/plan_driven/templates/plan_driven.rb +10 -1
  23. data/lib/plan_driven/charts.rb +252 -0
  24. data/lib/plan_driven/cli/config_commands.rb +60 -0
  25. data/lib/plan_driven/cli/plan_commands.rb +6 -2
  26. data/lib/plan_driven/cli/setup_commands.rb +10 -0
  27. data/lib/plan_driven/cli/ticket_commands.rb +64 -3
  28. data/lib/plan_driven/cli/ui.rb +41 -2
  29. data/lib/plan_driven/cli.rb +21 -4
  30. data/lib/plan_driven/configuration.rb +31 -3
  31. data/lib/plan_driven/connections.rb +69 -0
  32. data/lib/plan_driven/cursor_agents.rb +10 -2
  33. data/lib/plan_driven/delivery.rb +23 -5
  34. data/lib/plan_driven/evidence.rb +11 -4
  35. data/lib/plan_driven/github.rb +4 -0
  36. data/lib/plan_driven/guards/migration_guard.rb +86 -9
  37. data/lib/plan_driven/guards/ticket_guard.rb +1 -2
  38. data/lib/plan_driven/interview.rb +161 -0
  39. data/lib/plan_driven/local_agents.rb +254 -0
  40. data/lib/plan_driven/renderer/html.rb +34 -5
  41. data/lib/plan_driven/renderer/markdown.rb +67 -3
  42. data/lib/plan_driven/renderer/style.css +21 -0
  43. data/lib/plan_driven/renderer.rb +11 -4
  44. data/lib/plan_driven/statistics.rb +195 -0
  45. data/lib/plan_driven/template.rb +7 -2
  46. data/lib/plan_driven/usage.rb +114 -0
  47. data/lib/plan_driven/version.rb +1 -1
  48. data/lib/plan_driven/wizard/engine.rb +19 -0
  49. data/lib/plan_driven/wizard.rb +245 -0
  50. data/lib/plan_driven.rb +8 -1
  51. metadata +32 -6
@@ -0,0 +1,42 @@
1
+ <% can_draft = %w[approved ticketed].include?(@plan.status) %>
2
+ <div class="card">
3
+ <%= form_with url: run_plan_path(@plan.key), method: :post, class: "row" do %>
4
+ <%= hidden_field_tag :do, "tickets" %><%= hidden_field_tag :step, "tickets" %>
5
+ <%= text_field_tag :instruction, nil, placeholder: @plan.tickets.any? ? "what to change, e.g. split T2 into model and UI" : "optional guidance for the tickets",
6
+ disabled: !can_draft %>
7
+ <button type="submit" class="<%= "primary" if @plan.tickets.empty? %>" <%= "disabled" unless can_draft %>>
8
+ <%= @plan.tickets.any? ? "Redraft tickets" : "Draft tickets" %></button>
9
+ <% end %>
10
+ </div>
11
+
12
+ <% if @plan.tickets.any? %>
13
+ <div class="card">
14
+ <table>
15
+ <thead><tr><th>Ticket</th><th>Type</th><th>Est.</th><th>Depends on</th><th>Status</th></tr></thead>
16
+ <tbody>
17
+ <% @plan.tickets.each do |ticket| %>
18
+ <tr>
19
+ <td><strong><%= ticket.key %></strong> <%= ticket.title %>
20
+ <details><summary class="muted">acceptance criteria</summary>
21
+ <ul><% ticket.criteria.each do |criterion| %><li><%= criterion.is_a?(Hash) ? criterion.values.join(" ") : criterion %></li><% end %></ul>
22
+ </details></td>
23
+ <td><%= ticket.ticket_type %></td>
24
+ <td><%= ticket.estimate %></td>
25
+ <td><%= ticket.dependencies.join(", ").presence || "—" %></td>
26
+ <td><span class="pill"><%= ticket.status.tr("_", " ") %></span></td>
27
+ </tr>
28
+ <% end %>
29
+ </tbody>
30
+ </table>
31
+ </div>
32
+
33
+ <% if @plan.status == "ticketed" %>
34
+ <div class="card row">
35
+ <span>Approvals needed: <strong><%= @plan.missing_ticket_approvals.join(", ").presence || "none" %></strong></span>
36
+ <span style="margin-left:auto"></span>
37
+ <% @plan.missing_ticket_approvals.each do |role| %>
38
+ <%= run_button "Approve tickets as #{role}", "approve-tickets", { role: role }, primary: true %>
39
+ <% end %>
40
+ </div>
41
+ <% end %>
42
+ <% end %>
@@ -0,0 +1,31 @@
1
+ <% content_for :title, "plan_driven" %>
2
+ <h1>Implementation plans</h1>
3
+ <p class="muted">From an idea to merged pull requests, one step at a time. Each button runs the same
4
+ <code>plan-driven</code> command you could type yourself; the panel on the right shows it.</p>
5
+
6
+ <div class="row">
7
+ <%= link_to "New plan", new_plan_path, class: "button primary" %>
8
+ <%= button_to "Check the setup", run_path(do: "doctor"), form: { style: "display:inline" } %>
9
+ </div>
10
+
11
+ <% if @plans.any? %>
12
+ <div class="card">
13
+ <table>
14
+ <thead><tr><th>Plan</th><th>Title</th><th>Phase</th><th>Revision</th><th>Tickets</th><th></th></tr></thead>
15
+ <tbody>
16
+ <% @plans.each do |plan| %>
17
+ <tr>
18
+ <td><strong><%= plan.key %></strong></td>
19
+ <td><%= plan.title %></td>
20
+ <td><span class="pill <%= plan.status == "delivered" ? "good" : "wait" %>"><%= plan.status.tr("_", " ") %></span></td>
21
+ <td><%= plan.revision %></td>
22
+ <td><%= plan.tickets.size %></td>
23
+ <td><%= link_to "Open", plan_path(plan.key) %></td>
24
+ </tr>
25
+ <% end %>
26
+ </tbody>
27
+ </table>
28
+ </div>
29
+ <% else %>
30
+ <div class="card muted">No plans yet. Start with “New plan”.</div>
31
+ <% end %>
@@ -0,0 +1,21 @@
1
+ <% content_for :title, "New plan" %>
2
+ <h1>New plan</h1>
3
+ <p class="muted">The same questions <code>plan-driven new</code> asks in the terminal. Your answers go to the
4
+ command as its input; the model drafts the rest from them and your schema, then the guards check it.</p>
5
+
6
+ <%= form_with url: plans_path, method: :post do |f| %>
7
+ <label for="title">Title of the change</label>
8
+ <%= f.text_field :title, value: params[:title], id: "title", required: true %>
9
+
10
+ <% @template.asked.each do |section| %>
11
+ <label for="answer_<%= section.key %>"><%= section.title %>
12
+ <small><%= section.prompt %></small></label>
13
+ <%= text_area_tag "answers[#{section.key}]", params.dig(:answers, section.key), id: "answer_#{section.key}",
14
+ rows: section.required ? 4 : 2 %>
15
+ <% end %>
16
+
17
+ <div class="nav">
18
+ <%= link_to "← Back", root_path, class: "button" %>
19
+ <button type="submit" class="primary">Draft the plan →</button>
20
+ </div>
21
+ <% end %>
@@ -0,0 +1,37 @@
1
+ <% content_for :title, "#{@plan.key} #{@plan.title}" %>
2
+ <p class="muted" style="margin:0"><%= link_to "All plans", root_path %></p>
3
+ <h1><%= @plan.key %> · <%= @plan.title %></h1>
4
+ <p class="muted" style="margin:0">
5
+ <span class="pill <%= @plan.status == "delivered" ? "good" : "wait" %>"><%= @plan.status.tr("_", " ") %></span>
6
+ revision <%= @plan.revision %> · <%= PlanDriven::Workflow::PLAN_DESCRIPTIONS[@plan.status] %>
7
+ · <%= link_to "Statistics", plan_statistics_path(@plan.key) %>
8
+ </p>
9
+
10
+ <ol class="steps">
11
+ <% PlanDriven::Wizard::Steps::ORDER.each_with_index do |(key, title, hint), index| %>
12
+ <li>
13
+ <% if key == @step %>
14
+ <span class="here"><b><%= index + 1 %>. <%= title %></b><%= hint %></span>
15
+ <% elsif PlanDriven::Wizard::Steps.reached?(@plan, key) %>
16
+ <%= link_to plan_path(@plan.key, key) do %><b><%= index + 1 %>. <%= title %></b><%= hint %><% end %>
17
+ <% else %>
18
+ <span><b><%= index + 1 %>. <%= title %></b><%= hint %></span>
19
+ <% end %>
20
+ </li>
21
+ <% end %>
22
+ </ol>
23
+
24
+ <%= render @step %>
25
+
26
+ <% keys = PlanDriven::Wizard::Steps.keys; position = keys.index(@step) %>
27
+ <div class="nav">
28
+ <% if position.zero? %>
29
+ <%= link_to "← All plans", root_path, class: "button" %>
30
+ <% else %>
31
+ <%= link_to "← Back", plan_path(@plan.key, keys[position - 1]), class: "button" %>
32
+ <% end %>
33
+ <% if (following = keys[position + 1]) %>
34
+ <%= link_to "Next →", plan_path(@plan.key, following),
35
+ class: "button primary #{"disabled" unless PlanDriven::Wizard::Steps.reached?(@plan, following)}" %>
36
+ <% end %>
37
+ </div>
@@ -0,0 +1,94 @@
1
+ <% content_for :title, "#{@plan.key} statistics" %>
2
+ <% duration = ->(seconds) { PlanDriven::Statistics.duration(seconds) } %>
3
+ <p class="muted" style="margin:0"><%= link_to "← #{@plan.key} #{@plan.title}", plan_path(@plan.key) %></p>
4
+ <h1>Statistics</h1>
5
+ <p class="muted" style="margin:0">
6
+ <span class="pill <%= @plan.status == "delivered" ? "good" : "wait" %>"><%= @plan.status.tr("_", " ") %></span>
7
+ Computed from the audit trail: every number is a recorded event, nothing is estimated.
8
+ </p>
9
+
10
+ <% if @stats.started? %>
11
+ <% s = @stats.summary %>
12
+ <div class="kpis">
13
+ <div class="kpi">
14
+ <small>Idea to delivery</small>
15
+ <strong><%= duration[s[:lead_time]] %></strong>
16
+ <span class="muted"><%= @plan.status == "delivered" ? "plan drafted to delivered" : "so far" %></span>
17
+ </div>
18
+ <div class="kpi">
19
+ <small>Development</small>
20
+ <strong><%= duration[s[:development]] %></strong>
21
+ <span class="muted"><%= s[:merged] %> of <%= s[:tickets] %> tickets merged</span>
22
+ </div>
23
+ <div class="kpi <%= s[:proven] == s[:criteria] && s[:criteria].positive? ? "good" : (s[:evidence] ? "bad" : "") %>">
24
+ <small>Criteria proven</small>
25
+ <strong><%= s[:proven] %> <span>/ <%= s[:criteria] %></span></strong>
26
+ <span class="muted"><%= s[:evidence] ? "by a passing scenario" : "no evidence run yet" %></span>
27
+ </div>
28
+ <div class="kpi">
29
+ <small>Approved the first time</small>
30
+ <strong><%= s[:first_time] %> <span>/ <%= s[:merged] %></span></strong>
31
+ <span class="muted"><%= pluralize(s[:review_rounds], "review round") %></span>
32
+ </div>
33
+ <div class="kpi">
34
+ <small>Agents' share of the work</small>
35
+ <strong><%= s[:agent_share] ? "#{s[:agent_share]}%" : "-" %></strong>
36
+ <span class="muted">the rest is waiting on people</span>
37
+ </div>
38
+ <div class="kpi">
39
+ <small>Tokens</small>
40
+ <strong><%= PlanDriven::Usage.format_tokens(s[:tokens]) %></strong>
41
+ <span class="muted"><%= s[:cost] ? PlanDriven::Usage.format_cost(s[:cost]) : "set config.token_prices for dollars" %></span>
42
+ </div>
43
+ </div>
44
+
45
+ <% charts = PlanDriven::Charts.report(@plan, stats: @stats) %>
46
+ <% %w[statistics-proof.svg statistics-burnup.svg statistics-timeline.svg statistics-time.svg].each do |name| %>
47
+ <% next unless charts[name] %>
48
+ <div class="card chart"><%= charts[name].html_safe %></div>
49
+ <% end %>
50
+
51
+ <div class="card">
52
+ <h3 style="margin-top:0">Ticket by ticket</h3>
53
+ <table>
54
+ <thead>
55
+ <tr><th>#</th><th>Ticket</th><th>Est.</th><th>Queued</th><th>Agent</th><th>Review</th><th>Fixes</th><th>Merge</th><th>Rounds</th><th>Start to merge</th></tr>
56
+ </thead>
57
+ <tbody>
58
+ <% @stats.tickets.each do |row| %>
59
+ <tr>
60
+ <td><b><%= row.ticket.key %></b></td>
61
+ <td><%= row.ticket.title %></td>
62
+ <td><%= row.ticket.estimate %></td>
63
+ <% %w[queued agent review fixes merge].each do |phase| %>
64
+ <td><span class="swatch" style="background:<%= PlanDriven::Charts::COLORS[phase] %>"></span><%= row.seconds(phase).positive? ? duration[row.seconds(phase)] : "-" %></td>
65
+ <% end %>
66
+ <td><%= row.review_rounds %></td>
67
+ <td><% if row.merged? %><span class="pill good"><%= duration[row.cycle_time] %></span><% else %><span class="pill wait"><%= row.ticket.status.tr("_", " ") %></span><% end %></td>
68
+ </tr>
69
+ <% end %>
70
+ </tbody>
71
+ </table>
72
+ </div>
73
+ <% else %>
74
+ <div class="card">
75
+ <strong>Statistics start when the tickets are approved.</strong>
76
+ <p class="muted">Until then, this is how long planning has taken.</p>
77
+ </div>
78
+ <% end %>
79
+
80
+ <div class="card">
81
+ <h3 style="margin-top:0">Phases</h3>
82
+ <div class="phases">
83
+ <% @stats.phases.each do |label, seconds| %>
84
+ <div class="<%= "pending" unless seconds %>"><small><%= label %></small><b><%= seconds ? duration[seconds] : "not yet" %></b></div>
85
+ <% end %>
86
+ </div>
87
+ </div>
88
+
89
+ <div class="card row">
90
+ <span class="muted">The same numbers in the terminal, and the charts in the delivery report.</span>
91
+ <span style="margin-left:auto"></span>
92
+ <%= run_button "Print in the terminal", "stats" %>
93
+ <%= run_button "Write the delivery report", "report", {}, primary: true %>
94
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ PlanDriven::Wizard::Engine.routes.draw do
4
+ root "plans#index"
5
+ resources :plans, only: %i[new create], param: :key
6
+ get "plans/:key/statistics", to: "plans#statistics", as: :plan_statistics
7
+ get "plans/:key(/:step)", to: "plans#show", as: :plan, constraints: { step: /plan|approve|tickets|agents|finish/ }
8
+ post "plans/:key/run", to: "plans#run", as: :run_plan
9
+ get "plans/:key/files/:name", to: "plans#file", as: :plan_file, constraints: { name: /[a-z-]+\.(pdf|html)/ }
10
+ post "run", to: "plans#run_global", as: :run
11
+ post "actor", to: "plans#actor", as: :actor
12
+ get "configuration", to: "configuration#show", as: :configuration
13
+ post "configuration/question", to: "configuration#question", as: :configuration_question
14
+ post "configuration/connect", to: "configuration#connect", as: :configuration_connect
15
+ post "configuration/check", to: "configuration#check", as: :configuration_check
16
+ get "jobs/:id", to: "jobs#show", as: :job
17
+ end
data/exe/plan-driven CHANGED
@@ -4,4 +4,5 @@
4
4
  require "plan_driven"
5
5
  require "plan_driven/cli"
6
6
 
7
+ $stdout.sync = true
7
8
  exit PlanDriven::CLI.start(ARGV)
@@ -23,6 +23,10 @@ module PlanDriven
23
23
  create_file "docs/plans/.keep"
24
24
  end
25
25
 
26
+ def mount_wizard
27
+ route 'mount PlanDriven::Wizard::Engine, at: "/plan_driven" if Rails.env.development?'
28
+ end
29
+
26
30
  def show_next_steps
27
31
  say <<~TEXT
28
32
 
@@ -30,6 +34,9 @@ module PlanDriven
30
34
  bin/rails db:migrate
31
35
  bundle exec plan-driven configure # keys for the LLM, Cursor and GitHub
32
36
  bundle exec plan-driven new "Short title of the change"
37
+
38
+ Or do it all in the browser: start the app and open /plan_driven
39
+ (development only; every step runs the same plan-driven command).
33
40
  TEXT
34
41
  end
35
42
  end
@@ -14,11 +14,17 @@ if defined?(PlanDriven.configure)
14
14
  # config.plan_approvals = %w[review qa devops director]
15
15
  # config.ticket_approvals = %w[review]
16
16
 
17
- # Cursor cloud agents: one agent and one pull request per ticket.
17
+ # Coding agents: one agent and one pull request per ticket. Cursor cloud agents by default,
18
+ # or a local CLI in its own git worktree per ticket.
18
19
  # config.agent_model = "composer-2.5"
20
+ # config.agent_provider = :local
21
+ # config.agent_command = "claude -p --permission-mode acceptEdits --output-format json"
19
22
  # config.base_branch = "main"
20
23
  # config.max_parallel_agents = 3
21
24
 
25
+ # Dollars per million tokens, for the cost in `plan-driven usage` and the delivery report.
26
+ # config.token_prices = { "your-model-id" => { input: 3.0, output: 15.0, cache_read: 0.3 } }
27
+
22
28
  # GitHub. The repository is read from `git remote get-url origin` when not set.
23
29
  # config.github_repository = "your-org/your-app"
24
30
  # config.sync_issues = true
@@ -35,5 +41,8 @@ if defined?(PlanDriven.configure)
35
41
  # "Service objects live in app/services and respond to .call.",
36
42
  # "Authorization goes through Pundit policies, never in controllers."
37
43
  # ]
44
+
45
+ # The browser wizard at /plan_driven: development only by default, local requests always.
46
+ # config.wizard_enabled = true
38
47
  end
39
48
  end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module PlanDriven
6
+ # A plan's statistics as SVG, drawn in Ruby. The same file shows in the wizard, on GitHub next
7
+ # to the delivery report and in its PDF: no JavaScript, nothing to install.
8
+ module Charts
9
+ COLORS = {
10
+ "queued" => "#d1d5db", "agent" => "#3b82f6", "review" => "#f59e0b", "fixes" => "#8b5cf6",
11
+ "merge" => "#10b981", "passed" => "#16a34a", "failed" => "#dc2626", "not run" => "#f59e0b",
12
+ "no scenario" => "#9ca3af"
13
+ }.freeze
14
+ INK = "#1f2330"
15
+ MUTED = "#6b7280"
16
+ GRID = "#eef0f3"
17
+ FONT = "-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"
18
+ TICKS = [60, 300, 600, 900, 1800, 3600, 7200, 10_800, 21_600, 43_200, 86_400, 172_800, 604_800].freeze
19
+ STATUSES = ["passed", "failed", "not run", "no scenario"].freeze
20
+
21
+ # The report's charts by file name, leaving out any the plan has no data for yet.
22
+ def self.report(plan, stats: Statistics.new(plan))
23
+ { "statistics-proof.svg" => proof(Evidence.matrix(plan)), "statistics-burnup.svg" => burnup(stats),
24
+ "statistics-timeline.svg" => timeline(stats), "statistics-time.svg" => breakdown(stats) }.compact
25
+ end
26
+
27
+ module_function
28
+
29
+ # Criteria merged (blue) and proven by a passing test (green), against the plan's scope.
30
+ def burnup(stats)
31
+ data = stats.burnup or return
32
+ width = 760
33
+ height = 320
34
+ box = { left: 44, right: 24, top: 84, bottom: 36 }
35
+ x = scale_time(data[:from], data[:to], box[:left], width - box[:right])
36
+ top = [data[:scope], 1].max
37
+ y = ->(value) { box[:top] + ((1 - (value.to_f / top)) * (height - box[:top] - box[:bottom])) }
38
+ parts = [heading("Acceptance criteria, merged and proven", "#{data[:scope]} in scope")]
39
+ parts << legend([["Merged", COLORS["agent"]], ["Proven by a passing test", COLORS["passed"]],
40
+ ["Scope", MUTED]], 20, 70)
41
+ parts << value_grid(top, y, box[:left], width - box[:right])
42
+ parts << time_axis(data[:from], data[:to], x, height - box[:bottom], box[:top])
43
+ parts << (%(<line x1="#{box[:left]}" x2="#{width - box[:right]}" y1="#{f(y[data[:scope]])}" ) +
44
+ %(y2="#{f(y[data[:scope]])}" stroke="#{MUTED}" stroke-width="1.5" stroke-dasharray="5 4"/>))
45
+ parts << step_series(data[:merged], data[:to], x, y, COLORS["agent"], fill: true)
46
+ parts << step_series(data[:proven], data[:to], x, y, COLORS["passed"])
47
+ parts << run_dots(data[:proven].drop(1), x, y)
48
+ svg(width, height, parts.join("\n"), title: "Acceptance criteria merged and proven over time")
49
+ end
50
+
51
+ # One row per ticket on a shared clock: queued, agent coding, waiting for review, fixing
52
+ # feedback, approved but not merged.
53
+ def timeline(stats)
54
+ rows = stats.tickets.select { |row| row.segments.any? }
55
+ return if rows.empty?
56
+
57
+ from, to = stats.window
58
+ width = 760
59
+ box = { left: 190, right: 100, top: 84, bottom: 36 }
60
+ row_height = 30
61
+ height = box[:top] + (rows.size * row_height) + box[:bottom]
62
+ x = scale_time(from, to, box[:left], width - box[:right])
63
+ phases = Statistics::PHASES.select { |phase, _| rows.any? { |row| row.seconds(phase).positive? } }
64
+ parts = [heading("Where the time went, ticket by ticket", "#{Statistics.duration(to - from)} in development")]
65
+ parts << legend(phases.map { |phase, label| [label, COLORS[phase]] }, 20, 70)
66
+ parts << time_axis(from, to, x, height - box[:bottom], box[:top])
67
+ rows.each_with_index do |row, index|
68
+ parts << ticket_row(row, box[:top] + (index * row_height), x, box[:left])
69
+ end
70
+ svg(width, height, parts.join("\n"), title: "Where the time went, ticket by ticket")
71
+ end
72
+
73
+ # How the time tickets were worked on splits between agents and people.
74
+ def breakdown(stats)
75
+ totals = stats.totals.slice(*Statistics::WORK).select { |_, seconds| seconds.positive? }
76
+ sum = totals.values.sum
77
+ return if sum.zero?
78
+
79
+ width = 760
80
+ height = 250
81
+ center = [130, 150]
82
+ radius = 72
83
+ circumference = 2 * Math::PI * radius
84
+ offset = 0.0
85
+ rings = totals.map do |phase, seconds|
86
+ length = circumference * seconds / sum
87
+ ring = %(<circle cx="#{center[0]}" cy="#{center[1]}" r="#{radius}" fill="none" stroke="#{COLORS[phase]}" ) +
88
+ %(stroke-width="28" stroke-dasharray="#{f(length)} #{f(circumference - length)}" ) +
89
+ %(stroke-dashoffset="#{f(-offset)}" transform="rotate(-90 #{center[0]} #{center[1]})">) +
90
+ %(<title>#{esc(Statistics::PHASES[phase])}: #{Statistics.duration(seconds)}</title></circle>)
91
+ offset += length
92
+ ring
93
+ end
94
+ share = stats.agent_share
95
+ parts = [heading("Agents and people", "while tickets were being worked on")]
96
+ parts += rings
97
+ parts << (%(<text x="#{center[0]}" y="#{center[1] + 2}" text-anchor="middle" font-size="26" ) +
98
+ %(font-weight="700" fill="#{INK}">#{share}%</text>))
99
+ parts << %(<text x="#{center[0]}" y="#{center[1] + 22}" text-anchor="middle" fill="#{MUTED}">agents</text>)
100
+ totals.each_with_index do |(phase, seconds), index|
101
+ top = 100 + (index * 30)
102
+ percent = (seconds * 100.0 / sum).round
103
+ parts << %(<rect x="260" y="#{top - 11}" width="14" height="14" rx="3" fill="#{COLORS[phase]}"/>)
104
+ parts << %(<text x="284" y="#{top}" fill="#{INK}" font-size="13">#{esc(Statistics::PHASES[phase])}</text>)
105
+ parts << (%(<text x="560" y="#{top}" fill="#{INK}" font-size="13" text-anchor="end" ) +
106
+ %(font-weight="600">#{Statistics.duration(seconds)}</text>))
107
+ parts << %(<text x="620" y="#{top}" fill="#{MUTED}" font-size="13" text-anchor="end">#{percent}%</text>)
108
+ end
109
+ svg(width, height, parts.join("\n"), title: "Time split between agents and people")
110
+ end
111
+
112
+ # Every acceptance criterion's result in one bar.
113
+ def proof(rows)
114
+ return if rows.empty?
115
+
116
+ width = 760
117
+ height = 104
118
+ counts = STATUSES.to_h { |status| [status, rows.count { |row| row.status == status }] }
119
+ passed = counts["passed"]
120
+ color = if passed == rows.size then COLORS["passed"]
121
+ elsif counts["failed"].positive? then COLORS["failed"]
122
+ else INK
123
+ end
124
+ parts = [%(<text x="20" y="34" font-size="17" font-weight="700" fill="#{color}">) +
125
+ %(#{passed} of #{rows.size} acceptance criteria proven</text>)]
126
+ left = 20.0
127
+ span = width - 40.0
128
+ parts << %(<clipPath id="proof-bar"><rect x="20" y="48" width="#{span}" height="16" rx="8"/></clipPath>)
129
+ segments = counts.filter_map do |status, count|
130
+ next if count.zero?
131
+
132
+ length = span * count / rows.size
133
+ bar = %(<rect x="#{f(left)}" y="48" width="#{f(length)}" height="16" fill="#{COLORS[status]}">) +
134
+ %(<title>#{count} #{status}</title></rect>)
135
+ left += length
136
+ bar
137
+ end
138
+ parts << %(<g clip-path="url(#proof-bar)">#{segments.join}</g>)
139
+ shown = counts.reject { |_, count| count.zero? }
140
+ parts << legend(shown.map { |status, count| ["#{count} #{status}", COLORS[status]] }, 20, 90)
141
+ svg(width, height, parts.join("\n"), title: "#{passed} of #{rows.size} acceptance criteria proven")
142
+ end
143
+
144
+ def svg(width, height, body, title:)
145
+ <<~SVG
146
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{width} #{height}" width="#{width}" height="#{height}" role="img" font-family="#{FONT}" font-size="12">
147
+ <title>#{esc(title)}</title>
148
+ <rect width="#{width}" height="#{height}" rx="10" fill="#ffffff"/>
149
+ #{body}
150
+ </svg>
151
+ SVG
152
+ end
153
+
154
+ def heading(text, note)
155
+ %(<text x="20" y="28" font-size="15" font-weight="700" fill="#{INK}">#{esc(text)}</text>) +
156
+ %(<text x="20" y="46" fill="#{MUTED}">#{esc(note)}</text>)
157
+ end
158
+
159
+ def legend(items, left, top)
160
+ x = left
161
+ items.map do |label, color|
162
+ item = %(<rect x="#{f(x)}" y="#{top - 10}" width="12" height="12" rx="3" fill="#{color}"/>) +
163
+ %(<text x="#{f(x + 18)}" y="#{top}" fill="#{MUTED}">#{esc(label)}</text>)
164
+ x += (label.length * 6.6) + 40
165
+ item
166
+ end.join
167
+ end
168
+
169
+ def scale_time(from, to, left, right)
170
+ start = from.to_f
171
+ length = [to.to_f - start, 1].max
172
+ ->(time) { left + ((time.to_f - start) / length * (right - left)) }
173
+ end
174
+
175
+ # Round values up the axis: 0, 10, 20, 30, 40 rather than 0, 11, 21, 32, 42.
176
+ def value_grid(top, y, left, right)
177
+ step = [1, 2, 5, 10, 20, 25, 50, 100, 200, 500].find { |size| top / size <= 5 } || (top / 5.0).ceil
178
+ (0..top).step(step).map do |value|
179
+ %(<line x1="#{left}" x2="#{right}" y1="#{f(y[value])}" y2="#{f(y[value])}" stroke="#{GRID}"/>) +
180
+ %(<text x="#{left - 8}" y="#{f(y[value] + 4)}" text-anchor="end" fill="#{MUTED}">#{value}</text>)
181
+ end.join
182
+ end
183
+
184
+ # Clock ticks at a round interval, labelled in the plan's own time zone.
185
+ def time_axis(from, to, x, bottom, top)
186
+ length = to.to_f - from.to_f
187
+ step = TICKS.find { |seconds| length / seconds <= 7 } || TICKS.last
188
+ format = step >= 86_400 || length > 86_400 ? "%-d %b" : "%H:%M"
189
+ first = (from.to_f / step).ceil * step
190
+ (first..to.to_f).step(step).map do |seconds|
191
+ time = local(seconds, from)
192
+ %(<line x1="#{f(x[time])}" x2="#{f(x[time])}" y1="#{top}" y2="#{bottom}" stroke="#{GRID}"/>) +
193
+ %(<text x="#{f(x[time])}" y="#{bottom + 18}" text-anchor="middle" fill="#{MUTED}">) +
194
+ %(#{time.strftime(format)}</text>)
195
+ end.join
196
+ end
197
+
198
+ # A whole number of seconds as a time in the same zone as `like`, without float drift.
199
+ def local(seconds, like)
200
+ time = Time.at(seconds)
201
+ like.respond_to?(:time_zone) ? time.in_time_zone(like.time_zone) : time.getlocal(like.utc_offset)
202
+ end
203
+
204
+ def step_series(points, to, x, y, color, fill: false)
205
+ path = "M#{f(x[points.first.at])},#{f(y[points.first.value])}"
206
+ points.drop(1).each { |point| path << " H#{f(x[point.at])} V#{f(y[point.value])}" }
207
+ path << " H#{f(x[to])}"
208
+ area = ""
209
+ area = %(<path d="#{path} V#{f(y[0])} H#{f(x[points.first.at])} Z" fill="#{color}" fill-opacity=".12"/>) if fill
210
+ %(#{area}<path d="#{path}" fill="none" stroke="#{color}" stroke-width="2.5" stroke-linejoin="round"/>)
211
+ end
212
+
213
+ # One dot per evidence run, red when it failed, with the count on the last one.
214
+ def run_dots(points, x, y)
215
+ points.each_with_index.map do |point, index|
216
+ color = point.status == "passed" ? COLORS["passed"] : COLORS["failed"]
217
+ label = if index == points.size - 1
218
+ %(<text x="#{f(x[point.at] - 8)}" y="#{f(y[point.value] - 10)}" text-anchor="end" ) +
219
+ %(font-weight="700" fill="#{color}">#{point.value} proven</text>)
220
+ end
221
+ %(<circle cx="#{f(x[point.at])}" cy="#{f(y[point.value])}" r="5" fill="#{color}" stroke="#ffffff" ) +
222
+ %(stroke-width="2"><title>Evidence #{point.status}: #{point.value} proven</title></circle>#{label})
223
+ end.join
224
+ end
225
+
226
+ def ticket_row(row, top, x, left)
227
+ ticket = row.ticket
228
+ label = "#{ticket.key} #{ticket.title.to_s.truncate(24)}"
229
+ bars = row.segments.map do |segment|
230
+ start = x[segment.from]
231
+ length = [x[segment.to] - start, 2].max
232
+ %(<rect x="#{f(start)}" y="#{top + 6}" width="#{f(length)}" height="18" rx="3" ) +
233
+ %(fill="#{COLORS[segment.phase]}">) +
234
+ %(<title>#{esc(ticket.key)} · #{esc(Statistics::PHASES[segment.phase])} · ) +
235
+ %(#{Statistics.duration(segment.seconds)}</title></rect>)
236
+ end
237
+ finish = x[row.segments.last.to]
238
+ total = row.merged? ? Statistics.duration(row.cycle_time) : "open"
239
+ rounds = row.review_rounds.positive? ? " · #{row.review_rounds} fix" : ""
240
+ %(<text x="#{left - 12}" y="#{top + 19}" text-anchor="end" fill="#{INK}">#{esc(label)}</text>) +
241
+ bars.join + %(<text x="#{f(finish + 8)}" y="#{top + 19}" fill="#{MUTED}">#{esc(total + rounds)}</text>)
242
+ end
243
+
244
+ def f(number)
245
+ number.to_f.round(1).to_s.delete_suffix(".0")
246
+ end
247
+
248
+ def esc(text)
249
+ CGI.escapeHTML(text.to_s)
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ class CLI
5
+ # The interview's questions, and connecting the services with a key that's checked first.
6
+ module ConfigCommands
7
+ def cmd_questions
8
+ config = PlanDriven.configuration
9
+ ui.table(%w[Key Source Asks], config.template.asked.map do |section|
10
+ [section.key, Interview.origin(section.key, template: config.base_template), section.prompt]
11
+ end)
12
+ ui.muted "Changes are in #{Interview::PATH}; commit it so the team gets the same interview."
13
+ end
14
+
15
+ def cmd_question(key = nil)
16
+ raise ArgumentError, "Which question? Pass its key, for example who." if key.to_s.empty?
17
+
18
+ base = PlanDriven.configuration.base_template
19
+ if @options[:remove]
20
+ result = Interview.remove(key, template: base)
21
+ ui.success(result == :removed ? "Question #{key} removed" : "Question #{key} is back to the default")
22
+ else
23
+ result = Interview.change(key, question_fields, template: base)
24
+ ui.success "Question #{key} #{result}"
25
+ end
26
+ cmd_questions
27
+ end
28
+
29
+ def cmd_connect(name = nil)
30
+ service = Connections.find(name)
31
+ ui.muted "#{service.title}: #{service.purpose}. Get a key at #{service.url}"
32
+ value = ui.secret("#{service.title} key:")
33
+ raise ArgumentError, "No key given; nothing was stored." if value.empty?
34
+
35
+ ui.say "Checking the key with #{service.title}..."
36
+ detail = verify_connection(service, value)
37
+ path = Credentials.store(service.key, value)
38
+ ui.success "#{service.title}: #{detail}"
39
+ ui.muted " stored in #{path} (0600), never in the app"
40
+ return if ENV[service.env].to_s.strip.empty?
41
+
42
+ ui.warn "#{service.env} is set in this environment, and it wins over the stored key."
43
+ end
44
+
45
+ private
46
+
47
+ def question_fields
48
+ fields = { "title" => @options[:title], "question" => @options[:ask], "group" => @options[:group] }.compact
49
+ fields["required"] = @options[:required] unless @options[:required].nil?
50
+ fields
51
+ end
52
+
53
+ def verify_connection(service, value)
54
+ Connections.verify(service, value)
55
+ rescue Error => e
56
+ raise ProviderError, "#{e.message}; nothing was stored."
57
+ end
58
+ end
59
+ end
60
+ end
@@ -44,7 +44,11 @@ module PlanDriven
44
44
  plan = find_plan(reference)
45
45
  section = PlanDriven.configuration.template[key] or
46
46
  raise ArgumentError, "Which section? One of: #{PlanDriven.configuration.template.keys.join(", ")}"
47
- text = edit_in_editor(plan.section(section.key), "#{plan.key}-#{section.key}")
47
+ text = if @options[:from]
48
+ File.read(@options[:from], encoding: "UTF-8")
49
+ else
50
+ edit_in_editor(plan.section(section.key), "#{plan.key}-#{section.key}")
51
+ end
48
52
  return ui.muted("No change.") if text.strip == plan.section(section.key).strip
49
53
 
50
54
  delivery.edit_section(plan, section.key, text)
@@ -124,7 +128,7 @@ module PlanDriven
124
128
  PlanDriven.configuration.template.asked.to_h do |section|
125
129
  answer = ""
126
130
  loop do
127
- answer = ui.ask_multiline("#{section.title}: #{section.question}")
131
+ answer = ui.ask_multiline("#{section.title}: #{section.prompt}")
128
132
  break unless section.required && answer.strip.empty?
129
133
 
130
134
  ui.warn "#{section.title} is required."