Skip to content
Open
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
117 changes: 115 additions & 2 deletions src/ci/ci_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ use crate::authorship::rebase_authorship::{
rewrite_authorship_after_rebase_v2, rewrite_authorship_after_squash_or_rebase,
};
use crate::error::GitAiError;
use crate::git::refs::{get_reference_as_authorship_log_v3, show_authorship_note};
use crate::git::repository::{CommitRange, Repository};
use crate::git::refs::{
copy_ref, get_reference_as_authorship_log_v3, merge_notes_from_ref, ref_exists,
show_authorship_note,
};
use crate::git::repository::{exec_git, CommitRange, Repository};
use crate::git::sync_authorship::fetch_authorship_notes;
use std::fs;
use std::path::PathBuf;
Expand All @@ -18,6 +21,9 @@ pub enum CiEvent {
base_ref: String,
#[allow(dead_code)]
base_sha: String,
/// Clone URL of the fork repository, if this PR came from a fork.
/// When set, notes will be fetched from the fork before processing.
fork_clone_url: Option<String>,
},
}

Expand All @@ -38,6 +44,8 @@ pub enum CiRunResult {
#[allow(dead_code)]
authorship_log: AuthorshipLog,
},
/// Fork notes were fetched and preserved for a merge commit from a fork
ForkNotesPreserved,
/// No AI authorship to track (pre-git-ai commits or human-only code)
NoAuthorshipAvailable,
}
Expand Down Expand Up @@ -68,6 +76,7 @@ impl CiContext {
head_sha,
base_ref,
base_sha: _,
fork_clone_url,
} => {
println!("Working repository is in {}", self.repo.path().display());

Expand All @@ -76,6 +85,25 @@ impl CiContext {
fetch_authorship_notes(&self.repo, "origin")?;
println!("Fetched authorship history");

// If this is a fork PR, fetch notes from the fork repository
let mut fork_notes_fetched = false;
if let Some(fork_url) = fork_clone_url {
println!("Fetching authorship notes from fork...");
match Self::fetch_fork_notes(&self.repo, fork_url) {
Ok(true) => {
println!("Fetched authorship notes from fork");
fork_notes_fetched = true;
}
Ok(false) => println!("No authorship notes found on fork"),
Err(e) => {
println!(
"Warning: Failed to fetch fork notes ({}), continuing without them",
e
);
}
}
}

// Check if authorship already exists for this commit
match get_reference_as_authorship_log_v3(&self.repo, merge_commit_sha) {
Ok(existing_log) => {
Expand All @@ -96,6 +124,33 @@ impl CiContext {
let merge_commit = self.repo.find_commit(merge_commit_sha.clone())?;
let parent_count = merge_commit.parents().count();
if parent_count > 1 {
// For fork PRs with merge commits, the merged commits from the
// fork have the same SHAs. Now that we've fetched fork notes,
// those notes are attached to the correct commits. Just push them.
if fork_clone_url.is_some() {
if !ref_exists(&self.repo, "refs/notes/ai") {
println!(
"No local authorship notes available after origin/fork fetch; skipping fork note push"
);
return Ok(CiRunResult::SkippedSimpleMerge);
}

println!(
"{} has {} parents (merge commit from fork) - preserving fork notes",
merge_commit_sha, parent_count
);
if fork_notes_fetched {
println!("Fork notes were fetched and merged locally");
} else {
println!(
"Using existing local authorship notes (no additional fork notes fetched)"
);
}
println!("Pushing authorship...");
self.repo.push_authorship("origin")?;
println!("Pushed authorship. Done.");
return Ok(CiRunResult::ForkNotesPreserved);
}
println!(
"{} has {} parents (simple merge)",
merge_commit_sha, parent_count
Expand Down Expand Up @@ -233,6 +288,64 @@ impl CiContext {
Ok(())
}

/// Fetch authorship notes from a fork repository URL and merge them into
/// the local refs/notes/ai. Returns Ok(true) if notes were found and merged,
/// Ok(false) if no notes exist on the fork.
fn fetch_fork_notes(repo: &Repository, fork_url: &str) -> Result<bool, GitAiError> {
let tracking_ref = "refs/notes/ai-remote/fork";

// Check if the fork has notes
let mut ls_remote_args = repo.global_args_for_exec();
ls_remote_args.push("ls-remote".to_string());
ls_remote_args.push(fork_url.to_string());
ls_remote_args.push("refs/notes/ai".to_string());

match exec_git(&ls_remote_args) {
Ok(output) => {
let result = String::from_utf8_lossy(&output.stdout).to_string();
if result.trim().is_empty() {
return Ok(false);
}
}
Err(e) => {
return Err(GitAiError::Generic(format!(
"Failed to check fork for authorship notes: {}",
e
)));
}
}

// Fetch notes from the fork URL into a tracking ref
let fetch_refspec = format!("+refs/notes/ai:{}", tracking_ref);
let mut fetch_args = repo.global_args_for_exec();
fetch_args.push("-c".to_string());
fetch_args.push("core.hooksPath=/dev/null".to_string());
fetch_args.push("fetch".to_string());
fetch_args.push("--no-tags".to_string());
fetch_args.push("--recurse-submodules=no".to_string());
fetch_args.push("--no-write-fetch-head".to_string());
fetch_args.push("--no-write-commit-graph".to_string());
fetch_args.push("--no-auto-maintenance".to_string());
fetch_args.push(fork_url.to_string());
fetch_args.push(fetch_refspec);

exec_git(&fetch_args)?;

// Merge fork notes into local refs/notes/ai
let local_notes_ref = "refs/notes/ai";
if ref_exists(repo, tracking_ref) {
if ref_exists(repo, local_notes_ref) {
// Both exist - merge (fork notes fill in what origin doesn't have)
merge_notes_from_ref(repo, tracking_ref)?;
} else {
// Only fork notes exist - copy them to local
copy_ref(repo, tracking_ref, local_notes_ref)?;
}
}

Ok(true)
}

/// Get the rebased commits by walking back from merge_commit_sha.
/// For a rebase merge with N original commits, there should be N new commits
/// ending at merge_commit_sha.
Expand Down
25 changes: 25 additions & 0 deletions src/ci/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ pub fn get_github_ci_context() -> Result<Option<CiContext>, GitAiError> {
let base_ref = pull_request.base.ref_name;
let clone_url = pull_request.base.repo.clone_url.clone();

// Detect fork: if head repo URL differs from base repo URL, this is a fork PR
let fork_clone_url = if pull_request.head.repo.clone_url != pull_request.base.repo.clone_url {
let fork_url = pull_request.head.repo.clone_url.clone();
println!(
"Detected fork PR: head repo {} differs from base repo {}",
fork_url, clone_url
);
Some(fork_url)
} else {
None
};

let clone_dir = "git-ai-ci-clone".to_string();

// Authenticate the clone URL with GITHUB_TOKEN if available
Expand Down Expand Up @@ -97,6 +109,18 @@ pub fn get_github_ci_context() -> Result<Option<CiContext>, GitAiError> {
format!("pull/{}/head:refs/github/pr/{}", pr_number, pr_number),
])?;

// Authenticate the fork clone URL if this is a fork PR
let authenticated_fork_url = fork_clone_url.map(|fork_url| {
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
fork_url.replace(
"https://github.com/",
&format!("https://x-access-token:{}@github.com/", token),
)
} else {
fork_url
}
});

let repo = find_repository_in_path(&clone_dir.clone())?;

Ok(Some(CiContext {
Expand All @@ -107,6 +131,7 @@ pub fn get_github_ci_context() -> Result<Option<CiContext>, GitAiError> {
head_sha: head_sha.clone(),
base_ref: base_ref.clone(),
base_sha: pull_request.base.sha.clone(),
fork_clone_url: authenticated_fork_url,
},
temp_dir: PathBuf::from(clone_dir),
}))
Expand Down
90 changes: 87 additions & 3 deletions src/ci/gitlab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ struct GitLabMergeRequest {
merge_commit_sha: Option<String>,
squash_commit_sha: Option<String>,
squash: Option<bool>,
source_project_id: u64,
target_project_id: u64,
}

/// GitLab Project API response (minimal fields for fork detection)
#[derive(Debug, Clone, Deserialize)]
struct GitLabProject {
http_url_to_repo: String,
}

/// Query GitLab API for recently merged MRs and find one matching the current commit SHA.
Expand Down Expand Up @@ -175,6 +183,57 @@ pub fn get_gitlab_ci_context() -> Result<Option<CiContext>, GitAiError> {
effective_merge_sha
);

// Detect fork: if source_project_id differs from target_project_id, this is a fork MR
let fork_clone_url = if mr.source_project_id != mr.target_project_id {
println!(
"[GitLab CI] Detected fork MR: source project {} differs from target project {}",
mr.source_project_id, mr.target_project_id
);
// Query the source project API to get its clone URL
let source_project_endpoint = format!("{}/projects/{}", api_url, mr.source_project_id);
match minreq::get(&source_project_endpoint)
.with_header(auth_header_name, &auth_token)
.with_header(
"User-Agent",
format!("git-ai/{}", env!("CARGO_PKG_VERSION")),
)
.with_timeout(30)
.send()
{
Ok(resp) if resp.status_code == 200 => {
match serde_json::from_str::<GitLabProject>(resp.as_str().unwrap_or("{}")) {
Ok(project) => {
println!("[GitLab CI] Fork clone URL: {}", project.http_url_to_repo);
Some(project.http_url_to_repo)
}
Err(e) => {
println!(
"[GitLab CI] Warning: Failed to parse source project response: {}",
e
);
None
}
}
}
Ok(resp) => {
println!(
"[GitLab CI] Warning: Failed to query source project (status {}), fork notes may be lost",
resp.status_code
);
None
}
Err(e) => {
println!(
"[GitLab CI] Warning: Failed to query source project: {}, fork notes may be lost",
e
);
None
}
}
} else {
None
};

// Found a matching MR - clone and fetch
let clone_dir = "git-ai-ci-clone".to_string();
let clone_url = format!("{}/{}.git", server_url, project_path);
Expand Down Expand Up @@ -262,6 +321,18 @@ pub fn get_gitlab_ci_context() -> Result<Option<CiContext>, GitAiError> {
effective_merge_sha, mr.sha, mr.source_branch, mr.target_branch
);

// Authenticate the fork clone URL for fetching notes
let authenticated_fork_url = fork_clone_url.map(|fork_url| {
if let Ok(job_token) = std::env::var("CI_JOB_TOKEN") {
fork_url.replace(
&server_url,
&format!("{}://gitlab-ci-token:{}@{}", scheme, job_token, server_host),
)
} else {
fork_url
}
});

Ok(Some(CiContext {
repo,
event: CiEvent::Merge {
Expand All @@ -270,6 +341,7 @@ pub fn get_gitlab_ci_context() -> Result<Option<CiContext>, GitAiError> {
head_sha: mr.sha.clone(),
base_ref: mr.target_branch.clone(),
base_sha: String::new(), // Not readily available from MR API, but not used in current impl
fork_clone_url: authenticated_fork_url,
},
temp_dir: PathBuf::from(clone_dir),
}))
Expand All @@ -296,7 +368,9 @@ mod tests {
"sha": "abc123",
"merge_commit_sha": "def456",
"squash_commit_sha": null,
"squash": false
"squash": false,
"source_project_id": 123,
"target_project_id": 456
}"#;
let mr: GitLabMergeRequest = serde_json::from_str(json).unwrap();
assert_eq!(mr.iid, 42);
Expand All @@ -307,6 +381,8 @@ mod tests {
assert_eq!(mr.merge_commit_sha, Some("def456".to_string()));
assert!(mr.squash_commit_sha.is_none());
assert_eq!(mr.squash, Some(false));
assert_eq!(mr.source_project_id, 123);
assert_eq!(mr.target_project_id, 456);
}

#[test]
Expand All @@ -319,12 +395,16 @@ mod tests {
"sha": "head123",
"merge_commit_sha": "merge456",
"squash_commit_sha": "squash789",
"squash": true
"squash": true,
"source_project_id": 123,
"target_project_id": 123
}"#;
let mr: GitLabMergeRequest = serde_json::from_str(json).unwrap();
assert_eq!(mr.iid, 99);
assert_eq!(mr.squash_commit_sha, Some("squash789".to_string()));
assert_eq!(mr.squash, Some(true));
assert_eq!(mr.source_project_id, 123);
assert_eq!(mr.target_project_id, 123);
}

#[test]
Expand All @@ -333,14 +413,18 @@ mod tests {
"iid": 1,
"source_branch": "dev",
"target_branch": "main",
"sha": "abc"
"sha": "abc",
"source_project_id": 999,
"target_project_id": 999
}"#;
let mr: GitLabMergeRequest = serde_json::from_str(json).unwrap();
assert_eq!(mr.iid, 1);
assert!(mr.title.is_none());
assert!(mr.merge_commit_sha.is_none());
assert!(mr.squash_commit_sha.is_none());
assert!(mr.squash.is_none());
assert_eq!(mr.source_project_id, 999);
assert_eq!(mr.target_project_id, 999);
}

#[test]
Expand Down
Loading