tuicr 0.21.0

Review AI-generated diffs like a GitHub pull request, right from your terminal.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
use super::*;

impl App {
    /// Drive `:submit*` preflight: walk every local-draft comment in the
    /// current PR session, map each one against the displayed diff, bucket
    /// the results, and transition into the resolver (when there are
    /// unmappable comments) or the final-confirmation modal.
    ///
    /// PR 5 does not call the network; `[y]` in the confirmation modal
    /// stubs a "PR 6 will wire the network call" info message.
    pub fn start_submit(&mut self, event: crate::forge::submit::SubmitEvent) {
        self.start_submit_with(event, false);
    }

    /// Like `start_submit`, but when `skip_confirm` is `true` the flow
    /// bypasses `SubmitConfirm`. The action-picker path uses this because
    /// picking IS the confirmation; the resolver (if any unmappable
    /// comments) still runs first, then dispatches the network call
    /// directly. `:submit <event>` callers should pass `false`.
    pub fn start_submit_with(
        &mut self,
        event: crate::forge::submit::SubmitEvent,
        skip_confirm: bool,
    ) {
        use crate::forge::submit::{
            CommentAnchor, InlineComment, ResolverAction, UnmappableItem, map_comment,
        };

        let DiffSource::PullRequest(pr) = &self.diff_source else {
            self.set_warning(":submit only applies in PR mode");
            return;
        };
        if pr.is_read_only() {
            let reason = pr.read_only_reason().unwrap_or("read only");
            self.set_warning(format!("Cannot submit: PR is {reason}"));
            return;
        }
        // When the inline commit selector shows a strict subset, comments
        // anchor to the displayed (subset) diff, so `commit_id` must be the
        // SHA the diff was computed against — otherwise GitHub rejects with
        // 422 because the line/position isn't present in the diff against
        // the cumulative PR head. `pr_commits` is stored newest-first, so
        // the head of a (start_idx..=end_idx) range is `pr_commits[start_idx]`.
        let commit_id = match self.commit_selection_range {
            Some((start_idx, end_idx))
                if !self.pr_commits.is_empty()
                    && start_idx <= end_idx
                    && end_idx < self.pr_commits.len()
                    && !(start_idx == 0 && end_idx + 1 == self.pr_commits.len()) =>
            {
                self.pr_commits[start_idx].oid.clone()
            }
            _ => pr.key.head_sha.clone(),
        };

        // Source of truth for the diff: when the inline commit selector is
        // showing a strict subset, `range_diff_files` carries the merged
        // subset diff; otherwise `diff_files` is canonical.
        let files: Vec<&DiffFile> = match self.range_diff_files.as_ref() {
            Some(range) => range.iter().collect(),
            None => self.diff_files.iter().collect(),
        };

        let mut mappable: Vec<InlineComment> = Vec::new();
        let mut unmappable: Vec<UnmappableItem> = Vec::new();
        let mut total_local_drafts = 0_usize;

        // Walk file-level and line comments in display order. Review-level
        // comments (session.review_comments) are NOT inline-mapped; they
        // appear in the body via `build_review_body`.
        for file in &files {
            let Some(review) = self.session.files.get(file.display_path()) else {
                continue;
            };
            for comment in &review.file_comments {
                if comment.is_locked() || !self.comment_visible(comment) {
                    continue;
                }
                total_local_drafts += 1;
                bucket_mapping(
                    map_comment(comment, CommentAnchor::FileLevel, file, &self.forge_config),
                    &mut mappable,
                    &mut unmappable,
                );
            }
            let mut keys: Vec<&u32> = review.line_comments.keys().collect();
            keys.sort();
            for key in keys {
                for comment in &review.line_comments[key] {
                    if comment.is_locked() || !self.comment_visible(comment) {
                        continue;
                    }
                    total_local_drafts += 1;
                    let anchor = if comment.line_range.is_some() {
                        CommentAnchor::Range
                    } else {
                        CommentAnchor::Line {
                            line: *key,
                            side: comment.side.unwrap_or_default(),
                        }
                    };
                    bucket_mapping(
                        map_comment(comment, anchor, file, &self.forge_config),
                        &mut mappable,
                        &mut unmappable,
                    );
                }
            }
        }

        // Approve is the one event that's meaningful with no comments — a
        // bare "LGTM" approval. Every other event needs at least one local
        // draft comment or a review-level comment, otherwise there's
        // nothing to submit.
        let bare_allowed = matches!(event, crate::forge::submit::SubmitEvent::Approve);
        if !bare_allowed && total_local_drafts == 0 && self.session.review_comments.is_empty() {
            self.set_warning("Nothing to submit — no local-draft comments");
            return;
        }

        let resolver_choices = vec![ResolverAction::default(); unmappable.len()];
        let has_unmappable = !unmappable.is_empty();
        self.submit_state = Some(SubmitState {
            event,
            mappable,
            unmappable,
            resolver_choices,
            resolver_cursor: 0,
            commit_id,
            skip_confirm,
        });

        if has_unmappable {
            self.input_mode = InputMode::SubmitResolver;
        } else if skip_confirm {
            self.input_mode = InputMode::Normal;
            self.confirm_submit();
        } else {
            self.input_mode = InputMode::SubmitConfirm;
        }
    }

    /// Open the bare-`:submit` action picker. The user picks
    /// Comment/Approve/Request changes/Draft (or cancels); the picked event
    /// then runs through preflight with `skip_confirm = true` so no extra
    /// confirmation modal follows.
    pub fn start_submit_action_picker(&mut self) {
        if !matches!(self.diff_source, DiffSource::PullRequest(_)) {
            self.set_warning(":submit only applies in PR mode");
            return;
        }
        self.submit_picker_cursor = 0;
        self.input_mode = InputMode::SubmitActionPicker;
    }

    /// Move the action-picker cursor down by one row, wrapping at the end.
    pub fn submit_picker_cursor_down(&mut self) {
        let total = SUBMIT_PICKER_EVENTS.len();
        if total > 0 {
            self.submit_picker_cursor = (self.submit_picker_cursor + 1) % total;
        }
    }

    /// Move the action-picker cursor up by one row, wrapping at the start.
    pub fn submit_picker_cursor_up(&mut self) {
        let total = SUBMIT_PICKER_EVENTS.len();
        if total > 0 {
            self.submit_picker_cursor = (self.submit_picker_cursor + total - 1) % total;
        }
    }

    /// Confirm the action picker selection: dispatch into preflight with the
    /// chosen event and `skip_confirm = true`.
    pub fn submit_picker_confirm(&mut self) {
        let Some(event) = SUBMIT_PICKER_EVENTS
            .get(self.submit_picker_cursor)
            .map(|(_, ev)| *ev)
        else {
            self.cancel_submit_action_picker();
            return;
        };
        self.input_mode = InputMode::Normal;
        self.start_submit_with(event, true);
    }

    /// Cancel the action picker without entering preflight.
    pub fn cancel_submit_action_picker(&mut self) {
        self.input_mode = InputMode::Normal;
        self.submit_picker_cursor = 0;
    }

    pub fn cancel_submit(&mut self) {
        self.submit_state = None;
        self.input_mode = InputMode::Normal;
    }

    /// Move the resolver cursor down by one row, clamped to the last row.
    pub fn submit_resolver_cursor_down(&mut self) {
        if let Some(state) = self.submit_state.as_mut()
            && state.resolver_cursor + 1 < state.unmappable.len()
        {
            state.resolver_cursor += 1;
        }
    }

    pub fn submit_resolver_cursor_up(&mut self) {
        if let Some(state) = self.submit_state.as_mut()
            && state.resolver_cursor > 0
        {
            state.resolver_cursor -= 1;
        }
    }

    pub fn submit_resolver_toggle(&mut self) {
        use crate::forge::submit::ResolverAction;
        if let Some(state) = self.submit_state.as_mut()
            && let Some(choice) = state.resolver_choices.get_mut(state.resolver_cursor)
        {
            *choice = match choice {
                ResolverAction::MoveToSummary => ResolverAction::Omit,
                ResolverAction::Omit => ResolverAction::MoveToSummary,
            };
        }
    }

    /// Advance from the resolver. When `skip_confirm` is set (action-picker
    /// path), dispatch the network call directly; otherwise route to
    /// `SubmitConfirm` for the final confirmation modal.
    pub fn submit_resolver_advance(&mut self) {
        let Some(state) = self.submit_state.as_ref() else {
            return;
        };
        if state.skip_confirm {
            self.input_mode = InputMode::Normal;
            self.confirm_submit();
        } else {
            self.input_mode = InputMode::SubmitConfirm;
        }
    }

    /// True iff the original review head and the latest known PR head
    /// disagree. PR 5 cannot trigger this (the open-time head equals
    /// `current_pr_head`), but the field is exposed so the renderer can
    /// fold the warning in once PR 6 refreshes the remote head.
    pub fn submit_head_is_stale(&self) -> bool {
        let Some(state) = self.submit_state.as_ref() else {
            return false;
        };
        match self.current_pr_head.as_deref() {
            Some(latest) => latest != state.commit_id,
            None => false,
        }
    }

    /// Confirm submit — PR 6 dispatches the async `gh api .../reviews` call.
    /// Builds the body + payload on the main thread, saves the session, then
    /// hands off to `spawn_pr_submit`. The modal disappears immediately; a
    /// status-bar spinner takes over until the result lands in
    /// `poll_pr_submit_events`.
    pub fn confirm_submit(&mut self) {
        if let Err(e) = self.spawn_pr_submit() {
            self.set_error(format!("Submit failed: {e}"));
            self.submit_state = None;
            self.input_mode = InputMode::Normal;
        }
    }

    /// Kick off the create-review call asynchronously. Pre-submit-saves the
    /// session, builds the JSON payload on the main thread, then runs the
    /// network round-trip on a background thread. The result is applied
    /// later in `poll_pr_submit_events`.
    pub fn spawn_pr_submit(&mut self) -> Result<()> {
        use crate::forge::submit::{MovedToSummaryItem, ResolverAction, build_review_body};
        use crate::forge::traits::{CreateReviewRequest, PullRequestTarget};

        // Snapshot identity from the PR diff source first so the borrow on
        // `submit_state` below doesn't conflict.
        let DiffSource::PullRequest(pr) = self.diff_source.clone() else {
            return Err(TuicrError::UnsupportedOperation(
                "Not in PR mode".to_string(),
            ));
        };
        if self.pr_submit_state.is_some() {
            return Ok(()); // already in flight; ignore
        }

        let Some(state) = self.submit_state.take() else {
            return Ok(());
        };

        let summary_items: Vec<MovedToSummaryItem> = state
            .unmappable
            .iter()
            .zip(state.resolver_choices.iter())
            .filter_map(|(item, action)| {
                if *action == ResolverAction::MoveToSummary {
                    Some(MovedToSummaryItem {
                        comment: item.comment.clone(),
                        file: item.file.clone(),
                    })
                } else {
                    None
                }
            })
            .collect();
        let summary_comment_ids: Vec<String> =
            summary_items.iter().map(|i| i.comment.id.clone()).collect();
        let review_comment_ids: Vec<String> = self
            .session
            .review_comments
            .iter()
            .map(|c| c.id.clone())
            .collect();
        let body = build_review_body(
            &self.session.review_comments,
            &summary_items,
            &self.forge_config,
        );

        // Save the session BEFORE the network call — keeps the user's
        // local-draft work durable if anything goes sideways below.
        let _ = self.save_current_session_merging_external();

        let in_flight = SubmitInFlightState {
            event: state.event,
            mappable: state.mappable.clone(),
            summary_comment_ids,
            review_comment_ids,
            moved_to_summary_count: summary_items.len(),
            head_sha_snapshot: state.commit_id.clone(),
            repository: pr.key.repository.clone(),
            pr_number: pr.key.number,
            started_at: Instant::now(),
        };
        self.pr_submit_state = Some(in_flight.clone());
        self.input_mode = InputMode::Normal;

        let local_checkout = self
            .forge_backend
            .as_deref()
            .and_then(|backend| backend.local_checkout_path());

        let (tx, rx) = std::sync::mpsc::channel();
        self.pr_submit_rx = Some(rx);

        let repository = in_flight.repository.clone();
        let pr_number = in_flight.pr_number;
        let head_sha = in_flight.head_sha_snapshot.clone();
        let event = in_flight.event;
        let mappable = in_flight.mappable.clone();
        let commit_id = state.commit_id.clone();

        std::thread::spawn(move || {
            let backend = create_forge_backend(&repository, local_checkout);
            // Need PR details for repo/owner routing; refetch lightly via
            // the same target the user opened with.
            let target = PullRequestTarget::with_repository(
                repository.clone(),
                pr_number,
                pr_number.to_string(),
            );
            let result = match backend.get_pull_request(target) {
                Ok(details) => backend
                    .create_review(
                        &details,
                        CreateReviewRequest {
                            event,
                            commit_id: &commit_id,
                            body: &body,
                            comments: &mappable,
                        },
                    )
                    .map_err(|e| e.to_string()),
                Err(e) => Err(e.to_string()),
            };
            let _ = tx.send(PrSubmitEvent::Done {
                repository,
                pr_number,
                head_sha,
                result,
            });
        });
        Ok(())
    }

    /// Pump a pending create-review result. Applies lifecycle writes + the
    /// success message, or surfaces a sticky error.
    pub fn poll_pr_submit_events(&mut self) {
        let Some(rx) = self.pr_submit_rx.as_ref() else {
            return;
        };
        let event = match rx.try_recv() {
            Ok(e) => e,
            Err(_) => return,
        };
        self.pr_submit_rx = None;
        let in_flight = self.pr_submit_state.take();
        let PrSubmitEvent::Done {
            repository,
            pr_number,
            head_sha,
            result,
        } = event;

        // Stale-result discard: if the user reloaded the PR mid-submit, the
        // active head SHA may have moved. Drop the result rather than
        // silently mutating the wrong session.
        let Some(in_flight) = in_flight else {
            return;
        };
        let stale = in_flight.repository != repository
            || in_flight.pr_number != pr_number
            || in_flight.head_sha_snapshot != head_sha;
        if stale {
            self.set_message("Discarded stale submit result (PR was reloaded)".to_string());
            return;
        }

        self.finish_pr_submit(in_flight, result);
    }

    /// Human-readable name of the forge backing the current PR/MR review.
    /// Used to keep submit messaging accurate across GitHub, GitLab, and
    /// Bitbucket.
    pub fn forge_display_name(&self) -> &'static str {
        match &self.diff_source {
            DiffSource::PullRequest(pr) => pr.key.repository.kind.display_name(),
            _ => "forge",
        }
    }

    /// Return the forge backing the active PR/MR diff, when reviewing one.
    pub fn forge_kind(&self) -> Option<crate::forge::traits::ForgeKind> {
        match &self.diff_source {
            DiffSource::PullRequest(pr) => Some(pr.key.repository.kind),
            _ => None,
        }
    }

    /// Apply the create-review result on the main thread. On success: flip
    /// each included `Comment` to `Submitted` (or `PushedDraft` for the
    /// draft event), stamp `remote_review_id`, save the session again, and
    /// publish a success message. On failure: keep everything as
    /// `LocalDraft` and set a sticky error.
    pub fn finish_pr_submit(
        &mut self,
        in_flight: SubmitInFlightState,
        result: std::result::Result<crate::forge::traits::GhCreateReviewResponse, String>,
    ) {
        use crate::forge::submit::SubmitEvent;

        let response = match result {
            Ok(r) => r,
            Err(e) => {
                self.set_error(format!("Submit failed: {e}"));
                return;
            }
        };

        self.apply_submit_success(&in_flight, &response);

        // Post-submit save — captures the lifecycle transitions.
        let _ = self.save_current_session_merging_external();

        let inline_count = in_flight.mappable.len();
        let summary_count = in_flight.moved_to_summary_count;
        let forge_name = self.forge_display_name();
        let message = match in_flight.event {
            SubmitEvent::Draft => {
                let pr_url = match &self.diff_source {
                    DiffSource::PullRequest(pr) => pr.url.clone(),
                    _ => String::new(),
                };
                if pr_url.is_empty() {
                    format!(
                        "Pushed pending {forge_name} review #{}: {} inline, {} moved to summary",
                        response.id, inline_count, summary_count,
                    )
                } else {
                    format!(
                        "Pushed pending {forge_name} review #{}: {} inline, {} moved to summary — Finish it in {forge_name}: {}",
                        response.id, inline_count, summary_count, pr_url,
                    )
                }
            }
            _ => format!(
                "Submitted {forge_name} review #{}: {} inline, {} moved to summary",
                response.id, inline_count, summary_count,
            ),
        };
        if in_flight.event != SubmitEvent::Draft {
            self.mark_pr_commits_reviewed_through(&in_flight.head_sha_snapshot);
        }
        self.set_message(message);

        // Refetch remote threads so the just-submitted comments appear immediately.
        self.refetch_pr_threads();
    }

    /// Flip every comment that was sent — inline, summary-bound, and review-
    /// level — from `LocalDraft` to `Submitted` (or `PushedDraft` for
    /// `:submit draft`) and stamp `remote_review_id`. The comments stay in
    /// the session so the user keeps seeing their work; they're pruned by
    /// `prune_locked_comments` when remote threads are next fetched.
    pub fn apply_submit_success(
        &mut self,
        in_flight: &SubmitInFlightState,
        response: &crate::forge::traits::GhCreateReviewResponse,
    ) {
        use crate::forge::submit::SubmitEvent;
        use crate::model::comment::CommentLifecycleState;

        let new_state = match in_flight.event {
            SubmitEvent::Draft => CommentLifecycleState::PushedDraft,
            _ => CommentLifecycleState::Submitted,
        };
        let review_id = response.id.to_string();

        let target_ids: std::collections::HashSet<&str> = in_flight
            .mappable
            .iter()
            .map(|c| c.comment_id.as_str())
            .chain(in_flight.summary_comment_ids.iter().map(String::as_str))
            .chain(in_flight.review_comment_ids.iter().map(String::as_str))
            .collect();
        if target_ids.is_empty() {
            return;
        }

        for comment in self.session.review_comments.iter_mut() {
            if target_ids.contains(comment.id.as_str()) {
                comment.lifecycle_state = new_state;
                comment.remote_review_id = Some(review_id.clone());
            }
        }
        for review in self.session.files.values_mut() {
            for comment in review.file_comments.iter_mut() {
                if target_ids.contains(comment.id.as_str()) {
                    comment.lifecycle_state = new_state;
                    comment.remote_review_id = Some(review_id.clone());
                }
            }
            for comments in review.line_comments.values_mut() {
                for comment in comments.iter_mut() {
                    if target_ids.contains(comment.id.as_str()) {
                        comment.lifecycle_state = new_state;
                        comment.remote_review_id = Some(review_id.clone());
                    }
                }
            }
        }
        self.rebuild_annotations();
    }

    /// Drop locked (`Submitted`/`PushedDraft`) comments from the session.
    /// Called after a successful `forge_review_threads` fetch: anything that
    /// was published to the forge is now represented by the fresh remote
    /// threads, so keeping the locals would double-render every line.
    pub fn prune_locked_comments(&mut self) {
        self.session.review_comments.retain(|c| !c.is_locked());
        for review in self.session.files.values_mut() {
            review.file_comments.retain(|c| !c.is_locked());
            for comments in review.line_comments.values_mut() {
                comments.retain(|c| !c.is_locked());
            }
            review.line_comments.retain(|_, v| !v.is_empty());
        }
    }
}