Skip to content

Instantly share code, notes, and snippets.

@rossmacarthur
Last active March 2, 2025 16:07
Show Gist options
  • Save rossmacarthur/47d3df4882349d2f4e4decf0df5b7e8e to your computer and use it in GitHub Desktop.
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
#!/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