intent-record 1.1.0 → 1.2.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 +14 -0
- data/README.md +79 -1
- data/db/migrate/20260920000001_create_search_index.rb +64 -0
- data/lib/intent_record/blame/porcelain.rb +37 -0
- data/lib/intent_record/blame/spans.rb +74 -0
- data/lib/intent_record/cli/blame_input.rb +23 -0
- data/lib/intent_record/cli/dispatch.rb +7 -0
- data/lib/intent_record/cli/usage.rb +13 -1
- data/lib/intent_record/commands/blame.rb +74 -0
- data/lib/intent_record/commands/lookup.rb +1 -1
- data/lib/intent_record/commands/search.rb +49 -22
- data/lib/intent_record/match_expression.rb +50 -0
- data/lib/intent_record/models/asset_version.rb +3 -1
- data/lib/intent_record/search_index.rb +7 -0
- data/lib/intent_record/search_membership.rb +62 -0
- data/lib/intent_record/search_ranking.rb +57 -0
- data/lib/intent_record/search_snippet.rb +52 -0
- data/lib/intent_record/search_term.rb +59 -0
- data/lib/intent_record/version.rb +1 -1
- data/lib/intent_record/web/views/_intent_cards.erb +3 -1
- data/lib/intent_record/web/views/search.erb +1 -1
- data/lib/intent_record.rb +8 -0
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '09016b9ff684f0f9137aa00a6848f68b6018cd870495a73d9ebfe11dd4706d8f'
|
|
4
|
+
data.tar.gz: 93d53144d6ea2bfe0c997d6242ed4e0e34843392998562824196d44494a0c569
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 34aa8df17cbecd1c6eda808dde6dbb54d8c13d6acd5741c62b89cfdfb54843a942f951cd06e7f84935d45486609a90ec3d3696bcd30dfbf9230694d0c2bc9f41
|
|
7
|
+
data.tar.gz: bfccfc5091b3fde1060e638d6e9f3f451daad4d8a984ca144d705bae19133a948f10f555213061510eea2dda252312bc0dd07db87c14dcce5eba39087cdababf
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,20 @@ Notable changes, one section per released version. The format follows
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 1.2.0 - 2026-09-20
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `blame`, which answers "why is this line here" for a range of lines. Blame output goes in on stdin, so the tool still never shells out to git and the same command works for Perforce or Mercurial. `--format git-porcelain` reads what `git blame --porcelain` prints, so the common case is one command.
|
|
14
|
+
- Neighbouring lines from one change are answered once, as a span. A change the store has never heard of still gets a span, with no intents, and a line you have edited but not committed is marked `uncommitted`.
|
|
15
|
+
- A search term can name one field: `summary:retry`, `body:retry`, `uri:ACME-42`, `title:flaky`. The name is read in any case. Only those four names count, so a ticket url is still searched as itself.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Search answers most relevant first rather than newest first, with a term in a summary counting for more than the same term in a long body. Each result carries the fragment of the body its terms landed in, and the GUI's search page shows that instead of every body in full.
|
|
20
|
+
- A plain search word now also matches the other forms of itself, so `retry` finds a record that said `retried`. A term carrying punctuation is still matched as the literal you typed, so `100%` does not match "100 percent" and `ACME-4` still finds `ACME-42`.
|
|
21
|
+
- Upgrading indexes the records a store already holds, so ranking works on existing history from the first run rather than only on what is recorded afterwards.
|
|
22
|
+
|
|
9
23
|
## 1.1.0 - 2026-09-19
|
|
10
24
|
|
|
11
25
|
### Added
|
data/README.md
CHANGED
|
@@ -7,6 +7,7 @@ Local records storage for the intent behind individual code changes.
|
|
|
7
7
|
|
|
8
8
|
A commit message says what changed. The intent record says why: what the agent (or person) was trying to achieve, which ticket or design page asked for it, and which earlier change it builds on. intent-record stores that in a local SQLite database, links it to the commit hash, and answers the questions people and agents ask later.
|
|
9
9
|
|
|
10
|
+
- "Why is this line here?" (`git blame --porcelain f.rb | intent-record blame --format git-porcelain`)
|
|
10
11
|
- "What was this commit for?" (`lookup <hash>`)
|
|
11
12
|
- "What did we build for this Jira ticket, and why that way?" (`by-source <url-or-key>`)
|
|
12
13
|
- "Have we touched retry logic before, and what were we thinking?" (`search retry`)
|
|
@@ -46,6 +47,9 @@ intent-record lookup 8f3a1c2
|
|
|
46
47
|
# Or comes at it from the ticket
|
|
47
48
|
intent-record by-source ACME-42 --contains
|
|
48
49
|
|
|
50
|
+
# Or is staring at one line of a file and wants to know why it is there
|
|
51
|
+
git blame --porcelain lib/fetch.rb | intent-record blame --format git-porcelain
|
|
52
|
+
|
|
49
53
|
# Or wants to read it in a browser
|
|
50
54
|
intent-record serve
|
|
51
55
|
```
|
|
@@ -60,10 +64,11 @@ JSON in on stdin where input is needed, JSON out on stdout, exit code 0 on succe
|
|
|
60
64
|
| `attach <intent_id>` | Link more commits, stakeholder references or related intents to an existing record (JSON via stdin) |
|
|
61
65
|
| `show <intent_id>` | Full intent record with commits, stakeholder links and related intents |
|
|
62
66
|
| `lookup <commit> [--vcs name]` | All intents recorded against a commit. Accepts a full hash or a unique prefix of at least 4 characters. Ids are unique per system, not across them, so an id recorded in two systems is reported as ambiguous; name one with `--vcs` |
|
|
63
|
-
| `search <terms...> [--match all]` |
|
|
67
|
+
| `search <terms...> [--match all]` | Search over summary, body, and linked stakeholder URIs and titles, most relevant first, so a ticket key finds its intents |
|
|
64
68
|
| `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 |
|
|
65
69
|
| `recent [--limit N]` | Newest intents first |
|
|
66
70
|
| `systems` | Known VCS and stakeholder system names |
|
|
71
|
+
| `blame [--format git-porcelain]` | Intents for the commits a blame output names, answered as spans of lines (JSON via stdin, or `git blame --porcelain` output with `--format git-porcelain`) |
|
|
67
72
|
| `backfill` | Recover intent records from a history of commit messages (JSON via stdin) |
|
|
68
73
|
| `serve [--port N]` | Start the web GUI on 127.0.0.1 (default port 4791) |
|
|
69
74
|
|
|
@@ -89,6 +94,79 @@ Options take either `--name value` or `--name=value`. Unknown options and stray
|
|
|
89
94
|
|
|
90
95
|
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.
|
|
91
96
|
|
|
97
|
+
## Searching
|
|
98
|
+
|
|
99
|
+
Results come back most relevant first, not newest first, with a term in a summary
|
|
100
|
+
counting for more than the same term somewhere in a long body. Each result carries a
|
|
101
|
+
`snippet`: the part of the body the terms landed in, or the opening of the body when
|
|
102
|
+
the match was a literal one, so a page of hits is readable without opening any of them.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
intent-record search retry backoff # either word
|
|
106
|
+
intent-record search retry backoff --match all # both
|
|
107
|
+
intent-record search "retry the fetch" # the words in that order
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A plain word also matches the other forms of itself, so `retry` finds a record that
|
|
111
|
+
said `retried`. A term carrying punctuation is matched as the literal you typed:
|
|
112
|
+
`100%` does not match "100 percent", and `ACME-4` still finds `ACME-42` the way a
|
|
113
|
+
substring search does.
|
|
114
|
+
|
|
115
|
+
A term can name one field, for when a common word turns up everywhere:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
intent-record search summary:retry # summary, body, uri or title
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Only those four names count, so a ticket URL is searched as itself rather than read
|
|
122
|
+
as a field called `https`.
|
|
123
|
+
|
|
124
|
+
## Why is this line here
|
|
125
|
+
|
|
126
|
+
`lookup` answers for a commit, which means finding the commit first. That is three
|
|
127
|
+
steps: run blame, copy the hash, look it up. `blame` does the whole thing.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
git blame --porcelain lib/fetch.rb | intent-record blame --format git-porcelain
|
|
131
|
+
git blame --porcelain -L 40,60 lib/fetch.rb | intent-record blame --format git-porcelain
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The answer is one entry per change rather than one per line, because neighbouring
|
|
135
|
+
lines from the same change collapse into a span:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{"spans": [
|
|
139
|
+
{"from": 40, "to": 44,
|
|
140
|
+
"asset_version": {"vcs": "git", "external_id": "8f3a1c2d..."},
|
|
141
|
+
"intents": [{"intent_id": "EUt4WMY", "summary": "Retry flaky fetches with backoff", "...": "..."}]},
|
|
142
|
+
{"from": 45, "to": 45,
|
|
143
|
+
"asset_version": {"vcs": "git", "external_id": "b2c4e6f8..."},
|
|
144
|
+
"intents": []},
|
|
145
|
+
{"from": 46, "to": 46,
|
|
146
|
+
"asset_version": {"vcs": "git", "external_id": "0000000000..."},
|
|
147
|
+
"intents": [], "uncommitted": true}
|
|
148
|
+
]}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
A change nothing was recorded against still gets a span, with no intents. A line you
|
|
152
|
+
have edited but not committed is marked `uncommitted`, because there was never
|
|
153
|
+
anything to record against it.
|
|
154
|
+
|
|
155
|
+
The same change appearing twice in a file with someone else's edit between is
|
|
156
|
+
answered as two spans.
|
|
157
|
+
|
|
158
|
+
### Other version control systems
|
|
159
|
+
|
|
160
|
+
`--format git-porcelain` is a convenience. The contract is JSON, and any tool that
|
|
161
|
+
can say which change a line came from can feed it:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
echo '{"vcs": "perforce", "lines": [{"line": 40, "external_id": "12345"}]}' | intent-record blame
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`vcs` defaults to `git`. Line numbers start at 1, and the same line given twice is
|
|
168
|
+
rejected rather than answered from one of the two changes.
|
|
169
|
+
|
|
92
170
|
## Backfilling an existing repo
|
|
93
171
|
|
|
94
172
|
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.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# A relevance index over the text of an intent record. It decides the order the
|
|
2
|
+
# search answers in, never which records the search answers with: FTS5 tokenises,
|
|
3
|
+
# so it cannot see a substring inside a word and it cannot tell `100%` from `100
|
|
4
|
+
# percent`, both of which the substring search is asked for and tested on.
|
|
5
|
+
#
|
|
6
|
+
# Kept in step by triggers rather than from Ruby, so a writer that never heard of
|
|
7
|
+
# the index cannot leave it stale. `backfill` writes through `record` today, but
|
|
8
|
+
# the guarantee should not depend on that staying true.
|
|
9
|
+
class CreateSearchIndex < ActiveRecord::Migration[8.0]
|
|
10
|
+
TABLE = "intent_search".freeze
|
|
11
|
+
SOURCE = "intent_records".freeze
|
|
12
|
+
|
|
13
|
+
def up
|
|
14
|
+
# An external-content table: the text lives in intent_records and the index
|
|
15
|
+
# stores only the terms, so the store does not carry a second copy of every
|
|
16
|
+
# body.
|
|
17
|
+
execute(<<~SQL)
|
|
18
|
+
CREATE VIRTUAL TABLE #{TABLE} USING fts5(
|
|
19
|
+
summary, body,
|
|
20
|
+
content='#{SOURCE}', content_rowid='id',
|
|
21
|
+
tokenize='porter unicode61'
|
|
22
|
+
)
|
|
23
|
+
SQL
|
|
24
|
+
|
|
25
|
+
create_triggers
|
|
26
|
+
backfill_existing
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def down
|
|
30
|
+
%w[insert update delete].each { |event| execute("DROP TRIGGER IF EXISTS #{TABLE}_after_#{event}") }
|
|
31
|
+
execute("DROP TABLE IF EXISTS #{TABLE}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# An external-content table is told about a change rather than reading it: the
|
|
37
|
+
# 'delete' command hands back the old text so FTS5 can retract the terms it
|
|
38
|
+
# indexed, which is why an update deletes before it inserts.
|
|
39
|
+
def create_triggers
|
|
40
|
+
execute(<<~SQL)
|
|
41
|
+
CREATE TRIGGER #{TABLE}_after_insert AFTER INSERT ON #{SOURCE} BEGIN
|
|
42
|
+
INSERT INTO #{TABLE}(rowid, summary, body) VALUES (new.id, new.summary, new.body);
|
|
43
|
+
END
|
|
44
|
+
SQL
|
|
45
|
+
execute(<<~SQL)
|
|
46
|
+
CREATE TRIGGER #{TABLE}_after_delete AFTER DELETE ON #{SOURCE} BEGIN
|
|
47
|
+
INSERT INTO #{TABLE}(#{TABLE}, rowid, summary, body) VALUES('delete', old.id, old.summary, old.body);
|
|
48
|
+
END
|
|
49
|
+
SQL
|
|
50
|
+
execute(<<~SQL)
|
|
51
|
+
CREATE TRIGGER #{TABLE}_after_update AFTER UPDATE ON #{SOURCE} BEGIN
|
|
52
|
+
INSERT INTO #{TABLE}(#{TABLE}, rowid, summary, body) VALUES('delete', old.id, old.summary, old.body);
|
|
53
|
+
INSERT INTO #{TABLE}(rowid, summary, body) VALUES (new.id, new.summary, new.body);
|
|
54
|
+
END
|
|
55
|
+
SQL
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A store that already holds records gets them indexed here, so ranking works
|
|
59
|
+
# on the history from the first run after the upgrade rather than only on what
|
|
60
|
+
# is written afterwards.
|
|
61
|
+
def backfill_existing
|
|
62
|
+
execute("INSERT INTO #{TABLE}(rowid, summary, body) SELECT id, summary, body FROM #{SOURCE}")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module IntentRecord
|
|
2
|
+
module Blame
|
|
3
|
+
# Reads `git blame --porcelain` into the lines the command takes.
|
|
4
|
+
#
|
|
5
|
+
# The JSON shape is the contract and this is a convenience, so that blaming a
|
|
6
|
+
# file from a git repository is one command rather than one command and a
|
|
7
|
+
# converter. Keeping it a separate reader is what stops the store learning
|
|
8
|
+
# anything about git: it produces the same payload a person could have
|
|
9
|
+
# written by hand, and a second version control system earns a second reader
|
|
10
|
+
# rather than a change here.
|
|
11
|
+
module Porcelain
|
|
12
|
+
# "<sha> <line in the original file> <line in this file> [<how many
|
|
13
|
+
# follow>]". The second number is the one a reader is asking about. Content
|
|
14
|
+
# lines are prefixed with a tab, so a file whose own text looks like a
|
|
15
|
+
# header is never read as one.
|
|
16
|
+
HEADER = /\A(?<id>[0-9a-f]{40}|[0-9a-f]{64}) \d+ (?<line>\d+)(?: \d+)?\z/
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def lines(text)
|
|
21
|
+
found = text.to_s.lines.filter_map { |line| header(line) }
|
|
22
|
+
raise ValidationError, "No blame headers found. Expected `git blame --porcelain` output." if found.empty?
|
|
23
|
+
|
|
24
|
+
found
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def header(line)
|
|
28
|
+
match = HEADER.match(line.chomp)
|
|
29
|
+
return nil if match.nil?
|
|
30
|
+
|
|
31
|
+
{ "line" => match[:line].to_i, "external_id" => match[:id] }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private_class_method :header
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require_relative "../input_validator"
|
|
2
|
+
|
|
3
|
+
module IntentRecord
|
|
4
|
+
module Blame
|
|
5
|
+
# Reads a blame payload into the spans a reader is answered with.
|
|
6
|
+
#
|
|
7
|
+
# A blame tool answers per line, and a reader asked about a range. Answering
|
|
8
|
+
# per line would bury the useful part in repetition, so neighbouring lines
|
|
9
|
+
# from one change become one span. Neighbouring, not merely equal: the same
|
|
10
|
+
# change either side of someone else's edit is two places in the file, and
|
|
11
|
+
# one span across them would claim a line it does not own.
|
|
12
|
+
#
|
|
13
|
+
# Nothing here reaches the database, so a malformed payload is refused before
|
|
14
|
+
# the store is asked anything.
|
|
15
|
+
class Spans
|
|
16
|
+
Span = Struct.new(:external_id, :from, :to)
|
|
17
|
+
|
|
18
|
+
def initialize(lines)
|
|
19
|
+
@lines = lines
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
collapse(sorted)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def sorted
|
|
29
|
+
raise ValidationError, "At least one line is required" unless @lines.is_a?(Array) && @lines.any?
|
|
30
|
+
|
|
31
|
+
entries = @lines.map { |line| entry(line) }.sort_by(&:first)
|
|
32
|
+
reject_repeats(entries)
|
|
33
|
+
entries
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Sorted, so a repeat is adjacent. The same line attributed to two changes
|
|
37
|
+
# is a contradiction in the payload, and answering it would mean choosing
|
|
38
|
+
# one of them silently.
|
|
39
|
+
def reject_repeats(entries)
|
|
40
|
+
entries.each_cons(2) do |(earlier, _), (later, _)|
|
|
41
|
+
raise ValidationError, "Line #{earlier} is given more than once" if earlier == later
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def entry(line)
|
|
46
|
+
raise ValidationError, "Each line needs a line and an external_id" unless line.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
[line_number(line["line"]), InputValidator.required_string!(line, "external_id")]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A string is read in base ten: Integer() on its own reads "010" as
|
|
52
|
+
# octal and answers about line 8, a wrong answer given confidently. A
|
|
53
|
+
# fraction is refused rather than truncated for the same reason.
|
|
54
|
+
def line_number(raw)
|
|
55
|
+
number = raw.is_a?(Integer) ? raw : Integer(raw.to_s, 10, exception: false)
|
|
56
|
+
raise ValidationError, "line must be a positive integer, got #{raw.inspect}" if number.nil? || number < 1
|
|
57
|
+
|
|
58
|
+
number
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Walks in file order, extending the span in hand while the next line both
|
|
62
|
+
# follows the last one and names the same change.
|
|
63
|
+
def collapse(entries)
|
|
64
|
+
entries.each_with_object([]) do |(number, id), spans|
|
|
65
|
+
extends?(spans.last, number, id) ? spans.last.to = number : spans << Span.new(id, number, number)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def extends?(span, number, id)
|
|
70
|
+
!span.nil? && span.external_id == id && span.to == number - 1
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require_relative "../blame/porcelain"
|
|
2
|
+
require_relative "stdin_json"
|
|
3
|
+
|
|
4
|
+
module IntentRecord
|
|
5
|
+
class CLI
|
|
6
|
+
# What `blame` reads from stdin. The JSON shape is the contract; a format
|
|
7
|
+
# names a reader that produces it from what some blame tool already prints.
|
|
8
|
+
module BlameInput
|
|
9
|
+
FORMATS = { "git-porcelain" => Blame::Porcelain }.freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def read(format, stdin)
|
|
14
|
+
return StdinJson.read(stdin) if format.nil?
|
|
15
|
+
|
|
16
|
+
reader = FORMATS[format]
|
|
17
|
+
raise ValidationError, "Unknown --format #{format}. Known formats: #{FORMATS.keys.join(", ")}" if reader.nil?
|
|
18
|
+
|
|
19
|
+
{ "lines" => reader.lines(stdin.read) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -11,6 +11,8 @@ require_relative "../commands/recent"
|
|
|
11
11
|
require_relative "../commands/attach"
|
|
12
12
|
require_relative "../commands/systems"
|
|
13
13
|
require_relative "../commands/backfill"
|
|
14
|
+
require_relative "../commands/blame"
|
|
15
|
+
require_relative "blame_input"
|
|
14
16
|
|
|
15
17
|
module IntentRecord
|
|
16
18
|
class CLI
|
|
@@ -122,6 +124,11 @@ module IntentRecord
|
|
|
122
124
|
finish! { command.call(stdin_json) }
|
|
123
125
|
end
|
|
124
126
|
|
|
127
|
+
def run_blame
|
|
128
|
+
format = @parser.take_flag("--format")
|
|
129
|
+
finish! { Commands::Blame.new.call(BlameInput.read(format, @streams.stdin)) }
|
|
130
|
+
end
|
|
131
|
+
|
|
125
132
|
def run_systems
|
|
126
133
|
finish! { Commands::Systems.new.call }
|
|
127
134
|
end
|
|
@@ -8,10 +8,11 @@ module IntentRecord
|
|
|
8
8
|
attach <intent_id> Link more commits / stakeholder refs / related intents (JSON via stdin)
|
|
9
9
|
show <intent_id> Full intent record
|
|
10
10
|
lookup <commit> [--vcs name] Intents recorded against a commit (full hash or unique prefix)
|
|
11
|
-
search <terms...> [--match all]
|
|
11
|
+
search <terms...> [--match all] Search over summary, body and ticket links, best match first
|
|
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
|
+
blame [--format git-porcelain] Intents for the commits a blame output names (JSON via stdin)
|
|
15
16
|
backfill [options] Recover intents from commit messages (JSON via stdin, see below)
|
|
16
17
|
serve [--port N] Start the local web GUI
|
|
17
18
|
|
|
@@ -28,6 +29,17 @@ module IntentRecord
|
|
|
28
29
|
--order <which-first> newest-first (default, as git log prints) or oldest-first
|
|
29
30
|
--dry-run Report what it would write, write nothing
|
|
30
31
|
|
|
32
|
+
Search terms:
|
|
33
|
+
Ranked best first. A plain word also matches its other forms, so `retry`
|
|
34
|
+
finds "retried". A term carrying punctuation is matched literally, so
|
|
35
|
+
`100%` does not match "100 percent". A term may name one field:
|
|
36
|
+
summary:retry, body:retry, uri:ACME-42, title:flaky. The name is read in
|
|
37
|
+
any case. Only those four names count, so a ticket url is searched as itself.
|
|
38
|
+
|
|
39
|
+
Input shape for blame (vcs defaults to git):
|
|
40
|
+
{"vcs": "git", "lines": [{"line": 40, "external_id": "<hash>"}]}
|
|
41
|
+
With --format git-porcelain, `git blame --porcelain` output goes in as-is.
|
|
42
|
+
|
|
31
43
|
Input shape for backfill:
|
|
32
44
|
{"commits": [{"commit": "<hash>", "message": "<commit message>",
|
|
33
45
|
"author": "...", "ref": "<branch name>"}]}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require_relative "../blame/spans"
|
|
2
|
+
require_relative "../blame/porcelain"
|
|
3
|
+
require_relative "../asset_version_normalizer"
|
|
4
|
+
require_relative "../formatter"
|
|
5
|
+
require_relative "../models/asset_version"
|
|
6
|
+
|
|
7
|
+
module IntentRecord
|
|
8
|
+
module Commands
|
|
9
|
+
# Answers "why is this line here" for a range of lines.
|
|
10
|
+
#
|
|
11
|
+
# Blame output goes in on stdin rather than the tool shelling out to git, so
|
|
12
|
+
# the same command answers for Perforce or Mercurial and the store keeps
|
|
13
|
+
# knowing nothing about which repository a commit belongs to.
|
|
14
|
+
class Blame
|
|
15
|
+
DEFAULT_VCS = "git".freeze
|
|
16
|
+
|
|
17
|
+
# Git's all-zero object id, which blame gives a line that is in the working
|
|
18
|
+
# copy and not in the history. It is not a commit, so a reader is told the
|
|
19
|
+
# line is not committed rather than that nothing was recorded against it,
|
|
20
|
+
# and the store is not asked about it at all: the id is a valid shape, so
|
|
21
|
+
# a record can sit under it, and one did, but a line with no history cannot
|
|
22
|
+
# have had anything recorded against it.
|
|
23
|
+
# Forty zeros, or sixty-four in a repository using the sha256 object format.
|
|
24
|
+
UNCOMMITTED = /\A(?:0{40}|0{64})\z/
|
|
25
|
+
|
|
26
|
+
def call(input)
|
|
27
|
+
vcs = AssetVersionNormalizer.vcs_name(input["vcs"] || DEFAULT_VCS)
|
|
28
|
+
spans = IntentRecord::Blame::Spans.new(input["lines"]).call
|
|
29
|
+
{ "spans" => answers(spans, vcs) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def answers(spans, vcs)
|
|
35
|
+
known = versions(spans.reject { |span| uncommitted?(span, vcs) }, vcs)
|
|
36
|
+
spans.map { |span| answer(span, vcs, known[AssetVersionNormalizer.lookup_id(vcs, span.external_id)]) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Every span is answered, including one whose change the store has never
|
|
40
|
+
# heard of. Leaving it out would read as "this line has no history", which
|
|
41
|
+
# is the opposite of what an empty list of intents says.
|
|
42
|
+
def answer(span, vcs, version)
|
|
43
|
+
answered = {
|
|
44
|
+
"from" => span.from,
|
|
45
|
+
"to" => span.to,
|
|
46
|
+
"asset_version" => { "vcs" => vcs, "external_id" => span.external_id },
|
|
47
|
+
"intents" => intents(version)
|
|
48
|
+
}
|
|
49
|
+
uncommitted?(span, vcs) ? answered.merge("uncommitted" => true) : answered
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def uncommitted?(span, vcs)
|
|
53
|
+
vcs == DEFAULT_VCS && UNCOMMITTED.match?(span.external_id)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def intents(version)
|
|
57
|
+
return [] if version.nil?
|
|
58
|
+
|
|
59
|
+
version.intent_records.map { |record| Formatter.full(record) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Read once for the whole payload rather than per span. A blamed range is a
|
|
63
|
+
# handful of changes however many lines it covers, and asking per span
|
|
64
|
+
# would make the query count grow with the size of the range.
|
|
65
|
+
def versions(spans, vcs)
|
|
66
|
+
ids = spans.map { |span| AssetVersionNormalizer.lookup_id(vcs, span.external_id) }.uniq
|
|
67
|
+
scope = Models::AssetVersion.joins(:vcs_system)
|
|
68
|
+
.where(vcs_systems: { name: vcs }, external_id: ids)
|
|
69
|
+
.preload(intent_records: Formatter::PRELOADS)
|
|
70
|
+
scope.index_by(&:external_id)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -14,7 +14,7 @@ module IntentRecord
|
|
|
14
14
|
version = AssetVersionResolver.new(external_id: @external_id, vcs: @vcs).call
|
|
15
15
|
{
|
|
16
16
|
"asset_version" => Formatter.asset_version(version),
|
|
17
|
-
"intents" => Formatter.preloaded(version.intent_records
|
|
17
|
+
"intents" => Formatter.preloaded(version.intent_records).map { |r| Formatter.full(r) }
|
|
18
18
|
}
|
|
19
19
|
end
|
|
20
20
|
end
|
|
@@ -1,49 +1,76 @@
|
|
|
1
1
|
require_relative "../formatter"
|
|
2
|
-
require_relative "../
|
|
2
|
+
require_relative "../match_expression"
|
|
3
|
+
require_relative "../search_membership"
|
|
4
|
+
require_relative "../search_ranking"
|
|
5
|
+
require_relative "../search_snippet"
|
|
6
|
+
require_relative "../search_term"
|
|
3
7
|
require_relative "../models/intent_record"
|
|
4
8
|
|
|
5
9
|
module IntentRecord
|
|
6
10
|
module Commands
|
|
7
|
-
#
|
|
8
|
-
#
|
|
11
|
+
# Search over summary, body, and linked stakeholder uris and titles, so a
|
|
12
|
+
# ticket key such as ACME-42 finds the intents built for it.
|
|
13
|
+
#
|
|
14
|
+
# Which records answer is SearchMembership's decision and what order they
|
|
15
|
+
# answer in is SearchRanking's. The two are kept apart because they read a
|
|
16
|
+
# term differently, and only one of them may be trusted with a term that
|
|
17
|
+
# carries punctuation.
|
|
9
18
|
class Search
|
|
10
19
|
LIMIT = 200
|
|
11
|
-
|
|
20
|
+
MATCH_MODES = %w[any all].freeze
|
|
12
21
|
|
|
22
|
+
# Refuses bad input here, before the store is asked anything.
|
|
13
23
|
def initialize(terms:, match: "any")
|
|
14
|
-
|
|
24
|
+
raw = terms.map(&:strip).reject(&:empty?)
|
|
25
|
+
raise ValidationError, "At least one search term is required" if raw.empty?
|
|
26
|
+
raise ValidationError, "--match must be any or all" unless MATCH_MODES.include?(match)
|
|
27
|
+
|
|
28
|
+
@terms = raw.map { |term| SearchTerm.parse(term) }
|
|
15
29
|
@match = match
|
|
16
30
|
end
|
|
17
31
|
|
|
18
32
|
def call
|
|
19
|
-
|
|
20
|
-
raise ValidationError, "--match must be any or all" unless %w[any all].include?(@match)
|
|
21
|
-
|
|
22
|
-
{ "intents" => matching_records.map { |r| Formatter.full(r) } }
|
|
33
|
+
{ "intents" => matching_records.map { |r| formatted(r) } }
|
|
23
34
|
end
|
|
24
35
|
|
|
25
36
|
private
|
|
26
37
|
|
|
38
|
+
# The snippet belongs to the search rather than to the record, so it is
|
|
39
|
+
# merged here instead of in Formatter, which every other command shares.
|
|
40
|
+
def formatted(record)
|
|
41
|
+
snippet = SearchSnippet.text(indexed: record[SearchSnippet::COLUMN], body: record.body)
|
|
42
|
+
Formatter.full(record).merge(SearchSnippet::COLUMN => snippet)
|
|
43
|
+
end
|
|
44
|
+
|
|
27
45
|
def matching_records
|
|
28
|
-
Formatter.preloaded(
|
|
29
|
-
Models::IntentRecord.left_joins(:stakeholder_sources)
|
|
30
|
-
.group("intent_records.id")
|
|
31
|
-
.having(having_sql, *having_binds)
|
|
32
|
-
.order(created_at: :desc).limit(LIMIT)
|
|
33
|
-
)
|
|
46
|
+
Formatter.preloaded(with_ranking(matching_scope))
|
|
34
47
|
end
|
|
35
48
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
def matching_scope
|
|
50
|
+
Models::IntentRecord.left_joins(:stakeholder_sources)
|
|
51
|
+
.group("intent_records.id")
|
|
52
|
+
.having(*SearchMembership.new(terms: @terms, match: @match).condition)
|
|
53
|
+
.limit(LIMIT)
|
|
39
54
|
end
|
|
40
55
|
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
# Only the terms the index may answer rank anything. A term it may not be
|
|
57
|
+
# trusted with would rank by text the person did not ask for, which is the
|
|
58
|
+
# same reason it does not decide membership either.
|
|
59
|
+
def indexable
|
|
60
|
+
@terms.select(&:indexable?)
|
|
43
61
|
end
|
|
44
62
|
|
|
45
|
-
|
|
46
|
-
|
|
63
|
+
# Joins the relevance and fragment the index found, and orders by them.
|
|
64
|
+
# Terms that hold no searchable token leave nothing to rank, and an empty
|
|
65
|
+
# MATCH is a syntax error rather than an expression matching nothing, so
|
|
66
|
+
# that case orders by the tie-break alone and the body stands in for the
|
|
67
|
+
# fragment.
|
|
68
|
+
def with_ranking(scope)
|
|
69
|
+
expression = MatchExpression.for(indexable)
|
|
70
|
+
return scope.order(Arel.sql(SearchRanking::TIE_BREAK)) if expression.nil?
|
|
71
|
+
|
|
72
|
+
join = Models::IntentRecord.sanitize_sql_array([SearchRanking::JOIN, expression])
|
|
73
|
+
scope.select(SearchRanking::SELECTION).joins(join).order(Arel.sql(SearchRanking::ORDER))
|
|
47
74
|
end
|
|
48
75
|
end
|
|
49
76
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module IntentRecord
|
|
2
|
+
# Turns the terms a user typed into an FTS5 MATCH expression.
|
|
3
|
+
#
|
|
4
|
+
# FTS5 reads its right-hand side as an expression language, not as text: `100%`
|
|
5
|
+
# is a syntax error and a term such as `OR` or `NEAR` is an operator. Quoting
|
|
6
|
+
# each term makes it a phrase, which is the only form that carries whatever the
|
|
7
|
+
# user typed through the parser unread.
|
|
8
|
+
#
|
|
9
|
+
# Phrases are joined with OR. The expression is used twice, and OR is right
|
|
10
|
+
# for both: SearchMembership asks the index one term at a time, so an
|
|
11
|
+
# expression there holds a single phrase and the joiner never applies; and
|
|
12
|
+
# SearchRanking asks it about every term at once, where a record is scored on
|
|
13
|
+
# whichever terms it holds rather than dropped for lacking one, because which
|
|
14
|
+
# records answer has already been settled and AND would score some of them at
|
|
15
|
+
# nothing.
|
|
16
|
+
module MatchExpression
|
|
17
|
+
JOINER = " OR ".freeze
|
|
18
|
+
QUOTE = '"'.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Takes SearchTerms, which carry the text and the column the person named.
|
|
23
|
+
def for(terms)
|
|
24
|
+
phrases = terms.map { |term| phrase(term) }.compact
|
|
25
|
+
return nil if phrases.empty?
|
|
26
|
+
|
|
27
|
+
phrases.join(JOINER)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def phrase(term)
|
|
31
|
+
# A term of pure punctuation tokenises to nothing, and an empty phrase is
|
|
32
|
+
# a syntax error rather than a phrase that matches nothing.
|
|
33
|
+
return nil unless term.text.match?(/[[:alnum:]]/)
|
|
34
|
+
|
|
35
|
+
column(term.index_column) + quoted(term.text)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# FTS5 reads `summary:"retry"` as that phrase in that column only. A term
|
|
39
|
+
# naming no column is left unprefixed and is answered over all of them.
|
|
40
|
+
def column(name)
|
|
41
|
+
name.nil? ? "" : "#{name}:"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def quoted(text)
|
|
45
|
+
"#{QUOTE}#{text.gsub(QUOTE, QUOTE * 2)}#{QUOTE}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private_class_method :phrase, :column, :quoted
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -4,7 +4,9 @@ module IntentRecord
|
|
|
4
4
|
class AssetVersion < ApplicationRecord
|
|
5
5
|
belongs_to :vcs_system
|
|
6
6
|
has_many :intent_record_asset_versions, dependent: :destroy
|
|
7
|
-
|
|
7
|
+
# Oldest first, then id, so a commit carrying several intents reads as a
|
|
8
|
+
# story and the order is the query's rather than the plan's or Ruby's.
|
|
9
|
+
has_many :intent_records, -> { order(:created_at, :id) }, through: :intent_record_asset_versions
|
|
8
10
|
|
|
9
11
|
validates :external_id, presence: true, uniqueness: { scope: :vcs_system_id }
|
|
10
12
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require_relative "like_pattern"
|
|
2
|
+
require_relative "match_expression"
|
|
3
|
+
require_relative "search_ranking"
|
|
4
|
+
require_relative "search_term"
|
|
5
|
+
|
|
6
|
+
module IntentRecord
|
|
7
|
+
# Which records a search answers with.
|
|
8
|
+
#
|
|
9
|
+
# Two ways of matching sit behind one condition. Substring matching reads a
|
|
10
|
+
# term as the literal it is, which is what a person typing `100%` or `ACME-4`
|
|
11
|
+
# is asking for. The index reads it as a word, which is what finds `retried`
|
|
12
|
+
# for someone who typed `retry`.
|
|
13
|
+
#
|
|
14
|
+
# They are not interchangeable, and the index is the narrower of the two in
|
|
15
|
+
# one direction and the wider in the other. It cannot see inside a word, so it
|
|
16
|
+
# would lose `ACME-4` in `ACME-42`; and it drops punctuation, so, measured, it
|
|
17
|
+
# matches `100 percent` for `100%` and `done now` for `done_now`. A term the
|
|
18
|
+
# index may not be trusted with therefore gets the substring arm alone, which
|
|
19
|
+
# SearchTerm#indexable? decides.
|
|
20
|
+
class SearchMembership
|
|
21
|
+
JOINERS = { "all" => " AND ", "any" => " OR " }.freeze
|
|
22
|
+
|
|
23
|
+
def initialize(terms:, match:)
|
|
24
|
+
@terms = terms
|
|
25
|
+
@match = match
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Ready for `having`: the condition followed by its binds, in order.
|
|
29
|
+
def condition
|
|
30
|
+
clauses = @terms.map { |term| clause(term) }
|
|
31
|
+
[clauses.map(&:first).join(JOINERS.fetch(@match)), *clauses.flat_map { |c| c.drop(1) }]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def clause(term)
|
|
37
|
+
arms = [substring_arm(term), *index_arm(term)]
|
|
38
|
+
["(#{arms.join(" OR ")})", *substring_binds(term), *index_binds(term)]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Counted rather than tested for, because the join to stakeholder_sources
|
|
42
|
+
# gives a record one row per source and the condition is read after grouping.
|
|
43
|
+
def substring_arm(term)
|
|
44
|
+
"SUM(CASE WHEN #{term.fields.map { |f| LikePattern.contains(f) }.join(" OR ")} THEN 1 ELSE 0 END) > 0"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def substring_binds(term)
|
|
48
|
+
[LikePattern.contains_bind(term.text)] * term.fields.size
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def index_arm(term)
|
|
52
|
+
return [] unless term.indexable?
|
|
53
|
+
|
|
54
|
+
["EXISTS (SELECT 1 FROM #{SearchRanking::TABLE} " \
|
|
55
|
+
"WHERE #{SearchRanking::TABLE} MATCH ? AND #{SearchRanking::TABLE}.rowid = intent_records.id)"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def index_binds(term)
|
|
59
|
+
term.indexable? ? [MatchExpression.for([term])] : []
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require_relative "search_index"
|
|
2
|
+
require_relative "search_snippet"
|
|
3
|
+
|
|
4
|
+
module IntentRecord
|
|
5
|
+
# The order a search answers in.
|
|
6
|
+
#
|
|
7
|
+
# Which records match is settled by the substring search, because that is what
|
|
8
|
+
# a person typing `100%` or `ACME-4` is asking for and FTS5 cannot answer it:
|
|
9
|
+
# it tokenises, so it neither sees a substring inside a word nor tells `100%`
|
|
10
|
+
# from `100 percent`. What it can do is say which of the matches the terms are
|
|
11
|
+
# actually about, and that is all it is asked here.
|
|
12
|
+
#
|
|
13
|
+
# bm25 returns a negative score, better the lower, so ascending is best first.
|
|
14
|
+
# A record the index cannot see scores nothing and sorts after every record it
|
|
15
|
+
# can, which is the right place for a hit the terms only touch as a substring.
|
|
16
|
+
module SearchRanking
|
|
17
|
+
TABLE = SearchIndex::TABLE
|
|
18
|
+
ALIAS = "ranking".freeze
|
|
19
|
+
|
|
20
|
+
# Weights per indexed column, in the order the index declares them. A term in
|
|
21
|
+
# a one-line summary is a stronger signal than the same term somewhere in a
|
|
22
|
+
# body that may run for pages, and bm25's own length normalisation does not
|
|
23
|
+
# say so on its own.
|
|
24
|
+
SUMMARY_WEIGHT = 10.0
|
|
25
|
+
BODY_WEIGHT = 1.0
|
|
26
|
+
|
|
27
|
+
# Every ranked list needs a total order, or the rows that tie come back in
|
|
28
|
+
# whatever order the plan produces. Newest first among equals, then id, which
|
|
29
|
+
# is the order `recent` already answers in.
|
|
30
|
+
TIE_BREAK = "intent_records.created_at DESC, intent_records.id DESC".freeze
|
|
31
|
+
|
|
32
|
+
NO_MATCH_SCORE = 0.0
|
|
33
|
+
|
|
34
|
+
# The relevance and the fragment for every record the terms match, computed
|
|
35
|
+
# once for the search and joined to the candidates. Once, not once per
|
|
36
|
+
# record: a correlated subquery measured eight seconds for a common word on
|
|
37
|
+
# twenty-five thousand records, this a quarter of a second. `LIMIT -1` is
|
|
38
|
+
# what keeps SQLite from flattening the subquery into the outer query, where
|
|
39
|
+
# bm25 and snippet lose their context; the plan reports it as MATERIALIZE,
|
|
40
|
+
# and SearchRankingPlanTest holds it there. The `?` is filled by the caller
|
|
41
|
+
# through sanitize_sql_array.
|
|
42
|
+
JOIN = <<~SQL.squish.freeze
|
|
43
|
+
LEFT JOIN (
|
|
44
|
+
SELECT rowid AS indexed_id,
|
|
45
|
+
bm25(#{TABLE}, #{SUMMARY_WEIGHT}, #{BODY_WEIGHT}) AS relevance,
|
|
46
|
+
#{SearchSnippet::EXPRESSION} AS #{SearchSnippet::COLUMN}
|
|
47
|
+
FROM #{TABLE} WHERE #{TABLE} MATCH ? LIMIT -1
|
|
48
|
+
) AS #{ALIAS} ON #{ALIAS}.indexed_id = intent_records.id
|
|
49
|
+
SQL
|
|
50
|
+
|
|
51
|
+
ORDER = "COALESCE(#{ALIAS}.relevance, #{NO_MATCH_SCORE}) ASC, #{TIE_BREAK}".freeze
|
|
52
|
+
|
|
53
|
+
# What a search selects when it ranks: the record, and the fragment the
|
|
54
|
+
# subquery found for it.
|
|
55
|
+
SELECTION = "intent_records.*, #{ALIAS}.#{SearchSnippet::COLUMN} AS #{SearchSnippet::COLUMN}".freeze
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require_relative "search_index"
|
|
2
|
+
|
|
3
|
+
module IntentRecord
|
|
4
|
+
# What a search result shows instead of the whole body.
|
|
5
|
+
#
|
|
6
|
+
# A result carries the whole body, which is right for an agent reading one
|
|
7
|
+
# record and wrong for a person scanning two hundred. The index can say which
|
|
8
|
+
# part of a body the terms landed in, and that is the part worth reading first.
|
|
9
|
+
#
|
|
10
|
+
# It can only say it for a record it matched, though, and a term matched as a
|
|
11
|
+
# substring inside a longer word is one it never saw. Those fall back to the
|
|
12
|
+
# opening of the body, so every result carries something rather than leaving a
|
|
13
|
+
# blank in a list where every other row has text.
|
|
14
|
+
module SearchSnippet
|
|
15
|
+
# Roughly a sentence or two either side of the match, which is enough to see
|
|
16
|
+
# why a record answered without turning the list back into a wall of prose.
|
|
17
|
+
INDEX_TOKENS = 20
|
|
18
|
+
FALLBACK_LENGTH = 200
|
|
19
|
+
ELLIPSIS = "…".freeze
|
|
20
|
+
|
|
21
|
+
# Marks are left empty deliberately. A fragment with `[` and `]` around the
|
|
22
|
+
# match reads as punctuation the writer typed, and a fragment with HTML in it
|
|
23
|
+
# is either escaped into visible markup or trusted, and neither is wanted.
|
|
24
|
+
# Column 1 is the body. Evaluated inside SearchRanking's subquery, which is
|
|
25
|
+
# the one place a search still holds the index's context.
|
|
26
|
+
EXPRESSION = "snippet(#{SearchIndex::TABLE}, 1, '', '', '#{ELLIPSIS}', #{INDEX_TOKENS})".freeze
|
|
27
|
+
|
|
28
|
+
COLUMN = "snippet".freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def text(indexed:, body:)
|
|
33
|
+
return indexed unless indexed.nil? || indexed.empty?
|
|
34
|
+
|
|
35
|
+
shortened(body.to_s)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Cut at the last space where there is one, so the reader is not shown a
|
|
39
|
+
# fragment of a word. Japanese and Chinese prose has no spaces, and neither
|
|
40
|
+
# does a long url or a hash, and a cut that found no space and kept nothing
|
|
41
|
+
# showed an ellipsis on its own.
|
|
42
|
+
def shortened(body)
|
|
43
|
+
return body if body.length <= FALLBACK_LENGTH
|
|
44
|
+
|
|
45
|
+
cut = body[0, FALLBACK_LENGTH]
|
|
46
|
+
at_word = cut.rpartition(/\s/).first.rstrip
|
|
47
|
+
"#{at_word.empty? ? cut : at_word}#{ELLIPSIS}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private_class_method :shortened
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module IntentRecord
|
|
2
|
+
# One term a person typed, read into the fields it asks about and the text to
|
|
3
|
+
# look for in them.
|
|
4
|
+
#
|
|
5
|
+
# A term may name a field, as in `summary:retry`, for the case where a common
|
|
6
|
+
# word appears everywhere and ranking it first is not enough. The hazard that
|
|
7
|
+
# comes with it is that a ticket uri carries a colon too, so only the known
|
|
8
|
+
# names count as a name and anything else before a colon stays part of the term.
|
|
9
|
+
class SearchTerm
|
|
10
|
+
RECORD_FIELDS = { "summary" => "intent_records.summary", "body" => "intent_records.body" }.freeze
|
|
11
|
+
STAKEHOLDER_FIELDS = { "uri" => "stakeholder_sources.uri", "title" => "stakeholder_sources.title" }.freeze
|
|
12
|
+
FIELDS = RECORD_FIELDS.merge(STAKEHOLDER_FIELDS).freeze
|
|
13
|
+
ALL_FIELDS = FIELDS.values.freeze
|
|
14
|
+
|
|
15
|
+
# Case-insensitive, as every other name the tool takes is.
|
|
16
|
+
NAMED = /\A(#{FIELDS.keys.join("|")}):(.*)\z/mi
|
|
17
|
+
|
|
18
|
+
# Text the index may be trusted with: letters, digits and the spaces between
|
|
19
|
+
# them, with at least one letter or digit to tokenise. Anything else the
|
|
20
|
+
# index would drop, and dropping punctuation is how `100%` comes to match a
|
|
21
|
+
# record that only ever said `100 percent`.
|
|
22
|
+
WORD = /\A(?=.*[[:alnum:]])[[:alnum:][:space:]]+\z/
|
|
23
|
+
|
|
24
|
+
attr_reader :text, :fields, :index_column
|
|
25
|
+
|
|
26
|
+
def self.parse(raw)
|
|
27
|
+
match = NAMED.match(raw)
|
|
28
|
+
return new(text: raw, fields: ALL_FIELDS, index_column: nil) if match.nil?
|
|
29
|
+
|
|
30
|
+
named(match[1].downcase, match[2])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The shell hands `uri: acme-42` over as one word with the space inside,
|
|
34
|
+
# and the space is not part of what is being looked for.
|
|
35
|
+
def self.named(name, text)
|
|
36
|
+
text = text.strip
|
|
37
|
+
raise ValidationError, "#{name}: needs something to look for" if text.empty?
|
|
38
|
+
|
|
39
|
+
new(text: text, fields: [FIELDS.fetch(name)], index_column: RECORD_FIELDS.key?(name) ? name : nil)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(text:, fields:, index_column:)
|
|
43
|
+
@text = text
|
|
44
|
+
@fields = fields
|
|
45
|
+
@index_column = index_column
|
|
46
|
+
# Named a stakeholder field, which the index does not hold. Distinguished
|
|
47
|
+
# from a plain term, where a nil column means every column of the index.
|
|
48
|
+
@off_index = index_column.nil? && fields != ALL_FIELDS
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Whether the index may answer this term at all: not for a field it does
|
|
52
|
+
# not hold, and not for text it would read differently from the person.
|
|
53
|
+
def indexable?
|
|
54
|
+
!@off_index && WORD.match?(text)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private_class_method :named
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
· <code class="id"><%= h r["intent_id"] %></code>
|
|
12
12
|
</div>
|
|
13
13
|
</header>
|
|
14
|
-
|
|
14
|
+
<%# A search result carries the fragment its terms landed in; every other
|
|
15
|
+
page shows one record, where the body is what the reader came for. %>
|
|
16
|
+
<div class="body"><%= h(r["snippet"] || r["body"]) %></div>
|
|
15
17
|
<% if r["asset_versions"].any? %>
|
|
16
18
|
<div class="links">
|
|
17
19
|
<span class="label">Commits</span>
|
data/lib/intent_record.rb
CHANGED
|
@@ -16,6 +16,12 @@ end
|
|
|
16
16
|
require "intent_record/config"
|
|
17
17
|
require "intent_record/database"
|
|
18
18
|
require "intent_record/global_id"
|
|
19
|
+
require "intent_record/match_expression"
|
|
20
|
+
require "intent_record/search_index"
|
|
21
|
+
require "intent_record/search_ranking"
|
|
22
|
+
require "intent_record/search_term"
|
|
23
|
+
require "intent_record/search_membership"
|
|
24
|
+
require "intent_record/search_snippet"
|
|
19
25
|
require "intent_record/application_record"
|
|
20
26
|
require "intent_record/models/vcs_system"
|
|
21
27
|
require "intent_record/models/asset_version"
|
|
@@ -25,5 +31,7 @@ require "intent_record/models/stakeholder_system"
|
|
|
25
31
|
require "intent_record/models/stakeholder_source"
|
|
26
32
|
require "intent_record/models/stakeholder_reference"
|
|
27
33
|
require "intent_record/models/intent_record_link"
|
|
34
|
+
require "intent_record/blame/spans"
|
|
35
|
+
require "intent_record/blame/porcelain"
|
|
28
36
|
require "intent_record/backfill"
|
|
29
37
|
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Erik T. Madsen
|
|
@@ -107,6 +107,7 @@ files:
|
|
|
107
107
|
- LICENSE.txt
|
|
108
108
|
- README.md
|
|
109
109
|
- db/migrate/20260918000001_create_initial_schema.rb
|
|
110
|
+
- db/migrate/20260920000001_create_search_index.rb
|
|
110
111
|
- exe/intent-record
|
|
111
112
|
- lib/intent_record.rb
|
|
112
113
|
- lib/intent_record/application_record.rb
|
|
@@ -118,14 +119,18 @@ files:
|
|
|
118
119
|
- lib/intent_record/backfill/inspection.rb
|
|
119
120
|
- lib/intent_record/backfill/predecessor_finder.rb
|
|
120
121
|
- lib/intent_record/backfill/reference_scanner.rb
|
|
122
|
+
- lib/intent_record/blame/porcelain.rb
|
|
123
|
+
- lib/intent_record/blame/spans.rb
|
|
121
124
|
- lib/intent_record/cli.rb
|
|
122
125
|
- lib/intent_record/cli/argv_parser.rb
|
|
126
|
+
- lib/intent_record/cli/blame_input.rb
|
|
123
127
|
- lib/intent_record/cli/dispatch.rb
|
|
124
128
|
- lib/intent_record/cli/stdin_json.rb
|
|
125
129
|
- lib/intent_record/cli/streams.rb
|
|
126
130
|
- lib/intent_record/cli/usage.rb
|
|
127
131
|
- lib/intent_record/commands/attach.rb
|
|
128
132
|
- lib/intent_record/commands/backfill.rb
|
|
133
|
+
- lib/intent_record/commands/blame.rb
|
|
129
134
|
- lib/intent_record/commands/by_source.rb
|
|
130
135
|
- lib/intent_record/commands/lookup.rb
|
|
131
136
|
- lib/intent_record/commands/recent.rb
|
|
@@ -143,6 +148,7 @@ files:
|
|
|
143
148
|
- lib/intent_record/linkers/asset_version_specs.rb
|
|
144
149
|
- lib/intent_record/linkers/intent_linker.rb
|
|
145
150
|
- lib/intent_record/linkers/stakeholder_linker.rb
|
|
151
|
+
- lib/intent_record/match_expression.rb
|
|
146
152
|
- lib/intent_record/models/asset_version.rb
|
|
147
153
|
- lib/intent_record/models/intent_record.rb
|
|
148
154
|
- lib/intent_record/models/intent_record_asset_version.rb
|
|
@@ -151,6 +157,11 @@ files:
|
|
|
151
157
|
- lib/intent_record/models/stakeholder_source.rb
|
|
152
158
|
- lib/intent_record/models/stakeholder_system.rb
|
|
153
159
|
- lib/intent_record/models/vcs_system.rb
|
|
160
|
+
- lib/intent_record/search_index.rb
|
|
161
|
+
- lib/intent_record/search_membership.rb
|
|
162
|
+
- lib/intent_record/search_ranking.rb
|
|
163
|
+
- lib/intent_record/search_snippet.rb
|
|
164
|
+
- lib/intent_record/search_term.rb
|
|
154
165
|
- lib/intent_record/seeds.rb
|
|
155
166
|
- lib/intent_record/sqlite_connection_setup.rb
|
|
156
167
|
- lib/intent_record/stakeholder_normalizer.rb
|