Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ Layer 5 (binary): code_assistant — CLI, config, feature-gated frontend
- Client and snapshot dir injected via `AgentRuntimeOptions::pull_requests`;
the lifecycle sweep reads sessions' PRs through it in batches; see
`docs/pull-request-view.md`
- Checkout like `gh pr checkout` (`git::pull_request`, here or in a new
worktree session) and stack rebases (`git::stack_rebase`, scratch repo)
are git-crate work the core drives

### Session Storage
- One folder per session below `sessions/`, the ID is its path:
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

136 changes: 136 additions & 0 deletions crates/code_assistant_core/src/pull_requests/checkout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
//! Checking out a pull request: its head as git sees it, from what GitHub
//! reports (see [`git::pull_request`] for the checkout itself).

use git::pull_request::{ForkHead, PullRequestHead};
use github::RepoRef;
use github::model::Detail;
use std::path::PathBuf;

/// Where a pull request is checked out.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CheckoutPlace {
/// In the session's repository: switches the branch it works on.
Here,
/// In a worktree of its own, with a session of its own.
Worktree,
}

/// A checked-out pull request: the session working on it (a new one for a
/// worktree) and where.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PullRequestCheckout {
pub session_id: String,
pub path: PathBuf,
pub branch: String,
/// At the pull request's head (local commits keep a branch elsewhere).
pub on_head: bool,
}

/// The head of `detail` for a checkout in `repo`, whose remotes are
/// `(name, url)`: fetched through the remote of the pull request's
/// repository (else `origin`); a fork pushed to over the protocol that
/// remote uses, when its owner lets maintainers push.
pub fn pull_request_head(
detail: &Detail,
base: &RepoRef,
remotes: &[(String, String)],
) -> PullRequestHead {
let remote = remotes
.iter()
.find(|(_, url)| RepoRef::from_remote_url(url).as_ref() == Some(base))
.or_else(|| remotes.iter().find(|(name, _)| name == "origin"));
let ssh = remote.is_some_and(|(_, url)| url.starts_with("git@") || url.starts_with("ssh://"));
let fork = detail.is_cross_repository.then(|| ForkHead {
owner: detail.head_repository_owner.clone().unwrap_or_default(),
push_url: detail
.head_repository
.as_ref()
.filter(|_| detail.maintainer_can_modify)
.map(|name| match ssh {
true => format!("git@{}:{name}.git", base.host),
false => format!("https://{}/{name}.git", base.host),
}),
});
PullRequestHead {
number: detail.number,
branch: detail.head_branch.clone(),
sha: detail.head_sha.clone(),
base_branch: detail.base_branch.clone(),
remote: remote.map_or_else(|| "origin".to_owned(), |(name, _)| name.clone()),
fork,
}
}

#[cfg(test)]
mod tests {
use super::*;

fn detail(fork: bool, maintainers: bool) -> Detail {
let mut detail: Detail = serde_json::from_value(serde_json::json!({
"number": 8, "title": "t", "body": "", "url": "u", "author": null,
"state": "open", "is_draft": false, "mergeability": "mergeable",
"review_decision": null, "additions": 0, "deletions": 0, "changed_files": 0,
"head_branch": "topic", "head_sha": "abc1234", "head_repository": "contributor/r",
"head_repository_owner": "contributor", "is_cross_repository": false,
"base_branch": "main", "created_at": "2026-10-30T10:00:00Z",
"updated_at": "2026-10-30T10:00:00Z", "merged_at": null, "closed_at": null,
"reviewers": [], "labels": [], "checks": [], "checks_state": null,
"checks_truncated": false,
"merge_capabilities": {"merge": true, "squash": true, "rebase": true},
"auto_merge_allowed": false,
"viewer_permissions": {"actions": [], "comment": true, "resolve": true,
"verdicts": [], "request_reviewers": true, "update_methods": [],
"labels": true, "maintainer_edits": false},
"base_comparison": "up-to-date", "behind_by": null,
"auto_merge_enabled": false, "auto_merge_method": null,
"maintainer_can_modify": false
}))
.unwrap();
detail.is_cross_repository = fork;
detail.maintainer_can_modify = maintainers;
detail
}

fn remotes(urls: &[(&str, &str)]) -> Vec<(String, String)> {
urls.iter()
.map(|(name, url)| (name.to_string(), url.to_string()))
.collect()
}

#[test]
fn fetches_through_the_remote_of_the_pull_requests_repository() {
let base = RepoRef::new("github.com", "stippi", "r");
let head = pull_request_head(
&detail(false, false),
&base,
&remotes(&[
("origin", "git@github.com:me/r.git"),
("upstream", "https://github.com/stippi/r.git"),
]),
);
assert_eq!(head.remote, "upstream");
assert!(head.fork.is_none());
assert_eq!((head.branch.as_str(), head.number), ("topic", 8));
}

#[test]
fn a_fork_is_pushed_to_only_when_maintainers_may() {
let base = RepoRef::new("github.com", "stippi", "r");
let ssh = remotes(&[("origin", "git@github.com:stippi/r.git")]);
let head = pull_request_head(&detail(true, true), &base, &ssh);
let fork = head.fork.unwrap();
assert_eq!(fork.owner, "contributor");
assert_eq!(
fork.push_url.as_deref(),
Some("git@github.com:contributor/r.git")
);
let head = pull_request_head(&detail(true, false), &base, &ssh);
assert_eq!(head.fork.unwrap().push_url, None);
let https = remotes(&[("origin", "https://github.com/stippi/r.git")]);
let head = pull_request_head(&detail(true, true), &base, &https);
assert_eq!(
head.fork.unwrap().push_url.as_deref(),
Some("https://github.com/contributor/r.git")
);
}
}
5 changes: 5 additions & 0 deletions crates/code_assistant_core/src/pull_requests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@
//! [`UiEvent::PullRequestListChanged`]: crate::ui::UiEvent::PullRequestListChanged

mod cache;
mod checkout;
mod repo;
mod snapshots;
mod store;
#[cfg(test)]
mod tests;

pub use checkout::{CheckoutPlace, PullRequestCheckout};
pub use store::{PullRequestSource, PullRequests};

pub(crate) use checkout::pull_request_head;

pub(crate) use repo::{github_remote, github_repo, own_pull_request};

use github::RepoRef;
Expand All @@ -45,6 +49,7 @@ pub enum PullRequestPart {
Activity,
Diff,
FilesViewed,
Stack,
}

/// Where a session's pull requests live and which one is its own.
Expand Down
87 changes: 85 additions & 2 deletions crates/code_assistant_core/src/pull_requests/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ use futures::FutureExt;
use github::model::{
Action, Activity, Checks, Detail, DiffFileContents, DiffPage, DiffStat, EditableCommentKind,
FileChange, FilesViewed, LabelCandidates, ListPage, ListQuery, PullRequestState,
PullRequestUpdate, ReactionContent, ReviewSubmission, Reviewer, ReviewerCandidates,
ThreadCommentsPage, Viewer,
PullRequestUpdate, ReactionContent, ReviewSubmission, Reviewer, ReviewerCandidates, Stack,
StackAction, StackHead, StackRebase, ThreadCommentsPage, Viewer,
};
use github::{Priority, PullRequestRef, RepoRef};
use serde::Serialize;
Expand All @@ -36,6 +36,8 @@ const FILES_VIEWED_TTL: Duration = Duration::from_secs(15);
const VIEWER_TTL: Duration = Duration::from_secs(10 * 60);
const CANDIDATES_TTL: Duration = Duration::from_secs(10 * 60);
const HEAD_TTL: Duration = Duration::from_secs(60);
/// T3 Code reads a stack again after a minute.
const STACK_TTL: Duration = Duration::from_secs(60);

const PULL_REQUESTS_HELD: usize = 128;
const LISTS_HELD: usize = 64;
Expand Down Expand Up @@ -89,13 +91,38 @@ pub struct PullRequests {
activities: Cache<PullRequestRef, Activity>,
diffs: Cache<DiffKey, DiffPage>,
files_viewed: Cache<PullRequestRef, FilesViewed>,
/// The stack a pull request is a layer of (`None`: none).
stacks: Cache<PullRequestRef, Option<Stack>>,
viewers: Cache<String, Viewer>,
reviewer_candidates: Cache<PullRequestRef, ReviewerCandidates>,
label_candidates: Cache<PullRequestRef, LabelCandidates>,
/// The own pull request of a branch, by repository and branch name.
heads: Cache<(RepoRef, String), Option<u64>>,
}

/// Rebase a stack as `plan` says, in a scratch repository.
async fn rebase(plan: StackRebase) -> Result<()> {
let git = git::GitBinary::new()?;
let layers: Vec<git::stack_rebase::StackBranch> = plan
.layers
.into_iter()
.map(|layer| git::stack_rebase::StackBranch {
number: layer.number,
branch: layer.branch,
head_sha: layer.head_sha,
})
.collect();
git::stack_rebase::rebase_stack(
&git,
&plan.remote_url,
&plan.auth_header,
&plan.base,
&layers,
)
.await?;
Ok(())
}

/// Announces a pull request's part after a background refresh.
fn announce<K: 'static>(
events: &EventStream,
Expand Down Expand Up @@ -168,6 +195,10 @@ impl PullRequests {
policy(|_, _| FILES_VIEWED_TTL, PULL_REQUESTS_HELD),
announce(&events, PullRequestPart::FilesViewed, itself),
),
stacks: Cache::new(
policy(|_, _| STACK_TTL, PULL_REQUESTS_HELD),
announce(&events, PullRequestPart::Stack, itself),
),
viewers: Cache::new(policy(|_, _| VIEWER_TTL, VIEWERS_HELD), None),
reviewer_candidates: Cache::new(policy(|_, _| CANDIDATES_TTL, CANDIDATES_HELD), None),
label_candidates: Cache::new(policy(|_, _| CANDIDATES_TTL, CANDIDATES_HELD), None),
Expand Down Expand Up @@ -335,6 +366,13 @@ impl PullRequests {
.await
}

pub async fn stack(&self, pr: PullRequestRef) -> Result<Fetched<Option<Stack>>> {
read(&self.stacks, pr, &self.client, |client, pr| async move {
client.stack(&pr).await
})
.await
}

pub async fn thread_comments(
&self,
pr: PullRequestRef,
Expand Down Expand Up @@ -502,6 +540,49 @@ impl PullRequests {
self.written(&pr, &parts, written)
}

/// Merge the stack through `pr`, or rebase the stack it tops; then
/// every layer `expected` names is read anew.
pub async fn stack_action(
&self,
pr: PullRequestRef,
stack_number: u64,
expected: Vec<StackHead>,
action: StackAction,
) -> Result<()> {
let done: Result<()> = match action {
StackAction::Merge(method) => self
.interactive()
.merge_stack(&pr, stack_number, &expected, method)
.await
.map_err(Into::into),
StackAction::Rebase => {
match self
.interactive()
.stack_rebase(&pr, stack_number, &expected)
.await
{
Ok(plan) => rebase(plan).await,
Err(e) => Err(e.into()),
}
}
};
let parts = [
PullRequestPart::Detail,
PullRequestPart::Checks,
PullRequestPart::Activity,
PullRequestPart::Diff,
PullRequestPart::Stack,
];
let mut layers: Vec<u64> = expected.iter().map(|head| head.number).collect();
layers.push(pr.number);
layers.sort_unstable();
layers.dedup();
for number in layers {
self.invalidate(&pr.repo.pull_request(number), &parts);
}
done
}

/// Every part of the pull request is read anew on its next read.
pub fn refresh(&self, pr: &PullRequestRef) {
self.reviewer_candidates.invalidate(|key| key == pr);
Expand All @@ -512,6 +593,7 @@ impl PullRequests {
PullRequestPart::Activity,
PullRequestPart::Diff,
PullRequestPart::FilesViewed,
PullRequestPart::Stack,
];
self.invalidate(pr, &parts);
}
Expand Down Expand Up @@ -545,6 +627,7 @@ impl PullRequests {
PullRequestPart::Activity => self.activities.invalidate(|key| key == pr),
PullRequestPart::Diff => self.diffs.invalidate(|key| key.pr == *pr),
PullRequestPart::FilesViewed => self.files_viewed.invalidate(|key| key == pr),
PullRequestPart::Stack => self.stacks.invalidate(|key| key == pr),
}
self.events.publish_app(UiEvent::PullRequestChanged {
pr: pr.clone(),
Expand Down
43 changes: 43 additions & 0 deletions crates/code_assistant_core/src/pull_requests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,3 +418,46 @@ async fn refreshing_a_repositorys_lists_reads_them_anew_and_announces_it() {
assert!(!read.refreshing, "read anew, not served stale");
assert_eq!(h.requests("PullRequestSearch"), 2);
}

#[tokio::test]
async fn a_stack_action_reads_every_layer_anew_even_when_refused() {
let mut h = harness();
h.fake
.on_rest(
Method::Get,
"repos/stippi/code-assistant/stacks?pull_request=291",
)
.json(
json!([{"number": 4, "url": "u", "base": "main", "pull_requests": [
{"number": 290, "head": {"ref": "a", "sha": "aaa"}, "state": "open"},
{"number": 291, "head": {"ref": "b", "sha": "bbb"}, "state": "open"}
]}]),
);
// The viewer saw stack 3: it changed since.
let expected = vec![
github::model::StackHead {
number: 290,
head_sha: "aaa".into(),
},
github::model::StackHead {
number: 291,
head_sha: "bbb".into(),
},
];
let refused = h
.store
.stack_action(pr(), 3, expected, github::model::StackAction::Rebase)
.await
.unwrap_err();
assert_eq!(
refused.to_string(),
"The stack changed. Refresh it before trying again."
);
let events = h.drain_events().await;
for number in [290, 291] {
assert!(events.iter().any(|event| matches!(
event,
UiEvent::PullRequestChanged { pr, part: PullRequestPart::Stack } if pr.number == number
)));
}
}
Loading
Loading