use crate::error::{GwmError, Result};
use crate::forge::{
self, CheckOutcome, CreatedIssue, CreatedPr, Forge, ForgeKind, IssueCreateRequest, IssueState, IssueStatus, PrCheck,
PrCreateRequest, PrHead, PrState, PrStatus,
};
use crate::labels::{LabelSpec, RemoteLabel};
use crate::milestones::{self, MilestoneSpec, MilestoneState, RemoteMilestone};
use serde::Deserialize;
use std::ffi::OsString;
pub fn glab_program() -> OsString {
std::env::var_os("GWM_GLAB").unwrap_or_else(|| "glab".into())
}
pub fn glab_env_remove(origin: &forge::RemoteRef, has_workdir: bool) -> Vec<&'static str> {
if origin.path.is_empty() && !has_workdir {
return Vec::new();
}
let mut vars = vec![
"GITLAB_REPO",
"GITLAB_GROUP",
"REMOTE_ALIAS",
"GIT_REMOTE_ALIAS",
"REMOTE_NICKNAME",
"GIT_REMOTE_NICKNAME",
"GIT_REMOTE_URL_VAR",
"GITLAB_API_HOST",
"API_PROTOCOL",
];
if !glab_env(origin).is_empty() {
vars.push("GITLAB_URI");
vars.push("GL_HOST");
}
vars
}
pub fn glab_env(origin: &forge::RemoteRef) -> Vec<(String, String)> {
if origin.trust != forge::OriginTrust::FromUrl || origin.path.is_empty() {
return Vec::new();
}
let mut env = vec![("GITLAB_HOST".to_string(), origin.web_origin.clone())];
if let (Some(scheme), _) = origin_scheme_and_port(origin) {
env.push(("API_PROTOCOL".to_string(), scheme));
}
env
}
fn ci_autologin_active() -> bool {
std::env::var("GLAB_ENABLE_CI_AUTOLOGIN").as_deref() == Ok("true")
&& std::env::var("GITLAB_CI").as_deref() == Ok("true")
}
pub fn ci_autologin_conflict(origin: &forge::RemoteRef) -> Option<String> {
if !ci_autologin_active() {
return None;
}
if origin.trust != forge::OriginTrust::FromUrl {
let ssh_host = non_empty(std::env::var("CI_SERVER_SHELL_SSH_HOST").ok())?;
if ssh_host.eq_ignore_ascii_case(&origin.host) {
return None;
}
return Some(format!(
"refusing to run glab: CI auto-login would authenticate against the instance at \
'{ssh_host}' but this repo's origin is '{}'. glab ignores GITLAB_HOST in that \
mode, so the call would target the wrong instance. Unset \
GLAB_ENABLE_CI_AUTOLOGIN to proceed.",
origin.host
));
}
let (url_host, url_port, url_scheme) = match std::env::var("CI_SERVER_URL").ok() {
Some(u) => split_origin_url(u.trim()),
None => (None, None, None),
};
let (fqdn_host, fqdn_port) = match std::env::var("CI_SERVER_FQDN").ok() {
Some(f) => split_host_port(f.trim()),
None => (None, None),
};
let ci_host = non_empty(std::env::var("CI_SERVER_HOST").ok())
.or(url_host)
.or(fqdn_host)?;
let ci_scheme = non_empty(std::env::var("CI_SERVER_PROTOCOL").ok()).or(url_scheme);
let ci_port = non_empty(std::env::var("CI_SERVER_PORT").ok())
.or(url_port)
.or(fqdn_port);
let ci = (
ci_host.to_ascii_lowercase(),
effective_port(ci_port.as_deref(), ci_scheme.as_deref()),
);
let (origin_scheme, origin_port) = origin_scheme_and_port(origin);
let ours = (
origin.host.to_ascii_lowercase(),
effective_port(origin_port.as_deref(), origin_scheme.as_deref()),
);
let host_differs = ci.0 != ours.0;
let port_differs = matches!((&ci.1, &ours.1), (Some(a), Some(b)) if a != b);
if !host_differs && !port_differs {
return None;
}
Some(format!(
"refusing to run glab: CI auto-login would authenticate against '{}' but this \
repo's origin is '{}'. glab ignores GITLAB_HOST in that mode, so the call would \
target the wrong instance. Unset GLAB_ENABLE_CI_AUTOLOGIN to proceed.",
render(&ci),
render(&ours),
))
}
fn non_empty(v: Option<String>) -> Option<String> {
v.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
fn render((host, port): &(String, Option<String>)) -> String {
match port {
Some(p) => format!("{host}:{p}"),
None => host.clone(),
}
}
fn effective_port(port: Option<&str>, scheme: Option<&str>) -> Option<String> {
if let Some(p) = port {
return Some(p.to_string());
}
match scheme.map(|s| s.to_ascii_lowercase()).as_deref() {
Some("https") => Some("443".into()),
Some("http") => Some("80".into()),
_ => None,
}
}
fn origin_scheme_and_port(origin: &forge::RemoteRef) -> (Option<String>, Option<String>) {
let (_, port, scheme) = split_origin_url(&origin.web_origin);
(scheme, port)
}
fn split_origin_url(url: &str) -> (Option<String>, Option<String>, Option<String>) {
let (scheme, rest) = match url.split_once("://") {
Some((s, r)) => (Some(s.to_string()), r),
None => (None, url),
};
let rest = rest.trim_end_matches('/');
let rest = rest.split('/').next().unwrap_or(rest);
let (host, port) = split_host_port(rest);
(host, port, scheme)
}
fn split_host_port(s: &str) -> (Option<String>, Option<String>) {
if s.is_empty() {
return (None, None);
}
match s.rsplit_once(':') {
Some((h, p)) if !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()) => {
(Some(h.to_string()), Some(p.to_string()))
}
_ => (Some(s.to_string()), None),
}
}
fn resolve_selector(origin: &forge::RemoteRef, has_workdir: bool) -> String {
if origin.trust != forge::OriginTrust::FromUrl && has_workdir {
return String::new();
}
let Some(sub) = subfolder_from_env() else {
return origin.path.clone();
};
origin
.path
.strip_prefix(&format!("{sub}/"))
.unwrap_or(&origin.path)
.to_string()
}
fn subfolder_from_env() -> Option<String> {
let trimmed = |v: String| {
let s = v.trim().trim_matches('/').to_string();
(!s.is_empty()).then_some(s)
};
if let Some(sub) = std::env::var("GITLAB_SUBFOLDER").ok().and_then(trimmed) {
return Some(sub);
}
if !ci_autologin_active() {
return None;
}
let url = std::env::var("CI_SERVER_URL").ok()?;
let rest = url.trim().split_once("://").map(|(_, r)| r).unwrap_or(url.trim());
let (_, path) = rest.split_once('/')?;
let path = path.trim_matches('/').to_string();
(!path.is_empty()).then_some(path)
}
fn encode_segment(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(b as char),
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
fn repo_flag(slug: &str) -> Vec<String> {
if slug.is_empty() {
Vec::new()
} else {
vec!["--repo".into(), slug.into()]
}
}
fn project_path(slug: &str) -> String {
if slug.is_empty() {
return "projects/:fullpath".to_string();
}
format!("projects/{}", encode_segment(slug))
}
#[derive(Deserialize)]
struct RawIssue {
iid: u64,
title: String,
state: String,
#[serde(default)]
labels: Vec<String>,
#[serde(default)]
updated_at: String,
#[serde(default)]
web_url: String,
}
pub fn parse_issue_json(s: &str) -> Result<IssueStatus> {
let raw: RawIssue = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
kind: "gitlab issue",
source: e,
})?;
let state = match raw.state.as_str() {
"opened" => IssueState::Open,
"closed" => IssueState::Closed,
other => return Err(GwmError::Other(format!("unknown GitLab issue state '{}'", other))),
};
Ok(IssueStatus {
number: raw.iid,
title: raw.title,
state,
url: raw.web_url,
labels: raw.labels,
updated_at: raw.updated_at,
})
}
pub fn issue_view_argv(slug: &str, number: u64) -> Vec<String> {
let mut argv = vec!["issue".into(), "view".into(), number.to_string()];
argv.extend(repo_flag(slug));
argv.extend(["--output".into(), "json".into()]);
argv
}
#[derive(Deserialize)]
struct RawMr {
iid: u64,
#[serde(default)]
title: String,
state: String,
#[serde(default)]
draft: bool,
#[serde(default)]
work_in_progress: bool,
#[serde(default)]
web_url: String,
#[serde(default)]
updated_at: String,
#[serde(default)]
source_branch: String,
#[serde(default)]
target_branch: String,
#[serde(default)]
author: Option<RawAuthor>,
#[serde(default)]
head_pipeline: Option<RawPipeline>,
}
#[derive(Deserialize, Default)]
struct RawAuthor {
#[serde(default)]
username: String,
}
#[derive(Deserialize)]
struct RawPipeline {
#[serde(default)]
status: String,
#[serde(default)]
web_url: Option<String>,
#[serde(default)]
started_at: Option<String>,
#[serde(default)]
finished_at: Option<String>,
}
pub fn classify_pipeline_status(status: &str) -> CheckOutcome {
match status.trim().to_ascii_lowercase().as_str() {
"success" | "skipped" => CheckOutcome::Passing,
"failed" | "canceled" | "cancelled" | "canceling" | "cancelling" => CheckOutcome::Failing,
"created" | "waiting_for_resource" | "preparing" | "pending" | "running" | "scheduled" | "manual" => {
CheckOutcome::Running
}
_ => CheckOutcome::Unknown,
}
}
pub fn parse_mr_json(s: &str) -> Result<PrStatus> {
let raw: RawMr = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
kind: "gitlab merge request",
source: e,
})?;
let draft = raw.draft || raw.work_in_progress;
let state = match (raw.state.as_str(), draft) {
("merged", _) => PrState::Merged,
("closed", _) => PrState::Closed,
("opened" | "locked", true) => PrState::Draft,
("opened" | "locked", false) => PrState::Open,
(other, _) => return Err(GwmError::Other(format!("unknown GitLab MR state '{}'", other))),
};
let checks: Vec<PrCheck> = raw
.head_pipeline
.map(|p| {
vec![PrCheck {
name: "pipeline".into(),
outcome: classify_pipeline_status(&p.status),
url: p.web_url,
workflow_name: None,
started_at: p.started_at,
completed_at: p.finished_at,
}]
})
.unwrap_or_default();
let checks_total = checks.len() as u32;
let checks_passed = checks.iter().filter(|c| c.outcome == CheckOutcome::Passing).count() as u32;
let ci = forge::aggregate_ci_state(checks.iter().map(|c| c.outcome));
Ok(PrStatus {
number: raw.iid,
title: raw.title,
state,
url: raw.web_url,
updated_at: raw.updated_at,
checks_passed,
checks_total,
ci,
checks,
})
}
pub fn parse_mr_head_json(s: &str) -> Result<PrHead> {
let raw: RawMr = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
kind: "gitlab mr head",
source: e,
})?;
Ok(PrHead {
number: raw.iid,
author: raw.author.unwrap_or_default().username,
head_ref_name: raw.source_branch,
base_ref_name: raw.target_branch,
})
}
pub fn mr_view_argv(slug: &str, number: u64) -> Vec<String> {
let mut argv = vec!["mr".into(), "view".into(), number.to_string()];
argv.extend(repo_flag(slug));
argv.extend(["--output".into(), "json".into()]);
argv
}
pub fn mr_list_argv(slug: &str, branch: &str) -> Vec<String> {
let mut argv = vec!["mr".into(), "list".into()];
argv.extend(repo_flag(slug));
argv.extend([
"--source-branch".into(),
branch.into(),
"--all".into(),
"--output".into(),
"json".into(),
"--per-page".into(),
"100".into(),
]);
argv
}
pub fn parse_mr_list_number(s: &str) -> Result<Option<u64>> {
#[derive(Deserialize)]
struct MrRef {
iid: u64,
#[serde(default)]
project_id: Option<u64>,
#[serde(default)]
source_project_id: Option<u64>,
}
let arr: Vec<MrRef> = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
kind: "gitlab mr list",
source: e,
})?;
let ours = |m: &MrRef| matches!((m.project_id, m.source_project_id), (Some(t), Some(s)) if t == s);
let unidentified = |m: &MrRef| m.project_id.is_none() || m.source_project_id.is_none();
Ok(
arr
.iter()
.find(|m| ours(m))
.or_else(|| arr.iter().find(|m| unidentified(m)))
.or_else(|| arr.first())
.map(|m| m.iid),
)
}
pub fn issue_create_api_argv(slug: &str) -> Vec<String> {
api_post_argv(slug, "issues")
}
pub fn mr_create_api_argv(slug: &str) -> Vec<String> {
api_post_argv(slug, "merge_requests")
}
fn api_post_argv(slug: &str, collection: &str) -> Vec<String> {
vec![
"api".into(),
"-X".into(),
"POST".into(),
format!("{}/{}", project_path(slug), collection),
"--input".into(),
"-".into(),
]
}
pub fn issue_create_payload(title: &str, body: &str, labels: &[String]) -> String {
serde_json::json!({
"title": title,
"description": body,
"labels": labels.join(","),
})
.to_string()
}
pub fn mr_create_payload(title: &str, body: &str, head: &str, base: Option<&str>, draft: bool) -> Result<String> {
let base = base.ok_or_else(|| {
GwmError::Other(
"creating a GitLab merge request needs an explicit target branch: the REST endpoint has no default".into(),
)
})?;
let title = if draft {
format!("Draft: {title}")
} else {
title.to_string()
};
Ok(
serde_json::json!({
"title": title,
"description": body,
"source_branch": head,
"target_branch": base,
})
.to_string(),
)
}
pub fn parse_created_api(s: &str, kind: &'static str) -> Result<(u64, String)> {
#[derive(Deserialize)]
struct Created {
iid: u64,
#[serde(default)]
web_url: String,
}
let c: Created = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
kind: match kind {
"issue" => "gitlab created issue",
_ => "gitlab created mr",
},
source: e,
})?;
Ok((c.iid, c.web_url))
}
#[derive(Deserialize)]
struct RawLabel {
name: String,
#[serde(default)]
is_project_label: Option<bool>,
color: String,
#[serde(default)]
description: Option<String>,
}
fn parse_paginated_array<T: serde::de::DeserializeOwned>(s: &str, kind: &'static str) -> Result<Vec<T>> {
let mut out = Vec::new();
let mut pages = 0usize;
for page in serde_json::Deserializer::from_str(s).into_iter::<Vec<T>>() {
out.extend(page.map_err(|e| GwmError::GhJsonParse { kind, source: e })?);
pages += 1;
}
if pages == 0 {
return Err(GwmError::Other(format!(
"{kind}: glab returned no JSON at all — treating that as an empty \
remote would let --prune run against a baseline it never read"
)));
}
Ok(out)
}
pub fn parse_labels_json(s: &str) -> Result<Vec<RemoteLabel>> {
let raw: Vec<RawLabel> = parse_paginated_array(s, "gitlab labels")?;
Ok(
raw
.into_iter()
.filter(|r| r.is_project_label.unwrap_or(true))
.map(|r| RemoteLabel {
name: r.name,
description: r.description,
color: r.color.trim_start_matches('#').to_ascii_lowercase(),
})
.collect(),
)
}
pub fn label_list_argv(slug: &str) -> Vec<String> {
vec![
"api".into(),
"--paginate".into(),
format!(
"{}/labels?per_page=100&include_ancestor_groups=false",
project_path(slug)
),
]
}
pub fn label_create_argv(slug: &str, spec: &LabelSpec) -> Vec<String> {
let mut argv = vec![
"api".into(),
"-X".into(),
"POST".into(),
format!("{}/labels", project_path(slug)),
"--raw-field".into(),
format!("name={}", spec.name),
"--raw-field".into(),
format!("color=#{}", spec.color),
];
if let Some(desc) = spec.description.as_ref().filter(|s| !s.is_empty()) {
argv.push("--raw-field".into());
argv.push(format!("description={}", desc));
}
argv
}
pub fn label_update_argv(slug: &str, spec: &LabelSpec) -> Vec<String> {
vec![
"api".into(),
"-X".into(),
"PUT".into(),
format!("{}/labels/{}", project_path(slug), encode_segment(&spec.name)),
"--raw-field".into(),
format!("color=#{}", spec.color),
"--raw-field".into(),
format!(
"description={}",
spec.description.as_deref().filter(|s| !s.is_empty()).unwrap_or("")
),
]
}
pub fn label_delete_argv(slug: &str, name: &str) -> Vec<String> {
vec![
"api".into(),
"-X".into(),
"DELETE".into(),
format!("{}/labels/{}", project_path(slug), encode_segment(name)),
]
}
#[derive(Deserialize)]
struct RawMilestone {
id: u64,
title: String,
state: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
due_date: Option<String>,
}
pub fn parse_milestones_json(s: &str) -> Result<Vec<RemoteMilestone>> {
let raw: Vec<RawMilestone> = parse_paginated_array(s, "gitlab milestones")?;
raw
.into_iter()
.map(|r| {
let state = match r.state.as_str() {
"active" => MilestoneState::Open,
"closed" => MilestoneState::Closed,
other => {
return Err(GwmError::Other(format!(
"milestone '{}' has unknown GitLab state '{}': expected 'active' or 'closed'",
r.title, other
)))
}
};
let due_on = match r.due_date.as_deref().filter(|s| !s.is_empty()) {
Some(d) => Some(milestones::normalize_due_on(d)?),
None => None,
};
Ok(RemoteMilestone {
number: r.id,
title: r.title,
description: r.description,
due_on,
state,
})
})
.collect()
}
fn due_date_field(due_on: &str) -> &str {
due_on.split('T').next().unwrap_or(due_on)
}
pub fn check_due_on_is_date_only(spec: &MilestoneSpec) -> Result<()> {
let Some(due) = spec.due_on.as_deref().filter(|s| !s.is_empty()) else {
return Ok(());
};
let normalized = milestones::normalize_due_on(due)?;
if normalized.ends_with("T23:59:59Z") {
return Ok(());
}
Err(GwmError::Config(format!(
"milestone '{}': due_on '{}' carries a time of day, but GitLab stores milestone due dates as a date only \
('{}'). The value would be rewritten on every push without ever matching. Declare a bare date \
(due_on = \"{}\") instead.",
spec.title,
due,
due_date_field(&normalized),
due_date_field(&normalized),
)))
}
fn state_event(state: MilestoneState) -> &'static str {
match state {
MilestoneState::Open => "activate",
MilestoneState::Closed => "close",
}
}
pub fn milestone_list_argv(slug: &str) -> Vec<String> {
vec![
"api".into(),
"--paginate".into(),
format!("{}/milestones?per_page=100", project_path(slug)),
]
}
pub fn milestone_create_argv(slug: &str, spec: &MilestoneSpec) -> Vec<String> {
let mut argv = vec![
"api".into(),
"-X".into(),
"POST".into(),
format!("{}/milestones", project_path(slug)),
"--raw-field".into(),
format!("title={}", spec.title),
];
if let Some(desc) = spec.description.as_ref().filter(|s| !s.is_empty()) {
argv.push("--raw-field".into());
argv.push(format!("description={}", desc));
}
if let Some(due) = spec.due_on.as_ref().filter(|s| !s.is_empty()) {
argv.push("--raw-field".into());
argv.push(format!("due_date={}", due_date_field(due)));
}
argv
}
pub fn milestone_update_argv(slug: &str, number: u64, spec: &MilestoneSpec) -> Vec<String> {
vec![
"api".into(),
"-X".into(),
"PUT".into(),
format!("{}/milestones/{}", project_path(slug), number),
"--raw-field".into(),
format!("title={}", spec.title),
"--raw-field".into(),
format!(
"description={}",
spec.description.as_deref().filter(|s| !s.is_empty()).unwrap_or("")
),
"--raw-field".into(),
format!(
"due_date={}",
spec
.due_on
.as_deref()
.filter(|s| !s.is_empty())
.map(due_date_field)
.unwrap_or("")
),
"--raw-field".into(),
format!("state_event={}", state_event(spec.state)),
]
}
pub fn milestone_delete_argv(slug: &str, number: u64) -> Vec<String> {
vec![
"api".into(),
"-X".into(),
"DELETE".into(),
format!("{}/milestones/{}", project_path(slug), number),
]
}
fn parse_created_milestone_id(s: &str) -> Result<u64> {
#[derive(Deserialize)]
struct Created {
id: u64,
}
let created: Created = serde_json::from_str(s).map_err(|e| GwmError::GhJsonParse {
kind: "gitlab milestone create",
source: e,
})?;
Ok(created.id)
}
#[derive(Debug, Clone)]
pub struct GitLabForge {
origin: forge::RemoteRef,
program: OsString,
env: Vec<(String, String)>,
env_remove: Vec<&'static str>,
workdir: Option<std::path::PathBuf>,
refuse: Option<String>,
selector: String,
}
impl GitLabForge {
pub fn new(origin: forge::RemoteRef, workdir: Option<std::path::PathBuf>) -> Self {
Self {
env: glab_env(&origin),
env_remove: glab_env_remove(&origin, workdir.is_some()),
refuse: ci_autologin_conflict(&origin),
selector: resolve_selector(&origin, workdir.is_some()),
origin,
program: glab_program(),
workdir,
}
}
fn run_argv(&self, argv: Vec<String>) -> Result<String> {
self.run_argv_with_stdin(argv, None)
}
fn run_argv_object(&self, argv: Vec<String>) -> Result<String> {
self.run_spawn(argv, None, true)
}
fn run_argv_with_stdin(&self, argv: Vec<String>, stdin: Option<&[u8]>) -> Result<String> {
let redact = stdin.is_some();
self.run_spawn(argv, stdin, redact)
}
fn run_spawn(&self, argv: Vec<String>, stdin: Option<&[u8]>, redact_output: bool) -> Result<String> {
if let Some(why) = &self.refuse {
return Err(GwmError::Other(why.clone()));
}
let mut env_remove: Vec<&'static str> = self.env_remove.clone();
if redact_output {
env_remove.extend_from_slice(&["GLAB_DEBUG_HTTP", "GLAB_DEBUG"]);
}
forge::run_cli_with(
&self.program,
argv,
&forge::CliSpawn {
env: &self.env,
cwd: self.workdir.as_deref(),
env_remove: &env_remove,
redact_after: &[],
stdin,
redact_output,
},
)
}
}
impl Forge for GitLabForge {
fn kind(&self) -> ForgeKind {
ForgeKind::GitLab
}
fn slug(&self) -> &str {
&self.origin.path
}
fn web_origin(&self) -> &str {
&self.origin.web_origin
}
fn workdir(&self) -> Option<&std::path::Path> {
self.workdir.as_deref()
}
fn origin_is_authoritative(&self) -> bool {
self.origin.trust == forge::OriginTrust::FromUrl
}
fn repo_selector(&self) -> &str {
&self.selector
}
fn issue_url(&self, number: u64) -> String {
format!("{}/{}/-/issues/{}", self.origin.web_origin, self.origin.path, number)
}
fn pr_url(&self, number: u64) -> String {
format!(
"{}/{}/-/merge_requests/{}",
self.origin.web_origin, self.origin.path, number
)
}
fn pr_head_refspec(&self, number: u64) -> String {
format!("merge-requests/{number}/head")
}
fn fetch_issue(&self, number: u64) -> Result<IssueStatus> {
parse_issue_json(&self.run_argv_object(issue_view_argv(self.repo_selector(), number))?)
}
fn fetch_pr(&self, number: u64) -> Result<PrStatus> {
parse_mr_json(&self.run_argv_object(mr_view_argv(self.repo_selector(), number))?)
}
fn fetch_pr_head(&self, number: u64) -> Result<PrHead> {
parse_mr_head_json(&self.run_argv_object(mr_view_argv(self.repo_selector(), number))?)
}
fn find_pr_for_branch(&self, branch: &str) -> Result<Option<u64>> {
parse_mr_list_number(&self.run_argv_object(mr_list_argv(self.repo_selector(), branch))?)
}
fn create_issue(&self, req: &IssueCreateRequest<'_>) -> Result<CreatedIssue> {
let body = forge::read_body_file(req.body_file)?;
let payload = issue_create_payload(req.title, &body, req.labels);
let out = self.run_argv_with_stdin(issue_create_api_argv(self.repo_selector()), Some(payload.as_bytes()))?;
let (number, url) = parse_created_api(&out, "issue")?;
Ok(CreatedIssue {
number,
url: if url.is_empty() { self.issue_url(number) } else { url },
})
}
fn create_pr(&self, req: &PrCreateRequest<'_>) -> Result<CreatedPr> {
let body = forge::read_body_file(req.body_file)?;
let payload = mr_create_payload(req.title, &body, req.head, req.base, req.draft)?;
let out = self.run_argv_with_stdin(mr_create_api_argv(self.repo_selector()), Some(payload.as_bytes()))?;
let (number, url) = parse_created_api(&out, "mr")?;
Ok(CreatedPr {
number,
url: if url.is_empty() { self.pr_url(number) } else { url },
})
}
fn fetch_remote_labels(&self) -> Result<Vec<RemoteLabel>> {
parse_labels_json(&self.run_argv(label_list_argv(self.repo_selector()))?)
}
fn create_label(&self, spec: &LabelSpec) -> Result<()> {
self.run_argv(label_create_argv(self.repo_selector(), spec))?;
Ok(())
}
fn update_label(&self, spec: &LabelSpec) -> Result<()> {
self.run_argv(label_update_argv(self.repo_selector(), spec))?;
Ok(())
}
fn delete_label(&self, name: &str) -> Result<()> {
crate::labels::validate_label_name(name).map_err(|e| {
let inner = match e {
GwmError::Config(msg) => msg,
other => other.to_string(),
};
GwmError::Config(format!("labels (remote): {} — refusing to delete via `glab`", inner))
})?;
self.run_argv(label_delete_argv(self.repo_selector(), name))?;
Ok(())
}
fn fetch_remote_milestones(&self) -> Result<Vec<RemoteMilestone>> {
parse_milestones_json(&self.run_argv(milestone_list_argv(self.repo_selector()))?)
}
fn validate_milestone(&self, spec: &MilestoneSpec) -> Result<()> {
check_due_on_is_date_only(spec)
}
fn create_milestone(&self, spec: &MilestoneSpec) -> Result<()> {
check_due_on_is_date_only(spec)?;
let out = self.run_argv(milestone_create_argv(self.repo_selector(), spec))?;
if spec.state == MilestoneState::Closed {
let id = parse_created_milestone_id(&out)?;
self.run_argv(milestone_update_argv(self.repo_selector(), id, spec))?;
}
Ok(())
}
fn update_milestone(&self, number: u64, spec: &MilestoneSpec) -> Result<()> {
check_due_on_is_date_only(spec)?;
self.run_argv(milestone_update_argv(self.repo_selector(), number, spec))?;
Ok(())
}
fn delete_milestone(&self, number: u64) -> Result<()> {
self.run_argv(milestone_delete_argv(self.repo_selector(), number))?;
Ok(())
}
}