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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +74 -1
- data/README.md +437 -37
- data/app/controllers/plan_driven/wizard/application_controller.rb +59 -0
- data/app/controllers/plan_driven/wizard/configuration_controller.rb +62 -0
- data/app/controllers/plan_driven/wizard/jobs_controller.rb +16 -0
- data/app/controllers/plan_driven/wizard/plans_controller.rb +113 -0
- data/app/views/layouts/plan_driven/wizard/application.html.erb +151 -0
- data/app/views/plan_driven/wizard/configuration/show.html.erb +104 -0
- data/app/views/plan_driven/wizard/plans/_agents.html.erb +44 -0
- data/app/views/plan_driven/wizard/plans/_approve.html.erb +40 -0
- data/app/views/plan_driven/wizard/plans/_finish.html.erb +31 -0
- data/app/views/plan_driven/wizard/plans/_plan.html.erb +52 -0
- data/app/views/plan_driven/wizard/plans/_tickets.html.erb +42 -0
- data/app/views/plan_driven/wizard/plans/index.html.erb +31 -0
- data/app/views/plan_driven/wizard/plans/new.html.erb +21 -0
- data/app/views/plan_driven/wizard/plans/show.html.erb +37 -0
- data/app/views/plan_driven/wizard/plans/statistics.html.erb +94 -0
- data/config/routes.rb +17 -0
- data/exe/plan-driven +1 -0
- data/lib/generators/plan_driven/install_generator.rb +7 -0
- data/lib/generators/plan_driven/templates/plan_driven.rb +10 -1
- data/lib/plan_driven/charts.rb +252 -0
- data/lib/plan_driven/cli/config_commands.rb +60 -0
- data/lib/plan_driven/cli/plan_commands.rb +6 -2
- data/lib/plan_driven/cli/setup_commands.rb +10 -0
- data/lib/plan_driven/cli/ticket_commands.rb +64 -3
- data/lib/plan_driven/cli/ui.rb +41 -2
- data/lib/plan_driven/cli.rb +21 -4
- data/lib/plan_driven/configuration.rb +31 -3
- data/lib/plan_driven/connections.rb +69 -0
- data/lib/plan_driven/cursor_agents.rb +10 -2
- data/lib/plan_driven/delivery.rb +23 -5
- data/lib/plan_driven/evidence.rb +11 -4
- data/lib/plan_driven/github.rb +4 -0
- data/lib/plan_driven/guards/migration_guard.rb +86 -9
- data/lib/plan_driven/guards/ticket_guard.rb +1 -2
- data/lib/plan_driven/interview.rb +161 -0
- data/lib/plan_driven/local_agents.rb +254 -0
- data/lib/plan_driven/renderer/html.rb +34 -5
- data/lib/plan_driven/renderer/markdown.rb +67 -3
- data/lib/plan_driven/renderer/style.css +21 -0
- data/lib/plan_driven/renderer.rb +11 -4
- data/lib/plan_driven/statistics.rb +195 -0
- data/lib/plan_driven/template.rb +7 -2
- data/lib/plan_driven/usage.rb +114 -0
- data/lib/plan_driven/version.rb +1 -1
- data/lib/plan_driven/wizard/engine.rb +19 -0
- data/lib/plan_driven/wizard.rb +245 -0
- data/lib/plan_driven.rb +8 -1
- 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
|
@@ -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
|
-
#
|
|
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 =
|
|
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.
|
|
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."
|