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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +18 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +137 -0
  5. data/db/migrate/20260918000001_create_initial_schema.rb +68 -0
  6. data/exe/intent-record +5 -0
  7. data/lib/intent_record/application_record.rb +8 -0
  8. data/lib/intent_record/asset_version_normalizer.rb +43 -0
  9. data/lib/intent_record/asset_version_resolver.rb +76 -0
  10. data/lib/intent_record/cli/argv_parser.rb +78 -0
  11. data/lib/intent_record/cli/dispatch.rb +118 -0
  12. data/lib/intent_record/cli/stdin_json.rb +28 -0
  13. data/lib/intent_record/cli/streams.rb +9 -0
  14. data/lib/intent_record/cli/usage.rb +29 -0
  15. data/lib/intent_record/cli.rb +69 -0
  16. data/lib/intent_record/commands/attach.rb +39 -0
  17. data/lib/intent_record/commands/by_source.rb +52 -0
  18. data/lib/intent_record/commands/lookup.rb +22 -0
  19. data/lib/intent_record/commands/recent.rb +21 -0
  20. data/lib/intent_record/commands/record.rb +52 -0
  21. data/lib/intent_record/commands/search.rb +50 -0
  22. data/lib/intent_record/commands/show.rb +19 -0
  23. data/lib/intent_record/commands/systems.rb +16 -0
  24. data/lib/intent_record/config.rb +51 -0
  25. data/lib/intent_record/database.rb +74 -0
  26. data/lib/intent_record/formatter.rb +59 -0
  27. data/lib/intent_record/global_id.rb +22 -0
  28. data/lib/intent_record/input_validator.rb +69 -0
  29. data/lib/intent_record/like_pattern.rb +34 -0
  30. data/lib/intent_record/linkers/asset_version_linker.rb +43 -0
  31. data/lib/intent_record/linkers/asset_version_specs.rb +30 -0
  32. data/lib/intent_record/linkers/intent_linker.rb +35 -0
  33. data/lib/intent_record/linkers/stakeholder_linker.rb +61 -0
  34. data/lib/intent_record/models/asset_version.rb +12 -0
  35. data/lib/intent_record/models/intent_record.rb +29 -0
  36. data/lib/intent_record/models/intent_record_asset_version.rb +11 -0
  37. data/lib/intent_record/models/intent_record_link.rb +22 -0
  38. data/lib/intent_record/models/stakeholder_reference.rb +11 -0
  39. data/lib/intent_record/models/stakeholder_source.rb +12 -0
  40. data/lib/intent_record/models/stakeholder_system.rb +10 -0
  41. data/lib/intent_record/models/vcs_system.rb +10 -0
  42. data/lib/intent_record/seeds.rb +38 -0
  43. data/lib/intent_record/sqlite_connection_setup.rb +22 -0
  44. data/lib/intent_record/stakeholder_normalizer.rb +33 -0
  45. data/lib/intent_record/version.rb +3 -0
  46. data/lib/intent_record/web/app.rb +54 -0
  47. data/lib/intent_record/web/boot.rb +51 -0
  48. data/lib/intent_record/web/public/styles.css +50 -0
  49. data/lib/intent_record/web/query_router.rb +25 -0
  50. data/lib/intent_record/web/routes/commits.rb +16 -0
  51. data/lib/intent_record/web/routes/home.rb +21 -0
  52. data/lib/intent_record/web/routes/intents.rb +15 -0
  53. data/lib/intent_record/web/routes/search.rb +17 -0
  54. data/lib/intent_record/web/routes/sources.rb +19 -0
  55. data/lib/intent_record/web/views/_intent_cards.erb +43 -0
  56. data/lib/intent_record/web/views/commit.erb +12 -0
  57. data/lib/intent_record/web/views/error.erb +7 -0
  58. data/lib/intent_record/web/views/home.erb +14 -0
  59. data/lib/intent_record/web/views/intent.erb +5 -0
  60. data/lib/intent_record/web/views/layout.erb +28 -0
  61. data/lib/intent_record/web/views/search.erb +5 -0
  62. data/lib/intent_record/web/views/sources.erb +16 -0
  63. data/lib/intent_record.rb +28 -0
  64. 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,10 @@
1
+ require_relative "../application_record"
2
+ module IntentRecord
3
+ module Models
4
+ class VcsSystem < ApplicationRecord
5
+ has_many :asset_versions, 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,3 @@
1
+ module IntentRecord
2
+ VERSION = "1.0.0".freeze
3
+ 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