use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use sha1::{Digest, Sha1};
use crate::error::{Result, TuicrError};
use crate::forge::remote_comments::RemoteReviewThread;
use crate::forge::traits::{
ForgeBackend, ForgeFileLinesRequest, ForgeRepository, GhCreateReviewResponse,
PagedPullRequests, PullRequestCommit, PullRequestDetails, PullRequestListQuery,
PullRequestListScope, PullRequestReviewMetadata, PullRequestReviewRecord, PullRequestTarget,
};
use crate::model::DiffLine;
use crate::process::{
CommandOutputError, CommandOutputErrorKind, run_command_output, run_command_output_with_stdin,
};
use crate::vcs::slice_context_lines;
use super::models::{
GlabApprovalState, GlabCommit, GlabDiscussion, GlabMrDetails, GlabMrSummary, GlabMrVersion,
GlabUser,
};
use crate::forge::submit::{GhSide, SubmitEvent};
use crate::forge::traits::CreateReviewRequest;
const DEFAULT_GITLAB_HOST: &str = "gitlab.com";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GlabCommandError {
MissingGlab,
Failed { status: Option<i32>, stderr: String },
}
pub type GlabCommandResult<T> = std::result::Result<T, GlabCommandError>;
pub trait GlabCommandRunner {
fn run(&self, args: &[String]) -> GlabCommandResult<String>;
fn run_with_stdin(&self, _args: &[String], _stdin: &str) -> GlabCommandResult<String> {
panic!("run_with_stdin not implemented for this GlabCommandRunner");
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemGlabRunner;
impl GlabCommandRunner for SystemGlabRunner {
fn run(&self, args: &[String]) -> GlabCommandResult<String> {
run_command_output(
"glab",
None,
args.iter().map(|arg| OsStr::new(arg.as_str())),
)
.map_err(GlabCommandError::from)
}
fn run_with_stdin(&self, args: &[String], stdin: &str) -> GlabCommandResult<String> {
run_command_output_with_stdin(
"glab",
None,
args.iter().map(|arg| OsStr::new(arg.as_str())),
stdin,
)
.map_err(GlabCommandError::from)
}
}
impl From<CommandOutputError> for GlabCommandError {
fn from(error: CommandOutputError) -> Self {
match error.kind {
CommandOutputErrorKind::NotFound => Self::MissingGlab,
CommandOutputErrorKind::SpawnFailed | CommandOutputErrorKind::Unsuccessful => {
Self::Failed {
status: error.status,
stderr: error.stderr,
}
}
}
}
}
fn read_blob_with_repo(repo_root: &Path, sha: &str, path: &Path) -> Option<String> {
let spec = format!("{}:{}", sha, path.to_string_lossy());
let exists = run_command_output(
"git",
Some(repo_root),
["cat-file", "-e", spec.as_str()]
.iter()
.map(|s| OsStr::new(*s)),
);
if exists.is_err() {
return None;
}
run_command_output(
"git",
Some(repo_root),
["show", spec.as_str()].iter().map(|s| OsStr::new(*s)),
)
.ok()
}
fn local_range_diff(repo_root: &Path, start_sha: &str, end_sha: &str) -> Option<String> {
for sha in [start_sha, end_sha] {
let exists = run_command_output(
"git",
Some(repo_root),
["cat-file", "-e", sha].iter().map(|s| OsStr::new(*s)),
);
if exists.is_err() {
return None;
}
}
let range = format!("{start_sha}..{end_sha}");
run_command_output(
"git",
Some(repo_root),
["diff", range.as_str()].iter().map(|s| OsStr::new(*s)),
)
.ok()
}
fn gl_project_path(owner: &str, name: &str) -> String {
format!("{}/{}", owner, name).replace('/', "%2F")
}
fn gl_encode_file_path(path: &str) -> String {
path.replace('/', "%2F")
.replace(' ', "%20")
.replace('#', "%23")
.replace('?', "%3F")
}
fn non_empty(value: &str) -> Option<String> {
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
fn version_head_at(versions: &[GlabMrVersion], submitted_at: &DateTime<Utc>) -> Option<String> {
versions
.iter()
.filter_map(|version| {
let created_at = version.created_at.as_ref()?;
if created_at <= submitted_at && !version.head_commit_sha.is_empty() {
Some((created_at, version.head_commit_sha.as_str()))
} else {
None
}
})
.max_by(|(a, _), (b, _)| a.cmp(b))
.map(|(_, sha)| sha.to_string())
}
#[derive(Debug, Clone)]
pub struct GitLabGlabBackend<R = SystemGlabRunner> {
default_repository: Option<ForgeRepository>,
runner: R,
local_checkout: Option<PathBuf>,
}
impl GitLabGlabBackend<SystemGlabRunner> {
pub fn new(default_repository: Option<ForgeRepository>) -> Self {
Self {
default_repository,
runner: SystemGlabRunner,
local_checkout: None,
}
}
pub fn with_local_checkout(mut self, checkout: Option<PathBuf>) -> Self {
self.local_checkout = checkout;
self
}
}
impl<R> GitLabGlabBackend<R>
where
R: GlabCommandRunner,
{
pub fn with_runner(default_repository: Option<ForgeRepository>, runner: R) -> Self {
Self {
default_repository,
runner,
local_checkout: None,
}
}
fn resolve_repository(&self, target: &PullRequestTarget) -> Result<ForgeRepository> {
target
.repository
.clone()
.or_else(|| self.default_repository.clone())
.ok_or_else(|| {
TuicrError::Forge(format!(
"GitLab merge request target `{}` does not include a repository",
target.original
))
})
}
fn run_glab(&self, args: Vec<String>, host: &str) -> Result<String> {
self.runner
.run(&args)
.map_err(|err| map_glab_error(err, host))
}
fn repo_arg(repo: &ForgeRepository) -> String {
if repo.host == DEFAULT_GITLAB_HOST {
repo.slug()
} else {
format!("https://{}/{}", repo.host, repo.slug())
}
}
fn api_hostname_args(repo: &ForgeRepository) -> Vec<String> {
if repo.host != DEFAULT_GITLAB_HOST {
vec!["--hostname".to_string(), repo.host.clone()]
} else {
vec![]
}
}
fn run_api(&self, repo: &ForgeRepository, endpoint: String) -> Result<String> {
let mut args = vec!["api".to_string()];
args.extend(Self::api_hostname_args(repo));
args.push(endpoint);
self.run_glab(args, &repo.host)
}
fn current_username(&self, repo: &ForgeRepository) -> Result<Option<String>> {
let output = self.run_api(repo, "user".to_string())?;
let user: GlabUser = serde_json::from_str(&output)?;
Ok(non_empty(&user.username))
}
fn list_mr_versions(&self, pr: &PullRequestDetails) -> Result<Vec<GlabMrVersion>> {
let project = gl_project_path(&pr.repository.owner, &pr.repository.name);
let mut versions = Vec::new();
for page in 1..=100 {
let endpoint = format!(
"projects/{}/merge_requests/{}/versions?per_page=100&page={}",
project, pr.number, page,
);
let output = self.run_api(&pr.repository, endpoint)?;
let rows: Vec<GlabMrVersion> = serde_json::from_str(&output)?;
let received = rows.len();
versions.extend(rows);
if received < 100 {
break;
}
}
Ok(versions)
}
fn list_mr_approval_records(
&self,
pr: &PullRequestDetails,
versions: &[GlabMrVersion],
) -> Result<Vec<PullRequestReviewRecord>> {
let project = gl_project_path(&pr.repository.owner, &pr.repository.name);
let endpoint = format!(
"projects/{}/merge_requests/{}/approvals",
project, pr.number
);
let output = self.run_api(&pr.repository, endpoint)?;
let approvals: GlabApprovalState = serde_json::from_str(&output)?;
Ok(approvals
.approved_by
.into_iter()
.map(|approval| {
let commit_oid = approval
.approved_at
.as_ref()
.and_then(|approved_at| version_head_at(versions, approved_at));
PullRequestReviewRecord {
author: non_empty(&approval.user.username),
submitted_at: approval.approved_at,
commit_oid,
}
})
.collect())
}
fn list_mr_discussion_review_records(
&self,
pr: &PullRequestDetails,
versions: &[GlabMrVersion],
) -> Result<Vec<PullRequestReviewRecord>> {
let project = gl_project_path(&pr.repository.owner, &pr.repository.name);
let mut records = Vec::new();
for page in 1..=100 {
let endpoint = format!(
"projects/{}/merge_requests/{}/discussions?per_page=100&page={}",
project, pr.number, page,
);
let output = self.run_api(&pr.repository, endpoint)?;
let discussions: Vec<GlabDiscussion> = serde_json::from_str(&output)?;
let received = discussions.len();
for discussion in discussions {
for note in discussion.notes {
if note.system {
continue;
}
let commit_oid = note
.position
.as_ref()
.and_then(|position| position.head_sha.as_deref())
.and_then(non_empty)
.or_else(|| note.commit_id.as_deref().and_then(non_empty))
.or_else(|| {
note.created_at
.as_ref()
.and_then(|created_at| version_head_at(versions, created_at))
});
records.push(PullRequestReviewRecord {
author: non_empty(¬e.author.username),
submitted_at: note.created_at,
commit_oid,
});
}
}
if received < 100 {
break;
}
}
Ok(records)
}
}
impl<R> ForgeBackend for GitLabGlabBackend<R>
where
R: GlabCommandRunner,
{
fn list_pull_requests(&self, query: PullRequestListQuery) -> Result<PagedPullRequests> {
let page_size = query.page_size.max(1);
let requested = query.already_loaded + page_size + 1;
let mut args = vec![
"mr".to_string(),
"list".to_string(),
"--repo".to_string(),
Self::repo_arg(&query.repository),
"--output".to_string(),
"json".to_string(),
"--per-page".to_string(),
requested.to_string(),
];
if query.scope == PullRequestListScope::ReviewRequested {
args.push("--reviewer=@me".to_string());
}
let output = self.run_glab(args, &query.repository.host)?;
let rows: Vec<GlabMrSummary> = serde_json::from_str(&output)?;
let has_more = rows.len() > query.already_loaded + page_size;
let pull_requests = rows
.into_iter()
.skip(query.already_loaded)
.take(page_size)
.map(|row| row.into_summary(&query.repository))
.collect::<Vec<_>>();
let total_loaded = query.already_loaded + pull_requests.len();
Ok(PagedPullRequests {
pull_requests,
has_more,
total_loaded,
})
}
fn get_pull_request(&self, target: PullRequestTarget) -> Result<PullRequestDetails> {
let repository = self.resolve_repository(&target)?;
let args = vec![
"mr".to_string(),
"view".to_string(),
target.number.to_string(),
"--repo".to_string(),
Self::repo_arg(&repository),
"--output".to_string(),
"json".to_string(),
];
let output = self.run_glab(args, &repository.host)?;
let mr: GlabMrDetails = serde_json::from_str(&output)?;
mr.into_details(&repository)
}
fn get_pull_request_diff(&self, pr: &PullRequestDetails) -> Result<String> {
let args = vec![
"mr".to_string(),
"diff".to_string(),
pr.number.to_string(),
"--repo".to_string(),
Self::repo_arg(&pr.repository),
"--color=never".to_string(),
];
let raw = self.run_glab(args, &pr.repository.host)?;
Ok(inject_git_diff_headers(&raw))
}
fn local_checkout_path(&self) -> Option<PathBuf> {
self.local_checkout.clone()
}
fn list_pull_request_commits(&self, pr: &PullRequestDetails) -> Result<Vec<PullRequestCommit>> {
let project = gl_project_path(&pr.repository.owner, &pr.repository.name);
let mut commits: Vec<PullRequestCommit> = Vec::new();
for page in 1..=10 {
let endpoint = format!(
"projects/{}/merge_requests/{}/commits?per_page=100&page={}",
project, pr.number, page,
);
let mut args = vec!["api".to_string()];
args.extend(Self::api_hostname_args(&pr.repository));
args.push(endpoint);
let output = self.run_glab(args, &pr.repository.host)?;
let rows: Vec<GlabCommit> = serde_json::from_str(&output)?;
let received = rows.len();
commits.extend(rows.into_iter().map(GlabCommit::into_pull_request_commit));
if received < 100 {
break;
}
}
Ok(commits)
}
fn list_pull_request_review_metadata(
&self,
pr: &PullRequestDetails,
) -> Result<PullRequestReviewMetadata> {
let viewer_login = self.current_username(&pr.repository).unwrap_or_default();
let versions = self.list_mr_versions(pr).unwrap_or_default();
let mut reviews = self
.list_mr_approval_records(pr, &versions)
.unwrap_or_default();
reviews.extend(
self.list_mr_discussion_review_records(pr, &versions)
.unwrap_or_default(),
);
Ok(PullRequestReviewMetadata {
viewer_login,
reviews,
})
}
fn get_pull_request_commit_range_diff(
&self,
_pr: &PullRequestDetails,
start_sha: &str,
end_sha: &str,
) -> Result<String> {
if let Some(root) = self.local_checkout.as_deref()
&& let Some(diff) = local_range_diff(root, start_sha, end_sha)
{
return Ok(diff);
}
Err(TuicrError::UnsupportedOperation(
"Commit range diff without local checkout not yet supported for GitLab".to_string(),
))
}
fn list_review_threads(&self, pr: &PullRequestDetails) -> Result<Vec<RemoteReviewThread>> {
let project = gl_project_path(&pr.repository.owner, &pr.repository.name);
let mut all: Vec<RemoteReviewThread> = Vec::new();
for page in 1..=100 {
let endpoint = format!(
"projects/{}/merge_requests/{}/discussions?per_page=100&page={}",
project, pr.number, page,
);
let mut args = vec!["api".to_string()];
args.extend(Self::api_hostname_args(&pr.repository));
args.push(endpoint);
let output = self.run_glab(args, &pr.repository.host)?;
if std::env::var("TUICR_GLAB_DEBUG").is_ok() {
glab_debug_log(&format!(
"[GLAB_DEBUG] list_review_threads page={page} response: {output}\n"
));
}
let discussions: Vec<GlabDiscussion> = serde_json::from_str(&output)?;
let received = discussions.len();
if std::env::var("TUICR_GLAB_DEBUG").is_ok() {
let positional = discussions
.iter()
.filter(|d| d.notes.first().and_then(|n| n.position.as_ref()).is_some())
.count();
glab_debug_log(&format!(
"[GLAB_DEBUG] list_review_threads page={page}: {received} discussions, {positional} with position\n"
));
}
all.extend(
discussions
.into_iter()
.filter_map(|d| d.into_review_thread()),
);
if received < 100 {
break;
}
}
if std::env::var("TUICR_GLAB_DEBUG").is_ok() {
glab_debug_log(&format!(
"[GLAB_DEBUG] list_review_threads total inline threads returned: {}\n",
all.len()
));
}
Ok(all)
}
fn fetch_file_lines(&self, request: ForgeFileLinesRequest) -> Result<Vec<DiffLine>> {
if request.start_line == 0 || request.start_line > request.end_line {
return Ok(Vec::new());
}
let local_content = self
.local_checkout
.as_deref()
.and_then(|root| read_blob_with_repo(root, request.sha(), request.path.as_path()));
let content = if let Some(content) = local_content {
content
} else {
self.fetch_file_via_api(&request)?
};
Ok(slice_context_lines(
&content,
request.start_line,
request.end_line,
))
}
fn file_line_count(&self, request: ForgeFileLinesRequest) -> Result<u32> {
let local_content = self
.local_checkout
.as_deref()
.and_then(|root| read_blob_with_repo(root, request.sha(), request.path.as_path()));
let content = if let Some(content) = local_content {
content
} else {
self.fetch_file_via_api(&request)?
};
Ok(content.lines().count() as u32)
}
fn create_review(
&self,
pr: &PullRequestDetails,
request: CreateReviewRequest<'_>,
) -> Result<GhCreateReviewResponse> {
match request.event {
SubmitEvent::Comment
| SubmitEvent::Approve
| SubmitEvent::RequestChanges
| SubmitEvent::Draft => {}
}
let is_draft = request.event == SubmitEvent::Draft;
let project = gl_project_path(&pr.repository.owner, &pr.repository.name);
let start_sha = pr
.diff_start_sha
.as_deref()
.unwrap_or(&pr.base_sha)
.to_string();
let mut first_discussion_id: Option<String> = None;
if !request.body.is_empty() {
let endpoint = if is_draft {
format!(
"projects/{}/merge_requests/{}/draft_notes",
project, pr.number
)
} else {
format!("projects/{}/merge_requests/{}/notes", project, pr.number)
};
let body_json = if is_draft {
serde_json::to_string(&serde_json::json!({ "note": request.body }))?
} else {
serde_json::to_string(&serde_json::json!({ "body": request.body }))?
};
let mut args = vec![
"api".to_string(),
endpoint,
"--method".to_string(),
"POST".to_string(),
"--header".to_string(),
"Content-Type: application/json".to_string(),
"--input".to_string(),
"-".to_string(),
];
args.extend(Self::api_hostname_args(&pr.repository));
self.runner
.run_with_stdin(&args, &body_json)
.map_err(|err| map_create_notes_error(err, &pr.repository.host))?;
}
for comment in request.comments {
let new_path = comment.path.to_string_lossy().replace('\\', "/");
let old_path = comment
.old_path
.as_ref()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|| new_path.clone());
let mut position = serde_json::json!({
"position_type": "text",
"base_sha": pr.base_sha,
"start_sha": start_sha,
"head_sha": pr.head_sha,
"old_path": old_path,
"new_path": new_path,
});
match comment.side {
GhSide::Right => {
position["new_line"] = serde_json::Value::Number(comment.line.into());
if let Some(old_line) = comment.counterpart_line {
position["old_line"] = serde_json::Value::Number(old_line.into());
}
}
GhSide::Left => {
position["old_line"] = serde_json::Value::Number(comment.line.into());
if let Some(new_line) = comment.counterpart_line {
position["new_line"] = serde_json::Value::Number(new_line.into());
}
}
}
if let Some(start_line) = comment.start_line {
let start_side = comment.start_side.unwrap_or(comment.side);
let start_endpoint = gl_range_endpoint(&new_path, start_side, start_line);
let end_endpoint = gl_range_endpoint(&new_path, comment.side, comment.line);
position["line_range"] = serde_json::json!({
"start": start_endpoint,
"end": end_endpoint,
});
}
let body_json = if is_draft {
serde_json::to_string(&serde_json::json!({
"note": comment.body,
"position": position,
}))?
} else {
serde_json::to_string(&serde_json::json!({
"body": comment.body,
"position": position,
}))?
};
let endpoint = if is_draft {
format!(
"projects/{}/merge_requests/{}/draft_notes",
project, pr.number,
)
} else {
format!(
"projects/{}/merge_requests/{}/discussions",
project, pr.number,
)
};
let mut args = vec![
"api".to_string(),
endpoint,
"--method".to_string(),
"POST".to_string(),
"--header".to_string(),
"Content-Type: application/json".to_string(),
"--input".to_string(),
"-".to_string(),
];
args.extend(Self::api_hostname_args(&pr.repository));
if std::env::var("TUICR_GLAB_DEBUG").is_ok() {
let host = &pr.repository.host;
let project_encoded =
format!("{}/{}", pr.repository.owner, pr.repository.name).replace('/', "%2F");
let endpoint_label = if is_draft {
"draft_notes"
} else {
"discussions"
};
let url = format!(
"https://{host}/api/v4/projects/{project_encoded}/merge_requests/{}/{endpoint_label}",
pr.number
);
glab_debug_log(&format!(
"[GLAB_DEBUG] equivalent curl command:\n curl --request POST \\\n --header 'PRIVATE-TOKEN: <token>' \\\n --header 'Content-Type: application/json' \\\n --data '{}' \\\n '{}'\n # Get token with: glab auth status -t\n",
body_json, url
));
}
let output = self
.runner
.run_with_stdin(&args, &body_json)
.map_err(|err| map_create_notes_error(err, &pr.repository.host))?;
if std::env::var("TUICR_GLAB_DEBUG").is_ok() {
glab_debug_log(&format!("[GLAB_DEBUG] glab response: {output}\n"));
}
if first_discussion_id.is_none()
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&output)
&& let Some(id) = value.get("id").and_then(|v| {
v.as_str()
.map(str::to_string)
.or_else(|| v.as_u64().map(|n| n.to_string()))
})
{
first_discussion_id = Some(id);
}
}
if request.event == SubmitEvent::Approve {
let endpoint = format!("projects/{}/merge_requests/{}/approve", project, pr.number,);
let mut args = vec![
"api".to_string(),
endpoint,
"--method".to_string(),
"POST".to_string(),
];
args.extend(Self::api_hostname_args(&pr.repository));
self.run_glab(args, &pr.repository.host)?;
}
if request.event == SubmitEvent::RequestChanges {
let full_path = format!("{}/{}", pr.repository.owner, pr.repository.name);
let query = "mutation($projectPath: ID!, $iid: String!) { \
mergeRequestRequestChanges(input: { projectPath: $projectPath, iid: $iid }) { errors } }";
let mut args = vec![
"api".to_string(),
"graphql".to_string(),
"-f".to_string(),
format!("query={query}"),
"-f".to_string(),
format!("projectPath={full_path}"),
"-f".to_string(),
format!("iid={}", pr.number),
];
args.extend(Self::api_hostname_args(&pr.repository));
let output = self.run_glab(args, &pr.repository.host)?;
check_graphql_errors(&output, "mergeRequestRequestChanges")?;
}
let id = first_discussion_id
.as_deref()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let state = if request.event == SubmitEvent::RequestChanges {
"CHANGES_REQUESTED"
} else if is_draft {
"PENDING"
} else {
"COMMENTED"
};
Ok(GhCreateReviewResponse {
id,
html_url: pr.url.clone(),
state: state.to_string(),
})
}
}
impl<R> GitLabGlabBackend<R>
where
R: GlabCommandRunner,
{
fn fetch_file_via_api(&self, request: &ForgeFileLinesRequest) -> Result<String> {
let project = gl_project_path(&request.repository.owner, &request.repository.name);
let path_str = request.path.to_string_lossy().replace('\\', "/");
let encoded_path = gl_encode_file_path(&path_str);
let endpoint = format!(
"projects/{}/repository/files/{}/raw?ref={}",
project,
encoded_path,
request.sha(),
);
let mut args = vec!["api".to_string()];
args.extend(Self::api_hostname_args(&request.repository));
args.push(endpoint);
self.run_glab(args, &request.repository.host)
}
}
pub fn parse_pull_request_target_gitlab(input: &str) -> Result<PullRequestTarget> {
let trimmed = input.trim();
if trimmed.is_empty() {
return malformed_target(input);
}
if let Some(target) = parse_numeric_target(trimmed) {
return Ok(target);
}
if let Some(target) = parse_gitlab_url_target(trimmed) {
return Ok(target);
}
if let Some(target) = parse_gitlab_repo_hash_target(trimmed) {
return Ok(target);
}
malformed_target(input)
}
pub fn parse_gitlab_remote_url(remote_url: &str) -> Option<ForgeRepository> {
let trimmed = trim_url_suffix(remote_url.trim());
if trimmed.is_empty() {
return None;
}
if let Some((host, path)) = parse_scp_like_remote(trimmed) {
let resolved = resolve_ssh_hostname(host);
if !is_gitlab_host(&resolved) {
return None;
}
return gitlab_repository_from_path(&resolved, path);
}
let without_scheme = strip_scheme(trimmed).unwrap_or(trimmed);
let without_user = without_scheme
.rsplit_once('@')
.map(|(_, rest)| rest)
.unwrap_or(without_scheme);
let (host, path) = without_user.split_once('/')?;
let host = strip_port(host);
if !is_gitlab_host(host) {
return None;
}
gitlab_repository_from_path(host, path)
}
fn is_gitlab_host(host: &str) -> bool {
if host.contains("gitlab") {
return true;
}
glab_default_host().is_some_and(|known| known.eq_ignore_ascii_case(host))
}
fn glab_default_host() -> Option<String> {
let output = run_command_output("glab", None, ["config", "get", "host"]).ok()?;
let host = output.trim();
if host.is_empty() {
None
} else {
Some(host.to_string())
}
}
fn parse_numeric_target(target: &str) -> Option<PullRequestTarget> {
if !target.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
let number = target.parse::<u64>().ok()?;
if number == 0 {
return None;
}
Some(PullRequestTarget::number(number, target))
}
fn parse_gitlab_url_target(target: &str) -> Option<PullRequestTarget> {
let without_scheme = strip_scheme(target)?;
let trimmed = trim_url_suffix(without_scheme);
let parts: Vec<&str> = trimmed.split('/').filter(|p| !p.is_empty()).collect();
if parts.len() < 6 {
return None;
}
let host = parts[0];
let dash_pos = parts[1..].iter().position(|&p| p == "-")? + 1;
if parts.get(dash_pos + 1)? != &"merge_requests" {
return None;
}
let number = parts.get(dash_pos + 2)?.parse::<u64>().ok()?;
if number == 0 {
return None;
}
let project_parts = &parts[1..dash_pos];
if project_parts.len() < 2 {
return None;
}
let (owner_parts, repo_slice) = project_parts.split_at(project_parts.len() - 1);
let owner = owner_parts.join("/");
let repo = strip_git_suffix(repo_slice[0]);
Some(PullRequestTarget::with_repository(
ForgeRepository::gitlab(host, owner, repo),
number,
target,
))
}
fn parse_gitlab_repo_hash_target(target: &str) -> Option<PullRequestTarget> {
let (repo_part, number_part) = target.split_once('#')?;
let number = number_part.parse::<u64>().ok()?;
if number == 0 {
return None;
}
let parts = repo_part
.split('/')
.filter(|p| !p.is_empty())
.collect::<Vec<_>>();
let repository = if parts.len() >= 3 && parts[0].contains('.') {
let host = parts[0];
let (owner_parts, repo_slice) = parts[1..].split_at(parts.len() - 2);
let owner = owner_parts.join("/");
ForgeRepository::gitlab(host, owner, strip_git_suffix(repo_slice[0]))
} else if parts.len() >= 2 {
let (owner_parts, repo_slice) = parts.split_at(parts.len() - 1);
let owner = owner_parts.join("/");
ForgeRepository::gitlab(DEFAULT_GITLAB_HOST, owner, strip_git_suffix(repo_slice[0]))
} else {
return None;
};
Some(PullRequestTarget::with_repository(
repository, number, target,
))
}
fn gitlab_repository_from_path(host: &str, path: &str) -> Option<ForgeRepository> {
let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
if parts.len() < 2 {
return None;
}
let (owner_parts, repo_slice) = parts.split_at(parts.len() - 1);
let owner = owner_parts.join("/");
Some(ForgeRepository::gitlab(
host,
owner,
strip_git_suffix(trim_url_suffix(repo_slice[0])),
))
}
fn strip_scheme(value: &str) -> Option<&str> {
value
.strip_prefix("https://")
.or_else(|| value.strip_prefix("http://"))
.or_else(|| value.strip_prefix("ssh://"))
}
fn trim_url_suffix(value: &str) -> &str {
value
.split(['?', '#'])
.next()
.unwrap_or(value)
.trim_end_matches('/')
}
fn strip_port(host: &str) -> &str {
match host.rsplit_once(':') {
Some((h, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => h,
_ => host,
}
}
fn strip_git_suffix(value: &str) -> &str {
value.strip_suffix(".git").unwrap_or(value)
}
fn inject_git_diff_headers(diff: &str) -> String {
let mut result = String::with_capacity(diff.len() + diff.lines().count() * 64);
let mut lines = diff.lines().peekable();
while let Some(line) = lines.next() {
if let Some(old_raw) = line.strip_prefix("--- ") {
let new_raw = lines
.peek()
.and_then(|l| l.strip_prefix("+++ "))
.unwrap_or(old_raw);
let path = if old_raw == "/dev/null" {
new_raw
} else {
old_raw
};
let path = path.trim_start_matches("a/").trim_start_matches("b/");
result.push_str(&format!("diff --git a/{path} b/{path}\n"));
}
result.push_str(line);
result.push('\n');
}
result
}
fn parse_scp_like_remote(remote_url: &str) -> Option<(&str, &str)> {
if remote_url.contains("://") {
return None;
}
let (host_part, path) = remote_url.split_once(':')?;
if host_part.contains('/') || path.is_empty() {
return None;
}
let host = host_part
.rsplit_once('@')
.map(|(_, host)| host)
.unwrap_or(host_part);
Some((host, path))
}
fn resolve_ssh_hostname(alias: &str) -> String {
let resolved = read_ssh_hostname(alias);
normalize_ssh_transport_host(&resolved).to_string()
}
fn read_ssh_hostname(alias: &str) -> String {
let Ok(home) = std::env::var("HOME") else {
return alias.to_string();
};
let path = PathBuf::from(home).join(".ssh/config");
let Ok(content) = fs::read_to_string(path) else {
return alias.to_string();
};
resolve_ssh_hostname_from_config(alias, &content)
}
fn normalize_ssh_transport_host(host: &str) -> &str {
if host.eq_ignore_ascii_case("altssh.gitlab.com") {
"gitlab.com"
} else {
host
}
}
fn resolve_ssh_hostname_from_config(alias: &str, config: &str) -> String {
let mut in_block = false;
for raw in config.lines() {
let line = raw.split_once('#').map_or(raw, |(before, _)| before).trim();
if line.is_empty() {
continue;
}
let (key, value) = line
.split_once(|c: char| c.is_whitespace() || c == '=')
.unwrap_or((line, ""));
let value = value
.trim_start_matches(|c: char| c.is_whitespace() || c == '=')
.trim();
if key.eq_ignore_ascii_case("Host") {
in_block = value.split_whitespace().any(|pat| pat == alias);
} else if key.eq_ignore_ascii_case("Match") {
in_block = false;
} else if in_block && key.eq_ignore_ascii_case("HostName") {
return value.to_string();
}
}
alias.to_string()
}
fn map_glab_error(error: GlabCommandError, host: &str) -> TuicrError {
match error {
GlabCommandError::MissingGlab => TuicrError::Forge(
"GitLab integration requires `glab`.\nInstall GitLab CLI and run `glab auth login`."
.to_string(),
),
GlabCommandError::Failed { stderr, .. } if looks_like_auth_failure(&stderr) => {
TuicrError::Forge(format!(
"GitLab authentication failed.\nRun `glab auth login` for {host}."
))
}
GlabCommandError::Failed { stderr, status } => {
let detail = if stderr.is_empty() {
status
.map(|code| format!("glab exited with status {code}"))
.unwrap_or_else(|| "glab command failed".to_string())
} else {
stderr
};
TuicrError::Forge(format!("GitLab command failed: {detail}"))
}
}
}
fn gl_line_code(file_path: &str, old_line: u32, new_line: u32) -> String {
let hash = format!("{:x}", Sha1::digest(file_path.as_bytes()));
format!("{hash}_{old_line}_{new_line}")
}
fn gl_range_endpoint(new_path: &str, side: GhSide, line: u32) -> serde_json::Value {
match side {
GhSide::Right => serde_json::json!({
"type": "new",
"new_line": line,
"line_code": gl_line_code(new_path, 0, line),
}),
GhSide::Left => serde_json::json!({
"type": "old",
"old_line": line,
"line_code": gl_line_code(new_path, line, 0),
}),
}
}
fn check_graphql_errors(output: &str, mutation: &str) -> Result<()> {
let value: serde_json::Value = match serde_json::from_str(output) {
Ok(value) => value,
Err(_) => return Ok(()),
};
let collect = |array: Option<&serde_json::Value>| -> Vec<String> {
array
.and_then(|v| v.as_array())
.map(|items| {
items
.iter()
.map(|item| {
if let Some(s) = item.as_str() {
s.to_string()
} else if let Some(message) = item.get("message").and_then(|m| m.as_str()) {
message.to_string()
} else {
item.to_string()
}
})
.collect()
})
.unwrap_or_default()
};
let mut messages = collect(value.get("errors"));
messages.extend(collect(value.pointer(&format!("/data/{mutation}/errors"))));
if messages.is_empty() {
Ok(())
} else {
Err(TuicrError::Forge(format!(
"GitLab request-changes failed: {}",
messages.join(", ")
)))
}
}
fn map_create_notes_error(error: GlabCommandError, host: &str) -> TuicrError {
if let GlabCommandError::Failed { ref stderr, .. } = error
&& looks_like_permission_failure(stderr)
{
return TuicrError::Forge(
"Cannot submit review: GitLab token lacks merge request write permission.".to_string(),
);
}
map_glab_error(error, host)
}
fn looks_like_auth_failure(stderr: &str) -> bool {
let lower = stderr.to_ascii_lowercase();
lower.contains("glab auth login")
|| lower.contains("not logged in")
|| lower.contains("authentication failed")
|| lower.contains("requires authentication")
|| lower.contains("401 unauthorized")
}
fn looks_like_permission_failure(stderr: &str) -> bool {
let lower = stderr.to_ascii_lowercase();
lower.contains("403 forbidden")
|| lower.contains("http 403")
|| lower.contains("status: 403")
|| lower.contains("not allowed")
}
fn malformed_target<T>(input: &str) -> Result<T> {
Err(TuicrError::Forge(format!(
"Malformed GitLab merge request target: `{input}`"
)))
}
fn glab_debug_log(msg: &str) {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("/tmp/tuicr-glab-debug.log")
{
let _ = f.write_all(msg.as_bytes());
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use super::*;
use crate::forge::submit::{GhSide, InlineComment};
use crate::forge::traits::{
CreateReviewRequest, ForgeRepository, PullRequestDetails, PullRequestListQuery,
PullRequestListScope,
};
struct RecordingRunner {
calls: RefCell<Vec<(Vec<String>, Option<String>)>>,
responses: RefCell<Vec<String>>,
}
impl RecordingRunner {
fn new_with_responses(responses: Vec<String>) -> Self {
Self {
calls: RefCell::new(Vec::new()),
responses: RefCell::new(responses),
}
}
}
impl GlabCommandRunner for RecordingRunner {
fn run(&self, args: &[String]) -> GlabCommandResult<String> {
self.calls.borrow_mut().push((args.to_vec(), None));
let resp = self
.responses
.borrow_mut()
.drain(..1)
.next()
.unwrap_or_default();
Ok(resp)
}
fn run_with_stdin(&self, args: &[String], stdin: &str) -> GlabCommandResult<String> {
self.calls
.borrow_mut()
.push((args.to_vec(), Some(stdin.to_string())));
let resp = self
.responses
.borrow_mut()
.drain(..1)
.next()
.unwrap_or_default();
Ok(resp)
}
}
#[test]
fn list_pull_requests_can_filter_to_review_requested() {
let repo = ForgeRepository::gitlab("gitlab.com", "owner", "repo");
let runner = RecordingRunner::new_with_responses(vec!["[]".to_string()]);
let backend = GitLabGlabBackend::with_runner(None, runner);
backend
.list_pull_requests(PullRequestListQuery::first_page_with_scope(
repo,
1,
PullRequestListScope::ReviewRequested,
))
.unwrap();
let calls = backend.runner.calls.borrow();
assert_eq!(
calls[0].0,
vec![
"mr",
"list",
"--repo",
"owner/repo",
"--output",
"json",
"--per-page",
"2",
"--reviewer=@me",
]
);
}
#[test]
fn should_list_pull_request_review_metadata_for_gitlab() {
let repo = ForgeRepository::gitlab("gitlab.com", "owner", "repo");
let pr = make_pr_details(repo.clone());
let runner = RecordingRunner::new_with_responses(vec![
r#"{ "username": "ronen-hoffer", "name": "Ronen" }"#.to_string(),
r#"[
{
"head_commit_sha": "ccccccc3333333333333333333333333333cccc",
"created_at": "2026-06-03T09:00:00Z"
},
{
"head_commit_sha": "bbbbbbb2222222222222222222222222222bbbb",
"created_at": "2026-06-02T09:00:00Z"
}
]"#
.to_string(),
r#"{
"approved_by": [
{
"user": { "username": "ronen-hoffer", "name": "Ronen" },
"approved_at": "2026-06-02T12:00:00Z"
}
]
}"#
.to_string(),
r#"[
{
"id": "disc-1",
"individual_note": false,
"notes": [
{
"id": 100,
"type": "DiffNote",
"body": "left a comment",
"author": { "username": "alice", "name": "Alice" },
"created_at": "2026-06-01T10:00:00Z",
"system": false,
"position": {
"position_type": "text",
"head_sha": "aaaaaaa1111111111111111111111111111aaaa",
"new_path": "src/lib.rs",
"new_line": 12
}
},
{
"id": 101,
"type": "DiffNote",
"body": "my latest comment",
"author": { "username": "ronen-hoffer", "name": "Ronen" },
"created_at": "2026-06-03T10:00:00Z",
"system": false,
"commit_id": "ccccccc3333333333333333333333333333cccc",
"position": {
"position_type": "text",
"head_sha": "ccccccc3333333333333333333333333333cccc",
"new_path": "src/lib.rs",
"new_line": 18
}
}
]
}
]"#
.to_string(),
]);
let backend = GitLabGlabBackend::with_runner(Some(repo), runner);
let metadata = backend.list_pull_request_review_metadata(&pr).unwrap();
assert_eq!(metadata.viewer_login.as_deref(), Some("ronen-hoffer"));
assert_eq!(metadata.reviews.len(), 3);
assert_eq!(
metadata.reviews[0].commit_oid.as_deref(),
Some("bbbbbbb2222222222222222222222222222bbbb")
);
assert_eq!(metadata.reviews[2].author.as_deref(), Some("ronen-hoffer"));
assert_eq!(
metadata.reviews[2].commit_oid.as_deref(),
Some("ccccccc3333333333333333333333333333cccc")
);
let calls = backend.runner.calls.borrow();
assert!(calls.iter().any(|(args, _)| args == &["api", "user"]));
assert!(calls.iter().any(|(args, _)| {
args.iter()
.any(|arg| arg.contains("/versions?per_page=100&page=1"))
}));
assert!(
calls
.iter()
.any(|(args, _)| args.iter().any(|arg| arg.contains("/approvals")))
);
assert!(calls.iter().any(|(args, _)| {
args.iter()
.any(|arg| arg.contains("/discussions?per_page=100&page=1"))
}));
}
fn make_pr_details(repo: ForgeRepository) -> PullRequestDetails {
PullRequestDetails {
repository: repo,
number: 42,
title: "Test MR".to_string(),
url: "https://gitlab.com/owner/repo/-/merge_requests/42".to_string(),
state: "OPEN".to_string(),
is_draft: false,
author: None,
head_ref_name: "feature".to_string(),
base_ref_name: "main".to_string(),
head_sha: "headsha1".to_string(),
base_sha: "basesha1".to_string(),
body: String::new(),
updated_at: None,
closed: false,
merged_at: None,
diff_start_sha: Some("startsha1".to_string()),
}
}
#[test]
fn should_send_inline_comment_as_json_with_integer_line_number() {
let repo = ForgeRepository::gitlab("gitlab.com", "owner", "repo");
let pr = make_pr_details(repo.clone());
let inline = InlineComment {
path: "src/lib.rs".into(),
line: 15,
side: GhSide::Right,
counterpart_line: None,
start_line: None,
start_side: None,
old_path: None,
body: "nice work".to_string(),
comment_id: "c1".to_string(),
};
let response = r#"{"id":"disc-abc","individual_note":false}"#.to_string();
let runner = RecordingRunner::new_with_responses(vec![response]);
let backend = GitLabGlabBackend::with_runner(Some(repo), runner);
let request = CreateReviewRequest {
event: crate::forge::submit::SubmitEvent::Comment,
commit_id: "headsha1",
body: "",
comments: &[inline],
};
backend.create_review(&pr, request).unwrap();
let calls = backend.runner.calls.borrow();
assert_eq!(calls.len(), 1);
let (args, stdin) = &calls[0];
assert!(
args.contains(&"--input".to_string()),
"expected --input flag"
);
assert!(args.contains(&"-".to_string()), "expected - stdin flag");
assert!(
args.contains(&"Content-Type: application/json".to_string()),
"expected JSON Content-Type"
);
let body: serde_json::Value = serde_json::from_str(stdin.as_ref().unwrap()).unwrap();
let position = &body["position"];
assert_eq!(
position["new_line"],
serde_json::Value::Number(15.into()),
"new_line must be integer 15"
);
assert_eq!(
position["old_line"],
serde_json::Value::Null,
"old_line absent or null for Right-side comment"
);
assert_eq!(position["position_type"], "text");
assert_eq!(position["base_sha"], "basesha1");
assert_eq!(position["start_sha"], "startsha1");
assert_eq!(position["head_sha"], "headsha1");
assert_eq!(position["new_path"], "src/lib.rs");
assert_eq!(position["old_path"], "src/lib.rs");
assert_eq!(body["body"], "nice work");
}
#[test]
fn should_send_left_side_inline_comment_with_old_line() {
let repo = ForgeRepository::gitlab("gitlab.com", "owner", "repo");
let pr = make_pr_details(repo.clone());
let inline = InlineComment {
path: "src/main.rs".into(),
line: 7,
side: GhSide::Left,
counterpart_line: None,
start_line: None,
start_side: None,
old_path: None,
body: "old code".to_string(),
comment_id: "c2".to_string(),
};
let response = r#"{"id":"disc-def","individual_note":false}"#.to_string();
let runner = RecordingRunner::new_with_responses(vec![response]);
let backend = GitLabGlabBackend::with_runner(Some(repo), runner);
let request = CreateReviewRequest {
event: crate::forge::submit::SubmitEvent::Comment,
commit_id: "headsha1",
body: "",
comments: &[inline],
};
backend.create_review(&pr, request).unwrap();
let calls = backend.runner.calls.borrow();
assert_eq!(calls.len(), 1);
let (_, stdin) = &calls[0];
let body: serde_json::Value = serde_json::from_str(stdin.as_ref().unwrap()).unwrap();
let position = &body["position"];
assert_eq!(
position["old_line"],
serde_json::Value::Number(7.into()),
"old_line must be integer 7"
);
assert!(
position.get("new_line").is_none() || position["new_line"] == serde_json::Value::Null,
"new_line should be absent for Left-side comment"
);
}
#[test]
fn should_send_line_range_for_multi_line_range_comment() {
let repo = ForgeRepository::gitlab("gitlab.com", "owner", "repo");
let pr = make_pr_details(repo.clone());
let inline = InlineComment {
path: "src/lib.rs".into(),
line: 30,
side: GhSide::Right,
counterpart_line: None,
start_line: Some(25),
start_side: Some(GhSide::Right),
old_path: None,
body: "range comment".to_string(),
comment_id: "c-range".to_string(),
};
let response = r#"{"id":"disc-range","individual_note":false}"#.to_string();
let runner = RecordingRunner::new_with_responses(vec![response]);
let backend = GitLabGlabBackend::with_runner(Some(repo), runner);
let request = CreateReviewRequest {
event: crate::forge::submit::SubmitEvent::Comment,
commit_id: "headsha1",
body: "",
comments: &[inline],
};
backend.create_review(&pr, request).unwrap();
let calls = backend.runner.calls.borrow();
let (_, stdin) = &calls[0];
let body: serde_json::Value = serde_json::from_str(stdin.as_ref().unwrap()).unwrap();
let position = &body["position"];
assert_eq!(position["line_range"]["start"]["type"], "new");
assert_eq!(
position["line_range"]["start"]["new_line"],
serde_json::Value::Number(25.into())
);
assert_eq!(position["line_range"]["end"]["type"], "new");
assert_eq!(
position["line_range"]["end"]["new_line"],
serde_json::Value::Number(30.into())
);
assert!(position["line_range"]["start"]["line_code"].is_string());
assert!(position["line_range"]["end"]["line_code"].is_string());
}
#[test]
fn should_send_distinct_old_and_new_path_for_renamed_file() {
let repo = ForgeRepository::gitlab("gitlab.com", "owner", "repo");
let pr = make_pr_details(repo.clone());
let inline = InlineComment {
path: "src/new_name.rs".into(),
line: 5,
side: GhSide::Right,
counterpart_line: None,
start_line: None,
start_side: None,
old_path: Some("src/old_name.rs".into()),
body: "renamed file comment".to_string(),
comment_id: "c-rename".to_string(),
};
let response = r#"{"id":"disc-rename","individual_note":false}"#.to_string();
let runner = RecordingRunner::new_with_responses(vec![response]);
let backend = GitLabGlabBackend::with_runner(Some(repo), runner);
let request = CreateReviewRequest {
event: crate::forge::submit::SubmitEvent::Comment,
commit_id: "headsha1",
body: "",
comments: &[inline],
};
backend.create_review(&pr, request).unwrap();
let calls = backend.runner.calls.borrow();
let (_, stdin) = &calls[0];
let body: serde_json::Value = serde_json::from_str(stdin.as_ref().unwrap()).unwrap();
let position = &body["position"];
assert_eq!(position["old_path"], "src/old_name.rs");
assert_eq!(position["new_path"], "src/new_name.rs");
}
#[test]
fn should_send_both_line_numbers_for_context_line() {
let repo = ForgeRepository::gitlab("gitlab.com", "owner", "repo");
let pr = make_pr_details(repo.clone());
let inline = InlineComment {
path: "src/lib.rs".into(),
line: 20,
side: GhSide::Right,
counterpart_line: Some(18), start_line: None,
start_side: None,
old_path: None,
body: "context comment".to_string(),
comment_id: "c3".to_string(),
};
let response = r#"{"id":"disc-ctx","individual_note":false}"#.to_string();
let runner = RecordingRunner::new_with_responses(vec![response]);
let backend = GitLabGlabBackend::with_runner(Some(repo), runner);
let request = CreateReviewRequest {
event: crate::forge::submit::SubmitEvent::Comment,
commit_id: "headsha1",
body: "",
comments: &[inline],
};
backend.create_review(&pr, request).unwrap();
let calls = backend.runner.calls.borrow();
let (_, stdin) = &calls[0];
let body: serde_json::Value = serde_json::from_str(stdin.as_ref().unwrap()).unwrap();
let position = &body["position"];
assert_eq!(
position["new_line"],
serde_json::Value::Number(20.into()),
"new_line must be the primary line"
);
assert_eq!(
position["old_line"],
serde_json::Value::Number(18.into()),
"old_line must be the counterpart for context lines"
);
}
#[test]
fn should_call_approve_endpoint_for_approve() {
let repo = ForgeRepository::gitlab("gitlab.com", "owner", "repo");
let pr = make_pr_details(repo.clone());
let inline = InlineComment {
path: "src/lib.rs".into(),
line: 15,
side: GhSide::Right,
counterpart_line: None,
start_line: None,
start_side: None,
old_path: None,
body: "looks good".to_string(),
comment_id: "c-approve".to_string(),
};
let runner = RecordingRunner::new_with_responses(vec![
r#"{"id":"disc-approve","individual_note":false}"#.to_string(),
String::new(),
]);
let backend = GitLabGlabBackend::with_runner(Some(repo), runner);
let request = CreateReviewRequest {
event: crate::forge::submit::SubmitEvent::Approve,
commit_id: "headsha1",
body: "",
comments: &[inline],
};
let response = backend.create_review(&pr, request).unwrap();
assert_eq!(response.state, "COMMENTED");
let calls = backend.runner.calls.borrow();
assert_eq!(calls.len(), 2);
let approve_args = &calls[1].0;
assert!(
approve_args
.iter()
.any(|a| a == "projects/owner%2Frepo/merge_requests/42/approve"),
"expected approve endpoint, got {approve_args:?}"
);
}
#[test]
fn should_post_comments_then_request_changes_mutation() {
let repo = ForgeRepository::gitlab("gitlab.com", "owner", "repo");
let pr = make_pr_details(repo.clone());
let inline = InlineComment {
path: "src/lib.rs".into(),
line: 15,
side: GhSide::Right,
counterpart_line: None,
start_line: None,
start_side: None,
old_path: None,
body: "please change this".to_string(),
comment_id: "c-rc".to_string(),
};
let runner = RecordingRunner::new_with_responses(vec![
r#"{"id":1}"#.to_string(),
r#"{"id":"disc-rc","individual_note":false}"#.to_string(),
r#"{"data":{"mergeRequestRequestChanges":{"errors":[]}}}"#.to_string(),
]);
let backend = GitLabGlabBackend::with_runner(Some(repo), runner);
let request = CreateReviewRequest {
event: crate::forge::submit::SubmitEvent::RequestChanges,
commit_id: "headsha1",
body: "overall please address the comments",
comments: &[inline],
};
let response = backend.create_review(&pr, request).unwrap();
assert_eq!(response.state, "CHANGES_REQUESTED");
let calls = backend.runner.calls.borrow();
assert_eq!(calls.len(), 3);
assert!(
calls[0]
.0
.iter()
.any(|a| a == "projects/owner%2Frepo/merge_requests/42/notes"),
"expected body note endpoint, got {:?}",
calls[0].0
);
assert!(
calls[1]
.0
.iter()
.any(|a| a == "projects/owner%2Frepo/merge_requests/42/discussions"),
"expected discussion endpoint, got {:?}",
calls[1].0
);
let graphql_args = &calls[2].0;
assert_eq!(graphql_args[0], "api");
assert_eq!(graphql_args[1], "graphql");
assert!(
graphql_args
.iter()
.any(|a| a.contains("mergeRequestRequestChanges")),
"expected mergeRequestRequestChanges in query, got {graphql_args:?}"
);
assert!(
graphql_args.iter().any(|a| a == "projectPath=owner/repo"),
"expected un-encoded projectPath, got {graphql_args:?}"
);
assert!(
graphql_args.iter().any(|a| a == "iid=42"),
"expected iid=42, got {graphql_args:?}"
);
}
#[test]
fn should_error_when_request_changes_mutation_returns_errors() {
let repo = ForgeRepository::gitlab("gitlab.com", "owner", "repo");
let pr = make_pr_details(repo.clone());
let inline = InlineComment {
path: "src/lib.rs".into(),
line: 15,
side: GhSide::Right,
counterpart_line: None,
start_line: None,
start_side: None,
old_path: None,
body: "please change this".to_string(),
comment_id: "c-rc-err".to_string(),
};
let runner = RecordingRunner::new_with_responses(vec![
r#"{"id":"disc-rc","individual_note":false}"#.to_string(),
r#"{"data":{"mergeRequestRequestChanges":{"errors":["You are not a reviewer"]}}}"#
.to_string(),
]);
let backend = GitLabGlabBackend::with_runner(Some(repo), runner);
let request = CreateReviewRequest {
event: crate::forge::submit::SubmitEvent::RequestChanges,
commit_id: "headsha1",
body: "",
comments: &[inline],
};
let err = backend.create_review(&pr, request).unwrap_err();
match err {
TuicrError::Forge(msg) => {
assert!(
msg.contains("You are not a reviewer"),
"expected reviewer error message, got {msg}"
);
}
other => panic!("expected TuicrError::Forge, got {other:?}"),
}
}
#[test]
fn check_graphql_errors_extracts_message_from_top_level_error_objects() {
let output = r#"{"errors":[{"message":"Authentication required"}]}"#;
let err = check_graphql_errors(output, "mergeRequestRequestChanges").unwrap_err();
match err {
TuicrError::Forge(msg) => assert!(
msg.contains("Authentication required") && !msg.contains('{'),
"expected clean message, got {msg}"
),
other => panic!("expected TuicrError::Forge, got {other:?}"),
}
}
#[test]
fn check_graphql_errors_ok_on_empty_errors_and_non_json() {
check_graphql_errors(r#"{"data":{"m":{"errors":[]}}}"#, "m").unwrap();
check_graphql_errors("not json", "m").unwrap();
}
#[test]
fn should_post_draft_notes_for_draft_submission() {
let repo = ForgeRepository::gitlab("gitlab.com", "owner", "repo");
let pr = make_pr_details(repo.clone());
let inline = InlineComment {
path: "src/lib.rs".into(),
line: 15,
side: GhSide::Right,
counterpart_line: Some(12),
start_line: None,
start_side: None,
old_path: None,
body: "inline draft".to_string(),
comment_id: "c1".to_string(),
};
let runner = RecordingRunner::new_with_responses(vec![
r#"{"id":10}"#.to_string(),
r#"{"id":22}"#.to_string(),
]);
let backend = GitLabGlabBackend::with_runner(Some(repo), runner);
let request = CreateReviewRequest {
event: crate::forge::submit::SubmitEvent::Draft,
commit_id: "headsha1",
body: "overall draft",
comments: &[inline],
};
let response = backend.create_review(&pr, request).unwrap();
assert_eq!(response.state, "PENDING");
assert_eq!(response.id, 22, "inline draft note id should be reported");
let calls = backend.runner.calls.borrow();
assert_eq!(calls.len(), 2, "expected one body note and one inline note");
for (args, _) in calls.iter() {
let endpoint = &args[1];
assert!(
endpoint.ends_with("/draft_notes"),
"expected draft_notes endpoint, got {endpoint}"
);
assert!(
!endpoint.contains("discussions"),
"draft path must not hit discussions"
);
assert!(
!args.iter().any(|a| a.contains("approve") || a == "graphql"),
"draft path must not approve or run graphql"
);
}
let (_, body_stdin) = &calls[0];
let body: serde_json::Value = serde_json::from_str(body_stdin.as_ref().unwrap()).unwrap();
assert_eq!(body["note"], "overall draft");
assert!(
body.get("body").is_none(),
"draft must use `note`, not `body`"
);
assert!(body.get("position").is_none(), "body note has no position");
let (_, inline_stdin) = &calls[1];
let inline: serde_json::Value =
serde_json::from_str(inline_stdin.as_ref().unwrap()).unwrap();
assert_eq!(inline["note"], "inline draft");
assert!(
inline.get("body").is_none(),
"inline draft must use `note`, not `body`"
);
let position = &inline["position"];
assert_eq!(position["new_line"], serde_json::Value::Number(15.into()));
assert_eq!(position["old_line"], serde_json::Value::Number(12.into()));
}
#[test]
fn should_not_publish_draft_notes() {
let repo = ForgeRepository::gitlab("gitlab.com", "owner", "repo");
let pr = make_pr_details(repo.clone());
let inline = InlineComment {
path: "src/lib.rs".into(),
line: 15,
side: GhSide::Right,
counterpart_line: None,
start_line: None,
start_side: None,
old_path: None,
body: "inline draft".to_string(),
comment_id: "c1".to_string(),
};
let runner = RecordingRunner::new_with_responses(vec![
r#"{"id":1}"#.to_string(),
r#"{"id":2}"#.to_string(),
]);
let backend = GitLabGlabBackend::with_runner(Some(repo), runner);
let request = CreateReviewRequest {
event: crate::forge::submit::SubmitEvent::Draft,
commit_id: "headsha1",
body: "overall draft",
comments: &[inline],
};
backend.create_review(&pr, request).unwrap();
let calls = backend.runner.calls.borrow();
for (args, _) in calls.iter() {
assert!(
!args
.iter()
.any(|a| a.contains("bulk_publish") || a.contains("/publish")),
"draft path must not publish"
);
}
}
#[test]
fn should_post_comment_submission_to_notes_and_discussions() {
let repo = ForgeRepository::gitlab("gitlab.com", "owner", "repo");
let pr = make_pr_details(repo.clone());
let inline = InlineComment {
path: "src/lib.rs".into(),
line: 15,
side: GhSide::Right,
counterpart_line: None,
start_line: None,
start_side: None,
old_path: None,
body: "inline comment".to_string(),
comment_id: "c1".to_string(),
};
let runner = RecordingRunner::new_with_responses(vec![
String::new(),
r#"{"id":"disc-abc"}"#.to_string(),
]);
let backend = GitLabGlabBackend::with_runner(Some(repo), runner);
let request = CreateReviewRequest {
event: crate::forge::submit::SubmitEvent::Comment,
commit_id: "headsha1",
body: "overall comment",
comments: &[inline],
};
let response = backend.create_review(&pr, request).unwrap();
assert_eq!(response.state, "COMMENTED");
let calls = backend.runner.calls.borrow();
assert_eq!(calls.len(), 2);
assert!(calls[0].0[1].ends_with("/notes"));
assert!(calls[1].0[1].ends_with("/discussions"));
for (args, _) in calls.iter() {
assert!(
!args.iter().any(|a| a.contains("draft_notes")),
"comment path must not hit draft_notes"
);
}
}
#[test]
fn should_parse_gitlab_https_remote_url() {
let repo = parse_gitlab_remote_url("https://gitlab.com/owner/repo.git").unwrap();
assert_eq!(repo, ForgeRepository::gitlab("gitlab.com", "owner", "repo"));
}
#[test]
fn should_parse_gitlab_ssh_remote_url() {
let repo = parse_gitlab_remote_url("git@gitlab.com:owner/repo.git").unwrap();
assert_eq!(repo, ForgeRepository::gitlab("gitlab.com", "owner", "repo"));
}
#[test]
fn should_parse_gitlab_self_hosted_https() {
let repo = parse_gitlab_remote_url("https://gitlab.example.com/owner/repo.git").unwrap();
assert_eq!(
repo,
ForgeRepository::gitlab("gitlab.example.com", "owner", "repo")
);
}
#[test]
fn should_ignore_github_remote_url() {
assert!(parse_gitlab_remote_url("https://github.com/owner/repo.git").is_none());
assert!(parse_gitlab_remote_url("git@github.com:owner/repo.git").is_none());
}
#[test]
fn should_strip_ssh_port_from_self_hosted_ssh_scheme_remote() {
let repo = parse_gitlab_remote_url(
"ssh://git@gitlab.example.com:2222/engineering/platform/widget-service.git",
)
.unwrap();
assert_eq!(repo.host, "gitlab.example.com");
assert_eq!(repo.owner, "engineering/platform");
assert_eq!(repo.name, "widget-service");
assert_eq!(
GitLabGlabBackend::<SystemGlabRunner>::repo_arg(&repo),
"https://gitlab.example.com/engineering/platform/widget-service"
);
}
#[test]
fn repo_arg_uses_full_url_for_non_default_host() {
let repo = ForgeRepository::gitlab("git.example.com", "group/team", "project");
assert_eq!(
GitLabGlabBackend::<SystemGlabRunner>::repo_arg(&repo),
"https://git.example.com/group/team/project"
);
}
#[test]
fn repo_arg_uses_slug_for_default_host() {
let repo = ForgeRepository::gitlab("gitlab.com", "owner", "repo");
assert_eq!(
GitLabGlabBackend::<SystemGlabRunner>::repo_arg(&repo),
"owner/repo"
);
}
#[test]
fn should_parse_gitlab_nested_group_remote_url() {
let repo = parse_gitlab_remote_url("git@gitlab.com:technosylva/ai/synapse.git").unwrap();
assert_eq!(repo.owner, "technosylva/ai");
assert_eq!(repo.name, "synapse");
assert_eq!(repo.slug(), "technosylva/ai/synapse");
}
#[test]
fn should_parse_gitlab_mr_url_target() {
let target =
parse_pull_request_target_gitlab("https://gitlab.com/owner/repo/-/merge_requests/42")
.unwrap();
assert_eq!(target.number, 42);
let repo = target.repository.unwrap();
assert_eq!(repo.host, "gitlab.com");
assert_eq!(repo.owner, "owner");
assert_eq!(repo.name, "repo");
assert_eq!(repo.kind, crate::forge::traits::ForgeKind::GitLab);
}
#[test]
fn should_parse_gitlab_nested_group_url_target() {
let target = parse_pull_request_target_gitlab(
"https://gitlab.com/technosylva/ai/synapse/-/merge_requests/33",
)
.unwrap();
assert_eq!(target.number, 33);
let repo = target.repository.unwrap();
assert_eq!(repo.host, "gitlab.com");
assert_eq!(repo.owner, "technosylva/ai");
assert_eq!(repo.name, "synapse");
assert_eq!(repo.slug(), "technosylva/ai/synapse");
}
#[test]
fn should_parse_gitlab_repo_hash_target() {
let target = parse_pull_request_target_gitlab("owner/repo#42").unwrap();
assert_eq!(target.number, 42);
let repo = target.repository.unwrap();
assert_eq!(repo.host, DEFAULT_GITLAB_HOST);
assert_eq!(repo.owner, "owner");
assert_eq!(repo.name, "repo");
}
#[test]
fn should_parse_gitlab_numeric_target() {
let target = parse_pull_request_target_gitlab("123").unwrap();
assert_eq!(target.number, 123);
assert!(target.repository.is_none());
}
#[test]
fn normalizes_gitlab_ssh_over_443_transport_host() {
assert_eq!(
normalize_ssh_transport_host("altssh.gitlab.com"),
"gitlab.com"
);
assert_eq!(
normalize_ssh_transport_host("AltSSH.GitLab.com"),
"gitlab.com"
);
assert_eq!(normalize_ssh_transport_host("gitlab.com"), "gitlab.com");
assert_eq!(
normalize_ssh_transport_host("gitlab.example.com"),
"gitlab.example.com"
);
}
#[test]
fn should_reject_empty_target() {
assert!(parse_pull_request_target_gitlab("").is_err());
assert!(parse_pull_request_target_gitlab(" ").is_err());
}
}