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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +76 -0
- data/LICENSE.txt +21 -0
- data/README.md +823 -0
- data/exe/plan-driven +7 -0
- data/lib/generators/plan_driven/install_generator.rb +37 -0
- data/lib/generators/plan_driven/templates/create_plan_driven_tables.rb.tt +73 -0
- data/lib/generators/plan_driven/templates/plan_driven.rb +39 -0
- data/lib/plan_driven/agent_prompt.rb +86 -0
- data/lib/plan_driven/cli/plan_commands.rb +158 -0
- data/lib/plan_driven/cli/setup_commands.rb +97 -0
- data/lib/plan_driven/cli/ticket_commands.rb +155 -0
- data/lib/plan_driven/cli/ui.rb +101 -0
- data/lib/plan_driven/cli.rb +150 -0
- data/lib/plan_driven/configuration.rb +114 -0
- data/lib/plan_driven/credentials.rb +90 -0
- data/lib/plan_driven/cursor_agents.rb +82 -0
- data/lib/plan_driven/cursor_llm.mjs +70 -0
- data/lib/plan_driven/cursor_llm.rb +79 -0
- data/lib/plan_driven/delivery.rb +362 -0
- data/lib/plan_driven/drafter.rb +122 -0
- data/lib/plan_driven/errors.rb +22 -0
- data/lib/plan_driven/evidence.rb +108 -0
- data/lib/plan_driven/gherkin.rb +42 -0
- data/lib/plan_driven/github.rb +118 -0
- data/lib/plan_driven/guards/migration_guard.rb +94 -0
- data/lib/plan_driven/guards/plan_guard.rb +84 -0
- data/lib/plan_driven/guards/pr_guard.rb +140 -0
- data/lib/plan_driven/guards/ticket_guard.rb +178 -0
- data/lib/plan_driven/guards/ticket_normalizer.rb +90 -0
- data/lib/plan_driven/guards.rb +57 -0
- data/lib/plan_driven/http.rb +60 -0
- data/lib/plan_driven/json_reply.rb +26 -0
- data/lib/plan_driven/llm.rb +80 -0
- data/lib/plan_driven/models/approval.rb +12 -0
- data/lib/plan_driven/models/event.rb +13 -0
- data/lib/plan_driven/models/evidence_run.rb +17 -0
- data/lib/plan_driven/models/plan.rb +98 -0
- data/lib/plan_driven/models/record.rb +8 -0
- data/lib/plan_driven/models/ticket.rb +58 -0
- data/lib/plan_driven/models.rb +8 -0
- data/lib/plan_driven/railtie.rb +9 -0
- data/lib/plan_driven/renderer/html.rb +106 -0
- data/lib/plan_driven/renderer/markdown.rb +145 -0
- data/lib/plan_driven/renderer/pdf.rb +39 -0
- data/lib/plan_driven/renderer/style.css +22 -0
- data/lib/plan_driven/renderer.rb +41 -0
- data/lib/plan_driven/repository.rb +35 -0
- data/lib/plan_driven/schema_context.rb +98 -0
- data/lib/plan_driven/template.rb +113 -0
- data/lib/plan_driven/ticket_generator.rb +98 -0
- data/lib/plan_driven/version.rb +5 -0
- data/lib/plan_driven/workflow.rb +59 -0
- data/lib/plan_driven.rb +72 -0
- metadata +146 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
module Guards
|
|
5
|
+
# Deterministic fixes applied to drafted tickets before they're checked. Anything that has
|
|
6
|
+
# exactly one right answer is corrected here and reported, instead of being sent back to the
|
|
7
|
+
# model for another attempt.
|
|
8
|
+
class TicketNormalizer
|
|
9
|
+
TITLE_PREFIX = { "migration" => "Migration: ", "backfill" => "Data migration: " }.freeze
|
|
10
|
+
KIND_ALIASES = {
|
|
11
|
+
"schema" => "migration", "db" => "migration", "database" => "migration",
|
|
12
|
+
"data_migration" => "backfill", "data" => "backfill", "feature" => "code", "application" => "code",
|
|
13
|
+
"read_switch" => "switch", "contract" => "cleanup", "removal" => "cleanup", "documentation" => "docs"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(tickets, config: PlanDriven.configuration)
|
|
17
|
+
@tickets = Array(tickets).map { |ticket| ticket.to_h.transform_keys(&:to_s) }
|
|
18
|
+
@config = config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(report)
|
|
22
|
+
keys = {}
|
|
23
|
+
tickets = @tickets.each_with_index.map { |ticket, index| normalize(ticket, index, keys, report) }
|
|
24
|
+
remap_dependencies(tickets, keys)
|
|
25
|
+
tickets
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def normalize(ticket, index, keys, report)
|
|
31
|
+
key = "T#{index + 1}"
|
|
32
|
+
keys[ticket["key"].to_s] = key unless ticket["key"].to_s.empty?
|
|
33
|
+
ticket.merge!("key" => key, "position" => index)
|
|
34
|
+
ticket["kind"] = kind(ticket["kind"], key, report)
|
|
35
|
+
ticket["type"] = ticket["type"].to_s.upcase.presence || "TASK"
|
|
36
|
+
ticket["title"] = title(ticket["title"].to_s.strip, ticket["kind"], key, report)
|
|
37
|
+
ticket["estimate"] = estimate(ticket["estimate"], key, report)
|
|
38
|
+
normalize_lists(ticket)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def normalize_lists(ticket)
|
|
42
|
+
ticket["acceptance_criteria"] = list(ticket["acceptance_criteria"])
|
|
43
|
+
ticket["depends_on"] = list(ticket["depends_on"]).map(&:upcase)
|
|
44
|
+
ticket["touches"] = list(ticket["touches"]).map { |table| table.downcase.delete("`") }
|
|
45
|
+
ticket
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def kind(value, label, report)
|
|
49
|
+
value = value.to_s.downcase.strip.tr(" -", "__")
|
|
50
|
+
return value if Ticket::KINDS.include?(value)
|
|
51
|
+
|
|
52
|
+
mapped = KIND_ALIASES[value] || "code"
|
|
53
|
+
report.fix("#{label}: kind \"#{value}\" read as #{mapped}") unless value.empty?
|
|
54
|
+
mapped
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def title(value, kind, label, report)
|
|
58
|
+
prefix = TITLE_PREFIX[kind]
|
|
59
|
+
return value if prefix.nil? || value.start_with?(prefix)
|
|
60
|
+
|
|
61
|
+
stripped = value.sub(/\A(data\s+)?migration:\s*/i, "")
|
|
62
|
+
report.fix("#{label}: title prefixed with \"#{prefix.strip}\"")
|
|
63
|
+
"#{prefix}#{stripped}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def estimate(value, label, report)
|
|
67
|
+
number = value.to_s[/\d+/]&.to_i
|
|
68
|
+
return if number.nil?
|
|
69
|
+
|
|
70
|
+
scale = @config.estimate_scale
|
|
71
|
+
return number if scale.include?(number)
|
|
72
|
+
|
|
73
|
+
rounded = scale.find { |point| point >= number } || scale.last
|
|
74
|
+
report.fix("#{label}: estimate #{number} rounded up to #{rounded}, the next point on the scale")
|
|
75
|
+
rounded
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def list(value)
|
|
79
|
+
items = value.is_a?(Array) ? value : value.to_s.split(/\n|;/)
|
|
80
|
+
items.map { |item| item.to_s.strip.sub(/\A[-*\u2022]\s*/, "") }.reject(&:empty?)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def remap_dependencies(tickets, keys)
|
|
84
|
+
tickets.each do |ticket|
|
|
85
|
+
ticket["depends_on"] = ticket["depends_on"].map { |key| keys.fetch(key, key) }.uniq
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# Rules enforced in Ruby rather than asked for in a prompt. A prompt line costs tokens on every
|
|
5
|
+
# call and a model can ignore it; a guard runs every time and says exactly what's wrong.
|
|
6
|
+
module Guards
|
|
7
|
+
# Errors block the next phase. Warnings are shown and recorded, and a human decides.
|
|
8
|
+
class Report
|
|
9
|
+
attr_reader :errors, :warnings, :fixes, :passes
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@errors = []
|
|
13
|
+
@warnings = []
|
|
14
|
+
@fixes = []
|
|
15
|
+
@passes = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def error(message) = errors << message
|
|
19
|
+
def warning(message) = warnings << message
|
|
20
|
+
def fix(message) = fixes << message
|
|
21
|
+
# What was checked and found fine, so a reviewer can see what the guard looked at.
|
|
22
|
+
def pass(message) = passes << message
|
|
23
|
+
|
|
24
|
+
def ok?
|
|
25
|
+
errors.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def merge!(other)
|
|
29
|
+
errors.concat(other.errors)
|
|
30
|
+
warnings.concat(other.warnings)
|
|
31
|
+
fixes.concat(other.fixes)
|
|
32
|
+
passes.concat(other.passes)
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_h
|
|
37
|
+
{ "errors" => errors, "warnings" => warnings, "fixes" => fixes, "passes" => passes }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.from_h(hash)
|
|
41
|
+
report = new
|
|
42
|
+
hash ||= {}
|
|
43
|
+
report.errors.concat(Array(hash["errors"]))
|
|
44
|
+
report.warnings.concat(Array(hash["warnings"]))
|
|
45
|
+
report.fixes.concat(Array(hash["fixes"]))
|
|
46
|
+
report.passes.concat(Array(hash["passes"]))
|
|
47
|
+
report
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
require_relative "guards/plan_guard"
|
|
54
|
+
require_relative "guards/migration_guard"
|
|
55
|
+
require_relative "guards/ticket_normalizer"
|
|
56
|
+
require_relative "guards/ticket_guard"
|
|
57
|
+
require_relative "guards/pr_guard"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module PlanDriven
|
|
8
|
+
# A small JSON client over net/http. The transport is swappable so the specs never touch the
|
|
9
|
+
# network: anything responding to `call(method, url, headers, body)` and returning
|
|
10
|
+
# `[status, body_string]` works.
|
|
11
|
+
class HTTP
|
|
12
|
+
Response = Struct.new(:status, :body, keyword_init: true) do
|
|
13
|
+
def success?
|
|
14
|
+
status.between?(200, 299)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def json
|
|
18
|
+
body.to_s.strip.empty? ? {} : JSON.parse(body)
|
|
19
|
+
rescue JSON::ParserError
|
|
20
|
+
{}
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
attr_writer :transport
|
|
26
|
+
|
|
27
|
+
def transport
|
|
28
|
+
@transport ||= method(:net_http)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset!
|
|
32
|
+
@transport = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def request(method, url, headers: {}, body: nil, timeout: 60)
|
|
36
|
+
payload = body.nil? || body.is_a?(String) ? body : JSON.generate(body)
|
|
37
|
+
status, text = transport.call(method.to_s.upcase, url, headers, payload, timeout)
|
|
38
|
+
Response.new(status: status.to_i, body: text.to_s)
|
|
39
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
40
|
+
raise ProviderError, "#{URI(url).host} did not respond within #{timeout}s"
|
|
41
|
+
rescue SocketError, SystemCallError => e
|
|
42
|
+
raise ProviderError, "Could not reach #{URI(url).host}: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def net_http(method, url, headers, body, timeout)
|
|
48
|
+
uri = URI(url)
|
|
49
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
50
|
+
http.use_ssl = uri.scheme == "https"
|
|
51
|
+
http.open_timeout = 10
|
|
52
|
+
http.read_timeout = timeout
|
|
53
|
+
request = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri, headers)
|
|
54
|
+
request.body = body if body
|
|
55
|
+
response = http.request(request)
|
|
56
|
+
[response.code.to_i, response.body]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# Models wrap JSON in prose or code fences often enough that parsing has to be forgiving,
|
|
5
|
+
# and exact enough that anything else is reported back to the model as an error.
|
|
6
|
+
module JsonReply
|
|
7
|
+
FENCE = /```(?:json)?\s*(.*?)```/m
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def parse(text)
|
|
12
|
+
candidates(text.to_s).each do |candidate|
|
|
13
|
+
return JSON.parse(candidate)
|
|
14
|
+
rescue JSON::ParserError
|
|
15
|
+
next
|
|
16
|
+
end
|
|
17
|
+
raise InvalidResponseError, "the reply wasn't valid JSON"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def candidates(text)
|
|
21
|
+
fenced = text.scan(FENCE).flatten
|
|
22
|
+
braces = text[text.index("{").to_i..(text.rindex("}") || -1)]
|
|
23
|
+
[text, *fenced, braces].compact.map(&:strip).uniq
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# One call: a system prompt and messages in, text out. Planning is where a stronger model pays
|
|
5
|
+
# for itself, so the default is a GPT-4.1 or Claude Sonnet class model, not a mini one, and
|
|
6
|
+
# :cursor drafts with any model on the user's Cursor account (see CursorLLM).
|
|
7
|
+
class LLM
|
|
8
|
+
OPENAI_BASE = "https://api.openai.com/v1"
|
|
9
|
+
ANTHROPIC_BASE = "https://api.anthropic.com/v1"
|
|
10
|
+
|
|
11
|
+
Reply = Struct.new(:text, :input_tokens, :output_tokens, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
attr_reader :config
|
|
14
|
+
|
|
15
|
+
def initialize(config = PlanDriven.configuration, client: nil)
|
|
16
|
+
@config = config
|
|
17
|
+
@client = client
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def chat(system:, messages:, max_tokens: 8000)
|
|
21
|
+
return wrap(@client.call(system: system, messages: messages)) if @client
|
|
22
|
+
|
|
23
|
+
case config.llm_provider.to_sym
|
|
24
|
+
when :cursor then CursorLLM.new(config).chat(system: system, messages: messages)
|
|
25
|
+
when :anthropic then anthropic(system, messages, max_tokens)
|
|
26
|
+
else openai(system, messages, max_tokens)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def label
|
|
31
|
+
"#{config.llm_provider}/#{config.llm_model}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def anthropic?
|
|
37
|
+
config.llm_provider.to_sym == :anthropic
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def api_key!
|
|
41
|
+
config.llm_api_key or
|
|
42
|
+
raise ConfigurationError, "No API key for #{config.llm_provider}. Run `plan-driven configure` or set " \
|
|
43
|
+
"#{anthropic? ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"}."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def openai(system, messages, max_tokens)
|
|
47
|
+
body = { model: config.llm_model, temperature: config.temperature, max_completion_tokens: max_tokens,
|
|
48
|
+
response_format: { type: "json_object" },
|
|
49
|
+
messages: [{ role: "system", content: system }] + messages }
|
|
50
|
+
response = post("#{config.llm_api_base || OPENAI_BASE}/chat/completions", body,
|
|
51
|
+
"Authorization" => "Bearer #{api_key!}")
|
|
52
|
+
usage = response["usage"] || {}
|
|
53
|
+
Reply.new(text: response.dig("choices", 0, "message", "content").to_s,
|
|
54
|
+
input_tokens: usage["prompt_tokens"], output_tokens: usage["completion_tokens"])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def anthropic(system, messages, max_tokens)
|
|
58
|
+
body = { model: config.llm_model, system: system, max_tokens: max_tokens, temperature: config.temperature,
|
|
59
|
+
messages: messages }
|
|
60
|
+
response = post("#{config.llm_api_base || ANTHROPIC_BASE}/messages", body,
|
|
61
|
+
"x-api-key" => api_key!, "anthropic-version" => "2023-06-01")
|
|
62
|
+
usage = response["usage"] || {}
|
|
63
|
+
Reply.new(text: Array(response["content"]).filter_map { |part| part["text"] }.join,
|
|
64
|
+
input_tokens: usage["input_tokens"], output_tokens: usage["output_tokens"])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def post(url, body, headers)
|
|
68
|
+
response = HTTP.request(:post, url, headers: headers.merge("Content-Type" => "application/json"),
|
|
69
|
+
body: body, timeout: config.request_timeout)
|
|
70
|
+
return response.json if response.success?
|
|
71
|
+
|
|
72
|
+
message = response.json.dig("error", "message") || response.body.to_s[0, 200]
|
|
73
|
+
raise ProviderError, "#{config.llm_provider} returned #{response.status}: #{message}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def wrap(value)
|
|
77
|
+
value.is_a?(Reply) ? value : Reply.new(text: value.to_s)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
class Approval < Record
|
|
5
|
+
DECISIONS = %w[approved rejected].freeze
|
|
6
|
+
|
|
7
|
+
belongs_to :approvable, polymorphic: true
|
|
8
|
+
|
|
9
|
+
validates :role, :actor, presence: true
|
|
10
|
+
validates :decision, inclusion: { in: DECISIONS }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
# An append-only audit trail: who did what to which plan or ticket, and when.
|
|
5
|
+
class Event < Record
|
|
6
|
+
belongs_to :plan, class_name: "PlanDriven::Plan", inverse_of: :events
|
|
7
|
+
belongs_to :ticket, class_name: "PlanDriven::Ticket", optional: true
|
|
8
|
+
|
|
9
|
+
validates :name, :actor, presence: true
|
|
10
|
+
|
|
11
|
+
before_update { raise ActiveRecord::ReadOnlyRecord, "events are append-only" }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
class EvidenceRun < Record
|
|
5
|
+
belongs_to :plan, class_name: "PlanDriven::Plan", inverse_of: :evidence_runs
|
|
6
|
+
|
|
7
|
+
validates :kind, :status, presence: true
|
|
8
|
+
|
|
9
|
+
def scenarios
|
|
10
|
+
Array((results || {})["scenarios"])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def passed?
|
|
14
|
+
status == "passed"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
class Plan < Record
|
|
5
|
+
has_many :tickets, -> { order(:position) }, class_name: "PlanDriven::Ticket", dependent: :destroy,
|
|
6
|
+
inverse_of: :plan
|
|
7
|
+
has_many :approvals, as: :approvable, class_name: "PlanDriven::Approval", dependent: :destroy
|
|
8
|
+
has_many :events, -> { order(:created_at, :id) }, class_name: "PlanDriven::Event", dependent: :destroy,
|
|
9
|
+
inverse_of: :plan
|
|
10
|
+
has_many :evidence_runs, -> { order(:created_at) }, class_name: "PlanDriven::EvidenceRun",
|
|
11
|
+
dependent: :destroy, inverse_of: :plan
|
|
12
|
+
|
|
13
|
+
validates :key, :title, presence: true
|
|
14
|
+
validates :key, uniqueness: true
|
|
15
|
+
validates :status, inclusion: { in: Workflow::PLAN.keys }
|
|
16
|
+
|
|
17
|
+
before_validation :assign_key, on: :create
|
|
18
|
+
|
|
19
|
+
def self.find_by_reference!(reference)
|
|
20
|
+
reference = reference.to_s
|
|
21
|
+
find_by(key: reference.upcase) || find_by(id: reference[/\A\d+\z/]) ||
|
|
22
|
+
raise(ActiveRecord::RecordNotFound, "No plan #{reference}. `plan-driven list` shows them all.")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def section(key)
|
|
26
|
+
(sections || {})[key.to_s].to_s
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
EDITABLE = %w[draft in_review approved].freeze
|
|
30
|
+
|
|
31
|
+
def editable?
|
|
32
|
+
EDITABLE.include?(status)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Any edit is a new revision, and approvals belong to a revision, so changing an approved
|
|
36
|
+
# plan sends it back for approval.
|
|
37
|
+
def update_sections!(changes, actor:)
|
|
38
|
+
unless editable?
|
|
39
|
+
raise TransitionError, "plan #{key} is #{status}; its tickets are drafted, so the plan can't change. " \
|
|
40
|
+
"Start a follow-up plan instead."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
merged = (sections || {}).merge(changes.transform_keys(&:to_s))
|
|
44
|
+
transaction do
|
|
45
|
+
update!(sections: merged, revision: revision + 1)
|
|
46
|
+
log!("plan.revised", actor: actor, sections: changes.keys.map(&:to_s))
|
|
47
|
+
Workflow.plan_transition!(self, "draft") unless status == "draft"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def slug
|
|
52
|
+
"#{key.downcase}-#{title.parameterize[0, 60]}".delete_suffix("-")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def approvals_for_revision
|
|
56
|
+
approvals.where(revision: revision)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def approved_roles
|
|
60
|
+
approvals_for_revision.where(decision: "approved").pluck(:role).uniq
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def missing_approvals(required = PlanDriven.configuration.plan_approvals)
|
|
64
|
+
required.map(&:to_s) - approved_roles
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def ticket_approved_roles
|
|
68
|
+
approvals.where(role: ticket_roles.map { |role| "tickets:#{role}" }, decision: "approved",
|
|
69
|
+
revision: revision).pluck(:role).map { |role| role.delete_prefix("tickets:") }.uniq
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def missing_ticket_approvals
|
|
73
|
+
ticket_roles - ticket_approved_roles
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def log!(name, actor:, ticket: nil, **payload)
|
|
77
|
+
events.create!(name: name, actor: actor, ticket: ticket, payload: payload.presence)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def ticket!(reference)
|
|
81
|
+
tickets.find_by(key: reference.to_s.upcase) ||
|
|
82
|
+
raise(ActiveRecord::RecordNotFound, "Plan #{key} has no ticket #{reference}.")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def ticket_roles
|
|
88
|
+
PlanDriven.configuration.ticket_approvals.map(&:to_s)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def assign_key
|
|
92
|
+
return if key.present?
|
|
93
|
+
|
|
94
|
+
last = self.class.where("key LIKE 'PD-%'").pluck(:key).map { |value| value.delete_prefix("PD-").to_i }.max
|
|
95
|
+
self.key = "PD-#{last.to_i + 1}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
class Ticket < Record
|
|
5
|
+
KINDS = %w[migration dual_write backfill code switch cleanup docs].freeze
|
|
6
|
+
TYPES = %w[TASK STORY BUG].freeze
|
|
7
|
+
|
|
8
|
+
belongs_to :plan, class_name: "PlanDriven::Plan", inverse_of: :tickets
|
|
9
|
+
has_many :approvals, as: :approvable, class_name: "PlanDriven::Approval", dependent: :destroy
|
|
10
|
+
|
|
11
|
+
validates :key, :title, presence: true
|
|
12
|
+
validates :kind, inclusion: { in: KINDS }
|
|
13
|
+
validates :ticket_type, inclusion: { in: TYPES }
|
|
14
|
+
validates :status, inclusion: { in: Workflow::TICKET.keys }
|
|
15
|
+
|
|
16
|
+
def reference
|
|
17
|
+
"#{plan.key}/#{key}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def criteria
|
|
21
|
+
Array(acceptance_criteria)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def dependencies
|
|
25
|
+
Array(depends_on)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tables
|
|
29
|
+
Array(touches)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def dependency_tickets
|
|
33
|
+
plan.tickets.select { |ticket| dependencies.include?(ticket.key) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Ready to hand to an agent: approved and everything it builds on is merged, so the agent
|
|
37
|
+
# starts from a branch that already contains it.
|
|
38
|
+
def ready?
|
|
39
|
+
status == "approved" && dependency_tickets.all? { |ticket| ticket.status == "merged" }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def blocked_by
|
|
43
|
+
dependency_tickets.reject { |ticket| ticket.status == "merged" }.map(&:key)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def active_agent?
|
|
47
|
+
status == "running"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def feature_tag
|
|
51
|
+
"@#{plan.key.downcase}-#{key.downcase}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pr_approved_by
|
|
55
|
+
approvals.where(role: "pr", decision: "approved").order(:created_at).last&.actor
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
module Renderer
|
|
5
|
+
# A small Markdown-to-HTML converter for the documents this gem writes: headings, paragraphs,
|
|
6
|
+
# lists, tables, code blocks, emphasis and links. It isn't a general Markdown implementation,
|
|
7
|
+
# and doesn't need to be.
|
|
8
|
+
module HTML
|
|
9
|
+
STYLE = File.read(File.expand_path("style.css", __dir__))
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def document(markdown, title:)
|
|
14
|
+
<<~HTML
|
|
15
|
+
<!doctype html>
|
|
16
|
+
<html lang="en">
|
|
17
|
+
<head>
|
|
18
|
+
<meta charset="utf-8">
|
|
19
|
+
<title>#{CGI.escapeHTML(title)}</title>
|
|
20
|
+
<style>#{STYLE}</style>
|
|
21
|
+
</head>
|
|
22
|
+
<body>
|
|
23
|
+
<main>
|
|
24
|
+
#{convert(markdown)}
|
|
25
|
+
</main>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
28
|
+
HTML
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def convert(markdown)
|
|
32
|
+
lines = markdown.to_s.lines.map(&:chomp)
|
|
33
|
+
out = []
|
|
34
|
+
until lines.empty?
|
|
35
|
+
line = lines.first
|
|
36
|
+
if line.start_with?("```")
|
|
37
|
+
out << code_block(lines)
|
|
38
|
+
elsif line.match?(/\A\s*\|/)
|
|
39
|
+
out << table(take_while(lines) { |l| l.match?(/\A\s*\|/) })
|
|
40
|
+
elsif (match = line.match(/\A(\#{1,6})\s+(.*)\z/))
|
|
41
|
+
lines.shift
|
|
42
|
+
level = match[1].size
|
|
43
|
+
out << "<h#{level}>#{inline(match[2])}</h#{level}>"
|
|
44
|
+
elsif line.match?(/\A\s*([-*]|\d+\.)\s+/)
|
|
45
|
+
out << list(take_while(lines) { |l| l.match?(/\A\s*([-*]|\d+\.)\s+/) || l.match?(/\A\s{2,}\S/) })
|
|
46
|
+
elsif line.strip.empty?
|
|
47
|
+
lines.shift
|
|
48
|
+
else
|
|
49
|
+
paragraph = take_while(lines) { |l| !l.strip.empty? && !l.match?(/\A(#|```|\s*\||\s*([-*]|\d+\.)\s)/) }
|
|
50
|
+
paragraph = [lines.shift] if paragraph.empty?
|
|
51
|
+
out << "<p>#{inline(paragraph.join(" "))}</p>"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
out.join("\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def take_while(lines)
|
|
58
|
+
taken = []
|
|
59
|
+
taken << lines.shift while lines.any? && yield(lines.first)
|
|
60
|
+
taken
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def code_block(lines)
|
|
64
|
+
lines.shift
|
|
65
|
+
body = take_while(lines) { |line| !line.start_with?("```") }
|
|
66
|
+
lines.shift
|
|
67
|
+
"<pre><code>#{CGI.escapeHTML(body.join("\n"))}</code></pre>"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def table(rows)
|
|
71
|
+
cells = rows.map { |row| row.strip.delete_prefix("|").delete_suffix("|").split(/(?<!\\)\|/).map(&:strip) }
|
|
72
|
+
header, *body = cells
|
|
73
|
+
body = body.reject { |row| row.all? { |cell| cell.match?(/\A:?-+:?\z/) } }
|
|
74
|
+
head = header.map { |cell| "<th>#{inline(cell)}</th>" }.join
|
|
75
|
+
rows = body.map { |row| "<tr>#{row.map { |cell| "<td>#{inline(cell.gsub("\\|", "|"))}</td>" }.join}</tr>" }
|
|
76
|
+
"<table><thead><tr>#{head}</tr></thead><tbody>#{rows.join}</tbody></table>"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def list(lines)
|
|
80
|
+
ordered = lines.first.match?(/\A\s*\d+\./)
|
|
81
|
+
items = []
|
|
82
|
+
lines.each do |line|
|
|
83
|
+
if line.match?(/\A\s*([-*]|\d+\.)\s+/)
|
|
84
|
+
items << line.sub(/\A\s*([-*]|\d+\.)\s+/, "")
|
|
85
|
+
else
|
|
86
|
+
items[-1] = "#{items.last} #{line.strip}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
tag = ordered ? "ol" : "ul"
|
|
90
|
+
"<#{tag}>#{items.map { |item| "<li>#{inline(item)}</li>" }.join}</#{tag}>"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def inline(text)
|
|
94
|
+
codes = []
|
|
95
|
+
escaped = CGI.escapeHTML(text.to_s).gsub(/`([^`]+)`/) do
|
|
96
|
+
codes << Regexp.last_match(1)
|
|
97
|
+
"\u0000#{codes.size - 1}\u0000"
|
|
98
|
+
end
|
|
99
|
+
escaped = escaped.gsub(/\*\*(.+?)\*\*/, '<strong>\1</strong>')
|
|
100
|
+
.gsub(/(?<![*\w])\*(?!\s)(.+?)(?<!\s)\*(?![*\w])/, '<em>\1</em>')
|
|
101
|
+
.gsub(/\[([^\]]+)\]\((https?:[^)\s]+|[^)\s]+\.(?:md|html|pdf))\)/, '<a href="\2">\1</a>')
|
|
102
|
+
escaped.gsub(/\u0000(\d+)\u0000/) { "<code>#{codes[Regexp.last_match(1).to_i]}</code>" }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|