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,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module PlanDriven
|
|
6
|
+
# The parts of the GitHub REST API the workflow needs: issues for tickets, and pull requests,
|
|
7
|
+
# their files, CI checks and merge.
|
|
8
|
+
class GitHub
|
|
9
|
+
BASE = "https://api.github.com"
|
|
10
|
+
|
|
11
|
+
attr_reader :repository
|
|
12
|
+
|
|
13
|
+
def initialize(repository: Repository.slug, token: Credentials.fetch(:github_token), base: BASE)
|
|
14
|
+
@repository = repository or raise ConfigurationError, "No GitHub repository. Set config.github_repository " \
|
|
15
|
+
"or add a GitHub `origin` remote."
|
|
16
|
+
@token = token
|
|
17
|
+
@base = base
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def repo_url
|
|
21
|
+
"https://github.com/#{repository}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create_issue(title:, body:, labels: [])
|
|
25
|
+
request(:post, "/repos/#{repository}/issues", { title: title, body: body, labels: labels })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def update_issue(number, **fields)
|
|
29
|
+
request(:patch, "/repos/#{repository}/issues/#{number}", fields)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def comment(number, body)
|
|
33
|
+
request(:post, "/repos/#{repository}/issues/#{number}/comments", { body: body })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def pull(number)
|
|
37
|
+
request(:get, "/repos/#{repository}/pulls/#{number}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pull_files(number)
|
|
41
|
+
(1..30).each_with_object([]) do |page, files|
|
|
42
|
+
batch = request(:get, "/repos/#{repository}/pulls/#{number}/files?per_page=100&page=#{page}")
|
|
43
|
+
files.concat(batch)
|
|
44
|
+
break files if batch.size < 100
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check runs (GitHub Actions and apps) and commit statuses (older CI integrations), in one shape.
|
|
49
|
+
def checks(sha)
|
|
50
|
+
runs = request(:get, "/repos/#{repository}/commits/#{sha}/check-runs?per_page=100")["check_runs"].to_a
|
|
51
|
+
statuses = request(:get, "/repos/#{repository}/commits/#{sha}/status")["statuses"].to_a
|
|
52
|
+
runs.map { |run| run.slice("name", "status", "conclusion") } + statuses.map do |status|
|
|
53
|
+
state = status["state"]
|
|
54
|
+
{ "name" => status["context"], "status" => state == "pending" ? "in_progress" : "completed",
|
|
55
|
+
"conclusion" => state == "pending" ? nil : state }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# How many commits the base branch has that the pull request's branch doesn't.
|
|
60
|
+
def behind_by(pull)
|
|
61
|
+
base = pull.dig("base", "ref")
|
|
62
|
+
head = pull.dig("head", "sha")
|
|
63
|
+
return 0 unless base && head
|
|
64
|
+
|
|
65
|
+
request(:get, "/repos/#{repository}/compare/#{base}...#{head}")["behind_by"].to_i
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def file(path, ref:)
|
|
69
|
+
data = request(:get, "/repos/#{repository}/contents/#{path}?ref=#{ref}")
|
|
70
|
+
Base64.decode64(data["content"].to_s)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def review(number, body:, event: "COMMENT")
|
|
74
|
+
request(:post, "/repos/#{repository}/pulls/#{number}/reviews", { body: body, event: event })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def merge(number, title:, method:)
|
|
78
|
+
request(:put, "/repos/#{repository}/pulls/#{number}/merge", { commit_title: title, merge_method: method })
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Agents open draft pull requests, and GitHub won't merge a draft. REST can't undraft; GraphQL can.
|
|
82
|
+
def ready_for_review(number)
|
|
83
|
+
node_id = pull(number)["node_id"]
|
|
84
|
+
data = request(:post, "/graphql", {
|
|
85
|
+
query: "mutation($id: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $id}) " \
|
|
86
|
+
"{ pullRequest { isDraft } } }",
|
|
87
|
+
variables: { id: node_id }
|
|
88
|
+
})
|
|
89
|
+
raise ProviderError, "GitHub GraphQL: #{data["errors"].map { |e| e["message"] }.join("; ")}" if data["errors"]
|
|
90
|
+
|
|
91
|
+
data
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.pr_number(url)
|
|
95
|
+
url.to_s[%r{/pull/(\d+)}, 1]&.to_i
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def request(method, path, body = nil)
|
|
101
|
+
unless @token
|
|
102
|
+
raise ConfigurationError,
|
|
103
|
+
"No GitHub token. Run `plan-driven configure`, set GITHUB_TOKEN or `gh auth login`."
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
response = HTTP.request(method, "#{@base}#{path}", body: body, headers: {
|
|
107
|
+
"Authorization" => "Bearer #{@token}",
|
|
108
|
+
"Accept" => "application/vnd.github+json",
|
|
109
|
+
"X-GitHub-Api-Version" => "2022-11-28",
|
|
110
|
+
"Content-Type" => "application/json"
|
|
111
|
+
})
|
|
112
|
+
return response.json if response.success?
|
|
113
|
+
|
|
114
|
+
raise ProviderError,
|
|
115
|
+
"GitHub returned #{response.status} for #{path}: #{response.json["message"] || response.body[0, 200]}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
module Guards
|
|
5
|
+
# Zero-downtime rules for the Database changes section: expand first, contract last.
|
|
6
|
+
class MigrationGuard
|
|
7
|
+
DESTRUCTIVE = /\b(remove|drop|delete|rename)\w*\b[^.\n]{0,80}\b(column|table|field)s?\b|
|
|
8
|
+
\b(remove_column|drop_table|rename_column|rename_table|change_column)\b/ix
|
|
9
|
+
SAFE_REMOVAL = /ignored_columns|expand|contract|in a later (step|phase|release)|after (the )?backfill/i
|
|
10
|
+
NOT_NULL = /\bnot[\s_-]?null\b|null:\s*false/i
|
|
11
|
+
NOT_NULL_SAFE = /default|backfill|nullable first|after (the )?backfill|validate/i
|
|
12
|
+
INDEX = /\badd_index\b|\bindex(es)?\b/i
|
|
13
|
+
CONCURRENT = /concurrent|algorithm:\s*:concurrently|disable_ddl_transaction/i
|
|
14
|
+
NEW_TABLE = /
|
|
15
|
+
create_table\s+[:"']?([a-z][a-z0-9_]+)
|
|
16
|
+
| new\s+table:?\s*`?([a-z][a-z0-9_]+)
|
|
17
|
+
| create\s+(?:a\s+|the\s+)?(?:new\s+)?`([a-z][a-z0-9_]+)`\s+table
|
|
18
|
+
| create\s+(?:a\s+|the\s+)?(?:new\s+)?table\s+`?([a-z][a-z0-9_]+)
|
|
19
|
+
| create:?\s+`([a-z][a-z0-9_]+)`(?!\s+(?:column|index))
|
|
20
|
+
| _create_([a-z][a-z0-9_]+)\.rb
|
|
21
|
+
/ix
|
|
22
|
+
|
|
23
|
+
def initialize(text, schema:)
|
|
24
|
+
@text = text.to_s
|
|
25
|
+
@schema = schema
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call
|
|
29
|
+
report = Report.new
|
|
30
|
+
return report if @text.strip.empty?
|
|
31
|
+
|
|
32
|
+
check_destructive(report)
|
|
33
|
+
check_not_null(report)
|
|
34
|
+
check_indexes(report)
|
|
35
|
+
report
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def new_tables
|
|
39
|
+
@text.scan(NEW_TABLE).flatten.compact.map(&:downcase).uniq
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def check_destructive(report)
|
|
45
|
+
return unless @text.match?(DESTRUCTIVE)
|
|
46
|
+
return if @text.match?(SAFE_REMOVAL)
|
|
47
|
+
|
|
48
|
+
report.error("Database changes remove or rename a column or table in one step. Split it: stop using " \
|
|
49
|
+
"it and add it to `ignored_columns`, deploy, then remove it in a later migration.")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def check_not_null(report)
|
|
53
|
+
existing_tables_mentioned.each do |table|
|
|
54
|
+
paragraph = paragraph_changing(table)
|
|
55
|
+
next unless paragraph.match?(NOT_NULL) && !paragraph.match?(NOT_NULL_SAFE)
|
|
56
|
+
|
|
57
|
+
report.warning("A NOT NULL change on existing table #{table} has no default or backfill step; " \
|
|
58
|
+
"it will fail on existing rows")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def check_indexes(report)
|
|
63
|
+
adapter = @schema.respond_to?(:adapter) ? @schema.adapter : nil
|
|
64
|
+
return unless adapter.to_s.match?(/postg/i)
|
|
65
|
+
|
|
66
|
+
existing_tables_mentioned.each do |table|
|
|
67
|
+
paragraph = paragraph_changing(table)
|
|
68
|
+
next unless paragraph.match?(INDEX) && !paragraph.match?(CONCURRENT)
|
|
69
|
+
|
|
70
|
+
report.warning("An index on existing table #{table} isn't built concurrently; it locks writes while " \
|
|
71
|
+
"it builds")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def existing_tables_mentioned
|
|
76
|
+
@schema.tables.select { |table| @text.match?(/\b#{Regexp.escape(table)}\b/) } - new_tables
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Paragraphs that change an existing table, not ones that only describe it (plans list the
|
|
80
|
+
# current schema, "user_id integer, not null", before saying what changes).
|
|
81
|
+
def paragraph_changing(table)
|
|
82
|
+
name = Regexp.escape(table)
|
|
83
|
+
change = /
|
|
84
|
+
\b(add_column|change_column_null|change_column_default|add_reference|add_belongs_to|add_index|
|
|
85
|
+
change_table|add_check_constraint)\s*\(?\s*[:"']#{name}\b
|
|
86
|
+
| \b(add|adds|adding)\b[^.\n]{0,80}\b(to|on)\s+(the\s+)?`?#{name}`?
|
|
87
|
+
| `?#{name}`?\s+(table\s+)?(gets|gains)\b
|
|
88
|
+
| \A\#+\s+`?#{name}`?\s*\n[^\n]*\b(add\w*|change\w*|makes?)\b
|
|
89
|
+
/ix
|
|
90
|
+
@text.split(/\n\s*\n|\n(?=#+ )/).grep(change).join("\n")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
module Guards
|
|
5
|
+
# Checks a drafted plan against the template and against the application it describes.
|
|
6
|
+
class PlanGuard
|
|
7
|
+
PLACEHOLDER = /\b(TBD|TODO|lorem ipsum|FIXME|XXX)\b/i
|
|
8
|
+
RISK_LEVEL = /risk level\W{0,5}(LOW|MEDIUM|HIGH)\b/i
|
|
9
|
+
MODEL_PATH = %r{\bapp/models/[\w/]+\.rb\b}
|
|
10
|
+
CONSTANT = /`((?:[A-Z][a-z0-9]+)+(?:::(?:[A-Z][a-z0-9]+)+)*)`/
|
|
11
|
+
TABLE_COLUMN = /`([a-z][a-z0-9_]*)\.([a-z][a-z0-9_]*)`/
|
|
12
|
+
|
|
13
|
+
def initialize(sections, schema:, template: PlanDriven.configuration.template)
|
|
14
|
+
@sections = (sections || {}).transform_keys(&:to_s)
|
|
15
|
+
@schema = schema
|
|
16
|
+
@template = template
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
report = Report.new
|
|
21
|
+
check_sections(report)
|
|
22
|
+
check_risk_level(report)
|
|
23
|
+
check_existing_structure(report)
|
|
24
|
+
report.merge!(MigrationGuard.new(@sections["database_changes"].to_s, schema: @schema).call)
|
|
25
|
+
report
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def check_sections(report)
|
|
31
|
+
@template.sections.each do |section|
|
|
32
|
+
text = @sections[section.key].to_s.strip
|
|
33
|
+
if section.required && text.empty?
|
|
34
|
+
report.error("#{section.title} is empty")
|
|
35
|
+
elsif section.required && words(text) < section.min_words.to_i
|
|
36
|
+
report.error("#{section.title} is too thin (#{words(text)} words, at least #{section.min_words})")
|
|
37
|
+
end
|
|
38
|
+
if text.match?(PLACEHOLDER)
|
|
39
|
+
report.warning("#{section.title} still contains a placeholder (#{text[PLACEHOLDER]})")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def check_risk_level(report)
|
|
45
|
+
return if @sections["security"].to_s.match?(RISK_LEVEL)
|
|
46
|
+
|
|
47
|
+
report.error("Security doesn't state a risk level (a line like `Risk Level: MEDIUM`)")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The one place a plan must match the code exactly: what it calls existing has to exist.
|
|
51
|
+
def check_existing_structure(report)
|
|
52
|
+
text = @sections["existing_data_structure"].to_s
|
|
53
|
+
text.scan(MODEL_PATH).uniq.each do |path|
|
|
54
|
+
report.error("Existing Data Structure cites #{path}, which isn't in the app") unless root.join(path).exist?
|
|
55
|
+
end
|
|
56
|
+
text.scan(CONSTANT).flatten.uniq.each do |name|
|
|
57
|
+
next if @schema.model?(name) || known_constant?(name)
|
|
58
|
+
|
|
59
|
+
report.error("Existing Data Structure describes `#{name}` as existing, but there's no such model")
|
|
60
|
+
end
|
|
61
|
+
text.scan(TABLE_COLUMN).uniq.each do |table, column|
|
|
62
|
+
next unless @schema.table?(table)
|
|
63
|
+
next if @schema.column?(table, column)
|
|
64
|
+
|
|
65
|
+
report.error("Existing Data Structure mentions `#{table}.#{column}`, but #{table} has no #{column} column")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def known_constant?(name)
|
|
70
|
+
Object.const_defined?(name)
|
|
71
|
+
rescue NameError
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def root
|
|
76
|
+
PlanDriven.configuration.root_path
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def words(text)
|
|
80
|
+
text.split(/\s+/).count { |word| word.match?(/\w/) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
module Guards
|
|
5
|
+
# Checks the pull request an agent opened against the ticket it was given. Runs before a human
|
|
6
|
+
# is asked to approve it, and again before merge.
|
|
7
|
+
class PrGuard
|
|
8
|
+
MIGRATION_PATHS = %r{\Adb/}
|
|
9
|
+
CHECK_OK = %w[success neutral skipped].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(ticket, pull:, files:, checks:, features: {}, behind: 0, config: PlanDriven.configuration)
|
|
12
|
+
@ticket = ticket
|
|
13
|
+
@pull = pull || {}
|
|
14
|
+
@files = files
|
|
15
|
+
@checks = checks
|
|
16
|
+
@features = features
|
|
17
|
+
@behind = behind
|
|
18
|
+
@config = config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
report = Report.new
|
|
23
|
+
check_reference(report)
|
|
24
|
+
check_size(report)
|
|
25
|
+
check_specs(report)
|
|
26
|
+
check_migrations(report)
|
|
27
|
+
check_acceptance_scenarios(report) if @config.cucumber && @ticket.kind != "docs"
|
|
28
|
+
check_ci(report)
|
|
29
|
+
check_base(report)
|
|
30
|
+
report
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def paths
|
|
36
|
+
@paths ||= @files.map { |file| file["filename"] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def check_reference(report)
|
|
40
|
+
text = "#{@pull["title"]}\n#{@pull["body"]}"
|
|
41
|
+
report.warning("The PR doesn't mention #{@ticket.reference}") unless text.include?(@ticket.reference)
|
|
42
|
+
number = @ticket.issue_number
|
|
43
|
+
if number && !text.match?(/(close[sd]?|fix(e[sd])?|resolve[sd]?) ##{number}\b/i)
|
|
44
|
+
report.warning("The PR doesn't close issue ##{number}")
|
|
45
|
+
elsif text.include?(@ticket.reference)
|
|
46
|
+
report.pass(["Refers to #{@ticket.reference}", ("closes ##{number}" if number)].compact.join(" and "))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def check_size(report)
|
|
51
|
+
changed = @files.sum { |file| file["additions"].to_i + file["deletions"].to_i }
|
|
52
|
+
limit = @config.max_pr_changed_lines
|
|
53
|
+
return report.pass("#{@files.size} files, #{changed} changed lines (limit #{limit})") if changed <= limit
|
|
54
|
+
|
|
55
|
+
report.error("The PR changes #{changed} lines, above the limit of #{limit}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_specs(report)
|
|
59
|
+
return unless @config.require_specs_in_pr
|
|
60
|
+
return if @ticket.kind == "docs"
|
|
61
|
+
|
|
62
|
+
specs = paths.count { |path| spec_path?(path) }
|
|
63
|
+
return report.pass("#{specs} spec and feature files changed") if specs.positive?
|
|
64
|
+
|
|
65
|
+
report.error("The PR has no spec or feature changes")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def check_migrations(report)
|
|
69
|
+
added = @files.select { |file| file["filename"].start_with?("db/migrate/") && file["status"] == "added" }
|
|
70
|
+
if added.any? && !%w[migration backfill].include?(@ticket.kind)
|
|
71
|
+
report.error("A #{@ticket.kind} ticket adds a migration (#{added.first["filename"]}); " \
|
|
72
|
+
"schema changes belong in their own migration ticket")
|
|
73
|
+
end
|
|
74
|
+
check_migration_scope(report)
|
|
75
|
+
if added.empty? && @ticket.kind != "migration"
|
|
76
|
+
report.pass("No migrations, so the schema stays with the migration tickets")
|
|
77
|
+
end
|
|
78
|
+
return if added.empty? || paths.any? { |path| path.match?(%r{\Adb/(schema\.rb|structure\.sql)\z}) }
|
|
79
|
+
|
|
80
|
+
report.warning("A migration was added but db/schema.rb didn't change")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def check_migration_scope(report)
|
|
84
|
+
return unless @ticket.kind == "migration"
|
|
85
|
+
|
|
86
|
+
app_changes = paths.reject { |path| path.match?(MIGRATION_PATHS) || spec_path?(path) }
|
|
87
|
+
return report.pass("A migration ticket, and it only changes db/ and tests") if app_changes.empty?
|
|
88
|
+
|
|
89
|
+
report.warning("A migration ticket also changes #{app_changes.first(3).join(", ")}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def check_acceptance_scenarios(report)
|
|
93
|
+
tagged = @features.flat_map do |path, text|
|
|
94
|
+
Gherkin.parse(text, path: path).scenarios.select { |scenario| scenario.tags.include?(@ticket.feature_tag) }
|
|
95
|
+
end
|
|
96
|
+
if tagged.empty?
|
|
97
|
+
report.error("No Cucumber scenario is tagged #{@ticket.feature_tag}")
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
@ticket.criteria.each_index do |index|
|
|
101
|
+
tag = "@ac-#{index + 1}"
|
|
102
|
+
next if tagged.any? { |scenario| scenario.tags.include?(tag) }
|
|
103
|
+
|
|
104
|
+
report.error("Acceptance criterion #{index + 1} has no scenario tagged #{@ticket.feature_tag} #{tag}")
|
|
105
|
+
end
|
|
106
|
+
count = @ticket.criteria.size
|
|
107
|
+
return unless report.errors.none? { |message| message.start_with?("Acceptance criterion") }
|
|
108
|
+
|
|
109
|
+
report.pass("All #{count} acceptance criteria have a scenario tagged #{@ticket.feature_tag} @ac-N")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def check_ci(report)
|
|
113
|
+
if @checks.empty?
|
|
114
|
+
report.warning("No CI checks have reported on this PR yet")
|
|
115
|
+
return
|
|
116
|
+
end
|
|
117
|
+
pending = @checks.reject { |check| check["status"] == "completed" }
|
|
118
|
+
failed = @checks.select { |check| check["status"] == "completed" && !CHECK_OK.include?(check["conclusion"]) }
|
|
119
|
+
failed.each { |check| report.error("CI check \"#{check["name"]}\" #{check["conclusion"]}") }
|
|
120
|
+
report.warning("#{pending.size} CI check(s) still running") if pending.any?
|
|
121
|
+
return unless pending.empty? && failed.empty?
|
|
122
|
+
|
|
123
|
+
report.pass("CI is green: #{@checks.map { |check| check["name"] }.join(", ")}")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Another ticket merged since the agent branched: CI passed without those changes.
|
|
127
|
+
def check_base(report)
|
|
128
|
+
base = @pull.dig("base", "ref") || "the base branch"
|
|
129
|
+
return report.pass("Up to date with #{base}") if @behind.zero?
|
|
130
|
+
|
|
131
|
+
report.warning("The branch is #{@behind} commit(s) behind #{base}, so CI ran without them. " \
|
|
132
|
+
"Ask the agent to merge #{base} and run the checks again (`plan-driven feedback`).")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def spec_path?(path)
|
|
136
|
+
@config.spec_paths.any? { |prefix| path.start_with?(prefix) }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlanDriven
|
|
4
|
+
module Guards
|
|
5
|
+
# Checks the ticket breakdown: every ticket is small, testable and ordered so that each
|
|
6
|
+
# pull request can merge on its own without breaking the application.
|
|
7
|
+
class TicketGuard
|
|
8
|
+
BUILDS_ON_MIGRATION = %w[dual_write backfill code switch cleanup].freeze
|
|
9
|
+
MAX_CRITERIA = 8
|
|
10
|
+
LAYER_TITLE = /\A(models?|services?|controllers?|views?|polic(y|ies)|ui|api|routes?|specs?|tests?)\s*:/i
|
|
11
|
+
|
|
12
|
+
def initialize(tickets, plan_sections:, schema:, config: PlanDriven.configuration)
|
|
13
|
+
@tickets = tickets
|
|
14
|
+
@sections = (plan_sections || {}).transform_keys(&:to_s)
|
|
15
|
+
@schema = schema
|
|
16
|
+
@config = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
report = Report.new
|
|
21
|
+
if @tickets.empty?
|
|
22
|
+
report.error("There are no tickets")
|
|
23
|
+
return report
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@tickets.each { |ticket| check_ticket(ticket, report) }
|
|
27
|
+
check_slicing(report)
|
|
28
|
+
check_dependencies(report)
|
|
29
|
+
check_order(report) if report.ok?
|
|
30
|
+
check_coverage(report)
|
|
31
|
+
report
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# A model ticket, then a controller ticket, then a view ticket: none of them does anything
|
|
37
|
+
# a user can see, so no scenario can prove its criteria.
|
|
38
|
+
def check_slicing(report)
|
|
39
|
+
layered = @tickets.select { |ticket| ticket["kind"] == "code" && ticket["title"].to_s.match?(LAYER_TITLE) }
|
|
40
|
+
return if layered.size < 2
|
|
41
|
+
|
|
42
|
+
report.error("#{layered.map { |t| t["key"] }.join(", ")} split the work by layer (model, service, " \
|
|
43
|
+
"controller, view). Split code tickets by behaviour instead: each one delivers one thing a " \
|
|
44
|
+
"user can do, with its model, service, controller, view and specs together")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def check_ticket(ticket, report)
|
|
48
|
+
label = ticket["key"]
|
|
49
|
+
report.error("#{label} has no title") if ticket["title"].to_s.strip.empty?
|
|
50
|
+
report.warning("#{label}: title is longer than 100 characters") if ticket["title"].to_s.length > 100
|
|
51
|
+
report.error("#{label} has an unknown type #{ticket["type"]}") unless Ticket::TYPES.include?(ticket["type"])
|
|
52
|
+
report.error("#{label} has no description") if ticket["description"].to_s.strip.empty?
|
|
53
|
+
if ticket["type"] == "STORY" && ticket["story"].to_s !~ /\bso that\b/i
|
|
54
|
+
report.error("#{label} is a story without \"so that\"")
|
|
55
|
+
end
|
|
56
|
+
check_criteria(ticket, label, report)
|
|
57
|
+
check_estimate(ticket, label, report)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def check_criteria(ticket, label, report)
|
|
61
|
+
criteria = ticket["acceptance_criteria"]
|
|
62
|
+
report.error("#{label} has no acceptance criteria") if criteria.empty?
|
|
63
|
+
criteria.each_with_index do |criterion, index|
|
|
64
|
+
next if criterion.split.size >= 3
|
|
65
|
+
|
|
66
|
+
report.error("#{label} acceptance criterion #{index + 1} is too short to test: \"#{criterion}\"")
|
|
67
|
+
end
|
|
68
|
+
return unless criteria.size > MAX_CRITERIA
|
|
69
|
+
|
|
70
|
+
report.warning("#{label} has #{criteria.size} acceptance criteria; consider splitting it")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def check_estimate(ticket, label, report)
|
|
74
|
+
estimate = ticket["estimate"]
|
|
75
|
+
if estimate.nil?
|
|
76
|
+
report.error("#{label} has no estimate")
|
|
77
|
+
elsif estimate > @config.max_estimate
|
|
78
|
+
report.error("#{label} is estimated at #{estimate}, above the limit of #{@config.max_estimate}; split it")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def check_dependencies(report)
|
|
83
|
+
keys = @tickets.map { |ticket| ticket["key"] }
|
|
84
|
+
@tickets.each do |ticket|
|
|
85
|
+
(ticket["depends_on"] - keys).each do |missing|
|
|
86
|
+
report.error("#{ticket["key"]} depends on unknown #{missing}")
|
|
87
|
+
end
|
|
88
|
+
report.error("#{ticket["key"]} depends on itself") if ticket["depends_on"].include?(ticket["key"])
|
|
89
|
+
end
|
|
90
|
+
cycle = find_cycle
|
|
91
|
+
report.error("Dependencies form a cycle: #{cycle.join(" -> ")}") if cycle
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Expand before contract, per table: anything using a table must build on the migration
|
|
95
|
+
# that changes it, and removing things comes after every backfill and read switch.
|
|
96
|
+
def check_order(report)
|
|
97
|
+
@tickets.each do |ticket|
|
|
98
|
+
ancestors = ancestors_of(ticket["key"])
|
|
99
|
+
ticket["touches"].each do |table|
|
|
100
|
+
required_before(ticket, table).each do |earlier|
|
|
101
|
+
next if ancestors.include?(earlier["key"])
|
|
102
|
+
|
|
103
|
+
report.error("#{ticket["key"]} (#{phase(ticket)}) touches #{table} but doesn't depend on " \
|
|
104
|
+
"#{earlier["key"]} (#{phase(earlier)}), which must merge first")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def required_before(ticket, table)
|
|
111
|
+
others = @tickets.reject { |other| other["key"] == ticket["key"] || !other["touches"].include?(table) }
|
|
112
|
+
case phase(ticket)
|
|
113
|
+
when "cleanup"
|
|
114
|
+
others.select { |other| %w[migration dual_write backfill switch].include?(phase(other)) }
|
|
115
|
+
when *BUILDS_ON_MIGRATION then others.select { |other| phase(other) == "migration" }
|
|
116
|
+
else []
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A migration that removes or renames something is the contract step, whatever it's called.
|
|
121
|
+
def phase(ticket)
|
|
122
|
+
return ticket["kind"] unless ticket["kind"] == "migration"
|
|
123
|
+
|
|
124
|
+
text = "#{ticket["title"]}\n#{ticket["description"]}"
|
|
125
|
+
text.match?(MigrationGuard::DESTRUCTIVE) ? "cleanup" : "migration"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def check_coverage(report)
|
|
129
|
+
touched = @tickets.flat_map { |ticket| ticket["touches"] }.uniq
|
|
130
|
+
planned = MigrationGuard.new(@sections["database_changes"].to_s, schema: @schema)
|
|
131
|
+
new_tables = planned.new_tables
|
|
132
|
+
mentioned = @schema.tables.select { |table| @sections["database_changes"].to_s.match?(/\b#{table}\b/) }
|
|
133
|
+
(new_tables + mentioned).uniq.each do |table|
|
|
134
|
+
next if touched.include?(table)
|
|
135
|
+
|
|
136
|
+
report.warning("Database changes mention #{table}, but no ticket touches it")
|
|
137
|
+
end
|
|
138
|
+
(touched - @schema.tables - new_tables).each do |table|
|
|
139
|
+
report.warning("A ticket touches #{table}, which isn't in the schema or the plan's database changes")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def ancestors_of(key, seen = Set.new)
|
|
144
|
+
ticket = @tickets.find { |candidate| candidate["key"] == key }
|
|
145
|
+
Array(ticket && ticket["depends_on"]).each do |dependency|
|
|
146
|
+
next if seen.include?(dependency)
|
|
147
|
+
|
|
148
|
+
seen << dependency
|
|
149
|
+
ancestors_of(dependency, seen)
|
|
150
|
+
end
|
|
151
|
+
seen
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def find_cycle
|
|
155
|
+
state = {}
|
|
156
|
+
@tickets.each do |ticket|
|
|
157
|
+
path = visit(ticket["key"], state, [])
|
|
158
|
+
return path if path
|
|
159
|
+
end
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def visit(key, state, path)
|
|
164
|
+
return path + [key] if state[key] == :visiting
|
|
165
|
+
return if state[key] == :done
|
|
166
|
+
|
|
167
|
+
state[key] = :visiting
|
|
168
|
+
ticket = @tickets.find { |candidate| candidate["key"] == key }
|
|
169
|
+
Array(ticket && ticket["depends_on"]).each do |dependency|
|
|
170
|
+
found = visit(dependency, state, path + [key])
|
|
171
|
+
return found.drop_while { |step| step != found.last } if found
|
|
172
|
+
end
|
|
173
|
+
state[key] = :done
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|