intent-record 1.0.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 871b5d799ddc9505ac23dbbd3bf468d243ae3c48a4c04574e9fd0fc596a579ae
4
- data.tar.gz: f6370c0f8003d35971d1b717c1ca7d24632b38b9568496c22e7605838397f9d6
3
+ metadata.gz: b2abf7a93c2bbefd6b31f0ff8ee7f08def82e721b1166885b8701f1f003162e7
4
+ data.tar.gz: f5754926ff4e9b0ff7d339423196487610e816bdd164f3fc96344fb9423ef83c
5
5
  SHA512:
6
- metadata.gz: 7d0ef27f3db2d4bf6616ea46f225462e778dad93c6433738d90f03e7c68fac5c2ee196681f1445a15673915d5ac449f0737d12974d01af97cdb618a4530f2759
7
- data.tar.gz: 5dc2d7077843e89318b6d8d5ef7238663eb531e8ab01633a63f7a40c96f025577751e2a757f27fa64e8e1c31a81eef4ca9c8829d818fdd0f4bffbb1faa61867a
6
+ metadata.gz: 844a434054ae004e8bc75bb84b20e6d9a301842e860f66f715add6ac14926fa9c378d83a79d8fccabcdc97e9ff28a4b0c8fbed28194fc90b3f558ff687d6c5f5
7
+ data.tar.gz: 38a447b34c8fb3a2f4543c2d1c1e8d5cbb4d71669804cdd499b88ec6dfd1e48439fc26670f9e4e5bba838fa8bc4857a535a0abe6d9a8f59a2ed6008478904708
data/CHANGELOG.md CHANGED
@@ -6,6 +6,16 @@ Notable changes, one section per released version. The format follows
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 1.1.0 - 2026-09-19
10
+
11
+ ### Added
12
+
13
+ - `backfill`, which recovers intent records from a repository's commit history. Commits go in on stdin as JSON, `--pattern` says what a ticket reference looks like and `--uri-prefix` turns each match into a stakeholder source. Afterwards `lookup` and `by-source` answer for commits made before the tool was adopted.
14
+ - A backfilled record says in its body that the reasoning was never recorded, so an agent can tell where the real reasoning still needs attaching.
15
+ - Commits for one ticket are chained oldest to newest. `--order` says which end of the input is the old end and defaults to newest-first, the order `git log` prints.
16
+ - A later pass with a different pattern adds its references to the records an earlier pass created and reports them as `linked`. A pass that finds nothing new writes nothing.
17
+ - `--dry-run` writes nothing and reports the sources it would create and the commit subjects nothing matched.
18
+
9
19
  ## 1.0.0 - 2026-09-18
10
20
 
11
21
  First release.
data/README.md CHANGED
@@ -1,5 +1,6 @@
1
1
  # intent-record
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/intent-record.svg)](https://badge.fury.io/rb/intent-record)
3
4
  [![CI](https://github.com/beatmadsen/intent-record/actions/workflows/ci.yml/badge.svg)](https://github.com/beatmadsen/intent-record/actions/workflows/ci.yml)
4
5
 
5
6
  Local records storage for the intent behind individual code changes.
@@ -63,6 +64,7 @@ JSON in on stdin where input is needed, JSON out on stdout, exit code 0 on succe
63
64
  | `by-source <uri> [--contains]` | Intents linked to a stakeholder source, plus the distinct commits across them. `--contains` matches a substring such as a ticket key |
64
65
  | `recent [--limit N]` | Newest intents first |
65
66
  | `systems` | Known VCS and stakeholder system names |
67
+ | `backfill` | Recover intent records from a history of commit messages (JSON via stdin) |
66
68
  | `serve [--port N]` | Start the web GUI on 127.0.0.1 (default port 4791) |
67
69
 
68
70
  ### Input shape for `record` and `attach`
@@ -87,6 +89,86 @@ Options take either `--name value` or `--name=value`. Unknown options and stray
87
89
 
88
90
  An intent can be recorded before the commit exists and linked with `attach` afterwards. This also covers rebases and squashes, where the same intent ends up on a new hash. One commit can carry several intents and one intent can span several commits.
89
91
 
92
+ ## Backfilling an existing repo
93
+
94
+ A repo that adopts intent-record already has years of history, and `lookup` and `by-source` answer nothing for any of it. That history is usually exactly what someone needs when they open unfamiliar code. The ticket keys are already in the commit messages, so `backfill` reads them and writes the rows `record` would have written at the time.
95
+
96
+ It recovers the graph, not the reasoning. A commit message says what changed, and only the person who made the change knew why. Every backfilled record says so in its body, so an agent that later touches that code knows the real reasoning is still to be attached.
97
+
98
+ Commits go in on stdin as JSON, so the tool never shells out to git and the same command works for Perforce or Mercurial:
99
+
100
+ ```bash
101
+ git log --format='%H%x00%an%x00%B%x01' | ruby -rjson -e '
102
+ commits = $stdin.read.split("\x01").map(&:strip).reject(&:empty?).map do |entry|
103
+ hash, author, message = entry.split("\x00", 3)
104
+ { "commit" => hash, "author" => author, "message" => message.to_s.strip }
105
+ end
106
+ puts JSON.generate({ "commits" => commits })
107
+ ' > history.json
108
+
109
+ intent-record backfill --system jira \
110
+ --pattern 'ACME-\d+' \
111
+ --uri-prefix https://acme.atlassian.net/browse/ \
112
+ --dry-run < history.json
113
+ ```
114
+
115
+ Start with `--dry-run`. It writes nothing, and it reports the sources it would create and the first twenty commit subjects nothing matched, which is how you find the second convention your team used before anything is written:
116
+
117
+ ```json
118
+ {"created": 128, "linked": 0, "skipped": 41, "failed": 0, "dry_run": true,
119
+ "sources": ["https://acme.atlassian.net/browse/ACME-42"],
120
+ "unmatched": ["Fix a typo in the README", "Bump version to 0.4.1"]}
121
+ ```
122
+
123
+ Drop `--dry-run` to write. Each commit that names a ticket gets one record, linked to that commit and to every ticket its message names. A commit naming no ticket is not stored at all.
124
+
125
+ A history may use more than one convention. Run it once per convention, each with its own system:
126
+
127
+ ```bash
128
+ intent-record backfill --system github-issues \
129
+ --pattern '(?<![A-Za-z])#(\d+)' \
130
+ --uri-prefix https://github.com/acme/api/issues/ < history.json
131
+ ```
132
+
133
+ A commit the first pass already recorded keeps that record and gains the references this pass finds, so a commit naming both a Jira key and a GitHub issue ends up linked to both. The report counts those as `linked` rather than `created`.
134
+
135
+ Reruns stay cheap. A pass that finds nothing new to link reports the commit as skipped and writes nothing.
136
+
137
+ ### Options
138
+
139
+ - `--system <name>` names the stakeholder system the references belong to. Required.
140
+ - `--pattern <regex>` says what a reference looks like in a message. Required.
141
+ - `--uri-prefix <url>` is prepended to the key to give the source uri. Without it, the whole match is the uri.
142
+ - `--dry-run` reports what it would write and writes nothing.
143
+ - `--order <newest-first|oldest-first>` says which end of the history the list starts at. Defaults to `newest-first`, which is what `git log` gives you.
144
+
145
+ A capture group narrows what gets appended to the prefix. `ACME-\d+` with prefix `https://acme.atlassian.net/browse/` appends `ACME-42`; `ENG-(\d+)` with prefix `https://linear.app/acme/issue/ENG-` appends `7`. The source is titled with the matched text either way, so `search ENG-7` finds it.
146
+
147
+ A pattern matches anywhere in the message, including inside another key. `#(\d+)` finds the `77` in an Azure `AB#77` and links it to GitHub issue 77, which is a different thing entirely. Where a history mixes conventions, anchor the loose one: `(?<![A-Za-z])#(\d+)` matches `closes #123` and `fix (#42)` while leaving `AB#77` alone.
148
+
149
+ Patterns are case-sensitive, and so are the uris they build. A history that writes `ACME-42` in some commits and `acme-42` in others gives you two sources. `(?i)acme-\d+` matches both, but each uri is still built from what the commit wrote, so the two are not merged.
150
+
151
+ There is no default pattern per system, because Jira and Linear keys look alike and only you know which one `ABC-123` means.
152
+
153
+ ### What it does with the history
154
+
155
+ Each entry takes a `commit` and an optional `message`, `author` and `ref`. A commit with no message matches nothing and is skipped rather than failing the run. The ref is the branch name, scanned alongside the message for teams that put the key in the branch and never in the commit.
156
+
157
+ Commits for the same ticket are chained. Each record links to the most recent earlier record for that ticket, so `show` on any one of them walks back through the others, and the chain continues across separate runs.
158
+
159
+ The chain is always written oldest to newest, so the later commit builds on the earlier one. That needs to know which end of your list is the old end, which is what `--order` says. `git log` prints newest first and that is the default, so a reversed list (`git log --reverse`) needs `--order oldest-first` or every link points backwards.
160
+
161
+ Each commit is written in its own transaction. One malformed hash in a history of thousands costs that commit and not the run, and the report names it:
162
+
163
+ ```json
164
+ {"created": 3, "linked": 0, "skipped": 1, "failed": 1,
165
+ "failures": [{"commit": "bad-hash", "error": "git commit must be 40 or 64 hex characters, got \"bad-hash\""}]}
166
+ ```
167
+
168
+ Those failures come back on every rerun, since a hash that is not a hash can never be written.
169
+
170
+ Rewriting history changes hashes, and a rerun after a rebase links the new hashes to new records. The old ones stay, pointing at commits that no longer exist.
171
+
90
172
  ## Data model
91
173
 
92
174
  Commit hashes are treated as globally unique, so the store does not track which repository a commit belongs to. VCS and stakeholder system names are lowercased on the way in, and the common ones are seeded on first connect; `intent-record systems` lists them. Unknown names are added on first use.
@@ -112,7 +194,7 @@ Related-intent links may form cycles; the store records what it is told and leav
112
194
 
113
195
  ## Agent integration
114
196
 
115
- Any agent that can run a process and read stdout can use it. Record after each commit, `lookup` before touching unfamiliar code, `by-source` when picking up a ticket that has history.
197
+ Any agent that can run a process and read stdout can use it. Record after each commit, `lookup` before touching unfamiliar code, `by-source` when picking up a ticket that has history, and `backfill` once when adopting the tool on a repository that already has history.
116
198
 
117
199
  For Claude Code there is a skill that says when to do each of those and what a usable `body` contains: [intent-record](https://github.com/beatmadsen/claude-skills/tree/main/skills/intent-record), in the [beatmadsen/claude-skills](https://github.com/beatmadsen/claude-skills) collection. Install it for every project you work on:
118
200
 
@@ -0,0 +1,54 @@
1
+ require_relative "../models/intent_record"
2
+
3
+ module IntentRecord
4
+ module Backfill
5
+ # The text a backfilled record carries.
6
+ #
7
+ # A commit message says what changed, not why, and backfill cannot invent
8
+ # the why. So the body says plainly that the reasoning was never recorded,
9
+ # and the commit message follows it. The marker is a fixed string, which is
10
+ # what lets an agent find these records later and attach the real reasoning,
11
+ # and what a migration would search for if this ever becomes a column.
12
+ #
13
+ # The message is copied rather than pointed at: the store keeps no
14
+ # repository identity, so "see the commit" names no git anyone could open,
15
+ # and search, by-source and the GUI all read the store alone.
16
+ class BackfilledIntent
17
+ MARKER = "Backfilled from the commit message; the reasoning behind this change was not recorded.".freeze
18
+ SUMMARY_LIMIT = Models::IntentRecord::SUMMARY_MAX_LENGTH
19
+ BLANK_SUMMARY = "Backfilled commit with no message".freeze
20
+ ELLIPSIS = "...".freeze
21
+
22
+ def initialize(message:, author:)
23
+ @message = message.to_s
24
+ @author = author
25
+ end
26
+
27
+ def to_h
28
+ { "summary" => summary, "body" => body, "author" => @author }
29
+ end
30
+
31
+ private
32
+
33
+ # A truncated summary loses nothing: the body below keeps the subject
34
+ # whole.
35
+ def summary
36
+ subject.empty? ? BLANK_SUMMARY : truncated(subject)
37
+ end
38
+
39
+ def body
40
+ [MARKER, @message.strip].reject(&:empty?).join("\n\n")
41
+ end
42
+
43
+ def subject
44
+ @message.strip.lines.first.to_s.strip.gsub(/\s+/, " ")
45
+ end
46
+
47
+ def truncated(text)
48
+ return text if text.length <= SUMMARY_LIMIT
49
+
50
+ "#{text[0, SUMMARY_LIMIT - ELLIPSIS.length]}#{ELLIPSIS}"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,39 @@
1
+ require_relative "../input_validator"
2
+
3
+ module IntentRecord
4
+ module Backfill
5
+ # Reads what a backfill payload says about each commit. Every entry is
6
+ # checked before any is returned, so a payload with a bad entry late in the
7
+ # list never reaches the database. Nothing here needs one.
8
+ module CommitSpecs
9
+ module_function
10
+
11
+ def from(input)
12
+ entries(input).map { |entry| spec(entry) }
13
+ end
14
+
15
+ def entries(input)
16
+ InputValidator.array!(input, "commits")
17
+ end
18
+
19
+ def spec(entry)
20
+ raise ValidationError, "commits entries must be objects" unless entry.is_a?(Hash)
21
+
22
+ { commit: InputValidator.required_string!(entry, "commit"),
23
+ message: message(entry),
24
+ author: InputValidator.optional_string!(entry, "author"),
25
+ ref: InputValidator.optional_string!(entry, "ref") }
26
+ end
27
+
28
+ # An empty commit message is rare and real, and it names no ticket, so it
29
+ # is the same as any commit matching nothing. Refusing it would abort a
30
+ # whole history over one commit nobody wrote a message for. A message that
31
+ # is not a string is still a malformed payload rather than an absent one.
32
+ def message(entry)
33
+ InputValidator.optional_string!(entry, "message").to_s
34
+ end
35
+
36
+ private_class_method :entries, :spec, :message
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,46 @@
1
+ module IntentRecord
2
+ module Backfill
3
+ # What one dry run saw: the sources it would create and the commit subjects
4
+ # that matched nothing. A write run collects neither, because it is not an
5
+ # inspection, and reports neither.
6
+ class Inspection
7
+ # A real history has thousands of unmatched commits and nobody reads
8
+ # thousands of lines. Enough to recognise a convention, not enough to bury
9
+ # the counts.
10
+ UNMATCHED_SHOWN = 20
11
+
12
+ def initialize(collecting)
13
+ @collecting = collecting
14
+ @sources = []
15
+ @unmatched = []
16
+ end
17
+
18
+ def missed(subject)
19
+ @unmatched << subject if listable?(subject)
20
+ :skipped
21
+ end
22
+
23
+ def noted(references)
24
+ @sources.concat(references.map { |r| r["uri"] }) if @collecting
25
+ end
26
+
27
+ def to_h
28
+ return {} unless @collecting
29
+
30
+ { "dry_run" => true, "sources" => @sources.uniq, "unmatched" => @unmatched }
31
+ end
32
+
33
+ private
34
+
35
+ # A commit with no message has no subject to show, and a column of blank
36
+ # lines says nothing about what the pattern missed.
37
+ def listable?(subject)
38
+ @collecting && room_left? && !subject.empty?
39
+ end
40
+
41
+ def room_left?
42
+ @unmatched.size < UNMATCHED_SHOWN
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,49 @@
1
+ require_relative "../models/intent_record"
2
+ require_relative "../models/stakeholder_source"
3
+ require_relative "../stakeholder_normalizer"
4
+
5
+ module IntentRecord
6
+ module Backfill
7
+ # The intent a new record for these references builds on: the most recent
8
+ # earlier record against the same stakeholder source.
9
+ #
10
+ # The most recent one only, because linking to every earlier commit for a
11
+ # ticket would say each builds on all of them, which the history does not
12
+ # support. Asked of the store rather than remembered within a run, so a
13
+ # second run continues the chain the first one left.
14
+ module PredecessorFinder
15
+ module_function
16
+
17
+ # A commit naming two tickets with the same predecessor asks for that
18
+ # link once, which IntentLinker's find-or-create already settles, so the
19
+ # duplicate is left for it rather than removed twice.
20
+ def for(references)
21
+ references.filter_map { |reference| latest_intent_for(reference) }
22
+ end
23
+
24
+ def latest_intent_for(reference)
25
+ newest(intents_against(source_id(reference)))
26
+ end
27
+
28
+ def intents_against(source_id)
29
+ Models::IntentRecord.joins(:stakeholder_sources).where(stakeholder_sources: { id: source_id })
30
+ end
31
+
32
+ def newest(intents)
33
+ intents.order(created_at: :desc, id: :desc).first&.global_id
34
+ end
35
+
36
+ def source_id(reference)
37
+ Models::StakeholderSource.joins(:stakeholder_system)
38
+ .find_by(stakeholder_systems: { name: system_name(reference) },
39
+ uri: StakeholderNormalizer.uri(reference["uri"]))&.id
40
+ end
41
+
42
+ def system_name(reference)
43
+ StakeholderNormalizer.system_name(reference["system"])
44
+ end
45
+
46
+ private_class_method :latest_intent_for, :intents_against, :newest, :source_id, :system_name
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,70 @@
1
+ module IntentRecord
2
+ module Backfill
3
+ # Reads the stakeholder references a commit message names, as the
4
+ # `stakeholder_references` entries `record` already accepts. Nothing here
5
+ # reaches the database.
6
+ class ReferenceScanner
7
+ # A pattern that backtracks catastrophically would otherwise hang the
8
+ # command with no output. Ruby's engine memoises its way out of every
9
+ # such pattern this suite could construct, so the budget rarely decides
10
+ # anything; it is what stands between a report and a hang on an engine
11
+ # that gives up.
12
+ MATCH_TIMEOUT_SECONDS = 2.0
13
+
14
+ def initialize(system:, pattern:, uri_prefix:)
15
+ @system = system
16
+ @pattern = compiled(pattern)
17
+ @uri_prefix = uri_prefix.to_s
18
+ end
19
+
20
+ def call(message)
21
+ distinct(matches(message)).map { |match| reference(match) }
22
+ rescue Regexp::TimeoutError
23
+ raise ValidationError, timeout_message
24
+ end
25
+
26
+ private
27
+
28
+ def compiled(pattern)
29
+ Regexp.new(pattern, timeout: MATCH_TIMEOUT_SECONDS)
30
+ rescue RegexpError => e
31
+ raise ValidationError, "--pattern is not a valid regular expression: #{e.message}"
32
+ end
33
+
34
+ # Scanned as MatchData rather than with String#scan, because scan throws
35
+ # away the matched text as soon as the pattern has a group, and the title
36
+ # needs it.
37
+ def matches(message)
38
+ message.to_s.to_enum(:scan, @pattern).map { Regexp.last_match }
39
+ .map { |m| { uri: uri_for(key_of(m)), title: m[0] } }
40
+ end
41
+
42
+ # A message naming the same ticket twice, as merge commits do, asks for
43
+ # one source and not two. Keyed on the uri, because that is what makes a
44
+ # source the same source downstream.
45
+ def distinct(found)
46
+ found.uniq { |match| match[:uri] }
47
+ end
48
+
49
+ def reference(match)
50
+ { "system" => @system, "uri" => match[:uri], "title" => match[:title] }
51
+ end
52
+
53
+ def uri_for(key)
54
+ "#{@uri_prefix}#{key}"
55
+ end
56
+
57
+ # A pattern with a group says the uri is narrower than the text that found
58
+ # it: `ENG-(\d+)` finds `ENG-7` and means `7`, so the prefix can carry the
59
+ # project. With no group the whole match is the key.
60
+ def key_of(match)
61
+ match.size > 1 ? match[1] : match[0]
62
+ end
63
+
64
+ def timeout_message
65
+ "--pattern took longer than #{MATCH_TIMEOUT_SECONDS}s on one commit message; " \
66
+ "it probably backtracks catastrophically"
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,5 @@
1
+ require_relative "backfill/backfilled_intent"
2
+ require_relative "backfill/commit_specs"
3
+ require_relative "backfill/inspection"
4
+ require_relative "backfill/predecessor_finder"
5
+ require_relative "backfill/reference_scanner"
@@ -10,6 +10,7 @@ require_relative "../commands/by_source"
10
10
  require_relative "../commands/recent"
11
11
  require_relative "../commands/attach"
12
12
  require_relative "../commands/systems"
13
+ require_relative "../commands/backfill"
13
14
 
14
15
  module IntentRecord
15
16
  class CLI
@@ -67,6 +68,13 @@ module IntentRecord
67
68
  @parser.take_required_positional(label)
68
69
  end
69
70
 
71
+ def required_flag(name)
72
+ value = @parser.take_flag(name)
73
+ raise ValidationError, "#{name} is required" if value.nil? || value.strip.empty?
74
+
75
+ value
76
+ end
77
+
70
78
  def run_record
71
79
  finish! { Commands::Record.new.call(stdin_json) }
72
80
  end
@@ -104,6 +112,16 @@ module IntentRecord
104
112
  finish! { command.call(stdin_json) }
105
113
  end
106
114
 
115
+ def run_backfill
116
+ command = Commands::Backfill.scanning(system: required_flag("--system"),
117
+ pattern: required_flag("--pattern"),
118
+ uri_prefix: @parser.take_flag("--uri-prefix"),
119
+ dry_run: @parser.take_switch?("--dry-run"),
120
+ order: @parser.take_flag("--order") ||
121
+ Commands::Backfill::DEFAULT_ORDER)
122
+ finish! { command.call(stdin_json) }
123
+ end
124
+
107
125
  def run_systems
108
126
  finish! { Commands::Systems.new.call }
109
127
  end
@@ -12,6 +12,7 @@ module IntentRecord
12
12
  by-source <uri> [--contains] Intents linked to a stakeholder uri (Jira ticket, Confluence page, ...)
13
13
  recent [--limit N] Newest intents first
14
14
  systems Known vcs and stakeholder system names
15
+ backfill [options] Recover intents from commit messages (JSON via stdin, see below)
15
16
  serve [--port N] Start the local web GUI
16
17
 
17
18
  Options:
@@ -19,6 +20,18 @@ module IntentRecord
19
20
  --help, -h Show this help
20
21
  --version Show version
21
22
 
23
+ Options for backfill:
24
+ --system <name> Stakeholder system the found references belong to (required)
25
+ --pattern <regex> What a reference looks like in a message (required).
26
+ A capture group narrows what is appended to the prefix
27
+ --uri-prefix <url> Prefix + key = source uri. Without it the whole match is the uri
28
+ --order <which-first> newest-first (default, as git log prints) or oldest-first
29
+ --dry-run Report what it would write, write nothing
30
+
31
+ Input shape for backfill:
32
+ {"commits": [{"commit": "<hash>", "message": "<commit message>",
33
+ "author": "...", "ref": "<branch name>"}]}
34
+
22
35
  Input shape for record/attach (all keys optional except summary and body on record):
23
36
  {"summary": "...", "body": "...", "author": "...",
24
37
  "commits": ["<git hash>"], "asset_versions": [{"vcs": "perforce", "external_id": "123"}],
@@ -0,0 +1,160 @@
1
+ require_relative "record"
2
+ require_relative "../backfill/backfilled_intent"
3
+ require_relative "../backfill/commit_specs"
4
+ require_relative "../backfill/inspection"
5
+ require_relative "../backfill/predecessor_finder"
6
+ require_relative "../backfill/reference_scanner"
7
+ require_relative "../linkers/stakeholder_linker"
8
+ require_relative "../stakeholder_normalizer"
9
+ require_relative "../models/asset_version"
10
+ require_relative "../asset_version_normalizer"
11
+
12
+ module IntentRecord
13
+ module Commands
14
+ # Recovers intent records from a history of commit messages.
15
+ #
16
+ # One record per commit that names a reference, because `lookup <hash>` is
17
+ # meaningless if a record's summary describes forty other commits. A ticket
18
+ # spanning commits is what `by-source` already assembles from these.
19
+ #
20
+ # Each commit is its own transaction, so one malformed hash in a history of
21
+ # thousands costs that commit rather than the run. A commit that already has
22
+ # an intent is passed over, which is what makes a second run cheap: finding
23
+ # the convention you missed is the normal way to use this.
24
+ class Backfill
25
+ # `git log` prints newest first, which is what a person pipes in without
26
+ # thinking about it, so that is the default. The chain is written oldest
27
+ # to newest either way; this only says which end of the list is which.
28
+ ORDERS = %w[newest-first oldest-first].freeze
29
+ DEFAULT_ORDER = "newest-first".freeze
30
+
31
+ def self.scanning(system:, pattern:, uri_prefix: nil, **)
32
+ new(scanner: IntentRecord::Backfill::ReferenceScanner.new(system: system, pattern: pattern,
33
+ uri_prefix: uri_prefix),
34
+ **)
35
+ end
36
+
37
+ def initialize(scanner:, dry_run: false, order: DEFAULT_ORDER)
38
+ @scanner = scanner
39
+ @dry_run = dry_run
40
+ @order = validated_order(order)
41
+ end
42
+
43
+ def call(input)
44
+ specs = chronological(IntentRecord::Backfill::CommitSpecs.from(input))
45
+ seen = IntentRecord::Backfill::Inspection.new(@dry_run)
46
+ report(specs.map { |spec| process(spec, seen) }, seen)
47
+ end
48
+
49
+ private
50
+
51
+ def validated_order(order)
52
+ value = order.to_s.strip.downcase
53
+ return value if ORDERS.include?(value)
54
+
55
+ raise ValidationError, "--order must be one of #{ORDERS.join(", ")}, got #{order.inspect}"
56
+ end
57
+
58
+ # Each record links to the one before it, so the commits are written in
59
+ # the order they were made whichever end of the history the caller gave.
60
+ def chronological(specs)
61
+ @order == DEFAULT_ORDER ? specs.reverse : specs
62
+ end
63
+
64
+ # A commit already recorded keeps its record and gains whatever this pass
65
+ # found, because a second pass exists to link the convention the first
66
+ # pattern did not match. Skipping the commit whole would silently drop
67
+ # every reference in it.
68
+ def process(spec, seen)
69
+ references = @scanner.call(searchable(spec))
70
+ return seen.missed(subject(spec)) if references.empty?
71
+
72
+ write(spec, references, seen)
73
+ rescue Error => e
74
+ { commit: spec[:commit], error: e.message }
75
+ end
76
+
77
+ # Nothing this pass found is new, which is what a repeated pass looks
78
+ # like: neither created nor linked, and its sources are not offered as
79
+ # ones a dry run would add.
80
+ def write(spec, references, seen)
81
+ existing = recorded_intents(spec[:commit])
82
+ return :skipped if nothing_new?(existing, references)
83
+
84
+ seen.noted(references)
85
+ existing.empty? ? create(spec, references) : attach(existing, references)
86
+ end
87
+
88
+ def nothing_new?(existing, references)
89
+ existing.any? && already_linked?(existing, references)
90
+ end
91
+
92
+ def attach(existing, references)
93
+ return :linked if @dry_run
94
+
95
+ ActiveRecord::Base.transaction do
96
+ existing.each { |record| Linkers::StakeholderLinker.call(record, "stakeholder_references" => references) }
97
+ end
98
+ :linked
99
+ end
100
+
101
+ def already_linked?(existing, references)
102
+ wanted = references.map { |r| StakeholderNormalizer.uri(r["uri"]) }
103
+ existing.all? { |record| (wanted - record.stakeholder_sources.map(&:uri)).empty? }
104
+ end
105
+
106
+ def create(spec, references)
107
+ @dry_run ? :created : write!(spec, references)
108
+ end
109
+
110
+ def subject(spec)
111
+ spec[:message].strip.lines.first.to_s.strip
112
+ end
113
+
114
+ # The key is as often in the branch name as in the message, and a team
115
+ # that puts it there never puts it in both.
116
+ def searchable(spec)
117
+ [spec[:message], spec[:ref]].compact.join("\n")
118
+ end
119
+
120
+ def write!(spec, references)
121
+ ActiveRecord::Base.transaction { Commands::Record.new.call(payload(spec, references)) }
122
+ :created
123
+ end
124
+
125
+ def payload(spec, references)
126
+ intent = IntentRecord::Backfill::BackfilledIntent.new(message: spec[:message], author: spec[:author])
127
+ intent.to_h.merge("commits" => [spec[:commit]],
128
+ "stakeholder_references" => references,
129
+ "related_intent_ids" => IntentRecord::Backfill::PredecessorFinder.for(references))
130
+ end
131
+
132
+ # Asked of the store rather than remembered, so a commit recorded by an
133
+ # earlier run or by hand is treated the same.
134
+ def recorded_intents(commit)
135
+ version = Models::AssetVersion.joins(:vcs_system)
136
+ .find_by(vcs_systems: { name: "git" }, external_id: lookup_id(commit))
137
+ version ? version.intent_records.to_a : []
138
+ end
139
+
140
+ # A hash that is not a hash cannot match anything stored, and judging its
141
+ # shape here would refuse it before the per-commit rescue can report it.
142
+ def lookup_id(commit)
143
+ AssetVersionNormalizer.lookup_id("git", commit)
144
+ end
145
+
146
+ def report(outcomes, seen)
147
+ counts(outcomes).merge(seen.to_h)
148
+ end
149
+
150
+ def counts(outcomes)
151
+ failures = outcomes.grep(Hash)
152
+ { "created" => outcomes.count(:created),
153
+ "linked" => outcomes.count(:linked),
154
+ "skipped" => outcomes.count(:skipped),
155
+ "failed" => failures.size,
156
+ "failures" => failures.map { |f| { "commit" => f[:commit], "error" => f[:error] } } }
157
+ end
158
+ end
159
+ end
160
+ end
@@ -1,3 +1,3 @@
1
1
  module IntentRecord
2
- VERSION = "1.0.0".freeze
2
+ VERSION = "1.1.0".freeze
3
3
  end
data/lib/intent_record.rb CHANGED
@@ -25,4 +25,5 @@ require "intent_record/models/stakeholder_system"
25
25
  require "intent_record/models/stakeholder_source"
26
26
  require "intent_record/models/stakeholder_reference"
27
27
  require "intent_record/models/intent_record_link"
28
+ require "intent_record/backfill"
28
29
  require "intent_record/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: intent-record
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Erik T. Madsen
@@ -112,6 +112,12 @@ files:
112
112
  - lib/intent_record/application_record.rb
113
113
  - lib/intent_record/asset_version_normalizer.rb
114
114
  - lib/intent_record/asset_version_resolver.rb
115
+ - lib/intent_record/backfill.rb
116
+ - lib/intent_record/backfill/backfilled_intent.rb
117
+ - lib/intent_record/backfill/commit_specs.rb
118
+ - lib/intent_record/backfill/inspection.rb
119
+ - lib/intent_record/backfill/predecessor_finder.rb
120
+ - lib/intent_record/backfill/reference_scanner.rb
115
121
  - lib/intent_record/cli.rb
116
122
  - lib/intent_record/cli/argv_parser.rb
117
123
  - lib/intent_record/cli/dispatch.rb
@@ -119,6 +125,7 @@ files:
119
125
  - lib/intent_record/cli/streams.rb
120
126
  - lib/intent_record/cli/usage.rb
121
127
  - lib/intent_record/commands/attach.rb
128
+ - lib/intent_record/commands/backfill.rb
122
129
  - lib/intent_record/commands/by_source.rb
123
130
  - lib/intent_record/commands/lookup.rb
124
131
  - lib/intent_record/commands/recent.rb