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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE +21 -0
- data/README.md +207 -0
- data/app/assets/feature_machine/default.css +62 -0
- data/app/controllers/api/feature_machine/base_controller.rb +109 -0
- data/app/controllers/api/feature_machine/bug_reports_controller.rb +32 -0
- data/app/controllers/api/feature_machine/feature_requests_controller.rb +31 -0
- data/app/controllers/api/feature_machine/maintainer/feature_requests_controller.rb +28 -0
- data/app/controllers/api/feature_machine/roadmaps_controller.rb +9 -0
- data/app/controllers/api/feature_machine/work_items_controller.rb +56 -0
- data/app/controllers/concerns/feature_machine/current_user.rb +28 -0
- data/app/controllers/concerns/feature_machine/engine_layout.rb +12 -0
- data/app/controllers/feature_machine/admin/attachments_controller.rb +17 -0
- data/app/controllers/feature_machine/admin/base_controller.rb +16 -0
- data/app/controllers/feature_machine/admin/feature_requests_controller.rb +24 -0
- data/app/controllers/feature_machine/admin/triage_controller.rb +9 -0
- data/app/controllers/feature_machine/admin/work_item_relations_controller.rb +24 -0
- data/app/controllers/feature_machine/admin/work_items_controller.rb +88 -0
- data/app/controllers/feature_machine/base_controller.rb +10 -0
- data/app/controllers/feature_machine/bug_reports_controller.rb +29 -0
- data/app/controllers/feature_machine/feature_requests_controller.rb +27 -0
- data/app/controllers/feature_machine/mcp_controller.rb +36 -0
- data/app/controllers/feature_machine/roadmap_controller.rb +9 -0
- data/app/helpers/feature_machine/feature_requests_helper.rb +90 -0
- data/app/helpers/feature_machine/work_items_helper.rb +99 -0
- data/app/jobs/feature_machine/application_job.rb +6 -0
- data/app/jobs/feature_machine/request_embedding_job.rb +12 -0
- data/app/jobs/feature_machine/work_item_embedding_job.rb +12 -0
- data/app/models/feature_machine/request.rb +111 -0
- data/app/models/feature_machine/roadmap.rb +24 -0
- data/app/models/feature_machine/work_item.rb +203 -0
- data/app/models/feature_machine/work_item_relation.rb +60 -0
- data/app/prompts/feature_machine/drafter/_product_blurb.txt.erb +1 -0
- data/app/prompts/feature_machine/drafter/instructions.txt.erb +7 -0
- data/app/services/feature_machine/base_embedding_service.rb +52 -0
- data/app/services/feature_machine/drafter.rb +43 -0
- data/app/services/feature_machine/judge.rb +53 -0
- data/app/services/feature_machine/request_embedding_service.rb +14 -0
- data/app/services/feature_machine/triage_buckets.rb +146 -0
- data/app/services/feature_machine/work_item_drafter.rb +91 -0
- data/app/services/feature_machine/work_item_embedding_service.rb +14 -0
- data/app/services/feature_machine/work_item_spec_schema.rb +14 -0
- data/app/tools/feature_machine/mcp/base.rb +94 -0
- data/app/tools/feature_machine/mcp/get_work_item.rb +20 -0
- data/app/tools/feature_machine/mcp/list_bug_reports.rb +20 -0
- data/app/tools/feature_machine/mcp/list_feature_requests.rb +29 -0
- data/app/tools/feature_machine/mcp/list_work_items.rb +30 -0
- data/app/tools/feature_machine/mcp/read_roadmap.rb +22 -0
- data/app/tools/feature_machine/mcp/report_bug.rb +26 -0
- data/app/tools/feature_machine/mcp/request_feature.rb +27 -0
- data/app/tools/feature_machine/mcp/update_work_item.rb +33 -0
- data/app/tools/feature_machine/mcp.rb +25 -0
- data/app/views/api/feature_machine/feature_requests/index.json.jbuilder +2 -0
- data/app/views/api/feature_machine/feature_requests/show.json.jbuilder +1 -0
- data/app/views/api/feature_machine/maintainer/feature_requests/index.json.jbuilder +1 -0
- data/app/views/api/feature_machine/roadmaps/_work_item.json.jbuilder +12 -0
- data/app/views/api/feature_machine/roadmaps/show.json.jbuilder +10 -0
- data/app/views/api/feature_machine/work_items/_work_item.json.jbuilder +32 -0
- data/app/views/api/feature_machine/work_items/index.json.jbuilder +1 -0
- data/app/views/api/feature_machine/work_items/show.json.jbuilder +3 -0
- data/app/views/feature_machine/_default_styles.html.erb +5 -0
- data/app/views/feature_machine/_flash.html.erb +10 -0
- data/app/views/feature_machine/admin/triage/show.html.erb +120 -0
- data/app/views/feature_machine/admin/work_items/_form.html.erb +78 -0
- data/app/views/feature_machine/admin/work_items/edit.html.erb +40 -0
- data/app/views/feature_machine/admin/work_items/index.html.erb +80 -0
- data/app/views/feature_machine/admin/work_items/new.html.erb +21 -0
- data/app/views/feature_machine/admin/work_items/show.html.erb +168 -0
- data/app/views/feature_machine/admin/work_items/show.md.erb +31 -0
- data/app/views/feature_machine/bug_reports/index.html.erb +38 -0
- data/app/views/feature_machine/bug_reports/new.html.erb +83 -0
- data/app/views/feature_machine/feature_requests/_feature_request.html.erb +47 -0
- data/app/views/feature_machine/feature_requests/_feature_request.json.jbuilder +15 -0
- data/app/views/feature_machine/feature_requests/index.html.erb +44 -0
- data/app/views/feature_machine/feature_requests/new.html.erb +84 -0
- data/app/views/feature_machine/roadmap/_work_item.html.erb +34 -0
- data/app/views/feature_machine/roadmap/show.html.erb +70 -0
- data/app/views/layouts/feature_machine.html.erb +21 -0
- data/db/migrate/20260910180000_create_feature_requests.rb +18 -0
- data/db/migrate/20261004120000_create_work_items.rb +52 -0
- data/db/migrate/20261006120000_add_pull_request_url_to_work_items.rb +7 -0
- data/lib/feature_machine/auth.rb +89 -0
- data/lib/feature_machine/calibration/judge_cases.yml +137 -0
- data/lib/feature_machine/configuration.rb +165 -0
- data/lib/feature_machine/engine.rb +34 -0
- data/lib/feature_machine/judge_calibration.rb +85 -0
- data/lib/feature_machine/routes.rb +61 -0
- data/lib/feature_machine/version.rb +3 -0
- data/lib/feature_machine.rb +57 -0
- data/lib/generators/feature_machine/install/install_generator.rb +76 -0
- data/lib/generators/feature_machine/install/templates/initializer.rb +99 -0
- data/lib/tasks/feature_machine_calibration.rake +17 -0
- data/lib/tasks/feature_machine_embeddings.rake +10 -0
- 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,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
|