Skip to content

hackbot: collect feedback on the comments agents post - #6451

Draft
crythms wants to merge 21 commits into
mozilla:masterfrom
crythms:triage-feedback
Draft

hackbot: collect feedback on the comments agents post#6451
crythms wants to merge 21 commits into
mozilla:masterfrom
crythms:triage-feedback

Conversation

@crythms

@crythms crythms commented Aug 2, 2026

Copy link
Copy Markdown
Member

We have no good way to tell whether frontend-triage's Bugzilla comments are good enough. This
adds two ways to say so: a rating control on the run page, and a 👍/👎 link in
the posted comment.

The run-page one works before Apply, so a reviewer who decides not to post a
comment still records why. The Bugzilla one needs no login, since Bugzilla is
public and many of the people reading these comments have no Mozilla account.
Thumbs-down offers checkboxes matching the agent's own output fields
(root_cause, proposed_fix, …), so a rejection aggregates per-field.

Opt-in per agent: set feedback_link on an AgentSpec and both surfaces
switch on together. Only frontend-triage has it.

all ratings add rating feedback list

Design choices

Public ratings are anonymous; ratings made in Hackbot carry the reviewer's
email.
Bugzilla auth delegation would attribute the public ones too, but it
returns an API key that authenticates as that user for any Bugzilla call, which
I didn't want this service holding. rater_kind keeps the two kinds
distinguishable in the data.

The public page assumes automated clients will follow the link. Mail
gateways and chat clients fetch links to scan or preview them, so a GET never
writes — ?v= only pre-selects a thumb — and the write requires a nonce minted
when the page renders. Anonymous ratings are capped per run, and the page is
marked noindex, nofollow via a meta tag.

Nothing reads the ratings back yet. This PR only collects them. How they
feed into improving the agent depends on what people actually write, which we
don't know until some of it exists.

Deploying, in order

  1. Alembic d7e2f9a4c1b3 first — the endpoints need the table. Additive
    only. Please check alembic current shows f3c8a1d5b2e7: the revision was
    amended in place on the assumption it had never run.

  2. Deploy. Safe alone — without the env vars below no links are emitted, and
    the run-page control still works, so you can shake it down before anything
    is public.

  3. Set on hackbot-api, last, since this starts putting links into permanent
    public comments:

    FEEDBACK_TOKEN_SECRET      # openssl rand -base64 32, via Secret Manager
    FEEDBACK_PUBLIC_BASE_URL   # https://hackbot.moz.tools
    

    The secret must never rotate: tokens are HMACs over run ids, so a new key
    would 404 every link already published.

crythms added 21 commits August 1, 2026 18:50
The frontend-triage agent posts root-cause analyses to Bugzilla, but there
was no way to tell whether they were any good. The people best placed to
judge — the reporter, the module owner who'd take the patch — are often not
Mozilla employees, so the rating surface has to be reachable without a
Mozilla account.

Comments from opted-in agents now carry a signed link to a public page in
hackbot-ui, exempted from the SSO middleware. Ratings land in a new
run_feedback table alongside per-field labels keyed to the agent's own
structured output (root_cause, proposed_fix, target_files, confidence,
actionable), so a thumbs-down aggregates into something actionable rather
than prose.

The link is appended at apply time rather than where the agent records the
comment: only comments that actually reach Bugzilla should advertise a
feedback URL, and the signing secret belongs to hackbot-api rather than the
agent container.

Bot resistance is load-bearing here, not a refinement. The URL lands in a
public, heavily-crawled Bugzilla page and in bugmail to every CC'd account,
where corporate mail scanners pre-fetch every link they see. So a GET never
writes — the ?v= param only pre-selects a thumb — and the write requires a
nonce minted when the page renders, which a client that never rendered it
cannot produce. A per-run cap and partial unique indexes bound what's left.

Feedback is off by default per agent and enabled only for frontend-triage;
unset secret or base URL omits the footer entirely rather than posting a
broken link.
Asking a passer-by on a Bugzilla page to rewrite a technical root-cause
analysis was always the least likely thing in the schema to be used; a
rater who has a correction will put it in the comment. Removing it before
the migration runs anywhere.
Sign-in would have meant holding a live Bugzilla API key that can act as
the user, plus a privacy review, in exchange for weighting and dedupe that
only pay off at volumes this is unlikely to see soon. Not building it, so
the columns go too. Revisitable later as one small migration.

rater_kind stays: it's written on every row and is what separates human
votes from anything that gets past the nonce.
Cuts 41 cases to 29. Removed: a second HMAC-mismatch case dressed as a
different secret; is_enabled's base-url clause, already covered
behaviourally by the footer tests; two nonce cases at the route layer that
only re-prove what the links tests establish; an unknown-agent case that
can't happen since run.agent comes from the registry. Trimmed parametrize
lists that repeated one branch with different values.

Nothing that covers a distinct branch or a stated guarantee was removed.
Three changes that belong together.

The public pages move from /feedback to /rate. The SSO exemption in
middleware.ts is a prefix match, so a page added at /feedback would have
been silently public — putting the public surface in its own namespace
means everything under /feedback stays guarded by the default matcher
instead of relying on a narrower pattern. It also shortens the URL that
lands in Bugzilla.

Rater dedupe now keys on a per-browser cookie, falling back to the IP and
user agent hash. That fallback alone was unsafe: two people behind one
office or VPN egress IP on the same Firefox build hashed identically, and
because the write is an upsert the second would silently overwrite the
first. Losing a module owner's verdict to a colleague's later one is
exactly the signal this is meant to collect.

A cross-run review page at /feedback lists every rating with its comment,
filterable by agent, backed by a new GET /feedback. Run detail gains a
tally linking into it. Only agents with feedback links enabled can
accumulate ratings, so an empty result is also how a non-participating
agent renders — no flag needs plumbing to the UI.
The SSO-gated pages need a Google session, which makes them impossible to
open against sample data without real OAuth credentials. DEV_AUTH_EMAIL
stands in for one: honoured by devUser() in lib/session.ts and by an
inlined copy of the same check in middleware.ts, which cannot import that
module since it is server-only and middleware runs on the edge runtime.

Both are guarded on NODE_ENV, so the branch is dead code in any production
build. Verified by removing the variable: /feedback redirects to /login,
/api/feedback returns 401, and only /rate/* stays reachable.

The variable itself belongs in .env.local, which is gitignored, so nothing
that activates this is committed.
The global input rule sets width:100%, which a checkbox obeys — it took
the full row and pushed its label to the far edge, staggered per row. Both
checkboxes in the app were affected; the one in TriggerForm only shows for
build-repair, so it had gone unnoticed. Fixed at the global rule rather
than in feedback scope, so the next checkbox added doesn't hit it.

The rating page also showed the comment's trailing "add a needinfo"
footer, which is redundant and faintly contradictory on a page whose whole
purpose is to collect that correction. Trimmed from the preview only; what
Bugzilla receives is unchanged.
The /feedback -> /rate move updated lib/hackbot.ts and the route
directories but missed the client-side fetch in FeedbackForm, so Submit
posted to /api/feedback/:token, which no longer exists. It 404'd every
time.

Two things hid it. The endpoint tests exercised /api/rate directly rather
than the URL the component builds, and on a 404 res.json() threw on the
HTML error page before the res.ok check, so the user saw a JSON syntax
error rendered as small muted text — a hard failure that read as the
button doing nothing. Errors now use the same error-banner the rest of the
app uses, and a non-JSON body degrades to the status line.
The heading and preamble were rendered by the page, outside the form, so
after submitting they stayed on screen — leaving "Was this analysis
useful?" and an invitation to rate above a note saying the rating was
already recorded. Moving them into FeedbackForm puts the entire panel
under the component's own submitted state, so success shows the
confirmation and nothing else, at 18px semibold.
Whether we recognised a repeat rater is bookkeeping, not something worth
telling them about — and "updated" invites the question of what we know
about them. Both paths now answer identically, which also collapses the
write to a single return.

Dedupe is unchanged: a repeat from the same browser still replaces the
earlier verdict rather than stacking a row. The test now asserts that
structurally, via the rollback and update counts, rather than by reading
the message.
The list read as a stream, which is the wrong shape for scanning a set of
structured records. It is now a table — rating, agent, bug, run, what was
wrong, comment, when — reusing the runs table's styling, with dimensions
as tags rather than a joined string.

Two filters: clickable thumb toggles carrying live counts, and an agent
select. Both apply client-side against the full result set, which also
fixes the agent list: it was derived from the rows on screen, so it only
appeared with two or more agents and would have emptied itself the moment
you used it.
A comment is free prose of unpredictable length; a column wide enough for
it starves every other field, and one narrow enough to fit alongside them
cannot show it. Same trade-off run errors have, so it takes the same
shape: the record's own fields stay on one line, the comment spans the
table beneath it with a left rule tying the two together.

Unlike the error row this is not line-clamped. An error preview is
truncated because a stack trace could fill the page and the full text is
one click away; a rater comment is the thing you opened the page to read,
and the write path already caps its length.
The bug is reachable in one hop through the run, so the column was paying
for itself twice; removing it gives "What was wrong" 44% of the width.

Pagination follows RecentRuns exactly — 50 a page, a Load more button,
filters held in the URL and applied server-side so they compose with
paging. That also settles where the agent options come from: AGENT_NAMES
in lib/agents.ts, the same source the runs filter uses. Deriving them from
the loaded rows would have gone wrong again as soon as a page boundary hid
an agent.

Adds a rating filter to GET /feedback, which the thumb toggles now drive.
Two things came from copying RecentRuns too literally.

Its agent filter is built from AGENT_NAMES, the whole registry, which is
right there and wrong here: feedback links are opt-in per agent, so most
of that list can never appear on this page. The options now come from the
agents that have actually been rated.

The thumb toggles also lost their counts when paging arrived, because
counting loaded rows would only ever describe one page. A new
GET /feedback/stats returns totals broken down by agent in one query,
which drives both the counts and the option list. It deliberately takes
neither an agent nor a rating parameter — narrowing by either would
collapse the list it exists to populate, which is how the client-side
version of this filter managed to erase itself.

Counts follow the agent filter, so each number is how many rows clicking
that thumb would show.
Filters on the dimension labels via JSONB containment, so it composes with
the agent, rating and run filters already there.

The options are the full fixed vocabulary rather than only labels that
have been used — the opposite of the agent filter, and for a reason: an
agent without a feedback link can never appear, whereas any dimension is
one rating away, so an empty result is an answer rather than a dead end.

Tested by compiling the statement and asserting the containment clause is
present, since the fake DB in this suite never executes SQL — a filter
that silently failed to apply would otherwise read as "no rows match".
Comments: 126 lines to 109. Cut docstrings that restated their own code
(is_enabled, verify_token), the second half of anon_id's, and the third
copy of the /rate-vs-/feedback namespace rule — it now lives in
middleware.ts, where the risk is, with pointers from the two callers.
Dropped a couple of asides that narrated how the code got here rather than
what it does.

Tests: 40 to 35. Removed one that duplicated an assertion already made in
the test above it, one asserting a filter is absent when not requested,
one covering dict.get returning None, and one proving at the route layer
what a unit test already proves about anon_id. Folded a stats assertion
into its neighbour rather than losing it.
Reverts the DEV_AUTH_EMAIL escape hatch now that the UI review is done.
It was provably inert in production — verified by running a production
build with the variable set, where /feedback still redirected to /login
and /api/feedback still returned 401 — but nothing that exists only to
serve a local harness should ship, and an auth bypass is the last thing
worth asking a reviewer to reason about.

lib/session.ts is now identical to master; middleware.ts differs only by
the /rate exemption the public rating pages need.
The Actions preview rendered params.text, the comment as the agent recorded
it, while the rating footer is appended at apply time and never persisted.
So the preview never matched what Bugzilla received, and someone who
triggered the run had no way to rate their own result from Hackbot — they
had to open the bug.

RunActionDoc now carries posted_text: the exact string sent to Bugzilla,
derived on read via the same helper the applier uses. Nothing is stored, so
a re-apply still cannot stack a second copy of the link.

Only populated once the action is applied. The rating page rejects a
comment that was never posted, so a link offered before Apply would 404 —
before that the preview stays the agent's text, which is what there is to
review at that point.
The public rating link only works once a comment reaches Bugzilla, which
misses the judgment that matters most: a reviewer reading an analysis in
Hackbot, deciding it is wrong, and declining to post it at all. That is a
clearer signal than anything a drive-by rater leaves on a live bug.

POST /runs/{run_id}/feedback backs a rating control on the run page. It
differs from the public route in one flag on the shared lookup —
applied_only=False — so a comment that was only ever recorded can still be
judged. The public path keeps the stricter guard: an outsider can only rate
what Bugzilla actually received.

Internal ratings are attributed. The email comes from the session via
X-On-Behalf-Of, the convention requested_by already uses, and never from
the request body, so a client cannot rate as someone else. Dedupe moves to
a second partial index on (run_id, rater_id), and RaterKind becomes
anonymous | mozilla — "bugzilla" was dead once sign-in was dropped, and the
distinction worth keeping is reviewer-before-posting versus public-after.

Both surfaces are gated on one registry flag via rating_enabled(), so an
agent opts in by setting feedback_link on its AgentSpec and nothing else.
Only frontend-triage has. Actions carry can_rate so the control is hidden
for agents that have not, before Apply as well as after.

The preview keeps showing the agent's text rather than the posted comment.
Rendering the Bugzilla footer here would offer a second, anonymous rating
path beside the attributed one, and the two do not dedupe against each
other. posted_text stays on the API as the record of what was sent.

The migration is amended in place rather than stacked, since it has not
been applied anywhere yet.
Audits the comments added on this branch the same way the PR description
was audited: for claims stated as fact that were inference.

The prevalence claims go. "Bugmail reaches every CC'd account and corporate
mail scanners pre-fetch every link they see" was a threat model written as
an observation — I never checked that Mozilla bugmail passes through such a
gateway, nor that it is every link. It now says the design assumes automated
clients follow the link, which is the part that justifies the nonce.
Likewise "most recorded comments are never applied" becomes the fact behind
it: applying is a separate, currently manual step.

One comment was simply wrong. The thumb counts were documented as "always
how many rows clicking it would show", but stats take no dimension
parameter, so with a dimension selected the thumb reads 48 where the rows
are 6. The comment now says what the code does.

Also drops rationale duplicated between the TypeScript mirrors and the
pydantic models they mirror, and shortens the rest.
38 cases, down from 41. The applied-only contrast already asserts the 404
that a separate test re-made one call deeper. The stats breakdown covers
the "only rated agents" claim by construction, so giving one agent a single
rating folds the zero-fill case in with it. And posted_text's presence and
absence are two arms of one `if`, so they read better as one test.

No branch or stated guarantee lost coverage.
@crythms
crythms requested review from a team and msujaws and removed request for msujaws August 2, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant