16 KiB
APP-3923: AI-autogenerated commit messages and PR metadata — Tech Spec
Product spec: specs/APP-3923/PRODUCT.md
Parent stack: APP-3918 (header button) → APP-3920 (commit/push dialog) → APP-3922 (create-PR dialog) → APP-3923 (this branch).
Problem
APP-3922 landed the GitDialog::CreatePr mode and the CommitIntent::CommitAndCreatePr chain, both of which called gh pr create --fill. --fill just copies the latest commit subject/body into the PR, and commit messages themselves had to be typed manually. We want AI-generated copy for all three: commit message (at dialog open time), PR title and PR body (at confirm time).
The work has three logical layers that needed new plumbing:
- A server endpoint for AI generation of review-adjacent content.
- A client-side block-service method and request/response types.
- Git helpers that produce the LLM input (diff + branch commit messages).
Plus editor-state changes in
commit.rsso that an autogenerated draft is discoverable, overridable, and failure-visible.
Relevant code
app/src/ai/generate_code_review_content/api.rs— newGenerateCodeReviewContentRequest/Response+OutputTypeenumapp/src/ai/generate_code_review_content/mod.rs— module root (plus the follow-up TODO)app/src/ai/mod.rs:44— registers the new moduleapp/src/server/server_api/block.rs:54,175-196— newBlockClient::generate_code_review_contenttrait method andServerApiimpl, following the pattern ofgenerate_shared_block_titleapp/src/util/git.rs:445-527—MAX_DIFF_CHARS_FOR_AI,MAX_UNTRACKED_FILE_BYTES,BINARY_CHECK_BYTES,MAX_PR_TITLE_BYTES,truncate_on_char_boundary,get_diff_for_commit_messageapp/src/util/git.rs:677-721—get_diff_for_pr,get_branch_commit_messagesapp/src/util/git.rs:723-798—create_pr(repo_path, Option<&str>, Option<&str>)with--fillfallback when title/body areNone;sanitize_pr_titlehelperapp/src/code_review/git_dialog/commit.rs:66-70— placeholder constantsapp/src/code_review/git_dialog/commit.rs:239-305—generate_commit_message(open-time)app/src/code_review/git_dialog/commit.rs:313-319—is_ready_to_confirmapp/src/code_review/git_dialog/commit.rs:362-473—start_confirm(PR title/body gen forCommitAndCreatePr)app/src/code_review/git_dialog/pr.rs:101-174—start_confirm+create_pr_with_ai_content(shared helper used by both standalone PR andCommitAndCreatePr; parallelizes title/body withfutures::try_join!, falls back to--fillon AI failure)app/src/code_review/git_dialog/mod.rs:484-496—refresh_confirm_enabledcall site updated for the newis_ready_to_confirmsignature- Server side:
warp-server/router/handlers/generate_code_review_content.go(already deployed)
Current state
Before this branch:
commit.rsrequired a typed commit message; placeholder was"Leave blank to autogenerate a commit message"but there was no autogeneration wired — the confirm was disabled on empty.pr.rs::start_confirmcalledcreate_pr(&repo_path)→gh pr create --fill.commit.rs::start_confirmCommitAndCreatePrbranch likewise calledcreate_pr(&repo_path).BlockClientonly hadgenerate_shared_block_titleas an AI-adjacent method.util/git.rshad no diff-for-AI helpers. The dialog parent (GitDialog) owns chrome (title, buttons, loading state) and each mode owns its own state, body renderer, and confirm async; events collapse toCompleted | Cancelled. That contract is preserved by this branch — all new work lives inside the existing per-mode submodules.
Proposed changes
1. generate_code_review_content module (app/src/ai/generate_code_review_content/)
Mirrors the shape of generate_block_title/: a mod.rs that declares pub(crate) mod api; and an api.rs with request/response types.
pub enum OutputType {
CommitMessage,
PrTitle,
PrDescription,
}
pub struct GenerateCodeReviewContentRequest {
pub output_type: OutputType,
pub diff: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub branch_name: String,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub commit_messages: Vec<String>,
}
pub struct GenerateCodeReviewContentResponse {
pub content: String,
}
A single endpoint + request type is enough because all three output types share the same inputs (diff, optional branch name, optional commit subjects). The server dispatches on output_type.
2. BlockClient::generate_code_review_content
Added alongside generate_shared_block_title in app/src/server/server_api/block.rs. The ServerApi implementation POSTs to {server_root_url}/ai/generate_code_review_content with bearer auth, JSON body, and JSON response decoding — same skeleton as generate_shared_block_title. Reusing BlockClient keeps this off the GraphQL path (which would require a new mutation and cynic codegen) and matches where block-title gen already lives.
3. Diff helpers in app/src/util/git.rs
Four module-scope consts (MAX_DIFF_CHARS_FOR_AI = 16_000, MAX_UNTRACKED_FILE_BYTES = 4_000, BINARY_CHECK_BYTES = 1_024, MAX_PR_TITLE_BYTES = 200) plus a truncate_on_char_boundary helper and three git-diff helpers, all #[cfg(feature = "local_fs")] with wasm stubs to match existing conventions in the file. All byte-length truncation uses truncate_on_char_boundary to avoid UTF-8 panics on diffs/source files containing non-ASCII text.
get_diff_for_commit_message(repo_path, include_unstaged) -> Result<String>git diff HEADwheninclude_unstaged, elsegit diff --cached.- When
include_unstaged, iteratesgit ls-files --others --exclude-standard -z(NUL-separated to survive paths with spaces/non-ASCII), skips binaries viawarp_util::file_type::is_buffer_binary(&bytes[..BINARY_CHECK_BYTES]), and appends synthetic unified-diff hunks for each new file (capped atMAX_UNTRACKED_FILE_BYTES) so the LLM sees new-file-only commits. - Final output truncated at
MAX_DIFF_CHARS_FOR_AIwith\n... (diff truncated)marker.
get_diff_for_pr(repo_path) -> Result<String>- Diffs
{base}..origin/{current}whengit rev-parse --verify origin/{current}succeeds, else{base}..HEAD. - Same truncation rule as above.
- Diffs
get_branch_commit_messages(repo_path) -> Result<Vec<String>>git log {base}..HEAD --format=%s, one subject per vec element.
4. create_pr signature change
create_pr(repo_path, title: Option<&str>, body: Option<&str>) -> Result<PrInfo> replaces create_pr(repo_path). When both fields are Some, invokes gh pr create --title <t> --body <b> (title passes through sanitize_pr_title to first-line and cap at MAX_PR_TITLE_BYTES — GitHub silently collapses newlines in titles otherwise). When either is None, falls back to gh pr create --fill, used as a last-resort source when AI title/body generation fails so the PR is still created. Both wasm stub and both call sites (pr.rs and commit.rs) updated.
5. Commit-dialog open-time autogen (commit.rs)
Two placeholder constants in commit.rs:
GENERATING_PLACEHOLDER_TEXT = "Generating commit message…"(shown while gen is in flight; was"Leave blank to autogenerate a commit message").FALLBACK_PLACEHOLDER_TEXT = "Type a commit message"(shown after gen resolves, success or failure). A new privategenerate_commit_message(repo_path, branch_name, include_unstaged, ctx)fires fromnew_stateat dialog construction. It:
- Awaits
get_diff_for_commit_messageandblock_client.generate_code_review_content(CommitMessage, ...). - On success: if the editor is still empty (
!buffer_text.trim().is_empty()is false),editor.system_reset_buffer_text(generated.trim(), ctx); otherwise discards. Placeholder swaps toFALLBACK_PLACEHOLDER_TEXT.refresh_confirm_enabled. - On failure: placeholder swaps to
FALLBACK_PLACEHOLDER_TEXT,refresh_confirm_enabled. No toast — the empty editor plus placeholder already communicate that no draft arrived, and the failure isn't retryable.log::warn!for the underlying error. Nois_autogeneratingfield onCommitState. Confirm enablement is purely!file_changes.is_empty() && commit_message(state, app).is_some(); while gen is in flight the buffer is empty, so this is false naturally.
6. Confirm-time PR gen: shared create_pr_with_ai_content helper
Both flows (standalone pr::start_confirm and the CommitAndCreatePr branch of commit::start_confirm) delegate to pr::create_pr_with_ai_content(repo_path, branch_name, block_client):
get_diff_for_pr(repo_path).get_branch_commit_messages(repo_path)(wrapped in.unwrap_or_default()— commit subjects are advisory).- Parallel
block_client.generate_code_review_contentcalls (PrTitle+PrDescription) viafutures::try_join!, both sharing the samediff,branch_name, andcommit_messages. - On AI success:
create_pr(&repo_path, Some(&pr_title), Some(&pr_body)). - On AI failure (either call):
log::warn!and fall back tocreate_pr(&repo_path, None, None)so the PR still gets created viagh pr create --fill. Non-AI errors (diff fetch,gh pr createitself) bubble via?into the existingErrhandler, which logs and callsshow_toast(user_facing_git_error(&err.to_string()), ctx). AI errors no longer reach that path since they're converted to the--fillfallback.
7. is_ready_to_confirm simplification
Before: (state, app) → required non-empty file changes AND non-empty commit message.
Interim (mid-branch): dropped app, added is_autogenerating flag, made message optional.
Final: (state, app) → required non-empty file changes AND non-empty commit message again. The is_autogenerating flag is gone; the empty-buffer state during gen is what gates confirm.
start_confirm is correspondingly simplified: let Some(message) = commit_message(state, ctx) else { return; }; as a defensive guard (handles keyboard-shortcut dispatch that bypasses the button's disabled state), then straight into run_commit. The previously-added confirm-time AI fallback branch is deleted.
End-to-end flows
Commit message autogeneration
sequenceDiagram
participant User
participant CommitDialog as commit.rs
participant Git as util/git.rs
participant AI as BlockClient
User->>CommitDialog: Open dialog
CommitDialog->>CommitDialog: placeholder = "Generating…"; confirm disabled
CommitDialog->>Git: get_diff_for_commit_message
Git-->>CommitDialog: diff (≤ 16k chars, + synthesised untracked files)
CommitDialog->>AI: generate_code_review_content(CommitMessage)
alt success
AI-->>CommitDialog: draft
CommitDialog->>CommitDialog: if editor empty, insert draft<br/>placeholder = "Type a commit message"
CommitDialog->>User: editor populated → confirm enabled
else failure
AI-->>CommitDialog: error
CommitDialog->>CommitDialog: placeholder = "Type a commit message"<br/>log::warn + refresh_confirm_enabled
CommitDialog->>User: blank editor → confirm still disabled
end
PR title/body autogeneration (both flows)
sequenceDiagram
participant User
participant Dialog as pr.rs / commit.rs
participant Git as util/git.rs
participant AI as BlockClient
participant GH as gh CLI
User->>Dialog: Click Create PR (or Commit and create PR)
Dialog->>Dialog: set_loading("Creating…" or intent loading label)
note over Dialog,Git: CommitAndCreatePr also runs run_commit + run_push first
Dialog->>Git: get_diff_for_pr
Git-->>Dialog: diff
Dialog->>Git: get_branch_commit_messages
Git-->>Dialog: commit subjects
Dialog->>AI: generate_code_review_content(PrTitle)
AI-->>Dialog: title
Dialog->>AI: generate_code_review_content(PrDescription)
AI-->>Dialog: body
Dialog->>GH: gh pr create --title --body
GH-->>Dialog: PrInfo | error
Dialog->>User: success toast with Open PR link | friendly error toast
State machine for the commit message editor
stateDiagram-v2
[*] --> Generating
Generating --> Populated: gen success && editor empty
Generating --> Failed: gen error or empty response
Generating --> UserTyped: user types during gen
Populated --> UserTyped: user edits
Populated --> Empty: user clears
Failed --> UserTyped: user types
Empty --> UserTyped: user types
UserTyped --> Empty: user clears
Populated --> [*]: confirm
UserTyped --> [*]: confirm
Failed --> [*]: cancel
Empty --> [*]: cancel
note right of Failed
placeholder = "Type a commit message"
no toast (silent)
confirm disabled
end note
note left of Empty
placeholder = "Type a commit message"
confirm disabled
end note
Risks and mitigations
AI latency blocks the user. Commit message gen runs at open time in the background, so the user can start typing immediately; gen results are discarded if the user has typed anything. PR title/body gen runs at confirm time, which does extend the Creating… loading phase by two sequential AI calls. Mitigation is bounded by server SLA; a failure simply surfaces the existing friendly error toast.
AI errors don't surface to the user. AI-generation errors are caught inside create_pr_with_ai_content and transparently fall back to gh pr create --fill, so the user sees a successfully-created PR with latest-commit-derived copy rather than an error toast. Only non-AI errors (diff fetch, gh pr create itself) still flow through user_facing_git_error, which doesn't know about them specifically and maps them to the generic git fallback. Improving that mapping is an explicit follow-up.
Sequential PR gen calls double the latency. Addressed in-branch: create_pr_with_ai_content now runs PrTitle + PrDescription concurrently via futures::try_join!. The diff payload is cloned across both requests but latency is bounded by the slower of the two.
CommitAndCreatePr chain can leave the branch pushed but PR not created. If PR creation fails after run_commit + run_push (non-AI failure — AI failures now fall back to --fill), the commit and push are real but no PR exists. The header button transitions to the CreatePr state on the next diff-metadata refresh, so the user can retry via the standalone dialog. Documented in the product spec; no code-level mitigation in-branch.
Duplicate PR title/body gen code. The ~25-line PR title + PR body generation block appears verbatim in both commit.rs::start_confirm (CommitAndCreatePr branch) and pr.rs::start_confirm. An extracted helper would sit naturally in git_dialog/mod.rs. Deferred to follow-up.
Privacy / opt-out. GitOperationsInCodeReview gates the UI, but does not address AI-specific privacy concerns (sending diffs to an LLM). See the TODO at the top of app/src/ai/generate_code_review_content/mod.rs — follow-up work needs to add an AISettings toggle (mirroring is_shared_block_title_generation_enabled) and a customer-type guard that excludes Enterprise unless on Warp plan or dogfood, matching the pattern in terminal/share_block_modal.rs::should_send_title_gen_request.
Testing and validation
No automated tests added — the parent branches (APP-3920, APP-3922) also ship without tests and the git_dialog module has no test harness yet. Manual validation covers each path in the product spec's Validation section.
When we add a test harness, the highest-value targets are:
is_ready_to_confirmtransitions through the editor states (empty → typed → cleared).generate_commit_message's "user typed before response landed → discard" path.get_diff_for_commit_messagetruncation and untracked-file synthesis.
Follow-ups
- Add
AISettings::is_commit_message_generation_enabled(or similar) and wire it throughgenerate_commit_messageandcreate_pr_with_ai_content. Mirrorshare_block_modal.rs::should_send_title_gen_request. - Add customer-type guard for Enterprise users (allow Warp plan + dogfood, deny otherwise).
- Route AI errors to dedicated toast copy instead of the git-error mapper. Options explored: typed-error marker +
downcast_ref(clean but heavier) vs. fall-through-on-unknown-error (simpler but changes behavior for unknown git errors). Pick one and implement. - Extract the
origin/{current}-or-HEAD resolution helper — duplicated betweenget_branch_diff_entries(APP-3922) andget_diff_for_pr(this branch). - Consider a regenerate button for the commit message draft, and a preview/edit UI for PR title/body before
gh pr createfires. - Update PR #23945 title and description to cover all three generated fields (currently title only mentions commit messages).