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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c4b036261f92e29292d948f5bbef5a4d84d5a1bd3323d8cde92b6ae498c45f2f
|
|
4
|
+
data.tar.gz: 4800b671dac9fa5b1840ff84f513de37eeb198993c87833f68ea54f9bede0c7f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1ba4481ff6c8dcb0892de61b6b2cf1280c898c6057ce9734ce8feaac6bc839b1461a90196d7c45eb656395e83188e0fca5d6ecb0d9610034fdbab06cd54cdf61
|
|
7
|
+
data.tar.gz: 83f8946ce62ea59a18f41c96130bee8195097156b0e6202296ec1edfb6d0935a0fbf67dfea15515b21da112dcb55867d103c4127d27ee2dee6e79cce9d5f5b77
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-11-09)
|
|
4
|
+
|
|
5
|
+
First public release.
|
|
6
|
+
|
|
7
|
+
- Mountable engine: feature requests and bug reports in, triage, work items out.
|
|
8
|
+
- Roadmap page and JSON API, maintainer API, and an MCP server for agents.
|
|
9
|
+
- `rails g feature_machine:install` initializer and `feature_machine:install:migrations`.
|
|
10
|
+
With Rails' authentication generator present, the initializer reads the
|
|
11
|
+
session cookie for `current_user`, and the generator warns when there's no
|
|
12
|
+
root route for sign-in to land on.
|
|
13
|
+
- Optional embeddings (pgvector via neighbor) for duplicate detection, and a
|
|
14
|
+
RubyLLM drafter and judge behind `FeatureMachine.configure`.
|
|
15
|
+
- Maintainer MCP tools (`list_work_items`, `get_work_item`, `update_work_item`)
|
|
16
|
+
so a coding agent can pick up planned items and attach a pull request.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Valentino Stoll
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# feature_machine
|
|
2
|
+
|
|
3
|
+
Feature requests, work items, and the judge pipeline, packaged as a Rails engine.
|
|
4
|
+
|
|
5
|
+
Your users file feature requests and bug reports, on your site or through
|
|
6
|
+
their own agents over MCP. You triage them into work items, a judge ranks and
|
|
7
|
+
groups them, and your own coding agent can pick up a planned item over MCP and
|
|
8
|
+
attach the pull request for you to approve.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Ruby 3.3+, Rails 8.1+
|
|
13
|
+
- Postgres with the pgvector extension
|
|
14
|
+
- `ruby_llm` 2.1+ (only called if you name a model; see [Models](#models))
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# Gemfile
|
|
20
|
+
gem "feature_machine"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
bundle install
|
|
25
|
+
bin/rails g feature_machine:install
|
|
26
|
+
bin/rails feature_machine:install:migrations
|
|
27
|
+
bin/rails db:migrate
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The generator writes `config/initializers/feature_machine.rb` and draws the
|
|
31
|
+
engine's routes into `config/routes.rb`:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
feature_machine_routes :web # /feature-requests, /bug-reports, /roadmap
|
|
35
|
+
namespace :admin do
|
|
36
|
+
feature_machine_routes :admin # /admin/triage, /admin/work_items
|
|
37
|
+
end
|
|
38
|
+
namespace :api, defaults: { format: :json } do
|
|
39
|
+
feature_machine_routes :api # /api/feature_requests, /api/bug_reports, /api/roadmap,
|
|
40
|
+
end # /api/work_items, /api/maintainer/feature_requests
|
|
41
|
+
feature_machine_routes :mcp # POST /mcp, for agents
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Then tell the engine three things about your app in the initializer:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
FeatureMachine.configure do |config|
|
|
48
|
+
# Who is signed in. With Rails' authentication generator:
|
|
49
|
+
config.current_user = ->(request) { Session.find_by(id: request.cookie_jar.signed[:session_id])&.user }
|
|
50
|
+
# Who may triage. The work-items migration adds users.admin, so the default works.
|
|
51
|
+
config.admin = ->(user) { user.admin? }
|
|
52
|
+
# Bearer token -> credential: any object answering `user` and `allows?(scope)`
|
|
53
|
+
# for "read", "write" and "maintainer". Without it, agents can't connect.
|
|
54
|
+
config.authenticate_api = ->(token) { ApiToken.authenticate(token) }
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Upgrading an app that drew the routes by hand
|
|
59
|
+
|
|
60
|
+
The route macro now draws everything the engine ships: bug reports, the
|
|
61
|
+
roadmap, triage, attachments, and their APIs. If your `config/routes.rb` still
|
|
62
|
+
draws any of those next to `feature_machine_routes`, delete your copies, or
|
|
63
|
+
Rails stops at boot with "Invalid route name, already in use".
|
|
64
|
+
|
|
65
|
+
The engine's controllers no longer inherit `Admin::BaseController` or
|
|
66
|
+
`Api::BaseController` on their own. If yours add filters, a layout or headers
|
|
67
|
+
the engine's pages should keep, point the engine at them:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
config.admin_parent_controller = "Admin::BaseController"
|
|
71
|
+
config.api_parent_controller = "Api::BaseController"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Your API base keeps its own `render_error`. The engine reads the signed-in API
|
|
75
|
+
user through its own `api_user`, so your `current_user` is left alone.
|
|
76
|
+
|
|
77
|
+
## Host contract
|
|
78
|
+
|
|
79
|
+
That is everything a fresh `rails new` app needs. The engine brings its own
|
|
80
|
+
admin and API base controllers, so the host doesn't need `current_user`,
|
|
81
|
+
`Admin::BaseController`, `Api::BaseController` or a `has_many` on its user
|
|
82
|
+
model.
|
|
83
|
+
|
|
84
|
+
- **Postgres with pgvector.** The migrations enable the `vector` extension.
|
|
85
|
+
- **A `users` table.** `feature_requests.user_id` references it, and the
|
|
86
|
+
work-items migration adds `users.admin`. Name another model with
|
|
87
|
+
`config.user_class`.
|
|
88
|
+
- **Pages inherit your `ApplicationController`**, so your layout and auth
|
|
89
|
+
filters apply. Signed-out visitors go to `new_session_path` when you have one
|
|
90
|
+
(`config.sign_in_path`) and get a 401 otherwise. To inherit something else,
|
|
91
|
+
set `config.parent_controller`, `config.admin_parent_controller` or
|
|
92
|
+
`config.api_parent_controller`. An API base of your own keeps its own
|
|
93
|
+
`render_error` and error handling.
|
|
94
|
+
- **The engine's pages use `cn-*` classes and ship plain default styles for
|
|
95
|
+
them**, inlined in a `@layer feature_machine` block, so a host without a
|
|
96
|
+
design system still renders cleanly and any `cn-*` rule of yours wins
|
|
97
|
+
without `!important`. Set `config.default_stylesheet = false` if your own
|
|
98
|
+
stylesheet covers them all. They show their own flash notices,
|
|
99
|
+
because a new app's layout doesn't; set `config.flash_in_pages = false` if
|
|
100
|
+
yours does.
|
|
101
|
+
- **Your own roadmap or bug board** replaces the engine's by pointing
|
|
102
|
+
`config.roadmap_path` (and friends) at it.
|
|
103
|
+
- **An MCP server of your own** can skip `feature_machine_routes :mcp` and add
|
|
104
|
+
`FeatureMachine::Mcp.for(credential)` to its tool list instead. A browser
|
|
105
|
+
session cookie is never an MCP credential; only bearer tokens are.
|
|
106
|
+
|
|
107
|
+
### Models
|
|
108
|
+
|
|
109
|
+
Embeddings, the spec drafter and the triage judge are each off until you name a
|
|
110
|
+
RubyLLM model (`config.embedding_model`, `config.drafter_model`,
|
|
111
|
+
`config.judgment_model`). If a model is named but its provider key is missing
|
|
112
|
+
or the call fails, the feature steps aside: requests are still filed, triage
|
|
113
|
+
shows unranked groups, and forming a work item writes a plain digest instead
|
|
114
|
+
of a spec.
|
|
115
|
+
|
|
116
|
+
The triage judge uses RubyLLM's structured judgments, which only some providers
|
|
117
|
+
implement: OpenAI, Ollama, and TypeSafe's Jev models in ruby_llm 2.1. Pick a
|
|
118
|
+
`judgment_model` one of them serves. A chat model named through OpenRouter
|
|
119
|
+
can't judge: triage logs a warning and stays unranked.
|
|
120
|
+
|
|
121
|
+
OpenRouter does serve Jev's judgment API, so an OpenRouter key is enough:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
RubyLLM.configure do |c|
|
|
125
|
+
c.typesafe_api_base = "https://openrouter.ai/api"
|
|
126
|
+
c.typesafe_api_key = ENV["OPENROUTER_API_KEY"]
|
|
127
|
+
end
|
|
128
|
+
FeatureMachine.config.judgment_model = "jev-latest"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
For a local
|
|
132
|
+
[Jev-compatible server](https://rubyllm.com/configuration-providers/#jev-compatible-apis),
|
|
133
|
+
point `typesafe_api_base` at it instead and set
|
|
134
|
+
`config.judgment_provider = :typesafe`, so the server's own model id resolves
|
|
135
|
+
without a registry entry. Embeddings and the drafter work on any provider RubyLLM
|
|
136
|
+
supports, including OpenRouter (set `config.drafter_provider = :openrouter`).
|
|
137
|
+
|
|
138
|
+
Requests filed before `embedding_model` was set never got an embedding, and
|
|
139
|
+
triage leaves them out of every group. Embed them once you switch it on:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
bin/rails feature_machine:embed_requests
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`bin/rails feature_machine:calibrate_judge` checks your `judgment_model`
|
|
146
|
+
against mutated cases: paraphrases mustn't move an answer, and changes that
|
|
147
|
+
alter the meaning must. It spends tokens, so it never runs in CI.
|
|
148
|
+
In November 2026, `jev-latest` held 18 of 18. Rerun it when you change models.
|
|
149
|
+
|
|
150
|
+
## Agents over MCP
|
|
151
|
+
|
|
152
|
+
`feature_machine_routes :mcp` serves `POST /mcp`. Agents authenticate with a
|
|
153
|
+
bearer token through `config.authenticate_api`; a browser session cookie is
|
|
154
|
+
never accepted there.
|
|
155
|
+
|
|
156
|
+
Any token with `read` (and `write` for filing) sees:
|
|
157
|
+
|
|
158
|
+
| Tool | Does |
|
|
159
|
+
| --- | --- |
|
|
160
|
+
| `list_feature_requests` | The user's own requests, with status and the maintainer's note |
|
|
161
|
+
| `request_feature` | File a request on the user's behalf |
|
|
162
|
+
| `list_bug_reports` | The user's own bug reports, with status and note |
|
|
163
|
+
| `report_bug` | File a bug on the user's behalf |
|
|
164
|
+
| `read_roadmap` | In-progress and planned work, then the changelog |
|
|
165
|
+
|
|
166
|
+
A `maintainer` token whose user also passes `config.admin` additionally sees
|
|
167
|
+
`list_work_items`, `get_work_item` and `update_work_item`. Those aren't
|
|
168
|
+
advertised to anyone else, and the engine re-checks `config.admin` on every
|
|
169
|
+
maintainer call rather than trusting the token's scope alone. An agent can
|
|
170
|
+
move an item through its statuses and attach a pull request, but it can't
|
|
171
|
+
edit the spec, approve drafts or decline work.
|
|
172
|
+
|
|
173
|
+
## Configuration reference
|
|
174
|
+
|
|
175
|
+
All set in `config/initializers/feature_machine.rb`. Lambdas are called per
|
|
176
|
+
request or per record; strings naming classes are constantized lazily, so the
|
|
177
|
+
initializer never autoloads app code.
|
|
178
|
+
|
|
179
|
+
| Setting | Default | What it does |
|
|
180
|
+
| --- | --- | --- |
|
|
181
|
+
| `current_user` | `->(request) { nil }` | The signed-in user for a request |
|
|
182
|
+
| `admin` | `user.admin?` if defined | May this user triage? |
|
|
183
|
+
| `authenticate_api` | `nil` | Bearer token to credential (`user`, `allows?(scope)`). `nil` = no API or MCP access |
|
|
184
|
+
| `session_auth` | `true` | Let a browser session act as a credential on the JSON API (never MCP) |
|
|
185
|
+
| `user_class` | `"User"` | The model `feature_requests.user_id` points at |
|
|
186
|
+
| `user_label` | `"user #<id>"` | How a requester is named on admin pages and in specs |
|
|
187
|
+
| `product_name` | `"this app"` | Used in copy and error messages |
|
|
188
|
+
| `product_blurb` | `product_name` | One line describing the product to the drafter |
|
|
189
|
+
| `token_help_url` | `nil` | Where scope errors tell users to get a token |
|
|
190
|
+
| `protected_resource_url` | `nil` | OAuth protected-resource metadata URL for 401 `WWW-Authenticate` |
|
|
191
|
+
| `embedding_model` | `nil` | RubyLLM embedding model. `nil` = no similarity grouping |
|
|
192
|
+
| `drafter_model`, `drafter_provider`, `drafter_fallbacks` | `nil`, `nil`, `[]` | Chat model that writes work-item specs. `nil` = plain digest |
|
|
193
|
+
| `judgment_model`, `judgment_provider` | `nil`, `nil` | Structured-judgment model for triage. `nil` = unranked |
|
|
194
|
+
| `on_status_change` | `nil` | `->(request) { ... }` after a request's status changes (mail the requester, etc.) |
|
|
195
|
+
| `parent_controller` | `"ApplicationController"` | Base for engine pages |
|
|
196
|
+
| `admin_parent_controller` | `parent_controller` | Base for engine admin pages |
|
|
197
|
+
| `api_parent_controller` | `"ActionController::API"` | Base for engine API controllers |
|
|
198
|
+
| `layout` | host's own | Layout for engine pages; `"feature_machine"` is a minimal standalone shell |
|
|
199
|
+
| `admin_nav_partial` | `nil` | Host partial rendered atop engine admin pages, e.g. `"admin/nav"` |
|
|
200
|
+
| `sign_in_path` | `new_session_path` if drawn | `->(controller) { ... }` where signed-out visitors go |
|
|
201
|
+
| `flash_in_pages` | `true` | Engine pages render their own flash notices |
|
|
202
|
+
| `default_stylesheet` | `true` | Engine pages inline default `cn-*` styles (in a CSS layer your rules override) |
|
|
203
|
+
| `roadmap_path`, `bug_reports_path`, `new_bug_report_path`, `admin_triage_path` | engine's pages | `->(view, ...) { ... }` to link your own pages instead; `nil` drops the link |
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/* feature_machine default styles for the cn-* classes its views use.
|
|
2
|
+
Rendered inline once per engine page unless config.default_stylesheet is
|
|
3
|
+
false. Everything sits in @layer feature_machine, and layered rules lose
|
|
4
|
+
to unlayered ones, so any host stylesheet that defines a cn-* class wins
|
|
5
|
+
without !important. Colors come from the shadcn-style custom properties
|
|
6
|
+
(--background, --foreground, --muted-foreground, --border, --primary,
|
|
7
|
+
--destructive, --radius) when the host has them, with neutral fallbacks. */
|
|
8
|
+
@layer feature_machine {
|
|
9
|
+
.cn-container { max-width: 48rem; margin: 0 auto; padding: 2rem 1rem; font-family: system-ui, sans-serif; color: var(--foreground, #18181b); line-height: 1.5; }
|
|
10
|
+
.cn-font-heading { font-weight: 600; letter-spacing: -0.01em; }
|
|
11
|
+
|
|
12
|
+
.cn-page-header { margin-bottom: 1.5rem; }
|
|
13
|
+
.cn-page-header-eyebrow { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted-foreground, #71717a); margin: 0; }
|
|
14
|
+
.cn-page-header-title { font-size: 1.75rem; margin: 0.25rem 0; }
|
|
15
|
+
.cn-page-header-lede { color: var(--muted-foreground, #71717a); margin: 0; }
|
|
16
|
+
|
|
17
|
+
.cn-button { display: inline-flex; align-items: center; gap: 0.375rem; border: 1px solid transparent; border-radius: var(--radius, 0.375rem); font: inherit; font-size: 0.875rem; font-weight: 500; text-decoration: none; cursor: pointer; }
|
|
18
|
+
.cn-button-size-default { padding: 0.5rem 1rem; }
|
|
19
|
+
.cn-button-size-sm { padding: 0.25rem 0.625rem; font-size: 0.8125rem; }
|
|
20
|
+
.cn-button-variant-default { background: var(--primary, #18181b); color: var(--primary-foreground, #fafafa); }
|
|
21
|
+
.cn-button-variant-outline { background: transparent; border-color: var(--border, #e4e4e7); color: inherit; }
|
|
22
|
+
.cn-button-variant-ghost { background: transparent; color: inherit; }
|
|
23
|
+
.cn-button-variant-ghost:hover, .cn-button-variant-outline:hover { background: var(--accent, #f4f4f5); }
|
|
24
|
+
.cn-button-variant-destructive { background: var(--destructive, #dc2626); color: #fff; }
|
|
25
|
+
|
|
26
|
+
.cn-field { display: flex; flex-direction: column; gap: 0.375rem; margin-bottom: 1rem; }
|
|
27
|
+
.cn-label, .cn-field-label { font-size: 0.875rem; font-weight: 500; }
|
|
28
|
+
.cn-field-description { font-size: 0.8125rem; color: var(--muted-foreground, #71717a); margin: 0; }
|
|
29
|
+
.cn-field-error { font-size: 0.8125rem; color: var(--destructive, #dc2626); margin: 0; }
|
|
30
|
+
.cn-input, .cn-textarea, .cn-native-select { font: inherit; padding: 0.5rem 0.75rem; border: 1px solid var(--input, var(--border, #e4e4e7)); border-radius: var(--radius, 0.375rem); background: var(--background, #fff); color: inherit; }
|
|
31
|
+
.cn-textarea { min-height: 6rem; resize: vertical; }
|
|
32
|
+
|
|
33
|
+
.cn-alert { border: 1px solid var(--border, #e4e4e7); border-radius: var(--radius, 0.375rem); padding: 0.75rem 1rem; margin-bottom: 1rem; }
|
|
34
|
+
.cn-alert-variant-destructive { border-color: var(--destructive, #dc2626); color: var(--destructive, #dc2626); }
|
|
35
|
+
.cn-alert-title { font-weight: 600; margin: 0; }
|
|
36
|
+
.cn-alert-description { margin: 0.25rem 0 0; }
|
|
37
|
+
.cn-flash, .feature-machine-flash { padding: 0.5rem 0.75rem; border-radius: var(--radius, 0.375rem); background: var(--muted, #f4f4f5); }
|
|
38
|
+
|
|
39
|
+
.cn-empty { text-align: center; padding: 3rem 1rem; border: 1px dashed var(--border, #e4e4e7); border-radius: var(--radius, 0.375rem); }
|
|
40
|
+
.cn-empty-header, .cn-empty-content { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
|
|
41
|
+
.cn-empty-media-icon { width: 2.5rem; height: 2.5rem; color: var(--muted-foreground, #71717a); }
|
|
42
|
+
.cn-empty-title { font-weight: 600; margin: 0; }
|
|
43
|
+
.cn-empty-description { color: var(--muted-foreground, #71717a); margin: 0; }
|
|
44
|
+
|
|
45
|
+
.cn-entry-list, .cn-relation-list, .cn-spec-evidence-list { list-style: none; margin: 0; padding: 0; }
|
|
46
|
+
.cn-entry-section { margin: 1.5rem 0; }
|
|
47
|
+
.cn-entry-section-title { font-size: 1.125rem; margin: 0 0 0.5rem; }
|
|
48
|
+
.cn-nr-entry { border-bottom: 1px solid var(--border, #e4e4e7); padding: 1rem 0; }
|
|
49
|
+
.cn-nr-entry-title-row { display: flex; align-items: baseline; justify-content: space-between; gap: 0.75rem; }
|
|
50
|
+
.cn-nr-entry-title { font-weight: 600; margin: 0; }
|
|
51
|
+
.cn-nr-entry-summary { margin: 0.25rem 0; }
|
|
52
|
+
.cn-nr-entry-meta { display: flex; flex-wrap: wrap; gap: 0.375rem; font-size: 0.8125rem; color: var(--muted-foreground, #71717a); }
|
|
53
|
+
.cn-nr-entry-status { font-size: 0.75rem; padding: 0.125rem 0.5rem; border-radius: 999px; background: var(--muted, #f4f4f5); }
|
|
54
|
+
.cn-nr-entry-actions, .cn-review-panel-actions, .cn-triage-bucket-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem; }
|
|
55
|
+
|
|
56
|
+
.cn-triage-bucket, .cn-review-panel { border: 1px solid var(--border, #e4e4e7); border-radius: var(--radius, 0.375rem); padding: 1rem; margin-bottom: 1rem; }
|
|
57
|
+
.cn-triage-bucket-header { display: flex; justify-content: space-between; gap: 0.75rem; }
|
|
58
|
+
.cn-triage-bucket-title { font-weight: 600; margin: 0; }
|
|
59
|
+
.cn-triage-bucket-score, .cn-triage-bucket-demand, .cn-triage-summary, .cn-review-panel-lede { font-size: 0.8125rem; color: var(--muted-foreground, #71717a); }
|
|
60
|
+
.cn-triage-bucket-flag { font-size: 0.75rem; padding: 0.125rem 0.5rem; border-radius: 999px; background: var(--muted, #f4f4f5); }
|
|
61
|
+
.cn-triage-bucket-flag-warn { background: #fef3c7; color: #92400e; }
|
|
62
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# The engine's API controllers authenticate from the Rack env, never from the
|
|
2
|
+
# host's token model: FeatureMachine::Auth::Bearer (or the host's own
|
|
3
|
+
# middleware) has already put a credential at FeatureMachine::ENV_KEY. The
|
|
4
|
+
# prose in 401/403 bodies comes from FeatureMachine.config.
|
|
5
|
+
#
|
|
6
|
+
# The parent is config.api_parent_controller (ActionController::API by
|
|
7
|
+
# default). A host base that already renders errors its own way (render_error,
|
|
8
|
+
# not-found and missing-parameter handling) keeps it; the fallbacks below only
|
|
9
|
+
# fill in what the parent lacks, so a fresh host's API works with no base of
|
|
10
|
+
# its own.
|
|
11
|
+
class Api::FeatureMachine::BaseController < FeatureMachine.config.api_parent_controller.constantize
|
|
12
|
+
inherited_private = ->(name) { private_method_defined?(name) || method_defined?(name) }
|
|
13
|
+
|
|
14
|
+
unless inherited_private.(:render_error)
|
|
15
|
+
rescue_from ActiveRecord::RecordNotFound do
|
|
16
|
+
render_error(status: :not_found, error: "Not found", code: "not_found",
|
|
17
|
+
resolution: "Verify the resource id belongs to the authenticated user.")
|
|
18
|
+
end
|
|
19
|
+
rescue_from ActionController::ParameterMissing do |exception|
|
|
20
|
+
render_error(status: :bad_request, error: exception.message, code: "parameter_missing",
|
|
21
|
+
resolution: "Include the required parameter in the request body.")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Engine-owned names: a host base's own current_user or current_credential
|
|
28
|
+
# is left alone, and never stands in for the engine's.
|
|
29
|
+
attr_reader :api_user, :api_credential
|
|
30
|
+
|
|
31
|
+
unless inherited_private.(:render_error)
|
|
32
|
+
def render_error(status:, error:, code:, resolution: nil, errors: nil)
|
|
33
|
+
body = { error: error, code: code }
|
|
34
|
+
body[:resolution] = resolution if resolution
|
|
35
|
+
body[:errors] = errors if errors
|
|
36
|
+
render json: body, status: status
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Reads need `read`, writes need `write`; the work-item hand-off overrides
|
|
41
|
+
# it with `maintainer`.
|
|
42
|
+
unless inherited_private.(:required_scope)
|
|
43
|
+
def required_scope
|
|
44
|
+
request.get? || request.head? ? ::FeatureMachine::READ : ::FeatureMachine::WRITE
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def authenticate_user_with_token
|
|
49
|
+
@api_credential = request.env[::FeatureMachine::ENV_KEY]
|
|
50
|
+
@api_user = @api_credential&.user
|
|
51
|
+
return render_unauthenticated unless @api_user
|
|
52
|
+
return render_session_write_forbidden if session_credential? && !(request.get? || request.head?)
|
|
53
|
+
|
|
54
|
+
enforce_scope
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The engine's rule, not the credential's: for `maintainer`, the host's admin
|
|
58
|
+
# predicate must agree (FeatureMachine::Auth.allows?).
|
|
59
|
+
def enforce_scope
|
|
60
|
+
return if ::FeatureMachine::Auth.allows?(api_credential, required_scope)
|
|
61
|
+
|
|
62
|
+
response.headers["WWW-Authenticate"] =
|
|
63
|
+
%(Bearer realm="#{feature_machine_config.product_name}", error="insufficient_scope", scope="#{required_scope}")
|
|
64
|
+
render_error(
|
|
65
|
+
status: :forbidden,
|
|
66
|
+
error: "Token lacks the #{required_scope} scope",
|
|
67
|
+
code: "insufficient_scope",
|
|
68
|
+
resolution: "This endpoint requires a token with the `#{required_scope}` scope.#{create_token_hint}"
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def render_unauthenticated
|
|
73
|
+
config = feature_machine_config
|
|
74
|
+
response.headers["WWW-Authenticate"] = [
|
|
75
|
+
%(Bearer realm="#{config.product_name}", error="invalid_token", error_description="Missing or invalid bearer token."),
|
|
76
|
+
(%(resource="#{config.protected_resource_url}") if config.protected_resource_url)
|
|
77
|
+
].compact.join(", ")
|
|
78
|
+
|
|
79
|
+
resolution = [
|
|
80
|
+
"Send Authorization: Bearer <token>.",
|
|
81
|
+
("Humans create tokens at #{config.token_help_url}." if config.token_help_url),
|
|
82
|
+
("See #{config.protected_resource_url} for protected-resource metadata." if config.protected_resource_url)
|
|
83
|
+
].compact.join(" ")
|
|
84
|
+
render_error(status: :unauthorized, error: "Unauthorized", code: "unauthorized", resolution: resolution)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A browser session may read through the API; writes take a bearer token,
|
|
88
|
+
# so a cross-site form can never act on a signed-in user's behalf here.
|
|
89
|
+
def render_session_write_forbidden
|
|
90
|
+
render_error(
|
|
91
|
+
status: :forbidden,
|
|
92
|
+
error: "Writes need a bearer token",
|
|
93
|
+
code: "session_write_forbidden",
|
|
94
|
+
resolution: "This request is authenticated by your browser session, which the API accepts for reads only. Send Authorization: Bearer <token> to write.#{create_token_hint}"
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def session_credential?
|
|
99
|
+
api_credential.respond_to?(:session?) && api_credential.session?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def create_token_hint
|
|
103
|
+
feature_machine_config.token_help_url ? " Create one at #{feature_machine_config.token_help_url}." : ""
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def feature_machine_config
|
|
107
|
+
::FeatureMachine.config
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Agents report bugs for the user who handed them a token, and follow that
|
|
2
|
+
# user's own reports.
|
|
3
|
+
class Api::FeatureMachine::BugReportsController < Api::FeatureMachine::BaseController
|
|
4
|
+
before_action :authenticate_user_with_token
|
|
5
|
+
|
|
6
|
+
def index
|
|
7
|
+
@feature_requests = ::FeatureMachine::Request.owned_by(api_user).bug.includes(:user, :work_item).newest_first
|
|
8
|
+
render "api/feature_machine/feature_requests/index"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def create
|
|
12
|
+
@feature_request = ::FeatureMachine::Request.owned_by(api_user).bug.build(bug_report_params)
|
|
13
|
+
|
|
14
|
+
if @feature_request.save
|
|
15
|
+
render "api/feature_machine/feature_requests/show", status: :created
|
|
16
|
+
else
|
|
17
|
+
render_error(
|
|
18
|
+
status: :unprocessable_entity,
|
|
19
|
+
error: "Validation failed",
|
|
20
|
+
code: "validation_failed",
|
|
21
|
+
errors: @feature_request.errors.full_messages,
|
|
22
|
+
resolution: "Title is required (8-120 characters). Description is optional, up to 2000 characters."
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def bug_report_params
|
|
30
|
+
params.require(:bug_report).permit(:title, :description)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Agents file feature requests for the user who handed them a token, and
|
|
2
|
+
# follow that user's own requests. No one reads anyone else's.
|
|
3
|
+
class Api::FeatureMachine::FeatureRequestsController < Api::FeatureMachine::BaseController
|
|
4
|
+
before_action :authenticate_user_with_token
|
|
5
|
+
|
|
6
|
+
def index
|
|
7
|
+
@feature_requests = ::FeatureMachine::Request.owned_by(api_user).feature.includes(:user, :work_item).newest_first
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
@feature_request = ::FeatureMachine::Request.owned_by(api_user).feature.build(feature_request_params)
|
|
12
|
+
|
|
13
|
+
if @feature_request.save
|
|
14
|
+
render :show, status: :created
|
|
15
|
+
else
|
|
16
|
+
render_error(
|
|
17
|
+
status: :unprocessable_entity,
|
|
18
|
+
error: "Validation failed",
|
|
19
|
+
code: "validation_failed",
|
|
20
|
+
errors: @feature_request.errors.full_messages,
|
|
21
|
+
resolution: "Title is required (8-120 characters). Description is optional, up to 2000 characters."
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def feature_request_params
|
|
29
|
+
params.require(:feature_request).permit(:title, :description)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# The triage inbox as a maintainer's agent sees it: every user's requests and
|
|
2
|
+
# bug reports, so an agent can pull them into whatever tracker the maintainer
|
|
3
|
+
# triages from. Read-only on purpose. Bucketing a request into a work item,
|
|
4
|
+
# or declining it, stays with a human in /admin.
|
|
5
|
+
class Api::FeatureMachine::Maintainer::FeatureRequestsController < Api::FeatureMachine::BaseController
|
|
6
|
+
before_action :authenticate_user_with_token
|
|
7
|
+
|
|
8
|
+
# `untriaged` is the inbox: requested, not yet bucketed into a work item.
|
|
9
|
+
FILTERS = (%w[untriaged] + ::FeatureMachine::Request.statuses.keys).freeze
|
|
10
|
+
|
|
11
|
+
def index
|
|
12
|
+
filter = params[:status].presence || "untriaged"
|
|
13
|
+
unless FILTERS.include?(filter)
|
|
14
|
+
return render_error(status: :unprocessable_entity, error: "Unknown status", code: "validation_failed",
|
|
15
|
+
resolution: "status must be one of: #{FILTERS.join(', ')}.")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
scope = filter == "untriaged" ? ::FeatureMachine::Request.untriaged : ::FeatureMachine::Request.where(status: filter)
|
|
19
|
+
@feature_requests = scope.includes(:user, :work_item).oldest_first
|
|
20
|
+
@feature_requests = @feature_requests.where(kind: params[:kind]) if ::FeatureMachine::Request.kinds.key?(params[:kind].to_s)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def required_scope
|
|
26
|
+
::FeatureMachine::MAINTAINER
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# The roadmap and changelog for a signed-up user's agent. Any `read` token
|
|
2
|
+
# works; there is no anonymous version.
|
|
3
|
+
class Api::FeatureMachine::RoadmapsController < Api::FeatureMachine::BaseController
|
|
4
|
+
before_action :authenticate_user_with_token
|
|
5
|
+
|
|
6
|
+
def show
|
|
7
|
+
@roadmap = ::FeatureMachine::Roadmap.new
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# The hand-off surface for a maintainer's agents: read approved work items
|
|
2
|
+
# with their full spec, pick one up, and report progress. Drafts and declined
|
|
3
|
+
# items are invisible here, and nothing about triage (forming, speccing,
|
|
4
|
+
# approving, declining) is reachable: that stays with a human in /admin.
|
|
5
|
+
class Api::FeatureMachine::WorkItemsController < Api::FeatureMachine::BaseController
|
|
6
|
+
before_action :authenticate_user_with_token
|
|
7
|
+
before_action :set_work_item, only: %i[show update]
|
|
8
|
+
|
|
9
|
+
rescue_from ::FeatureMachine::WorkItem::InvalidTransition, with: :render_invalid_transition
|
|
10
|
+
|
|
11
|
+
STATUSES = ::FeatureMachine::WorkItem::MAINTAINER_STATUSES
|
|
12
|
+
|
|
13
|
+
# Defaults to the pickup queue: planned work, oldest first.
|
|
14
|
+
def index
|
|
15
|
+
status = params[:status].presence || "planned"
|
|
16
|
+
unless STATUSES.include?(status)
|
|
17
|
+
return render_error(status: :unprocessable_entity, error: "Unknown status", code: "validation_failed",
|
|
18
|
+
resolution: "status must be one of: #{STATUSES.join(', ')}.")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@work_items = ::FeatureMachine::WorkItem.where(status: status).includes(feature_requests: :user).order(:created_at)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def show
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Only `status`, `progress_note` and `pull_request_url`. The spec is the
|
|
28
|
+
# human's; an agent that disagrees with it says so in the progress note.
|
|
29
|
+
# Sending the status an item already has is a conflict, not a no-op: that is
|
|
30
|
+
# what stops a second agent picking up work someone already has. To add a
|
|
31
|
+
# note or a pull request, send only that.
|
|
32
|
+
def update
|
|
33
|
+
attributes = params.require(:work_item).permit(:status, :progress_note, :pull_request_url)
|
|
34
|
+
changes = attributes.slice(:progress_note, :pull_request_url)
|
|
35
|
+
attributes[:status].present? ? @work_item.agent_transition!(attributes[:status], changes) : @work_item.update!(changes)
|
|
36
|
+
render :show
|
|
37
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
38
|
+
render_error(status: :unprocessable_entity, error: "Validation failed", code: "validation_failed", errors: e.record.errors.full_messages,
|
|
39
|
+
resolution: "Asking for review needs `pull_request_url`: a GitHub pull request URL.")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def required_scope
|
|
45
|
+
::FeatureMachine::MAINTAINER
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def set_work_item
|
|
49
|
+
@work_item = ::FeatureMachine::WorkItem.where(status: STATUSES).find(params[:id])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def render_invalid_transition(exception)
|
|
53
|
+
render_error(status: :conflict, error: exception.message, code: "invalid_transition",
|
|
54
|
+
resolution: "Re-read the work item: another agent may have picked it up, or it may already be shipped.")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module FeatureMachine
|
|
2
|
+
# Who is asking, by the host's own answer (config.current_user), never by a
|
|
3
|
+
# current_user method the host may not define. Pages need a signed-in user:
|
|
4
|
+
# when the host's own filters let a signed-out request through, it goes to
|
|
5
|
+
# config.sign_in_path, or gets a 401 if the host has none.
|
|
6
|
+
module CurrentUser
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
helper_method :feature_machine_user
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def feature_machine_user
|
|
16
|
+
return @feature_machine_user if defined?(@feature_machine_user)
|
|
17
|
+
|
|
18
|
+
@feature_machine_user = FeatureMachine.config.user_for(request)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def require_feature_machine_user
|
|
22
|
+
return if feature_machine_user
|
|
23
|
+
|
|
24
|
+
path = FeatureMachine.config.sign_in_path&.call(self)
|
|
25
|
+
path ? redirect_to(path) : head(:unauthorized)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module FeatureMachine
|
|
2
|
+
# Engine pages render through FeatureMachine.config.layout. Rails treats a
|
|
3
|
+
# nil from the proc as "no opinion", so the host's inherited layout applies
|
|
4
|
+
# and engine pages look like the rest of the host's.
|
|
5
|
+
module EngineLayout
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
layout -> { FeatureMachine.config.layout }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Attaches untriaged requests (typically a whole triage bucket) to an
|
|
2
|
+
# existing work item.
|
|
3
|
+
class FeatureMachine::Admin::AttachmentsController < FeatureMachine::Admin::BaseController
|
|
4
|
+
def create
|
|
5
|
+
return redirect_to(admin_triage_path, alert: "Choose a work item to add those requests to.") if params[:work_item_id].blank?
|
|
6
|
+
|
|
7
|
+
# Only items still in play: attaching to a shipped or declined one would
|
|
8
|
+
# stamp the requests with that outcome straight away.
|
|
9
|
+
work_item = ::FeatureMachine::WorkItem.active.find(params[:work_item_id])
|
|
10
|
+
requests = ::FeatureMachine::Request.untriaged.where(id: params[:request_ids]).to_a
|
|
11
|
+
::FeatureMachine::Request.transaction do
|
|
12
|
+
requests.each { |request| request.update!(work_item: work_item) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
redirect_to admin_triage_path, notice: "#{helpers.pluralize(requests.size, 'request')} attached to \"#{work_item.title}\"."
|
|
16
|
+
end
|
|
17
|
+
end
|