Last active
March 2, 2025 16:07
-
-
Save rossmacarthur/47d3df4882349d2f4e4decf0df5b7e8e to your computer and use it in GitHub Desktop.
Copy GitHub labels from one GitHub repository to another, update existing label color and description, delete extra labels
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env rust-script | |
//! ```cargo | |
//! [dependencies] | |
//! anyhow = "1.0.96" | |
//! argh = "0.1.13" | |
//! casual = "0.2.0" | |
//! indexmap = "2.7.1" | |
//! serde = { version = "1.0.218", features = ["derive"] } | |
//! serde_json = "1.0.139" | |
//! ureq = { version = "3.0.8", features = ["json"] } | |
//! ``` | |
use std::env::VarError; | |
use std::str::FromStr; | |
use std::sync::LazyLock; | |
use std::time::Duration; | |
use std::{env, thread}; | |
use anyhow::Context as _; | |
use anyhow::{Result, anyhow}; | |
use argh::FromArgs; | |
use indexmap::IndexMap; | |
use serde::Deserialize; | |
use serde::Serialize; | |
use serde::de::DeserializeOwned; | |
use ureq::typestate::WithBody; | |
trait ReqWithAuth: Sized { | |
fn with_auth(self) -> Result<Self>; | |
} | |
impl<T> ReqWithAuth for ureq::RequestBuilder<T> { | |
fn with_auth(self) -> Result<Self> { | |
static GITHUB_TOKEN: LazyLock<Result<String, VarError>> = | |
LazyLock::new(|| env::var("GITHUB_TOKEN")); | |
Ok(self | |
.header("Accept", "application/vnd.github.v3+json") | |
.header( | |
"Authorization", | |
format!("Bearer {}", GITHUB_TOKEN.as_ref()?), | |
)) | |
} | |
} | |
trait ReqWithBodyExt { | |
fn json<S: Serialize, R: DeserializeOwned>(self, send: S) -> Result<R>; | |
} | |
impl ReqWithBodyExt for ureq::RequestBuilder<WithBody> { | |
fn json<S: Serialize, R: DeserializeOwned>(self, send: S) -> Result<R> { | |
let r = self.with_auth()?.send_json(send)?.body_mut().read_json()?; | |
Ok(r) | |
} | |
} | |
#[derive(Debug, Clone)] | |
enum LabelOp { | |
Rename { | |
old: String, | |
new: String, | |
}, | |
Create { | |
label: String, | |
color: String, | |
description: String, | |
}, | |
Update { | |
label: String, | |
color: String, | |
description: String, | |
}, | |
Delete { | |
label: String, | |
}, | |
} | |
#[derive(Debug, Deserialize)] | |
struct Label { | |
name: String, | |
color: String, | |
description: Option<String>, | |
} | |
#[derive(Debug, Default, Serialize)] | |
struct LabelUpdates<'a> { | |
#[serde(skip_serializing_if = "Option::is_none")] | |
name: Option<&'a str>, | |
#[serde(skip_serializing_if = "Option::is_none")] | |
new_name: Option<&'a str>, | |
#[serde(skip_serializing_if = "Option::is_none")] | |
color: Option<&'a str>, | |
#[serde(skip_serializing_if = "Option::is_none")] | |
description: Option<&'a str>, | |
} | |
fn list_labels(repo: &Repo) -> Result<Vec<Label>> { | |
let Repo { owner, name } = repo; | |
let url = format!("https://api.github.com/repos/{owner}/{name}/labels"); | |
Ok(ureq::get(url).with_auth()?.call()?.body_mut().read_json()?) | |
} | |
fn rename_label(repo: &Repo, old: &str, new: &str) -> Result<Label> { | |
let Repo { owner, name } = repo; | |
let old = old.replace(" ", "%20"); | |
let url = format!("https://api.github.com/repos/{owner}/{name}/labels/{old}"); | |
let updates = LabelUpdates { | |
new_name: Some(new), | |
..Default::default() | |
}; | |
ureq::patch(url).json(updates) | |
} | |
fn update_label(repo: &Repo, label: &str, color: &str, description: &str) -> Result<Label> { | |
let Repo { owner, name } = repo; | |
let label = label.replace(" ", "%20"); | |
let url = format!("https://api.github.com/repos/{owner}/{name}/labels/{label}"); | |
let updates = LabelUpdates { | |
color: Some(color), | |
description: Some(description), | |
..Default::default() | |
}; | |
ureq::patch(url).json(updates) | |
} | |
fn delete_label(repo: &Repo, label: &str) -> Result<()> { | |
let Repo { owner, name } = repo; | |
let label = label.replace(" ", "%20"); | |
let url = format!("https://api.github.com/repos/{owner}/{name}/labels/{label}"); | |
ureq::delete(url).with_auth()?.call()?; | |
Ok(()) | |
} | |
fn create_label(repo: &Repo, label: &str, color: &str, description: &str) -> Result<Label> { | |
let Repo { owner, name } = repo; | |
let url = format!("https://api.github.com/repos/{owner}/{name}/labels"); | |
let updates = LabelUpdates { | |
name: Some(label), | |
color: Some(color), | |
description: Some(description), | |
..Default::default() | |
}; | |
ureq::post(url).json(updates) | |
} | |
/// Figure out how to update the actual labels based on the wanted labels | |
/// - Add new labels that are in source but not in target | |
/// - Remove labels that are in target but not in source | |
/// - Update labels that are in both but have different colors or descriptions | |
fn calc_ops(opt: &Opt) -> Result<Vec<LabelOp>> { | |
let source_labels = list_labels(&opt.source)?; | |
let mut source_map: IndexMap<String, Label> = source_labels | |
.into_iter() | |
.map(|label| (label.name.clone(), label)) | |
.collect(); | |
let target_labels = list_labels(&opt.target)?; | |
let mut target_map: IndexMap<String, Label> = target_labels | |
.into_iter() | |
.map(|label| (label.name.clone(), label)) | |
.collect(); | |
let mut ops = Vec::new(); | |
// Small personal hack, can be removed | |
if let Some(label) = target_map.get("enhancement") { | |
if !target_map.contains_key("feature") | |
&& matches!( | |
label.description.as_deref(), | |
None | Some("New feature or request") | |
) | |
{ | |
ops.push(LabelOp::Rename { | |
old: "enhancement".to_owned(), | |
new: "feature".to_owned(), | |
}); | |
ops.push(LabelOp::Update { | |
label: "feature".to_owned(), | |
color: source_map["feature"].color.clone(), | |
description: source_map["feature"].description.clone().unwrap(), | |
}); | |
target_map.swap_remove("enhancement"); | |
source_map.swap_remove("feature"); | |
} | |
} | |
for (l, label) in &source_map { | |
if let Some(have) = target_map.get(l) { | |
if label.color == have.color && label.description == have.description { | |
continue; | |
} | |
ops.push(LabelOp::Update { | |
label: l.clone(), | |
color: label.color.clone(), | |
description: label.description.clone().unwrap(), | |
}); | |
} else { | |
ops.push(LabelOp::Create { | |
label: l.clone(), | |
color: label.color.clone(), | |
description: label.description.clone().unwrap(), | |
}); | |
} | |
} | |
for (l, _) in &target_map { | |
if !source_map.contains_key(l) { | |
ops.push(LabelOp::Delete { label: l.clone() }); | |
} | |
} | |
Ok(ops) | |
} | |
#[derive(Debug, Clone, FromArgs)] | |
#[argh(description = "Sync labels between two repositories")] | |
struct Opt { | |
/// the source repository to sync labels from (owner/name) | |
#[argh(option)] | |
source: Repo, | |
/// the target repository to sync labels to (owner/name) | |
#[argh(option)] | |
target: Repo, | |
} | |
#[derive(Debug, Clone)] | |
struct Repo { | |
owner: String, | |
name: String, | |
} | |
impl FromStr for Repo { | |
type Err = anyhow::Error; | |
fn from_str(s: &str) -> Result<Self, Self::Err> { | |
let mut parts = s.splitn(2, '/'); | |
let owner = parts.next().ok_or_else(|| anyhow!("missing owner"))?; | |
let name = parts.next().ok_or_else(|| anyhow!("missing name"))?; | |
Ok(Repo { | |
owner: owner.to_owned(), | |
name: name.to_owned(), | |
}) | |
} | |
} | |
fn main() -> anyhow::Result<()> { | |
let opt: Opt = argh::from_env(); | |
let ops = calc_ops(&opt)?; | |
if ops.is_empty() { | |
println!("No changes required"); | |
return Ok(()); | |
} | |
for op in &ops { | |
match op { | |
LabelOp::Rename { | |
old: old_name, | |
new: new_name, | |
} => { | |
println!("Rename `{}` to `{}`", old_name, new_name); | |
} | |
LabelOp::Create { | |
label: name, | |
color, | |
description, | |
} => { | |
println!( | |
"Create `{}` with color '{}' and description '{}'", | |
name, color, description | |
); | |
} | |
LabelOp::Update { | |
label: name, | |
color, | |
description, | |
} => { | |
println!( | |
"Update `{}` with color '{}' and description '{}'", | |
name, color, description | |
); | |
} | |
LabelOp::Delete { label: name } => { | |
println!("Delete `{}`", name); | |
} | |
} | |
} | |
if !casual::confirm("\nDo you want to apply these changes?") { | |
return Err(anyhow!("Aborted")); | |
} | |
println!(); | |
for op in ops { | |
thread::sleep(Duration::from_millis(100)); | |
match op { | |
LabelOp::Rename { old, new } => { | |
rename_label(&opt.target, &old, &new) | |
.with_context(|| format!("failed to rename label `{}` to `{}`", old, new))?; | |
println!("Renamed `{}` to `{}`", old, new); | |
} | |
LabelOp::Create { | |
label, | |
color, | |
description, | |
} => { | |
create_label(&opt.target, &label, &color, &description).with_context(|| { | |
format!( | |
"failed to create label `{}` with color {} and description {}", | |
label, color, description | |
) | |
})?; | |
println!( | |
"Created `{}` with color {} and description {}", | |
label, color, description | |
); | |
} | |
LabelOp::Update { | |
label, | |
color, | |
description, | |
} => { | |
update_label(&opt.target, &label, &color, &description).with_context(|| { | |
format!( | |
"failed to update label `{}` with color {} and description {}", | |
label, color, description | |
) | |
})?; | |
println!( | |
"Updated `{}` with color {} and description {}", | |
label, color, description | |
); | |
} | |
LabelOp::Delete { label } => { | |
delete_label(&opt.target, &label) | |
.with_context(|| format!("failed to delete label `{}`", label))?; | |
println!("Deleted `{}`", label); | |
} | |
} | |
} | |
Ok(()) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment