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,43 @@
|
|
|
1
|
+
require_relative "../models/vcs_system"
|
|
2
|
+
require_relative "../models/asset_version"
|
|
3
|
+
require_relative "../models/intent_record_asset_version"
|
|
4
|
+
require_relative "../asset_version_normalizer"
|
|
5
|
+
require_relative "asset_version_specs"
|
|
6
|
+
|
|
7
|
+
module IntentRecord
|
|
8
|
+
module Linkers
|
|
9
|
+
# Links an intent record to the asset versions a payload names. Reading the
|
|
10
|
+
# payload is AssetVersionSpecs; this writes the rows. Returns the versions.
|
|
11
|
+
class AssetVersionLinker
|
|
12
|
+
def self.call(record, input)
|
|
13
|
+
new(record).call(input)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(record)
|
|
17
|
+
@record = record
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(input)
|
|
21
|
+
AssetVersionSpecs.from(input).map { |vcs, external_id| link!(vcs, external_id) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def link!(raw_vcs, raw_id)
|
|
27
|
+
vcs = AssetVersionNormalizer.vcs_name(raw_vcs)
|
|
28
|
+
version = find_or_create_version(vcs, AssetVersionNormalizer.external_id(vcs, raw_id))
|
|
29
|
+
Models::IntentRecordAssetVersion.find_or_create_by!(intent_record: @record, asset_version: version) do |l|
|
|
30
|
+
l.created_at = Time.now.utc
|
|
31
|
+
end
|
|
32
|
+
version
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def find_or_create_version(vcs, external_id)
|
|
36
|
+
system = Models::VcsSystem.find_or_create_by!(name: vcs) { |s| s.created_at = Time.now.utc }
|
|
37
|
+
Models::AssetVersion.find_or_create_by!(vcs_system: system, external_id: external_id) do |v|
|
|
38
|
+
v.created_at = Time.now.utc
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require_relative "../input_validator"
|
|
2
|
+
|
|
3
|
+
module IntentRecord
|
|
4
|
+
module Linkers
|
|
5
|
+
# Reads the asset versions a payload asks for, as [vcs, external_id] pairs.
|
|
6
|
+
# `commits` is git shorthand; `asset_versions` names its own system. Every
|
|
7
|
+
# entry is checked before any is returned, so a payload with a bad entry
|
|
8
|
+
# later in the list never reaches the database.
|
|
9
|
+
module AssetVersionSpecs
|
|
10
|
+
DEFAULT_VCS = "git".freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def from(input)
|
|
15
|
+
commits(input).map { |c| [DEFAULT_VCS, c] } + explicit(input).map { |v| [v["vcs"], v["external_id"]] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def commits(input)
|
|
19
|
+
InputValidator.non_blank_strings!(InputValidator.array!(input, "commits"), "commit")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def explicit(input)
|
|
23
|
+
InputValidator.hashes_with!(InputValidator.array!(input, "asset_versions"),
|
|
24
|
+
"asset_versions", "vcs", "external_id")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private_class_method :commits, :explicit
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require_relative "../models/intent_record"
|
|
2
|
+
require_relative "../models/intent_record_link"
|
|
3
|
+
require_relative "../input_validator"
|
|
4
|
+
|
|
5
|
+
module IntentRecord
|
|
6
|
+
module Linkers
|
|
7
|
+
# Links an intent record to earlier intent records it builds on (`related_intent_ids`).
|
|
8
|
+
class IntentLinker
|
|
9
|
+
def self.call(record, input)
|
|
10
|
+
new(record).call(input)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(record)
|
|
14
|
+
@record = record
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(input)
|
|
18
|
+
ids = InputValidator.non_blank_strings!(InputValidator.array!(input, "related_intent_ids"), "related_intent_id")
|
|
19
|
+
ids.map { |id| link!(id) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def link!(global_id)
|
|
25
|
+
raise ValidationError, "An intent record cannot link to itself" if global_id == @record.global_id
|
|
26
|
+
|
|
27
|
+
target = Models::IntentRecord.find_by(global_id: global_id)
|
|
28
|
+
raise NotFoundError, "Related intent record not found: #{global_id}" unless target
|
|
29
|
+
|
|
30
|
+
Models::IntentRecordLink.find_or_create_by!(source: @record, target: target) { |l| l.created_at = Time.now.utc }
|
|
31
|
+
target
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require_relative "../models/stakeholder_system"
|
|
2
|
+
require_relative "../models/stakeholder_source"
|
|
3
|
+
require_relative "../models/stakeholder_reference"
|
|
4
|
+
require_relative "../input_validator"
|
|
5
|
+
require_relative "../stakeholder_normalizer"
|
|
6
|
+
|
|
7
|
+
module IntentRecord
|
|
8
|
+
module Linkers
|
|
9
|
+
# Links an intent record to stakeholder sources given as [{system, uri, title?}].
|
|
10
|
+
# A source is unique per (system, uri), so the same ticket referenced under two
|
|
11
|
+
# systems is two sources.
|
|
12
|
+
#
|
|
13
|
+
# A non-blank title replaces the stored one. A title that is missing, null or
|
|
14
|
+
# blank leaves it alone, which means a title cannot be cleared once stored.
|
|
15
|
+
# Where one payload names the same source twice, the last title in it wins.
|
|
16
|
+
class StakeholderLinker
|
|
17
|
+
def self.call(record, input)
|
|
18
|
+
new(record).call(input)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(record)
|
|
22
|
+
@record = record
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(input)
|
|
26
|
+
refs = InputValidator.hashes_with!(InputValidator.array!(input, "stakeholder_references"),
|
|
27
|
+
"stakeholder_references", "system", "uri")
|
|
28
|
+
refs.map { |ref| link!(ref) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def link!(ref)
|
|
34
|
+
source = find_or_create_source(ref)
|
|
35
|
+
Models::StakeholderReference.find_or_create_by!(intent_record: @record, stakeholder_source: source) do |r|
|
|
36
|
+
r.created_at = Time.now.utc
|
|
37
|
+
end
|
|
38
|
+
source
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def find_or_create_source(ref)
|
|
42
|
+
source = initialized_source(ref)
|
|
43
|
+
title = InputValidator.optional_string!(ref, "title")
|
|
44
|
+
source.title = title if title
|
|
45
|
+
source.created_at ||= Time.now.utc
|
|
46
|
+
source.save!
|
|
47
|
+
source
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialized_source(ref)
|
|
51
|
+
system = find_or_create_system(StakeholderNormalizer.system_name(ref["system"]))
|
|
52
|
+
Models::StakeholderSource.find_or_initialize_by(stakeholder_system: system,
|
|
53
|
+
uri: StakeholderNormalizer.uri(ref["uri"]))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def find_or_create_system(name)
|
|
57
|
+
Models::StakeholderSystem.find_or_create_by!(name: name) { |s| s.created_at = Time.now.utc }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require_relative "../application_record"
|
|
2
|
+
module IntentRecord
|
|
3
|
+
module Models
|
|
4
|
+
class AssetVersion < ApplicationRecord
|
|
5
|
+
belongs_to :vcs_system
|
|
6
|
+
has_many :intent_record_asset_versions, dependent: :destroy
|
|
7
|
+
has_many :intent_records, through: :intent_record_asset_versions
|
|
8
|
+
|
|
9
|
+
validates :external_id, presence: true, uniqueness: { scope: :vcs_system_id }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require_relative "../application_record"
|
|
2
|
+
module IntentRecord
|
|
3
|
+
module Models
|
|
4
|
+
class IntentRecord < ApplicationRecord
|
|
5
|
+
SUMMARY_MAX_LENGTH = 350
|
|
6
|
+
|
|
7
|
+
# Ordered by the link rows, so a caller reads a record's commits, references
|
|
8
|
+
# and related intents back in the order they attached them. Without an
|
|
9
|
+
# order the answer is whatever the query plan produces, which is not
|
|
10
|
+
# insertion order: SQLite answers from a covering index where it can.
|
|
11
|
+
ATTACHMENT_ORDER = -> { order(:id) }
|
|
12
|
+
|
|
13
|
+
has_many :intent_record_asset_versions, ATTACHMENT_ORDER, dependent: :destroy
|
|
14
|
+
has_many :asset_versions, through: :intent_record_asset_versions
|
|
15
|
+
has_many :stakeholder_references, ATTACHMENT_ORDER, dependent: :destroy
|
|
16
|
+
has_many :stakeholder_sources, through: :stakeholder_references
|
|
17
|
+
has_many :outgoing_links, ATTACHMENT_ORDER, class_name: "IntentRecord::Models::IntentRecordLink",
|
|
18
|
+
foreign_key: :source_intent_record_id, dependent: :destroy,
|
|
19
|
+
inverse_of: :source
|
|
20
|
+
has_many :incoming_links, ATTACHMENT_ORDER, class_name: "IntentRecord::Models::IntentRecordLink",
|
|
21
|
+
foreign_key: :target_intent_record_id, dependent: :destroy,
|
|
22
|
+
inverse_of: :target
|
|
23
|
+
|
|
24
|
+
validates :global_id, presence: true, length: { is: GlobalId::LENGTH }, uniqueness: true
|
|
25
|
+
validates :summary, presence: true, length: { maximum: SUMMARY_MAX_LENGTH }
|
|
26
|
+
validates :body, presence: true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
require_relative "../application_record"
|
|
2
|
+
module IntentRecord
|
|
3
|
+
module Models
|
|
4
|
+
class IntentRecordAssetVersion < ApplicationRecord
|
|
5
|
+
belongs_to :intent_record
|
|
6
|
+
belongs_to :asset_version
|
|
7
|
+
|
|
8
|
+
validates :intent_record_id, uniqueness: { scope: :asset_version_id }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require_relative "../application_record"
|
|
2
|
+
module IntentRecord
|
|
3
|
+
module Models
|
|
4
|
+
class IntentRecordLink < ApplicationRecord
|
|
5
|
+
belongs_to :source, class_name: "IntentRecord::Models::IntentRecord",
|
|
6
|
+
foreign_key: :source_intent_record_id, inverse_of: :outgoing_links
|
|
7
|
+
belongs_to :target, class_name: "IntentRecord::Models::IntentRecord",
|
|
8
|
+
foreign_key: :target_intent_record_id, inverse_of: :incoming_links
|
|
9
|
+
|
|
10
|
+
validates :source_intent_record_id, uniqueness: { scope: :target_intent_record_id }
|
|
11
|
+
validate :not_self_referencing
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def not_self_referencing
|
|
16
|
+
return unless source_intent_record_id && source_intent_record_id == target_intent_record_id
|
|
17
|
+
|
|
18
|
+
errors.add(:base, "An intent record cannot link to itself")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
require_relative "../application_record"
|
|
2
|
+
module IntentRecord
|
|
3
|
+
module Models
|
|
4
|
+
class StakeholderReference < ApplicationRecord
|
|
5
|
+
belongs_to :intent_record
|
|
6
|
+
belongs_to :stakeholder_source
|
|
7
|
+
|
|
8
|
+
validates :intent_record_id, uniqueness: { scope: :stakeholder_source_id }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require_relative "../application_record"
|
|
2
|
+
module IntentRecord
|
|
3
|
+
module Models
|
|
4
|
+
class StakeholderSource < ApplicationRecord
|
|
5
|
+
belongs_to :stakeholder_system
|
|
6
|
+
has_many :stakeholder_references, dependent: :destroy
|
|
7
|
+
has_many :intent_records, through: :stakeholder_references
|
|
8
|
+
|
|
9
|
+
validates :uri, presence: true, uniqueness: { scope: :stakeholder_system_id }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
require_relative "../application_record"
|
|
2
|
+
module IntentRecord
|
|
3
|
+
module Models
|
|
4
|
+
class StakeholderSystem < ApplicationRecord
|
|
5
|
+
has_many :stakeholder_sources, dependent: :restrict_with_exception
|
|
6
|
+
|
|
7
|
+
validates :name, presence: true, uniqueness: true
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require_relative "models/vcs_system"
|
|
2
|
+
require_relative "models/stakeholder_system"
|
|
3
|
+
|
|
4
|
+
module IntentRecord
|
|
5
|
+
# Well-known systems seeded on every connect so the store works out of the box.
|
|
6
|
+
# Names are lowercase, hyphenated; user-supplied names are normalised the same way.
|
|
7
|
+
module Seeds
|
|
8
|
+
VCS_SYSTEMS = %w[
|
|
9
|
+
git mercurial subversion perforce fossil bazaar cvs darcs pijul sapling plastic-scm
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
STAKEHOLDER_SYSTEMS = %w[
|
|
13
|
+
jira confluence linear
|
|
14
|
+
github-issues github-pull-requests github-discussions github-projects
|
|
15
|
+
gitlab-issues gitlab-merge-requests gitlab-epics
|
|
16
|
+
bitbucket azure-devops azure-boards
|
|
17
|
+
trello asana notion monday clickup basecamp shortcut youtrack pivotal-tracker
|
|
18
|
+
slack microsoft-teams discord email
|
|
19
|
+
google-docs google-sheets sharepoint dropbox-paper coda
|
|
20
|
+
figma miro lucidchart
|
|
21
|
+
servicenow zendesk intercom freshdesk
|
|
22
|
+
productboard aha airfocus
|
|
23
|
+
doors polarion jama codebeamer
|
|
24
|
+
adr rfc wiki web
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
def self.apply!
|
|
28
|
+
seed!(Models::VcsSystem, VCS_SYSTEMS)
|
|
29
|
+
seed!(Models::StakeholderSystem, STAKEHOLDER_SYSTEMS)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.seed!(model, names)
|
|
33
|
+
existing = model.where(name: names).pluck(:name)
|
|
34
|
+
now = Time.now.utc
|
|
35
|
+
(names - existing).each { |name| model.create!(name: name, created_at: now) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "active_record"
|
|
2
|
+
require "active_record/connection_adapters/sqlite3_adapter"
|
|
3
|
+
|
|
4
|
+
module IntentRecord
|
|
5
|
+
# Applied to every pooled SQLite connection: pragmas, and a Unicode-aware ULOWER() so
|
|
6
|
+
# case-insensitive search works for non-ASCII letters (SQLite's LOWER folds ASCII only).
|
|
7
|
+
module SqliteConnectionSetup
|
|
8
|
+
BUSY_TIMEOUT_MS = 5_000
|
|
9
|
+
PRAGMAS = ["PRAGMA foreign_keys = ON", "PRAGMA journal_mode = WAL",
|
|
10
|
+
"PRAGMA busy_timeout = #{BUSY_TIMEOUT_MS}"].freeze
|
|
11
|
+
|
|
12
|
+
def configure_connection
|
|
13
|
+
super
|
|
14
|
+
PRAGMAS.each { |sql| @raw_connection.execute(sql) }
|
|
15
|
+
@raw_connection.create_function("ulower", 1) do |fn, value|
|
|
16
|
+
fn.result = value&.to_s&.downcase
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(SqliteConnectionSetup)
|
|
22
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module IntentRecord
|
|
4
|
+
# Normalises stakeholder system names and source URIs so the same ticket is one source.
|
|
5
|
+
module StakeholderNormalizer
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def system_name(raw)
|
|
9
|
+
raw.strip.downcase.gsub(/[\s_]+/, "-")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# A value with neither scheme nor host is an opaque identifier such as a bare
|
|
13
|
+
# ticket key, and case is significant there. Anything else is a URI, whose
|
|
14
|
+
# scheme URI.parse has already downcased for us; only the host is left to do.
|
|
15
|
+
def uri(raw)
|
|
16
|
+
value = raw.strip
|
|
17
|
+
parsed = URI.parse(value)
|
|
18
|
+
return value.chomp("/") unless parsed.scheme || parsed.host
|
|
19
|
+
|
|
20
|
+
downcased_host(parsed).to_s.chomp("/")
|
|
21
|
+
rescue URI::InvalidURIError
|
|
22
|
+
value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def downcased_host(parsed)
|
|
26
|
+
return parsed unless parsed.host
|
|
27
|
+
|
|
28
|
+
normalized = parsed.dup
|
|
29
|
+
normalized.host = parsed.host.downcase
|
|
30
|
+
normalized
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "sinatra/base"
|
|
2
|
+
require "erb"
|
|
3
|
+
require_relative "query_router"
|
|
4
|
+
require_relative "routes/home"
|
|
5
|
+
require_relative "routes/commits"
|
|
6
|
+
require_relative "routes/intents"
|
|
7
|
+
require_relative "routes/search"
|
|
8
|
+
require_relative "routes/sources"
|
|
9
|
+
|
|
10
|
+
module IntentRecord
|
|
11
|
+
module Web
|
|
12
|
+
# Read-only local GUI. Expects the database to be connected before requests arrive.
|
|
13
|
+
class App < Sinatra::Base
|
|
14
|
+
set :host_authorization, { permitted_hosts: [] }
|
|
15
|
+
set :views, File.expand_path("views", __dir__)
|
|
16
|
+
set :public_folder, File.expand_path("public", __dir__)
|
|
17
|
+
set :static, true
|
|
18
|
+
set :show_exceptions, false
|
|
19
|
+
set :dump_errors, false
|
|
20
|
+
|
|
21
|
+
helpers do
|
|
22
|
+
def h(text)
|
|
23
|
+
ERB::Util.html_escape(text)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def intent_cards(records)
|
|
27
|
+
erb :_intent_cards, locals: { records: records }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
error NotFoundError do
|
|
32
|
+
status 404
|
|
33
|
+
erb :error, locals: { title: "Not found", message: env["sinatra.error"].message }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
error ValidationError do
|
|
37
|
+
status 400
|
|
38
|
+
erb :error, locals: { title: "Cannot answer that", message: env["sinatra.error"].message }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
error StandardError do
|
|
42
|
+
status 500
|
|
43
|
+
env["rack.errors"].puts "#{env["sinatra.error"].class}: #{env["sinatra.error"].message}"
|
|
44
|
+
erb :error, locals: { title: "Something went wrong", message: env["sinatra.error"].message }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
register Routes::Home
|
|
48
|
+
register Routes::Commits
|
|
49
|
+
register Routes::Intents
|
|
50
|
+
register Routes::Search
|
|
51
|
+
register Routes::Sources
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require_relative "../config"
|
|
2
|
+
require_relative "../database"
|
|
3
|
+
require_relative "app"
|
|
4
|
+
require_relative "../cli/streams"
|
|
5
|
+
|
|
6
|
+
module IntentRecord
|
|
7
|
+
module Web
|
|
8
|
+
# Boots the GUI on localhost only. Connects (and migrates) the database first.
|
|
9
|
+
module Boot
|
|
10
|
+
BIND = "127.0.0.1".freeze
|
|
11
|
+
DEFAULT_PORT = 4791
|
|
12
|
+
PORT_RANGE = (1..65_535)
|
|
13
|
+
|
|
14
|
+
Options = Struct.new(:port, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Out of range, the bind fails with a resolution error that says nothing about
|
|
19
|
+
# ports, so reject it here rather than let the socket layer explain it badly.
|
|
20
|
+
# Leftovers are rejected here too, because serve forwards its argv straight
|
|
21
|
+
# to Boot and so never passes through Dispatch#finish!.
|
|
22
|
+
def parse_argv(argv)
|
|
23
|
+
parser = CLI::ArgvParser.new(argv)
|
|
24
|
+
port = parser.take_integer_flag("--port", DEFAULT_PORT)
|
|
25
|
+
parser.reject_leftovers!
|
|
26
|
+
raise ValidationError, "--port must be between #{PORT_RANGE.first} and #{PORT_RANGE.last}, got #{port}" \
|
|
27
|
+
unless PORT_RANGE.cover?(port)
|
|
28
|
+
|
|
29
|
+
Options.new(port: port)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def run!(config:, argv:, streams: CLI::Streams.default)
|
|
33
|
+
port = parse_argv(argv).port
|
|
34
|
+
config.load!
|
|
35
|
+
Database.connect!(config.db_path)
|
|
36
|
+
streams.stderr.puts "intent-record GUI on http://#{BIND}:#{port} (db: #{config.db_path})"
|
|
37
|
+
serve!(port)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Only the bind itself can refuse a port, and Database.connect! has already
|
|
41
|
+
# translated its own permission failures, so these two reach here from App only.
|
|
42
|
+
def serve!(port)
|
|
43
|
+
App.run!(bind: BIND, port: port)
|
|
44
|
+
rescue Errno::EADDRINUSE
|
|
45
|
+
raise Error, "Port #{port} is already in use; pass --port to choose another"
|
|
46
|
+
rescue Errno::EACCES
|
|
47
|
+
raise Error, "Port #{port} needs elevated privileges; pass --port to choose another"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #f7f9fb; --surface: #fff; --tint: #E8F4F8; --tint-strong: #C7E9F1;
|
|
3
|
+
--ink: #2a3b4a; --ink-soft: #506373; --ink-mute: #6b7c8c;
|
|
4
|
+
--border: rgba(42, 59, 74, 0.10); --border-soft: rgba(42, 59, 74, 0.05);
|
|
5
|
+
--shadow: 0 1px 3px rgba(42, 59, 74, 0.06); --radius: 12px; --radius-sm: 8px;
|
|
6
|
+
}
|
|
7
|
+
@media (prefers-color-scheme: dark) {
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #14191e; --surface: #1d242b; --tint: #22303a; --tint-strong: #2d4351;
|
|
10
|
+
--ink: #e4ebf1; --ink-soft: #b3c0cc; --ink-mute: #8797a6;
|
|
11
|
+
--border: rgba(228, 235, 241, 0.14); --border-soft: rgba(228, 235, 241, 0.07);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
* { box-sizing: border-box; }
|
|
15
|
+
html, body { margin: 0; padding: 0; }
|
|
16
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--ink); font-size: 14px; line-height: 1.5; }
|
|
17
|
+
a { color: inherit; text-decoration: none; }
|
|
18
|
+
a:hover { text-decoration: underline; }
|
|
19
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.92em; }
|
|
20
|
+
h1 { font-size: 22px; font-weight: 600; margin: 0 0 4px; }
|
|
21
|
+
h2 { font-size: 16px; font-weight: 600; margin: 0 0 4px; }
|
|
22
|
+
h3 { font-size: 12px; font-weight: 600; margin: 24px 0 8px; color: var(--ink-soft); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
23
|
+
.topnav { background: var(--surface); border-bottom: 1px solid var(--border-soft); padding: 10px 24px; display: flex; align-items: center; gap: 18px; box-shadow: var(--shadow); }
|
|
24
|
+
.topnav .brand { font-weight: 700; padding-right: 14px; border-right: 1px solid var(--border-soft); }
|
|
25
|
+
.topnav .lookup { display: flex; gap: 8px; flex: 1; max-width: 640px; }
|
|
26
|
+
.topnav .lookup input { flex: 1; }
|
|
27
|
+
.topnav .who { margin-left: auto; color: var(--ink-mute); font-size: 12px; }
|
|
28
|
+
input[type="text"] { font: inherit; color: var(--ink); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 7px 10px; outline: none; }
|
|
29
|
+
input[type="text"]:focus { border-color: var(--tint-strong); box-shadow: 0 0 0 3px var(--tint); }
|
|
30
|
+
button { font: inherit; font-weight: 500; color: var(--ink); background: var(--tint); border: 1px solid var(--tint-strong); border-radius: var(--radius-sm); padding: 7px 14px; cursor: pointer; }
|
|
31
|
+
button:hover { background: var(--tint-strong); }
|
|
32
|
+
.page { max-width: 960px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
33
|
+
.page > header { margin-bottom: 20px; }
|
|
34
|
+
.subtitle { color: var(--ink-mute); font-size: 13px; margin: 0; }
|
|
35
|
+
.card { background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); border: 1px solid var(--border-soft); padding: 18px; margin-bottom: 16px; }
|
|
36
|
+
.card.muted { background: transparent; border: 1px dashed var(--border); box-shadow: none; }
|
|
37
|
+
.card pre { background: var(--tint); padding: 10px 12px; border-radius: var(--radius-sm); overflow-x: auto; }
|
|
38
|
+
.intent .meta { color: var(--ink-mute); font-size: 12px; margin-bottom: 10px; }
|
|
39
|
+
.intent .body { white-space: pre-wrap; word-wrap: break-word; margin-bottom: 12px; }
|
|
40
|
+
.links { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 6px; }
|
|
41
|
+
.links .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink-mute); margin-right: 4px; }
|
|
42
|
+
.chip { background: var(--tint); border: 1px solid var(--border-soft); border-radius: 999px; padding: 3px 10px; font-size: 12px; }
|
|
43
|
+
.chip.ext::after { content: " ↗"; color: var(--ink-mute); }
|
|
44
|
+
.empty { color: var(--ink-mute); font-style: italic; }
|
|
45
|
+
.card.tint { background: var(--tint); }
|
|
46
|
+
.commit-list { list-style: none; margin: 0; padding: 0; }
|
|
47
|
+
.commit-list li { margin: 4px 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
|
|
48
|
+
.commit-list .chip { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin-right: 6px; }
|
|
49
|
+
.footer { max-width: 960px; margin: 0 auto; padding: 0 16px 32px; color: var(--ink-mute); font-size: 12px; }
|
|
50
|
+
.footer code { background: var(--tint); padding: 2px 6px; border-radius: 4px; }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module IntentRecord
|
|
4
|
+
module Web
|
|
5
|
+
# Decides where a free-text query from the home page search box should go.
|
|
6
|
+
module QueryRouter
|
|
7
|
+
HEX = /\A[0-9a-f]{4,}\z/i
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def path_for(raw)
|
|
12
|
+
query = raw.to_s.strip
|
|
13
|
+
return "/" if query.empty?
|
|
14
|
+
return "/commits/#{query}" if HEX.match?(query)
|
|
15
|
+
return "/sources?uri=#{URI.encode_www_form_component(query)}" if url?(query)
|
|
16
|
+
|
|
17
|
+
"/search?q=#{URI.encode_www_form_component(query)}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def url?(query)
|
|
21
|
+
query.match?(%r{\A[a-z][a-z0-9+.-]*://}i)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require_relative "../../commands/lookup"
|
|
2
|
+
|
|
3
|
+
module IntentRecord
|
|
4
|
+
module Web
|
|
5
|
+
module Routes
|
|
6
|
+
module Commits
|
|
7
|
+
def self.registered(app)
|
|
8
|
+
app.get "/commits/:external_id" do
|
|
9
|
+
result = Commands::Lookup.new(external_id: params[:external_id], vcs: params[:vcs]).call
|
|
10
|
+
erb :commit, locals: { version: result["asset_version"], intents: result["intents"] }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require_relative "../../commands/recent"
|
|
2
|
+
require_relative "../query_router"
|
|
3
|
+
|
|
4
|
+
module IntentRecord
|
|
5
|
+
module Web
|
|
6
|
+
module Routes
|
|
7
|
+
module Home
|
|
8
|
+
def self.registered(app)
|
|
9
|
+
app.get "/" do
|
|
10
|
+
recent = Commands::Recent.new(limit: 20).call
|
|
11
|
+
erb :home, locals: { recent: recent["intents"] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
app.get "/go" do
|
|
15
|
+
redirect QueryRouter.path_for(params[:q])
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require_relative "../../commands/show"
|
|
2
|
+
|
|
3
|
+
module IntentRecord
|
|
4
|
+
module Web
|
|
5
|
+
module Routes
|
|
6
|
+
module Intents
|
|
7
|
+
def self.registered(app)
|
|
8
|
+
app.get "/intents/:id" do
|
|
9
|
+
erb :intent, locals: { intent: Commands::Show.new(intent_id: params[:id]).call }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require_relative "../../commands/search"
|
|
2
|
+
|
|
3
|
+
module IntentRecord
|
|
4
|
+
module Web
|
|
5
|
+
module Routes
|
|
6
|
+
module Search
|
|
7
|
+
def self.registered(app)
|
|
8
|
+
app.get "/search" do
|
|
9
|
+
terms = params[:q].to_s.split
|
|
10
|
+
results = terms.empty? ? [] : Commands::Search.new(terms: terms, match: "all").call["intents"]
|
|
11
|
+
erb :search, locals: { query: params[:q].to_s, intents: results }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|