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,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