intent-record 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +137 -0
- data/db/migrate/20260918000001_create_initial_schema.rb +68 -0
- data/exe/intent-record +5 -0
- data/lib/intent_record/application_record.rb +8 -0
- data/lib/intent_record/asset_version_normalizer.rb +43 -0
- data/lib/intent_record/asset_version_resolver.rb +76 -0
- data/lib/intent_record/cli/argv_parser.rb +78 -0
- data/lib/intent_record/cli/dispatch.rb +118 -0
- data/lib/intent_record/cli/stdin_json.rb +28 -0
- data/lib/intent_record/cli/streams.rb +9 -0
- data/lib/intent_record/cli/usage.rb +29 -0
- data/lib/intent_record/cli.rb +69 -0
- data/lib/intent_record/commands/attach.rb +39 -0
- data/lib/intent_record/commands/by_source.rb +52 -0
- data/lib/intent_record/commands/lookup.rb +22 -0
- data/lib/intent_record/commands/recent.rb +21 -0
- data/lib/intent_record/commands/record.rb +52 -0
- data/lib/intent_record/commands/search.rb +50 -0
- data/lib/intent_record/commands/show.rb +19 -0
- data/lib/intent_record/commands/systems.rb +16 -0
- data/lib/intent_record/config.rb +51 -0
- data/lib/intent_record/database.rb +74 -0
- data/lib/intent_record/formatter.rb +59 -0
- data/lib/intent_record/global_id.rb +22 -0
- data/lib/intent_record/input_validator.rb +69 -0
- data/lib/intent_record/like_pattern.rb +34 -0
- data/lib/intent_record/linkers/asset_version_linker.rb +43 -0
- data/lib/intent_record/linkers/asset_version_specs.rb +30 -0
- data/lib/intent_record/linkers/intent_linker.rb +35 -0
- data/lib/intent_record/linkers/stakeholder_linker.rb +61 -0
- data/lib/intent_record/models/asset_version.rb +12 -0
- data/lib/intent_record/models/intent_record.rb +29 -0
- data/lib/intent_record/models/intent_record_asset_version.rb +11 -0
- data/lib/intent_record/models/intent_record_link.rb +22 -0
- data/lib/intent_record/models/stakeholder_reference.rb +11 -0
- data/lib/intent_record/models/stakeholder_source.rb +12 -0
- data/lib/intent_record/models/stakeholder_system.rb +10 -0
- data/lib/intent_record/models/vcs_system.rb +10 -0
- data/lib/intent_record/seeds.rb +38 -0
- data/lib/intent_record/sqlite_connection_setup.rb +22 -0
- data/lib/intent_record/stakeholder_normalizer.rb +33 -0
- data/lib/intent_record/version.rb +3 -0
- data/lib/intent_record/web/app.rb +54 -0
- data/lib/intent_record/web/boot.rb +51 -0
- data/lib/intent_record/web/public/styles.css +50 -0
- data/lib/intent_record/web/query_router.rb +25 -0
- data/lib/intent_record/web/routes/commits.rb +16 -0
- data/lib/intent_record/web/routes/home.rb +21 -0
- data/lib/intent_record/web/routes/intents.rb +15 -0
- data/lib/intent_record/web/routes/search.rb +17 -0
- data/lib/intent_record/web/routes/sources.rb +19 -0
- data/lib/intent_record/web/views/_intent_cards.erb +43 -0
- data/lib/intent_record/web/views/commit.erb +12 -0
- data/lib/intent_record/web/views/error.erb +7 -0
- data/lib/intent_record/web/views/home.erb +14 -0
- data/lib/intent_record/web/views/intent.erb +5 -0
- data/lib/intent_record/web/views/layout.erb +28 -0
- data/lib/intent_record/web/views/search.erb +5 -0
- data/lib/intent_record/web/views/sources.erb +16 -0
- data/lib/intent_record.rb +28 -0
- metadata +194 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 871b5d799ddc9505ac23dbbd3bf468d243ae3c48a4c04574e9fd0fc596a579ae
|
|
4
|
+
data.tar.gz: f6370c0f8003d35971d1b717c1ca7d24632b38b9568496c22e7605838397f9d6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7d0ef27f3db2d4bf6616ea46f225462e778dad93c6433738d90f03e7c68fac5c2ee196681f1445a15673915d5ac449f0737d12974d01af97cdb618a4530f2759
|
|
7
|
+
data.tar.gz: 5dc2d7077843e89318b6d8d5ef7238663eb531e8ab01633a63f7a40c96f025577751e2a757f27fa64e8e1c31a81eef4ca9c8829d818fdd0f4bffbb1faa61867a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes, one section per released version. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## Unreleased
|
|
8
|
+
|
|
9
|
+
## 1.0.0 - 2026-09-18
|
|
10
|
+
|
|
11
|
+
First release.
|
|
12
|
+
|
|
13
|
+
- `record`, `attach`, `show`, `lookup`, `search`, `by-source`, `recent`, `systems` and `serve`. JSON in on stdin, JSON out on stdout, exit 1 with `{"error": ...}` on failure. Argv a command does not recognise is refused rather than ignored.
|
|
14
|
+
- SQLite store at `~/.intent-record/`, created and migrated on first use. `--config-dir` points one command at another store, `INTENT_RECORD_CONFIG_DIR` points a whole shell at one.
|
|
15
|
+
- An intent links to any number of commits, to stakeholder sources such as Jira, Confluence and Linear, and to earlier intents it builds on. Commits can be attached after the fact, which covers rebases and squashes.
|
|
16
|
+
- Prefix lookup from four characters for hash-based systems. An id recorded in two systems is reported as ambiguous rather than answered with one of them.
|
|
17
|
+
- Every list comes back in an order the query names, in both the CLI and the GUI.
|
|
18
|
+
- Local web GUI on 127.0.0.1 with commit, intent, search and stakeholder source pages.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Erik T. Madsen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# intent-record
|
|
2
|
+
|
|
3
|
+
[](https://github.com/beatmadsen/intent-record/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Local records storage for the intent behind individual code changes.
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
- "What was this commit for?" (`lookup <hash>`)
|
|
10
|
+
- "What did we build for this Jira ticket, and why that way?" (`by-source <url-or-key>`)
|
|
11
|
+
- "Have we touched retry logic before, and what were we thinking?" (`search retry`)
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gem install intent-record
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
On first run a config directory is created at `~/.intent-record/` with the database at `~/.intent-record/intent-record.db`. Edit `~/.intent-record/config.yml` to move the database.
|
|
20
|
+
|
|
21
|
+
To use a different store for one command, pass `--config-dir <dir>` before or after the command name. `INTENT_RECORD_CONFIG_DIR` does the same for a whole shell, except that a misspelled variable name is ignored without a word while a misspelled flag is refused.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
intent-record --config-dir /tmp/scratch recent
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# After committing a change, record why it was made
|
|
31
|
+
echo '{
|
|
32
|
+
"summary": "Retry flaky fetches with backoff",
|
|
33
|
+
"body": "CI failed three times this week on transient DNS errors. Retrying 3x with exponential backoff instead of failing the build.",
|
|
34
|
+
"author": "claude",
|
|
35
|
+
"commits": ["8f3a1c2d9e4b5f60718293a4b5c6d7e8f9a0b1c2"],
|
|
36
|
+
"stakeholder_references": [
|
|
37
|
+
{"system": "jira", "uri": "https://acme.atlassian.net/browse/ACME-42", "title": "Flaky fetch in CI"}
|
|
38
|
+
]
|
|
39
|
+
}' | intent-record record
|
|
40
|
+
# => {"intent_id":"EUt4WMY","summary":"Retry flaky fetches with backoff", ... full record ...}
|
|
41
|
+
|
|
42
|
+
# Later, someone stares at that commit in git blame
|
|
43
|
+
intent-record lookup 8f3a1c2
|
|
44
|
+
|
|
45
|
+
# Or comes at it from the ticket
|
|
46
|
+
intent-record by-source ACME-42 --contains
|
|
47
|
+
|
|
48
|
+
# Or wants to read it in a browser
|
|
49
|
+
intent-record serve
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
JSON in on stdin where input is needed, JSON out on stdout, exit code 0 on success and 1 with `{"error": "..."}` otherwise.
|
|
55
|
+
|
|
56
|
+
| Command | Description |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `record` | Record the intent behind a change (JSON via stdin) |
|
|
59
|
+
| `attach <intent_id>` | Link more commits, stakeholder references or related intents to an existing record (JSON via stdin) |
|
|
60
|
+
| `show <intent_id>` | Full intent record with commits, stakeholder links and related intents |
|
|
61
|
+
| `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` |
|
|
62
|
+
| `search <terms...> [--match all]` | Case-insensitive substring search over summary, body, and linked stakeholder URIs and titles, so a ticket key finds its intents |
|
|
63
|
+
| `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
|
+
| `recent [--limit N]` | Newest intents first |
|
|
65
|
+
| `systems` | Known VCS and stakeholder system names |
|
|
66
|
+
| `serve [--port N]` | Start the web GUI on 127.0.0.1 (default port 4791) |
|
|
67
|
+
|
|
68
|
+
### Input shape for `record` and `attach`
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"summary": "one line, at most 350 characters (record only, required)",
|
|
73
|
+
"body": "the reasoning, as long as it needs to be (record only, required)",
|
|
74
|
+
"author": "optional: agent or person name",
|
|
75
|
+
"commits": ["git hashes; shorthand for asset_versions with vcs git"],
|
|
76
|
+
"asset_versions": [{"vcs": "perforce", "external_id": "12345"}],
|
|
77
|
+
"stakeholder_references": [{"system": "jira", "uri": "https://...", "title": "optional"}],
|
|
78
|
+
"related_intent_ids": ["intent ids this change builds on"]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Options take either `--name value` or `--name=value`. Unknown options and stray arguments are rejected rather than ignored.
|
|
83
|
+
|
|
84
|
+
`lookup` without `--vcs` searches every system and prefers an exact id match over a prefix match. Git commits must be full SHA-1 or SHA-256 hashes; they are stored lowercase. Prefix lookup applies to hash-based systems only (git, mercurial, fossil, sapling, pijul, darcs), so a Perforce changelist `1234` never matches `12345`. URIs are normalised (lowercase scheme and host, no trailing slash) and system names are lowercased and hyphenated, so `Jira` and `jira`, or a ticket URL with and without a trailing slash, are one source. A later non-blank `title` for a source replaces the stored one.
|
|
85
|
+
|
|
86
|
+
`record` and `attach` both return the full intent record. A record's commits, stakeholder references and related intents come back in the order they were attached.
|
|
87
|
+
|
|
88
|
+
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
|
+
|
|
90
|
+
## Data model
|
|
91
|
+
|
|
92
|
+
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.
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
vcs_systems (name)
|
|
96
|
+
asset_versions (vcs_system, external_id) a commit, changelist, revision
|
|
97
|
+
intent_records (global_id, summary, body, author)
|
|
98
|
+
intent_record_asset_versions intent <-> asset version, many to many
|
|
99
|
+
stakeholder_systems (name)
|
|
100
|
+
stakeholder_sources (stakeholder_system, uri, title) a ticket, page, thread
|
|
101
|
+
stakeholder_references intent <-> source, many to many
|
|
102
|
+
intent_record_links (source, target) intent builds on intent
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Intent ids are random 7-character base58 strings. Database ids never appear in output.
|
|
106
|
+
|
|
107
|
+
Related-intent links may form cycles; the store records what it is told and leaves the meaning to the reader. Bodies have no size cap.
|
|
108
|
+
|
|
109
|
+
## Web GUI
|
|
110
|
+
|
|
111
|
+
`intent-record serve` binds to localhost only. The home page has one box: paste a commit hash, a ticket URL, or a few words, and it takes you to the right page. Commit pages list every intent for that hash; source pages list every intent and every commit for a ticket.
|
|
112
|
+
|
|
113
|
+
## Agent integration
|
|
114
|
+
|
|
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.
|
|
116
|
+
|
|
117
|
+
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
|
+
|
|
119
|
+
```bash
|
|
120
|
+
git clone https://github.com/beatmadsen/claude-skills.git ~/claude-skills
|
|
121
|
+
ln -s ~/claude-skills/skills/intent-record ~/.claude/skills/intent-record
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Or copy `skills/intent-record` into a single project's `.claude/skills/` and commit it, so everyone working in that repository records the same way.
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
bin/setup
|
|
130
|
+
bundle exec rake # tests, then the same tests with unordered queries reversed, then rubocop
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow and release steps, and [CHANGELOG.md](CHANGELOG.md) for what changed in each version.
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
[MIT](LICENSE.txt)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
class CreateInitialSchema < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :vcs_systems do |t|
|
|
4
|
+
t.string :name, null: false
|
|
5
|
+
t.datetime :created_at, null: false
|
|
6
|
+
end
|
|
7
|
+
add_index :vcs_systems, :name, unique: true
|
|
8
|
+
|
|
9
|
+
create_table :asset_versions do |t|
|
|
10
|
+
t.references :vcs_system, null: false, foreign_key: true
|
|
11
|
+
t.string :external_id, null: false
|
|
12
|
+
t.datetime :created_at, null: false
|
|
13
|
+
end
|
|
14
|
+
add_index :asset_versions, %i[vcs_system_id external_id], unique: true
|
|
15
|
+
add_index :asset_versions, :external_id
|
|
16
|
+
|
|
17
|
+
create_table :intent_records do |t|
|
|
18
|
+
t.string :global_id, limit: 7, null: false
|
|
19
|
+
t.string :summary, null: false
|
|
20
|
+
t.text :body, null: false
|
|
21
|
+
t.string :author
|
|
22
|
+
t.datetime :created_at, null: false
|
|
23
|
+
end
|
|
24
|
+
add_index :intent_records, :global_id, unique: true
|
|
25
|
+
add_index :intent_records, :created_at
|
|
26
|
+
|
|
27
|
+
create_table :intent_record_asset_versions do |t|
|
|
28
|
+
t.references :intent_record, null: false, foreign_key: true
|
|
29
|
+
t.references :asset_version, null: false, foreign_key: true
|
|
30
|
+
t.datetime :created_at, null: false
|
|
31
|
+
end
|
|
32
|
+
add_index :intent_record_asset_versions, %i[intent_record_id asset_version_id],
|
|
33
|
+
unique: true, name: "idx_intent_asset_unique"
|
|
34
|
+
|
|
35
|
+
create_table :stakeholder_systems do |t|
|
|
36
|
+
t.string :name, null: false
|
|
37
|
+
t.datetime :created_at, null: false
|
|
38
|
+
end
|
|
39
|
+
add_index :stakeholder_systems, :name, unique: true
|
|
40
|
+
|
|
41
|
+
create_table :stakeholder_sources do |t|
|
|
42
|
+
t.references :stakeholder_system, null: false, foreign_key: true
|
|
43
|
+
t.text :uri, null: false
|
|
44
|
+
t.string :title
|
|
45
|
+
t.datetime :created_at, null: false
|
|
46
|
+
end
|
|
47
|
+
add_index :stakeholder_sources, %i[stakeholder_system_id uri], unique: true
|
|
48
|
+
|
|
49
|
+
create_table :stakeholder_references do |t|
|
|
50
|
+
t.references :intent_record, null: false, foreign_key: true
|
|
51
|
+
t.references :stakeholder_source, null: false, foreign_key: true
|
|
52
|
+
t.datetime :created_at, null: false
|
|
53
|
+
end
|
|
54
|
+
add_index :stakeholder_references, %i[intent_record_id stakeholder_source_id],
|
|
55
|
+
unique: true, name: "idx_stakeholder_ref_unique"
|
|
56
|
+
|
|
57
|
+
create_table :intent_record_links do |t|
|
|
58
|
+
t.bigint :source_intent_record_id, null: false
|
|
59
|
+
t.bigint :target_intent_record_id, null: false
|
|
60
|
+
t.datetime :created_at, null: false
|
|
61
|
+
end
|
|
62
|
+
add_index :intent_record_links, %i[source_intent_record_id target_intent_record_id],
|
|
63
|
+
unique: true, name: "idx_intent_link_unique"
|
|
64
|
+
add_index :intent_record_links, :target_intent_record_id
|
|
65
|
+
add_foreign_key :intent_record_links, :intent_records, column: :source_intent_record_id
|
|
66
|
+
add_foreign_key :intent_record_links, :intent_records, column: :target_intent_record_id
|
|
67
|
+
end
|
|
68
|
+
end
|
data/exe/intent-record
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module IntentRecord
|
|
2
|
+
# Normalises external ids per VCS. Hash-based systems store their ids lowercased and
|
|
3
|
+
# support prefix lookup. Only git is checked for shape, and must be a full SHA-1 or
|
|
4
|
+
# SHA-256; the other hash-based systems take an id as given, so a prefix among those
|
|
5
|
+
# can collide, and the resolver reports that at lookup time rather than preventing it
|
|
6
|
+
# here.
|
|
7
|
+
module AssetVersionNormalizer
|
|
8
|
+
HASH_BASED = %w[git mercurial fossil sapling pijul darcs].freeze
|
|
9
|
+
GIT_HASH = /\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def vcs_name(vcs)
|
|
14
|
+
vcs.strip.downcase
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def hash_based?(vcs)
|
|
18
|
+
HASH_BASED.include?(vcs)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def external_id(vcs, raw)
|
|
22
|
+
id = raw.strip
|
|
23
|
+
id = id.downcase if hash_based?(vcs)
|
|
24
|
+
if vcs == "git" && !GIT_HASH.match?(id)
|
|
25
|
+
raise ValidationError,
|
|
26
|
+
"git commit must be 40 or 64 hex characters, got #{raw.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
id
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Hash ids are canonically lowercase here, and that should not depend on a
|
|
33
|
+
# collation default we do not set. Nothing driving the CLI can tell this apart
|
|
34
|
+
# from leaving the id alone, because the resolver tries the id as written too
|
|
35
|
+
# and sqlite's LIKE folds ASCII case; the tests that decide it ask this
|
|
36
|
+
# directly, or go through the resolver below the prefix minimum, where there
|
|
37
|
+
# is no second chance from the prefix query.
|
|
38
|
+
def lookup_id(vcs, raw)
|
|
39
|
+
id = raw.strip
|
|
40
|
+
vcs.nil? || hash_based?(vcs) ? id.downcase : id
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require_relative "models/asset_version"
|
|
2
|
+
require_relative "models/vcs_system"
|
|
3
|
+
require_relative "asset_version_normalizer"
|
|
4
|
+
require_relative "like_pattern"
|
|
5
|
+
|
|
6
|
+
module IntentRecord
|
|
7
|
+
# Resolves an external id to one asset version. Exact match first; for hash-based VCSs a
|
|
8
|
+
# unique prefix of at least MIN_PREFIX_LENGTH characters also resolves.
|
|
9
|
+
class AssetVersionResolver
|
|
10
|
+
MIN_PREFIX_LENGTH = 4
|
|
11
|
+
|
|
12
|
+
def initialize(external_id:, vcs: nil)
|
|
13
|
+
@vcs = vcs && AssetVersionNormalizer.vcs_name(vcs)
|
|
14
|
+
@raw = external_id.strip
|
|
15
|
+
@external_id = AssetVersionNormalizer.lookup_id(@vcs, external_id)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
exact_match || resolve_prefix
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# With no vcs given the id could belong to a hash-based system, where case is
|
|
25
|
+
# insignificant, or to a case-sensitive one, where it is not, so both spellings
|
|
26
|
+
# are candidates. When a vcs is given the normaliser has already picked the one
|
|
27
|
+
# that system uses.
|
|
28
|
+
def exact_candidates
|
|
29
|
+
@vcs ? [@external_id] : [@raw, @external_id].uniq
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Ids are unique per version control system, not across them, so one spelling
|
|
33
|
+
# can name two different things. Answering with either of them would be
|
|
34
|
+
# SQLite's choice, and it would hide everything recorded against the other,
|
|
35
|
+
# so say so instead and let the caller name the system.
|
|
36
|
+
def exact_match
|
|
37
|
+
exact_candidates.each do |id|
|
|
38
|
+
found = scope.where(external_id: id).to_a
|
|
39
|
+
next if found.empty?
|
|
40
|
+
raise ValidationError, ambiguous(id, found.size) if found.size > 1
|
|
41
|
+
|
|
42
|
+
return found.first
|
|
43
|
+
end
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def ambiguous(id, count)
|
|
48
|
+
"Ambiguous id #{id}: matches #{count} asset versions in different " \
|
|
49
|
+
"version control systems. Name one with --vcs."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve_prefix
|
|
53
|
+
candidates = prefix_candidates
|
|
54
|
+
raise NotFoundError, "Asset version not found: #{@external_id}" if candidates.empty?
|
|
55
|
+
if candidates.size > 1
|
|
56
|
+
raise ValidationError,
|
|
57
|
+
"Ambiguous prefix #{@external_id}: matches #{candidates.size} asset versions"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
candidates.first
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def scope
|
|
64
|
+
base = Models::AssetVersion.includes(:vcs_system).joins(:vcs_system)
|
|
65
|
+
@vcs ? base.where(vcs_systems: { name: @vcs }) : base
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def prefix_candidates
|
|
69
|
+
return [] if @external_id.length < MIN_PREFIX_LENGTH
|
|
70
|
+
|
|
71
|
+
scope.where(vcs_systems: { name: AssetVersionNormalizer::HASH_BASED })
|
|
72
|
+
.where(LikePattern.prefix("asset_versions.external_id"), LikePattern.prefix_bind(@external_id))
|
|
73
|
+
.to_a
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module IntentRecord
|
|
2
|
+
class CLI
|
|
3
|
+
# Owns the arguments it is given and consumes them: each `take_*` removes what
|
|
4
|
+
# it matched, so `reject_leftovers!` can then judge whatever nobody claimed.
|
|
5
|
+
# The caller's own array is never touched. Options may be given as
|
|
6
|
+
# `--name value` or `--name=value`.
|
|
7
|
+
class ArgvParser
|
|
8
|
+
def initialize(argv)
|
|
9
|
+
@argv = argv.dup
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# What is left unconsumed, for a caller that forwards its arguments on
|
|
13
|
+
# rather than judging them here.
|
|
14
|
+
def remaining
|
|
15
|
+
@argv.dup
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def take_flag(name)
|
|
19
|
+
indexes = @argv.each_index.select { |i| @argv[i] == name || @argv[i].start_with?("#{name}=") }
|
|
20
|
+
raise ValidationError, "#{name} given more than once" if indexes.size > 1
|
|
21
|
+
return nil if indexes.empty?
|
|
22
|
+
|
|
23
|
+
extract_flag(indexes.first, name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def take_integer_flag(name, default)
|
|
27
|
+
raw = take_flag(name)
|
|
28
|
+
return default if raw.nil?
|
|
29
|
+
raise ValidationError, "#{name} requires an integer, got #{raw.inspect}" unless /\A\d+\z/.match?(raw)
|
|
30
|
+
|
|
31
|
+
raw.to_i
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Judged by its stripped form, so returned stripped too. Handing back the
|
|
35
|
+
# padded value let a command look an id up verbatim and miss a record that
|
|
36
|
+
# exists.
|
|
37
|
+
def take_required_positional(label)
|
|
38
|
+
value = @argv.shift
|
|
39
|
+
raise ValidationError, "#{label} is required" if value.nil? || value.strip.empty?
|
|
40
|
+
|
|
41
|
+
value.strip
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Everything that is not an option. The options are kept, so
|
|
45
|
+
# reject_leftovers! still judges them.
|
|
46
|
+
def take_positionals
|
|
47
|
+
options, positionals = @argv.partition { |token| token.start_with?("--") }
|
|
48
|
+
@argv.replace(options)
|
|
49
|
+
positionals
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def take_switch?(name)
|
|
53
|
+
!@argv.delete(name).nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reject_leftovers!
|
|
57
|
+
return if @argv.empty?
|
|
58
|
+
|
|
59
|
+
raise ValidationError, "Unexpected argument(s): #{@argv.join(" ")}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# A flag whose value is missing is deliberately left in place rather than
|
|
65
|
+
# consumed, so that it reaches reject_leftovers! as an error instead of
|
|
66
|
+
# passing for a value nobody gave.
|
|
67
|
+
def extract_flag(idx, name)
|
|
68
|
+
token = @argv[idx]
|
|
69
|
+
return @argv.delete_at(idx).delete_prefix("#{name}=") if token.include?("=")
|
|
70
|
+
return nil unless @argv[idx + 1] && !@argv[idx + 1].start_with?("--")
|
|
71
|
+
|
|
72
|
+
value = @argv[idx + 1]
|
|
73
|
+
@argv.slice!(idx, 2)
|
|
74
|
+
value
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "argv_parser"
|
|
3
|
+
require_relative "stdin_json"
|
|
4
|
+
require_relative "usage"
|
|
5
|
+
require_relative "../commands/record"
|
|
6
|
+
require_relative "../commands/show"
|
|
7
|
+
require_relative "../commands/lookup"
|
|
8
|
+
require_relative "../commands/search"
|
|
9
|
+
require_relative "../commands/by_source"
|
|
10
|
+
require_relative "../commands/recent"
|
|
11
|
+
require_relative "../commands/attach"
|
|
12
|
+
require_relative "../commands/systems"
|
|
13
|
+
|
|
14
|
+
module IntentRecord
|
|
15
|
+
class CLI
|
|
16
|
+
# Maps a command name to the command object that handles it and prints its JSON result.
|
|
17
|
+
# Each run_* parses argv fully, then `finish!` rejects anything left over before running.
|
|
18
|
+
class Dispatch
|
|
19
|
+
HANDLER_PREFIX = "run_".freeze
|
|
20
|
+
|
|
21
|
+
# The commands there are, taken from the handlers that implement them, so
|
|
22
|
+
# the list cannot fall behind what the dispatch actually answers to.
|
|
23
|
+
def self.commands
|
|
24
|
+
handlers = private_instance_methods(false).grep(/\A#{HANDLER_PREFIX}/)
|
|
25
|
+
handlers.map { |name| name.to_s.delete_prefix(HANDLER_PREFIX).tr("_", "-") }.sort
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(argv, streams:, config:)
|
|
29
|
+
@parser = ArgvParser.new(argv)
|
|
30
|
+
@streams = streams
|
|
31
|
+
@config = config
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(command)
|
|
35
|
+
handler = "#{HANDLER_PREFIX}#{command.to_s.tr("-", "_")}"
|
|
36
|
+
raise ValidationError, unknown(command) unless respond_to?(handler, true)
|
|
37
|
+
|
|
38
|
+
emit(send(handler))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# An option in the command's place is almost always a misspelled one, and
|
|
44
|
+
# calling it an unknown command sends the reader looking for a command they
|
|
45
|
+
# never meant to type.
|
|
46
|
+
def unknown(command)
|
|
47
|
+
return "Unknown option: #{command}" if command.to_s.start_with?("--")
|
|
48
|
+
|
|
49
|
+
"Unknown command: #{command}. Known commands: #{self.class.commands.join(", ")}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def emit(result)
|
|
53
|
+
@streams.stdout.puts JSON.generate(result)
|
|
54
|
+
0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def finish!
|
|
58
|
+
@parser.reject_leftovers!
|
|
59
|
+
yield
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stdin_json
|
|
63
|
+
StdinJson.read(@streams.stdin)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def positional(label)
|
|
67
|
+
@parser.take_required_positional(label)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def run_record
|
|
71
|
+
finish! { Commands::Record.new.call(stdin_json) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def run_show
|
|
75
|
+
command = Commands::Show.new(intent_id: positional("intent_id"))
|
|
76
|
+
finish! { command.call }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def run_lookup
|
|
80
|
+
vcs = @parser.take_flag("--vcs")
|
|
81
|
+
command = Commands::Lookup.new(external_id: positional("external_id"), vcs: vcs)
|
|
82
|
+
finish! { command.call }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def run_search
|
|
86
|
+
match = @parser.take_flag("--match") || "any"
|
|
87
|
+
terms = @parser.take_positionals
|
|
88
|
+
finish! { Commands::Search.new(terms: terms, match: match).call }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def run_by_source
|
|
92
|
+
contains = @parser.take_switch?("--contains")
|
|
93
|
+
command = Commands::BySource.new(uri: positional("uri"), contains: contains)
|
|
94
|
+
finish! { command.call }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def run_recent
|
|
98
|
+
limit = @parser.take_integer_flag("--limit", Commands::Recent::DEFAULT_LIMIT)
|
|
99
|
+
finish! { Commands::Recent.new(limit: limit).call }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def run_attach
|
|
103
|
+
command = Commands::Attach.new(intent_id: positional("intent_id"))
|
|
104
|
+
finish! { command.call(stdin_json) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def run_systems
|
|
108
|
+
finish! { Commands::Systems.new.call }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def run_serve
|
|
112
|
+
require_relative "../web/boot"
|
|
113
|
+
Web::Boot.run!(config: @config, argv: @parser.remaining, streams: @streams)
|
|
114
|
+
{}
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module IntentRecord
|
|
4
|
+
class CLI
|
|
5
|
+
# The JSON object an agent sends on stdin. Nothing here concerns argv, which
|
|
6
|
+
# is why it is not in ArgvParser.
|
|
7
|
+
module StdinJson
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def read(stdin)
|
|
11
|
+
raw = stdin.read.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
12
|
+
raise ValidationError, "Input is not valid UTF-8" unless raw.valid_encoding?
|
|
13
|
+
raise ValidationError, "Empty input" if raw.strip.empty?
|
|
14
|
+
|
|
15
|
+
parse_object(raw)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parse_object(raw)
|
|
19
|
+
parsed = JSON.parse(raw)
|
|
20
|
+
raise ValidationError, "Input must be a JSON object" unless parsed.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
parsed
|
|
23
|
+
rescue JSON::ParserError => e
|
|
24
|
+
raise ValidationError, "Invalid JSON input: #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module IntentRecord
|
|
2
|
+
class CLI
|
|
3
|
+
USAGE = <<~USAGE.freeze
|
|
4
|
+
Usage: intent-record <command> [options]
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
record Record the intent behind a change (JSON via stdin)
|
|
8
|
+
attach <intent_id> Link more commits / stakeholder refs / related intents (JSON via stdin)
|
|
9
|
+
show <intent_id> Full intent record
|
|
10
|
+
lookup <commit> [--vcs name] Intents recorded against a commit (full hash or unique prefix)
|
|
11
|
+
search <terms...> [--match all] Substring search over summary and body
|
|
12
|
+
by-source <uri> [--contains] Intents linked to a stakeholder uri (Jira ticket, Confluence page, ...)
|
|
13
|
+
recent [--limit N] Newest intents first
|
|
14
|
+
systems Known vcs and stakeholder system names
|
|
15
|
+
serve [--port N] Start the local web GUI
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--config-dir <dir> Use the store in this directory instead of ~/.intent-record
|
|
19
|
+
--help, -h Show this help
|
|
20
|
+
--version Show version
|
|
21
|
+
|
|
22
|
+
Input shape for record/attach (all keys optional except summary and body on record):
|
|
23
|
+
{"summary": "...", "body": "...", "author": "...",
|
|
24
|
+
"commits": ["<git hash>"], "asset_versions": [{"vcs": "perforce", "external_id": "123"}],
|
|
25
|
+
"stakeholder_references": [{"system": "jira", "uri": "https://...", "title": "..."}],
|
|
26
|
+
"related_intent_ids": ["<intent_id>"]}
|
|
27
|
+
USAGE
|
|
28
|
+
end
|
|
29
|
+
end
|