Skip to content

Commit 035825c

Browse files
authored
Merge pull request #13697 from gitbutlerapp/fix-pr-targets-after-push
Fix PR/MR base branches going stale after pushing stacks
2 parents e98fd3c + 646a9f0 commit 035825c

7 files changed

Lines changed: 266 additions & 69 deletions

File tree

crates/but-api/src/legacy/forge.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -365,12 +365,12 @@ pub async fn set_review_draftiness(
365365
.await
366366
}
367367

368-
/// Update the stacked review descriptions to have the correct footers.
368+
/// Update stacked reviews: description footers and, optionally, target branches.
369369
#[but_api(napi)]
370370
#[instrument(err(Debug))]
371371
pub async fn update_review_footers(
372372
ctx: ThreadSafeContext,
373-
reviews: Vec<but_forge::ForgeReviewDescriptionUpdate>,
373+
reviews: Vec<but_forge::ForgeReviewUpdate>,
374374
) -> Result<()> {
375375
let (storage, forge_repo_info, preferred_forge_user) = {
376376
let ctx = ctx.into_thread_local();
@@ -384,7 +384,7 @@ pub async fn update_review_footers(
384384
)
385385
};
386386

387-
but_forge::update_review_description_tables(
387+
but_forge::sync_reviews(
388388
&preferred_forge_user,
389389
&forge_repo_info.context("No forge could be determined for this repository branch")?,
390390
&reviews,

crates/but-forge/src/lib.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ mod db;
88
mod review;
99
pub use ci::{CiCheck, CiConclusion, CiOutput, CiStatus, ci_checks_for_ref_with_cache};
1010
pub use review::{
11-
CacheConfig, CreateForgeReviewParams, ForgeAccountValidity, ForgeReview,
12-
ForgeReviewDescriptionUpdate, ForgeReviewFilter, ReviewTemplateFunctions,
13-
available_review_templates, check_forge_account_is_valid, create_forge_review,
14-
get_forge_review, get_review_template_functions, list_forge_reviews_for_branch,
15-
list_forge_reviews_with_cache, merge_review, set_review_auto_merge_state,
16-
set_review_draftiness, update_review_description_tables,
11+
CacheConfig, CreateForgeReviewParams, ForgeAccountValidity, ForgeReview, ForgeReviewFilter,
12+
ForgeReviewTargetUpdate, ForgeReviewUpdate, ReviewTemplateFunctions,
13+
available_review_templates, check_forge_account_is_valid, compute_review_target_updates,
14+
create_forge_review, get_forge_review, get_review_template_functions,
15+
list_forge_reviews_for_branch, list_forge_reviews_with_cache, merge_review,
16+
set_review_auto_merge_state, set_review_draftiness, sync_reviews,
1717
};
1818

1919
fn determine_forge_from_host(host: &str) -> Option<ForgeName> {

crates/but-forge/src/review.rs

Lines changed: 175 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,98 +1087,167 @@ pub async fn create_forge_review(
10871087
#[derive(Debug, Clone, Serialize, Deserialize)]
10881088
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
10891089
#[serde(rename_all = "camelCase")]
1090-
pub struct ForgeReviewDescriptionUpdate {
1090+
pub struct ForgeReviewUpdate {
10911091
/// The unique identifier number for this review within its repository. This can be a PR or MR number.
10921092
pub number: i64,
10931093
/// The current body/description of the review, which may be None if no description is set.
10941094
pub body: Option<String>,
10951095
/// The platform-specific symbol for this review type (e.g., "#" for GitHub pull requests and "!" for MRs).
10961096
pub unit_symbol: String,
1097+
/// If set, update the base/target branch of this review to the given value.
1098+
pub target_branch: Option<String>,
10971099
}
10981100

10991101
#[cfg(feature = "export-schema")]
1100-
but_schemars::register_sdk_type!(ForgeReviewDescriptionUpdate);
1102+
but_schemars::register_sdk_type!(ForgeReviewUpdate);
11011103

1102-
impl From<ForgeReview> for ForgeReviewDescriptionUpdate {
1104+
impl From<ForgeReview> for ForgeReviewUpdate {
11031105
fn from(review: ForgeReview) -> Self {
1104-
ForgeReviewDescriptionUpdate {
1106+
ForgeReviewUpdate {
11051107
number: review.number,
11061108
body: review.body,
11071109
unit_symbol: review.unit_symbol,
1110+
target_branch: Some(review.target_branch),
11081111
}
11091112
}
11101113
}
11111114

1112-
/// Update the review description tables for a set of reviews
1113-
pub async fn update_review_description_tables(
1115+
impl From<ForgeReviewTargetUpdate> for ForgeReviewUpdate {
1116+
fn from(update: ForgeReviewTargetUpdate) -> Self {
1117+
ForgeReviewUpdate {
1118+
number: update.number,
1119+
body: None,
1120+
unit_symbol: String::new(),
1121+
target_branch: Some(update.target_branch),
1122+
}
1123+
}
1124+
}
1125+
1126+
/// Update reviews: description footers and, optionally, target/base branches.
1127+
///
1128+
/// Per-review failures are collected rather than aborting the batch.
1129+
pub async fn sync_reviews(
11141130
preferred_forge_user: &Option<crate::ForgeUser>,
11151131
forge_repo_info: &crate::forge::ForgeRepoInfo,
1116-
reviews: &[ForgeReviewDescriptionUpdate],
1132+
reviews: &[ForgeReviewUpdate],
11171133
storage: &but_forge_storage::Controller,
11181134
) -> Result<()> {
11191135
let crate::forge::ForgeRepoInfo {
11201136
forge, owner, repo, ..
11211137
} = forge_repo_info;
11221138

1139+
let mut errors: Vec<String> = Vec::new();
1140+
1141+
// Skip body updates when no review has footer content (e.g. push-only target updates).
1142+
let has_footer_content = reviews.iter().any(|r| !r.unit_symbol.is_empty());
1143+
11231144
match forge {
11241145
ForgeName::GitHub => {
11251146
let preferred_account = preferred_forge_user.as_ref().and_then(|user| user.github());
11261147
let pr_numbers: Vec<i64> = reviews.iter().map(|r| r.number).collect();
11271148

11281149
for review in reviews {
1129-
let updated_body = update_body(
1130-
review.body.as_deref(),
1131-
review.number,
1132-
&pr_numbers,
1133-
&review.unit_symbol,
1134-
);
1150+
let updated_body = if has_footer_content {
1151+
Some(update_body(
1152+
review.body.as_deref(),
1153+
review.number,
1154+
&pr_numbers,
1155+
&review.unit_symbol,
1156+
))
1157+
} else {
1158+
None
1159+
};
11351160

11361161
let params = but_github::UpdatePullRequestParams {
11371162
owner,
11381163
repo,
11391164
pr_number: review.number,
11401165
title: None,
1141-
body: Some(&updated_body),
1142-
base: None,
1166+
body: updated_body.as_deref(),
1167+
base: review.target_branch.as_deref(),
11431168
state: None,
11441169
};
11451170

1146-
but_github::pr::update(preferred_account, params, storage).await?;
1171+
if let Err(err) = but_github::pr::update(preferred_account, params, storage).await {
1172+
errors.push(format!("PR #{}: {err}", review.number));
1173+
}
11471174
}
1148-
1149-
Ok(())
11501175
}
11511176
ForgeName::GitLab => {
11521177
let project_id = GitLabProjectId::new(owner, repo);
11531178
let preferred_account = preferred_forge_user.as_ref().and_then(|user| user.gitlab());
11541179
let mr_iids: Vec<i64> = reviews.iter().map(|r| r.number).collect();
11551180

11561181
for review in reviews {
1157-
let updated_body = update_body(
1158-
review.body.as_deref(),
1159-
review.number,
1160-
&mr_iids,
1161-
&review.unit_symbol,
1162-
);
1182+
let updated_body = if has_footer_content {
1183+
Some(update_body(
1184+
review.body.as_deref(),
1185+
review.number,
1186+
&mr_iids,
1187+
&review.unit_symbol,
1188+
))
1189+
} else {
1190+
None
1191+
};
11631192

11641193
let params = but_gitlab::UpdateMergeRequestParams {
11651194
project_id: project_id.clone(),
11661195
mr_iid: review.number,
11671196
title: None,
1168-
description: Some(&updated_body),
1169-
target_branch: None,
1197+
description: updated_body.as_deref(),
1198+
target_branch: review.target_branch.as_deref(),
11701199
state_event: None,
11711200
};
11721201

1173-
but_gitlab::mr::update(preferred_account, params, storage).await?;
1202+
if let Err(err) = but_gitlab::mr::update(preferred_account, params, storage).await {
1203+
errors.push(format!("MR !{}: {err}", review.number));
1204+
}
11741205
}
1206+
}
1207+
_ => {
1208+
return Err(Error::msg(format!(
1209+
"Updating reviews for forge {forge:?} is not implemented yet.",
1210+
)));
1211+
}
1212+
}
11751213

1176-
Ok(())
1214+
if errors.is_empty() {
1215+
Ok(())
1216+
} else {
1217+
Err(Error::msg(format!(
1218+
"Some reviews failed to update:\n{}",
1219+
errors.join("\n")
1220+
)))
1221+
}
1222+
}
1223+
1224+
/// A target branch update for a single review (PR/MR).
1225+
#[derive(Debug, Clone)]
1226+
pub struct ForgeReviewTargetUpdate {
1227+
pub number: i64,
1228+
pub target_branch: String,
1229+
}
1230+
1231+
/// Compute the expected target branch for each review in a stack.
1232+
/// Walks branches bottom-to-top; each review targets the preceding branch
1233+
/// (or `base_branch` for the bottom-most).
1234+
pub fn compute_review_target_updates(
1235+
heads: &[(String, Option<i64>)],
1236+
base_branch: &str,
1237+
) -> Vec<ForgeReviewTargetUpdate> {
1238+
let mut updates = Vec::new();
1239+
let mut current_target = base_branch;
1240+
// heads are expected bottom-to-top (base branch first).
1241+
for (branch_name, review_number) in heads {
1242+
if let Some(number) = review_number {
1243+
updates.push(ForgeReviewTargetUpdate {
1244+
number: *number,
1245+
target_branch: current_target.to_string(),
1246+
});
11771247
}
1178-
_ => Err(Error::msg(format!(
1179-
"Updating review descriptions for forge {forge:?} is not implemented yet.",
1180-
))),
1248+
current_target = branch_name;
11811249
}
1250+
updates
11821251
}
11831252

11841253
/// Replaces or inserts a new footer into an existing body of text.
@@ -1709,4 +1778,79 @@ mod tests {
17091778
assert_eq!(result, "Head content\n\nTail content");
17101779
assert!(!result.contains(STACKING_FOOTER_BOUNDARY_TOP));
17111780
}
1781+
1782+
// --- compute_review_target_updates tests ---
1783+
1784+
fn heads(specs: &[(&str, Option<i64>)]) -> Vec<(String, Option<i64>)> {
1785+
specs
1786+
.iter()
1787+
.map(|(name, id)| (name.to_string(), *id))
1788+
.collect()
1789+
}
1790+
1791+
#[test]
1792+
fn target_updates_empty_stack() {
1793+
let result = compute_review_target_updates(&[], "main");
1794+
assert!(result.is_empty());
1795+
}
1796+
1797+
#[test]
1798+
fn target_updates_no_reviews() {
1799+
let h = heads(&[("branch-a", None), ("branch-b", None)]);
1800+
let result = compute_review_target_updates(&h, "main");
1801+
assert!(result.is_empty());
1802+
}
1803+
1804+
#[test]
1805+
fn target_updates_single_branch_with_review() {
1806+
let h = heads(&[("branch-a", Some(1))]);
1807+
let result = compute_review_target_updates(&h, "main");
1808+
assert_eq!(result.len(), 1);
1809+
assert_eq!(result[0].number, 1);
1810+
assert_eq!(result[0].target_branch, "main");
1811+
}
1812+
1813+
#[test]
1814+
fn target_updates_stacked_reviews_point_to_parent() {
1815+
// bottom-to-top: branch-a -> branch-b -> branch-c
1816+
let h = heads(&[
1817+
("branch-a", Some(1)),
1818+
("branch-b", Some(2)),
1819+
("branch-c", Some(3)),
1820+
]);
1821+
let result = compute_review_target_updates(&h, "main");
1822+
assert_eq!(result.len(), 3);
1823+
assert_eq!(result[0].number, 1);
1824+
assert_eq!(result[0].target_branch, "main");
1825+
assert_eq!(result[1].number, 2);
1826+
assert_eq!(result[1].target_branch, "branch-a");
1827+
assert_eq!(result[2].number, 3);
1828+
assert_eq!(result[2].target_branch, "branch-b");
1829+
}
1830+
1831+
#[test]
1832+
fn target_updates_skips_branches_without_reviews() {
1833+
// branch-a has no review, branch-b does — b should target a (not main)
1834+
let h = heads(&[("branch-a", None), ("branch-b", Some(5))]);
1835+
let result = compute_review_target_updates(&h, "main");
1836+
assert_eq!(result.len(), 1);
1837+
assert_eq!(result[0].number, 5);
1838+
assert_eq!(result[0].target_branch, "branch-a");
1839+
}
1840+
1841+
#[test]
1842+
fn target_updates_gap_in_middle() {
1843+
// a(PR) -> b(no PR) -> c(PR): c should target b, a should target main
1844+
let h = heads(&[
1845+
("branch-a", Some(1)),
1846+
("branch-b", None),
1847+
("branch-c", Some(3)),
1848+
]);
1849+
let result = compute_review_target_updates(&h, "main");
1850+
assert_eq!(result.len(), 2);
1851+
assert_eq!(result[0].number, 1);
1852+
assert_eq!(result[0].target_branch, "main");
1853+
assert_eq!(result[1].number, 3);
1854+
assert_eq!(result[1].target_branch, "branch-b");
1855+
}
17121856
}

crates/but/src/command/legacy/forge/review.rs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -705,12 +705,11 @@ async fn publish_reviews_for_branch_and_dependents(
705705
match published_review {
706706
PublishReviewResult::Published(review) => {
707707
newly_published.push(*review.clone());
708-
all_reviews_in_order.push(*review);
708+
all_reviews_in_order.push((*review, current_target_branch.to_string()));
709709
}
710710
PublishReviewResult::AlreadyExists(reviews) => {
711711
if let Some(review) = reviews.first() {
712-
// Ignore other existing reviews for ordering
713-
all_reviews_in_order.push(review.clone());
712+
all_reviews_in_order.push((review.clone(), current_target_branch.to_string()));
714713
}
715714
already_existing.extend(reviews);
716715
}
@@ -723,12 +722,21 @@ async fn publish_reviews_for_branch_and_dependents(
723722
}
724723
}
725724

726-
// Update the PR descriptions to have the footers
727-
but_api::legacy::forge::update_review_footers(
728-
ctx.to_sync(),
729-
all_reviews_in_order.into_iter().map(Into::into).collect(),
730-
)
731-
.await?;
725+
// Update footers and fix any drifted target branches in a single pass.
726+
let review_updates: Vec<but_forge::ForgeReviewUpdate> = all_reviews_in_order
727+
.into_iter()
728+
.map(|(review, expected_target)| {
729+
let mut update: but_forge::ForgeReviewUpdate = review.into();
730+
// Only send a target update when it has drifted.
731+
if update.target_branch.as_ref() == Some(&expected_target) {
732+
update.target_branch = None;
733+
} else {
734+
update.target_branch = Some(expected_target);
735+
}
736+
update
737+
})
738+
.collect();
739+
but_api::legacy::forge::update_review_footers(ctx.to_sync(), review_updates).await?;
732740

733741
let outcome = PublishReviewsOutcome {
734742
published: newly_published,

0 commit comments

Comments
 (0)