feature_machine 0.1.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 (95) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE +21 -0
  4. data/README.md +207 -0
  5. data/app/assets/feature_machine/default.css +62 -0
  6. data/app/controllers/api/feature_machine/base_controller.rb +109 -0
  7. data/app/controllers/api/feature_machine/bug_reports_controller.rb +32 -0
  8. data/app/controllers/api/feature_machine/feature_requests_controller.rb +31 -0
  9. data/app/controllers/api/feature_machine/maintainer/feature_requests_controller.rb +28 -0
  10. data/app/controllers/api/feature_machine/roadmaps_controller.rb +9 -0
  11. data/app/controllers/api/feature_machine/work_items_controller.rb +56 -0
  12. data/app/controllers/concerns/feature_machine/current_user.rb +28 -0
  13. data/app/controllers/concerns/feature_machine/engine_layout.rb +12 -0
  14. data/app/controllers/feature_machine/admin/attachments_controller.rb +17 -0
  15. data/app/controllers/feature_machine/admin/base_controller.rb +16 -0
  16. data/app/controllers/feature_machine/admin/feature_requests_controller.rb +24 -0
  17. data/app/controllers/feature_machine/admin/triage_controller.rb +9 -0
  18. data/app/controllers/feature_machine/admin/work_item_relations_controller.rb +24 -0
  19. data/app/controllers/feature_machine/admin/work_items_controller.rb +88 -0
  20. data/app/controllers/feature_machine/base_controller.rb +10 -0
  21. data/app/controllers/feature_machine/bug_reports_controller.rb +29 -0
  22. data/app/controllers/feature_machine/feature_requests_controller.rb +27 -0
  23. data/app/controllers/feature_machine/mcp_controller.rb +36 -0
  24. data/app/controllers/feature_machine/roadmap_controller.rb +9 -0
  25. data/app/helpers/feature_machine/feature_requests_helper.rb +90 -0
  26. data/app/helpers/feature_machine/work_items_helper.rb +99 -0
  27. data/app/jobs/feature_machine/application_job.rb +6 -0
  28. data/app/jobs/feature_machine/request_embedding_job.rb +12 -0
  29. data/app/jobs/feature_machine/work_item_embedding_job.rb +12 -0
  30. data/app/models/feature_machine/request.rb +111 -0
  31. data/app/models/feature_machine/roadmap.rb +24 -0
  32. data/app/models/feature_machine/work_item.rb +203 -0
  33. data/app/models/feature_machine/work_item_relation.rb +60 -0
  34. data/app/prompts/feature_machine/drafter/_product_blurb.txt.erb +1 -0
  35. data/app/prompts/feature_machine/drafter/instructions.txt.erb +7 -0
  36. data/app/services/feature_machine/base_embedding_service.rb +52 -0
  37. data/app/services/feature_machine/drafter.rb +43 -0
  38. data/app/services/feature_machine/judge.rb +53 -0
  39. data/app/services/feature_machine/request_embedding_service.rb +14 -0
  40. data/app/services/feature_machine/triage_buckets.rb +146 -0
  41. data/app/services/feature_machine/work_item_drafter.rb +91 -0
  42. data/app/services/feature_machine/work_item_embedding_service.rb +14 -0
  43. data/app/services/feature_machine/work_item_spec_schema.rb +14 -0
  44. data/app/tools/feature_machine/mcp/base.rb +94 -0
  45. data/app/tools/feature_machine/mcp/get_work_item.rb +20 -0
  46. data/app/tools/feature_machine/mcp/list_bug_reports.rb +20 -0
  47. data/app/tools/feature_machine/mcp/list_feature_requests.rb +29 -0
  48. data/app/tools/feature_machine/mcp/list_work_items.rb +30 -0
  49. data/app/tools/feature_machine/mcp/read_roadmap.rb +22 -0
  50. data/app/tools/feature_machine/mcp/report_bug.rb +26 -0
  51. data/app/tools/feature_machine/mcp/request_feature.rb +27 -0
  52. data/app/tools/feature_machine/mcp/update_work_item.rb +33 -0
  53. data/app/tools/feature_machine/mcp.rb +25 -0
  54. data/app/views/api/feature_machine/feature_requests/index.json.jbuilder +2 -0
  55. data/app/views/api/feature_machine/feature_requests/show.json.jbuilder +1 -0
  56. data/app/views/api/feature_machine/maintainer/feature_requests/index.json.jbuilder +1 -0
  57. data/app/views/api/feature_machine/roadmaps/_work_item.json.jbuilder +12 -0
  58. data/app/views/api/feature_machine/roadmaps/show.json.jbuilder +10 -0
  59. data/app/views/api/feature_machine/work_items/_work_item.json.jbuilder +32 -0
  60. data/app/views/api/feature_machine/work_items/index.json.jbuilder +1 -0
  61. data/app/views/api/feature_machine/work_items/show.json.jbuilder +3 -0
  62. data/app/views/feature_machine/_default_styles.html.erb +5 -0
  63. data/app/views/feature_machine/_flash.html.erb +10 -0
  64. data/app/views/feature_machine/admin/triage/show.html.erb +120 -0
  65. data/app/views/feature_machine/admin/work_items/_form.html.erb +78 -0
  66. data/app/views/feature_machine/admin/work_items/edit.html.erb +40 -0
  67. data/app/views/feature_machine/admin/work_items/index.html.erb +80 -0
  68. data/app/views/feature_machine/admin/work_items/new.html.erb +21 -0
  69. data/app/views/feature_machine/admin/work_items/show.html.erb +168 -0
  70. data/app/views/feature_machine/admin/work_items/show.md.erb +31 -0
  71. data/app/views/feature_machine/bug_reports/index.html.erb +38 -0
  72. data/app/views/feature_machine/bug_reports/new.html.erb +83 -0
  73. data/app/views/feature_machine/feature_requests/_feature_request.html.erb +47 -0
  74. data/app/views/feature_machine/feature_requests/_feature_request.json.jbuilder +15 -0
  75. data/app/views/feature_machine/feature_requests/index.html.erb +44 -0
  76. data/app/views/feature_machine/feature_requests/new.html.erb +84 -0
  77. data/app/views/feature_machine/roadmap/_work_item.html.erb +34 -0
  78. data/app/views/feature_machine/roadmap/show.html.erb +70 -0
  79. data/app/views/layouts/feature_machine.html.erb +21 -0
  80. data/db/migrate/20260910180000_create_feature_requests.rb +18 -0
  81. data/db/migrate/20261004120000_create_work_items.rb +52 -0
  82. data/db/migrate/20261006120000_add_pull_request_url_to_work_items.rb +7 -0
  83. data/lib/feature_machine/auth.rb +89 -0
  84. data/lib/feature_machine/calibration/judge_cases.yml +137 -0
  85. data/lib/feature_machine/configuration.rb +165 -0
  86. data/lib/feature_machine/engine.rb +34 -0
  87. data/lib/feature_machine/judge_calibration.rb +85 -0
  88. data/lib/feature_machine/routes.rb +61 -0
  89. data/lib/feature_machine/version.rb +3 -0
  90. data/lib/feature_machine.rb +57 -0
  91. data/lib/generators/feature_machine/install/install_generator.rb +76 -0
  92. data/lib/generators/feature_machine/install/templates/initializer.rb +99 -0
  93. data/lib/tasks/feature_machine_calibration.rake +17 -0
  94. data/lib/tasks/feature_machine_embeddings.rake +10 -0
  95. metadata +207 -0
@@ -0,0 +1,16 @@
1
+ # Every engine admin page. Anyone config.admin doesn't vouch for gets a 404
2
+ # rather than a 403, so the admin surface doesn't advertise itself. The
3
+ # parent is the host's (config.admin_parent_controller), so its layout and
4
+ # any admin filters of its own still apply.
5
+ class FeatureMachine::Admin::BaseController < FeatureMachine.config.admin_parent_controller.constantize
6
+ include FeatureMachine::CurrentUser
7
+ include FeatureMachine::EngineLayout
8
+
9
+ before_action :require_feature_machine_admin
10
+
11
+ private
12
+
13
+ def require_feature_machine_admin
14
+ head :not_found unless FeatureMachine.config.admin?(feature_machine_user)
15
+ end
16
+ end
@@ -0,0 +1,24 @@
1
+ # Per-request triage decisions: move a request into or out of a work item,
2
+ # decline it with a note, or reopen it.
3
+ class FeatureMachine::Admin::FeatureRequestsController < FeatureMachine::Admin::BaseController
4
+ def update
5
+ feature_request = ::FeatureMachine::Request.find(params[:id])
6
+ attributes = params.expect(feature_request: [ :work_item_id, :status, :progress_note ])
7
+ # Which statuses an admin may set lives on the model.
8
+ return head :unprocessable_entity unless feature_request.update(attributes)
9
+
10
+ redirect_back fallback_location: admin_triage_path, notice: notice_for(feature_request)
11
+ end
12
+
13
+ private
14
+
15
+ def notice_for(feature_request)
16
+ if feature_request.work_item
17
+ "Attached to \"#{feature_request.work_item.title}\"."
18
+ elsif feature_request.declined?
19
+ "Request declined."
20
+ else
21
+ "Request is back in triage."
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,9 @@
1
+ # The triage inbox: untriaged requests, bucketed by similarity and ranked by
2
+ # how many different people asked.
3
+ class FeatureMachine::Admin::TriageController < FeatureMachine::Admin::BaseController
4
+ def show
5
+ @buckets = ::FeatureMachine::TriageBuckets.new.call
6
+ @request_count = @buckets.sum { |bucket| bucket.requests.size }
7
+ @work_items = ::FeatureMachine::WorkItem.active.order(:title)
8
+ end
9
+ end
@@ -0,0 +1,24 @@
1
+ class FeatureMachine::Admin::WorkItemRelationsController < FeatureMachine::Admin::BaseController
2
+ before_action :set_work_item
3
+
4
+ def create
5
+ relation = @work_item.outgoing_relations.build(params.expect(work_item_relation: [ :to_work_item_id, :kind ]))
6
+
7
+ if relation.save
8
+ redirect_to admin_work_item_path(@work_item), notice: "Linked."
9
+ else
10
+ redirect_to admin_work_item_path(@work_item), alert: relation.errors.full_messages.to_sentence
11
+ end
12
+ end
13
+
14
+ def destroy
15
+ @work_item.relations.find(params[:id]).destroy!
16
+ redirect_to admin_work_item_path(@work_item), notice: "Link removed."
17
+ end
18
+
19
+ private
20
+
21
+ def set_work_item
22
+ @work_item = ::FeatureMachine::WorkItem.find(params[:work_item_id])
23
+ end
24
+ end
@@ -0,0 +1,88 @@
1
+ class FeatureMachine::Admin::WorkItemsController < FeatureMachine::Admin::BaseController
2
+ before_action :set_work_item, only: %i[show edit update destroy draft]
3
+
4
+ def index
5
+ grouped = ::FeatureMachine::WorkItem.includes(:feature_requests).newest_first.group_by(&:status)
6
+ @work_items = ::FeatureMachine::WorkItem::ADMIN_ORDER.index_with { |status| grouped.fetch(status, []) }
7
+ end
8
+
9
+ # The hand-off view. `.md` is the same spec as a document to paste into an
10
+ # issue tracker or hand to whoever builds it.
11
+ def show
12
+ respond_to do |format|
13
+ format.html
14
+ format.md { render layout: false }
15
+ end
16
+ end
17
+
18
+ def new
19
+ @work_item = ::FeatureMachine::WorkItem.new(kind: params[:kind].presence_in(::FeatureMachine::WorkItem.kinds.keys) || "feature")
20
+ end
21
+
22
+ # Two ways in: the blank form, or "form a work item" on a triage bucket,
23
+ # which arrives with request_ids and drafts the whole spec from them.
24
+ def create
25
+ requests = ::FeatureMachine::Request.untriaged.where(id: params[:request_ids]).includes(:user).oldest_first
26
+ return create_from_requests(requests) if requests.any?
27
+
28
+ @work_item = ::FeatureMachine::WorkItem.new(work_item_params)
29
+ if @work_item.save
30
+ redirect_to edit_admin_work_item_path(@work_item), notice: "Work item created."
31
+ else
32
+ render :new, status: :unprocessable_entity
33
+ end
34
+ end
35
+
36
+ def edit
37
+ end
38
+
39
+ # Also serves the one-click review decisions on the show page (ship it,
40
+ # send it back), which post just a status.
41
+ def update
42
+ if @work_item.update(work_item_params)
43
+ redirect_to admin_work_item_path(@work_item), notice: @work_item.saved_change_to_status? ? "Now #{helpers.work_item_status_label(@work_item.status).downcase}." : "Work item saved."
44
+ else
45
+ render :edit, status: :unprocessable_entity
46
+ end
47
+ end
48
+
49
+ def destroy
50
+ @work_item.destroy!
51
+ redirect_to admin_work_items_path, notice: "Work item deleted. Its requests are back in triage."
52
+ end
53
+
54
+ # Rewrites the spec from the item's requests. Always lands on the edit
55
+ # form: a draft is something to review, never something to publish as-is.
56
+ def draft
57
+ draft = ::FeatureMachine::WorkItemDrafter.new(@work_item.feature_requests.includes(:user).oldest_first, kind: @work_item.kind).call
58
+
59
+ if @work_item.update(draft.attributes)
60
+ redirect_to edit_admin_work_item_path(@work_item),
61
+ notice: draft.from_model? ? "Spec drafted from the requests. Review it before planning." : "The model was unavailable, so the spec is a plain digest of the requests."
62
+ else
63
+ redirect_to edit_admin_work_item_path(@work_item), alert: "Draft could not be saved: #{@work_item.errors.full_messages.to_sentence}"
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ # The draft is written by the model from user text, so it always opens in
70
+ # the editor as a private draft; planning it is a separate, human step.
71
+ def create_from_requests(requests)
72
+ draft = ::FeatureMachine::WorkItemDrafter.new(requests).call
73
+ work_item = ::FeatureMachine::WorkItem.create!(draft.attributes.merge(kind: requests.first.kind))
74
+ requests.each { |request| request.update!(work_item: work_item) }
75
+
76
+ formed = "Work item formed from #{helpers.pluralize(requests.size, 'request')}."
77
+ redirect_to edit_admin_work_item_path(work_item),
78
+ notice: draft.from_model? ? "#{formed} The spec is a first draft: review it before planning." : "#{formed} The model was unavailable, so only the problem is filled in, from the requests."
79
+ end
80
+
81
+ def set_work_item
82
+ @work_item = ::FeatureMachine::WorkItem.find(params[:id])
83
+ end
84
+
85
+ def work_item_params
86
+ params.expect(work_item: [ :title, :kind, :status, :summary, :problem, :scope, :acceptance_criteria, :progress_note, :pull_request_url ])
87
+ end
88
+ end
@@ -0,0 +1,10 @@
1
+ # The engine's signed-in pages: your requests, your bug reports, the roadmap.
2
+ # The parent is the host's (config.parent_controller, ApplicationController by
3
+ # default), so its layout and auth filters run first; the engine then insists
4
+ # on a user of its own from config.current_user.
5
+ class FeatureMachine::BaseController < FeatureMachine.config.parent_controller.constantize
6
+ include FeatureMachine::CurrentUser
7
+ include FeatureMachine::EngineLayout
8
+
9
+ before_action :require_feature_machine_user
10
+ end
@@ -0,0 +1,29 @@
1
+ # Bug reports are requests of kind `bug`, taken in through their own door,
2
+ # because a bug asks a different question than a feature does.
3
+ class FeatureMachine::BugReportsController < FeatureMachine::BaseController
4
+ # "Your bug reports": only ever the signed-in user's own, since a report can
5
+ # describe their data.
6
+ def index
7
+ @bug_reports = ::FeatureMachine::Request.owned_by(feature_machine_user).bug.includes(:work_item).newest_first
8
+ end
9
+
10
+ def new
11
+ @bug_report = ::FeatureMachine::Request.bug.new
12
+ end
13
+
14
+ def create
15
+ @bug_report = ::FeatureMachine::Request.owned_by(feature_machine_user).bug.build(bug_report_params)
16
+
17
+ if @bug_report.save
18
+ redirect_to bug_reports_path, notice: "Thanks. Only you can see this report; its status will update here."
19
+ else
20
+ render :new, status: :unprocessable_entity
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def bug_report_params
27
+ params.expect(bug_report: [ :title, :description ])
28
+ end
29
+ end
@@ -0,0 +1,27 @@
1
+ class FeatureMachine::FeatureRequestsController < FeatureMachine::BaseController
2
+ # "Your feature requests": only ever the signed-in user's own. What everyone
3
+ # can see is the roadmap, which lists work items rather than requests.
4
+ def index
5
+ @feature_requests = ::FeatureMachine::Request.owned_by(feature_machine_user).feature.includes(:work_item).newest_first
6
+ end
7
+
8
+ def new
9
+ @feature_request = ::FeatureMachine::Request.new
10
+ end
11
+
12
+ def create
13
+ @feature_request = ::FeatureMachine::Request.owned_by(feature_machine_user).feature.build(feature_request_params)
14
+
15
+ if @feature_request.save
16
+ redirect_to feature_requests_path, notice: "Thanks. Its status will update here."
17
+ else
18
+ render :new, status: :unprocessable_entity
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def feature_request_params
25
+ params.expect(feature_request: [ :title, :description ])
26
+ end
27
+ end
@@ -0,0 +1,36 @@
1
+ # Stateless MCP endpoint (streamable HTTP, JSON responses). Each POST is an
2
+ # independent JSON-RPC exchange. Users' agents file and follow their own
3
+ # requests; a maintainer token also gets the three work-item tools the
4
+ # fulfilment agent calls. Only bearer tokens count here: a browser session
5
+ # cookie is never an MCP credential, so no page can drive these tools.
6
+ class FeatureMachine::McpController < ActionController::API
7
+ def handle
8
+ result = server.handle_json(request.body.read)
9
+ result.nil? ? head(:accepted) : render(json: result)
10
+ end
11
+
12
+ # Stateless: no server-initiated stream to offer on GET.
13
+ def reject_get
14
+ head :method_not_allowed
15
+ end
16
+
17
+ private
18
+
19
+ def server
20
+ MCP::Server.new(
21
+ name: "feature_machine",
22
+ title: FeatureMachine.config.product_name,
23
+ version: FeatureMachine::VERSION,
24
+ instructions: "Feature requests, bug reports and the roadmap for #{FeatureMachine.config.product_name}.",
25
+ tools: FeatureMachine::Mcp.for(credential),
26
+ server_context: { credential: credential }
27
+ )
28
+ end
29
+
30
+ def credential
31
+ return @credential if defined?(@credential)
32
+
33
+ found = request.env[FeatureMachine::ENV_KEY]
34
+ @credential = (found unless found.respond_to?(:session?) && found.session?)
35
+ end
36
+ end
@@ -0,0 +1,9 @@
1
+ # The roadmap and changelog: what is being built, what is queued, and what
2
+ # shipped. It lists work items, not raw requests, so it reads as a product
3
+ # changelog rather than a suggestion box. Signed-in users only; agents read
4
+ # the same thing at GET /api/roadmap with a token.
5
+ class FeatureMachine::RoadmapController < FeatureMachine::BaseController
6
+ def show
7
+ @roadmap = ::FeatureMachine::Roadmap.new
8
+ end
9
+ end
@@ -0,0 +1,90 @@
1
+ module FeatureMachine::FeatureRequestsHelper
2
+ # Status -> the design system's status tone. One place, so the board, the
3
+ # summary strip and any future email all agree on what "planned" looks like.
4
+ STATUS_TONES = {
5
+ "requested" => :neutral,
6
+ "planned" => :info,
7
+ "in_progress" => :progress,
8
+ "shipped" => :success,
9
+ "declined" => :danger
10
+ }.freeze
11
+
12
+ STATUS_LABELS = {
13
+ "requested" => "Requested",
14
+ "planned" => "Planned",
15
+ "in_progress" => "In progress",
16
+ "shipped" => "Shipped",
17
+ "declined" => "Declined"
18
+ }.freeze
19
+
20
+ # A bug isn't "requested" or "shipped"; same lifecycle, said the way the
21
+ # reporter would.
22
+ BUG_STATUS_LABELS = STATUS_LABELS.merge(
23
+ "requested" => "Reported",
24
+ "planned" => "Confirmed",
25
+ "in_progress" => "Being fixed",
26
+ "shipped" => "Fixed",
27
+ "declined" => "Won't fix"
28
+ ).freeze
29
+
30
+ # What each status means for the person who reported the bug.
31
+ BUG_STATUS_BLURBS = {
32
+ "requested" => "We have your report and haven't looked at it yet.",
33
+ "planned" => "Confirmed and queued for a fix.",
34
+ "in_progress" => "A fix is being worked on now.",
35
+ "shipped" => "The fix is live.",
36
+ "declined" => "We're not going to change this."
37
+ }.freeze
38
+
39
+ # What each status means for the person who asked for the feature. The
40
+ # shipped line names the host's product, so it is built at call time from
41
+ # FeatureMachine.config rather than frozen here.
42
+ STATUS_BLURBS = {
43
+ "requested" => "We have your request and haven't decided on it yet.",
44
+ "planned" => "Accepted and queued. It's on the roadmap.",
45
+ "in_progress" => "Being built right now.",
46
+ "shipped" => "Live on %{product}.",
47
+ "declined" => "We're not going to build this."
48
+ }.freeze
49
+
50
+ # The one-line explanation of where a request stands, worded by kind.
51
+ def request_status_blurb(feature_request)
52
+ blurbs = feature_request.bug? ? BUG_STATUS_BLURBS : STATUS_BLURBS
53
+ format(blurbs.fetch(feature_request.status), product: FeatureMachine.config.product_name)
54
+ end
55
+
56
+ # The status chip for a request: tone from the status, pulse while in
57
+ # progress, wording by kind.
58
+ def feature_request_status(feature_request)
59
+ status = feature_request.status
60
+ labels = feature_request.bug? ? BUG_STATUS_LABELS : STATUS_LABELS
61
+ feature_machine_status(tone: STATUS_TONES.fetch(status), pulse: feature_request.in_progress?) do
62
+ labels.fetch(status)
63
+ end
64
+ end
65
+
66
+ # Engine-local status chip. Emits the same class hooks the host's design
67
+ # system styles (cn-nr-status*), so the engine owns no CSS and no component
68
+ # dependency; a host without those hooks still gets a sane unstyled chip.
69
+ def feature_machine_status(tone: :neutral, pulse: false, &block)
70
+ classes = [
71
+ "cn-nr-status inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-semibold",
72
+ "cn-nr-status-tone-#{tone}"
73
+ ].join(" ")
74
+ data = { slot: "nr-status", tone: tone }
75
+ data[:pulse] = "true" if pulse
76
+ content_tag(:span, class: classes, data: data) do
77
+ safe_join([
78
+ content_tag(:span, "", class: "cn-nr-status-dot size-1.5 shrink-0 rounded-full bg-current",
79
+ data: { slot: "nr-status-dot" }, aria: { hidden: true }),
80
+ capture(&block)
81
+ ], " ")
82
+ end
83
+ end
84
+
85
+ # A host link the engine offers only when the host configured it.
86
+ def feature_machine_host_link(name, text, **options)
87
+ path = FeatureMachine.config.host_path(name, self)
88
+ link_to(text, path, **options) if path
89
+ end
90
+ end
@@ -0,0 +1,99 @@
1
+ module FeatureMachine::WorkItemsHelper
2
+ WORK_ITEM_STATUS_TONES = {
3
+ "draft" => :neutral,
4
+ "planned" => :info,
5
+ "in_progress" => :progress,
6
+ "in_review" => :info,
7
+ "shipped" => :success,
8
+ "declined" => :danger
9
+ }.freeze
10
+
11
+ WORK_ITEM_STATUS_LABELS = {
12
+ "draft" => "Draft",
13
+ "planned" => "Planned",
14
+ "in_progress" => "In progress",
15
+ "in_review" => "In review",
16
+ "shipped" => "Shipped",
17
+ "declined" => "Declined"
18
+ }.freeze
19
+
20
+ WORK_ITEM_STATUS_BLURBS = {
21
+ "draft" => "Formed from requests, not yet a commitment. Private until planned.",
22
+ "in_review" => "Built, with a pull request waiting on you. Users still see these as in progress.",
23
+ "in_progress" => "Being built right now.",
24
+ "planned" => "Accepted and queued. Order here isn't a promise about order of delivery.",
25
+ "shipped" => "Live on %{product}.",
26
+ "declined" => "Not going to happen."
27
+ }.freeze
28
+
29
+ def work_item_status_label(status)
30
+ WORK_ITEM_STATUS_LABELS.fetch(status.to_s)
31
+ end
32
+
33
+ # The shipped line names the host's product, so it is built at call time
34
+ # from FeatureMachine.config rather than frozen here.
35
+ def work_item_status_blurb(status)
36
+ format(WORK_ITEM_STATUS_BLURBS.fetch(status.to_s), product: FeatureMachine.config.product_name)
37
+ end
38
+
39
+ # The status chip. Admin pages show the real status; anything users see
40
+ # passes `public: true`, which folds review into "In progress".
41
+ def work_item_status(work_item, public: false)
42
+ status = public ? work_item.public_status : work_item.status
43
+ work_item_status_chip(tone: WORK_ITEM_STATUS_TONES.fetch(status), pulse: status == "in_progress") do
44
+ work_item_status_label(status)
45
+ end
46
+ end
47
+
48
+ # Engine-local status chip. Emits the same class hooks the host's design
49
+ # system styles (cn-nr-status*), so the engine owns no CSS and no component
50
+ # dependency; a host without those hooks still gets a sane unstyled chip.
51
+ def work_item_status_chip(tone: :neutral, pulse: false, &block)
52
+ classes = [
53
+ "cn-nr-status inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-semibold",
54
+ "cn-nr-status-tone-#{tone}"
55
+ ].join(" ")
56
+ data = { slot: "nr-status", tone: tone }
57
+ data[:pulse] = "true" if pulse
58
+ content_tag(:span, class: classes, data: data) do
59
+ safe_join([
60
+ content_tag(:span, "", class: "cn-nr-status-dot size-1.5 shrink-0 rounded-full bg-current",
61
+ data: { slot: "nr-status-dot" }, aria: { hidden: true }),
62
+ capture(&block)
63
+ ], " ")
64
+ end
65
+ end
66
+
67
+ # "Feature" / "Bug", or "Fix" once a bug has shipped: the changelog reads
68
+ # as what changed, not what was wrong.
69
+ def work_kind_label(record)
70
+ return "Fix" if record.bug? && record.try(:shipped?)
71
+
72
+ record.bug? ? "Bug" : "Feature"
73
+ end
74
+
75
+ # How much demand sits behind a work item, counting people rather than requests.
76
+ def work_item_demand(work_item)
77
+ people = work_item.feature_requests.map(&:user_id).uniq.size
78
+ people.zero? ? nil : "#{pluralize(people, 'person')} asked"
79
+ end
80
+
81
+ # The pull request's address, rebuilt from the fixed GitHub origin and the
82
+ # validated path. Nil when there is no usable pull request.
83
+ def pull_request_href(work_item)
84
+ path = work_item.pull_request_path
85
+ "#{::FeatureMachine::WorkItem::GITHUB}#{path}" if path
86
+ end
87
+
88
+ # A new item may start anywhere; a saved one offers where it stands plus
89
+ # the moves its transition table allows.
90
+ def work_item_status_options(work_item = nil)
91
+ statuses = ::FeatureMachine::WorkItem.statuses.keys
92
+ statuses &= [ work_item.status_in_database, *work_item.next_statuses ] if work_item&.persisted?
93
+ statuses.map { |status| [ work_item_status_label(status), status ] }
94
+ end
95
+
96
+ def work_item_relation_options
97
+ ::FeatureMachine::WorkItemRelation::KINDS.map { |kind, (forward, _inverse)| [ forward, kind ] }
98
+ end
99
+ end
@@ -0,0 +1,6 @@
1
+ module FeatureMachine
2
+ # The engine's own job base, so a host needs no ApplicationJob of its own.
3
+ # Queue adapter and the like still come from the host's Active Job config.
4
+ class ApplicationJob < ActiveJob::Base
5
+ end
6
+ end
@@ -0,0 +1,12 @@
1
+ module FeatureMachine
2
+ class RequestEmbeddingJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(feature_request_id)
6
+ feature_request = ::FeatureMachine::Request.find_by(id: feature_request_id)
7
+ return unless feature_request
8
+
9
+ RequestEmbeddingService.new(feature_request).call
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ module FeatureMachine
2
+ class WorkItemEmbeddingJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(work_item_id)
6
+ work_item = ::FeatureMachine::WorkItem.find_by(id: work_item_id)
7
+ return unless work_item
8
+
9
+ WorkItemEmbeddingService.new(work_item).call
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,111 @@
1
+ # Something a user asked the host app for: a feature, or a bug report. This
2
+ # is the raw intake record, in the requester's own words. What actually gets
3
+ # built is a WorkItem; triage is the act of bucketing requests into one.
4
+ #
5
+ # A request is visible to the person who filed it and to admins, never to
6
+ # other users. What everyone signed in can see is the roadmap of work items.
7
+ #
8
+ # Requesters can only create. A request's status follows its work item; the
9
+ # only status an admin sets directly is `declined`.
10
+ module FeatureMachine
11
+ class Request < ApplicationRecord
12
+ self.table_name = "feature_requests"
13
+
14
+ # Phase 2 keeps the public surface unchanged: params keys, dom_id,
15
+ # polymorphic route helpers and i18n keys all still read `feature_request`.
16
+ # Requests filed before embeddings were switched on never got one, and
17
+ # triage leaves them out of every group. Embeds each one now, in this
18
+ # process: queued jobs would die with a rake task on an in-process
19
+ # adapter. Returns how many it filled; the rest stay nil, as a failed
20
+ # call leaves them.
21
+ def self.backfill_embeddings
22
+ return 0 unless ::FeatureMachine.config.embeddings?
23
+
24
+ ids = unembedded.ids
25
+ ids.each { |id| RequestEmbeddingJob.perform_now(id) }
26
+ ids.size - unembedded.where(id: ids).count
27
+ end
28
+
29
+ def self.model_name
30
+ @_model_name ||= ActiveModel::Name.new(self, nil, "FeatureRequest")
31
+ end
32
+
33
+ belongs_to :user, class_name: ::FeatureMachine.config.user_class
34
+ belongs_to :work_item, class_name: "FeatureMachine::WorkItem", optional: true
35
+
36
+ enum :kind, { feature: 0, bug: 1 }, validate: true
37
+ enum :status, { requested: 0, planned: 1, in_progress: 2, shipped: 3, declined: 4 }, validate: true
38
+
39
+ # The only statuses set on a request directly, and only while it has no
40
+ # work item: decline it, or reopen it. Every other status is copied from
41
+ # the work item it joins (follow_work_item, or WorkItem#sync_requests).
42
+ DIRECT_STATUSES = %w[requested declined].freeze
43
+
44
+ has_neighbors :embedding
45
+
46
+ validates :title, presence: true, length: { minimum: 8, maximum: 120 }
47
+ validates :description, length: { maximum: 2000 }
48
+ validates :progress_note, length: { maximum: 1000 }
49
+ validate :status_set_directly_only_while_unattached, on: :update
50
+
51
+ scope :newest_first, -> { order(created_at: :desc) }
52
+ scope :oldest_first, -> { order(created_at: :asc) }
53
+ scope :embedded, -> { where.not(embedding: nil) }
54
+ scope :unembedded, -> { where(embedding: nil) }
55
+ # One requester's own requests. The engine never asks the host's user
56
+ # model for an association, so a host needs no has_many to adopt it.
57
+ scope :owned_by, ->(user) { where(user: user) }
58
+ # Still waiting for an admin: not bucketed into a work item, not declined.
59
+ scope :untriaged, -> { requested.where(work_item_id: nil) }
60
+
61
+ before_save :follow_work_item, if: :will_save_change_to_work_item_id?
62
+ before_save :stamp_shipped_at
63
+ after_commit :enqueue_embedding, on: [ :create, :update ], if: :embedding_wanted?
64
+ # Hosts hear about status moves (planned, shipped, declined...) to tell the
65
+ # requester wherever they asked; the engine itself only shows the roadmap.
66
+ after_update_commit :announce_status_change, if: -> { saved_change_to_status? && ::FeatureMachine.config.on_status_change }
67
+
68
+ private
69
+
70
+ # Joining or leaving a work item rewrites status in before_save, so a
71
+ # status passed alongside a work_item_id change is moot. Without one, a
72
+ # direct move is only requested <-> declined, and only when unattached.
73
+ def status_set_directly_only_while_unattached
74
+ return unless will_save_change_to_status?
75
+ return if will_save_change_to_work_item_id?
76
+ return if work_item.nil? && DIRECT_STATUSES.include?(status)
77
+
78
+ errors.add(:status, work_item ? "follows its work item" : "can only be set to requested or declined")
79
+ end
80
+
81
+ # Joining a work item means taking on its public state; leaving one puts
82
+ # the request back in the triage inbox.
83
+ def follow_work_item
84
+ if work_item
85
+ assign_attributes(work_item.request_attributes)
86
+ else
87
+ assign_attributes(status: :requested, progress_note: nil, shipped_at: nil)
88
+ end
89
+ end
90
+
91
+ def stamp_shipped_at
92
+ self.shipped_at ||= Time.current if shipped? && (new_record? || will_save_change_to_status?)
93
+ end
94
+
95
+ def embedding_wanted?
96
+ ::FeatureMachine.config.embeddings? && embedding_stale?
97
+ end
98
+
99
+ def embedding_stale?
100
+ saved_change_to_title? || saved_change_to_description? || embedding.nil?
101
+ end
102
+
103
+ def announce_status_change
104
+ ::FeatureMachine.config.on_status_change.call(self)
105
+ end
106
+
107
+ def enqueue_embedding
108
+ RequestEmbeddingJob.perform_later(id)
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,24 @@
1
+ module FeatureMachine
2
+ # The roadmap as shown to users and their agents: upcoming work items by
3
+ # status, then shipped ones as a changelog by month. It is read straight from
4
+ # work items, so it is current the moment a status changes.
5
+ class Roadmap
6
+ # @return [Hash{String => Array<WorkItem>}] in_progress then planned, empty sections included
7
+ def upcoming
8
+ @upcoming ||= begin
9
+ grouped = WorkItem.upcoming.includes(:feature_requests).newest_first.group_by(&:public_status)
10
+ WorkItem::UPCOMING.index_with { |status| grouped.fetch(status, []) }
11
+ end
12
+ end
13
+
14
+ # @return [Hash{Date => Array<WorkItem>}] first day of each month => items shipped in it, newest first
15
+ def changelog
16
+ @changelog ||= WorkItem.changelog.includes(:feature_requests)
17
+ .group_by { |item| item.shipped_at.beginning_of_month.to_date }
18
+ end
19
+
20
+ def empty?
21
+ upcoming.values.all?(&:empty?) && changelog.empty?
22
+ end
23
+ end
24
+ end