plan_driven 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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +76 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +823 -0
  5. data/exe/plan-driven +7 -0
  6. data/lib/generators/plan_driven/install_generator.rb +37 -0
  7. data/lib/generators/plan_driven/templates/create_plan_driven_tables.rb.tt +73 -0
  8. data/lib/generators/plan_driven/templates/plan_driven.rb +39 -0
  9. data/lib/plan_driven/agent_prompt.rb +86 -0
  10. data/lib/plan_driven/cli/plan_commands.rb +158 -0
  11. data/lib/plan_driven/cli/setup_commands.rb +97 -0
  12. data/lib/plan_driven/cli/ticket_commands.rb +155 -0
  13. data/lib/plan_driven/cli/ui.rb +101 -0
  14. data/lib/plan_driven/cli.rb +150 -0
  15. data/lib/plan_driven/configuration.rb +114 -0
  16. data/lib/plan_driven/credentials.rb +90 -0
  17. data/lib/plan_driven/cursor_agents.rb +82 -0
  18. data/lib/plan_driven/cursor_llm.mjs +70 -0
  19. data/lib/plan_driven/cursor_llm.rb +79 -0
  20. data/lib/plan_driven/delivery.rb +362 -0
  21. data/lib/plan_driven/drafter.rb +122 -0
  22. data/lib/plan_driven/errors.rb +22 -0
  23. data/lib/plan_driven/evidence.rb +108 -0
  24. data/lib/plan_driven/gherkin.rb +42 -0
  25. data/lib/plan_driven/github.rb +118 -0
  26. data/lib/plan_driven/guards/migration_guard.rb +94 -0
  27. data/lib/plan_driven/guards/plan_guard.rb +84 -0
  28. data/lib/plan_driven/guards/pr_guard.rb +140 -0
  29. data/lib/plan_driven/guards/ticket_guard.rb +178 -0
  30. data/lib/plan_driven/guards/ticket_normalizer.rb +90 -0
  31. data/lib/plan_driven/guards.rb +57 -0
  32. data/lib/plan_driven/http.rb +60 -0
  33. data/lib/plan_driven/json_reply.rb +26 -0
  34. data/lib/plan_driven/llm.rb +80 -0
  35. data/lib/plan_driven/models/approval.rb +12 -0
  36. data/lib/plan_driven/models/event.rb +13 -0
  37. data/lib/plan_driven/models/evidence_run.rb +17 -0
  38. data/lib/plan_driven/models/plan.rb +98 -0
  39. data/lib/plan_driven/models/record.rb +8 -0
  40. data/lib/plan_driven/models/ticket.rb +58 -0
  41. data/lib/plan_driven/models.rb +8 -0
  42. data/lib/plan_driven/railtie.rb +9 -0
  43. data/lib/plan_driven/renderer/html.rb +106 -0
  44. data/lib/plan_driven/renderer/markdown.rb +145 -0
  45. data/lib/plan_driven/renderer/pdf.rb +39 -0
  46. data/lib/plan_driven/renderer/style.css +22 -0
  47. data/lib/plan_driven/renderer.rb +41 -0
  48. data/lib/plan_driven/repository.rb +35 -0
  49. data/lib/plan_driven/schema_context.rb +98 -0
  50. data/lib/plan_driven/template.rb +113 -0
  51. data/lib/plan_driven/ticket_generator.rb +98 -0
  52. data/lib/plan_driven/version.rb +5 -0
  53. data/lib/plan_driven/workflow.rb +59 -0
  54. data/lib/plan_driven.rb +72 -0
  55. metadata +146 -0
@@ -0,0 +1,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ # The whole process, one method per step. Each step checks the phase, runs its guard, records
5
+ # who did it, and leaves documentation behind. The CLI is a thin layer over this.
6
+ class Delivery
7
+ attr_reader :actor, :config
8
+
9
+ def initialize(actor: PlanDriven.actor, config: PlanDriven.configuration, llm: nil, schema: nil,
10
+ github: nil, agents: nil)
11
+ @actor = actor
12
+ @config = config
13
+ @llm = llm
14
+ @schema = schema
15
+ @github = github
16
+ @agents = agents
17
+ end
18
+
19
+ # -- Plan -------------------------------------------------------------------------------------
20
+
21
+ def create_plan(title:, answers:)
22
+ result = drafter.draft(answers, title: title)
23
+ plan = Plan.create!(title: title, interview: answers, sections: result.sections, created_by: actor,
24
+ guard_report: result.report.to_h)
25
+ plan.log!("plan.drafted", actor: actor, model: llm.label, attempts: result.attempts,
26
+ assumptions: result.assumptions)
27
+ Renderer.write_plan(plan, config: config)
28
+ [plan, result]
29
+ end
30
+
31
+ def check_plan(plan)
32
+ report = Guards::PlanGuard.new(plan.sections, schema: schema, template: config.template).call
33
+ plan.update!(guard_report: report.to_h)
34
+ report
35
+ end
36
+
37
+ def edit_section(plan, key, text)
38
+ plan.update_sections!({ key => text }, actor: actor)
39
+ check_plan(plan)
40
+ Renderer.write_plan(plan, config: config)
41
+ end
42
+
43
+ def redraft_section(plan, key, instruction)
44
+ text = drafter.redraft(plan.sections, key, instruction)
45
+ edit_section(plan, key, text)
46
+ text
47
+ end
48
+
49
+ def submit(plan)
50
+ report = check_plan(plan)
51
+ raise GuardError, report.errors unless report.ok?
52
+
53
+ Workflow.plan_transition!(plan, "in_review")
54
+ plan.log!("plan.submitted", actor: actor, revision: plan.revision, warnings: report.warnings)
55
+ Renderer.write_plan(plan, config: config)
56
+ end
57
+
58
+ def approve_plan(plan, role:, note: nil)
59
+ decide(plan, role: role, decision: "approved", note: note)
60
+ if plan.missing_approvals.empty?
61
+ Workflow.plan_transition!(plan, "approved")
62
+ plan.log!("plan.approved", actor: actor, revision: plan.revision)
63
+ end
64
+ Renderer.write_plan(plan, config: config)
65
+ plan.missing_approvals
66
+ end
67
+
68
+ def reject_plan(plan, role:, note:)
69
+ decide(plan, role: role, decision: "rejected", note: note)
70
+ Workflow.plan_transition!(plan, "draft")
71
+ plan.log!("plan.changes_requested", actor: actor, note: note)
72
+ Renderer.write_plan(plan, config: config)
73
+ end
74
+
75
+ # -- Tickets ----------------------------------------------------------------------------------
76
+
77
+ def draft_tickets(plan, instruction: nil)
78
+ require_status!(plan, %w[approved ticketed])
79
+ result = TicketGenerator.new(llm: llm, schema: schema, config: config).generate(plan, instruction: instruction)
80
+ raise GuardError, result.report.errors unless result.report.ok?
81
+
82
+ replace_tickets(plan, result)
83
+ Renderer.write_plan(plan, config: config)
84
+ result
85
+ end
86
+
87
+ def approve_tickets(plan, role:, note: nil)
88
+ require_status!(plan, %w[ticketed])
89
+ decide(plan, role: "tickets:#{role}", decision: "approved", note: note)
90
+ return plan.missing_ticket_approvals if plan.missing_ticket_approvals.any?
91
+
92
+ Plan.transaction do
93
+ plan.tickets.each { |ticket| Workflow.ticket_transition!(ticket, "approved") }
94
+ Workflow.plan_transition!(plan, "tickets_approved")
95
+ plan.log!("tickets.approved", actor: actor, count: plan.tickets.size)
96
+ end
97
+ sync_issues(plan) if config.sync_issues
98
+ Renderer.write_plan(plan, config: config)
99
+ []
100
+ end
101
+
102
+ def sync_issues(plan)
103
+ plan.tickets.reject(&:issue_number).each do |ticket|
104
+ issue = github.create_issue(title: "[#{ticket.reference}] #{ticket.title}", body: issue_body(ticket),
105
+ labels: config.issue_labels + [plan.key.downcase, ticket.kind])
106
+ ticket.update!(issue_number: issue["number"])
107
+ plan.log!("ticket.issue_created", actor: actor, ticket: ticket, issue: issue["number"])
108
+ end
109
+ end
110
+
111
+ # -- Development ------------------------------------------------------------------------------
112
+
113
+ # Ready tickets, within the parallel agent limit, optionally narrowed to the given keys.
114
+ def startable(plan, only: nil)
115
+ capacity = [config.max_parallel_agents - plan.tickets.count(&:active_agent?), 0].max
116
+ candidates = plan.tickets.select(&:ready?)
117
+ keys = Array(only).map(&:upcase)
118
+ candidates = candidates.select { |ticket| keys.include?(ticket.key) } if keys.any?
119
+ candidates.first(capacity)
120
+ end
121
+
122
+ def develop(plan, only: nil)
123
+ require_status!(plan, %w[tickets_approved in_development])
124
+ launched = startable(plan, only: only).map { |ticket| launch(ticket) }
125
+ Workflow.plan_transition!(plan, "in_development") if launched.any? && plan.status == "tickets_approved"
126
+ launched
127
+ end
128
+
129
+ def launch(ticket, prompt: AgentPrompt.new(ticket, config: config))
130
+ agent, run = agents.launch(prompt: prompt.to_s, repo_url: github.repo_url, name: prompt.pr_title)
131
+ Workflow.ticket_transition!(ticket, "running")
132
+ ticket.update!(agent_id: agent["id"], agent_run_id: run.id, agent_url: agent["url"])
133
+ ticket.plan.log!("ticket.agent_started", actor: actor, ticket: ticket, agent: agent["id"], run: run.id)
134
+ ticket
135
+ end
136
+
137
+ # Polls running agents and open pull requests, moving tickets along as work lands.
138
+ def refresh(plan)
139
+ plan.tickets.each do |ticket|
140
+ case ticket.status
141
+ when "running" then refresh_agent(ticket)
142
+ when "pr_open", "pr_approved" then refresh_pull(ticket)
143
+ end
144
+ end
145
+ finish(plan)
146
+ plan.tickets.reset
147
+ end
148
+
149
+ def review(ticket)
150
+ require_ticket_status!(ticket, %w[pr_open pr_approved])
151
+ pull = github.pull(ticket.pr_number)
152
+ files = github.pull_files(ticket.pr_number)
153
+ features = feature_files(files, pull.dig("head", "sha"))
154
+ checks = github.checks(pull.dig("head", "sha"))
155
+ report = Guards::PrGuard.new(ticket, pull: pull, files: files, checks: checks, features: features,
156
+ behind: github.behind_by(pull), config: config).call
157
+ ticket.update!(guard_report: report.to_h)
158
+ ticket.plan.log!("ticket.reviewed", actor: actor, ticket: ticket, errors: report.errors.size,
159
+ warnings: report.warnings.size)
160
+ report
161
+ end
162
+
163
+ def approve_pr(ticket, note: nil)
164
+ report = review(ticket)
165
+ raise GuardError, report.errors unless report.ok?
166
+
167
+ decide(ticket, role: "pr", decision: "approved", note: note)
168
+ Workflow.ticket_transition!(ticket, "pr_approved") if ticket.status == "pr_open"
169
+ ticket.plan.log!("ticket.pr_approved", actor: actor, ticket: ticket, pr: ticket.pr_number)
170
+ github.review(ticket.pr_number, body: "Approved in plan-driven by #{actor}. #{note}".strip)
171
+ report
172
+ end
173
+
174
+ def request_changes(ticket, feedback)
175
+ require_ticket_status!(ticket, %w[pr_open pr_approved failed])
176
+ decide(ticket, role: "pr", decision: "rejected", note: feedback) unless ticket.status == "failed"
177
+ Workflow.ticket_transition!(ticket, "changes_requested") unless ticket.status == "failed"
178
+ run = agents.follow_up(ticket.agent_id, follow_up_prompt(ticket, feedback))
179
+ Workflow.ticket_transition!(ticket, "running")
180
+ ticket.update!(agent_run_id: run.id)
181
+ ticket.plan.log!("ticket.changes_requested", actor: actor, ticket: ticket, feedback: feedback, run: run.id)
182
+ ticket
183
+ end
184
+
185
+ def merge(ticket)
186
+ require_ticket_status!(ticket, %w[pr_approved])
187
+ require_mergeable!(ticket)
188
+ github.ready_for_review(ticket.pr_number) if github.pull(ticket.pr_number)["draft"]
189
+ result = github.merge(ticket.pr_number, title: "#{ticket.title} (#{ticket.reference})",
190
+ method: config.merge_method)
191
+ ticket.update!(merged_sha: result["sha"])
192
+ Workflow.ticket_transition!(ticket, "merged")
193
+ ticket.plan.log!("ticket.merged", actor: actor, ticket: ticket, sha: result["sha"])
194
+ finish(ticket.plan)
195
+ ticket
196
+ end
197
+
198
+ # -- Evidence and report ----------------------------------------------------------------------
199
+
200
+ def evidence(plan, command: nil)
201
+ Evidence.run_cucumber(plan, actor: actor, command: command)
202
+ end
203
+
204
+ def report(plan)
205
+ plan.log!("report.written", actor: actor)
206
+ Renderer.write_report(plan, config: config)
207
+ end
208
+
209
+ private
210
+
211
+ def require_mergeable!(ticket)
212
+ report = review(ticket)
213
+ raise GuardError, report.errors unless report.ok?
214
+ return unless report.warnings.any? { |warning| warning.include?("still running") }
215
+
216
+ raise GuardError, ["CI is still running; wait for it to finish"]
217
+ end
218
+
219
+ def decide(record, role:, decision:, note:)
220
+ plan = record.is_a?(Plan) ? record : record.plan
221
+ if record.is_a?(Plan) && !role.start_with?("tickets:")
222
+ require_status!(plan, %w[in_review])
223
+ allowed = config.plan_approvals.map(&:to_s)
224
+ unless allowed.include?(role.to_s)
225
+ raise ArgumentError,
226
+ "unknown role #{role}; this project approves as #{allowed.join(", ")}"
227
+ end
228
+ end
229
+ record.approvals.create!(role: role.to_s, decision: decision, actor: actor, note: note, revision: plan.revision)
230
+ end
231
+
232
+ def refresh_agent(ticket)
233
+ run = agents.run(ticket.agent_id, ticket.agent_run_id)
234
+ return unless run.terminal?
235
+
236
+ if run.finished? && run.pr_url
237
+ ticket.update!(pr_url: run.pr_url, pr_number: GitHub.pr_number(run.pr_url), branch: run.branch)
238
+ Workflow.ticket_transition!(ticket, "pr_open")
239
+ ticket.plan.log!("ticket.pr_opened", actor: "cursor-agent", ticket: ticket, pr: run.pr_url)
240
+ else
241
+ Workflow.ticket_transition!(ticket, "failed")
242
+ ticket.plan.log!("ticket.agent_failed", actor: "cursor-agent", ticket: ticket, status: run.status,
243
+ result: run.result.to_s[0, 500])
244
+ end
245
+ end
246
+
247
+ def refresh_pull(ticket)
248
+ pull = github.pull(ticket.pr_number)
249
+ return unless pull["merged"]
250
+
251
+ ticket.update!(merged_sha: pull["merge_commit_sha"])
252
+ Workflow.ticket_transition!(ticket, "merged")
253
+ ticket.plan.log!("ticket.merged", actor: pull.dig("merged_by", "login") || "github", ticket: ticket,
254
+ sha: pull["merge_commit_sha"], outside_plan_driven: true)
255
+ end
256
+
257
+ def finish(plan)
258
+ plan.tickets.reset
259
+ return unless plan.status == "in_development" && plan.tickets.all? { |ticket| ticket.status == "merged" }
260
+
261
+ Workflow.plan_transition!(plan, "delivered")
262
+ plan.log!("plan.delivered", actor: actor)
263
+ end
264
+
265
+ def feature_files(files, sha)
266
+ files.select { |file| file["filename"].end_with?(".feature") && file["status"] != "removed" }
267
+ .to_h { |file| [file["filename"], github.file(file["filename"], ref: sha)] }
268
+ end
269
+
270
+ def follow_up_prompt(ticket, feedback)
271
+ report = Guards::Report.from_h(ticket.guard_report)
272
+ checks = if report.errors.any?
273
+ "\n\nThe automated checks also found:\n#{report.errors.map do |e|
274
+ "- #{e}"
275
+ end.join("\n")}"
276
+ else
277
+ ""
278
+ end
279
+ "Review feedback on the pull request for #{ticket.reference}:\n\n#{feedback}#{checks}\n\n" \
280
+ "Push the fixes to the same branch. Keep the pull request title and description format."
281
+ end
282
+
283
+ def issue_body(ticket)
284
+ criteria = ticket.criteria.each_with_index.map { |criterion, index| "- [ ] #{index + 1}. #{unlinked(criterion)}" }
285
+ notes = ticket.implementation_notes.presence
286
+ <<~MD
287
+ #{unlinked(ticket.story)}
288
+
289
+ #{unlinked(ticket.description)}
290
+
291
+ ### Acceptance criteria
292
+ #{criteria.join("\n")}
293
+ #{"\n### Implementation notes\n#{unlinked(notes)}\n" if notes}
294
+ ---
295
+ Plan #{ticket.plan.key}: #{ticket.plan.title} · kind: #{ticket.kind} · estimate: #{ticket.estimate}
296
+ #{"· depends on #{ticket.dependencies.join(", ")}" if ticket.dependencies.any?}
297
+ MD
298
+ end
299
+
300
+ # GitHub links "#1" and "@name" anywhere outside code, so "You're #1 on the waitlist" would point
301
+ # at an unrelated pull request and "(@event)" would mention a user. An empty comment breaks
302
+ # the link and renders nothing.
303
+ def unlinked(text)
304
+ text.to_s.split(/(```.*?```|`[^`\n]*`)/m).each_with_index.map do |part, index|
305
+ index.odd? ? part : part.gsub(/(?<![\w&])([#@])(?=\w)/, '\1<!-- -->')
306
+ end.join
307
+ end
308
+
309
+ def replace_tickets(plan, result)
310
+ Plan.transaction do
311
+ plan.tickets.destroy_all
312
+ result.tickets.each { |attributes| plan.tickets.create!(ticket_attributes(attributes)) }
313
+ Workflow.plan_transition!(plan, "ticketed") if plan.status == "approved"
314
+ plan.log!("tickets.drafted", actor: actor, count: result.tickets.size, attempts: result.attempts,
315
+ fixes: result.report.fixes, warnings: result.report.warnings)
316
+ end
317
+ plan.tickets.reset
318
+ end
319
+
320
+ def ticket_attributes(attributes)
321
+ {
322
+ key: attributes["key"], position: attributes["position"], title: attributes["title"],
323
+ kind: attributes["kind"], ticket_type: attributes["type"], story: attributes["story"],
324
+ description: attributes["description"], acceptance_criteria: attributes["acceptance_criteria"],
325
+ implementation_notes: attributes["implementation_notes"], estimate: attributes["estimate"],
326
+ depends_on: attributes["depends_on"], touches: attributes["touches"]
327
+ }
328
+ end
329
+
330
+ def require_status!(plan, statuses)
331
+ return if statuses.include?(plan.status)
332
+
333
+ raise TransitionError, "plan #{plan.key} is #{plan.status} (#{Workflow::PLAN_DESCRIPTIONS[plan.status]})"
334
+ end
335
+
336
+ def require_ticket_status!(ticket, statuses)
337
+ return if statuses.include?(ticket.status)
338
+
339
+ raise TransitionError, "ticket #{ticket.reference} is #{ticket.status}; this needs #{statuses.join(" or ")}"
340
+ end
341
+
342
+ def drafter
343
+ Drafter.new(llm: llm, schema: schema, config: config)
344
+ end
345
+
346
+ def llm
347
+ @llm ||= LLM.new(config)
348
+ end
349
+
350
+ def schema
351
+ @schema ||= SchemaContext.new
352
+ end
353
+
354
+ def github
355
+ @github ||= GitHub.new
356
+ end
357
+
358
+ def agents
359
+ @agents ||= CursorAgents.new(config: config)
360
+ end
361
+ end
362
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ # Turns interview answers into the drafted sections of a plan, then keeps the model honest:
5
+ # the guard runs on every draft, and its errors go back to the model as a list to fix.
6
+ class Drafter
7
+ Result = Struct.new(:sections, :report, :attempts, :assumptions, keyword_init: true)
8
+
9
+ def initialize(llm: LLM.new, schema: SchemaContext.new, config: PlanDriven.configuration)
10
+ @llm = llm
11
+ @schema = schema
12
+ @config = config
13
+ @template = config.template
14
+ end
15
+
16
+ def draft(answers, title:)
17
+ answers = answers.transform_keys(&:to_s)
18
+ messages = [{ role: "user", content: request(answers, title) }]
19
+ attempts = 0
20
+
21
+ loop do
22
+ attempts += 1
23
+ reply = @llm.chat(system: system_prompt, messages: messages)
24
+ begin
25
+ parsed = parse(reply.text)
26
+ rescue InvalidResponseError => e
27
+ raise if attempts > @config.max_repair_attempts
28
+
29
+ messages += [{ role: "assistant", content: reply.text },
30
+ { role: "user",
31
+ content: "That reply can't be used: #{e.message}. Reply with the JSON object only." }]
32
+ next
33
+ end
34
+ sections = answers.merge(parsed.fetch("sections", {}).slice(*@template.drafted.map(&:key)))
35
+ report = Guards::PlanGuard.new(sections, schema: @schema, template: @template).call
36
+ if report.ok? || attempts > @config.max_repair_attempts
37
+ return Result.new(sections: sections, report: report, attempts: attempts,
38
+ assumptions: Array(parsed["assumptions"]))
39
+ end
40
+
41
+ messages += [{ role: "assistant", content: reply.text }, { role: "user", content: repair(report) }]
42
+ end
43
+ end
44
+
45
+ # One section again, with an instruction from the reviewer ("shorter", "add the index").
46
+ def redraft(sections, key, instruction)
47
+ section = @template[key] or raise ArgumentError, "unknown section #{key}"
48
+ content = <<~TEXT
49
+ The current plan, as JSON:
50
+ #{JSON.pretty_generate(sections)}
51
+
52
+ Rewrite only the section "#{section.key}" (#{section.title}). Reviewer's instruction: #{instruction}
53
+ Guidance for this section: #{section.guidance}
54
+ Reply with JSON: {"sections": {"#{section.key}": "..."}}
55
+ TEXT
56
+ reply = @llm.chat(system: system_prompt, messages: [{ role: "user", content: content }])
57
+ parse(reply.text).dig("sections", section.key).to_s
58
+ end
59
+
60
+ def system_prompt
61
+ <<~PROMPT
62
+ You write implementation plans for a Ruby on Rails team. A plan is read by developers,
63
+ QA, DevOps and a director, then split into tickets that coding agents implement.
64
+
65
+ Write in plain, specific English. Name real models, tables, columns and file paths.
66
+ Follow Rails conventions. Changes are additive and legacy-safe: expand, dual write,
67
+ backfill, switch reads, then contract. Never describe something as existing unless it
68
+ is in the schema you are given. When you don't know, say so under Outstanding questions.
69
+
70
+ Reply with one JSON object and nothing else:
71
+ {"sections": {"<key>": "<markdown>", ...}, "assumptions": ["..."]}
72
+
73
+ Sections to write, by key:
74
+ #{@template.drafted.map { |section| "- #{section.key} (#{section.title}): #{section.guidance}" }.join("\n")}
75
+ PROMPT
76
+ end
77
+
78
+ private
79
+
80
+ def request(answers, title)
81
+ asked = @template.asked.filter_map do |section|
82
+ value = answers[section.key].to_s.strip
83
+ "#{section.title}:\n#{value}" unless value.empty?
84
+ end
85
+ <<~TEXT
86
+ Plan title: #{title}
87
+
88
+ The team's answers:
89
+ #{asked.join("\n\n")}
90
+
91
+ The application's schema:
92
+ #{@schema.to_prompt}
93
+ #{"\nMore context:\n#{@config.extra_context}" if @config.extra_context}
94
+ TEXT
95
+ end
96
+
97
+ def repair(report)
98
+ <<~TEXT
99
+ The plan was checked and these problems must be fixed:
100
+ #{report.errors.map { |error| "- #{error}" }.join("\n")}
101
+
102
+ Reply with the full JSON again, every section included.
103
+ TEXT
104
+ end
105
+
106
+ def parse(text)
107
+ parsed = JsonReply.parse(text)
108
+ raise InvalidResponseError, "the reply had no \"sections\" object" unless parsed["sections"].is_a?(Hash)
109
+
110
+ parsed["sections"] = parsed["sections"].transform_values { |value| stringify(value) }
111
+ parsed
112
+ end
113
+
114
+ def stringify(value)
115
+ case value
116
+ when Array then value.map { |item| "- #{item}" }.join("\n")
117
+ when Hash then value.map { |heading, body| "### #{heading}\n#{stringify(body)}" }.join("\n\n")
118
+ else value.to_s
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ class Error < StandardError; end
5
+ class ConfigurationError < Error; end
6
+ class ProviderError < Error; end
7
+ class InvalidResponseError < Error; end
8
+
9
+ # A phase transition that the workflow doesn't allow, such as generating tickets for a plan
10
+ # nobody has approved.
11
+ class TransitionError < Error; end
12
+
13
+ # A guard found problems that block the next step. `problems` holds the messages.
14
+ class GuardError < Error
15
+ attr_reader :problems
16
+
17
+ def initialize(problems, message = nil)
18
+ @problems = Array(problems)
19
+ super(message || "#{@problems.size} problem(s) block this step:\n#{@problems.map { |p| " - #{p}" }.join("\n")}")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tmpdir"
5
+
6
+ module PlanDriven
7
+ # Proof that the delivered code does what the plan promised: every acceptance criterion maps to
8
+ # a Cucumber scenario tagged `@<plan>-<ticket> @ac-N`, and the scenarios' results are stored with
9
+ # the commit they ran against.
10
+ module Evidence
11
+ Row = Struct.new(:ticket, :number, :criterion, :scenarios, keyword_init: true) do
12
+ def status
13
+ return "no scenario" if scenarios.empty?
14
+ return "passed" if scenarios.all? { |scenario| scenario["status"] == "passed" }
15
+
16
+ scenarios.any? { |scenario| scenario["status"] == "failed" } ? "failed" : "not run"
17
+ end
18
+ end
19
+
20
+ module_function
21
+
22
+ def tag_expression(plan)
23
+ plan.tickets.map(&:feature_tag).join(" or ")
24
+ end
25
+
26
+ def run_cucumber(plan, actor:, command: nil, root: PlanDriven.configuration.root_path)
27
+ Dir.mktmpdir do |dir|
28
+ out = File.join(dir, "cucumber.json")
29
+ shown = command&.join(" ") || %(bundle exec cucumber --tags "#{tag_expression(plan)}")
30
+ command ||= ["bundle", "exec", "cucumber", "--tags", tag_expression(plan), "--format", "json", "--out", out,
31
+ "--format", "progress"]
32
+ output, status = Open3.capture2e(*command, chdir: root.to_s)
33
+ json = File.exist?(out) ? File.read(out) : "[]"
34
+ record(plan, json, command: shown, actor: actor, exit_ok: status.success?, output: output)
35
+ end
36
+ end
37
+
38
+ def record(plan, json, command:, actor:, exit_ok: true, output: nil)
39
+ scenarios = parse(json)
40
+ status = if scenarios.empty? then "no scenarios"
41
+ elsif exit_ok && scenarios.all? { |scenario| scenario["status"] == "passed" } then "passed"
42
+ else "failed"
43
+ end
44
+ run = plan.evidence_runs.create!(kind: "cucumber", command: command, status: status,
45
+ commit_sha: Repository.head_sha,
46
+ results: { "scenarios" => scenarios, "output" => output.to_s.last(4000) })
47
+ plan.log!("evidence.recorded", actor: actor, status: status, scenarios: scenarios.size)
48
+ run
49
+ end
50
+
51
+ # Cucumber's JSON formatter: features -> elements (scenarios) -> steps with results.
52
+ def parse(json)
53
+ Array(JSON.parse(json.to_s.strip.empty? ? "[]" : json)).flat_map do |feature|
54
+ feature_tags = Array(feature["tags"]).map { |tag| tag["name"] }
55
+ Array(feature["elements"]).select { |element| element["type"] == "scenario" }.map do |element|
56
+ tags = (feature_tags + Array(element["tags"]).map { |tag| tag["name"] }).uniq
57
+ { "name" => element["name"], "tags" => tags, "file" => "#{feature["uri"]}:#{element["line"]}",
58
+ "status" => scenario_status(element) }
59
+ end
60
+ end
61
+ rescue JSON::ParserError
62
+ []
63
+ end
64
+
65
+ def scenario_status(element)
66
+ statuses = (Array(element["before"]) + Array(element["steps"]) + Array(element["after"]))
67
+ .map { |step| step.dig("result", "status") }
68
+ return "failed" if statuses.include?("failed")
69
+ return "passed" if statuses.any? && statuses.all? { |status| %w[passed skipped].include?(status) } &&
70
+ Array(element["steps"]).all? { |step| step.dig("result", "status") == "passed" }
71
+
72
+ "not run"
73
+ end
74
+
75
+ def matrix(plan)
76
+ scenarios = plan.evidence_runs.last&.scenarios || []
77
+ plan.tickets.flat_map do |ticket|
78
+ ticket.criteria.each_with_index.map do |criterion, index|
79
+ matching = scenarios.select do |scenario|
80
+ scenario["tags"].include?(ticket.feature_tag) && scenario["tags"].include?("@ac-#{index + 1}")
81
+ end
82
+ Row.new(ticket: ticket, number: index + 1, criterion: criterion, scenarios: matching)
83
+ end
84
+ end
85
+ end
86
+
87
+ def matrix_markdown(plan)
88
+ run = plan.evidence_runs.last
89
+ return "No test evidence recorded yet. Run `plan-driven evidence #{plan.key}`." unless run
90
+
91
+ rows = matrix(plan).map do |row|
92
+ scenario = row.scenarios.map { |s| "#{s["name"]} (`#{s["file"]}`)" }.join("; ").presence || "-"
93
+ ["#{row.ticket.key}.#{row.number}", row.criterion, scenario, row.status]
94
+ end
95
+ commit = run.commit_sha.present? ? " on commit `#{run.commit_sha[0, 7]}`" : ""
96
+ intro = "Cucumber, run #{run.created_at.strftime("%-d %b %Y %H:%M")}#{commit}: `#{run.command}`"
97
+ "#{intro}\n\n#{Renderer::Markdown.table(%w[AC Criterion Scenario Result], rows)}"
98
+ end
99
+
100
+ def summary_line(plan)
101
+ return "No test evidence recorded yet." unless plan.evidence_runs.any?
102
+
103
+ rows = matrix(plan)
104
+ passed = rows.count { |row| row.status == "passed" }
105
+ "#{passed} of #{rows.size} acceptance criteria are proven by a passing scenario."
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlanDriven
4
+ # Just enough Gherkin to know which scenarios exist and how they're tagged. Tags on the
5
+ # Feature line apply to every scenario in it, as they do in Cucumber.
6
+ module Gherkin
7
+ Scenario = Struct.new(:name, :tags, :line, :path, :steps, keyword_init: true)
8
+ Feature = Struct.new(:name, :tags, :path, :scenarios, keyword_init: true)
9
+
10
+ SCENARIO = /\A\s*(Scenario(?: Outline| Template)?|Example):\s*(.*)\z/
11
+ FEATURE = /\A\s*(Feature|Ability|Business Need):\s*(.*)\z/
12
+ STEP = /\A\s*(Given|When|Then|And|But|\*)\s+(.*)\z/
13
+
14
+ module_function
15
+
16
+ def parse(text, path: nil)
17
+ state = { feature: Feature.new(name: nil, tags: [], path: path, scenarios: []), tags: [], scenario: nil }
18
+ text.to_s.each_line.with_index(1) { |raw, number| read_line(state, raw.strip, number) }
19
+ state[:feature]
20
+ end
21
+
22
+ def read_line(state, line, number)
23
+ return if line.empty? || line.start_with?("#")
24
+
25
+ feature = state[:feature]
26
+ if line.start_with?("@")
27
+ state[:tags].concat(line.split(/\s+/).grep(/\A@/))
28
+ elsif (match = line.match(FEATURE))
29
+ feature.name = match[2].strip
30
+ feature.tags = state.delete(:tags)
31
+ state[:tags] = []
32
+ elsif (match = line.match(SCENARIO))
33
+ state[:scenario] = Scenario.new(name: match[2].strip, tags: (feature.tags + state[:tags]).uniq, line: number,
34
+ path: feature.path, steps: [])
35
+ feature.scenarios << state[:scenario]
36
+ state[:tags] = []
37
+ elsif state[:scenario] && (match = line.match(STEP))
38
+ state[:scenario].steps << "#{match[1]} #{match[2]}"
39
+ end
40
+ end
41
+ end
42
+ end