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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "cli/streams"
|
|
3
|
+
require_relative "cli/argv_parser"
|
|
4
|
+
require_relative "cli/dispatch"
|
|
5
|
+
|
|
6
|
+
module IntentRecord
|
|
7
|
+
# Entry point for the `intent-record` executable. JSON in, JSON out, exit 0 on success.
|
|
8
|
+
class CLI
|
|
9
|
+
CONFIG_DIR_FLAG = "--config-dir".freeze
|
|
10
|
+
|
|
11
|
+
def self.run(argv)
|
|
12
|
+
exit new(argv, config: Config.default, streams: Streams.default).run
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(argv, config:, streams:)
|
|
16
|
+
@argv = argv.dup
|
|
17
|
+
@config = config
|
|
18
|
+
@streams = streams
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
take_config_dir!
|
|
23
|
+
dispatch(@argv.shift)
|
|
24
|
+
rescue Error => e
|
|
25
|
+
emit_error(e.message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# Lifted before the command is taken, so it may be written on either side of
|
|
31
|
+
# it. A misspelling is left in argv, where it becomes an unknown command or an
|
|
32
|
+
# unexpected argument: an option this CLI does not recognise is an error,
|
|
33
|
+
# which an environment variable nobody set can never be.
|
|
34
|
+
def take_config_dir!
|
|
35
|
+
parser = ArgvParser.new(@argv)
|
|
36
|
+
dir = parser.take_flag(CONFIG_DIR_FLAG)
|
|
37
|
+
@argv = parser.remaining
|
|
38
|
+
@config = Config.new(config_dir: dir) if dir
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def dispatch(command)
|
|
42
|
+
return usage if command.nil? || (@argv + [command]).intersect?(%w[--help -h])
|
|
43
|
+
return version if %w[--version version].include?(command)
|
|
44
|
+
|
|
45
|
+
connect!
|
|
46
|
+
Dispatch.new(@argv, streams: @streams, config: @config).call(command)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def connect!
|
|
50
|
+
@config.load!
|
|
51
|
+
Database.connect!(@config.db_path)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def usage
|
|
55
|
+
@streams.stderr.puts USAGE
|
|
56
|
+
0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def version
|
|
60
|
+
@streams.stdout.puts VERSION
|
|
61
|
+
0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def emit_error(message)
|
|
65
|
+
@streams.stdout.puts JSON.generate({ "error" => message })
|
|
66
|
+
1
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require_relative "../formatter"
|
|
2
|
+
require_relative "../models/intent_record"
|
|
3
|
+
require_relative "../linkers/asset_version_linker"
|
|
4
|
+
require_relative "../linkers/stakeholder_linker"
|
|
5
|
+
require_relative "../linkers/intent_linker"
|
|
6
|
+
|
|
7
|
+
module IntentRecord
|
|
8
|
+
module Commands
|
|
9
|
+
# Links more commits, stakeholder sources or related intents to an existing intent record.
|
|
10
|
+
class Attach
|
|
11
|
+
LINK_KEYS = %w[commits asset_versions stakeholder_references related_intent_ids].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(intent_id:)
|
|
14
|
+
@intent_id = intent_id
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(input)
|
|
18
|
+
record = Models::IntentRecord.find_by(global_id: @intent_id)
|
|
19
|
+
raise NotFoundError, "Intent record not found: #{@intent_id}" unless record
|
|
20
|
+
raise ValidationError, "Nothing to attach: provide one of #{LINK_KEYS.join(", ")}" if nothing_to_attach?(input)
|
|
21
|
+
|
|
22
|
+
ActiveRecord::Base.transaction { link_all(record, input) }
|
|
23
|
+
Formatter.full(record.reload)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def nothing_to_attach?(input)
|
|
29
|
+
LINK_KEYS.none? { |key| !input[key].nil? && input[key] != [] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def link_all(record, input)
|
|
33
|
+
Linkers::AssetVersionLinker.call(record, input)
|
|
34
|
+
Linkers::StakeholderLinker.call(record, input)
|
|
35
|
+
Linkers::IntentLinker.call(record, input)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require_relative "../formatter"
|
|
2
|
+
require_relative "../like_pattern"
|
|
3
|
+
require_relative "../stakeholder_normalizer"
|
|
4
|
+
require_relative "../models/stakeholder_source"
|
|
5
|
+
|
|
6
|
+
module IntentRecord
|
|
7
|
+
module Commands
|
|
8
|
+
# Answers "what did we build for this ticket, and why": every intent linked to a
|
|
9
|
+
# stakeholder source (by exact uri or substring) plus the distinct commits across them.
|
|
10
|
+
class BySource
|
|
11
|
+
def initialize(uri:, contains: false)
|
|
12
|
+
@uri = uri.strip
|
|
13
|
+
@contains = contains
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
sources = matching_sources.includes(:stakeholder_system).to_a
|
|
18
|
+
intents = intents_for(sources)
|
|
19
|
+
{
|
|
20
|
+
"sources" => sources.map { |s| Formatter.source(s) },
|
|
21
|
+
"asset_versions" => intents.flat_map { |i| i["asset_versions"] }.uniq,
|
|
22
|
+
"intents" => intents
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def matching_sources
|
|
29
|
+
return sources.where(uri: StakeholderNormalizer.uri(@uri)) unless @contains
|
|
30
|
+
|
|
31
|
+
sources.where(LikePattern.contains("uri"), LikePattern.contains_bind(@uri))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# In the order the sources were first recorded. An unordered query is
|
|
35
|
+
# answered in whatever order the plan produces, and that is a list a person
|
|
36
|
+
# reads.
|
|
37
|
+
def sources
|
|
38
|
+
Models::StakeholderSource.order(:id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def intents_for(sources)
|
|
42
|
+
Formatter.preloaded(matching_intents(sources)).map { |r| Formatter.full(r) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def matching_intents(sources)
|
|
46
|
+
Models::IntentRecord.joins(:stakeholder_references)
|
|
47
|
+
.where(stakeholder_references: { stakeholder_source_id: sources.map(&:id) })
|
|
48
|
+
.distinct.order(:created_at)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require_relative "../asset_version_resolver"
|
|
2
|
+
require_relative "../formatter"
|
|
3
|
+
|
|
4
|
+
module IntentRecord
|
|
5
|
+
module Commands
|
|
6
|
+
# Everything recorded against one commit (or other asset version).
|
|
7
|
+
class Lookup
|
|
8
|
+
def initialize(external_id:, vcs: nil)
|
|
9
|
+
@external_id = external_id
|
|
10
|
+
@vcs = vcs
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
version = AssetVersionResolver.new(external_id: @external_id, vcs: @vcs).call
|
|
15
|
+
{
|
|
16
|
+
"asset_version" => Formatter.asset_version(version),
|
|
17
|
+
"intents" => Formatter.preloaded(version.intent_records.order(:created_at)).map { |r| Formatter.full(r) }
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require_relative "../formatter"
|
|
2
|
+
require_relative "../models/intent_record"
|
|
3
|
+
|
|
4
|
+
module IntentRecord
|
|
5
|
+
module Commands
|
|
6
|
+
class Recent
|
|
7
|
+
DEFAULT_LIMIT = 20
|
|
8
|
+
|
|
9
|
+
def initialize(limit: DEFAULT_LIMIT)
|
|
10
|
+
@limit = limit
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
raise ValidationError, "--limit must be at least 1" if @limit < 1
|
|
15
|
+
|
|
16
|
+
records = Formatter.preloaded(Models::IntentRecord.order(created_at: :desc, id: :desc).limit(@limit))
|
|
17
|
+
{ "intents" => records.map { |r| Formatter.full(r) } }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require_relative "../input_validator"
|
|
2
|
+
require_relative "../formatter"
|
|
3
|
+
require_relative "../global_id"
|
|
4
|
+
require_relative "../models/intent_record"
|
|
5
|
+
require_relative "../linkers/asset_version_linker"
|
|
6
|
+
require_relative "../linkers/stakeholder_linker"
|
|
7
|
+
require_relative "../linkers/intent_linker"
|
|
8
|
+
|
|
9
|
+
module IntentRecord
|
|
10
|
+
module Commands
|
|
11
|
+
# Creates an intent record and links it to commits, stakeholder sources and related intents.
|
|
12
|
+
class Record
|
|
13
|
+
def call(input)
|
|
14
|
+
attributes = validated(input)
|
|
15
|
+
|
|
16
|
+
ActiveRecord::Base.transaction do
|
|
17
|
+
record = create!(attributes)
|
|
18
|
+
link_all(record, input)
|
|
19
|
+
Formatter.full(record)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Nothing here reaches the database, so bad input is refused before the
|
|
26
|
+
# store is asked anything, and these rules can be exercised without one.
|
|
27
|
+
def validated(input)
|
|
28
|
+
{ summary: summary(input),
|
|
29
|
+
body: InputValidator.required_string!(input, "body"),
|
|
30
|
+
author: InputValidator.optional_string!(input, "author"),
|
|
31
|
+
created_at: Time.now.utc }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Minting the id asks the store whether it is taken, which is why it waits
|
|
35
|
+
# until the input has been accepted.
|
|
36
|
+
def create!(attributes)
|
|
37
|
+
Models::IntentRecord.create!(attributes.merge(global_id: GlobalId.unique_for(Models::IntentRecord)))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def summary(input)
|
|
41
|
+
InputValidator.required_string!(input, "summary", max_length: Models::IntentRecord::SUMMARY_MAX_LENGTH)
|
|
42
|
+
.gsub(/\s+/, " ")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def link_all(record, input)
|
|
46
|
+
Linkers::AssetVersionLinker.call(record, input)
|
|
47
|
+
Linkers::StakeholderLinker.call(record, input)
|
|
48
|
+
Linkers::IntentLinker.call(record, input)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require_relative "../formatter"
|
|
2
|
+
require_relative "../like_pattern"
|
|
3
|
+
require_relative "../models/intent_record"
|
|
4
|
+
|
|
5
|
+
module IntentRecord
|
|
6
|
+
module Commands
|
|
7
|
+
# Case-insensitive substring search over summary, body, and linked stakeholder uris and titles,
|
|
8
|
+
# so a ticket key such as ACME-42 finds the intents built for it.
|
|
9
|
+
class Search
|
|
10
|
+
LIMIT = 200
|
|
11
|
+
FIELDS = %w[intent_records.summary intent_records.body stakeholder_sources.uri stakeholder_sources.title].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(terms:, match: "any")
|
|
14
|
+
@terms = terms.map(&:strip).reject(&:empty?)
|
|
15
|
+
@match = match
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
raise ValidationError, "At least one search term is required" if @terms.empty?
|
|
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) } }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
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
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def having_sql
|
|
37
|
+
joiner = @match == "all" ? " AND " : " OR "
|
|
38
|
+
@terms.map { "SUM(CASE WHEN #{term_clause} THEN 1 ELSE 0 END) > 0" }.join(joiner)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def term_clause
|
|
42
|
+
FIELDS.map { |f| LikePattern.contains(f) }.join(" OR ")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def having_binds
|
|
46
|
+
@terms.flat_map { |t| [LikePattern.contains_bind(t)] * FIELDS.size }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require_relative "../formatter"
|
|
2
|
+
require_relative "../models/intent_record"
|
|
3
|
+
|
|
4
|
+
module IntentRecord
|
|
5
|
+
module Commands
|
|
6
|
+
class Show
|
|
7
|
+
def initialize(intent_id:)
|
|
8
|
+
@intent_id = intent_id
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
record = Formatter.preloaded(Models::IntentRecord).find_by(global_id: @intent_id)
|
|
13
|
+
raise NotFoundError, "Intent record not found: #{@intent_id}" unless record
|
|
14
|
+
|
|
15
|
+
Formatter.full(record)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require_relative "../models/vcs_system"
|
|
2
|
+
require_relative "../models/stakeholder_system"
|
|
3
|
+
|
|
4
|
+
module IntentRecord
|
|
5
|
+
module Commands
|
|
6
|
+
# Vocabulary discovery: which vcs and stakeholder system names are known.
|
|
7
|
+
class Systems
|
|
8
|
+
def call
|
|
9
|
+
{
|
|
10
|
+
"vcs_systems" => Models::VcsSystem.order(:name).pluck(:name),
|
|
11
|
+
"stakeholder_systems" => Models::StakeholderSystem.order(:name).pluck(:name)
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module IntentRecord
|
|
5
|
+
# Locates the config directory and the SQLite database path.
|
|
6
|
+
# Bootstraps ~/.intent-record/config.yml on first use.
|
|
7
|
+
class Config
|
|
8
|
+
DEFAULT_CONFIG_DIR = File.expand_path("~/.intent-record")
|
|
9
|
+
DB_FILENAME = "intent-record.db".freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :config_dir, :db_path
|
|
12
|
+
|
|
13
|
+
# The one caller that means "wherever this person keeps their records" says so
|
|
14
|
+
# by name. Everywhere else has to name a directory, so no caller can arrive at
|
|
15
|
+
# somebody's own store by losing an argument.
|
|
16
|
+
def self.default
|
|
17
|
+
new(config_dir: ENV.fetch("INTENT_RECORD_CONFIG_DIR", DEFAULT_CONFIG_DIR))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(config_dir:)
|
|
21
|
+
raise ArgumentError, "config_dir is required; use Config.default for this user's own store" if config_dir.nil?
|
|
22
|
+
|
|
23
|
+
@config_dir = config_dir
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def load!
|
|
27
|
+
bootstrap! unless File.exist?(config_file_path)
|
|
28
|
+
read_config!
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def bootstrap!
|
|
35
|
+
FileUtils.mkdir_p(@config_dir)
|
|
36
|
+
File.write(config_file_path, YAML.dump("db_path" => File.join(@config_dir, DB_FILENAME)))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def config_file_path
|
|
40
|
+
File.join(@config_dir, "config.yml")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def read_config!
|
|
44
|
+
data = YAML.load_file(config_file_path)
|
|
45
|
+
valid = data.is_a?(Hash) && !data["db_path"].to_s.strip.empty?
|
|
46
|
+
raise ConfigError, "db_path is not configured in #{config_file_path}" unless valid
|
|
47
|
+
|
|
48
|
+
@db_path = data["db_path"]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require "active_record"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
require_relative "seeds"
|
|
5
|
+
require_relative "sqlite_connection_setup"
|
|
6
|
+
|
|
7
|
+
module IntentRecord
|
|
8
|
+
module Database
|
|
9
|
+
MIGRATIONS_PATH = File.expand_path("../../db/migrate", __dir__)
|
|
10
|
+
|
|
11
|
+
# mkdir_p raises an Errno when the parent cannot be created, but once the
|
|
12
|
+
# directory exists sqlite reports an unwritable file or directory through
|
|
13
|
+
# ActiveRecord instead, so both families have to become the same DatabaseError.
|
|
14
|
+
UNWRITABLE_CAUSES = [SQLite3::CantOpenException, SQLite3::ReadOnlyException].freeze
|
|
15
|
+
|
|
16
|
+
def self.connect!(db_path)
|
|
17
|
+
translating_unwritable(db_path) do
|
|
18
|
+
FileUtils.mkdir_p(File.dirname(db_path))
|
|
19
|
+
establish!(db_path)
|
|
20
|
+
while_setting_up(db_path) { migrate_and_seed! }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.translating_unwritable(db_path)
|
|
25
|
+
yield
|
|
26
|
+
rescue Errno::EACCES, Errno::EPERM, Errno::EROFS => e
|
|
27
|
+
raise DatabaseError, unwritable(db_path, e)
|
|
28
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
29
|
+
raise unless UNWRITABLE_CAUSES.any? { |klass| e.cause.is_a?(klass) }
|
|
30
|
+
|
|
31
|
+
raise DatabaseError, unwritable(db_path, e.cause)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.establish!(db_path)
|
|
35
|
+
ActiveRecord::Base.logger = nil
|
|
36
|
+
# The pragma sets sqlite's own busy timeout, which is not the same thing as
|
|
37
|
+
# the adapter's: without :timeout ActiveRecord installs no busy handler and
|
|
38
|
+
# a write that meets a concurrent one fails rather than waiting.
|
|
39
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: db_path,
|
|
40
|
+
timeout: SqliteConnectionSetup::BUSY_TIMEOUT_MS)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.migrate_and_seed!
|
|
44
|
+
run_migrations!
|
|
45
|
+
Seeds.apply!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.unwritable(db_path, error)
|
|
49
|
+
"Database path is not writable: #{db_path} (#{error.message})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.disconnect!
|
|
53
|
+
ActiveRecord::Base.remove_connection
|
|
54
|
+
rescue StandardError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Migrating and seeding both read and then write, so two processes reaching a
|
|
59
|
+
# new store together each find the tables missing and each create them. Their
|
|
60
|
+
# individual statements succeed, so sqlite's locking has nothing to object to
|
|
61
|
+
# until one of them hits "table already exists". Serialise the whole setup.
|
|
62
|
+
def self.while_setting_up(db_path)
|
|
63
|
+
File.open("#{db_path}.setup", File::RDWR | File::CREAT) do |lock|
|
|
64
|
+
lock.flock(File::LOCK_EX)
|
|
65
|
+
yield
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.run_migrations!
|
|
70
|
+
ActiveRecord::Migration.verbose = false
|
|
71
|
+
ActiveRecord::MigrationContext.new(MIGRATIONS_PATH).migrate
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module IntentRecord
|
|
2
|
+
# Turns models into the JSON shapes the CLI emits. Internal ids never leak.
|
|
3
|
+
#
|
|
4
|
+
# `full` reads four associations per record and does not load them itself, so a
|
|
5
|
+
# caller formatting more than one record hands it a scope through `preloaded`.
|
|
6
|
+
# Loading them here instead would query once per record however the caller
|
|
7
|
+
# asked, which is most of the work in a list of two hundred.
|
|
8
|
+
module Formatter
|
|
9
|
+
PRELOADS = [{ asset_versions: :vcs_system },
|
|
10
|
+
{ stakeholder_sources: :stakeholder_system },
|
|
11
|
+
{ outgoing_links: :target },
|
|
12
|
+
{ incoming_links: :source }].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def preloaded(scope)
|
|
17
|
+
scope.preload(*PRELOADS)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def summary(record)
|
|
21
|
+
{
|
|
22
|
+
"intent_id" => record.global_id,
|
|
23
|
+
"summary" => record.summary,
|
|
24
|
+
"author" => record.author,
|
|
25
|
+
"created_at" => record.created_at.utc.iso8601
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def full(record)
|
|
30
|
+
summary(record).merge("body" => record.body).merge(external_links(record)).merge(intent_links(record))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def external_links(record)
|
|
34
|
+
{
|
|
35
|
+
"asset_versions" => record.asset_versions.map { |v| asset_version(v) },
|
|
36
|
+
"stakeholder_references" => record.stakeholder_sources.map { |s| source(s) }
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def intent_links(record)
|
|
41
|
+
{
|
|
42
|
+
"related_intents" => record.outgoing_links.map { |l| summary(l.target) },
|
|
43
|
+
"related_by_intents" => record.incoming_links.map { |l| summary(l.source) }
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def asset_version(version)
|
|
48
|
+
{ "vcs" => version.vcs_system.name, "external_id" => version.external_id }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def source(source)
|
|
52
|
+
{ "system" => source.stakeholder_system.name, "uri" => source.uri, "title" => source.title }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Steps of `full`. Only `full`, `preloaded`, `asset_version` and `source` are
|
|
56
|
+
# asked for from outside.
|
|
57
|
+
private_class_method :summary, :external_links, :intent_links
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module IntentRecord
|
|
2
|
+
module GlobalId
|
|
3
|
+
BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".freeze
|
|
4
|
+
PATTERN = /\A[1-9A-HJ-NP-Za-km-z]{7}\z/
|
|
5
|
+
LENGTH = 7
|
|
6
|
+
|
|
7
|
+
def self.generate
|
|
8
|
+
Array.new(LENGTH) { BASE58_ALPHABET[rand(58)] }.join
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.valid?(id)
|
|
12
|
+
id.is_a?(String) && PATTERN.match?(id)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.unique_for(model)
|
|
16
|
+
loop do
|
|
17
|
+
candidate = generate
|
|
18
|
+
return candidate unless model.exists?(global_id: candidate)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module IntentRecord
|
|
2
|
+
# Shape checks for the JSON an agent sends on stdin. Raises ValidationError with a field-named message.
|
|
3
|
+
module InputValidator
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def required_string!(input, key, max_length: nil)
|
|
7
|
+
value = non_blank_string!(input, key)
|
|
8
|
+
max_length ? within_length!(value, key, max_length) : value
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def optional_string!(input, key)
|
|
12
|
+
value = input[key]
|
|
13
|
+
return nil if value.nil?
|
|
14
|
+
raise ValidationError, "#{key} must be a string" unless value.is_a?(String)
|
|
15
|
+
|
|
16
|
+
value = value.strip
|
|
17
|
+
value.empty? ? nil : value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def array!(input, key)
|
|
21
|
+
value = input[key]
|
|
22
|
+
return [] if value.nil?
|
|
23
|
+
raise ValidationError, "#{key} must be an array" unless value.is_a?(Array)
|
|
24
|
+
|
|
25
|
+
value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Judged by its stripped form, so returned stripped too. Returning the padded
|
|
29
|
+
# value would hand a caller something this module has already treated as the
|
|
30
|
+
# same as a shorter string, and a caller that looks an id up verbatim would
|
|
31
|
+
# then miss a record that exists.
|
|
32
|
+
def non_blank_strings!(values, label)
|
|
33
|
+
values.map do |v|
|
|
34
|
+
raise ValidationError, "#{label} must be a non-empty string" unless v.is_a?(String) && !v.strip.empty?
|
|
35
|
+
|
|
36
|
+
v.strip
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def hashes_with!(values, label, *keys)
|
|
41
|
+
values.each do |v|
|
|
42
|
+
raise ValidationError, "#{label} entries must be objects" unless v.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
keys.each { |k| required_string!(v, k) }
|
|
45
|
+
end
|
|
46
|
+
values
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def non_blank_string!(input, key)
|
|
50
|
+
value = input[key]
|
|
51
|
+
raise ValidationError, "#{key} is required" if value.nil?
|
|
52
|
+
raise ValidationError, "#{key} must be a string" unless value.is_a?(String)
|
|
53
|
+
|
|
54
|
+
value = value.strip
|
|
55
|
+
raise ValidationError, "#{key} cannot be blank" if value.empty?
|
|
56
|
+
|
|
57
|
+
value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def within_length!(value, key, max_length)
|
|
61
|
+
raise ValidationError, "#{key} must be #{max_length} characters or fewer" if value.length > max_length
|
|
62
|
+
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Steps of required_string!, not part of what this module offers callers.
|
|
67
|
+
private_class_method :non_blank_string!, :within_length!
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require "active_record"
|
|
2
|
+
|
|
3
|
+
module IntentRecord
|
|
4
|
+
# Builds case-insensitive, literal-safe LIKE fragments. SQLite only honours the escape
|
|
5
|
+
# character when an ESCAPE clause is present, so every LIKE goes through here.
|
|
6
|
+
module LikePattern
|
|
7
|
+
ESCAPE = "ESCAPE '\\'".freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def contains(column)
|
|
12
|
+
"ULOWER(#{column}) LIKE ? #{ESCAPE}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def contains_bind(term)
|
|
16
|
+
"%#{escape(term.downcase)}%"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def prefix(column)
|
|
20
|
+
"#{column} LIKE ? #{ESCAPE}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def prefix_bind(term)
|
|
24
|
+
"#{escape(term)}%"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def escape(term)
|
|
28
|
+
ActiveRecord::Base.sanitize_sql_like(term)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A step of the two binds, not part of what this module offers callers.
|
|
32
|
+
private_class_method :escape
|
|
33
|
+
end
|
|
34
|
+
end
|