diff --git a/crates/core/src/project.rs b/crates/core/src/project.rs index c6ad5ff3..d2499a80 100644 --- a/crates/core/src/project.rs +++ b/crates/core/src/project.rs @@ -87,6 +87,10 @@ pub struct SessionMeta { /// Reviews being written in this thread, one per pull request it shows. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pull_request_reviews: Vec, + /// Writes to the native stacks this thread shows that the host is running or could not + /// confirm. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pull_request_operations: Vec, pub id: String, pub title: String, pub provider: ProviderKind, @@ -279,6 +283,7 @@ impl SessionMeta { title: format!("New {} session", provider.display_name()), pull_requests: Vec::new(), pull_request_reviews: Vec::new(), + pull_request_operations: Vec::new(), provider, profile_id: None, cwd, diff --git a/crates/core/src/pull_request.rs b/crates/core/src/pull_request.rs index e2892bc2..b4a7194a 100644 --- a/crates/core/src/pull_request.rs +++ b/crates/core/src/pull_request.rs @@ -1,7 +1,20 @@ use serde::{Deserialize, Serialize}; +macro_rules! stacks_docs { + () => { + "https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-stacked-pull-requests" + }; +} + +/// GitHub's documentation of native stacks, which the instructions and the stack map both link. +pub const STACKS_DOCS_URL: &str = stacks_docs!(); + /// Prepended to each turn while the pull request tools are registered; the transcript shows it. -pub const LINKING_INSTRUCTIONS: &str = "\nWhen the tcode_pull_requests MCP server exposes link_pull_request, use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, link every layer, not just the current branch or top PR. This applies to gh, gh stack, other CLIs and host APIs: they do not register PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link anything missing. Do not link unrelated PRs mentioned only as background. If linking fails, report that failure instead of claiming the PR is linked.\nWhen asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: Tcode wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. A wake is news, not a merge decision: check readiness yourself before merging. When you hand the work back to the user, call unwatch_pull_request first.\nFor dependent changes, GitHub native stacks preserve the full bottom-to-top topology and merge scope; see https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-stacked-pull-requests .\n\n\n"; +pub const LINKING_INSTRUCTIONS: &str = concat!( + "\nWhen the tcode_pull_requests MCP server exposes link_pull_request, use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, link every layer, not just the current branch or top PR. This applies to gh, gh stack, other CLIs and host APIs: they do not register PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link anything missing. Do not link unrelated PRs mentioned only as background. If linking fails, report that failure instead of claiming the PR is linked.\nWhen asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: Tcode wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. A wake is news, not a merge decision: check readiness yourself before merging. When you hand the work back to the user, call unwatch_pull_request first.\nFor dependent changes, GitHub native stacks preserve the full bottom-to-top topology and merge scope; see ", + stacks_docs!(), + " .\n\n\n" +); /// The rest of a turn's injected context when it leads with [`LINKING_INSTRUCTIONS`]. pub fn strip_linking_instructions(context: &str) -> Option<&str> { @@ -242,6 +255,303 @@ pub fn stack_route<'a>( } } +/// The native stack a visible link of the thread carries with `key` among its layers. +pub fn native_stack<'a>( + links: &'a [ThreadPullRequestLink], + key: &PullRequestKey, +) -> Option<&'a PullRequestStack> { + links + .iter() + .filter(|link| link.visible()) + .find_map(|link| match &link.stack { + PullRequestStackState::Native(stack) + if link.key.host == key.host + && link.key.repository == key.repository + && stack.layers.iter().any(|layer| layer.number == key.number) => + { + Some(stack) + } + _ => None, + }) +} + +/// Why GitHub will not merge a layer the merge scope holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StackBlocker { + Draft, + Closed, +} + +/// How the thread holds a layer of its stack. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StackLayerCondition { + Linked, + Dismissed, + NotLinked, +} + +/// What a layer is to a merge of the selected one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StackLayerRole { + Selected, + /// Merged already, below the selected layer: not part of the merge. + BelowMerged, + /// Lands together with the selected layer. + InScope, + /// In the scope, and GitHub refuses to merge it. + Blocks(StackBlocker), + Above, +} + +#[derive(Debug, Clone)] +pub struct StackMapRow<'a> { + pub layer: &'a PullRequestStackLayer, + /// The thread's visible link of the layer, whose snapshot titles it. + pub link: Option<&'a ThreadPullRequestLink>, + /// `None` where no snapshot of the layer says. + pub draft: Option, + pub condition: StackLayerCondition, + pub role: StackLayerRole, +} + +/// A native stack from the selected layer's point of view: every layer bottom to top, the +/// layers a merge of the selected one lands, and those GitHub refuses among them. Read from the +/// stored topology and links only; a write reads GitHub again. +#[derive(Debug, Clone)] +pub struct StackMap<'a> { + pub stack: &'a PullRequestStack, + pub rows: Vec>, + pub selected: usize, +} + +impl StackMap<'_> { + pub fn selected(&self) -> &StackMapRow<'_> { + &self.rows[self.selected] + } + + /// The unmerged layers from the bottom through the selected one, bottom first: what Merge + /// stack lands. + pub fn scope(&self) -> Vec { + self.rows[..=self.selected] + .iter() + .filter(|row| row.layer.state != PullRequestState::Merged) + .map(|row| row.layer.number) + .collect() + } + + /// The scope's layers GitHub refuses, below the selected one, bottom first. + pub fn blockers(&self) -> Vec<(u64, StackBlocker)> { + self.rows + .iter() + .filter_map(|row| match row.role { + StackLayerRole::Blocks(blocker) => Some((row.layer.number, blocker)), + _ => None, + }) + .collect() + } + + /// The open layers a rebase moves, bottom first. + pub fn unmerged(&self) -> Vec { + self.rows + .iter() + .filter(|row| row.layer.state != PullRequestState::Merged) + .map(|row| row.layer.number) + .collect() + } +} + +/// The selected layer's map of the native stack the thread shows it in. Every layer being +/// merged must be open and ready for review: a closed or draft layer below the selected one +/// blocks it, and one whose draft flag is unknown does not until a fresh read says. +pub fn stack_map<'a>( + links: &'a [ThreadPullRequestLink], + key: &PullRequestKey, +) -> Option> { + let stack = native_stack(links, key)?; + let selected = stack + .layers + .iter() + .position(|layer| layer.number == key.number)?; + let rows = stack + .layers + .iter() + .enumerate() + .map(|(index, layer)| { + let held = links.iter().find(|link| { + link.key.host == key.host + && link.key.repository == key.repository + && link.key.number == layer.number + }); + let link = held.filter(|link| link.visible()); + let condition = match held { + Some(link) if link.visible() => StackLayerCondition::Linked, + Some(_) => StackLayerCondition::Dismissed, + None => StackLayerCondition::NotLinked, + }; + let draft = link + .and_then(|link| link.snapshot.as_ref()) + .map(|snapshot| snapshot.is_draft); + let role = if index == selected { + StackLayerRole::Selected + } else if index > selected { + StackLayerRole::Above + } else if layer.state == PullRequestState::Merged { + StackLayerRole::BelowMerged + } else if layer.state == PullRequestState::Closed { + StackLayerRole::Blocks(StackBlocker::Closed) + } else if draft == Some(true) { + StackLayerRole::Blocks(StackBlocker::Draft) + } else { + StackLayerRole::InScope + }; + StackMapRow { + layer, + link, + draft, + condition, + role, + } + }) + .collect(); + Some(StackMap { + stack, + rows, + selected, + }) +} + +/// A write to a native stack that the host is running, or whose end it could not confirm. The +/// host publishes it with every thread that shows the stack, so each device and a reconnecting +/// one show the same state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PullRequestStackOperation { + #[serde(deserialize_with = "canonical_locator")] + pub host: String, + #[serde(deserialize_with = "canonical_locator")] + pub repository: String, + pub stack: u64, + pub started_at: u64, + pub kind: StackOperationKind, +} + +impl PullRequestStackOperation { + pub fn is_for(&self, host: &str, repository: &str, stack: u64) -> bool { + self.host == host && self.repository == repository && self.stack == stack + } + + /// Whether the operation is moving layer `number` now, so that layer waits for it: a merge + /// whose scope holds it, until a sync read what became of an unconfirmed one, or a rebase + /// still running over it. + pub fn covers(&self, number: u64) -> bool { + match &self.kind { + StackOperationKind::Merging { layers, .. } + | StackOperationKind::MergeUnconfirmed { + layers, + checked: false, + .. + } => layers.contains(&number), + StackOperationKind::Rebasing { layers } => { + layers.iter().any(|layer| layer.number == number) + } + StackOperationKind::MergeUnconfirmed { checked: true, .. } + | StackOperationKind::RebaseEnded { .. } => false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum StackOperationKind { + /// GitHub's asynchronous merge `id`, submitted from `target`, landing `layers` bottom first. + /// `adopted` when GitHub named an operation already running instead of taking a new one. + Merging { + id: String, + target: u64, + layers: Vec, + adopted: bool, + }, + /// Still pending when the host stopped following it; never submitted again. `checked` once a + /// sync after that read the target still open, so the stack's writes are offered again. + MergeUnconfirmed { + id: String, + target: u64, + layers: Vec, + checked: bool, + }, + Rebasing { + layers: Vec, + }, + /// A rebase that ended, each layer at its final step, kept until the next full sync so + /// every device can still read how it ended and what to do about a stop. + RebaseEnded { + layers: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StackRebaseLayer { + pub number: u64, + pub branch: String, + pub step: StackRebaseStep, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum StackRebaseStep { + Waiting, + Rebasing, + Pushing, + Pushed { + from: String, + to: String, + }, + AlreadyCurrent, + Failed { + reason: StackRebaseFailure, + }, + /// Above the layer the rebase stopped at. + NotStarted, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum StackRebaseFailure { + /// The layer's own commits do not apply onto the layer below; the scratch rebase was + /// aborted and the branch left as it was. + Conflict, + /// The branch is no longer at the head that was reviewed, so the lease kept it. + LeaseRefused, + /// The push got no answer in time, so it may have landed. + PushUnconfirmed, + Git { + step: StackRebaseGitStep, + message: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StackRebaseGitStep { + Preparing, + Fetching, + ForkPoint, + CheckingOut, + Rebasing, + Pushing, +} + +/// The operation the thread shows for the native stack `key` is a layer of. +pub fn stack_operation<'a>( + operations: &'a [PullRequestStackOperation], + links: &[ThreadPullRequestLink], + key: &PullRequestKey, +) -> Option<&'a PullRequestStackOperation> { + let stack = native_stack(links, key)?; + operations + .iter() + .find(|operation| operation.is_for(&key.host, &key.repository, stack.number)) +} + /// The visible links a watch holds, which keep their thread waiting between wakes. pub fn watched(links: &[ThreadPullRequestLink]) -> impl Iterator { links @@ -871,4 +1181,58 @@ mod tests { assert!(!is_pull_request_url(ordinary)); } } + + #[test] + fn a_layer_waits_only_for_the_write_moving_it_now() { + let operation = |kind| PullRequestStackOperation { + host: "github.com".into(), + repository: "sample/project".into(), + stack: 50, + started_at: 1, + kind, + }; + let rebase = |step: StackRebaseStep| { + vec![StackRebaseLayer { + number: 2, + branch: "layer-2".into(), + step, + }] + }; + let unconfirmed = |checked| StackOperationKind::MergeUnconfirmed { + id: "op".into(), + target: 3, + layers: vec![2, 3], + checked, + }; + let merging = operation(StackOperationKind::Merging { + id: "op".into(), + target: 3, + layers: vec![2, 3], + adopted: false, + }); + assert!( + merging.covers(3) && !merging.covers(4), + "the layers above stay free" + ); + assert!(operation(unconfirmed(false)).covers(2)); + assert!( + !operation(unconfirmed(true)).covers(2), + "a sync read the target still open" + ); + assert!( + operation(StackOperationKind::Rebasing { + layers: rebase(StackRebaseStep::Rebasing) + }) + .covers(2) + ); + assert!( + !operation(StackOperationKind::RebaseEnded { + layers: rebase(StackRebaseStep::Failed { + reason: StackRebaseFailure::Conflict + }) + }) + .covers(2), + "a stopped rebase is read, not waited for" + ); + } } diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index c506fce9..302f866e 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -615,6 +615,19 @@ pub enum RuntimeToast { number: u64, notice: tcode_core::pull_request_watch::WatchNotice, }, + /// How a write to a native stack ended after its command was answered: a followed merge, + /// a rebase, or an unconfirmed merge a sync has since read. `session_id` is the thread + /// `target` is opened from. + PullRequestStack { + session_id: String, + target: tcode_core::pull_request::PullRequestKey, + stack: u64, + layers: Vec, + base: String, + result: crate::PullRequestActionResult, + /// A merge that ended after the host stopped following it. + late: bool, + }, ProviderUpdatesAvailable { updates: Vec, }, diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 1bdc60b8..b874ead5 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -19,8 +19,9 @@ pub use pull_request::{ PullRequestRead, PullRequestReadResponse, PullRequestRejection, PullRequestReviewAnchor, PullRequestReviewState, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewer, PullRequestReviewerCandidate, PullRequestReviewerCandidates, PullRequestReviewerKind, - PullRequestReviewerState, PullRequestThreadReplies, PullRequestViewedFiles, - PullRequestViewedState, + PullRequestReviewerState, PullRequestStackActionState, PullRequestStackHead, + PullRequestStackLayerState, PullRequestStackPushAccess, PullRequestThreadReplies, + PullRequestViewedFiles, PullRequestViewedState, }; pub mod terminal; mod wire; @@ -130,6 +131,13 @@ pub use wire::{ // rejection InStack or StackUnknown; Query::PullRequest reads ActionState; // settings carry project_merge_methods and remove_agent_credits_on_merge, // patched by ProjectMergeMethod and RemoveAgentCreditsOnMerge. +// Unreleased: RunPullRequestAction merges a native stack through GitHub's +// asynchronous merge (MergeStack) and rebases one on the host (RebaseStack), +// answering Pending, MergeUnconfirmed, RebaseStarted, Rebased or RebaseStopped, +// or the stack rejections; InStack is removed; Query::PullRequest reads +// StackState; thread metadata carries pull_request_operations, the stack writes +// running or unconfirmed and a rebase's end until the next full sync; the +// PullRequestStack toast reports how one ended. pub const PROTOCOL_VERSION: u32 = 10; #[cfg(test)] diff --git a/crates/protocol/src/pull_request.rs b/crates/protocol/src/pull_request.rs index cdf939d5..d132fbb6 100644 --- a/crates/protocol/src/pull_request.rs +++ b/crates/protocol/src/pull_request.rs @@ -2,7 +2,10 @@ use agent::FileChangeKind; use serde::{Deserialize, Serialize}; -use tcode_core::{pull_request::PullRequestMergeMethod, session::ReviewSide}; +use tcode_core::{ + pull_request::{PullRequestMergeMethod, PullRequestState, StackRebaseFailure}, + session::ReviewSide, +}; /// The largest image a media read returns; past it the host stops reading. pub const MAX_PULL_REQUEST_MEDIA_BYTES: usize = 8 * 1024 * 1024; @@ -41,6 +44,11 @@ pub enum PullRequestRead { ReviewerCandidates, /// What merging, updating the branch and the other lifecycle actions would meet now. ActionState, + /// The native stack the pull request is a layer of, as GitHub has it now, for a merge or, + /// with `rebase`, a rebase of the stack. + StackState { + rebase: bool, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -376,6 +384,51 @@ pub struct PullRequestActionState { pub can_merge: bool, } +/// A native stack as GitHub has it now, read before a stack write is confirmed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PullRequestStackActionState { + pub stack: u64, + pub base: String, + /// Bottom to top. + pub layers: Vec, + /// The repository's enabled methods, in GitHub's order. + pub merge_methods: Vec, + pub merge_queue: bool, + pub can_merge: bool, + /// Whether the host's Git has a name and an email to commit the rebased layers with; read + /// for a rebase only. + pub git_identity: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PullRequestStackLayerState { + pub number: u64, + pub title: String, + pub head_branch: String, + /// `None` when GitHub gave no head, which no stack write goes ahead without. + pub head: Option, + pub state: PullRequestState, + pub draft: bool, + /// Read for a rebase only, for each unmerged layer. + pub push: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PullRequestStackPushAccess { + Write, + /// A fork's branch that allows maintainers to push. + MaintainerCanModify, + Denied, +} + +/// The head a stack write was confirmed at, for one layer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PullRequestStackHead { + pub number: u64, + pub head: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", content = "content", rename_all = "snake_case")] pub enum PullRequestReadResponse { @@ -388,6 +441,7 @@ pub enum PullRequestReadResponse { LabelCandidates(PullRequestLabelCandidates), ReviewerCandidates(PullRequestReviewerCandidates), ActionState(PullRequestActionState), + StackState(PullRequestStackActionState), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -469,6 +523,19 @@ pub enum PullRequestAction { remove_credits: bool, }, DisableAutoMerge, + /// GitHub's asynchronous merge of this layer of native stack `stack` and every unmerged + /// layer below it, at the heads in `heads`, which must be exactly that scope's. + MergeStack { + stack: u64, + heads: Vec, + method: PullRequestMergeMethod, + }, + /// Rebases every unmerged layer of native stack `stack` onto the one below it on the host, + /// pushing each with a lease on its head in `heads`, which must be exactly those layers'. + RebaseStack { + stack: u64, + heads: Vec, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -501,6 +568,32 @@ pub enum PullRequestActionResult { number: u64, url: String, }, + /// GitHub took a stack merge as operation `id` and works on it; the host follows it. + /// `adopted` when GitHub named an operation already running instead of taking this one. + Pending { + id: String, + adopted: bool, + }, + /// Operation `id` was still pending when the host stopped following it; it was not + /// submitted again. + MergeUnconfirmed { + id: String, + }, + /// The host started rebasing the stack; its progress is the stack's operation. + RebaseStarted, + /// Every layer was rebased: `pushed` were force-pushed, `current` needed nothing. + Rebased { + pushed: Vec, + current: Vec, + }, + /// The rebase stopped at `failed`: `pushed` stay rebased on GitHub, `untouched` above it + /// were not changed. + RebaseStopped { + pushed: Vec, + failed: u64, + reason: StackRebaseFailure, + untouched: Vec, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -526,12 +619,33 @@ pub enum PullRequestRejection { }, /// A read the write needed failed, so it was not sent. Failed, - /// Layer `index` of a native stack of `layers` merges with the layers below it, which Tcode - /// does not do yet. - InStack { - index: u32, - layers: u32, - }, /// Whether the pull request is in a native stack is not known yet. StackUnknown, + /// The stack's number or its layers are not what the write was confirmed against. + StackChanged, + /// Layer `number`'s head is `actual`, not the `expected` one the write was confirmed at. + LayerChanged { + number: u64, + expected: String, + actual: String, + }, + LayerNotOpen { + number: u64, + state: PullRequestState, + }, + LayerDraft { + number: u64, + }, + /// The account may not push to these layers' branches. + NoPushAccess { + numbers: Vec, + }, + /// A write to the stack is already running or unconfirmed on the host. + OperationRunning, + /// GitHub reports a merge of the stack already running without naming it. + MergeRunning, + /// The host's Git has no name or email to commit rebased layers with. + NoGitIdentity, + /// Stack writes go only from a pull request the thread links. + NotLinked, } diff --git a/crates/runtime/src/app/mod.rs b/crates/runtime/src/app/mod.rs index d91dbefd..16291168 100644 --- a/crates/runtime/src/app/mod.rs +++ b/crates/runtime/src/app/mod.rs @@ -254,6 +254,7 @@ mod options; mod orchestrate; mod plugins; mod providers; +mod pull_request_stacks; mod pull_request_watch; mod pull_requests; mod send; diff --git a/crates/runtime/src/app/pull_request_stacks.rs b/crates/runtime/src/app/pull_request_stacks.rs new file mode 100644 index 00000000..04af0908 --- /dev/null +++ b/crates/runtime/src/app/pull_request_stacks.rs @@ -0,0 +1,558 @@ +//! The host's writes to a native stack: an asynchronous merge it follows until GitHub answers or +//! five minutes pass, and a rebase it runs layer by layer. Either one is the stack's operation, +//! recorded with every thread that shows the stack so each client, and a restarted host, knows +//! it; one runs per stack at a time. + +use super::pull_requests::failure; +use super::*; +use tcode_core::pull_request::{ + self, PullRequestKey, PullRequestStackOperation, PullRequestStackState, PullRequestState, + StackOperationKind, StackRebaseLayer, StackRebaseStep, +}; +use tcode_protocol::{ + CommandResponse, ProtocolError, PullRequestAction, PullRequestActionResult as Outcome, + PullRequestRejection, PullRequestStackHead, RuntimeToast, +}; +use tcode_services::github::stack_actions::{ + MergeStatus, MergeSubmission, merge_outcome, poll_schedule, +}; + +/// An unconfirmed merge is kept this long when no sync reads what became of it. +const UNCONFIRMED_KEPT_SECS: u64 = 24 * 60 * 60; + +/// A native stack, by host, repository and number. +pub(super) type StackKey = (String, String, u64); + +fn answer(outcome: Outcome) -> Result { + Ok(CommandResponse::PullRequestAction(outcome)) +} + +fn stack_key(key: &PullRequestKey, stack: u64) -> StackKey { + (key.host.clone(), key.repository.clone(), stack) +} + +fn shows_stack(meta: &SessionMeta, (host, repository, number): &StackKey) -> bool { + meta.pull_requests.iter().any(|link| { + link.visible() + && link.key.host == *host + && link.key.repository == *repository + && matches!(&link.stack, PullRequestStackState::Native(stack) if stack.number == *number) + }) +} + +impl AppState { + /// The stack's operation as the threads record it. + fn stack_operation_record(&self, stack: &StackKey) -> Option { + self.sessions.iter().find_map(|meta| { + self.find_meta(&meta.id)? + .pull_request_operations + .into_iter() + .find(|operation| operation.is_for(&stack.0, &stack.1, stack.2)) + }) + } + + /// Records the stack's operation with every thread that shows the stack, or clears it from + /// every thread that has it. + fn put_stack_operation( + &mut self, + stack: &StackKey, + operation: Option, + cx: &mut HostCx, + ) { + let ids: Vec<_> = self.sessions.iter().map(|meta| meta.id.clone()).collect(); + for id in ids { + let Some(mut meta) = self.find_meta(&id) else { + continue; + }; + let held = meta + .pull_request_operations + .iter() + .any(|record| record.is_for(&stack.0, &stack.1, stack.2)); + let shows = meta.archived_at.is_none() && shows_stack(&meta, stack); + if !held && !(shows && operation.is_some()) { + continue; + } + meta.pull_request_operations + .retain(|record| !record.is_for(&stack.0, &stack.1, stack.2)); + if let Some(operation) = operation.clone().filter(|_| shows) { + meta.pull_request_operations.push(operation); + } + self.save_pull_request_meta(meta, cx); + } + } + + /// The thread a stack's result opens into, and the base its layers merge into. + fn stack_context(&self, stack: &StackKey) -> Option<(String, String)> { + self.sessions.iter().find_map(|meta| { + let meta = self.find_meta(&meta.id)?; + meta.pull_requests + .iter() + .find_map(|link| match &link.stack { + PullRequestStackState::Native(native) + if link.visible() + && link.key.host == stack.0 + && link.key.repository == stack.1 + && native.number == stack.2 => + { + Some((meta.id.clone(), native.base.clone())) + } + _ => None, + }) + }) + } + + fn report_stack( + &self, + stack: &StackKey, + target: u64, + layers: Vec, + result: Outcome, + late: bool, + cx: &mut HostCx, + ) { + let Some((session_id, base)) = self.stack_context(stack) else { + return; + }; + emit_runtime( + cx, + RuntimeEvent::Toast(RuntimeToast::PullRequestStack { + session_id, + target: PullRequestKey::new(&stack.0, &stack.1, target), + stack: stack.2, + layers, + base, + result, + late, + }), + ); + } + + fn sync_stack_layers(&mut self, stack: &StackKey, layers: &[u64], cx: &mut HostCx) { + for number in layers { + self.request_pull_request_sync(PullRequestKey::new(&stack.0, &stack.1, *number), cx); + } + } + + /// A merge or rebase of the stack `key` is a layer of. Only a pull request the thread links + /// writes to its stack, one write per stack at a time. + pub(super) fn run_stack_action( + &mut self, + session_id: &str, + key: PullRequestKey, + action: PullRequestAction, + cx: &mut HostCx, + ) -> HostTask> { + let refuse = + |rejection| cx.spawn_background(async move { answer(Outcome::Rejected(rejection)) }); + let links = self + .find_meta(session_id) + .map(|meta| meta.pull_requests) + .unwrap_or_default(); + if !links.iter().any(|link| link.visible() && link.key == key) { + return refuse(PullRequestRejection::NotLinked); + } + let Some(native) = pull_request::native_stack(&links, &key) else { + return refuse(PullRequestRejection::StackUnknown); + }; + let (PullRequestAction::MergeStack { stack, .. } + | PullRequestAction::RebaseStack { stack, .. }) = &action + else { + return refuse(PullRequestRejection::Invalid); + }; + if native.number != *stack { + return refuse(PullRequestRejection::StackChanged); + } + let stack = stack_key(&key, *stack); + let busy = self.pull_requests.stack_writes.contains(&stack) + || self + .stack_operation_record(&stack) + .is_some_and(|operation| match operation.kind { + StackOperationKind::MergeUnconfirmed { checked, .. } => !checked, + StackOperationKind::RebaseEnded { .. } => false, + _ => true, + }); + if busy { + return refuse(PullRequestRejection::OperationRunning); + } + self.pull_requests.stack_writes.insert(stack.clone()); + match action { + PullRequestAction::MergeStack { heads, method, .. } => { + self.merge_stack(stack, key, heads, method, cx) + } + PullRequestAction::RebaseStack { heads, .. } => { + self.rebase_stack(stack, key, heads, cx) + } + _ => unreachable!("matched above"), + } + } + + fn merge_stack( + &mut self, + stack: StackKey, + key: PullRequestKey, + heads: Vec, + method: tcode_core::pull_request::PullRequestMergeMethod, + cx: &mut HostCx, + ) -> HostTask> { + let reads = self.pull_requests.reads.clone(); + let submitting = key.clone(); + let number = stack.2; + let task = cx.unblock(move || reads.merge_stack(&submitting, number, &heads, method)); + let host = cx.clone(); + cx.spawn_background(async move { + let submission = task.await; + let outcome = host + .enqueue_and_wait(move |state, cx| { + state.pull_requests.stack_writes.remove(&stack); + match submission { + MergeSubmission::Done(outcome) => { + if !matches!(outcome, Outcome::Rejected(_)) { + state.request_pull_request_sync(key, cx); + } + outcome + } + MergeSubmission::Following { + id, + adopted, + layers, + } => { + let operation = PullRequestStackOperation { + host: stack.0.clone(), + repository: stack.1.clone(), + stack: stack.2, + started_at: now_secs(), + kind: StackOperationKind::Merging { + id: id.clone(), + target: key.number, + layers, + adopted, + }, + }; + state.put_stack_operation(&stack, Some(operation.clone()), cx); + state.follow_stack_merge(operation, cx); + Outcome::Pending { id, adopted } + } + } + }) + .await + .map_err(|_| failure("Host closed."))?; + answer(outcome) + }) + } + + /// Asks GitHub what became of the operation on the poll schedule from its submission, and + /// once more when a restarted host finds its schedule already past. Still pending at the + /// deadline, it is recorded as unconfirmed and never submitted again. + fn follow_stack_merge(&mut self, operation: PullRequestStackOperation, cx: &mut HostCx) { + let StackOperationKind::Merging { id, target, .. } = &operation.kind else { + return; + }; + let (id, started_at) = (id.clone(), operation.started_at); + let key = PullRequestKey::new(&operation.host, &operation.repository, *target); + let stack = ( + operation.host.clone(), + operation.repository.clone(), + operation.stack, + ); + let reads = self.pull_requests.reads.clone(); + let host = cx.clone(); + let task = cx.spawn_background(async move { + let elapsed = now_secs().saturating_sub(started_at); + let mut due: Vec<_> = poll_schedule() + .into_iter() + .filter(|offset| *offset >= elapsed) + .collect(); + if due.is_empty() { + due.push(elapsed); + } + let mut ended = None; + for offset in due { + let wait = (started_at + offset).saturating_sub(now_secs()); + smol::Timer::after(Duration::from_secs(wait)).await; + let (reads, key, id) = (reads.clone(), key.clone(), id.clone()); + match host.unblock(move || reads.merge_status(&key, &id)).await { + Ok(MergeStatus::Pending { .. }) => {} + Ok(status) => { + ended = Some(status); + break; + } + // An unanswered ask is no answer; the next one may be. + Err(error) => log::debug!("stack merge status skipped: {error}"), + } + } + host.enqueue(move |state, cx| state.end_stack_merge(&stack, &id, ended, cx)); + }); + self.pull_requests.workers.push(task); + } + + fn end_stack_merge( + &mut self, + stack: &StackKey, + id: &str, + ended: Option, + cx: &mut HostCx, + ) { + let Some(mut operation) = self.stack_operation_record(stack) else { + return; + }; + let StackOperationKind::Merging { + id: following, + target, + layers, + .. + } = &operation.kind + else { + return; + }; + if following != id { + return; + } + let (target, layers) = (*target, layers.clone()); + let result = match ended.as_ref().and_then(merge_outcome) { + Some(result) => { + self.put_stack_operation(stack, None, cx); + result + } + None => { + operation.kind = StackOperationKind::MergeUnconfirmed { + id: id.to_owned(), + target, + layers: layers.clone(), + checked: false, + }; + self.put_stack_operation(stack, Some(operation), cx); + Outcome::MergeUnconfirmed { id: id.to_owned() } + } + }; + self.sync_stack_layers(stack, &layers, cx); + self.report_stack(stack, target, layers, result, false, cx); + } + + fn rebase_stack( + &mut self, + stack: StackKey, + key: PullRequestKey, + heads: Vec, + cx: &mut HostCx, + ) -> HostTask> { + let reads = self.pull_requests.reads.clone(); + let planning = key.clone(); + let number = stack.2; + let task = cx.unblock(move || reads.plan_stack_rebase(&planning, number, &heads)); + let host = cx.clone(); + cx.spawn_background(async move { + let plan = match task.await { + Ok(plan) => plan, + Err(rejection) => { + let _ = host + .enqueue_and_wait(move |state, _| { + state.pull_requests.stack_writes.remove(&stack) + }) + .await; + return answer(Outcome::Rejected(rejection)); + } + }; + host.enqueue_and_wait(move |state, cx| state.start_stack_rebase(stack, key, plan, cx)) + .await + .map_err(|_| failure("Host closed."))?; + answer(Outcome::RebaseStarted) + }) + } + + fn start_stack_rebase( + &mut self, + stack: StackKey, + key: PullRequestKey, + plan: tcode_services::github::stack_actions::RebasePlan, + cx: &mut HostCx, + ) { + let layers: Vec<_> = plan + .layers + .iter() + .map(|layer| StackRebaseLayer { + number: layer.number, + branch: layer.branch.clone(), + step: StackRebaseStep::Waiting, + }) + .collect(); + let numbers: Vec<_> = layers.iter().map(|layer| layer.number).collect(); + self.put_stack_operation( + &stack, + Some(PullRequestStackOperation { + host: stack.0.clone(), + repository: stack.1.clone(), + stack: stack.2, + started_at: now_secs(), + kind: StackOperationKind::Rebasing { layers }, + }), + cx, + ); + let (progress, steps) = smol::channel::unbounded(); + let run = cx.unblock(move || { + plan.run(|index, step| { + let _ = progress.send_blocking((index, step)); + }) + }); + let host = cx.clone(); + let task = cx.spawn_background(async move { + while let Ok((index, step)) = steps.recv().await { + let stack = stack.clone(); + host.enqueue(move |state, cx| state.step_stack_rebase(&stack, index, step, cx)); + } + let outcome = run.await; + host.enqueue(move |state, cx| { + state.pull_requests.stack_writes.remove(&stack); + state.end_stack_rebase(&stack, cx); + state.sync_stack_layers(&stack, &numbers, cx); + state.report_stack(&stack, key.number, numbers, outcome, false, cx); + }); + }); + self.pull_requests.workers.push(task); + } + + fn step_stack_rebase( + &mut self, + stack: &StackKey, + index: usize, + step: StackRebaseStep, + cx: &mut HostCx, + ) { + let Some(mut operation) = self.stack_operation_record(stack) else { + return; + }; + let StackOperationKind::Rebasing { layers } = &mut operation.kind else { + return; + }; + let Some(layer) = layers.get_mut(index) else { + return; + }; + layer.step = step; + self.put_stack_operation(stack, Some(operation), cx); + } + + /// Keeps the rebase with each layer's final step until the next full sync, which drops it. + fn end_stack_rebase(&mut self, stack: &StackKey, cx: &mut HostCx) { + let Some(mut operation) = self.stack_operation_record(stack) else { + return; + }; + let StackOperationKind::Rebasing { layers } = operation.kind else { + return; + }; + operation.kind = StackOperationKind::RebaseEnded { layers }; + self.put_stack_operation(stack, Some(operation), cx); + } + + /// Drops every ended rebase: a full sync reads its layers as they now are. + pub(super) fn drop_ended_rebases(&mut self, cx: &mut HostCx) { + let ended: HashSet = self + .sessions + .iter() + .filter_map(|meta| self.find_meta(&meta.id)) + .flat_map(|meta| meta.pull_request_operations) + .filter(|operation| matches!(operation.kind, StackOperationKind::RebaseEnded { .. })) + .map(|operation| (operation.host, operation.repository, operation.stack)) + .collect(); + for stack in ended { + self.put_stack_operation(&stack, None, cx); + } + } + + /// After a restart: a merge is followed again from where its schedule stands; a rebase + /// cannot be, since its Git process ended with the host, so its layers are read again. + pub(super) fn resume_stack_operations(&mut self, cx: &mut HostCx) { + let mut seen = HashSet::new(); + let operations: Vec<_> = self + .sessions + .iter() + .filter_map(|meta| self.find_meta(&meta.id)) + .flat_map(|meta| meta.pull_request_operations) + .filter(|operation| { + seen.insert(( + operation.host.clone(), + operation.repository.clone(), + operation.stack, + )) + }) + .collect(); + for operation in operations { + let stack = ( + operation.host.clone(), + operation.repository.clone(), + operation.stack, + ); + match &operation.kind { + StackOperationKind::Merging { .. } => self.follow_stack_merge(operation, cx), + StackOperationKind::Rebasing { layers } => { + let numbers: Vec<_> = layers.iter().map(|layer| layer.number).collect(); + self.put_stack_operation(&stack, None, cx); + self.sync_stack_layers(&stack, &numbers, cx); + } + StackOperationKind::MergeUnconfirmed { .. } + | StackOperationKind::RebaseEnded { .. } => {} + } + } + } + + /// What a sync read of `key` says of an unconfirmed merge it is the target of: merged or + /// closed ends it, and still open lets the stack's writes go again, a later attempt + /// following GitHub's operation should it still run. Past a day it is dropped unread. + pub(super) fn reconcile_unconfirmed_merges( + &mut self, + key: Option<(&PullRequestKey, PullRequestState)>, + cx: &mut HostCx, + ) { + let mut seen = HashSet::new(); + let unconfirmed: Vec<_> = self + .sessions + .iter() + .filter_map(|meta| self.find_meta(&meta.id)) + .flat_map(|meta| meta.pull_request_operations) + .filter(|operation| { + matches!(operation.kind, StackOperationKind::MergeUnconfirmed { .. }) + && seen.insert(( + operation.host.clone(), + operation.repository.clone(), + operation.stack, + )) + }) + .collect(); + for mut operation in unconfirmed { + let stack = ( + operation.host.clone(), + operation.repository.clone(), + operation.stack, + ); + let StackOperationKind::MergeUnconfirmed { + target, + layers, + checked, + .. + } = &mut operation.kind + else { + continue; + }; + if now_secs().saturating_sub(operation.started_at) >= UNCONFIRMED_KEPT_SECS { + self.put_stack_operation(&stack, None, cx); + continue; + } + let Some((_, state)) = key.filter(|(key, _)| { + key.host == stack.0 && key.repository == stack.1 && key.number == *target + }) else { + continue; + }; + match state { + PullRequestState::Merged => { + let (target, layers) = (*target, layers.clone()); + self.put_stack_operation(&stack, None, cx); + self.report_stack(&stack, target, layers, Outcome::Applied, true, cx); + } + PullRequestState::Closed => self.put_stack_operation(&stack, None, cx), + PullRequestState::Open if !*checked => { + *checked = true; + self.put_stack_operation(&stack, Some(operation), cx); + } + PullRequestState::Open => {} + } + } + } +} diff --git a/crates/runtime/src/app/pull_requests.rs b/crates/runtime/src/app/pull_requests.rs index 7199ba5a..30dc5036 100644 --- a/crates/runtime/src/app/pull_requests.rs +++ b/crates/runtime/src/app/pull_requests.rs @@ -16,7 +16,7 @@ use tcode_services::github::{ pub(super) struct PullRequestRuntime { service: Arc, - reads: Arc, + pub(super) reads: Arc, last_synced: HashMap, requested: HashMap, generation: u64, @@ -27,7 +27,9 @@ pub(super) struct PullRequestRuntime { /// Threads triggered during a discovery pass, each with the refresh its triggers asked for. discover_again: HashMap, merge_commands: HashSet, - workers: Vec>, + pub(super) workers: Vec>, + /// Stacks with a write submitted and not yet answered. + pub(super) stack_writes: HashSet, /// Counts the review submissions that have landed, so a conversation read clears an /// unanswered one only when it began after it. submissions: u64, @@ -47,6 +49,7 @@ impl PullRequestRuntime { discover_again: HashMap::new(), merge_commands: HashSet::new(), workers: Vec::new(), + stack_writes: HashSet::new(), submissions: 0, } } @@ -59,7 +62,7 @@ struct SyncGroup { request: Option, } -fn failure(message: impl Into) -> ProtocolError { +pub(super) fn failure(message: impl Into) -> ProtocolError { ProtocolError { code: "pull_request_failed".into(), message: message.into(), @@ -371,6 +374,11 @@ impl AppState { reads.action_state(&key)?, PullRequestReadResponse::ActionState, ), + // Read for one confirmation and never kept: the write reads it again. + PullRequestRead::StackState { rebase } => ( + PullRequestReadResponse::StackState(reads.stack_state(&key, rebase)?), + SystemTime::now(), + ), PullRequestRead::Media { url, validator } => { let media = reads.media(&key, &url, validator.as_deref())?; let expires_at = match &media { @@ -479,8 +487,14 @@ impl AppState { if let Err(error) = self.linked_pull_request(session_id, &key) { return cx.spawn_background(async move { Err(error) }); } - // A native stack merges a layer with the layers below it, which is not one pull request's - // merge; until that route exists, nothing is sent for one, nor for a pull request whose + if matches!( + action, + PullRequestAction::MergeStack { .. } | PullRequestAction::RebaseStack { .. } + ) { + return self.run_stack_action(session_id, key, action, cx); + } + // A native stack merges a layer with the layers below it only through its own route, so + // nothing of one pull request's merge is sent for a layer, nor for a pull request whose // stack is not known yet. if matches!( action, @@ -492,8 +506,8 @@ impl AppState { .unwrap_or_default(); let rejection = match pull_request::stack_route(&links, &key).0 { pull_request::PullRequestStackRoute::Single => None, - pull_request::PullRequestStackRoute::Layer { index, layers } => { - Some(tcode_protocol::PullRequestRejection::InStack { index, layers }) + pull_request::PullRequestStackRoute::Layer { .. } => { + Some(tcode_protocol::PullRequestRejection::Invalid) } pull_request::PullRequestStackRoute::Unknown => { Some(tcode_protocol::PullRequestRejection::StackUnknown) @@ -763,6 +777,9 @@ impl AppState { resident .pull_request_reviews .clone_from(&meta.pull_request_reviews); + resident + .pull_request_operations + .clone_from(&meta.pull_request_operations); } self.persist_meta(&meta, cx); } @@ -863,6 +880,7 @@ impl AppState { }); self.pull_requests.workers.push(task); } + self.resume_stack_operations(cx); self.start_pull_request_watch_worker(cx); } pub(super) fn sweep_pull_requests( @@ -874,6 +892,10 @@ impl AppState { return cx.spawn_background(async {}); } self.pull_requests.syncing = true; + self.reconcile_unconfirmed_merges(None, cx); + if !requested_only { + self.drop_ended_rebases(cx); + } let sweep_generation = self.pull_requests.generation; let now = now_secs(); let mut groups: HashMap = HashMap::new(); @@ -1084,6 +1106,7 @@ impl AppState { self.pull_requests .last_synced .insert(key.clone(), now_secs()); + self.reconcile_unconfirmed_merges(Some((&key, summary.snapshot.state)), cx); if self.pull_requests.requested.get(&key).copied() == generation { self.pull_requests.requested.remove(&key); } diff --git a/crates/runtime/src/app/pull_requests_tests.rs b/crates/runtime/src/app/pull_requests_tests.rs index 9f4adc6b..b7945920 100644 --- a/crates/runtime/src/app/pull_requests_tests.rs +++ b/crates/runtime/src/app/pull_requests_tests.rs @@ -1615,10 +1615,7 @@ fn a_stack_layer_is_never_merged_alone_and_a_revert_is_linked_as_created() { for action in [merge(), update()] { assert_eq!( act(&state, &mut cx, action), - rejected(tcode_protocol::PullRequestRejection::InStack { - index: 2, - layers: 2 - }) + rejected(tcode_protocol::PullRequestRejection::Invalid) ); } assert!( @@ -1662,3 +1659,405 @@ fn a_stack_layer_is_never_merged_alone_and_a_revert_is_linked_as_created() { .update(&mut cx, |state, _| state.close_store()) .unwrap(); } + +/// GitHub as a stack merge meets it: stack #50 of #2 and #3 into `main`, an asynchronous merge +/// of #3 answering by operation, and the summaries the sync reads. +#[derive(Default)] +struct StackHost { + submissions: usize, + mutations: usize, + asked: Vec, + status: HashMap, + state: &'static str, +} + +fn stack_reply(host: &Mutex, exchange: fixture::Exchange) { + let line = exchange + .request + .lines() + .next() + .unwrap_or_default() + .to_owned(); + let mut host = host.lock().unwrap(); + let path = line.split(' ').nth(1).unwrap_or_default().to_owned(); + let reply = if path.starts_with("/repos/sample/project/stacks?pull_request=") + || path == "/repos/sample/project/stacks/50" + { + let layers: Vec<_> = [2, 3] + .map(|number| { + json!({ + "number": number, "title": format!("Change {number}"), "state": "open", + "draft": false, + "head": {"ref": format!("layer-{number}"), "sha": format!("{number}").repeat(40)}, + }) + }) + .to_vec(); + let stack = json!({ + "number": 50, "url": "https://api.github.com/repos/sample/project/stacks/50", + "base": {"ref": "main"}, + "pull_requests": layers, + }); + if path.ends_with("/50") { + stack + } else { + json!([stack]) + } + } else if line.starts_with("PUT /repos/sample/project/pulls/3/merge-async ") { + host.submissions += 1; + json!({"status": "pending", "details": {"uuid": "op-1"}}) + } else if let Some(id) = path.strip_prefix("/repos/sample/project/pulls/3/merge-async/") { + host.asked.push(id.to_owned()); + json!({"status": host.status.get(id).copied().unwrap_or("pending"), "details": {"uuid": id}}) + } else { + let sent: Value = serde_json::from_slice(&exchange.body).unwrap_or_default(); + if sent["query"] + .as_str() + .is_some_and(|query| query.trim_start().starts_with("mutation")) + { + host.mutations += 1; + } + let mut data = serde_json::Map::new(); + for (name, number) in sent["variables"].as_object().into_iter().flatten() { + if let Some(alias) = name.strip_suffix("_number") { + let mut row = pr(number.as_u64().unwrap(), host.state, true); + row["stack"] = json!({"number": 50}); + data.insert(alias.to_owned(), json!({"pullRequest": row})); + } + } + json!({"data": data}) + }; + exchange.reply(200, "", &serde_json::to_vec(&reply).unwrap()); +} + +/// A thread linking #3 and the layer below it, #2, of stack #50 into `main`. +fn stack_thread() -> SessionMeta { + let stack = PullRequestStackState::Native(PullRequestStack { + id: "50".into(), + number: 50, + url: "https://github.com/sample/project/stacks/50".into(), + base: "main".into(), + layers: [2, 3] + .map(|number| tcode_core::pull_request::PullRequestStackLayer { + url: format!("https://github.com/sample/project/pull/{number}"), + number, + head_branch: format!("layer-{number}"), + state: PullRequestState::Open, + }) + .to_vec(), + }); + let mut meta = linked("active", 3, false); + pull_request::link_pull_request( + &mut meta.pull_requests, + PullRequestKey::new("github.com", "sample/project", 2), + "https://github.com/sample/project/pull/2".into(), + PullRequestSource::Stack, + 1, + false, + ); + for link in &mut meta.pull_requests { + link.stack = stack.clone(); + } + meta +} + +#[test] +fn a_stack_merge_is_followed_across_a_restart_and_never_submitted_again() { + use tcode_core::pull_request::{PullRequestStackOperation, StackOperationKind}; + use tcode_protocol::{ + EventEnvelope, HostMessage, PullRequestRejection, PullRequestStackHead, + RuntimeNotification, RuntimeToast, ServerEvent, + }; + let dir = TestStore::new("tcode-pr-stack-merge"); + let fixture = fixture::Fixture::new(); + let api = client(&dir, &fixture); + let host = Arc::new(Mutex::new(StackHost { + state: "OPEN", + ..StackHost::default() + })); + let serving = host.clone(); + let _server = fixture.serve(move |exchange| stack_reply(&serving, exchange)); + let meta = stack_thread(); + let mut cx = TestAppContext::default(); + let state = cx.new_entity(TestClientState::new((*dir).clone())); + state.update(&mut cx, |state, _| { + state.pull_requests = PullRequestRuntime::new(api); + dir.upsert_meta(&meta).unwrap(); + state.sessions.push(meta); + }); + let target = PullRequestKey::new("github.com", "sample/project", 3); + let merge = PullRequestAction::MergeStack { + stack: 50, + heads: [2, 3] + .map(|number| PullRequestStackHead { + number, + head: format!("{number}").repeat(40), + }) + .to_vec(), + method: tcode_core::pull_request::PullRequestMergeMethod::Merge, + }; + let mut next_id = 0; + let mut act = |cx: &mut TestAppContext| { + next_id += 1; + let command = Command::RunPullRequestAction { + session_id: "active".into(), + key: target.clone(), + action: merge.clone(), + }; + acked(&state, cx, next_id, command).unwrap() + }; + let operation = |state: &TestEntity| { + state.read(|state| { + state + .find_meta("active") + .unwrap() + .pull_request_operations + .first() + .map(|operation| operation.kind.clone()) + }) + }; + let ended = |cx: &mut TestAppContext| { + cx.drain_outgoing() + .into_iter() + .filter_map(|message| match message { + HostMessage::Event(EventEnvelope { + event: + ServerEvent::Runtime(RuntimeNotification::Toast( + RuntimeToast::PullRequestStack { + result, + late, + layers, + .. + }, + )), + .. + }) => Some((result, late, layers)), + _ => None, + }) + .collect::>() + }; + + // Submitted once, then followed by its operation until GitHub says it merged. GitHub keeps + // answering pending until the record is read, so no poll can end it first. + assert_eq!( + act(&mut cx), + CommandResponse::PullRequestAction(PullRequestActionResult::Pending { + id: "op-1".into(), + adopted: false, + }) + ); + assert!(matches!( + operation(&state), + Some(StackOperationKind::Merging { ref id, target: 3, ref layers, adopted: false }) + if id == "op-1" && *layers == [2, 3] + )); + host.lock().unwrap().status.insert("op-1".into(), "merged"); + cx.run_until(|state| { + state + .find_meta("active") + .unwrap() + .pull_request_operations + .is_empty() + }); + assert_eq!( + ended(&mut cx), + vec![(PullRequestActionResult::Applied, false, vec![2, 3])] + ); + + let seed = |state: &TestEntity, cx: &mut TestAppContext, kind| { + state.update(cx, |state, cx| { + let mut meta = state.find_meta("active").unwrap(); + meta.pull_request_operations = vec![PullRequestStackOperation { + host: "github.com".into(), + repository: "sample/project".into(), + stack: 50, + started_at: now_secs() - 400, + kind, + }]; + state.save_pull_request_meta(meta, cx); + }) + }; + // Until a sync reads what became of an unconfirmed merge, the stack takes no other write. + seed( + &state, + &mut cx, + StackOperationKind::MergeUnconfirmed { + id: "op-0".into(), + target: 3, + layers: vec![2, 3], + checked: false, + }, + ); + assert_eq!( + act(&mut cx), + CommandResponse::PullRequestAction(PullRequestActionResult::Rejected( + PullRequestRejection::OperationRunning + )) + ); + + // A host restarted after the five minutes asks once more, finds it still pending, and + // keeps it as unconfirmed instead of submitting it again; the sync that then reads the + // target merged ends it, reported as finished late. + host.lock().unwrap().state = "MERGED"; + seed( + &state, + &mut cx, + StackOperationKind::Merging { + id: "op-2".into(), + target: 3, + layers: vec![2, 3], + adopted: false, + }, + ); + state.update(&mut cx, |state, cx| state.resume_stack_operations(cx)); + cx.run_until(|state| { + state + .find_meta("active") + .unwrap() + .pull_request_operations + .is_empty() + }); + assert_eq!( + ended(&mut cx), + vec![ + ( + PullRequestActionResult::MergeUnconfirmed { id: "op-2".into() }, + false, + vec![2, 3] + ), + (PullRequestActionResult::Applied, true, vec![2, 3]), + ] + ); + let host = host.lock().unwrap(); + assert_eq!(host.submissions, 1, "nothing is submitted again"); + assert_eq!(host.asked.iter().filter(|id| *id == "op-2").count(), 1); + assert_eq!( + host.mutations, 0, + "a stack merge is never one pull request's merge" + ); + drop(host); + state + .update(&mut cx, |state, _| state.close_store()) + .unwrap(); +} + +#[test] +fn an_ended_rebase_is_kept_for_every_device_until_the_next_full_sync_and_blocks_no_write() { + use tcode_core::pull_request::{ + PullRequestStackOperation, StackOperationKind, StackRebaseFailure, StackRebaseLayer, + StackRebaseStep, + }; + use tcode_protocol::PullRequestStackHead; + let dir = TestStore::new("tcode-pr-stack-rebase-ended"); + let fixture = fixture::Fixture::new(); + let api = client(&dir, &fixture); + let host = Arc::new(Mutex::new(StackHost { + state: "OPEN", + ..StackHost::default() + })); + let serving = host.clone(); + let _server = fixture.serve(move |exchange| stack_reply(&serving, exchange)); + let mut meta = stack_thread(); + let ended = StackOperationKind::RebaseEnded { + layers: vec![ + StackRebaseLayer { + number: 2, + branch: "layer-2".into(), + step: StackRebaseStep::Pushed { + from: "2".repeat(40), + to: "4".repeat(40), + }, + }, + StackRebaseLayer { + number: 3, + branch: "layer-3".into(), + step: StackRebaseStep::Failed { + reason: StackRebaseFailure::Conflict, + }, + }, + ], + }; + meta.pull_request_operations = vec![PullRequestStackOperation { + host: "github.com".into(), + repository: "sample/project".into(), + stack: 50, + started_at: now_secs(), + kind: ended.clone(), + }]; + let mut cx = TestAppContext::default(); + let state = cx.new_entity(TestClientState::new((*dir).clone())); + state.update(&mut cx, |state, _| { + state.pull_requests = PullRequestRuntime::new(api); + dir.upsert_meta(&meta).unwrap(); + state.sessions.push(meta); + }); + let operation = |state: &TestEntity| { + state.read(|state| { + state + .find_meta("active") + .unwrap() + .pull_request_operations + .first() + .map(|operation| operation.kind.clone()) + }) + }; + + // The reads its end asks for refresh the layers and keep how it ended. + state.update(&mut cx, |state, cx| { + state.request_pull_request_sync(PullRequestKey::new("github.com", "sample/project", 3), cx) + }); + cx.run_until(|state| { + !state.pull_requests.syncing + && !state.pull_requests.sync_scheduled + && state.pull_requests.requested.is_empty() + }); + assert_eq!(operation(&state), Some(ended.clone())); + + // A stopped rebase is no running write: the stack takes the next one. + host.lock().unwrap().status.insert("op-1".into(), "merged"); + let merge = Command::RunPullRequestAction { + session_id: "active".into(), + key: PullRequestKey::new("github.com", "sample/project", 3), + action: PullRequestAction::MergeStack { + stack: 50, + heads: [2, 3] + .map(|number| PullRequestStackHead { + number, + head: format!("{number}").repeat(40), + }) + .to_vec(), + method: tcode_core::pull_request::PullRequestMergeMethod::Merge, + }, + }; + assert_eq!( + acked(&state, &mut cx, 1, merge).unwrap(), + CommandResponse::PullRequestAction(PullRequestActionResult::Pending { + id: "op-1".into(), + adopted: false, + }) + ); + cx.run_until(|state| { + state + .find_meta("active") + .unwrap() + .pull_request_operations + .is_empty() + }); + + // The next full sync drops it. + state.update(&mut cx, |state, cx| { + let mut meta = state.find_meta("active").unwrap(); + meta.pull_request_operations = vec![PullRequestStackOperation { + host: "github.com".into(), + repository: "sample/project".into(), + stack: 50, + started_at: now_secs(), + kind: ended, + }]; + state.save_pull_request_meta(meta, cx); + }); + sweep(&state, &mut cx); + assert_eq!(operation(&state), None); + state + .update(&mut cx, |state, _| state.close_store()) + .unwrap(); +} diff --git a/crates/services/src/github/api.rs b/crates/services/src/github/api.rs index f88c071d..22819c23 100644 --- a/crates/services/src/github/api.rs +++ b/crates/services/src/github/api.rs @@ -104,6 +104,9 @@ pub struct RestRequest<'a> { pub if_none_match: Option<&'a str>, /// Replaces the JSON media type, for representations such as a raw diff or file. pub accept: Option<&'a str>, + /// Error statuses whose body the caller reads as an answer, such as a conflict naming what it + /// conflicts with. + pub answers: &'a [u16], } impl<'a> RestRequest<'a> { pub fn get(path: &'a str) -> Self { @@ -113,6 +116,7 @@ impl<'a> RestRequest<'a> { body: None, if_none_match: None, accept: None, + answers: &[], } } } @@ -215,6 +219,7 @@ impl GitHubApi { body: Some(&body), if_none_match: None, accept: None, + answers: &[], }, Some(&document.query), options, @@ -445,7 +450,10 @@ impl GitHubApi { if errors.is_some() { return Err(GitHubError::Response { status, messages }); } - if (200..300).contains(&status) || (status == 304 && input.if_none_match.is_some()) { + if (200..300).contains(&status) + || (status == 304 && input.if_none_match.is_some()) + || input.answers.contains(&status) + { self.ledger.lock().unwrap().succeeded(host, scope, lease); if graphql.is_some() && truncated { return Err(GitHubError::BodyTooLarge); diff --git a/crates/services/src/github/mod.rs b/crates/services/src/github/mod.rs index 684b0ff7..a01cd7cf 100644 --- a/crates/services/src/github/mod.rs +++ b/crates/services/src/github/mod.rs @@ -12,6 +12,8 @@ pub mod pull_requests; mod quota; mod read_cache; pub mod repository; +pub mod stack_actions; +pub mod stack_rebase; pub use api::{GitHubApi, GitHubError, RequestOptions, Response, RestRequest}; pub use credentials::{Credential, CredentialError, Credentials, Identity}; diff --git a/crates/services/src/github/pull_request_actions.rs b/crates/services/src/github/pull_request_actions.rs index f2b40b36..7875ae16 100644 --- a/crates/services/src/github/pull_request_actions.rs +++ b/crates/services/src/github/pull_request_actions.rs @@ -261,7 +261,7 @@ impl Reader<'_> { } /// Read now, not from the cache: a merge or a branch update acts on the head as it stands. - fn action_state(&self) -> Result<(String, PullRequestActionState), GitHubError> { + pub(super) fn action_state(&self) -> Result<(String, PullRequestActionState), GitHubError> { let response = self.query( "PullRequestActionState", ACTION_STATE.to_owned(), @@ -380,6 +380,7 @@ impl Reader<'_> { body, if_none_match: None, accept: None, + answers: &[], }, &RequestOptions { operation, @@ -491,7 +492,10 @@ impl PullRequestReads { json!({"subjectId": subject, "body": body}), ) } - PullRequestAction::SubmitReview { .. } => return Err(Rejection::Invalid), + // A review takes the runtime's draft, and a stack write the stack's own route. + PullRequestAction::SubmitReview { .. } + | PullRequestAction::MergeStack { .. } + | PullRequestAction::RebaseStack { .. } => return Err(Rejection::Invalid), PullRequestAction::ReplyToThread { thread_id, body } => { if blank(body) { return Err(Rejection::Invalid); diff --git a/crates/services/src/github/pull_requests.rs b/crates/services/src/github/pull_requests.rs index 4220ae4f..1a7faf7b 100644 --- a/crates/services/src/github/pull_requests.rs +++ b/crates/services/src/github/pull_requests.rs @@ -471,7 +471,7 @@ fn decode_summary(raw: &Value, key: &PullRequestKey) -> Option { stack_number: raw.get("stack").map(|stack| stack["number"].as_u64()), }) } -fn decode_stack(raw: &Value, repository: &Repository) -> Option { +pub(super) fn decode_stack(raw: &Value, repository: &Repository) -> Option { let number = raw["number"].as_u64()?; let id = raw["id"] .as_str() diff --git a/crates/services/src/github/stack_actions.rs b/crates/services/src/github/stack_actions.rs new file mode 100644 index 00000000..a6b8dc60 --- /dev/null +++ b/crates/services/src/github/stack_actions.rs @@ -0,0 +1,575 @@ +//! Writes to a GitHub native stack: the asynchronous merge of a layer with the layers below it, +//! and the rebase of every unmerged layer onto the one below it. Each re-reads the stack first +//! and goes ahead only at exactly the layers and heads the user confirmed. + +use super::{ + GitHubError, RequestOptions, RestRequest, + api::Authentication, + graphql::Document, + pull_request_actions::rejection, + pull_request_reads::{PullRequestReads, Reader, is_revision, percent_encode}, + pull_requests::decode_stack, + stack_rebase::{self, Identity, RebaseLayer}, +}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use tcode_core::pull_request::{ + PullRequestKey, PullRequestMergeMethod, PullRequestState, StackRebaseStep, +}; +use tcode_protocol::{ + PullRequestActionResult as Outcome, PullRequestRejection as Rejection, + PullRequestStackActionState, PullRequestStackHead, PullRequestStackLayerState, + PullRequestStackPushAccess, +}; + +/// What GitHub said of an asynchronous merge, on submission or when asked again. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MergeStatus { + Pending { id: String }, + Merged, + Enqueued, + Failed { message: Option }, +} + +/// What became of a stack merge's submission. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MergeSubmission { + /// Answered at once, or refused before anything was sent. + Done(Outcome), + /// GitHub works on operation `id`; `adopted` when it was already running. + Following { + id: String, + adopted: bool, + layers: Vec, + }, +} + +/// A rebase the host may start: the stack's base, its unmerged layers bottom first with the +/// heads that were reviewed, and where to fetch and push them. +#[derive(Clone)] +pub struct RebasePlan { + pub base: String, + pub layers: Vec, + pub remote: String, + token: String, + pub identity: Identity, +} + +struct FreshStack { + number: u64, + base: String, + layers: Vec, +} + +fn method_name(method: PullRequestMergeMethod) -> &'static str { + match method { + PullRequestMergeMethod::Merge => "merge", + PullRequestMergeMethod::Squash => "squash", + PullRequestMergeMethod::Rebase => "rebase", + } +} + +fn merge_status(body: &Value) -> Option { + let details = &body["details"]; + Some(match body["status"].as_str()? { + "pending" => MergeStatus::Pending { + id: details["uuid"] + .as_str() + .filter(|id| !id.is_empty())? + .to_owned(), + }, + "merged" => MergeStatus::Merged, + "enqueued" => MergeStatus::Enqueued, + "failed" => MergeStatus::Failed { + message: details["message"] + .as_str() + .filter(|message| !message.trim().is_empty()) + .map(str::to_owned), + }, + _ => return None, + }) +} + +/// What a finished asynchronous merge comes to. +pub fn merge_outcome(status: &MergeStatus) -> Option { + Some(match status { + MergeStatus::Pending { .. } => return None, + MergeStatus::Merged => Outcome::Applied, + MergeStatus::Enqueued => Outcome::Queued { position: None }, + MergeStatus::Failed { message } => Outcome::Rejected(Rejection::Refused { + messages: message.iter().cloned().collect(), + }), + }) +} + +/// The seconds after submission at which a pending merge is asked about again: 1, 2, 4, 8 and +/// then 10 seconds apart, up to five minutes. +pub fn poll_schedule() -> Vec { + let mut at = Vec::new(); + let mut elapsed = 0; + for attempt in 0.. { + elapsed += (1u64 << attempt.min(4)).min(10); + if elapsed > MERGE_DEADLINE_SECS { + break; + } + at.push(elapsed); + } + at +} + +/// How long the host follows a pending merge after submitting it. +pub const MERGE_DEADLINE_SECS: u64 = 300; + +/// The layers a write goes ahead with must be exactly `expected`, each at its confirmed head: +/// a layer added, removed or reordered, or a head moved, rejects it before anything is sent. +fn check_heads( + layers: &[&PullRequestStackLayerState], + expected: &[PullRequestStackHead], +) -> Result<(), Rejection> { + if layers.len() != expected.len() + || layers + .iter() + .zip(expected) + .any(|(layer, expected)| layer.number != expected.number) + { + return Err(Rejection::StackChanged); + } + for (layer, expected) in layers.iter().zip(expected) { + match &layer.head { + Some(head) if *head == expected.head => {} + Some(head) => { + return Err(Rejection::LayerChanged { + number: layer.number, + expected: expected.head.clone(), + actual: head.clone(), + }); + } + None => return Err(Rejection::StackChanged), + } + } + Ok(()) +} + +impl Reader<'_> { + /// The stack the pull request is in, read now: `None` when it is in none. The listing names + /// the stack; its detail carries each layer's title as well as its head and state. + fn fresh_stack(&self) -> Result, GitHubError> { + let read = |path: String| { + self.api.rest( + &self.key.host, + RestRequest::get(&self.rest_path(&path)), + &RequestOptions { + operation: "PullRequestStackState", + ..self.options.clone() + }, + ) + }; + let raw: Value = match read(format!("stacks?pull_request={}", self.key.number)) { + Ok(response) => response.json()?, + Err(GitHubError::NotFound) => return Ok(None), + Err(error) => return Err(error), + }; + let Some(listed) = raw.as_array().ok_or(GitHubError::InvalidResponse)?.first() else { + return Ok(None); + }; + let number = listed["number"] + .as_u64() + .ok_or(GitHubError::InvalidResponse)?; + let stack: Value = read(format!("stacks/{number}"))?.json()?; + let topology = + decode_stack(&stack, &self.repository).ok_or(GitHubError::InvalidResponse)?; + let rows = stack["pull_requests"] + .as_array() + .ok_or(GitHubError::InvalidResponse)?; + let layers = topology + .layers + .iter() + .zip(rows) + .map(|(layer, row)| PullRequestStackLayerState { + number: layer.number, + title: row["title"].as_str().unwrap_or_default().to_owned(), + head_branch: layer.head_branch.clone(), + head: row["head"]["sha"] + .as_str() + .filter(|sha| is_revision(sha)) + .map(str::to_owned), + state: layer.state, + draft: row["draft"].as_bool() == Some(true), + push: None, + }) + .collect(); + Ok(Some(FreshStack { + number: topology.number, + base: topology.base, + layers, + })) + } + + /// Whether the account may push to each layer's branch: a write role on its repository, or + /// a fork's branch that allows maintainers to push. + fn push_access( + &self, + numbers: &[u64], + ) -> Result, GitHubError> { + if numbers.is_empty() { + return Ok(BTreeMap::new()); + } + let fields: String = numbers + .iter() + .map(|number| { + format!( + " pr{number}: pullRequest(number: {number}) {{ headRepository {{ viewerPermission }} maintainerCanModify }}" + ) + }) + .collect(); + let document = Document { + query: format!( + "query PullRequestStackPushAccess($owner: String!, $name: String!) {{ repository(owner: $owner, name: $name) {{{fields} }} }}" + ), + variables: BTreeMap::from([ + ("owner".to_owned(), json!(self.repository.owner)), + ("name".to_owned(), json!(self.repository.name)), + ]), + }; + let response: Value = self + .api + .graphql( + &self.key.host, + &document, + &RequestOptions { + operation: "PullRequestStackPushAccess", + ..self.options.clone() + }, + )? + .json()?; + Ok(numbers + .iter() + .map(|number| { + let pr = &response["data"]["repository"][format!("pr{number}")]; + let write = matches!( + pr["headRepository"]["viewerPermission"].as_str(), + Some("ADMIN" | "MAINTAIN" | "WRITE") + ); + let access = if pr["headRepository"].is_null() { + PullRequestStackPushAccess::Denied + } else if write { + PullRequestStackPushAccess::Write + } else if pr["maintainerCanModify"].as_bool() == Some(true) { + PullRequestStackPushAccess::MaintainerCanModify + } else { + PullRequestStackPushAccess::Denied + }; + (*number, access) + }) + .collect()) + } + + fn token(&self) -> Result { + match &self.options.authentication { + Authentication::Pinned(credential) => Ok(credential.token.clone()), + _ => Err(GitHubError::Request), + } + } +} + +impl PullRequestReads { + /// The stack as GitHub has it now, with what a merge of `key` or a rebase would meet. + pub fn stack_state( + &self, + key: &PullRequestKey, + rebase: bool, + ) -> Result { + let reader = self.reader(key)?; + let stack = reader.fresh_stack()?.ok_or(GitHubError::NotFound)?; + let (_, action) = reader.action_state()?; + let mut layers = stack.layers; + let mut git_identity = None; + if rebase { + let unmerged: Vec<_> = layers + .iter() + .filter(|layer| layer.state != PullRequestState::Merged) + .map(|layer| layer.number) + .collect(); + let access = reader.push_access(&unmerged)?; + for layer in &mut layers { + layer.push = access.get(&layer.number).copied(); + } + git_identity = Some(stack_rebase::identity().is_some()); + } + Ok(PullRequestStackActionState { + stack: stack.number, + base: stack.base, + layers, + merge_methods: action.merge_methods, + merge_queue: action.merge_queue, + can_merge: action.can_merge, + git_identity, + }) + } + + /// Submits GitHub's asynchronous merge of `key` and every unmerged layer below it, once a + /// fresh read of the stack shows exactly the confirmed scope at its confirmed heads, every + /// layer of it open and ready for review. A conflict naming an operation already running is + /// followed instead of submitting another. + pub fn merge_stack( + &self, + key: &PullRequestKey, + stack: u64, + heads: &[PullRequestStackHead], + method: PullRequestMergeMethod, + ) -> MergeSubmission { + self.invalidate(key); + let submission = self + .submit_stack_merge(key, stack, heads, method) + .unwrap_or_else(|rejection| MergeSubmission::Done(Outcome::Rejected(rejection))); + self.invalidate(key); + submission + } + + fn submit_stack_merge( + &self, + key: &PullRequestKey, + stack: u64, + heads: &[PullRequestStackHead], + method: PullRequestMergeMethod, + ) -> Result { + let reader = self.reader(key).map_err(rejection)?; + let fresh = reader + .fresh_stack() + .map_err(rejection)? + .ok_or(Rejection::StackChanged)?; + let Some(target) = fresh + .layers + .iter() + .position(|layer| layer.number == key.number) + else { + return Err(Rejection::StackChanged); + }; + if fresh.number != stack { + return Err(Rejection::StackChanged); + } + let selected = &fresh.layers[target]; + if selected.state != PullRequestState::Open { + return Err(Rejection::LayerNotOpen { + number: selected.number, + state: selected.state, + }); + } + let scope: Vec<_> = fresh.layers[..=target] + .iter() + .filter(|layer| layer.state != PullRequestState::Merged) + .collect(); + check_heads(&scope, heads)?; + if let Some(layer) = scope + .iter() + .find(|layer| layer.state != PullRequestState::Open) + { + return Err(Rejection::LayerNotOpen { + number: layer.number, + state: layer.state, + }); + } + if let Some(layer) = scope.iter().find(|layer| layer.draft) { + return Err(Rejection::LayerDraft { + number: layer.number, + }); + } + let layers: Vec<_> = scope.iter().map(|layer| layer.number).collect(); + let head = selected.head.clone().ok_or(Rejection::StackChanged)?; + let body = json!({ + "merge_method": method_name(method), + "merge_action": "default", + "sha": head, + }); + let answer = reader.api.rest( + &key.host, + RestRequest { + method: "PUT", + path: &reader.rest_path(&format!("pulls/{}/merge-async", key.number)), + body: Some(&body), + if_none_match: None, + accept: None, + answers: &[409], + }, + &RequestOptions { + operation: "MergePullRequestStack", + ..reader.options.clone() + }, + ); + let response = match answer { + Ok(response) => response, + Err( + GitHubError::Request + | GitHubError::Deadline + | GitHubError::BodyTooLarge + | GitHubError::InvalidResponse, + ) => return Ok(MergeSubmission::Done(Outcome::Uncertain)), + Err(GitHubError::Response { status, .. }) if status >= 500 => { + return Ok(MergeSubmission::Done(Outcome::Uncertain)); + } + Err(error) => return Err(rejection(error)), + }; + let parsed: Value = response.json().unwrap_or_default(); + if response.status == 409 { + // GitHub keeps one merge request per stack; the one it names is followed, never + // a second submitted. + return match parsed["details"]["uuid"] + .as_str() + .or(parsed["uuid"].as_str()) + .filter(|id| !id.is_empty()) + { + Some(id) => Ok(MergeSubmission::Following { + id: id.to_owned(), + adopted: true, + layers, + }), + None => Err(Rejection::MergeRunning), + }; + } + Ok(match merge_status(&parsed) { + Some(MergeStatus::Pending { id }) => MergeSubmission::Following { + id, + adopted: false, + layers, + }, + Some(status) => { + MergeSubmission::Done(merge_outcome(&status).unwrap_or(Outcome::Uncertain)) + } + // Something answered, and the merge may be under way. + None => MergeSubmission::Done(Outcome::Uncertain), + }) + } + + /// Asks GitHub once what became of operation `id`. + pub fn merge_status(&self, key: &PullRequestKey, id: &str) -> Result { + let reader = self.reader(key)?; + let raw: Value = reader + .api + .rest( + &key.host, + RestRequest::get(&reader.rest_path(&format!( + "pulls/{}/merge-async/{}", + key.number, + percent_encode(id) + ))), + &RequestOptions { + operation: "PullRequestStackMergeStatus", + ..reader.options.clone() + }, + )? + .json()?; + merge_status(&raw).ok_or(GitHubError::InvalidResponse) + } + + /// The rebase `key`'s stack may start: a fresh read shows exactly the confirmed unmerged + /// layers at their confirmed heads, all open, the account may push to every one of them, and + /// the host's Git has a name and an email to commit with. Nothing is written. + pub fn plan_stack_rebase( + &self, + key: &PullRequestKey, + stack: u64, + heads: &[PullRequestStackHead], + ) -> Result { + let reader = self.reader(key).map_err(rejection)?; + let fresh = reader + .fresh_stack() + .map_err(rejection)? + .ok_or(Rejection::StackChanged)?; + if fresh.number != stack { + return Err(Rejection::StackChanged); + } + let unmerged: Vec<_> = fresh + .layers + .iter() + .filter(|layer| layer.state != PullRequestState::Merged) + .collect(); + check_heads(&unmerged, heads)?; + if let Some(layer) = unmerged + .iter() + .find(|layer| layer.state != PullRequestState::Open) + { + return Err(Rejection::LayerNotOpen { + number: layer.number, + state: layer.state, + }); + } + let numbers: Vec<_> = unmerged.iter().map(|layer| layer.number).collect(); + let access = reader.push_access(&numbers).map_err(rejection)?; + let denied: Vec<_> = numbers + .iter() + .copied() + .filter(|number| { + access + .get(number) + .is_none_or(|access| *access == PullRequestStackPushAccess::Denied) + }) + .collect(); + if !denied.is_empty() { + return Err(Rejection::NoPushAccess { numbers: denied }); + } + let identity = stack_rebase::identity().ok_or(Rejection::NoGitIdentity)?; + Ok(RebasePlan { + base: fresh.base, + layers: unmerged + .iter() + .map(|layer| RebaseLayer { + number: layer.number, + branch: layer.head_branch.clone(), + head: layer.head.clone().unwrap_or_default(), + }) + .collect(), + remote: format!( + "https://{}/{}/{}.git", + key.host, reader.repository.owner, reader.repository.name + ), + token: reader.token().map_err(rejection)?, + identity, + }) + } +} + +impl RebasePlan { + /// Runs the planned rebase, telling `progress` each layer's step, and answers how it ended. + pub fn run(&self, progress: impl FnMut(usize, StackRebaseStep)) -> Outcome { + let steps = stack_rebase::cascade( + &self.remote, + &self.token, + &self.identity, + &self.base, + &self.layers, + progress, + ); + rebase_outcome(&self.layers, &steps) + } +} + +/// What the layers' final steps come to. +pub fn rebase_outcome(layers: &[RebaseLayer], steps: &[StackRebaseStep]) -> Outcome { + let numbers = |wanted: fn(&StackRebaseStep) -> bool| -> Vec { + layers + .iter() + .zip(steps) + .filter(|(_, step)| wanted(step)) + .map(|(layer, _)| layer.number) + .collect() + }; + let pushed = numbers(|step| matches!(step, StackRebaseStep::Pushed { .. })); + match layers + .iter() + .zip(steps) + .find_map(|(layer, step)| match step { + StackRebaseStep::Failed { reason } => Some((layer.number, reason.clone())), + _ => None, + }) { + Some((failed, reason)) => Outcome::RebaseStopped { + pushed, + failed, + reason, + untouched: numbers(|step| matches!(step, StackRebaseStep::NotStarted)), + }, + None => Outcome::Rebased { + pushed, + current: numbers(|step| matches!(step, StackRebaseStep::AlreadyCurrent)), + }, + } +} diff --git a/crates/services/src/github/stack_rebase.rs b/crates/services/src/github/stack_rebase.rs new file mode 100644 index 00000000..5ad2c24d --- /dev/null +++ b/crates/services/src/github/stack_rebase.rs @@ -0,0 +1,358 @@ +//! A native stack's rebase, which GitHub has no endpoint for: every unmerged layer, bottom to +//! top, moves its own commits onto the new head of the layer below it (the bottom one onto the +//! base) in a scratch clone, and each moved branch is force-pushed only while it is still at +//! the head that was reviewed. The project's checkout is never touched. + +use std::{ + io::Read as _, + path::{Path, PathBuf}, + process::Stdio, + time::{Duration, Instant}, +}; +use tcode_core::pull_request::{StackRebaseFailure, StackRebaseGitStep, StackRebaseStep}; + +/// Each Git command of a rebase ends by this. +const GIT_TIMEOUT: Duration = Duration::from_secs(120); + +/// One unmerged layer, bottom first, with the head that was reviewed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RebaseLayer { + pub number: u64, + pub branch: String, + pub head: String, +} + +/// The name and email the rebased commits are committed with: the host's own Git identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Identity { + name: String, + email: String, +} + +impl Identity { + pub fn new(name: impl Into, email: impl Into) -> Self { + Self { + name: name.into(), + email: email.into(), + } + } +} + +/// The host's Git name and email, as a new repository on it would commit with. +pub fn identity() -> Option { + let dir = std::env::temp_dir(); + let read = |key: &str| match git(&dir, &[], &["config", "--get", key]) { + Run::Exited { + ok: true, stdout, .. + } => Some(stdout.trim().to_owned()).filter(|value| !value.is_empty()), + _ => None, + }; + Some(Identity::new(read("user.name")?, read("user.email")?)) +} + +enum Run { + Exited { + ok: bool, + stdout: String, + stderr: String, + }, + TimedOut, + Failed(String), +} + +fn git(dir: &Path, env: &[(String, String)], args: &[&str]) -> Run { + let mut command = crate::process::command("git"); + command + .args(args) + .current_dir(dir) + .env("GIT_TERMINAL_PROMPT", "0") + // The lease refusal is recognised by Git's own words, which a host locale translates. + .env("LC_ALL", "C") + .env("LANGUAGE", "") + .envs(env.iter().map(|(key, value)| (key, value))) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => return Run::Failed(error.to_string()), + }; + let drain = |pipe: Option>| { + std::thread::spawn(move || { + let mut text = String::new(); + if let Some(mut pipe) = pipe { + let _ = pipe.read_to_string(&mut text); + } + text + }) + }; + let stdout = drain(child.stdout.take().map(|pipe| Box::new(pipe) as _)); + let stderr = drain(child.stderr.take().map(|pipe| Box::new(pipe) as _)); + let started = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) if started.elapsed() < GIT_TIMEOUT => { + std::thread::sleep(Duration::from_millis(20)) + } + _ => { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + } + }; + let (stdout, stderr) = ( + stdout.join().unwrap_or_default(), + stderr.join().unwrap_or_default(), + ); + match status { + Some(status) => Run::Exited { + ok: status.success(), + stdout, + stderr, + }, + None => Run::TimedOut, + } +} + +/// A directory removed with everything in it once the rebase ends. +struct Scratch(PathBuf); + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Rebases `layers` of the stack on `base` at `remote`, telling `progress` each layer's step, +/// and answers every layer's final step. It stops at the first layer that fails: the scratch +/// rebase is aborted, that layer's branch is left as it was, the layers below it stay pushed +/// and those above it are not started. `token` rides only in this run's Git environment. +/// `identity` is the one the plan found before the user confirmed, passed in rather than left +/// to the scratch clone's Git, so a host without a name or email refuses the rebase before +/// anything is pushed instead of failing at the first layer's commit. +pub fn cascade( + remote: &str, + token: &str, + identity: &Identity, + base: &str, + layers: &[RebaseLayer], + mut progress: impl FnMut(usize, StackRebaseStep), +) -> Vec { + let mut steps = vec![StackRebaseStep::Waiting; layers.len()]; + if layers.is_empty() { + return steps; + } + let mut set = |steps: &mut Vec, index: usize, step: StackRebaseStep| { + steps[index] = step.clone(); + progress(index, step); + }; + let fail = |steps: &mut Vec, + set: &mut dyn FnMut(&mut Vec, usize, StackRebaseStep), + index: usize, + reason: StackRebaseFailure| { + set(steps, index, StackRebaseStep::Failed { reason }); + for above in index + 1..layers.len() { + set(steps, above, StackRebaseStep::NotStarted); + } + }; + let git_failure = |step: StackRebaseGitStep, run: Run| StackRebaseFailure::Git { + step, + message: match run { + Run::Exited { stderr, .. } => { + stderr.trim().lines().last().unwrap_or_default().to_owned() + } + Run::TimedOut => "timed out".to_owned(), + Run::Failed(message) => message, + }, + }; + let scratch = Scratch(std::env::temp_dir().join(format!( + "tcode-stack-rebase-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ))); + if let Err(error) = std::fs::create_dir_all(&scratch.0) { + fail( + &mut steps, + &mut set, + 0, + StackRebaseFailure::Git { + step: StackRebaseGitStep::Preparing, + message: error.to_string(), + }, + ); + return steps; + } + use base64::Engine as _; + let basic = base64::engine::general_purpose::STANDARD.encode(format!("x-access-token:{token}")); + let config = [ + ("user.name".to_owned(), identity.name.clone()), + ("user.email".to_owned(), identity.email.clone()), + ( + format!("http.{remote}.extraheader"), + format!("AUTHORIZATION: basic {basic}"), + ), + ]; + let mut env = vec![("GIT_CONFIG_COUNT".to_owned(), config.len().to_string())]; + for (index, (key, value)) in config.into_iter().enumerate() { + env.push((format!("GIT_CONFIG_KEY_{index}"), key)); + env.push((format!("GIT_CONFIG_VALUE_{index}"), value)); + } + let run = |args: &[&str]| git(&scratch.0, &env, args); + let ok = |run: &Run| matches!(run, Run::Exited { ok: true, .. }); + let output = |run: Run| match run { + Run::Exited { stdout, .. } => stdout.trim().to_owned(), + _ => String::new(), + }; + + for args in [ + &["init", "--quiet"][..], + &["remote", "add", "origin", remote][..], + ] { + let result = run(args); + if !ok(&result) { + fail( + &mut steps, + &mut set, + 0, + git_failure(StackRebaseGitStep::Preparing, result), + ); + return steps; + } + } + let mut refspecs = vec![format!("+refs/heads/{base}:refs/remotes/origin/{base}")]; + refspecs.extend( + layers + .iter() + .map(|layer| format!("+refs/heads/{0}:refs/remotes/origin/{0}", layer.branch)), + ); + let mut fetch = vec!["fetch", "--quiet", "--no-tags", "origin"]; + fetch.extend(refspecs.iter().map(String::as_str)); + let fetched = run(&fetch); + if !ok(&fetched) { + fail( + &mut steps, + &mut set, + 0, + git_failure(StackRebaseGitStep::Fetching, fetched), + ); + return steps; + } + + let base_ref = format!("origin/{base}"); + let mut parent_old = base_ref.clone(); + let mut parent_new = base_ref; + for (index, layer) in layers.iter().enumerate() { + set(&mut steps, index, StackRebaseStep::Rebasing); + // The bottom layer's own commits start where it forked from the base; above it they + // start at the reviewed head of the layer below, whose commits it must not replay. + let upstream = if index == 0 { + let fork = run(&["merge-base", &parent_old, &layer.head]); + if !ok(&fork) { + fail( + &mut steps, + &mut set, + index, + git_failure(StackRebaseGitStep::ForkPoint, fork), + ); + return steps; + } + output(fork) + } else { + parent_old.clone() + }; + let checkout = run(&["checkout", "--quiet", "--detach", &layer.head]); + if !ok(&checkout) { + fail( + &mut steps, + &mut set, + index, + git_failure(StackRebaseGitStep::CheckingOut, checkout), + ); + return steps; + } + match run(&["rebase", "--quiet", "--onto", &parent_new, &upstream]) { + Run::Exited { ok: true, .. } => {} + Run::Exited { ok: false, .. } => { + let _ = run(&["rebase", "--abort"]); + fail(&mut steps, &mut set, index, StackRebaseFailure::Conflict); + return steps; + } + failed => { + let _ = run(&["rebase", "--abort"]); + fail( + &mut steps, + &mut set, + index, + git_failure(StackRebaseGitStep::Rebasing, failed), + ); + return steps; + } + } + let rebased = run(&["rev-parse", "HEAD"]); + if !ok(&rebased) { + fail( + &mut steps, + &mut set, + index, + git_failure(StackRebaseGitStep::Rebasing, rebased), + ); + return steps; + } + let rebased = output(rebased); + if rebased == layer.head { + set(&mut steps, index, StackRebaseStep::AlreadyCurrent); + } else { + set(&mut steps, index, StackRebaseStep::Pushing); + let lease = format!( + "--force-with-lease=refs/heads/{}:{}", + layer.branch, layer.head + ); + let target = format!("{rebased}:refs/heads/{}", layer.branch); + match run(&["push", "--quiet", &lease, "origin", &target]) { + Run::Exited { ok: true, .. } => set( + &mut steps, + index, + StackRebaseStep::Pushed { + from: layer.head.clone(), + to: rebased.clone(), + }, + ), + Run::Exited { stderr, .. } if stderr.contains("stale info") => { + fail( + &mut steps, + &mut set, + index, + StackRebaseFailure::LeaseRefused, + ); + return steps; + } + Run::TimedOut => { + fail( + &mut steps, + &mut set, + index, + StackRebaseFailure::PushUnconfirmed, + ); + return steps; + } + failed => { + fail( + &mut steps, + &mut set, + index, + git_failure(StackRebaseGitStep::Pushing, failed), + ); + return steps; + } + } + } + parent_old = layer.head.clone(); + parent_new = rebased; + } + steps +} diff --git a/crates/services/tests/stack_actions.rs b/crates/services/tests/stack_actions.rs new file mode 100644 index 00000000..320f32da --- /dev/null +++ b/crates/services/tests/stack_actions.rs @@ -0,0 +1,530 @@ +use serde_json::{Value, json}; +use std::{ + path::Path, + sync::{Arc, Mutex}, +}; +use tcode_core::pull_request::{ + PullRequestKey, PullRequestMergeMethod, PullRequestState, StackRebaseFailure, StackRebaseStep, +}; +use tcode_protocol::{PullRequestActionResult, PullRequestRejection, PullRequestStackHead}; +use tcode_services::github::{ + GitHubApi, + pull_request_reads::PullRequestReads, + stack_actions::MergeSubmission, + stack_rebase::{self, Identity, RebaseLayer}, +}; + +#[path = "support/github.rs"] +#[allow(dead_code)] // tests/github.rs drives the fixture by hand as well. +mod fixture; +use fixture::{Fixture, Server, Store}; + +fn sha(digit: char) -> String { + std::iter::repeat_n(digit, 40).collect() +} + +/// The stack GitHub's stacks API lists for #3: #1 merged, then #2 and #3 open. +struct Stack { + number: u64, + layers: Vec<(u64, String, &'static str, bool)>, +} + +impl Default for Stack { + fn default() -> Self { + Self { + number: 50, + layers: vec![ + (1, sha('a'), "merged", false), + (2, sha('b'), "open", false), + (3, sha('c'), "open", false), + ], + } + } +} + +impl Stack { + /// The listing names the stack and its members, without their titles. + fn listing(&self) -> Value { + json!([{ + "number": self.number, + "url": "https://api.github.com/repos/octo/repo/stacks/50", + "base": {"ref": "main"}, + "pull_requests": self.layers.iter().map(|(number, head, ..)| json!({ + "number": number, + "head": {"ref": format!("layer-{number}"), "sha": head}, + })).collect::>(), + }]) + } + + fn detail(&self) -> Value { + json!({ + "number": self.number, + "url": "https://api.github.com/repos/octo/repo/stacks/50", + "base": {"ref": "main"}, + "pull_requests": self.layers.iter().map(|(number, head, state, draft)| json!({ + "number": number, + "title": format!("Layer {number}"), + "head": {"ref": format!("layer-{number}"), "sha": head}, + "state": if *state == "merged" { "closed" } else { state }, + "merged_at": (*state == "merged").then_some("2026-10-01T00:00:00Z"), + "draft": draft, + })).collect::>(), + }) + } +} + +/// A GitHub listing `stack` for #3 that answers every other request with `other`, and the +/// request line of everything it was asked. +fn github( + stack: Stack, + other: impl Fn(&str, &[u8]) -> (u16, Value) + Send + Sync + 'static, +) -> (Arc, Server, Arc>>) { + let store = Store::new(); + let fixture = Fixture::new(); + let reads = PullRequestReads::new(GitHubApi::new( + store.credentials(&[("GH_TOKEN", "fixture-token")]), + fixture.builder(), + )); + let asked: Arc>> = Arc::default(); + let seen = asked.clone(); + let server: Server = fixture.serve(move |exchange| { + let line = exchange.request.lines().next().unwrap().to_owned(); + seen.lock().unwrap().push(line.clone()); + let (status, reply) = if line.starts_with("GET /repos/octo/repo/stacks?pull_request=3 ") { + (200, stack.listing()) + } else if line.starts_with(&format!("GET /repos/octo/repo/stacks/{} ", stack.number)) { + (200, stack.detail()) + } else { + other(&line, &exchange.body) + }; + exchange.reply(status, "", reply.to_string().as_bytes()); + }); + (reads, server, asked) +} + +fn heads(heads: &[(u64, String)]) -> Vec { + heads + .iter() + .map(|(number, head)| PullRequestStackHead { + number: *number, + head: head.clone(), + }) + .collect() +} + +/// Submits a merge of #3 at `heads` to a GitHub listing `stack` that answers the submission with +/// `put`, and returns what came of it with the body of every submission GitHub saw. +fn merge( + stack: Stack, + put: (u16, Value), + confirmed: &[(u64, String)], +) -> (MergeSubmission, Vec) { + let submitted: Arc>> = Arc::default(); + let seen = submitted.clone(); + let (reads, _server, _) = github(stack, move |line, body| { + if line.starts_with("PUT /repos/octo/repo/pulls/3/merge-async ") { + seen.lock() + .unwrap() + .push(serde_json::from_slice(body).unwrap()); + put.clone() + } else { + (404, json!({"message": "Not Found"})) + } + }); + let submission = reads.merge_stack( + &PullRequestKey::new("github.com", "octo/repo", 3), + 50, + &heads(confirmed), + PullRequestMergeMethod::Squash, + ); + let submitted = submitted.lock().unwrap().clone(); + (submission, submitted) +} + +fn scope() -> Vec<(u64, String)> { + vec![(2, sha('b')), (3, sha('c'))] +} + +#[test] +fn a_stack_merge_is_one_submission_at_the_target_head_and_merged_queued_and_pending_differ() { + let cases = [ + ( + json!({"status": "merged", "details": {"sha": sha('d')}}), + MergeSubmission::Done(PullRequestActionResult::Applied), + ), + ( + json!({"status": "enqueued", "details": {}}), + MergeSubmission::Done(PullRequestActionResult::Queued { position: None }), + ), + ( + json!({"status": "pending", "details": {"uuid": "op-1"}}), + MergeSubmission::Following { + id: "op-1".into(), + adopted: false, + layers: vec![2, 3], + }, + ), + ( + json!({"status": "failed", "details": {"message": "Required checks have not passed"}}), + MergeSubmission::Done(PullRequestActionResult::Rejected( + PullRequestRejection::Refused { + messages: vec!["Required checks have not passed".into()], + }, + )), + ), + ]; + for (answer, expected) in cases { + let (submission, submitted) = merge(Stack::default(), (202, answer), &scope()); + assert_eq!(submission, expected); + assert_eq!( + submitted, + vec![json!({"merge_method": "squash", "merge_action": "default", "sha": sha('c')})] + ); + } +} + +#[test] +fn a_conflict_naming_a_running_merge_is_followed_and_one_naming_none_is_refused() { + let (adopted, submitted) = merge( + Stack::default(), + ( + 409, + json!({"message": "A merge request already exists", "details": {"uuid": "op-0"}}), + ), + &scope(), + ); + assert_eq!( + adopted, + MergeSubmission::Following { + id: "op-0".into(), + adopted: true, + layers: vec![2, 3], + } + ); + assert_eq!(submitted.len(), 1); + let (unnamed, submitted) = merge( + Stack::default(), + (409, json!({"message": "A merge request already exists"})), + &scope(), + ); + assert_eq!( + unnamed, + MergeSubmission::Done(PullRequestActionResult::Rejected( + PullRequestRejection::MergeRunning + )) + ); + assert_eq!(submitted.len(), 1); +} + +#[test] +fn a_moved_head_a_changed_scope_or_a_blocked_layer_sends_nothing() { + let refused = |rejection| MergeSubmission::Done(PullRequestActionResult::Rejected(rejection)); + let with = |change: fn(&mut Stack)| { + let mut stack = Stack::default(); + change(&mut stack); + stack + }; + let cases = [ + ( + Stack::default(), + vec![(2, sha('e')), (3, sha('c'))], + refused(PullRequestRejection::LayerChanged { + number: 2, + expected: sha('e'), + actual: sha('b'), + }), + ), + // A layer the confirmation did not show is not merged with the others. + ( + Stack::default(), + vec![(3, sha('c'))], + refused(PullRequestRejection::StackChanged), + ), + ( + with(|stack| stack.number = 51), + scope(), + refused(PullRequestRejection::StackChanged), + ), + ( + with(|stack| stack.layers[1].3 = true), + scope(), + refused(PullRequestRejection::LayerDraft { number: 2 }), + ), + ( + with(|stack| stack.layers[1].2 = "closed"), + scope(), + refused(PullRequestRejection::LayerNotOpen { + number: 2, + state: PullRequestState::Closed, + }), + ), + ( + with(|stack| stack.layers[2].2 = "merged"), + vec![(2, sha('b'))], + refused(PullRequestRejection::LayerNotOpen { + number: 3, + state: PullRequestState::Merged, + }), + ), + ]; + for (stack, heads, expected) in cases { + let (submission, submitted) = merge( + stack, + (202, json!({"status": "merged", "details": {}})), + &heads, + ); + assert_eq!(submission, expected); + assert!(submitted.is_empty(), "nothing is submitted: {expected:?}"); + } +} + +#[test] +fn a_layer_the_account_cannot_push_to_refuses_the_rebase_before_anything_runs() { + let (reads, _server, asked) = github(Stack::default(), |line, _| { + if line.starts_with("POST ") && line.contains("graphql") { + ( + 200, + json!({"data": {"repository": { + "pr2": {"headRepository": {"viewerPermission": "WRITE"}, "maintainerCanModify": false}, + "pr3": {"headRepository": {"viewerPermission": "READ"}, "maintainerCanModify": false}, + }}}), + ) + } else { + (404, json!({"message": "Not Found"})) + } + }); + let plan = reads.plan_stack_rebase( + &PullRequestKey::new("github.com", "octo/repo", 3), + 50, + &heads(&scope()), + ); + assert_eq!( + plan.err(), + Some(PullRequestRejection::NoPushAccess { numbers: vec![3] }) + ); + // No plan, so no Git runs and nothing is pushed: GitHub was only read. + let asked = asked.lock().unwrap(); + assert!( + asked.iter().all(|line| line.starts_with("GET ") + || (line.starts_with("POST ") && line.contains("graphql"))), + "{asked:?}" + ); +} + +/// A bare repository standing in for GitHub, holding a three-layer stack cut from `main`, with +/// `main` moved ahead since. Each layer changes its own file, so each has one commit of its own. +struct Remote { + _root: tempdir::Dir, + path: std::path::PathBuf, + work: std::path::PathBuf, +} + +mod tempdir { + pub struct Dir(pub std::path::PathBuf); + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } +} + +fn git(cwd: &Path, args: &[&str]) -> String { + let output = tcode_services::process::command("git") + .current_dir(cwd) + .args([ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "init.defaultBranch=main", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_owned() +} + +impl Remote { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "tcode-stack-remote-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("remote.git"); + let work = root.join("work"); + git( + &root, + &["init", "--quiet", "--bare", path.to_str().unwrap()], + ); + git( + &root, + &[ + "clone", + "--quiet", + path.to_str().unwrap(), + work.to_str().unwrap(), + ], + ); + let remote = Self { + _root: tempdir::Dir(root), + path, + work, + }; + remote.commit("README.md", "start\n", "Initial"); + git(&remote.work, &["push", "--quiet", "origin", "HEAD:main"]); + for (layer, parent) in [ + ("layer-1", "main"), + ("layer-2", "layer-1"), + ("layer-3", "layer-2"), + ] { + git(&remote.work, &["switch", "--quiet", "-c", layer, parent]); + remote.commit(&format!("{layer}.txt"), "one\n", &format!("{layer}: add")); + } + git(&remote.work, &["switch", "--quiet", "main"]); + remote.commit("README.md", "main moved ahead\n", "Docs"); + git( + &remote.work, + &[ + "push", "--quiet", "origin", "main", "layer-1", "layer-2", "layer-3", + ], + ); + remote + } + + fn commit(&self, file: &str, text: &str, message: &str) { + std::fs::write(self.work.join(file), text).unwrap(); + git(&self.work, &["add", "-A"]); + git(&self.work, &["commit", "--quiet", "-m", message]); + } + + fn head(&self, branch: &str) -> String { + git(&self.path, &["rev-parse", &format!("refs/heads/{branch}")]) + } + + fn layers(&self) -> Vec { + (1..=3) + .map(|number| RebaseLayer { + number, + branch: format!("layer-{number}"), + head: self.head(&format!("layer-{number}")), + }) + .collect() + } + + fn rebase(&self, layers: &[RebaseLayer]) -> Vec { + stack_rebase::cascade( + self.path.to_str().unwrap(), + // An extra header keyed to a filesystem remote is never sent. + "fixture-token", + &Identity::new("Host", "host@example.test"), + "main", + layers, + |_, _| {}, + ) + } +} + +#[test] +fn each_layer_moves_only_its_own_commits_onto_the_rebased_layer_below() { + let remote = Remote::new(); + let layers = remote.layers(); + let steps = remote.rebase(&layers); + let main = remote.head("main"); + let heads: Vec<_> = (1..=3) + .map(|n| remote.head(&format!("layer-{n}"))) + .collect(); + assert_eq!( + steps, + layers + .iter() + .zip(&heads) + .map(|(layer, head)| StackRebaseStep::Pushed { + from: layer.head.clone(), + to: head.clone(), + }) + .collect::>() + ); + let parents = [&main, &heads[0], &heads[1]]; + for (index, head) in heads.iter().enumerate() { + assert_eq!( + &git(&remote.path, &["rev-parse", &format!("{head}~1")]), + parents[index] + ); + assert_eq!( + git( + &remote.path, + &["log", "--format=%s", &format!("{}..{head}", parents[index])] + ), + format!("layer-{}: add", index + 1) + ); + assert_eq!( + git(&remote.path, &["log", "-1", "--format=%cn <%ce>", head]), + "Host " + ); + } + // Rebased already, the stack has nothing to move. + let again = remote.rebase(&remote.layers()); + assert_eq!(again, vec![StackRebaseStep::AlreadyCurrent; 3]); +} + +#[test] +fn the_lease_keeps_a_branch_pushed_to_since_review_and_the_layer_below_stays_pushed() { + let remote = Remote::new(); + let reviewed = remote.layers(); + git(&remote.work, &["switch", "--quiet", "layer-2"]); + remote.commit("layer-2.txt", "two\n", "layer-2: pushed meanwhile"); + git(&remote.work, &["push", "--quiet", "origin", "layer-2"]); + let concurrent = remote.head("layer-2"); + let steps = remote.rebase(&reviewed); + assert!(matches!(steps[0], StackRebaseStep::Pushed { .. })); + assert_eq!( + steps[1..], + [ + StackRebaseStep::Failed { + reason: StackRebaseFailure::LeaseRefused + }, + StackRebaseStep::NotStarted + ] + ); + assert_eq!(remote.head("layer-2"), concurrent); + assert_eq!(remote.head("layer-3"), reviewed[2].head); + assert_eq!( + git( + &remote.path, + &["rev-parse", &format!("{}~1", remote.head("layer-1"))] + ), + remote.head("main") + ); +} + +#[test] +fn a_conflict_stops_at_the_failing_layer_untouched_with_the_lower_layers_pushed() { + let remote = Remote::new(); + git(&remote.work, &["switch", "--quiet", "main"]); + remote.commit("layer-2.txt", "main's own\n", "main writes layer 2's file"); + git(&remote.work, &["push", "--quiet", "origin", "main"]); + let reviewed = remote.layers(); + let steps = remote.rebase(&reviewed); + assert!(matches!(steps[0], StackRebaseStep::Pushed { .. })); + assert_eq!( + steps[1..], + [ + StackRebaseStep::Failed { + reason: StackRebaseFailure::Conflict + }, + StackRebaseStep::NotStarted + ] + ); + assert_ne!(remote.head("layer-1"), reviewed[0].head); + assert_eq!(remote.head("layer-2"), reviewed[1].head); + assert_eq!(remote.head("layer-3"), reviewed[2].head); +} diff --git a/crates/ui/src/assets.rs b/crates/ui/src/assets.rs index 35c10214..82e8a7cd 100644 --- a/crates/ui/src/assets.rs +++ b/crates/ui/src/assets.rs @@ -29,6 +29,10 @@ gpui_component_assets::icon_assets!( ListOrdered, Hourglass, ArrowUpDown, + CircleQuestionMark, + CornerDownRight, + GitBranch, + Upload, ] ); diff --git a/crates/ui/src/pull_requests/compose.rs b/crates/ui/src/pull_requests/compose.rs index ce87dbef..45434e64 100644 --- a/crates/ui/src/pull_requests/compose.rs +++ b/crates/ui/src/pull_requests/compose.rs @@ -127,14 +127,33 @@ pub(super) fn rejection_reason(rejection: &PullRequestRejection) -> String { } PullRequestRejection::Refused { .. } => crate::tr!("pull_requests.result.reason_invalid"), PullRequestRejection::Failed => crate::tr!("pull_requests.result.reason_failed"), - PullRequestRejection::InStack { index, layers } => crate::tr!( - "pull_requests.result.reason_stack", - index = index.to_string(), - count = layers.to_string() - ), PullRequestRejection::StackUnknown => { crate::tr!("pull_requests.result.reason_stack_unknown") } + PullRequestRejection::StackChanged => crate::tr!("pull_requests.stack.changed_layers"), + PullRequestRejection::LayerChanged { number, actual, .. } => crate::tr!( + "pull_requests.stack.changed_head", + number = number, + new = actual.chars().take(7).collect::() + ), + PullRequestRejection::LayerNotOpen { number, .. } + | PullRequestRejection::LayerDraft { number } => { + crate::tr!("pull_requests.stack.changed_state", number = number) + } + PullRequestRejection::NoPushAccess { numbers } => crate::tr!( + "pull_requests.stack.result_no_push", + list = numbers + .iter() + .map(|number| format!("#{number}")) + .collect::>() + .join(", ") + ), + PullRequestRejection::OperationRunning => crate::tr!("pull_requests.stack.waiting_state"), + PullRequestRejection::MergeRunning => { + crate::tr!("pull_requests.stack.result_409_no_id_body") + } + PullRequestRejection::NoGitIdentity => crate::tr!("pull_requests.stack.result_no_identity"), + PullRequestRejection::NotLinked => crate::tr!("pull_requests.stack.result_not_linked"), } .into_owned() } @@ -220,7 +239,12 @@ fn toast(write: Write, result: &PullRequestActionResult, number: u64) -> Option< PullRequestActionResult::UpToDate | PullRequestActionResult::Queued { .. } | PullRequestActionResult::AutoMergeEnabled { .. } - | PullRequestActionResult::Opened { .. } => return None, + | PullRequestActionResult::Opened { .. } + | PullRequestActionResult::Pending { .. } + | PullRequestActionResult::MergeUnconfirmed { .. } + | PullRequestActionResult::RebaseStarted + | PullRequestActionResult::Rebased { .. } + | PullRequestActionResult::RebaseStopped { .. } => return None, PullRequestActionResult::Uncertain => match write { Write::Comment | Write::Edit => Notification::warning( crate::tr!("pull_requests.result.uncertain_comment").into_owned(), diff --git a/crates/ui/src/pull_requests/detail.rs b/crates/ui/src/pull_requests/detail.rs index 6a10c3d4..c22c2f67 100644 --- a/crates/ui/src/pull_requests/detail.rs +++ b/crates/ui/src/pull_requests/detail.rs @@ -12,8 +12,8 @@ use gpui::{ }; use gpui_base::{h_flex, v_flex}; use tcode_core::pull_request::{ - ChecksState, Mergeability, PullRequestKey, PullRequestSource, PullRequestStackState, - PullRequestState, ReviewDecision, ThreadPullRequestLink, + ChecksState, Mergeability, PullRequestKey, PullRequestSource, PullRequestState, ReviewDecision, + ThreadPullRequestLink, }; use tcode_protocol::{ Command, ProtocolError, PullRequestConversation, PullRequestFile, PullRequestFiles, @@ -29,7 +29,6 @@ use crate::{ store::{TopicKind, WorkspaceStore, observe_store_topics}, theme::ActiveTheme as _, widgets::{ - Popover, button::{Button, ButtonVariants as _}, input::Input, menu::DropdownMenu as _, @@ -270,21 +269,34 @@ impl PullRequestView { fn stack(&self, cx: &App) -> Option { let (session, key) = self.current.as_ref()?; - self.store - .read(cx) - .pull_requests(session) - .iter() - .filter(|link| link.visible()) - .find_map(|link| match &link.stack { - PullRequestStackState::Native(stack) - if link.key.host == key.host - && link.key.repository == key.repository - && stack.layers.iter().any(|layer| layer.number == key.number) => - { - Some(stack.clone()) - } - _ => None, - }) + tcode_core::pull_request::native_stack(self.store.read(cx).pull_requests(session), key) + .cloned() + } + + fn stack_offer(&self, cx: &App) -> Option { + let (session, key) = self.current.as_ref()?; + let store = self.store.read(cx); + super::stack::StackOffer::new( + key, + store.pull_requests(session), + store + .thread_meta(session) + .map_or(&[][..], |meta| meta.pull_request_operations.as_slice()), + ) + } + + pub(super) fn store(&self) -> &Entity { + &self.store + } + + /// Opens another layer of the stack in the thread's page, as a row of its map does. + pub(super) fn select_layer(&mut self, key: PullRequestKey, cx: &mut Context) { + if let Some((session, _)) = self.current.clone() { + self.store.update(cx, |store, cx| { + store.set_open_pull_request(&session, Some(key.clone()), cx) + }); + self.show(session, key, cx); + } } pub(super) fn url(&self, cx: &App) -> Option { @@ -941,9 +953,20 @@ impl PullRequestView { )); } } - if let Some(stack) = &stack { + if let Some(offer) = self.stack_offer(cx) { has_chip = true; - chips = chips.child(self.layer_selector(stack, &key, cx)); + let target = (!self.read_only(cx)) + .then(|| self.shown_target(cx)) + .flatten(); + let action = self.page().and_then(|page| page.action_state.data.clone()); + chips = chips.child(super::stack::map_selector( + cx.entity(), + offer, + target, + action, + compact, + cx, + )); } if visible && link.as_ref().is_some_and(|link| link.watch.is_some()) @@ -1299,179 +1322,6 @@ impl PullRequestView { ); } - fn layer_selector( - &self, - stack: &tcode_core::pull_request::PullRequestStack, - key: &PullRequestKey, - cx: &mut Context, - ) -> AnyElement { - let compact = self.compact(cx); - let index = stack - .layers - .iter() - .position(|layer| layer.number == key.number) - .map_or(0, |index| index + 1); - let count = stack.layers.len(); - let muted = cx.theme().muted_foreground; - let trigger = Button::new("pr-layer-select") - .ghost() - .outline() - .compact() - .child( - h_flex() - .gap_1p5() - .items_center() - .text_size(px(13.)) - .child(Icon::new(IconName::Layers).size(px(14.))) - .child( - crate::tr!( - "pull_requests.layer_position", - index = index.to_string(), - count = count.to_string() - ) - .into_owned(), - ) - .child(Icon::new(IconName::ChevronDown).xsmall().text_color(muted)), - ); - let links = self - .current - .as_ref() - .map(|(session, _)| self.store.read(cx).pull_requests(session).to_vec()) - .unwrap_or_default(); - let stack = stack.clone(); - let current = key.clone(); - let view = cx.entity(); - let popover = Popover::new("pr-layer-popover").trigger(trigger); - let popover = if compact { - popover.bottom_sheet(crate::tr!("pull_requests.detail.stack_title").into_owned()) - } else { - popover - }; - popover - .content(move |_, _, cx| { - let popover = cx.entity(); - let rows = stack.layers.iter().rev().map(|layer| { - let layer_key = - PullRequestKey::new(¤t.host, ¤t.repository, layer.number); - let link = links.iter().find(|link| link.key == layer_key); - let linked = link.is_some_and(|link| link.visible()); - let (glyph, color, _) = appearance(row_state(link, Some(layer.state)), cx); - let selected = layer_key == current; - let title = link - .and_then(|link| link.snapshot.as_ref()) - .filter(|_| linked) - .map(|snapshot| snapshot.title.clone()); - let condition = match link.map(|link| link.source) { - Some(PullRequestSource::Dismissed) => { - Some(crate::tr!("pull_requests.source_dismissed")) - } - None => Some(crate::tr!("pull_requests.detail.layer_not_linked")), - _ => None, - }; - let view = view.clone(); - let popover = popover.clone(); - let label = format!("#{} {}", layer.number, title.clone().unwrap_or_default()); - material::accessible_clickable( - h_flex(), - ("pr-layer", layer.number as usize), - gpui::Role::MenuItem, - label, - cx, - ) - .aria_selected(selected) - .w_full() - .h(px(if compact { 44. } else { 32. })) - .px_2() - .gap_2() - .items_center() - .rounded(cx.theme().tokens.radius.sm) - .cursor_pointer() - .hover(|row| row.bg(cx.theme().list_hover)) - .when(selected, |row| row.bg(cx.theme().list_active)) - .child(Icon::new(glyph).size(px(14.)).text_color(if linked { - color - } else { - cx.theme().muted_foreground - })) - .child( - div() - .font_family(cx.theme().mono_font_family.clone()) - .text_size(px(12.)) - .child(format!("#{}", layer.number)), - ) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_size(px(13.)) - .when(title.is_none(), |text| { - text.font_family(cx.theme().mono_font_family.clone()) - .text_color(cx.theme().muted_foreground) - }) - .child(title.unwrap_or_else(|| layer.head_branch.clone())), - ) - .children(condition.map(|condition| { - div() - .text_size(px(11.)) - .text_color(cx.theme().muted_foreground) - .child(condition) - })) - .when(selected, |row| { - row.child(Icon::new(IconName::Check).size(px(12.))) - }) - .on_click(move |_, window, cx| { - view.update(cx, |view, cx| { - if let Some((session, _)) = view.current.clone() { - view.store.update(cx, |store, cx| { - store.set_open_pull_request( - &session, - Some(layer_key.clone()), - cx, - ) - }); - view.show(session, layer_key.clone(), cx); - } - }); - popover.update(cx, |state, cx| state.dismiss(window, cx)); - }) - }); - v_flex() - .id("pr-layer-list") - .role(gpui::Role::Menu) - .w(px(340.)) - .max_h(px(360.)) - .p_1() - .gap_0p5() - .overflow_y_scroll() - .child( - h_flex() - .px_2() - .py_1() - .gap_1() - .items_center() - .text_size(px(11.)) - .text_color(cx.theme().muted_foreground) - .child(Icon::new(IconName::Layers).size(px(12.))) - .child( - crate::tr!( - "pull_requests.stack_caption", - count = stack.layers.len().to_string(), - base = stack.base.clone() - ) - .into_owned(), - ), - ) - .children(rows) - }) - .bg(cx.theme().popover) - .border_1() - .border_color(cx.theme().border) - .shadow_xl() - .rounded(material::radius_overlay(cx)) - .into_any_element() - } - /// The menu of the row this pull request was opened from. fn menu(&self, cx: &mut Context) -> Option { let offer = (!self.read_only(cx)) diff --git a/crates/ui/src/pull_requests/lifecycle.rs b/crates/ui/src/pull_requests/lifecycle.rs index cfabf940..dffd293e 100644 --- a/crates/ui/src/pull_requests/lifecycle.rs +++ b/crates/ui/src/pull_requests/lifecycle.rs @@ -51,6 +51,8 @@ pub(super) enum Lifecycle { DisableAutoMerge, AskConflicts, AskChecks, + MergeStack, + RebaseStack, } /// A lifecycle item of a pull request's menu or primary action. @@ -77,9 +79,10 @@ pub(super) enum Primary { FixChecks, AutoMergeOn(PullRequestMergeMethod), Queued(Option), - MergeOnGitHub { - index: u32, - }, + /// The stack's merge through this layer, disabled while a layer below blocks it. + MergeStack, + /// The stack's running or unconfirmed write, in place of a merge. + StackOperation, UpdateBranch, EnableAutoMerge, Merge { @@ -103,6 +106,8 @@ pub(super) struct Offer { pub(super) action: Option, /// The method a merge would use. pub(super) method: Option, + /// The native stack it is a layer of. + pub(super) stack: Option, /// A write is in flight, or one went unanswered and GitHub has not been read since. pub(super) busy: bool, } @@ -113,6 +118,7 @@ impl Offer { pub(super) fn new( key: &PullRequestKey, links: &[ThreadPullRequestLink], + operations: &[tcode_core::pull_request::PullRequestStackOperation], action: Option, chosen: Option, project_default: Option, @@ -156,6 +162,7 @@ impl Offer { route, action, method, + stack: super::stack::StackOffer::new(key, links, operations), busy, }) } @@ -183,8 +190,18 @@ impl Offer { return Some(Primary::Queued(action.queue_position)); } match self.route { - PullRequestStackRoute::Layer { index, .. } => { - return Some(Primary::MergeOnGitHub { index }); + PullRequestStackRoute::Layer { .. } => { + let stack = self.stack.as_ref()?; + if stack.operation_here().is_some() { + return Some(Primary::StackOperation); + } + return match stack.merge(Some(action)) { + super::stack::Avail::Enabled => Some(Primary::MergeStack), + super::stack::Avail::Disabled(_) if !stack.map().blockers().is_empty() => { + Some(Primary::MergeStack) + } + _ => None, + }; } PullRequestStackRoute::Unknown => return None, PullRequestStackRoute::Single => {} @@ -226,10 +243,19 @@ impl Offer { let primary = self.primary(); let open = self.state == PullRequestState::Open; let number = self.key.number.to_string(); + // A layer the stack's write is moving waits for it to end, as for a write of its own. + let free = !self.busy + && self + .stack + .as_ref() + .is_none_or(|stack| stack.operation_here().is_none()); let write = |menu: PopupMenu, label: &str, kind: Lifecycle| { - menu.menu_with_enable(crate::tr!(label).into_owned(), self.item(kind), !self.busy) + menu.menu_with_enable(crate::tr!(label).into_owned(), self.item(kind), free) }; let mut menu = menu.separator(); + if let (PullRequestStackRoute::Layer { .. }, Some(stack)) = (self.route, &self.stack) { + menu = self.stack_group(stack, menu); + } if open && may(|action| action.can_update) { if !self.draft { menu = write(menu, "pull_requests.actions.draft", Lifecycle::Draft); @@ -310,12 +336,7 @@ impl Offer { } } } - PullRequestStackRoute::Layer { index, .. } => { - menu = menu.label(stack_reason(&number, index)).menu( - crate::tr!("pull_requests.actions.merge_on_github").into_owned(), - Box::new(OpenUrl(self.url.clone())), - ); - } + PullRequestStackRoute::Layer { .. } => {} PullRequestStackRoute::Unknown => { menu = menu .menu_with_enable( @@ -346,8 +367,57 @@ impl Offer { } } +impl Offer { + /// A native stack's group: Merge stack and Rebase stack as the stack allows them, then the + /// method the stack merge uses. + fn stack_group(&self, stack: &super::stack::StackOffer, mut menu: PopupMenu) -> PopupMenu { + use super::stack::Avail; + let action = self.action.as_ref(); + let items = [ + ( + super::stack::merge_count_label(stack), + stack.merge(action), + Lifecycle::MergeStack, + ), + ( + super::stack::rebase_menu_label(), + stack.rebase(action), + Lifecycle::RebaseStack, + ), + ]; + for (label, avail, kind) in items { + match avail { + Avail::Hidden => {} + Avail::Enabled => menu = menu.menu_with_enable(label, self.item(kind), !self.busy), + Avail::Disabled(reason) => { + menu = menu + .menu_with_enable(label, self.item(kind), false) + .label(reason) + } + } + } + if let Some(action) = action + && action.merge_methods.len() >= 2 + && stack.merge(Some(action)) != Avail::Hidden + { + menu = menu.label(crate::tr!("pull_requests.actions.merge_method").into_owned()); + for method in &action.merge_methods { + menu = menu.menu_with_check( + method_label(*method), + self.method == Some(*method), + Box::new(ChooseMergeMethod { + key: self.key.clone(), + method: *method, + }), + ); + } + } + menu + } +} + /// The client's choice, then the project's default, then the first the repository enables. -fn merge_method( +pub(super) fn merge_method( enabled: &[PullRequestMergeMethod], chosen: Option, project_default: Option, @@ -368,7 +438,7 @@ pub(super) fn method_label(method: PullRequestMergeMethod) -> String { .into_owned() } -fn segment_label(method: PullRequestMergeMethod) -> String { +pub(super) fn segment_label(method: PullRequestMergeMethod) -> String { crate::tr!(match method { PullRequestMergeMethod::Merge => "pull_requests.merge.segment_merge", PullRequestMergeMethod::Squash => "pull_requests.merge.segment_squash", @@ -377,15 +447,6 @@ fn segment_label(method: PullRequestMergeMethod) -> String { .into_owned() } -pub(super) fn stack_reason(number: &str, index: u32) -> String { - crate::tr!( - "pull_requests.actions.stack_reason", - number = number.to_owned(), - index = index.to_string() - ) - .into_owned() -} - fn short(head: &str) -> String { head.chars().take(7).collect() } @@ -437,7 +498,18 @@ impl Target { let project_default = project .as_ref() .and_then(|(id, _)| workspace.settings().project_merge_methods.get(id).copied()); - let offer = Offer::new(key, links, action, chosen, project_default, false)?; + let operations = workspace + .thread_meta(session) + .map_or(&[][..], |meta| meta.pull_request_operations.as_slice()); + let offer = Offer::new( + key, + links, + operations, + action, + chosen, + project_default, + false, + )?; let snapshot = links .iter() .find(|link| link.key == *key) @@ -553,6 +625,8 @@ impl Target { Lifecycle::Merge => open_merge_dialog(self, false, window, cx), Lifecycle::EnableAutoMerge => open_merge_dialog(self, true, window, cx), Lifecycle::AskConflicts | Lifecycle::AskChecks => self.ask(kind, cx), + Lifecycle::MergeStack => super::stack::open_merge_dialog(self, window, cx), + Lifecycle::RebaseStack => super::stack::open_rebase_dialog(self, window, cx), } } @@ -868,7 +942,13 @@ impl Target { }; open_on_github(Notification::warning(message).title(title)) } - PullRequestActionResult::Partial { .. } => return None, + // A stack write's answers are the stack's own words. + PullRequestActionResult::Partial { .. } + | PullRequestActionResult::Pending { .. } + | PullRequestActionResult::MergeUnconfirmed { .. } + | PullRequestActionResult::RebaseStarted + | PullRequestActionResult::Rebased { .. } + | PullRequestActionResult::RebaseStopped { .. } => return None, }; let settled = matches!( result, @@ -1477,19 +1557,47 @@ pub(super) fn primary_element( }, None, ), - Primary::MergeOnGitHub { index } => { + Primary::MergeStack => { + let Some(stack) = target.offer.stack.as_ref() else { + return div().into_any_element(); + }; + let (enabled, tooltip) = match stack.merge(target.offer.action.as_ref()) { + super::stack::Avail::Disabled(reason) => (false, reason), + _ => (true, super::stack::merge_tooltip(stack)), + }; let button = Button::new("pr-primary-action") - .outline() - .icon(IconName::ExternalLink) - .label(crate::tr!("pull_requests.actions.merge_on_github")) - .tooltip(stack_reason(&number, index)) - .on_click(move |_, _, cx| cx.open_url(&url)); + .primary() + .icon(IconName::GitMerge) + .label(super::stack::merge_primary_label()) + .aria_label(super::stack::merge_primary_accessible( + target.offer.key.number, + )) + .tooltip(tooltip) + .loading(busy) + .disabled(busy || !enabled) + .on_click(run(Lifecycle::MergeStack)); if compact { button.w_full().into_any_element() } else { button.small().into_any_element() } } + Primary::StackOperation => { + let Some(operation) = target + .offer + .stack + .as_ref() + .and_then(|stack| stack.operation.as_ref()) + else { + return div().into_any_element(); + }; + let links = target + .store + .read(cx) + .pull_requests(&target.session) + .to_vec(); + super::stack::operation_chip(Some(target.clone()), operation, &links, compact, cx) + } Primary::UpdateBranch => button( IconName::ArrowUpDown, crate::tr!("pull_requests.actions.update_branch").into_owned(), diff --git a/crates/ui/src/pull_requests/mod.rs b/crates/ui/src/pull_requests/mod.rs index 72e52e4d..81dde6f8 100644 --- a/crates/ui/src/pull_requests/mod.rs +++ b/crates/ui/src/pull_requests/mod.rs @@ -37,8 +37,10 @@ mod files; mod lifecycle; mod meta; mod review; +mod stack; pub use detail::PullRequestView; +pub use stack::present_result as present_stack_result; #[derive(Action, Clone, PartialEq, Deserialize)] #[action(namespace=tcode_pull_requests,no_json)] @@ -675,7 +677,18 @@ impl PullRequestsPanel { } let session = store.active_session_id()?; let busy = detail.read(cx).lifecycle_busy(&session, &key); - lifecycle::Offer::new(&key, store.pull_requests(&session), None, None, None, busy) + let operations = store + .thread_meta(&session) + .map_or(&[][..], |meta| meta.pull_request_operations.as_slice()); + lifecycle::Offer::new( + &key, + store.pull_requests(&session), + operations, + None, + None, + None, + busy, + ) } } fn run_lifecycle( @@ -951,6 +964,37 @@ impl PullRequestsPanel { ); } } + // The stack's view of the row: a layer below that blocks merging it, and the stack's + // write running over it. + let (blocker, running) = { + let store = self.store.read(cx); + let session = store.active_session_id(); + let links = session + .as_deref() + .map_or(&[][..], |id| store.pull_requests(id)); + let operations = session + .as_deref() + .and_then(|id| store.thread_meta(id)) + .map_or(&[][..], |meta| meta.pull_request_operations.as_slice()); + let running = pull_request::stack_operation(operations, links, &key) + .filter(|operation| operation.covers(key.number)) + .map(|operation| stack::chip_words(operation, cx)); + (stack::row_blocker(links, &key), running) + }; + if let Some(blocker) = blocker { + signals = signals.child( + div() + .id("pr-signal-blocked") + .child( + Icon::new(IconName::Lock) + .size(px(14.)) + .text_color(cx.theme().muted_foreground), + ) + .tooltip(move |window, cx| { + crate::widgets::tooltip::Tooltip::new(blocker.clone()).build(window, cx) + }), + ); + } let row_id = SharedString::from(format!( "pr-row-{}-{}-{}", key.host, key.repository, key.number @@ -1075,7 +1119,22 @@ impl PullRequestsPanel { .flex_none() .min_w(px(if compact { 44. } else { 36. })) .min_h(px(if compact { 44. } else { 24. })) - .when(!compact, |slot| { + .when_some( + running.clone().filter(|_| !compact), + |slot, (icon, color, _, tooltip)| { + slot.child( + div() + .id("pr-signal-operation") + .group_hover(row_id.clone(), |signal| signal.invisible()) + .child(Icon::new(icon).size(px(14.)).text_color(color)) + .tooltip(move |window, cx| { + crate::widgets::tooltip::Tooltip::new(tooltip.clone()) + .build(window, cx) + }), + ) + }, + ) + .when(!compact && running.is_none(), |slot| { slot.child( div() .text_size(px(11.)) @@ -1203,6 +1262,11 @@ impl Render for PullRequestsPanel { .as_deref() .map(|id| store.pull_requests(id).to_vec()) .unwrap_or_default(); + let operations = session + .as_deref() + .and_then(|id| store.thread_meta(id)) + .map(|meta| meta.pull_request_operations.clone()) + .unwrap_or_default(); let read_only = store .session_status() .is_none_or(|status| status.conversation_read_only); @@ -1273,32 +1337,10 @@ impl Render for PullRequestsPanel { } for group in pull_request::groups(&links) { if let Some(stack) = group.stack { - rows = rows.child( - h_flex() - .id(SharedString::from(format!("pr-stack-{}", stack.id))) - .h(px(28.)) - .px_3() - .gap_2() - .items_center() - .text_size(px(11.)) - .text_color(cx.theme().muted_foreground) - .tooltip(|window, cx| { - crate::widgets::tooltip::Tooltip::new( - crate::tr!("pull_requests.stack_tooltip").into_owned(), - ) - .build(window, cx) - }) - .child(Icon::new(IconName::Layers).xsmall()) - .child( - crate::tr!( - "pull_requests.stack_caption", - count = stack.layers.len().to_string(), - base = stack.base.clone() - ) - .into_owned(), - ), - ); let anchor = group.links[0]; + let operation = + tcode_core::pull_request::stack_operation(&operations, &links, &anchor.key); + rows = rows.child(stack::caption(stack, operation, &links, compact, cx)); for (index, layer) in stack.layers.iter().enumerate() { let key = PullRequestKey::new(&anchor.key.host, &anchor.key.repository, layer.number); diff --git a/crates/ui/src/pull_requests/stack.rs b/crates/ui/src/pull_requests/stack.rs new file mode 100644 index 00000000..035a7d09 --- /dev/null +++ b/crates/ui/src/pull_requests/stack.rs @@ -0,0 +1,2933 @@ +//! A GitHub native stack as the thread shows it: the stack map behind the page's layer +//! selector, what Merge stack and Rebase stack are offered as, their confirmations, the rebase's +//! progress and the words every answer gets. The host owns every write and its state; the map +//! reads only the stored topology and links, and each confirmation reads GitHub fresh. + +use std::rc::Rc; + +use gpui::{ + AnyElement, App, AppContext as _, ClipboardItem, Context, Entity, Hsla, + InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, + StatefulInteractiveElement as _, Styled as _, Window, div, prelude::FluentBuilder as _, px, +}; +use gpui_base::{StyledExt as _, h_flex, v_flex}; +use tcode_core::pull_request::{ + self as core_pr, PullRequestKey, PullRequestMergeMethod, PullRequestStackOperation, + PullRequestState, StackBlocker, StackLayerCondition, StackLayerRole, StackMap, + StackOperationKind, StackRebaseFailure, StackRebaseGitStep, StackRebaseStep, + ThreadPullRequestLink, +}; +use tcode_protocol::{ + Command, PullRequestActionResult, PullRequestActionState, PullRequestRead, + PullRequestReadResponse, PullRequestRejection, PullRequestStackActionState, + PullRequestStackHead, PullRequestStackPushAccess, +}; + +use super::{ + appearance, + compose::{answer_of, rejection_reason}, + lifecycle::{Lifecycle, Target, method_label}, + row_state, +}; +use crate::{ + icon::{Icon, IconName}, + material, + overlay::{Notification, OverlayExt as _}, + sizing::Sizable as _, + store::WorkspaceStore, + theme::ActiveTheme as _, + widgets::Popover, + widgets::{ + button::{Button, ButtonVariants as _}, + checkbox::Checkbox, + spinner::Spinner, + tooltip::Tooltip, + }, +}; + +/// Whether a stack write is offered, and why not when it is shown disabled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Avail { + Hidden, + Disabled(String), + Enabled, +} + +/// The native stack a pull request the thread shows is a layer of, with the stack's operation. +#[derive(Clone)] +pub(super) struct StackOffer { + pub(super) key: PullRequestKey, + links: Rc>, + pub(super) operation: Option, + /// The thread links the selected layer itself: only then do its stack's writes go from it. + pub(super) linked: bool, +} + +pub(super) type StackId = (String, String, u64); + +impl StackOffer { + pub(super) fn new( + key: &PullRequestKey, + links: &[ThreadPullRequestLink], + operations: &[PullRequestStackOperation], + ) -> Option { + core_pr::stack_map(links, key)?; + Some(Self { + key: key.clone(), + linked: links.iter().any(|link| link.visible() && link.key == *key), + operation: core_pr::stack_operation(operations, links, key).cloned(), + links: Rc::new(links.to_vec()), + }) + } + + pub(super) fn map(&self) -> StackMap<'_> { + core_pr::stack_map(&self.links, &self.key).expect("a stack offer has a map") + } + + pub(super) fn base(&self) -> String { + self.map().stack.base.clone() + } + + fn running(&self) -> Option { + Some(match &self.operation.as_ref()?.kind { + StackOperationKind::Merging { .. } => tr("disabled_merging"), + StackOperationKind::MergeUnconfirmed { checked: false, .. } => tr("waiting_state"), + StackOperationKind::Rebasing { .. } => tr("disabled_rebasing"), + StackOperationKind::MergeUnconfirmed { .. } + | StackOperationKind::RebaseEnded { .. } => { + return None; + } + }) + } + + /// Merge stack: the selected layer and every unmerged layer below it, together. + pub(super) fn merge(&self, action: Option<&PullRequestActionState>) -> Avail { + let map = self.map(); + let selected = map.selected(); + let number = self.key.number.to_string(); + if selected.layer.state != PullRequestState::Open + || action.is_some_and(|action| !action.can_merge || action.queued) + { + return Avail::Hidden; + } + if let Some(reason) = self.running() { + return Avail::Disabled(reason); + } + if !self.linked { + return Avail::Disabled(tr_with("disabled_not_linked", &[("number", number)])); + } + if action.is_some_and(|action| action.merge_methods.is_empty()) { + return Avail::Disabled(tr("no_methods")); + } + if selected.draft == Some(true) { + return Avail::Disabled(tr_with("summary_selected_draft", &[("number", number)])); + } + match map.blockers().first() { + Some((blocker, kind)) => Avail::Disabled(blocked(*blocker, *kind)), + None => Avail::Enabled, + } + } + + /// Rebase stack: every unmerged layer, whichever one is selected. + pub(super) fn rebase(&self, action: Option<&PullRequestActionState>) -> Avail { + let map = self.map(); + if map.unmerged().is_empty() || action.is_some_and(|action| !action.can_merge) { + return Avail::Hidden; + } + if matches!( + self.operation.as_ref().map(|operation| &operation.kind), + Some(StackOperationKind::Rebasing { .. }) + ) { + return Avail::Hidden; + } + if let Some(reason) = self.running() { + return Avail::Disabled(reason); + } + if action.is_some_and(|action| action.queued) { + return Avail::Disabled(tr("disabled_queued")); + } + if !self.linked { + return Avail::Disabled(tr_with( + "disabled_not_linked", + &[("number", self.key.number.to_string())], + )); + } + match map + .rows + .iter() + .find(|row| row.layer.state == PullRequestState::Closed) + { + Some(row) => Avail::Disabled(tr_with( + "rebase_blocked_closed", + &[("number", row.layer.number.to_string())], + )), + None => Avail::Enabled, + } + } + + /// The stack's operation, while it is moving the selected layer. + pub(super) fn operation_here(&self) -> Option<&PullRequestStackOperation> { + self.operation + .as_ref() + .filter(|operation| operation.covers(self.key.number)) + } +} + +/// A `pull_requests.stack` string. +fn tr(key: &str) -> String { + crate::translate(format!("pull_requests.stack.{key}")).into_owned() +} + +fn tr_with(key: &str, args: &[(&str, String)]) -> String { + let names: Vec<_> = args.iter().map(|(name, _)| *name).collect(); + let values: Vec<_> = args.iter().map(|(_, value)| value.clone()).collect(); + crate::translate_with_args(format!("pull_requests.stack.{key}"), &names, &values).into_owned() +} + +fn blocked(blocker: u64, kind: StackBlocker) -> String { + tr_with( + match kind { + StackBlocker::Draft => "blocked_signal_draft", + StackBlocker::Closed => "blocked_signal_closed", + }, + &[("blocker", blocker.to_string())], + ) +} + +/// "#412, #413 and #414", "#412–#418" for a consecutive run, else a count. +pub(super) fn list(numbers: &[u64]) -> String { + let n = |index: usize| numbers[index].to_string(); + match numbers.len() { + 0 => String::new(), + 1 => format!("#{}", numbers[0]), + 2 => tr_with("list_two", &[("a", n(0)), ("b", n(1))]), + 3 => tr_with("list_three", &[("a", n(0)), ("b", n(1)), ("c", n(2))]), + count if numbers.windows(2).all(|pair| pair[1] == pair[0] + 1) => { + tr_with("list_range", &[("first", n(0)), ("last", n(count - 1))]) + } + count => tr_with("list_count", &[("count", count.to_string())]), + } +} + +fn short(head: &str) -> String { + head.chars().take(7).collect() +} + +fn state_word(state: PullRequestState, draft: bool) -> String { + crate::tr!(match (state, draft) { + (PullRequestState::Open, true) => "pull_requests.state_draft", + (PullRequestState::Open, false) => "pull_requests.state_open", + (PullRequestState::Merged, _) => "pull_requests.state_merged", + (PullRequestState::Closed, _) => "pull_requests.state_closed", + }) + .into_owned() +} + +fn state_lower(state: PullRequestState, draft: bool) -> String { + crate::tr!(match (state, draft) { + (PullRequestState::Open, true) => "pull_requests.state_draft_lower", + (PullRequestState::Open, false) => "pull_requests.state_open_lower", + (PullRequestState::Merged, _) => "pull_requests.state_merged_lower", + (PullRequestState::Closed, _) => "pull_requests.state_closed_lower", + }) + .into_owned() +} + +/// The map's one-line reading of what merging the selected layer would do. +fn summary(map: &StackMap<'_>) -> String { + let selected = map.selected(); + let number = selected.layer.number.to_string(); + match selected.layer.state { + PullRequestState::Merged => return tr_with("summary_merged", &[("number", number)]), + PullRequestState::Closed => return tr_with("summary_closed", &[("number", number)]), + PullRequestState::Open => {} + } + if selected.draft == Some(true) { + return tr_with("summary_selected_draft", &[("number", number)]); + } + let blockers = map.blockers(); + if let Some((blocker, kind)) = blockers.first() { + let mut text = tr_with( + match kind { + StackBlocker::Draft => "summary_blocked_draft", + StackBlocker::Closed => "summary_blocked_closed", + }, + &[("number", number), ("blocker", blocker.to_string())], + ); + if blockers.len() > 1 { + text.push(' '); + text.push_str(&tr_with( + "summary_more", + &[("count", (blockers.len() - 1).to_string())], + )); + } + return text; + } + let scope = map.scope(); + if scope.len() <= 1 { + return tr_with("summary_bottom", &[("number", number)]); + } + tr_with( + "summary_scope", + &[ + ("number", number), + ("count", scope.len().to_string()), + ("base", map.stack.base.clone()), + ], + ) +} + +/// The label and tooltip of the Merge stack primary. +pub(super) fn merge_tooltip(offer: &StackOffer) -> String { + let map = offer.map(); + let scope = map.scope(); + let number = offer.key.number.to_string(); + if scope.len() <= 1 { + return tr_with( + "merge_tooltip_one", + &[("number", number), ("base", map.stack.base.clone())], + ); + } + tr_with( + "merge_tooltip", + &[ + ("first", scope[0].to_string()), + ("number", number), + ("base", map.stack.base.clone()), + ("count", scope.len().to_string()), + ], + ) +} + +pub(super) fn merge_count_label(offer: &StackOffer) -> String { + tr_with( + "merge_count", + &[("count", offer.map().scope().len().max(1).to_string())], + ) +} + +pub(super) fn rebase_menu_label() -> String { + tr("rebase_menu") +} + +pub(super) fn rebase_tooltip(offer: &StackOffer) -> String { + tr_with( + "rebase_tooltip", + &[ + ("stack", offer.map().stack.number.to_string()), + ("base", offer.base()), + ], + ) +} + +pub(super) fn merge_primary_label() -> String { + tr("merge") +} + +pub(super) fn merge_primary_accessible(number: u64) -> String { + tr_with("merge_label", &[("number", number.to_string())]) +} + +/// The operation's chip: the header's primary slot, the map and the linked rows' caption. A +/// merge's chip opens a popover with its scope and GitHub's operation id; a rebase's opens its +/// progress where this device may act on the stack, and is its tooltip alone elsewhere. +pub(super) fn operation_chip( + target: Option, + operation: &PullRequestStackOperation, + links: &[ThreadPullRequestLink], + compact: bool, + cx: &App, +) -> AnyElement { + let (icon, color, label, tooltip) = chip_words(operation, cx); + let chip = || { + h_flex() + .id(SharedString::from(format!( + "pr-stack-operation-{}", + operation.stack + ))) + .flex_none() + .gap_1() + .items_center() + .px_2() + .py(px(2.)) + .rounded(material::radius_chip(cx)) + .bg(color.opacity(0.1)) + .text_color(color) + .text_size(px(if compact { 13. } else { 12. })) + .child(match operation.kind { + StackOperationKind::Rebasing { .. } => Spinner::new().xsmall().into_any_element(), + _ => Icon::new(icon).size(px(14.)).into_any_element(), + }) + .child(label.clone()) + }; + let (StackOperationKind::Merging { + id, + target: merged, + layers, + .. + } + | StackOperationKind::MergeUnconfirmed { + id, + target: merged, + layers, + .. + }) = &operation.kind + else { + let chip = chip().tooltip({ + let tooltip = tooltip.clone(); + move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx) + }); + return match target { + Some(target) => chip + .cursor_pointer() + .on_click(move |_, window, cx| open_progress(target.clone(), window, cx)) + .into_any_element(), + None => chip.into_any_element(), + }; + }; + let id = id.clone(); + let url = core_pr::stack_map( + links, + &PullRequestKey::new(&operation.host, &operation.repository, *merged), + ) + .map(|map| map.stack.url.clone()); + let titles: Vec<(u64, String)> = layers + .iter() + .map(|&number| { + let title = links + .iter() + .find(|link| { + link.key.host == operation.host + && link.key.repository == operation.repository + && link.key.number == number + }) + .and_then(|link| link.snapshot.as_ref()) + .map(|snapshot| snapshot.title.clone()) + .unwrap_or_default(); + (number, title) + }) + .collect(); + let popover = Popover::new(SharedString::from(format!( + "pr-stack-operation-popover-{}", + operation.stack + ))) + .trigger( + Button::new(SharedString::from(format!( + "pr-stack-operation-trigger-{}", + operation.stack + ))) + .ghost() + .compact() + .aria_label(format!( + "{label}, {}", + tr_with("operation_id", &[("id", id.clone())]) + )) + .tooltip(tooltip.clone()) + .child(chip().cursor_pointer()), + ); + let popover = if compact { + popover.bottom_sheet(tr("popover_title")) + } else { + popover + }; + popover + .content(move |_, _, cx| { + let muted = cx.theme().muted_foreground; + let mono = cx.theme().mono_font_family.clone(); + v_flex() + .when(!compact, |content| content.w(px(340.))) + .p_3() + .gap_2() + .text_size(px(12.)) + .child(div().child(tooltip.clone())) + .child( + v_flex() + .gap_0p5() + .children(titles.iter().map(|(number, title)| { + h_flex() + .gap_2() + .child(div().font_family(mono.clone()).child(format!("#{number}"))) + .child(div().flex_1().min_w_0().truncate().child(title.clone())) + })), + ) + .child( + h_flex() + .gap_1() + .items_center() + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .font_family(mono.clone()) + .text_color(muted) + .child(tr_with("operation_id", &[("id", id.clone())])), + ) + .child( + Button::new("pr-stack-copy-id") + .ghost() + .xsmall() + .compact() + .icon(IconName::Copy) + .tooltip(tr("copy_id")) + .on_click({ + let id = id.clone(); + move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(id.clone())) + } + }), + ), + ) + .children(url.clone().map(|url| { + Button::new("pr-stack-operation-open") + .ghost() + .xsmall() + .icon(IconName::ExternalLink) + .label(crate::tr!("pull_requests.open_on_github")) + .on_click(move |_, _, cx| cx.open_url(&url)) + })) + }) + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .shadow_xl() + .rounded(material::radius_overlay(cx)) + .into_any_element() +} + +/// The chip's glyph, colour, label and long form. +pub(super) fn chip_words( + operation: &PullRequestStackOperation, + cx: &App, +) -> (IconName, Hsla, String, String) { + let ago = super::detail::ago(operation.started_at); + match &operation.kind { + StackOperationKind::Merging { + layers, adopted, .. + } => { + let mut tooltip = tr_with( + "chip_merging_tooltip", + &[("list", list(layers)), ("ago", ago)], + ); + if *adopted { + tooltip.push(' '); + tooltip.push_str(&tr("chip_adopted_tooltip")); + } + ( + IconName::Hourglass, + cx.theme().warning, + tr("chip_merging"), + tooltip, + ) + } + StackOperationKind::MergeUnconfirmed { .. } => ( + IconName::CircleQuestionMark, + cx.theme().warning, + tr("chip_unconfirmed"), + tr("chip_unconfirmed_tooltip"), + ), + StackOperationKind::Rebasing { layers } => { + let done = layers + .iter() + .filter(|layer| { + matches!( + layer.step, + StackRebaseStep::Pushed { .. } | StackRebaseStep::AlreadyCurrent + ) + }) + .count(); + let label = tr_with( + "chip_rebasing", + &[ + ("done", done.to_string()), + ("count", layers.len().to_string()), + ], + ); + (IconName::RefreshCw, cx.theme().info, label.clone(), label) + } + StackOperationKind::RebaseEnded { layers } => { + let (icon, color, label) = match layers + .iter() + .find(|layer| matches!(layer.step, StackRebaseStep::Failed { .. })) + { + Some(failed) => ( + IconName::CircleX, + cx.theme().danger, + tr_with( + "result_rebase_stopped", + &[("number", failed.number.to_string())], + ), + ), + None => ( + IconName::CircleCheck, + cx.theme().success, + tr_with("result_rebased", &[("stack", operation.stack.to_string())]), + ), + }; + (icon, color, label.clone(), label) + } + } +} + +/// The page's stack map: the layer selector's trigger, and the popover (a sheet on a phone) +/// with every layer, the merge scope and its blockers, the base, the note on GitHub stacks and +/// the stack's writes. +pub(super) fn map_selector( + view: Entity, + offer: StackOffer, + target: Option, + action: Option, + compact: bool, + cx: &App, +) -> AnyElement { + let map = offer.map(); + let index = map.selected + 1; + let count = map.rows.len(); + let stack_number = map.stack.number; + let muted = cx.theme().muted_foreground; + let mut accessible = tr_with( + "trigger_label", + &[ + ("stack", stack_number.to_string()), + ("index", index.to_string()), + ("count", count.to_string()), + ], + ); + let status = offer + .operation + .as_ref() + .and_then(|operation| match operation.kind { + StackOperationKind::Merging { .. } => { + Some((IconName::Hourglass, cx.theme().warning, "trigger_merging")) + } + StackOperationKind::MergeUnconfirmed { .. } => Some(( + IconName::CircleQuestionMark, + cx.theme().warning, + "trigger_unconfirmed", + )), + StackOperationKind::Rebasing { .. } => { + Some((IconName::RefreshCw, cx.theme().info, "trigger_rebasing")) + } + StackOperationKind::RebaseEnded { .. } => None, + }); + if let Some((_, _, suffix)) = status { + accessible.push_str(&tr(suffix)); + } + let trigger = Button::new("pr-layer-select") + .ghost() + .outline() + .compact() + .aria_label(accessible.clone()) + .tooltip(accessible) + .child( + h_flex() + .gap_1p5() + .items_center() + .text_size(px(13.)) + .child(Icon::new(IconName::Layers).size(px(14.))) + .child( + crate::tr!( + "pull_requests.layer_position", + index = index.to_string(), + count = count.to_string() + ) + .into_owned(), + ) + .children( + status.map(|(icon, color, _)| Icon::new(icon).size(px(12.)).text_color(color)), + ) + .child(Icon::new(IconName::ChevronDown).xsmall().text_color(muted)), + ); + let popover = Popover::new("pr-layer-popover").trigger(trigger); + let popover = if compact { + popover.bottom_sheet(crate::tr!("pull_requests.detail.stack_title").into_owned()) + } else { + popover + }; + popover + .content(move |_, _, cx| { + let popover = cx.entity(); + map_content( + &view, + &offer, + target.as_ref(), + action.as_ref(), + compact, + popover, + cx, + ) + }) + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .shadow_xl() + .rounded(material::radius_overlay(cx)) + .into_any_element() +} + +fn map_content( + view: &Entity, + offer: &StackOffer, + target: Option<&Target>, + action: Option<&PullRequestActionState>, + compact: bool, + popover: Entity, + cx: &mut Context, +) -> AnyElement { + let map = offer.map(); + let theme = cx.theme().clone(); + let muted = theme.muted_foreground; + let mono = theme.mono_font_family.clone(); + let selected_number = offer.key.number; + let store = view.read(cx).store().clone(); + let collapsed = store.read(cx).stack_note_collapsed(); + let head = h_flex() + .px_2() + .py_1() + .gap_1() + .items_center() + .text_size(px(11.)) + .text_color(muted) + .child(Icon::new(IconName::Layers).size(px(12.))) + .child(div().flex_1().min_w_0().truncate().child(tr_with( + "head", + &[ + ("stack", map.stack.number.to_string()), + ("count", map.rows.len().to_string()), + ("base", map.stack.base.clone()), + ], + ))) + .child( + Button::new("pr-stack-about") + .ghost() + .xsmall() + .compact() + .icon(IconName::CircleQuestionMark) + .tooltip(tr("about")) + .on_click({ + let store = store.clone(); + move |_, _, cx| { + store.update(cx, |store, cx| { + let collapsed = store.stack_note_collapsed(); + store.set_stack_note_collapsed(!collapsed, cx) + }) + } + }), + ); + let note = (!collapsed).then(|| { + v_flex() + .px_2() + .pb_1() + .gap_1() + .items_start() + .text_size(px(11.)) + .text_color(muted) + .child(tr("note")) + .child( + Button::new("pr-stack-docs") + .ghost() + .xsmall() + .icon(IconName::ExternalLink) + .label(tr("docs_link")) + .on_click(|_, _, cx| cx.open_url(core_pr::STACKS_DOCS_URL)), + ) + }); + let operation = offer.operation.as_ref().map(|operation| { + h_flex().px_2().py_1().child(operation_chip( + target.cloned(), + operation, + &offer.links, + compact, + cx, + )) + }); + let rows = map.rows.iter().rev().map(|row| { + let number = row.layer.number; + let layer_key = PullRequestKey::new(&offer.key.host, &offer.key.repository, number); + let linked = row.condition == StackLayerCondition::Linked; + let (glyph, color, _) = appearance(row_state(row.link, Some(row.layer.state)), cx); + let title = row + .link + .and_then(|link| link.snapshot.as_ref()) + .map(|snapshot| snapshot.title.clone()); + let state = state_lower(row.layer.state, row.draft == Some(true)); + let (trailing, role_label): (AnyElement, String) = match row.role { + StackLayerRole::Selected => ( + Icon::new(IconName::Check).size(px(12.)).into_any_element(), + tr("role_label_selected"), + ), + StackLayerRole::Above => ( + div() + .text_color(muted) + .child(state_word(row.layer.state, row.draft == Some(true))) + .into_any_element(), + tr("role_label_above"), + ), + StackLayerRole::InScope => ( + div() + .text_color(muted) + .child(tr_with( + "role_in_scope", + &[("number", selected_number.to_string())], + )) + .into_any_element(), + tr_with( + "role_label_in_scope", + &[("number", selected_number.to_string())], + ), + ), + StackLayerRole::Blocks(kind) => { + let (color, label) = match kind { + StackBlocker::Draft => (theme.warning, tr("blocks_draft")), + StackBlocker::Closed => (theme.danger, tr("blocks_closed")), + }; + ( + h_flex() + .gap_1() + .items_center() + .text_color(color) + .child(Icon::new(IconName::Lock).size(px(12.))) + .child(label) + .into_any_element(), + tr_with( + "role_label_blocks", + &[("number", selected_number.to_string())], + ), + ) + } + StackLayerRole::BelowMerged => ( + div() + .text_color(muted) + .child(tr("role_merged")) + .into_any_element(), + tr("role_label_merged"), + ), + }; + let condition = match row.condition { + StackLayerCondition::Dismissed => Some(crate::tr!("pull_requests.source_dismissed")), + StackLayerCondition::NotLinked => { + Some(crate::tr!("pull_requests.detail.layer_not_linked")) + } + StackLayerCondition::Linked => None, + }; + let name = title + .clone() + .unwrap_or_else(|| row.layer.head_branch.clone()); + let mut label = tr_with( + "row_label", + &[ + ("number", number.to_string()), + ("title", name.clone()), + ("state", state), + ("role", role_label), + ], + ); + if let Some(condition) = &condition { + label.push_str(", "); + label.push_str(&condition.to_lowercase()); + } + let selected = row.role == StackLayerRole::Selected; + let rail = matches!(row.role, StackLayerRole::Selected | StackLayerRole::InScope); + let view = view.clone(); + let popover = popover.clone(); + material::accessible_clickable( + h_flex(), + ("pr-layer", number as usize), + gpui::Role::MenuItem, + label, + cx, + ) + .aria_selected(selected) + .w_full() + .h(px(if compact { 44. } else { 36. })) + .px_2() + .gap_2() + .items_center() + .text_size(px(11.)) + .rounded(theme.tokens.radius.sm) + .cursor_pointer() + .hover(|row| row.bg(theme.list_hover)) + .when(selected, |row| row.bg(theme.list_active)) + // The merge scope reads as one bar down the rows that land together. + .child( + div() + .w(px(2.)) + .h_full() + .when(rail, |bar| bar.bg(theme.primary.opacity(0.6))), + ) + .child( + Icon::new(glyph) + .size(px(14.)) + .text_color(if linked { color } else { muted }), + ) + .child( + div() + .font_family(mono.clone()) + .text_size(px(12.)) + .child(format!("#{number}")), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(13.)) + .when(title.is_none(), |text| { + text.font_family(mono.clone()).text_color(muted) + }) + .child(name), + ) + .child(trailing) + .children(condition.map(|condition| div().text_color(muted).child(condition.into_owned()))) + .on_click(move |_, window, cx| { + view.update(cx, |view, cx| view.select_layer(layer_key.clone(), cx)); + popover.update(cx, |state, cx| state.dismiss(window, cx)); + }) + }); + let base = h_flex() + .px_2() + .h(px(24.)) + .gap_1() + .items_center() + .text_size(px(11.)) + .text_color(muted) + .font_family(mono.clone()) + .child(Icon::new(IconName::CornerDownRight).size(px(12.))) + .child(map.stack.base.clone()); + let summary = v_flex() + .px_2() + .py_1() + .gap_0p5() + .child(div().text_size(px(12.)).child(summary(&map))) + .child( + div() + .text_size(px(11.)) + .text_color(muted) + .child(tr("summary_source")), + ); + let read_only = target.is_none(); + let action_button = |id: &'static str, + icon: IconName, + label: String, + avail: Avail, + kind: Lifecycle, + tooltip: Option| { + if read_only || avail == Avail::Hidden { + return None; + } + let target = target.cloned(); + let popover = popover.clone(); + let disabled = matches!(avail, Avail::Disabled(_)); + let tooltip = match avail { + Avail::Disabled(reason) => Some(reason), + _ => tooltip, + }; + let button = Button::new(id) + .outline() + .icon(icon) + .label(label) + .disabled(disabled) + .when_some(tooltip, |button, tooltip| button.tooltip(tooltip)) + .on_click(move |_, window, cx| { + popover.update(cx, |state, cx| state.dismiss(window, cx)); + if let Some(target) = target.clone() { + target.run(kind, window, cx); + } + }); + Some(if compact { + button.w_full().h(px(44.)).into_any_element() + } else { + button.xsmall().into_any_element() + }) + }; + let actions: Vec = [ + action_button( + "pr-stack-merge", + IconName::GitMerge, + merge_count_label(offer), + offer.merge(action), + Lifecycle::MergeStack, + Some(merge_tooltip(offer)), + ), + action_button( + "pr-stack-rebase", + IconName::RefreshCw, + rebase_menu_label(), + offer.rebase(action), + Lifecycle::RebaseStack, + Some(rebase_tooltip(offer)), + ), + ] + .into_iter() + .flatten() + .collect(); + let actions = (!actions.is_empty()).then(|| { + let row = if compact { v_flex() } else { h_flex() }; + row.gap_2() + .px_2() + .pt_2() + .pb_1() + .border_t_1() + .border_color(theme.border) + .children(actions) + }); + v_flex() + .id("pr-layer-list") + .role(gpui::Role::Menu) + .when(compact, |list| list.w_full()) + .when(!compact, |list| list.w(px(380.)).max_h(px(480.))) + .p_1() + .gap_0p5() + .overflow_y_scroll() + .child(head) + .children(note) + .children(operation) + .children(rows) + .child(base) + .child(summary) + .children(actions) + .into_any_element() +} + +/// The linked rows' stack caption: what the stack is, the operation running on it, and the +/// short note with GitHub's docs. +pub(super) fn caption( + stack: &core_pr::PullRequestStack, + operation: Option<&PullRequestStackOperation>, + links: &[ThreadPullRequestLink], + compact: bool, + cx: &App, +) -> AnyElement { + let muted = cx.theme().muted_foreground; + v_flex() + .px_3() + .py_1() + .gap_0p5() + .text_size(px(11.)) + .text_color(muted) + .child( + h_flex() + .font_medium() + .id(SharedString::from(format!("pr-stack-{}", stack.id))) + .gap_2() + .items_center() + .tooltip(|window, cx| { + Tooltip::new(crate::tr!("pull_requests.stack_tooltip").into_owned()) + .build(window, cx) + }) + .child(Icon::new(IconName::Layers).xsmall()) + .child( + div().flex_1().min_w_0().truncate().child( + crate::tr!( + "pull_requests.stack_caption", + count = stack.layers.len().to_string(), + base = stack.base.clone() + ) + .into_owned(), + ), + ) + .children( + operation.map(|operation| operation_chip(None, operation, links, compact, cx)), + ), + ) + .child( + h_flex() + .flex_wrap() + .gap_1() + .items_center() + .child(tr("note_short")) + .child( + Button::new(SharedString::from(format!("pr-stack-docs-{}", stack.id))) + .ghost() + .xsmall() + .compact() + .icon(IconName::ExternalLink) + .label(tr("about")) + .on_click(|_, _, cx| cx.open_url(core_pr::STACKS_DOCS_URL)), + ), + ) + .into_any_element() +} + +/// A linked layer's blocked signal: a draft or closed layer below it that it would merge with. +pub(super) fn row_blocker(links: &[ThreadPullRequestLink], key: &PullRequestKey) -> Option { + let map = core_pr::stack_map(links, key)?; + let selected = map.selected(); + if selected.condition != StackLayerCondition::Linked + || selected.layer.state != PullRequestState::Open + || selected.draft == Some(true) + { + return None; + } + let (blocker, kind) = *map.blockers().first()?; + Some(blocked(blocker, kind)) +} + +/// One toast slot per stack: a newer answer replaces the older one on every layer's page. +struct StackWrite; + +fn slot(stack: &StackId) -> SharedString { + SharedString::from(format!("{}/{}/{}", stack.0, stack.1, stack.2)) +} + +/// Where a stack's answer was asked from and what it covered, for its words. +pub(super) struct Answer<'a> { + pub(super) stack: StackId, + pub(super) target: u64, + pub(super) layers: &'a [u64], + pub(super) base: &'a str, + /// Each layer's head branch, for the words that name one. + pub(super) branches: Vec<(u64, String)>, + pub(super) url: Option, + pub(super) rebase: bool, + pub(super) late: bool, +} + +/// The words of a stack write's answer. A success goes by itself; anything else stays until +/// it is read. +pub(super) fn answer_toast( + answer: &Answer<'_>, + result: &PullRequestActionResult, +) -> Option { + let number = answer.target.to_string(); + let count = answer.layers.len().max(1); + let open = |note: Notification| match answer.url.clone() { + Some(url) => note.action(move |_, _, _| { + let url = url.clone(); + Button::new("pr-stack-result-open") + .ghost() + .xsmall() + .label(crate::tr!("pull_requests.open_on_github")) + .on_click(move |_, _, cx| cx.open_url(&url)) + }), + None => note, + }; + let not = |title: String, body: String| Notification::error(body).title(title); + let note = match result { + PullRequestActionResult::Applied if answer.late => Notification::success(tr_with( + "result_finished_late_body", + &[ + ("list", list(answer.layers)), + ("base", answer.base.to_owned()), + ], + )) + .title(tr("result_finished_late")), + PullRequestActionResult::Applied => Notification::success(tr_with( + "result_merged_body", + &[ + ("list", list(answer.layers)), + ("base", answer.base.to_owned()), + ], + )) + .title(if count == 1 { + tr_with("result_merged_one", &[("number", number)]) + } else { + tr_with( + "result_merged", + &[("count", count.to_string()), ("number", number)], + ) + }), + PullRequestActionResult::Queued { .. } => Notification::info(tr("result_queued_body")) + .title(tr_with("result_queued", &[("number", number)])), + PullRequestActionResult::Pending { adopted: false, .. } => Notification::info(tr_with( + "result_submitted_body", + &[("list", list(answer.layers))], + )) + .title(tr("result_submitted")), + PullRequestActionResult::Pending { adopted: true, .. } => { + Notification::info(tr("result_adopted_body")).title(tr("result_adopted")) + } + PullRequestActionResult::MergeUnconfirmed { .. } => { + open(Notification::warning(tr("result_deadline_body")).title(tr("result_deadline"))) + } + PullRequestActionResult::RebaseStarted => return None, + PullRequestActionResult::Rebased { pushed, current } => Notification::success(tr_with( + "result_rebased_body", + &[ + ("pushed", pushed.len().to_string()), + ("current", current.len().to_string()), + ], + )) + .title(tr_with( + "result_rebased", + &[("stack", answer.stack.2.to_string())], + )), + PullRequestActionResult::RebaseStopped { + pushed, + failed, + reason, + untouched, + } => { + let stayed = if pushed.is_empty() { + String::new() + } else { + tr_with("result_pushed_stay", &[("list", list(pushed))]) + }; + let failed_number = failed.to_string(); + match reason { + StackRebaseFailure::PushUnconfirmed => open( + Notification::warning(tr_with( + "result_push_uncertain_body", + &[ + ("pushed", stayed.clone()), + ("branch", branch_of(answer, *failed)), + ], + )) + .title(tr_with( + "result_push_uncertain", + &[("number", failed_number)], + )), + ), + _ => { + let body = match reason { + StackRebaseFailure::Conflict => tr_with( + "result_rebase_conflict_body", + &[ + ("pushed", stayed.clone()), + ("number", failed_number.clone()), + ( + "untouched", + if untouched.is_empty() { + String::new() + } else { + tr_with("result_untouched", &[("list", list(untouched))]) + }, + ), + ], + ), + StackRebaseFailure::LeaseRefused => tr_with( + "result_rebase_lease_body", + &[ + ("branch", branch_of(answer, *failed)), + ("pushed", stayed.clone()), + ], + ), + StackRebaseFailure::Git { step, message } => tr_with( + "result_rebase_git_body", + &[ + ("step", git_step(*step)), + ("message", message.clone()), + ("pushed", stayed.clone()), + ], + ), + StackRebaseFailure::PushUnconfirmed => unreachable!(), + }; + open(Notification::error(body.trim().to_owned()).title(tr_with( + "result_rebase_stopped", + &[("number", failed_number)], + ))) + } + } + } + // A rebase that did not start names why and that nothing moved. + PullRequestActionResult::Rejected(rejection) + if answer.rebase && *rejection != PullRequestRejection::OperationRunning => + { + not( + tr_with( + "result_not_rebased", + &[("reason", rejection_reason(rejection))], + ), + tr("nothing_changed"), + ) + } + PullRequestActionResult::Rejected(rejection) => { + let titled = not; + match rejection { + PullRequestRejection::LayerChanged { + number, + expected, + actual, + } => titled( + tr_with("result_layer_changed", &[("number", number.to_string())]), + tr_with( + "result_layer_changed_body", + &[("expected", short(expected)), ("actual", short(actual))], + ), + ), + PullRequestRejection::StackChanged => { + titled(tr("result_stack_changed"), tr("result_stack_changed_body")) + } + PullRequestRejection::LayerNotOpen { number, state } => titled( + tr_with( + "result_not_open", + &[ + ("number", number.to_string()), + ("state", state_lower(*state, false)), + ], + ), + tr("result_not_open_body"), + ), + PullRequestRejection::LayerDraft { number } => titled( + tr_with( + "result_not_open", + &[ + ("number", number.to_string()), + ("state", state_lower(PullRequestState::Open, true)), + ], + ), + tr("result_not_open_body"), + ), + PullRequestRejection::OperationRunning => { + Notification::info(tr("waiting_state")).title(tr("result_running")) + } + PullRequestRejection::MergeRunning => { + open(not(tr("result_409_no_id"), tr("result_409_no_id_body"))) + } + PullRequestRejection::NoPushAccess { numbers } => not( + tr_with( + "result_not_rebased", + &[( + "reason", + tr_with("result_no_push", &[("list", list(numbers))]), + )], + ), + tr("nothing_changed"), + ), + PullRequestRejection::NoGitIdentity => not( + tr_with( + "result_not_rebased", + &[("reason", tr("result_no_identity"))], + ), + tr("nothing_changed"), + ), + PullRequestRejection::NotLinked => { + titled(tr("result_refused"), tr("result_not_linked")) + } + PullRequestRejection::Refused { messages } if !answer.rebase => { + let body = if messages.is_empty() { + tr("result_refused_body") + } else { + let joined = messages.join(" "); + if joined.chars().count() > 320 { + format!("{}…", joined.chars().take(320).collect::()) + } else { + joined + } + }; + not(tr("result_refused"), body) + } + rejection => titled(tr("result_refused"), rejection_reason(rejection)), + } + } + PullRequestActionResult::Uncertain => open( + Notification::warning( + crate::tr!( + "pull_requests.result.uncertain_body", + message = crate::tr!("pull_requests.result.connection_lost").into_owned() + ) + .into_owned(), + ) + .title(tr_with("result_uncertain", &[("number", number)])), + ), + _ => return None, + }; + let settled = matches!( + result, + PullRequestActionResult::Applied + | PullRequestActionResult::Queued { .. } + | PullRequestActionResult::Rebased { .. } + ); + Some( + note.autohide(settled) + .id1::(slot(&answer.stack)), + ) +} + +fn branch_of(answer: &Answer<'_>, number: u64) -> String { + answer + .branches + .iter() + .find(|(layer, _)| *layer == number) + .map(|(_, branch)| branch.clone()) + .unwrap_or_else(|| format!("#{number}")) +} + +fn branches(links: &[ThreadPullRequestLink], key: &PullRequestKey) -> Vec<(u64, String)> { + core_pr::native_stack(links, key) + .map(|stack| { + stack + .layers + .iter() + .map(|layer| (layer.number, layer.head_branch.clone())) + .collect() + }) + .unwrap_or_default() +} + +fn git_step(step: StackRebaseGitStep) -> String { + tr(match step { + StackRebaseGitStep::Preparing => "git_preparing", + StackRebaseGitStep::Fetching => "git_fetching", + StackRebaseGitStep::ForkPoint => "git_fork_point", + StackRebaseGitStep::CheckingOut => "git_checking_out", + StackRebaseGitStep::Rebasing => "git_rebasing", + StackRebaseGitStep::Pushing => "git_pushing", + }) +} + +/// The host's report of how a stack write ended, for this client's toast and its rebase view. +#[allow(clippy::too_many_arguments)] +pub fn present_result( + store: &Entity, + session: &str, + target: &PullRequestKey, + stack: u64, + layers: &[u64], + base: &str, + result: &PullRequestActionResult, + late: bool, + window: &mut Window, + cx: &mut App, +) { + let id = (target.host.clone(), target.repository.clone(), stack); + let rebase = matches!( + result, + PullRequestActionResult::Rebased { .. } | PullRequestActionResult::RebaseStopped { .. } + ); + let answer = Answer { + stack: id, + target: target.number, + layers, + base, + branches: branches(store.read(cx).pull_requests(session), target), + url: Some(format!( + "https://{}/{}/pull/{}", + target.host, target.repository, target.number + )), + rebase, + late, + }; + if let Some(note) = answer_toast(&answer, result) { + window.push_notification(note, cx); + } +} + +fn send( + target: &Target, + action: tcode_protocol::PullRequestAction, + cx: &mut App, +) -> gpui::Task { + (target.started)(cx); + let task = target.store.update(cx, |store, cx| { + store.command( + Command::RunPullRequestAction { + session_id: target.session.clone(), + key: target.offer.key.clone(), + action, + }, + cx, + ) + }); + cx.spawn(async move |_| match answer_of(task.await) { + Ok(result) => result, + Err(error) => PullRequestActionResult::Rejected(PullRequestRejection::Refused { + messages: vec![super::detail::reason(&error)], + }), + }) +} + +/// A rejection that is about the stack the dialog shows: the dialog stays with it. +fn stays(result: &PullRequestActionResult) -> bool { + matches!( + result, + PullRequestActionResult::Rejected( + PullRequestRejection::StackChanged + | PullRequestRejection::LayerChanged { .. } + | PullRequestRejection::LayerNotOpen { .. } + | PullRequestRejection::LayerDraft { .. } + | PullRequestRejection::NoPushAccess { .. } + | PullRequestRejection::NoGitIdentity + ) + ) +} + +fn notice_text(result: &PullRequestActionResult) -> Option { + let PullRequestActionResult::Rejected(rejection) = result else { + return None; + }; + Some(match rejection { + PullRequestRejection::LayerChanged { number, actual, .. } => tr_with( + "changed_head", + &[("number", number.to_string()), ("new", short(actual))], + ), + PullRequestRejection::StackChanged => tr("changed_layers"), + PullRequestRejection::LayerNotOpen { number, .. } + | PullRequestRejection::LayerDraft { number } => { + tr_with("changed_state", &[("number", number.to_string())]) + } + PullRequestRejection::NoPushAccess { numbers } => { + tr_with("rebase_blocked_access", &[("layers", list(numbers))]) + } + PullRequestRejection::NoGitIdentity => tr_with( + "rebase_blocked_identity", + &[("machine", machine_label(None))], + ), + _ => return None, + }) +} + +fn machine_label(store: Option<&WorkspaceStore>) -> String { + store + .and_then(|store| store.remote_host_name().map(str::to_owned)) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| tr("this_machine")) +} + +fn dialog_width(target: &Target, window: &Window, cx: &App) -> gpui::Pixels { + if target.window_state.read(cx).compact { + (window.viewport_size().width - px(2. * material::COMPACT_PAGE_INSET)).min(px(480.)) + } else { + px(480.) + } +} + +fn fact(label: String, value: impl IntoElement, cx: &App) -> AnyElement { + h_flex() + .gap_2() + .items_start() + .child( + div() + .w(px(104.)) + .flex_none() + .text_size(px(12.)) + .text_color(cx.theme().muted_foreground) + .child(label), + ) + .child(div().flex_1().min_w_0().text_size(px(13.)).child(value)) + .into_any_element() +} + +fn line(icon: IconName, color: Hsla, text: String) -> AnyElement { + h_flex() + .gap_1p5() + .items_start() + .text_size(px(12.)) + .child( + div() + .pt(px(2.)) + .child(Icon::new(icon).size(px(12.)).text_color(color)), + ) + .child(div().flex_1().min_w_0().child(text)) + .into_any_element() +} + +fn warning_wash(cx: &App) -> gpui::Div { + v_flex() + .gap_1() + .p_2() + .rounded(cx.theme().tokens.radius.md) + .bg(cx.theme().warning.opacity(0.1)) + .text_size(px(12.)) +} + +/// One fresh layer of a confirmation, with its tag. +fn layer_row( + index: Option, + layer: &tcode_protocol::PullRequestStackLayerState, + tag: AnyElement, + branch: bool, + muted_row: bool, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let (glyph, color, _) = appearance( + match (layer.state, layer.draft) { + (PullRequestState::Open, true) => core_pr::PullRequestBadgeState::Draft, + (PullRequestState::Open, false) => core_pr::PullRequestBadgeState::Open, + (PullRequestState::Merged, _) => core_pr::PullRequestBadgeState::Merged, + (PullRequestState::Closed, _) => core_pr::PullRequestBadgeState::Closed, + }, + cx, + ); + let head = layer.head.clone(); + h_flex() + .id(SharedString::from(format!( + "pr-stack-layer-{}", + layer.number + ))) + .min_h(px(40.)) + .px_2() + .gap_2() + .items_center() + .when(muted_row, |row| row.text_color(theme.muted_foreground)) + .children(index.map(|index| { + div() + .w(px(14.)) + .text_size(px(11.)) + .text_color(theme.muted_foreground) + .child((index + 1).to_string()) + })) + .child(Icon::new(glyph).size(px(14.)).text_color(if muted_row { + theme.muted_foreground + } else { + color + })) + .child( + div() + .font_family(theme.mono_font_family.clone()) + .text_size(px(12.)) + .child(format!("#{}", layer.number)), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .child( + div() + .truncate() + .text_size(px(13.)) + .child(layer.title.clone()), + ) + .when(branch, |text| { + text.child( + div() + .truncate() + .text_size(px(11.)) + .font_family(theme.mono_font_family.clone()) + .text_color(theme.muted_foreground) + .child(layer.head_branch.clone()), + ) + }), + ) + .when(index.is_some(), |row| { + row.child( + div() + .id(SharedString::from(format!( + "pr-stack-head-{}", + layer.number + ))) + .font_family(theme.mono_font_family.clone()) + .text_size(px(12.)) + .text_color(theme.muted_foreground) + .child(head.as_deref().map(short).unwrap_or_else(|| "—".into())) + .when_some(head, |cell, head| { + cell.tooltip(move |window, cx| Tooltip::new(head.clone()).build(window, cx)) + }), + ) + }) + .child(div().flex_none().text_size(px(11.)).child(tag)) + .into_any_element() +} + +fn scope_box(rows: Vec, cx: &App) -> AnyElement { + v_flex() + .id("pr-stack-scope") + .max_h(px(240.)) + .overflow_y_scroll() + .rounded(material::radius_card(cx)) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().muted.opacity(0.5)) + .children(rows) + .into_any_element() +} + +/// The Merge stack confirmation: the scope GitHub holds now, each layer at the head that will be +/// sent, what stays above, and what the merge would meet. +struct MergeStackDialog { + target: Target, + state: Option>, + method: Option, + project: Option<(String, String)>, + project_default: Option, + make_default: bool, + sending: bool, + /// A host answer about the stack this dialog showed; confirm waits for a new read. + notice: Option, + _observe: gpui::Subscription, +} + +impl MergeStackDialog { + fn load(&mut self, cx: &mut Context) { + self.state = None; + self.notice = None; + let task = self.target.store.update(cx, |store, cx| { + store.read_pull_request( + self.target.session.clone(), + self.target.offer.key.clone(), + PullRequestRead::StackState { rebase: false }, + cx, + ) + }); + cx.spawn(async move |this, cx| { + let result = task.await; + let _ = this.update(cx, |this, cx| { + this.state = Some(match result { + Ok((PullRequestReadResponse::StackState(state), _)) => { + this.method = super::lifecycle::merge_method( + &state.merge_methods, + this.target.offer.method, + this.project_default, + ); + Ok(state) + } + Ok(_) => Err(String::new()), + Err(error) => Err(super::detail::reason(&error)), + }); + cx.notify(); + }); + }) + .detach(); + } + + /// The fresh scope: the unmerged layers from the bottom through the selected one. + fn scope( + state: &PullRequestStackActionState, + number: u64, + ) -> Vec<&tcode_protocol::PullRequestStackLayerState> { + let Some(target) = state.layers.iter().position(|layer| layer.number == number) else { + return Vec::new(); + }; + state.layers[..=target] + .iter() + .filter(|layer| layer.state != PullRequestState::Merged) + .collect() + } + + fn blockers(&self, state: &PullRequestStackActionState) -> Vec { + let number = self.target.offer.key.number; + let scope = Self::scope(state, number); + let mut blockers = Vec::new(); + if scope.is_empty() { + blockers.push(tr("changed_layers")); + } + for layer in &scope { + if layer.state != PullRequestState::Open || layer.draft { + blockers.push(tr_with( + "note_blocked", + &[ + ("number", layer.number.to_string()), + ("state", state_lower(layer.state, layer.draft)), + ], + )); + } else if layer.head.is_none() { + blockers.push(tr_with("no_head", &[("number", layer.number.to_string())])); + } + } + if state.merge_methods.is_empty() { + blockers.push(tr("no_methods")); + } + if !state.can_merge { + blockers.push(tr("no_merge_right")); + } + blockers + } + + fn confirm(&mut self, window: &mut Window, cx: &mut Context) { + let (Some(Ok(state)), Some(method)) = (&self.state, self.method) else { + return; + }; + if self.sending || !self.blockers(state).is_empty() || self.notice.is_some() { + return; + } + let number = self.target.offer.key.number; + let scope = Self::scope(state, number); + let heads: Vec<_> = scope + .iter() + .filter_map(|layer| { + Some(PullRequestStackHead { + number: layer.number, + head: layer.head.clone()?, + }) + }) + .collect(); + let layers: Vec<_> = scope.iter().map(|layer| layer.number).collect(); + if self.make_default + && let Some((project, _)) = self.project.clone() + { + self.target.store.update(cx, |store, _| { + store.set_project_merge_method(project, method) + }); + } + self.sending = true; + cx.notify(); + let stack = state.stack; + let base = state.base.clone(); + let task = send( + &self.target, + tcode_protocol::PullRequestAction::MergeStack { + stack, + heads, + method, + }, + cx, + ); + let target = self.target.clone(); + cx.spawn_in(window, async move |this, cx| { + let result = task.await; + let _ = this.update_in(cx, |this, window, cx| { + this.sending = false; + let answer = Answer { + stack: ( + target.offer.key.host.clone(), + target.offer.key.repository.clone(), + stack, + ), + target: target.offer.key.number, + layers: &layers, + base: &base, + branches: branches( + target.store.read(cx).pull_requests(&target.session), + &target.offer.key, + ), + url: Some(target.offer.url.clone()).filter(|url| !url.is_empty()), + rebase: false, + late: false, + }; + if let Some(note) = answer_toast(&answer, &result) { + window.push_notification(note, cx); + } + if stays(&result) { + this.notice = notice_text(&result); + } else { + window.close_dialog(cx); + } + (target.done)(&result, window, cx); + cx.notify(); + }); + }) + .detach(); + } +} + +impl Render for MergeStackDialog { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let target = &self.target; + let key = &target.offer.key; + let theme = cx.theme().clone(); + let muted = theme.muted_foreground; + let state = self.state.as_ref().and_then(|state| state.as_ref().ok()); + let view = cx.entity(); + let stored = target + .offer + .stack + .as_ref() + .map(|offer| offer.map().stack.clone()); + let base = state + .map(|state| state.base.clone()) + .or_else(|| stored.as_ref().map(|stack| stack.base.clone())) + .unwrap_or_default(); + let stack_number = state + .map(|state| state.stack) + .or_else(|| stored.as_ref().map(|stack| stack.number)) + .unwrap_or_default(); + let mut facts = v_flex().gap_1p5().child(fact( + crate::tr!("pull_requests.merge.repository").into_owned(), + div() + .font_family(theme.mono_font_family.clone()) + .child(key.repository.clone()), + cx, + )); + facts = facts.child(fact( + tr("fact_stack"), + tr_with( + "fact_stack_value", + &[("stack", stack_number.to_string()), ("base", base.clone())], + ), + cx, + )); + let method = match (state, self.method) { + (Some(state), Some(chosen)) if state.merge_methods.len() >= 2 => { + let segments = state.merge_methods.iter().map(|method| { + let method = *method; + let view = view.clone(); + material::segment( + SharedString::from(format!("pr-stack-merge-method-{method:?}")), + super::lifecycle::segment_label(method), + chosen == method, + cx, + ) + .on_change(move |_, _, _, cx| { + view.update(cx, |dialog, cx| { + dialog.method = Some(method); + cx.notify(); + }) + }) + }); + material::segmented_track("pr-stack-merge-method", segments, cx).into_any_element() + } + (Some(_), Some(chosen)) => div().child(method_label(chosen)).into_any_element(), + (Some(_), None) => div().into_any_element(), + (None, _) => Spinner::new().small().into_any_element(), + }; + let default_box = self + .method + .filter(|method| state.is_some() && Some(*method) != self.project_default) + .zip(self.project.clone()) + .map(|(method, (_, project))| { + let view = view.clone(); + Checkbox::new("pr-stack-merge-default") + .label( + crate::tr!( + "pull_requests.merge.make_default", + method = super::lifecycle::segment_label(method), + project = project + ) + .into_owned(), + ) + .checked(self.make_default) + .disabled(self.sending) + .on_click(move |checked, _, cx| { + let checked = *checked; + view.update(cx, |dialog, cx| { + dialog.make_default = checked; + cx.notify(); + }) + }) + }); + facts = facts.child(fact( + crate::tr!("pull_requests.merge.method").into_owned(), + v_flex().gap_2().child(method).children(default_box), + cx, + )); + + let mut body = v_flex().gap_3().child(facts); + let mut ready = false; + match &self.state { + None => { + // Until GitHub answers, the stored scope shows with no heads. + if let Some(offer) = &target.offer.stack { + let map = offer.map(); + let rows: Vec<_> = map.rows[..=map.selected] + .iter() + .filter(|row| row.layer.state != PullRequestState::Merged) + .enumerate() + .map(|(index, row)| { + h_flex() + .min_h(px(40.)) + .px_2() + .gap_2() + .items_center() + .child( + div() + .w(px(14.)) + .text_size(px(11.)) + .text_color(muted) + .child((index + 1).to_string()), + ) + .child( + div() + .font_family(theme.mono_font_family.clone()) + .text_size(px(12.)) + .child(format!("#{}", row.layer.number)), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(13.)) + .child( + row.link + .and_then(|link| link.snapshot.as_ref()) + .map(|snapshot| snapshot.title.clone()) + .unwrap_or_else(|| row.layer.head_branch.clone()), + ), + ) + .child(Spinner::new().xsmall()) + .into_any_element() + }) + .collect(); + body = body.child(scope_box(rows, cx)); + } + } + Some(Err(reason)) => { + body = body.child( + v_flex() + .gap_1() + .text_size(px(12.)) + .text_color(theme.danger) + .child( + crate::tr!( + "pull_requests.merge.load_failed", + number = key.number.to_string() + ) + .into_owned(), + ) + .when(!reason.is_empty(), |failure| failure.child(reason.clone())) + .child( + Button::new("pr-stack-merge-retry") + .outline() + .xsmall() + .label(crate::tr!("pull_requests.detail.retry")) + .on_click(cx.listener(|this, _, _, cx| this.load(cx))), + ), + ); + } + Some(Ok(state)) => { + let scope = Self::scope(state, key.number); + let numbers: Vec<_> = scope.iter().map(|layer| layer.number).collect(); + let selected_index = state + .layers + .iter() + .position(|layer| layer.number == key.number) + .unwrap_or_default(); + let merged_below: Vec<_> = state.layers[..selected_index.min(state.layers.len())] + .iter() + .filter(|layer| layer.state == PullRequestState::Merged) + .map(|layer| layer.number) + .collect(); + let rows: Vec<_> = scope + .iter() + .enumerate() + .map(|(index, layer)| { + let tag = if layer.state != PullRequestState::Open || layer.draft { + let color = if layer.draft { + theme.warning + } else { + theme.danger + }; + h_flex() + .gap_1() + .text_color(color) + .child(Icon::new(IconName::Lock).size(px(12.))) + .child(tr(if layer.draft { + "blocks_draft" + } else { + "blocks_closed" + })) + .into_any_element() + } else { + div() + .text_color(muted) + .child(tr("tag_ready")) + .into_any_element() + }; + layer_row(Some(index), layer, tag, false, false, cx) + }) + .collect(); + body = body.child( + v_flex() + .gap_1() + .child(div().text_size(px(12.)).text_color(muted).child(tr_with( + "scope_label", + &[("count", scope.len().to_string())], + ))) + .children(merged_below.iter().map(|number| { + div() + .text_size(px(11.)) + .text_color(muted) + .child(tr_with("already_merged", &[("number", number.to_string())])) + })) + .child(scope_box(rows, cx)), + ); + let above: Vec<_> = state.layers[(selected_index + 1).min(state.layers.len())..] + .iter() + .filter(|layer| layer.state == PullRequestState::Open) + .collect(); + if !above.is_empty() { + body = body.child( + v_flex() + .gap_1() + .child(div().text_size(px(12.)).text_color(muted).child(tr_with( + "above_label", + &[("count", above.len().to_string())], + ))) + .children(above.iter().map(|layer| { + layer_row( + None, + layer, + div() + .child(tr_with( + "above_tag", + &[("base", state.base.clone())], + )) + .into_any_element(), + false, + true, + cx, + ) + })), + ); + } + let mut notes = vec![line( + IconName::GitMerge, + theme.info, + tr_with( + "note_merge", + &[ + ("count", scope.len().to_string()), + ("base", state.base.clone()), + ], + ), + )]; + if state.merge_queue { + notes.push(line(IconName::ListOrdered, theme.info, tr("note_queue"))); + } + let blockers = self.blockers(state); + for blocker in &blockers { + notes.push(line(IconName::Lock, theme.danger, blocker.clone())); + } + let links = target.store.read(cx).pull_requests(&target.session); + let snapshot = |number: u64| { + links + .iter() + .find(|link| { + link.visible() + && link.key.host == key.host + && link.key.repository == key.repository + && link.key.number == number + }) + .and_then(|link| link.snapshot.clone()) + }; + let failing: Vec<_> = numbers + .iter() + .filter(|number| { + snapshot(**number).is_some_and(|snapshot| { + snapshot.checks_state == Some(core_pr::ChecksState::Failing) + }) + }) + .collect(); + for number in failing.iter().take(3) { + notes.push(line( + IconName::CircleX, + theme.danger, + tr_with("note_checks", &[("number", number.to_string())]), + )); + } + if failing.len() > 3 { + notes.push( + div() + .text_size(px(12.)) + .child(tr_with( + "summary_more", + &[("count", (failing.len() - 3).to_string())], + )) + .into_any_element(), + ); + } + for number in &numbers { + if snapshot(*number).is_some_and(|snapshot| { + snapshot.review_decision == Some(core_pr::ReviewDecision::ChangesRequested) + }) { + notes.push(line( + IconName::MessageSquareWarning, + theme.danger, + tr_with("note_changes", &[("number", number.to_string())]), + )); + } + } + if let Some(PullRequestStackOperation { + started_at, + kind: StackOperationKind::MergeUnconfirmed { .. }, + .. + }) = target + .offer + .stack + .as_ref() + .and_then(|offer| offer.operation.as_ref()) + { + notes.push(line( + IconName::CircleQuestionMark, + theme.warning, + tr_with( + "previous_unconfirmed", + &[("ago", super::detail::ago(*started_at))], + ), + )); + } + body = body.child(v_flex().gap_1().children(notes)); + let changed = self.notice.clone(); + if let Some(changed) = &changed { + body = body.child( + warning_wash(cx) + .child(line(IconName::TriangleAlert, theme.warning, tr("changed"))) + .child(div().child(changed.clone())) + .child( + Button::new("pr-stack-review-again") + .outline() + .xsmall() + .label(tr("review_again")) + .on_click(cx.listener(|this, _, _, cx| this.load(cx))), + ), + ); + } + ready = blockers.is_empty() && changed.is_none() && self.method.is_some(); + } + } + let count = state + .map(|state| Self::scope(state, key.number).len()) + .unwrap_or_default(); + let confirm_label = if self.sending { + tr("submitting") + } else if count <= 1 { + tr_with("merge_confirm_one", &[("number", key.number.to_string())]) + } else { + tr_with("merge_confirm", &[("count", count.to_string())]) + }; + body.child( + h_flex() + .gap_2() + .justify_end() + .child( + Button::new("pr-stack-merge-cancel") + .outline() + .small() + .disabled(self.sending) + .label(crate::tr!("pull_requests.actions.cancel")) + .on_click(|_, window, cx| window.close_dialog(cx)), + ) + .child( + Button::new("pr-stack-merge-confirm") + .primary() + .small() + .loading(self.sending) + .disabled(!ready || self.sending) + .label(confirm_label) + .on_click(cx.listener(|this, _, window, cx| this.confirm(window, cx))), + ), + ) + } +} + +pub(super) fn open_merge_dialog(target: Target, window: &mut Window, cx: &mut App) { + let workspace = target.store.read(cx); + let project = workspace + .thread_meta(&target.session) + .and_then(|meta| meta.project_id.clone()) + .and_then(|id| { + workspace + .projects() + .into_iter() + .find(|project| project.id == id) + .map(|project| (project.id, project.name)) + }); + let project_default = project + .as_ref() + .and_then(|(id, _)| workspace.settings().project_merge_methods.get(id).copied()); + let scope = target + .offer + .stack + .as_ref() + .map(|offer| offer.map().scope().len()) + .unwrap_or(1); + let number = target.offer.key.number.to_string(); + let title = if scope <= 1 { + tr_with("merge_title_one", &[("number", number)]) + } else { + tr_with("merge_title", &[("number", number)]) + }; + let store = target.store.clone(); + let width_target = target.clone(); + let dialog = cx.new(|cx| { + let mut dialog = MergeStackDialog { + target, + state: None, + method: None, + project, + project_default, + make_default: false, + sending: false, + notice: None, + _observe: cx.observe(&store, |_, _, cx| cx.notify()), + }; + dialog.load(cx); + dialog + }); + window.open_dialog(cx, move |base, window, cx| { + let sending = dialog.read(cx).sending; + base.title(title.clone()) + .w(dialog_width(&width_target, window, cx)) + .keyboard(!sending) + .close_button(!sending) + .overlay_closable(!sending) + .footer(crate::overlay::DialogActions::new()) + .content({ + let dialog = dialog.clone(); + move |content, _, _| content.child(dialog.clone()) + }) + }); +} + +/// The Rebase stack confirmation, which turns into the rebase's progress once the host started +/// it, and its end once the host reported it. +struct RebaseStackDialog { + target: Target, + state: Option>, + sending: bool, + started: bool, + notice: Option, + /// The host's record of the rebase as last read. + last: Option, + _observe: gpui::Subscription, +} + +impl RebaseStackDialog { + fn load(&mut self, cx: &mut Context) { + self.state = None; + self.notice = None; + let task = self.target.store.update(cx, |store, cx| { + store.read_pull_request( + self.target.session.clone(), + self.target.offer.key.clone(), + PullRequestRead::StackState { rebase: true }, + cx, + ) + }); + cx.spawn(async move |this, cx| { + let result = task.await; + let _ = this.update(cx, |this, cx| { + this.state = Some(match result { + Ok((PullRequestReadResponse::StackState(state), _)) => Ok(state), + Ok(_) => Err(String::new()), + Err(error) => Err(super::detail::reason(&error)), + }); + cx.notify(); + }); + }) + .detach(); + } + + fn unmerged( + state: &PullRequestStackActionState, + ) -> Vec<&tcode_protocol::PullRequestStackLayerState> { + state + .layers + .iter() + .filter(|layer| layer.state != PullRequestState::Merged) + .collect() + } + + fn blockers(&self, state: &PullRequestStackActionState, machine: &str) -> Vec { + let layers = Self::unmerged(state); + let mut blockers = Vec::new(); + if layers.is_empty() { + blockers.push(tr("rebase_nothing")); + } + let denied: Vec<_> = layers + .iter() + .filter(|layer| layer.push == Some(PullRequestStackPushAccess::Denied)) + .map(|layer| layer.number) + .collect(); + if !denied.is_empty() { + blockers.push(tr_with( + "rebase_blocked_access", + &[("layers", list(&denied))], + )); + } + for layer in &layers { + if layer.state == PullRequestState::Closed { + blockers.push(tr_with( + "rebase_blocked_closed", + &[("number", layer.number.to_string())], + )); + } else if layer.head.is_none() { + blockers.push(tr_with("no_head", &[("number", layer.number.to_string())])); + } + } + if state.git_identity == Some(false) { + blockers.push(tr_with( + "rebase_blocked_identity", + &[("machine", machine.to_owned())], + )); + } + blockers + } + + fn confirm(&mut self, window: &mut Window, cx: &mut Context) { + let Some(Ok(state)) = &self.state else { return }; + let machine = machine_label(Some(self.target.store.read(cx))); + if self.sending || !self.blockers(state, &machine).is_empty() || self.notice.is_some() { + return; + } + let heads: Vec<_> = Self::unmerged(state) + .iter() + .filter_map(|layer| { + Some(PullRequestStackHead { + number: layer.number, + head: layer.head.clone()?, + }) + }) + .collect(); + let layers: Vec<_> = heads.iter().map(|head| head.number).collect(); + let stack = state.stack; + let base = state.base.clone(); + self.sending = true; + cx.notify(); + let task = send( + &self.target, + tcode_protocol::PullRequestAction::RebaseStack { stack, heads }, + cx, + ); + let target = self.target.clone(); + cx.spawn_in(window, async move |this, cx| { + let result = task.await; + let _ = this.update_in(cx, |this, window, cx| { + this.sending = false; + if result == PullRequestActionResult::RebaseStarted { + this.started = true; + } else { + let answer = Answer { + stack: ( + target.offer.key.host.clone(), + target.offer.key.repository.clone(), + stack, + ), + target: target.offer.key.number, + layers: &layers, + base: &base, + branches: branches( + target.store.read(cx).pull_requests(&target.session), + &target.offer.key, + ), + url: Some(target.offer.url.clone()).filter(|url| !url.is_empty()), + rebase: true, + late: false, + }; + if let Some(note) = answer_toast(&answer, &result) { + window.push_notification(note, cx); + } + if stays(&result) { + this.notice = notice_text(&result); + } else { + window.close_dialog(cx); + } + } + (target.done)(&result, window, cx); + cx.notify(); + }); + }) + .detach(); + } + + fn confirm_view(&self, cx: &mut Context) -> AnyElement { + let target = &self.target; + let theme = cx.theme().clone(); + let muted = theme.muted_foreground; + let machine = machine_label(Some(target.store.read(cx))); + let base = target + .offer + .stack + .as_ref() + .map(StackOffer::base) + .unwrap_or_default(); + let mut body = v_flex().gap_3().child( + v_flex() + .gap_1p5() + .child(fact( + crate::tr!("pull_requests.merge.repository").into_owned(), + div() + .font_family(theme.mono_font_family.clone()) + .child(target.offer.key.repository.clone()), + cx, + )) + .child(fact( + tr("fact_onto"), + div().font_family(theme.mono_font_family.clone()).child( + self.state + .as_ref() + .and_then(|state| state.as_ref().ok()) + .map(|state| state.base.clone()) + .unwrap_or(base), + ), + cx, + )) + .child(fact( + tr("fact_runs_on"), + tr_with("runs_on_value", &[("machine", machine.clone())]), + cx, + )), + ); + let mut ready = false; + let mut count = 0; + match &self.state { + None => body = body.child(h_flex().justify_center().child(Spinner::new().small())), + Some(Err(reason)) => { + body = body.child( + v_flex() + .gap_1() + .text_size(px(12.)) + .text_color(theme.danger) + .child( + crate::tr!( + "pull_requests.merge.load_failed", + number = target.offer.key.number.to_string() + ) + .into_owned(), + ) + .when(!reason.is_empty(), |failure| failure.child(reason.clone())) + .child( + Button::new("pr-stack-rebase-retry") + .outline() + .xsmall() + .label(crate::tr!("pull_requests.detail.retry")) + .on_click(cx.listener(|this, _, _, cx| this.load(cx))), + ), + ) + } + Some(Ok(state)) => { + let layers = Self::unmerged(state); + count = layers.len(); + let rows: Vec<_> = layers + .iter() + .enumerate() + .map(|(index, layer)| { + let tag = match layer.push { + Some(PullRequestStackPushAccess::Write) => h_flex() + .gap_1() + .text_color(muted) + .child(Icon::new(IconName::Check).size(px(12.))) + .child(tr("access_write")), + Some(PullRequestStackPushAccess::MaintainerCanModify) => h_flex() + .gap_1() + .text_color(muted) + .child(Icon::new(IconName::Check).size(px(12.))) + .child(tr("access_maintainer")), + _ => h_flex() + .gap_1() + .text_color(theme.danger) + .child(Icon::new(IconName::Lock).size(px(12.))) + .child(tr("access_denied")), + }; + layer_row(Some(index), layer, tag.into_any_element(), true, false, cx) + }) + .collect(); + let merged: Vec<_> = state + .layers + .iter() + .filter(|layer| layer.state == PullRequestState::Merged) + .map(|layer| layer.number) + .collect(); + body = body.child( + v_flex() + .gap_1() + .child(div().text_size(px(12.)).text_color(muted).child(tr_with( + "rebase_layers_label", + &[("count", count.to_string())], + ))) + .children(merged.iter().map(|number| { + div() + .text_size(px(11.)) + .text_color(muted) + .child(tr_with("merged_skipped", &[("number", number.to_string())])) + })) + .child(scope_box(rows, cx)), + ); + body = body + .child( + v_flex() + .gap_1() + .child(line(IconName::RefreshCw, muted, tr("rebase_step_own"))) + .child(line(IconName::Upload, muted, tr("rebase_step_lease"))) + .child(line( + IconName::CircleDashed, + muted, + tr("rebase_step_checks"), + )) + .child(line(IconName::GitBranch, muted, tr("rebase_step_local"))), + ) + .child(div().text_size(px(11.)).text_color(muted).child(tr_with( + "rebase_no_promise", + &[("machine", machine.clone())], + ))); + let blockers = self.blockers(state, &machine); + if !blockers.is_empty() { + body = + body.child( + v_flex().gap_1().children(blockers.iter().map(|blocker| { + line(IconName::Lock, theme.danger, blocker.clone()) + })), + ); + } + let changed = self.notice.clone(); + if let Some(changed) = &changed { + body = body.child( + warning_wash(cx) + .child(line(IconName::TriangleAlert, theme.warning, tr("changed"))) + .child(div().child(changed.clone())) + .child( + Button::new("pr-stack-rebase-review-again") + .outline() + .xsmall() + .label(tr("review_again")) + .on_click(cx.listener(|this, _, _, cx| this.load(cx))), + ), + ); + } + ready = blockers.is_empty() && changed.is_none(); + } + } + if self.state.is_none() { + count = target + .offer + .stack + .as_ref() + .map_or(0, |offer| offer.map().unmerged().len()); + } + let label = if self.sending { + tr("starting") + } else if count == 1 { + tr("rebase_confirm_one") + } else { + tr_with("rebase_confirm", &[("count", count.to_string())]) + }; + body.child( + h_flex() + .gap_2() + .justify_end() + .child( + Button::new("pr-stack-rebase-cancel") + .outline() + .small() + .disabled(self.sending) + .label(crate::tr!("pull_requests.actions.cancel")) + .on_click(|_, window, cx| window.close_dialog(cx)), + ) + .child( + Button::new("pr-stack-rebase-confirm") + .primary() + .small() + .icon(IconName::RefreshCw) + .loading(self.sending) + .disabled(!ready || self.sending) + .label(label) + .on_click(cx.listener(|this, _, window, cx| this.confirm(window, cx))), + ), + ) + .into_any_element() + } +} + +impl Render for RebaseStackDialog { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.started { + return self.confirm_view(cx); + } + progress_view(&self.target, &mut self.last, cx) + } +} + +/// The rows of a rebase as the host runs it, or as the host keeps its end until the next sync. +fn progress_view( + target: &Target, + last: &mut Option, + cx: &mut App, +) -> AnyElement { + let theme = cx.theme().clone(); + let muted = theme.muted_foreground; + let store = target.store.read(cx); + let machine = machine_label(Some(store)); + let offer = StackOffer::new( + &target.offer.key, + store.pull_requests(&target.session), + store + .thread_meta(&target.session) + .map_or(&[][..], |meta| meta.pull_request_operations.as_slice()), + ); + let base = offer.as_ref().map(StackOffer::base).unwrap_or_default(); + let mut started_at = None; + if let Some(operation) = offer.and_then(|offer| offer.operation) { + if let StackOperationKind::Rebasing { .. } = operation.kind { + started_at = Some(operation.started_at); + } + if let StackOperationKind::Rebasing { .. } | StackOperationKind::RebaseEnded { .. } = + operation.kind + { + *last = Some(operation.kind); + } + } + // Once the sync drops the record, the view keeps the rows it last read. + let (last, ended): (&[core_pr::StackRebaseLayer], bool) = match last.as_ref() { + Some(StackOperationKind::Rebasing { layers }) => (layers, false), + Some(StackOperationKind::RebaseEnded { layers }) => (layers, true), + _ => (&[], false), + }; + let rows: Vec<_> = last + .iter() + .enumerate() + .map(|(index, layer)| { + let below = index + .checked_sub(1) + .and_then(|below| last.get(below)) + .map(|below| below.number); + let (icon, text): (AnyElement, String) = match &layer.step { + StackRebaseStep::Waiting => ( + Icon::new(IconName::CircleDashed) + .size(px(14.)) + .text_color(muted) + .into_any_element(), + tr("step_waiting"), + ), + StackRebaseStep::Rebasing => ( + Spinner::new().xsmall().into_any_element(), + match below { + Some(below) => tr_with("step_rebasing", &[("number", below.to_string())]), + None => tr_with("step_rebasing_base", &[("base", base.clone())]), + }, + ), + StackRebaseStep::Pushing => ( + Spinner::new().xsmall().into_any_element(), + tr("step_pushing"), + ), + StackRebaseStep::Pushed { from, to } => ( + Icon::new(IconName::CircleCheck) + .size(px(14.)) + .text_color(theme.success) + .into_any_element(), + tr_with("step_pushed", &[("from", short(from)), ("to", short(to))]), + ), + StackRebaseStep::AlreadyCurrent => ( + Icon::new(IconName::Check) + .size(px(14.)) + .text_color(muted) + .into_any_element(), + tr("step_current"), + ), + StackRebaseStep::Failed { reason } => ( + Icon::new(match reason { + StackRebaseFailure::PushUnconfirmed => IconName::CircleQuestionMark, + _ => IconName::CircleX, + }) + .size(px(14.)) + .text_color(match reason { + StackRebaseFailure::PushUnconfirmed => theme.warning, + _ => theme.danger, + }) + .into_any_element(), + match reason { + StackRebaseFailure::Conflict => tr("step_conflict"), + StackRebaseFailure::LeaseRefused => tr("step_lease"), + StackRebaseFailure::PushUnconfirmed => tr("step_unknown"), + StackRebaseFailure::Git { step, .. } => { + tr_with("step_git", &[("step", git_step(*step))]) + } + }, + ), + StackRebaseStep::NotStarted => ( + Icon::new(IconName::Minus) + .size(px(14.)) + .text_color(muted) + .into_any_element(), + tr("step_not_started"), + ), + }; + h_flex() + .id(SharedString::from(format!( + "pr-stack-progress-{}", + layer.number + ))) + .h(px(36.)) + .px_2() + .gap_2() + .items_center() + .text_size(px(12.)) + .child( + div() + .w(px(14.)) + .text_size(px(11.)) + .text_color(muted) + .child((index + 1).to_string()), + ) + .child(icon) + .child( + div() + .font_family(theme.mono_font_family.clone()) + .child(format!("#{}", layer.number)), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .font_family(theme.mono_font_family.clone()) + .text_color(muted) + .child(layer.branch.clone()), + ) + .child(div().flex_none().text_size(px(11.)).child(text)) + .into_any_element() + }) + .collect(); + let mut body = v_flex().gap_2().child(scope_box(rows, cx)); + if let Some(started) = started_at { + body = body.child(div().text_size(px(11.)).text_color(muted).child(tr_with( + "progress_started", + &[("ago", super::detail::ago(started)), ("machine", machine)], + ))); + } + let stopped = last.iter().find_map(|layer| match &layer.step { + StackRebaseStep::Failed { reason } => Some((layer.number, reason)), + _ => None, + }); + if let Some((failed, reason)) = stopped.filter(|_| ended) { + let numbers = |wanted: fn(&StackRebaseStep) -> bool| -> Vec { + last.iter() + .filter(|layer| wanted(&layer.step)) + .map(|layer| layer.number) + .collect() + }; + let pushed = numbers(|step| matches!(step, StackRebaseStep::Pushed { .. })); + let mut rest = vec![failed]; + rest.extend(numbers(|step| matches!(step, StackRebaseStep::NotStarted))); + let state = if pushed.is_empty() { + tr_with("recovery_none", &[("rest", list(&rest))]) + } else { + tr_with( + "recovery_state", + &[("pushed", list(&pushed)), ("rest", list(&rest))], + ) + }; + let layer = last.iter().position(|layer| layer.number == failed); + let branch = layer + .map(|index| last[index].branch.clone()) + .unwrap_or_default(); + let below = layer + .and_then(|index| index.checked_sub(1)) + .map(|index| last[index].branch.clone()) + .unwrap_or_else(|| base.clone()); + let advice = match reason { + StackRebaseFailure::Conflict => tr_with( + "recovery_conflict", + &[ + ("number", failed.to_string()), + ("branch", branch.clone()), + ("below", below.clone()), + ], + ), + StackRebaseFailure::LeaseRefused | StackRebaseFailure::PushUnconfirmed => { + tr_with("recovery_lease", &[("branch", branch.clone())]) + } + StackRebaseFailure::Git { .. } => tr("recovery_git"), + }; + let can_ask = !target + .store + .read(cx) + .session_status() + .is_none_or(|status| status.conversation_read_only); + let ask = matches!(reason, StackRebaseFailure::Conflict).then(|| { + let store = target.store.clone(); + let window_state = target.window_state.clone(); + let session = target.session.clone(); + let text = tr_with( + "ask_rebase_conflict", + &[ + ("number", failed.to_string()), + ("branch", branch.clone()), + ("below", below.clone()), + ], + ); + Button::new("pr-stack-ask") + .outline() + .xsmall() + .disabled(!can_ask) + .label(crate::tr!(if can_ask { + "pull_requests.actions.ask_agent" + } else { + "pull_requests.actions.thread_unavailable" + })) + .on_click(move |_, window, cx| { + store.update(cx, |store, cx| { + store.append_to_composer(session.clone(), text.clone(), cx) + }); + window.close_dialog(cx); + if window_state.read(cx).destination() + == crate::window_state::Destination::PullRequest + { + window_state.update(cx, |state, cx| state.back(cx)); + } + }) + }); + let url = format!( + "https://{}/{}/pull/{failed}", + target.offer.key.host, target.offer.key.repository + ); + body = body.child( + warning_wash(cx) + .child(div().child(state)) + .child(div().child(advice)) + .child( + h_flex().gap_2().items_center().children(ask).child( + Button::new("pr-stack-open-failed") + .ghost() + .xsmall() + .icon(IconName::ExternalLink) + .label(tr_with("open_layer", &[("number", failed.to_string())])) + .on_click(move |_, _, cx| cx.open_url(&url)), + ), + ), + ); + } + body.child( + h_flex().justify_end().child( + Button::new("pr-stack-progress-close") + .outline() + .small() + .label(tr(if ended { "done" } else { "hide" })) + .on_click(|_, window, cx| window.close_dialog(cx)), + ), + ) + .into_any_element() +} + +pub(super) fn open_rebase_dialog(target: Target, window: &mut Window, cx: &mut App) { + let stack = target + .offer + .stack + .as_ref() + .map(|offer| offer.map().stack.number) + .unwrap_or_default(); + let store = target.store.clone(); + let width_target = target.clone(); + let dialog = cx.new(|cx| { + let mut dialog = RebaseStackDialog { + target, + state: None, + sending: false, + started: false, + notice: None, + last: None, + _observe: cx.observe(&store, |_, _, cx| cx.notify()), + }; + dialog.load(cx); + dialog + }); + window.open_dialog(cx, move |base, window, cx| { + let dialog_state = dialog.read(cx); + let sending = dialog_state.sending; + let title = if dialog_state.started { + tr_with("progress_title", &[("stack", stack.to_string())]) + } else { + tr_with("rebase_title", &[("stack", stack.to_string())]) + }; + base.title(title) + .w(dialog_width(&width_target, window, cx)) + .keyboard(!sending) + .close_button(!sending) + .overlay_closable(!sending) + .footer(crate::overlay::DialogActions::new()) + .content({ + let dialog = dialog.clone(); + move |content, _, _| content.child(dialog.clone()) + }) + }); +} + +/// The running rebase's progress, opened from its chip. +fn open_progress(target: Target, window: &mut Window, cx: &mut App) { + let stack = target + .offer + .stack + .as_ref() + .map(|offer| offer.map().stack.number) + .unwrap_or_default(); + let store = target.store.clone(); + let width_target = target.clone(); + let dialog = cx.new(|cx| RebaseStackDialog { + target, + state: None, + sending: false, + started: true, + notice: None, + last: None, + _observe: cx.observe(&store, |_, _, cx| cx.notify()), + }); + window.open_dialog(cx, move |base, window, cx| { + base.title(tr_with("progress_title", &[("stack", stack.to_string())])) + .w(dialog_width(&width_target, window, cx)) + .footer(crate::overlay::DialogActions::new()) + .content({ + let dialog = dialog.clone(); + move |content, _, _| content.child(dialog.clone()) + }) + }); +} diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 39fbe87b..0d08f254 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -1211,6 +1211,22 @@ impl AppShell { self.present_pull_request_watch(session_id, *number, notice, window, cx); return; } + RuntimeEvent::Toast(RuntimeToast::PullRequestStack { + session_id, + target, + stack, + layers, + base, + result, + late, + }) => { + if let Some(store) = self.store() { + crate::pull_requests::present_stack_result( + &store, session_id, target, *stack, layers, base, result, *late, window, cx, + ); + } + return; + } RuntimeEvent::Toast(toast) => toast, }; diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index b8225768..75cf6f54 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -2017,6 +2017,31 @@ impl WorkspaceStore { .collect(); } + /// Whether this device folded the stack map's note about GitHub stacks away. + pub fn stack_note_collapsed(&self) -> bool { + self.client_preferences + .navigation + .as_ref() + .and_then(|navigation| navigation["stack_note_collapsed"].as_bool()) + .unwrap_or(false) + } + + pub fn set_stack_note_collapsed(&mut self, collapsed: bool, cx: &mut Context) { + self.client_preferences + .navigation + .get_or_insert_with(|| serde_json::json!({}))["stack_note_collapsed"] = + serde_json::json!(collapsed); + if let Some(host) = &self.client_host { + let mut preferences = host.load_preferences(); + preferences + .navigation + .get_or_insert_with(|| serde_json::json!({}))["stack_note_collapsed"] = + serde_json::json!(collapsed); + host.save_preferences(&preferences); + } + cx.notify(); + } + pub fn set_open_pull_request( &mut self, session_id: &str, diff --git a/locales/en.yml b/locales/en.yml index 0f97b8b3..6225f328 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -1862,7 +1862,6 @@ pull_requests: queued: "#%{number} joined the merge queue" queued_body: "It merges when the queue reaches it." ready: "#%{number} is ready for review" - reason_stack: "it is layer %{index} of %{count} of a GitHub stack, which merges with the layers below it; Tcode does not do that yet" reason_stack_unknown: "Tcode is still checking whether it is in a stack" reopened: "Reopened #%{number}" reverted: "Opened #%{new} to revert #%{number}" @@ -1896,7 +1895,6 @@ pull_requests: fix_checks: "Fix failing checks" merge_method: "Merge method" merge_now: "Merge now…" - merge_on_github: "Merge on GitHub" primary_label: "%{label} #%{number}" queued: "Queued to merge" queued_position: "Queued to merge · position %{position}" @@ -1911,7 +1909,6 @@ pull_requests: revert_menu: "Revert…" revert_title: "Revert #%{number}?" stack_checking: "Checking whether #%{number} is in a stack…" - stack_reason: "#%{number} is layer %{index} of a GitHub stack. Merging it also lands the layers below it, which Tcode does not do yet. Merge on GitHub." thread_unavailable: "This thread can't take messages" update_branch: "Update branch" update_rebase: "Update with rebase" @@ -1953,6 +1950,180 @@ pull_requests: segment_squash: "Squash" title: "Merge #%{number}?" title_auto: "Enable auto-merge for #%{number}?" + stack: + waiting_state: "Waiting for GitHub's current state." + review_again: "Review again" + open: "Open" + trigger_label: "Stack #%{stack}, layer %{index} of %{count}" + trigger_merging: ", merging" + trigger_rebasing: ", rebasing" + trigger_unconfirmed: ", merge outcome unknown" + head: "Stack #%{stack} · %{count} layers · into %{base}" + about: "About GitHub stacks" + note: "GitHub keeps a stack's pull requests in order, each built on the one below. Merging a layer lands every unmerged layer below it." + note_short: "Merging a layer lands the layers below it." + docs_link: "GitHub docs: stacked pull requests" + role_in_scope: "Lands with #%{number}" + role_merged: "Merged" + blocks_draft: "Draft · blocks" + blocks_closed: "Closed · blocks" + row_label: "#%{number} %{title}, %{state}, %{role}" + role_label_selected: "selected" + role_label_above: "above" + role_label_merged: "merged" + role_label_in_scope: "lands with #%{number}" + role_label_blocks: "blocks merging #%{number}" + summary_scope: "Merging #%{number} lands %{count} pull requests into %{base}, bottom first." + summary_bottom: "#%{number} is the bottom layer. Merging it lands only #%{number}." + summary_blocked_draft: "#%{number} can't merge yet: #%{blocker} below it is a draft." + summary_blocked_closed: "#%{number} can't merge yet: #%{blocker} below it is closed." + summary_selected_draft: "#%{number} is a draft." + summary_merged: "#%{number} is merged." + summary_closed: "#%{number} is closed." + summary_more: "and %{count} more" + summary_source: "From the last sync. Merging checks GitHub first." + blocked_signal_draft: "Can't merge yet: #%{blocker} below it is a draft." + blocked_signal_closed: "Can't merge yet: #%{blocker} below it is closed." + merge: "Merge stack" + merge_label: "Merge stack #%{number}" + merge_count: "Merge stack (%{count})…" + merge_tooltip: "Merges #%{first}–#%{number} into %{base} (%{count} pull requests)" + merge_tooltip_one: "Merges #%{number} into %{base}" + merge_title: "Merge stack through #%{number}?" + merge_title_one: "Merge #%{number}?" + fact_stack: "Stack" + fact_stack_value: "#%{stack} · into %{base}" + scope_label: "Merges, bottom first (%{count})" + already_merged: "#%{number} is already merged and isn't included." + tag_ready: "Ready" + tag_changed: "Changed" + above_label: "Stays open (%{count})" + above_tag: "GitHub rebases it onto %{base}" + note_merge: "GitHub merges these %{count} pull requests together at the heads shown, checking the repository's rules first. Then it rebases the layers above onto %{base}." + note_queue: "This repository uses a merge queue. The stack joins the queue and isn't merged until the queue reaches it." + note_blocked: "Every layer being merged must be open and ready for review. #%{number} is %{state}." + note_checks: "Checks are failing on #%{number}." + note_changes: "Changes were requested on #%{number}." + no_head: "GitHub didn't report #%{number}'s head. Refresh and try again." + changed: "The stack changed while this was open." + changed_head: "#%{number}'s head is now %{new}." + changed_layers: "Layers were added, removed or reordered." + changed_state: "#%{number} is no longer open." + merge_confirm: "Merge %{count} pull requests" + merge_confirm_one: "Merge #%{number}" + submitting: "Submitting…" + no_methods: "This repository has no merge method enabled." + no_merge_right: "Your account can't merge in this repository." + previous_unconfirmed: "A merge submitted %{ago} never reported back. If GitHub is still working on it, Tcode follows it instead of submitting another." + rebase_menu: "Rebase stack…" + rebase_tooltip: "Rebases every open layer of stack #%{stack} onto %{base}, bottom first." + rebase_title: "Rebase stack #%{stack}?" + fact_onto: "Onto" + fact_runs_on: "Runs on" + runs_on_value: "%{machine}, in a temporary copy. This project's checkout isn't touched." + this_machine: "this computer" + rebase_layers_label: "Rebased and force-pushed, bottom first (%{count})" + access_write: "Can push" + access_maintainer: "Maintainer edits allowed" + access_denied: "No push access" + merged_skipped: "#%{number} is merged and is skipped." + rebase_step_own: "Each layer's own commits move onto the new head of the layer below, bottom first." + rebase_step_lease: "Each changed branch is force-pushed, only if it is still at the head shown. A branch someone pushed to since is left alone and the rebase stops there." + rebase_step_checks: "Checks restart on every pushed layer, and the repository's rules may dismiss approvals." + rebase_step_local: "Pull or reset local copies of these branches before pushing to them again from this thread." + rebase_no_promise: "The commits are rewritten by Git on %{machine}, not by GitHub. Tcode doesn't promise the result matches GitHub's own rebase." + rebase_blocked_access: "You can't push to every branch in this stack: %{layers}. Nothing will change." + rebase_blocked_closed: "#%{number} is closed. Reopen or remove it from the stack first." + rebase_blocked_identity: "Set a Git name and email on %{machine} to rebase." + rebase_nothing: "No open layers to rebase." + rebase_confirm: "Rebase %{count} branches" + rebase_confirm_one: "Rebase 1 branch" + starting: "Starting…" + progress_title: "Rebasing stack #%{stack}" + step_waiting: "Waiting" + step_rebasing: "Rebasing onto #%{number}" + step_rebasing_base: "Rebasing onto %{base}" + step_pushing: "Pushing" + step_pushed: "Pushed %{from} → %{to}" + step_current: "Already up to date" + step_conflict: "Conflicts with the layer below" + step_lease: "Branch moved; not pushed" + step_git: "Stopped while %{step}" + step_not_started: "Not changed" + step_unknown: "Unknown" + git_preparing: "preparing" + git_fetching: "fetching" + git_fork_point: "reading the fork point" + git_checking_out: "checking out" + git_rebasing: "rebasing" + git_pushing: "pushing" + progress_started: "Started %{ago} on %{machine}. Each Git step may take up to 2 minutes." + hide: "Hide" + done: "Done" + recovery_state: "%{pushed} are rebased on GitHub. %{rest} are as they were." + recovery_none: "%{rest} are as they were." + recovery_conflict: "Resolve #%{number} on its branch: rebase %{branch} onto the new %{below}, fix the conflicts and push. Then run Rebase stack again for the layers above." + recovery_lease: "Review the new commits on %{branch}, then run Rebase stack again." + recovery_git: "Fix the cause above, then run Rebase stack again. Layers already rebased are skipped as up to date." + open_layer: "Open #%{number} on GitHub" + ask_rebase_conflict: "#%{number} (%{branch}) conflicts with %{below} after a stack rebase. Rebase %{branch} onto origin/%{below}, resolve the conflicts and push with --force-with-lease." + chip_merging: "Merging stack…" + chip_unconfirmed: "Merge outcome unknown" + chip_rebasing: "Rebasing stack · %{done} of %{count}" + chip_merging_tooltip: "GitHub is merging %{list}, started %{ago}. Tcode checks it every few seconds for up to 5 minutes." + chip_adopted_tooltip: "Following a merge that was already running on GitHub." + chip_unconfirmed_tooltip: "GitHub hadn't finished the merge after 5 minutes. Tcode stopped checking and didn't submit it again. The next sync shows what happened." + popover_title: "Stack merge" + operation_id: "Operation %{id}" + copy_id: "Copy operation id" + disabled_merging: "GitHub is merging this stack." + disabled_rebasing: "Tcode is rebasing this stack." + disabled_queued: "The stack is in the merge queue." + disabled_not_linked: "Link #%{number} to this thread to merge or rebase its stack from here." + list_two: "#%{a} and #%{b}" + list_three: "#%{a}, #%{b} and #%{c}" + list_range: "#%{first}–#%{last}" + list_count: "%{count} pull requests" + result_merged: "Merged %{count} pull requests through #%{number}" + result_merged_one: "Merged #%{number}" + result_merged_body: "%{list} into %{base}. GitHub rebases the layers above." + result_queued: "Stack through #%{number} joined the merge queue" + result_queued_body: "It isn't merged until the queue reaches it." + result_submitted: "Stack merge submitted" + result_submitted_body: "GitHub is merging %{list}. Tcode checks until it finishes." + result_adopted: "A stack merge was already running" + result_adopted_body: "GitHub is still working on a merge of this stack. Tcode is following it instead of submitting another." + result_layer_changed: "Not merged: #%{number} changed" + result_layer_changed_body: "Its head moved from %{expected} to %{actual}. Nothing was sent. Review the stack again." + result_stack_changed: "Not merged: the stack changed" + result_stack_changed_body: "Layers were added, removed or reordered on GitHub. Nothing was sent." + result_not_open: "Not merged: #%{number} is %{state}" + result_not_open_body: "Every layer being merged must be open and ready for review. Nothing was sent." + result_running: "A stack operation is already running" + result_refused: "GitHub refused the stack merge" + result_refused_body: "Check the stack's branch rules and merge requirements." + result_uncertain: "Tcode couldn't confirm whether #%{number}'s stack merged" + result_deadline: "GitHub hasn't finished merging the stack" + result_deadline_body: "Tcode stopped checking after 5 minutes and didn't submit it again. The next sync shows whether it merged." + result_finished_late: "Stack merge finished" + result_finished_late_body: "%{list} merged into %{base} after Tcode stopped checking." + result_409_no_id: "Not merged: GitHub reports a merge already running" + result_409_no_id_body: "It didn't say which. Check GitHub before trying again." + result_rebased: "Rebased stack #%{stack}" + result_rebased_body: "%{pushed} branches pushed, %{current} already up to date. Pull before pushing from this thread." + result_rebase_stopped: "Stack rebase stopped at #%{number}" + result_rebase_conflict_body: "%{pushed} #%{number} conflicts with the layer below and wasn't changed. %{untouched}" + result_rebase_lease_body: "Someone pushed to %{branch} after you opened this, so it wasn't overwritten. %{pushed}" + result_rebase_git_body: "Git failed while %{step}: %{message} %{pushed}" + result_pushed_stay: "%{list} were rebased and pushed and stay that way." + result_untouched: "%{list} weren't touched." + result_not_rebased: "Not rebased: %{reason}" + nothing_changed: "Nothing was changed." + result_no_push: "you can't push to %{list}." + result_no_identity: "the host's Git has no name or email to commit with." + result_push_uncertain: "Tcode couldn't confirm whether #%{number} was pushed" + result_push_uncertain_body: "It was not retried. %{pushed} Check %{branch} on GitHub before rebasing again." + result_not_linked: "only a pull request this thread links acts on its stack." agents: title: "Agents" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index cec9c0b5..003bdf92 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -1855,7 +1855,6 @@ pull_requests: queued: "#%{number} 已加入合并队列" queued_body: "轮到时会自动合并。" ready: "#%{number} 已准备好审查" - reason_stack: "它是 GitHub 堆叠 %{count} 层中的第 %{index} 层,会与下面的各层一起合并;Tcode 暂不支持" reason_stack_unknown: "Tcode 仍在检查它是否属于堆叠" reopened: "已重新打开 #%{number}" reverted: "已创建 #%{new} 以还原 #%{number}" @@ -1889,7 +1888,6 @@ pull_requests: fix_checks: "修复未通过的检查" merge_method: "合并方式" merge_now: "立即合并…" - merge_on_github: "在 GitHub 上合并" primary_label: "%{label} #%{number}" queued: "已排队等待合并" queued_position: "已排队等待合并 · 第 %{position} 位" @@ -1904,7 +1902,6 @@ pull_requests: revert_menu: "还原…" revert_title: "还原 #%{number}?" stack_checking: "正在检查 #%{number} 是否属于堆叠…" - stack_reason: "#%{number} 是 GitHub 堆叠的第 %{index} 层。合并它会同时合入它下面的各层,Tcode 暂不支持。请在 GitHub 上合并。" thread_unavailable: "此对话无法接收消息" update_branch: "更新分支" update_rebase: "以变基方式更新" @@ -1946,6 +1943,180 @@ pull_requests: segment_squash: "压缩" title: "合并 #%{number}?" title_auto: "为 #%{number} 启用自动合并?" + stack: + waiting_state: "正在等待 GitHub 的当前状态。" + review_again: "重新检查" + open: "打开" + trigger_label: "堆叠 #%{stack},第 %{index} 层,共 %{count} 层" + trigger_merging: ", 正在合并" + trigger_rebasing: ", 正在变基" + trigger_unconfirmed: ", 合并结果未知" + head: "堆叠 #%{stack} · %{count} 层 · 合入 %{base}" + about: "关于 GitHub 堆叠" + note: "GitHub 会按顺序维护堆叠中的拉取请求,每一层都基于下面一层。合并某一层会同时合入它下面所有未合并的层。" + note_short: "合并某一层会同时合入它下面的各层。" + docs_link: "GitHub 文档:堆叠拉取请求" + role_in_scope: "随 #%{number} 一起合入" + role_merged: "已合并" + blocks_draft: "草稿 · 阻止合并" + blocks_closed: "已关闭 · 阻止合并" + row_label: "#%{number} %{title},%{state},%{role}" + role_label_selected: "当前所选" + role_label_above: "位于上方" + role_label_merged: "已合并" + role_label_in_scope: "随 #%{number} 一起合入" + role_label_blocks: "阻止合并 #%{number}" + summary_scope: "合并 #%{number} 会自下而上将 %{count} 个拉取请求合入 %{base}。" + summary_bottom: "#%{number} 是最底层,合并它只会合入 #%{number}。" + summary_blocked_draft: "#%{number} 暂时无法合并:它下面的 #%{blocker} 是草稿。" + summary_blocked_closed: "#%{number} 暂时无法合并:它下面的 #%{blocker} 已关闭。" + summary_selected_draft: "#%{number} 是草稿。" + summary_merged: "#%{number} 已合并。" + summary_closed: "#%{number} 已关闭。" + summary_more: "等另外 %{count} 个" + summary_source: "基于上次同步。合并前会先向 GitHub 核实。" + blocked_signal_draft: "暂时无法合并:下面的 #%{blocker} 是草稿。" + blocked_signal_closed: "暂时无法合并:下面的 #%{blocker} 已关闭。" + merge: "合并堆叠" + merge_label: "合并堆叠 #%{number}" + merge_count: "合并堆叠(%{count})…" + merge_tooltip: "将 #%{first}–#%{number} 合入 %{base}(%{count} 个拉取请求)" + merge_tooltip_one: "将 #%{number} 合入 %{base}" + merge_title: "合并堆叠至 #%{number}?" + merge_title_one: "合并 #%{number}?" + fact_stack: "堆叠" + fact_stack_value: "#%{stack} · 合入 %{base}" + scope_label: "自下而上合并(%{count})" + already_merged: "#%{number} 已合并,不包含在内。" + tag_ready: "就绪" + tag_changed: "已变更" + above_label: "保持开启(%{count})" + above_tag: "GitHub 会将其变基到 %{base}" + note_merge: "GitHub 会按所示的头部提交一起合并这 %{count} 个拉取请求,并先检查仓库规则。随后它会把上方的各层变基到 %{base}。" + note_queue: "此仓库使用合并队列。堆叠会加入队列,轮到之前不会合并。" + note_blocked: "要合并的每一层都必须处于开启状态且已准备好审查。#%{number} 当前为%{state}。" + note_checks: "#%{number} 的检查未通过。" + note_changes: "#%{number} 有人要求修改。" + no_head: "GitHub 没有返回 #%{number} 的头部提交。请刷新后重试。" + changed: "此窗口打开期间堆叠已发生变化。" + changed_head: "#%{number} 的头部提交现在是 %{new}。" + changed_layers: "有层被添加、移除或重新排序。" + changed_state: "#%{number} 已不再处于开启状态。" + merge_confirm: "合并 %{count} 个拉取请求" + merge_confirm_one: "合并 #%{number}" + submitting: "正在提交…" + no_methods: "此仓库未启用任何合并方式。" + no_merge_right: "你的账号无法在此仓库中合并。" + previous_unconfirmed: "%{ago}提交的合并一直没有返回结果。如果 GitHub 仍在处理,Tcode 会跟进该操作,而不会再提交一次。" + rebase_menu: "变基堆叠…" + rebase_tooltip: "自下而上将堆叠 #%{stack} 的每个开启层变基到 %{base}。" + rebase_title: "变基堆叠 #%{stack}?" + fact_onto: "变基到" + fact_runs_on: "运行于" + runs_on_value: "%{machine},在临时副本中进行。不会改动此项目的检出目录。" + this_machine: "这台电脑" + rebase_layers_label: "自下而上变基并强制推送(%{count})" + access_write: "可推送" + access_maintainer: "允许维护者编辑" + access_denied: "无推送权限" + merged_skipped: "#%{number} 已合并,将跳过。" + rebase_step_own: "自下而上,每一层只把自己的提交移到下面一层的新头部提交之上。" + rebase_step_lease: "每个有变化的分支只在仍处于所示头部提交时才会被强制推送。若之后有人推送过某分支,该分支保持不变,变基在此停止。" + rebase_step_checks: "每个被推送的层都会重新运行检查,仓库规则可能会撤销已有的批准。" + rebase_step_local: "在此对话再次推送这些分支前,请先拉取或重置它们的本地副本。" + rebase_no_promise: "提交由 %{machine} 上的 Git 重写,而不是由 GitHub 重写。Tcode 不保证结果与 GitHub 自身的变基一致。" + rebase_blocked_access: "你无法推送此堆叠中的所有分支:%{layers}。不会做任何更改。" + rebase_blocked_closed: "#%{number} 已关闭。请先重新打开它,或将其移出堆叠。" + rebase_blocked_identity: "请先在 %{machine} 上设置 Git 用户名和邮箱再变基。" + rebase_nothing: "没有可变基的开启层。" + rebase_confirm: "变基 %{count} 个分支" + rebase_confirm_one: "变基 1 个分支" + starting: "正在开始…" + progress_title: "正在变基堆叠 #%{stack}" + step_waiting: "等待中" + step_rebasing: "正在变基到 #%{number}" + step_rebasing_base: "正在变基到 %{base}" + step_pushing: "正在推送" + step_pushed: "已推送 %{from} → %{to}" + step_current: "已是最新" + step_conflict: "与下面一层冲突" + step_lease: "分支已变动,未推送" + step_git: "在%{step}时停止" + step_not_started: "未更改" + step_unknown: "未知" + git_preparing: "准备" + git_fetching: "获取" + git_fork_point: "读取分叉点" + git_checking_out: "检出" + git_rebasing: "变基" + git_pushing: "推送" + progress_started: "%{ago}在 %{machine} 上开始。每个 Git 步骤最长可能需要 2 分钟。" + hide: "隐藏" + done: "完成" + recovery_state: "%{pushed} 已在 GitHub 上完成变基。%{rest} 保持原样。" + recovery_none: "%{rest} 保持原样。" + recovery_conflict: "在 #%{number} 的分支上解决:将 %{branch} 变基到新的 %{below},修复冲突后推送。然后对上方各层再次运行“变基堆叠”。" + recovery_lease: "先查看 %{branch} 上的新提交,然后再次运行“变基堆叠”。" + recovery_git: "修复上述问题后再次运行“变基堆叠”。已完成变基的层会作为最新状态被跳过。" + open_layer: "在 GitHub 上打开 #%{number}" + ask_rebase_conflict: "堆叠变基后,#%{number}(%{branch})与 %{below} 存在冲突。请将 %{branch} 变基到 origin/%{below},解决冲突后使用 --force-with-lease 推送。" + chip_merging: "正在合并堆叠…" + chip_unconfirmed: "合并结果未知" + chip_rebasing: "正在变基堆叠 · %{done}/%{count}" + chip_merging_tooltip: "GitHub 正在合并 %{list},开始于%{ago}。Tcode 会每隔几秒检查一次,最长 5 分钟。" + chip_adopted_tooltip: "正在跟进 GitHub 上已在运行的合并。" + chip_unconfirmed_tooltip: "5 分钟后 GitHub 仍未完成合并。Tcode 已停止检查,且没有再次提交。下次同步会显示结果。" + popover_title: "堆叠合并" + operation_id: "操作 %{id}" + copy_id: "复制操作 ID" + disabled_merging: "GitHub 正在合并此堆叠。" + disabled_rebasing: "Tcode 正在变基此堆叠。" + disabled_queued: "此堆叠已在合并队列中。" + disabled_not_linked: "将 #%{number} 关联到此对话后,才能在这里合并或变基它的堆叠。" + list_two: "#%{a} 和 #%{b}" + list_three: "#%{a}、#%{b} 和 #%{c}" + list_range: "#%{first}–#%{last}" + list_count: "%{count} 个拉取请求" + result_merged: "已合并截至 #%{number} 的 %{count} 个拉取请求" + result_merged_one: "已合并 #%{number}" + result_merged_body: "%{list} 已合入 %{base}。GitHub 会变基上方的各层。" + result_queued: "截至 #%{number} 的堆叠已加入合并队列" + result_queued_body: "轮到之前不会合并。" + result_submitted: "已提交堆叠合并" + result_submitted_body: "GitHub 正在合并 %{list}。Tcode 会持续检查直到完成。" + result_adopted: "已有堆叠合并在运行" + result_adopted_body: "GitHub 仍在处理此堆叠的合并。Tcode 会跟进它,而不会再提交一次。" + result_layer_changed: "未合并:#%{number} 已发生变化" + result_layer_changed_body: "其头部提交已从 %{expected} 变为 %{actual}。未发送任何内容。请重新检查堆叠。" + result_stack_changed: "未合并:堆叠已发生变化" + result_stack_changed_body: "GitHub 上有层被添加、移除或重新排序。未发送任何内容。" + result_not_open: "未合并:#%{number} 当前为%{state}" + result_not_open_body: "要合并的每一层都必须处于开启状态且已准备好审查。未发送任何内容。" + result_running: "已有堆叠操作在运行" + result_refused: "GitHub 拒绝了堆叠合并" + result_refused_body: "请检查堆叠的分支规则和合并要求。" + result_uncertain: "Tcode 无法确认 #%{number} 所在堆叠是否已合并" + result_deadline: "GitHub 尚未完成堆叠合并" + result_deadline_body: "5 分钟后 Tcode 已停止检查,且没有再次提交。下次同步会显示是否已合并。" + result_finished_late: "堆叠合并已完成" + result_finished_late_body: "Tcode 停止检查后,%{list} 已合入 %{base}。" + result_409_no_id: "未合并:GitHub 报告已有合并在运行" + result_409_no_id_body: "GitHub 未说明是哪一个。再次尝试前请先在 GitHub 上确认。" + result_rebased: "已变基堆叠 #%{stack}" + result_rebased_body: "已推送 %{pushed} 个分支,%{current} 个已是最新。在此对话推送前请先拉取。" + result_rebase_stopped: "堆叠变基在 #%{number} 处停止" + result_rebase_conflict_body: "%{pushed}#%{number} 与下面一层冲突,未被更改。%{untouched}" + result_rebase_lease_body: "你打开此窗口后有人推送了 %{branch},因此没有覆盖它。%{pushed}" + result_rebase_git_body: "Git 在%{step}时失败:%{message} %{pushed}" + result_pushed_stay: "%{list} 已变基并推送,并保持该状态。" + result_untouched: "%{list} 未被改动。" + result_not_rebased: "未变基:%{reason}" + nothing_changed: "未做任何更改。" + result_no_push: "你无法推送 %{list}。" + result_no_identity: "主机上的 Git 没有可用于提交的用户名或邮箱。" + result_push_uncertain: "Tcode 无法确认 #%{number} 是否已推送" + result_push_uncertain_body: "未自动重试。%{pushed}再次变基前请先在 GitHub 上检查 %{branch}。" + result_not_linked: "只有此对话关联的拉取请求才能操作其堆叠。" agents: title: "子代理"