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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +83 -1
- data/lib/intent_record/backfill/backfilled_intent.rb +54 -0
- data/lib/intent_record/backfill/commit_specs.rb +39 -0
- data/lib/intent_record/backfill/inspection.rb +46 -0
- data/lib/intent_record/backfill/predecessor_finder.rb +49 -0
- data/lib/intent_record/backfill/reference_scanner.rb +70 -0
- data/lib/intent_record/backfill.rb +5 -0
- data/lib/intent_record/cli/dispatch.rb +18 -0
- data/lib/intent_record/cli/usage.rb +13 -0
- data/lib/intent_record/commands/backfill.rb +160 -0
- data/lib/intent_record/version.rb +1 -1
- data/lib/intent_record.rb +1 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b2abf7a93c2bbefd6b31f0ff8ee7f08def82e721b1166885b8701f1f003162e7
|
|
4
|
+
data.tar.gz: f5754926ff4e9b0ff7d339423196487610e816bdd164f3fc96344fb9423ef83c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
[](https://badge.fury.io/rb/intent-record)
|
|
3
4
|
[](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
|
|
@@ -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
|
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.
|
|
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
|