feat(pulls,ai,automations,mcp): add reviewer notes and draft review controls
Brought to you by:
thebguy
Originally created by: theBGuy
Originally owned by: theBGuy
Give authors a way to record deliberate implementation decisions before review so AI reviewers can use that context and avoid re-flagging accepted tradeoffs. Reviewer notes can be deposited per branch through MCP or entered during PR creation, while draft PRs can now defer their first automated review until they are marked ready.
src-tauri/src/review_notes.rs and src/lib/review-notes/store.ts, including atomic writes, app-data mirroring, reload handling, and serialized updates.set_review_notes MCP tool in src-tauri/src/mcp_server/write_local.rs for agents and other write-enabled MCP clients to create, update, or clear local notes after validating the branch.ReviewerNotesField in src/features/pulls/ReviewerNotesField.tsx, which pre-fills notes for the selected head branch without overwriting user edits and allows authors to clear deposited notes.src/features/pulls/CreatePrDialog.tsx and src/features/pulls/CreateLocalPrDialog.tsx; remote notes are posted as the first PR comment, local notes are stored as the first local conversation comment, and consumed deposits are removed after creation.src/features/pulls/useGeneratePrDescription.ts, src/lib/ai/types.ts, and src/lib/ai/prompt.ts so generated descriptions can reflect reviewer notes and review prompts treat them as author-provided context.src/lib/ai/notes-context.ts and integrates it with src/lib/automations/runner.ts so later remote reviews can recover notes from the first PR comment.src/App.tsx and invalidates the related queries.reviewDraftPrs setting, defaulting to false, in src/lib/settings/api.ts.src/features/automations/AutomationsSection.tsx and src/features/settings/SettingsScreen.tsx.src/features/pulls/CreatePrDialog.tsx.src/lib/automations/sync.ts, src/lib/automations/useBackgroundPrSync.ts, and src/features/repository/usePrNotifications.ts so drafts remain deferred unless the setting is enabled.README.md, src/features/help/content.ts, and changelog.d/added-reviewer-notes.md.site/src/data/capabilities.ts.src/lib/analytics/track.ts..gitignore.
Originally posted by: theBGuy
ποΈ Notes for reviewers
Deliberate calls for this PR, recorded before review:
review-notes.jsonkeys repo identity (git-common-dir) at the TOP level with the branch map as the value, mirroringlocal-prs.json. Both the Rust core (review_notes.rs) and the TS mirror (review-notes/store.ts) omit theidentityKeyFor/consolidate fold machinery on purpose: this store is identity-keyed from day one, there are no legacy path keys to fold. Module docs on both sides say so β don't suggest adding it.notes-context.tsline-scans for the ASCII anchor**Notes for reviewers**rather thanstartsWithon the full emoji marker: forge APIs can normalize returned comment bodies (CRLF, stripped variation selector, leading whitespace) and the emoji is multi-codepoint. Don't tighten it back. Live-proven on GitHub; GitLab/Bitbucket normalization is exactly why it's tolerant (recorded residual watch).budgetReviewExtrasβ it's author input like the description, not bot soft-context; the 8,000-char slice is the guard (same cap idiom as the plan prompt's issueBody).reviewDraftPrsdefaults false: an in-app draft create now defers the first review, and the ready flip is picked up by the existingmaybeCatchUpMissedOpen(no prior record β eligible). A gated-out draft is NOT a lost review β the comment at the gate says so. The setting governs BOTH the dialog trigger and the catch-up filter, removing the old dialog/poller draft asymmetry.aiEnabled: an MCP-written deposit on disk must not be silently deleted while Hide AI is on. Within the AI-on path, consume deliberately runs even when the comment post fails (the create consumed the note; the review still gets it via the event).AutomationsSection's diff is mostly re-indentation β the section converted towithForm(with thepropsoption) so the new toggle joins the app-wide AppSettings Save/Discard batch like every other AppSettings boolean; the automations-RULES draft machinery inside it is unchanged.reviewDraftPrsis an AppSettings field, not automations config β that's why it's batched, not immediate-apply.CreateLocalPrDialogthreadsreviewNoteson the automation event.PromoteLocalPrDialogis deliberately untouched: its existing comment replay already carries a local notes comment to the promoted remote PR.isBot(notes post as the user,asBot: false), own-context requires the GitDesktop attribution anchor (dialog posts lack it). Adversarially verified pre-open. (This very comment, posted via the MCP, does carry the attribution footer β so on this PR the notes additionally appear distilled in own-context; known, harmless.)track.tsone-line type addition βhas_review_notesonpull_request_createdwas forced by the dialog's analytics call; disclosed tight coupling, not scope creep.Posted by GitDesktop β automated agent comment, verify before acting on it.
Originally posted by: theBGuy
π€ GitDesktop AI review Β·
opusThis is a well-structured feature with careful storage-layer handling (atomic writes, serialized read-modify-write on the TS side to dodge the autoSave write-race, thorough Rust unit tests) and thoughtful prompt plumbing. Overall it's sound and I see nothing merge-blocking. One functional gap in the draft-deferral path is worth addressing, plus a minor doc fix.
Correctness / edge cases
should-fix β
src/lib/automations/sync.ts(maybeCatchUpMissedOpen/CATCH_UP_WINDOW_MS) together with the new draft gate insrc/features/pulls/CreatePrDialog.tsx(if (!value.draft || reviewDraftPrs)). WithreviewDraftPrsdefaultingfalse, an in-app draft create no longer fires its first review β it's deferred to the catch-up poller. But catch-up eligibility also requiresnow - Date.parse(c.createdAt) <= CATCH_UP_WINDOW_MS(14 days), andcreatedAtis the PR's original open time, unaffected by marking it ready. There is no in-app "mark ready β fire pr-open" path (I checked:triggerAutomations({kind:"pr-open"})fires only from the two create dialogs; every other ready-flip route goes throughmaybeCatchUpMissedOpen). Concrete case: user has a PR opened review automation, creates a draft in-app, leaves it draft for >14 days (routine for a long-lived WIP branch), then marks it ready β the catch-up filters it out by age and the first review never runs, whereas before this PR the draft was reviewed at create time. The author's note item 5 records the reliance onmaybeCatchUpMissedOpenfor the ready-flip ("no prior record β eligible") but doesn't account for the recency window that can silently drop it, and the README/help copy ("waits until it's marked ready") carries no such caveat. Fix options: fire a freshpr-openevent on the in-app mark-ready action, or exempt deferred-draft PRs from theCATCH_UP_WINDOW_MSgate (e.g. gate on a "deferred" marker / the ready-transition time rather thancreatedAt).Readability
nit β
src/lib/review-notes/store.ts:9-11, theReviewNoteJSDoc: "Written by the GUI's Create-PR dialogs and, out-of-process, by the MCP server." The GUI dialogs never write a deposit through this module β they only read (getReviewNote) and consume (deleteReviewNote); the sole writer of deposits is the Rust/MCP side. Reword to "Written by the MCP server; read and consumed (cleared) by the GUI's Create-PR dialogs" so ownership ofreview-notes.jsonwrites is unambiguous. (Also flagged by Copilot.)Dismissing the other Copilot finding
CreatePrDialog.tsx:343(upstream-lens automation): not a new issue. The runner's origin-pinning for remote comment posting/diff fetch is a pre-existing, explicitly recorded gap (runner.tslines ~392 and ~611), unchanged by this PR. And the second half of the claim β that reviewer notes might not land before the review reads the conversation β doesn't apply to the freshpr-openpath: the notes are carried on the event (reviewNotes), andgenerateReviewTextshort-circuits the conversation fetch wheneventNotesis present, so the review never depends on the comment being visible first. The notes comment itself is correctly posted undercreateLens, so it lands on the right (upstream) PR.Everything else checks out: the settings field is typed non-optional and
loadSettingsspreadsDEFAULT_SETTINGS, so the legacy-absent read is safe (false); thereviewDraftPrstoggle rides the settings form's dirty-detection and Save viatoDraft; both catch-up call sites pass a defined value; the local-PR note comment legitimately omits the optionalauthor; and the marker lift/anchor looseness is a documented, deliberate choice (item 3).Posted by GitDesktop β AI output, verify before acting on it.
Originally posted by: theBGuy
π€ GitDesktop AI security audit Β·
opusI traced the reviewer-notes data flow through the Rust store, the MCP tool, and the AI-review runner. The Rust side is clean:
set_review_notesis gated on--allow-write,verify_branchfully-qualifies the ref (refs/heads/{branch}^{{commit}}) and passes it as an argv element viarun_git_rawwith a leading--guard (ensure_not_flag), and the branch is used only as a JSON map key, never a filesystem path β no injection or traversal. One real issue in the notes-recovery path:Severity: Medium β Confidence: 7/10
prompt-injection/xpiaβsrc/lib/ai/notes-context.ts(resolveReviewerNotesContext), consumed bysrc/lib/automations/runner.tsand injected viasrc/lib/ai/prompt.ts(## Author's notes for reviewers).Exploit scenario: On re-review rounds the runner recovers the "Notes for reviewers" from the PR conversation, because those events carry no
reviewNotes.pr-syncevents (the "On new commits to a reviewed PR" automation) have noreviewNotesfield at all, and the catch-up path synthesizes apr-openevent without one (sync.ts:174), so in both casesgenerateReviewTextcallsresolveReviewerNotesContext(repoPath, prNumber). That function fetches origin PR comments and selects the newest comment whose first non-blank line merely contains the ASCII anchor**Notes for reviewers**β it does not check the comment's author. Any GitHub user who can comment on the PR (i.e. anyone, on a public repo β including on the maintainer's own PR) can post:That body is then placed verbatim (8000-char slice, and deliberately outside
budgetReviewExtras) into the review prompt under## Author's notes for reviewers, andSECURITY_REVIEW_SYSTEMnow instructs the model to treat that section as "the author's recorded risk decision β¦ a disposition to verify against, not a fresh finding." So an external party can steer/suppress the automated security review's findings on the owner's PR β the exact security control the feature is meant to serve.How I verified it's real vs. by-design embedding: the sibling harvester
src/lib/ai/own-context.tsgates on the app-controlledGD_COMMENT_ANCHORfooter (only GitDesktop's own posting paths emit it) and frames its output as soft, non-authoritative resolution context.resolveReviewerNotesContexthas no equivalent trust gate (no author check, no GD anchor) yet its output is framed as authoritative author input. The feature's design assumes the marker comment is the author's own first comment (posted via the dialog asasBot: false); recovering by marker-only breaks that assumption across the PR-comment trust boundary. This isn't the "embed PR/diff content by design" non-issue β it elevates an arbitrary commenter to the author's trust level in the security-decision channel.Remediation: When recovering the notes comment, verify its author equals the PR author before trusting it (the
ExternalReviewItemalready carriesauthor, asown-context.tsuses at line 126; compare against the PR author login), or restrict recovery to a comment the app itself provably posted (e.g. persist the posted comment id, or require the GitDesktop footer anchor asown-contextdoes). Failing that, frame the notes section in the prompt as unverified/untrusted rather than as the author's accepted-risk disposition.Copilot's two inline comments here (upstream-lens
triggerAutomationsorigin-pinning; thestore.tsheader-comment/writer mismatch) are correctness/documentation concerns with no attacker-controlled path β not security issues, so nothing to action on that front.Posted by GitDesktop β AI output, verify before acting on it.
Originally posted by: theBGuy
Round-1 dispositions (all four findings verified against the code before triage):
Draft deferred >14 days loses its first review (AI review, should-fix) β ACCEPTED, fix in the next push. The trace is correct: pr-open fires only from the two create dialogs, the catch-up filter gates on
createdAt(unchanged by the ready flip), andCATCH_UP_WINDOW_MSis 14d β so an in-app draft older than that, marked ready, would silently never get its first review. Fix: the app's own Mark ready action fires a freshpr-openevent directly (mirroring the catch-up event shape). This is claim-safe with no extra guard: a delivered review keeps its dedup claim (so a draft that WAS reviewed underreviewDraftPrs: truewon't double-review), while a canceled/failed run released it (so the re-fire proceeds). Disclosed residual: an external ready flip (web/CLI) on a >14d-old PR still rides the catch-up poller and its standing backfill window β that's the poller's pre-existing, deliberate spend bound; a doc caveat rides along.Marker-lift trusts any commenter (security audit, Medium 7/10) β ACCEPTED, fix in the next push. Confirmed:
resolveReviewerNotesContextfilters on comment-kind and anchor only β no author gate β and the section it feeds now carries author-level trust inSECURITY_REVIEW_SYSTEM. The audit's trust-boundary analysis is exactly right (and correctly distinguished from the by-design content-embedding carve-out). Fix: the lift verifies the marker comment's author equals the PR author before trusting it (one best-effort PR fetch inside the already-best-effort path); non-author marker comments are ignored. This preserves the wire format β dialog- and MCP-posted notes are the author's own login β and restores the design assumption the audit named.store.tsJSDoc mis-attributes deposit writes to the GUI (AI nit + Copilot) β ACCEPTED, already applied (orchestrator fix): reworded to "written by the MCP server; read and consumed by the Create-PR dialogs."Upstream-lens automation / notes-timing concern (Copilot) β DECLINED, per the general review's dismissal, independently re-verified: the runner's origin-pinning is pre-existing and recorded in-code at its two sites (comment posting, diff fetch), unchanged by this PR; the fresh pr-open path carries notes on the event and
generateReviewTextshort-circuits the conversation fetch when they're present, so the review never depends on comment visibility; and the notes comment posts undercreateLens, landing on the correct (upstream) PR. Copilot's inline threads get individual replies once GitHub'spulls/91/commentsendpoint stops 503ing (it's been flaky all evening).Fixes 1β2 land in one push together with this comment's carve-out (3).
Posted by GitDesktop β automated agent comment, verify before acting on it.
Originally posted by: theBGuy
π€ GitDesktop AI review Β·
opusΒ· automatedSummary
Adds an author-facing "Notes for reviewers" workflow (per-branch deposits via MCP + Create-PR dialog field, posted as the PR's first comment and threaded into the review/description prompts) plus a
reviewDraftPrssetting that defers a draft's first review until it's readied. The storage layer, prompt plumbing, and the two fixes from the prior round are sound. One should-fix remains: the new in-app "mark ready" review trigger drops the eligibility guard that its sibling catch-up path enforces, so it can double-review.Correctness
should-fix β
src/features/pulls/RemotePrView.tsx(fireReadyReview, ~line 605, called from both mark-readyonSuccesshandlers). This fix correctly closes the >14-day in-app gap from the last round, but it firespr-openwith no "already reviewed" eligibility guard, relying solely on the runner's cross-instance automation claim. Its comment asserts "No eligibility guard is needed: the runner's per-headSha claim dedup makes this safe" β but that claim only covers automated runs. A manual review does not take a claim:src/lib/stores/reviews.ts:517callssaveReview(...)with noclaim_automation_run, and the runner'sgetLatestReviewwatermark check runs only forevent.kind === "pr-sync"(runner.ts:194), never forpr-open.Concrete case (user has a PR opened review automation,
reviewDraftPrs: false): create a draft β run a manual AI review on it from the review panel (saveReviewrecords it, no claim) β Mark ready in-app.fireReadyReviewfirespr-open; the claim check finds no prior automation claim for this head, so a second, automated review runs and posts a duplicate comment on the same head. The equivalent external ready flip does not do this: it ridesmaybeCatchUpMissedOpenβcatchUpEligible, which returnsfalsewhen any prior review (manual or automated, either mode) exists (sync.ts:204-205). So in-app and external ready diverge, and the in-app path is the leakier one β exactly the asymmetry the setting was meant to remove.Fix: gate
fireReadyReviewwith the same no-prior-review check catch-up uses (callgetLatestReviewfor the general/security modes and bail if any exists), or factorcatchUpEligible's body into a shared helper and reuse it here beforetriggerAutomations.Resolved since last review
fireReadyReviewnow fires a freshpr-openon in-app mark-ready (RemotePrView.tsx), bypassing theCATCH_UP_WINDOW_MSgate; the README/help copy gained the matching "marking it ready in GitDesktop always fires that first review" caveat. (The residual should-fix above is a new consequence of this fix, not the old finding.)ReviewNoteJSDoc write-ownership β fixed; store.ts:5-7 now reads "Written (out-of-process) by the MCP server'sset_review_notes; the GUI's Create-PR dialogs only read β¦ and consume β¦".resolveReviewerNotesContextnow fetchesforgePrView, and lifts only the newest marker comment whose author matches the PR author (case-insensitive), bailing when the author can't be established.ExternalReviewItem.author/PrDetails.authorare both typed non-optionalstring, so the added.trim()calls are safe.Dismissed
store.ts:7write-ownership finding is resolved by the JSDoc rewrite above.CreatePrDialog.tsx:344upstream-lens finding: not a new issue here β the notes comment posts undercreateLens(lands on the correct target PR), and the freshpr-openpath carries notes on the event, so the review doesn't depend on comment visibility. The runner's origin-pinning is a pre-existing, recorded gap this PR doesn't touch.I verified the generate-description argument threading (
generate(..., provider, availableLabels, reviewNotes)) β both call sites pass args in the correct positional order, soreviewNotesis not misrouted intoprovider/availableLabels. The Rust store core, its tests, the serialized read-modify-write on the TS side, and the double-counting guards (item 9) all check out.Posted by GitDesktop β AI output, verify before acting on it.
Originally posted by: theBGuy
Round-2 disposition:
Mark-ready can double-review after a manual panel review β ACCEPTED, fix in the next push, and the review's refutation of our round-1 rationale is correct on the record: the "claim dedup makes this guard-free" argument covered automated-vs-automated only. Verified all three cites: the manual panel path saves via
saveReviewwith no claim (reviews.ts:517), the runner's prior/watermark check runs only forpr-sync(runner.ts), and the external path'scatchUpEligibleblocks on any prior review in either mode plus a dismissed current head (sync.ts). Fix per the review's suggested factoring: extractcatchUpEligible's eligibility core (no-prior-either-mode + not-dismissed-at-head) into a shared helper used by both the catch-up poller andfireReadyReviewβ which also gives the in-app path the dismissed-head semantics it was silently missing, keeping in-app and external ready flips behaviorally identical.Posted by GitDesktop β automated agent comment, verify before acting on it.
Originally posted by: cloudflare-workers-and-pages[bot]
Deploying gitdesktop with Β
Β Cloudflare Pages
e6bff38View logs
Originally posted by: theBGuy
π€ GitDesktop AI review Β·
opusΒ· automatedThe round-2 double-review finding is fixed correctly, and I independently verified the fix rather than taking the comment's word for it. The change is sound and I found no new blockers.
fireReadyReviewnow gates onprOpenEligible(repoPath, String(number), headSha ?? "")before firingpr-open. I traced therefargument end-to-end to confirm the guard actually sees a manual panel review:saveReviewwithkind: target.kind, ref: target.ref(reviews.ts:520-521), and the panel'sReviewTargetis built withprRef={String(number)}/prKind="remote"(RemotePrView.tsx:1226-1227βPrReviewPanel.tsx:95-98).prOpenEligiblecallsgetLatestReview(repoPath, "remote", String(number), mode), which filtersr.kind === "remote" && r.ref === String(number)(reviews-history.ts:161).So a manual review recorded under
(remote, String(number))is found by the guard called with the same(repoPath, String(number))β the round-2 case (draft β manual panel review β in-app Mark ready) now returnsfalseand skips the duplicate. The catch-up path is unchanged behaviorally (catchUpEligibledelegates to the sameprOpenEligible), so the two ready paths are genuinely identical. Fail-closed on store error is fine here β the background poller'smaybeCatchUpMissedOpenretries a readied draft on the next tick.Resolved since last review
fireReadyReviewis gated by the sharedprOpenEligible, which detects a prior review in either mode regardless of whether it took an automation claim. Verified therefmapping above.ReviewNoteJSDoc write-ownership (Copilotstore.ts:7) β fixed; the header now reads "Written (out-of-process) by the MCP server'sset_review_notes; the GUI's Create-PR dialogs only read (getReviewNote) and consume (deleteReviewNote)", visible in the current diff.resolveReviewerNotesContextfetchesforgePrView, bails when the author can't be established (!prAuthor.trim()), and lifts only the newest marker comment whoseit.authormatches the PR author (case-insensitive). BothPrDetails.authorandExternalReviewItem.authorare typed non-optionalstring(types.ts:1591,types.ts:1668), so the.trim()calls can't throw.Dismissed
CreatePrDialog.tsx:344upstream-lens finding is not a new issue: the notes comment posts undercreateLens(lands on the correct target PR), and the freshpr-openpath carriesreviewNoteson the event, so the review doesn't depend on the origin-pinned harvest. The runner's origin-pinning for later re-review rounds is a pre-existing, recorded gap this PR doesn't touch.Spot-checks that passed: the
settingsFormOptsdraft includesreviewDraftPrs(viatoDraft(DEFAULT_SETTINGS)omitting onlyrecentRepos/diffViewMode/defaultBranch), so the newform.AppField name="reviewDraftPrs"binds and saves through the AppSettings batch as item 7 describes; the draft gate inCreatePrDialogreads a realsettings = useSettings(); and the generate-description call sites passreviewNotesin the correct trailing positional slot. TheAutomationsSectionwithFormconversion is re-indentation plus the one new toggle, consistent with the recorded deliberate decision.Posted by GitDesktop β AI output, verify before acting on it.
Ticket changed by: theBGuy