use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::Deserialize;
pub const PROTOCOL_JSON: &str = r#"{"protocol":[1],"ops":["claim"]}"#;
#[derive(Deserialize)]
struct Wire {
#[serde(default)]
binding: WireBinding,
#[serde(default)]
metadata: BTreeMap<String, Vec<String>>,
#[serde(default)]
previous_state: Option<WireTask>,
#[serde(default)]
rolling_back: Option<String>,
#[serde(default)]
actor: String,
}
#[derive(Deserialize, Default)]
struct WireBinding {
#[serde(default)]
landing: String,
#[serde(default)]
invocation_path: String,
}
#[derive(Deserialize)]
struct WireTask {
#[serde(default)]
tags: Vec<String>,
}
#[derive(Deserialize)]
struct Config {
#[serde(default = "enabled")]
epic_skip: bool,
#[serde(default)]
chore: Vec<ChoreSpec>,
}
#[derive(Deserialize)]
struct ChoreSpec {
title: String,
#[serde(default)]
body: Option<String>,
#[serde(default)]
priority: Option<i64>,
}
fn enabled() -> bool {
true
}
pub trait Bl {
fn run(&self, cwd: &Path, argv: &[String]) -> io::Result<String>;
}
pub fn run(op: &str, phase: &str, plugin: &str, stdin: &str, bl: &dyn Bl) -> io::Result<()> {
let wire: Wire = serde_json::from_str(stdin).map_err(io::Error::other)?;
if op != "claim" || phase != "post" || wire.rolling_back.is_some() {
return Ok(());
}
if wire.previous_state.as_ref().is_some_and(|s| s.tags.iter().any(|t| t == plugin)) {
return Ok(());
}
let config = load_config(&config_path(&wire.binding.landing, plugin))?;
if config.chore.is_empty() {
return Ok(());
}
let id = claimed_id(&wire.metadata)?;
let cwd = Path::new(&wire.binding.invocation_path);
if config.epic_skip && has_children(&bl.run(cwd, &["list".to_string(), "--json".to_string()])?, id)? {
return Ok(());
}
for spec in &config.chore {
bl.run(cwd, &render_create(spec, id, plugin, &wire.actor))?;
}
Ok(())
}
fn claimed_id(metadata: &BTreeMap<String, Vec<String>>) -> io::Result<&str> {
metadata
.get("bl-id")
.and_then(|v| v.first())
.map(String::as_str)
.ok_or_else(|| io::Error::other("claim.post payload carries no bl-id (§7)"))
}
fn config_path(landing: &str, plugin: &str) -> PathBuf {
Path::new(landing).join("config").join("plugins").join(plugin).join("chores.toml")
}
fn load_config(path: &Path) -> io::Result<Config> {
match fs::read_to_string(path) {
Ok(s) => toml::from_str(&s).map_err(io::Error::other),
Err(e) if e.kind() == io::ErrorKind::NotFound => toml::from_str("").map_err(io::Error::other),
Err(e) => Err(e),
}
}
fn has_children(list_json: &str, parent: &str) -> io::Result<bool> {
let items: Vec<ListItem> = serde_json::from_str(list_json).map_err(io::Error::other)?;
Ok(items.iter().any(|it| it.parent.as_deref() == Some(parent)))
}
#[derive(Deserialize)]
struct ListItem {
#[serde(default)]
parent: Option<String>,
}
fn render_create(spec: &ChoreSpec, parent: &str, tag: &str, actor: &str) -> Vec<String> {
let mut argv = vec![
"create".to_string(),
"--parent".to_string(),
parent.to_string(),
"--blocks".to_string(),
"close".to_string(),
"-t".to_string(),
tag.to_string(),
"--as".to_string(),
actor.to_string(),
];
if let Some(p) = spec.priority {
argv.push("-p".to_string());
argv.push(p.to_string());
}
if let Some(body) = &spec.body {
argv.push("--body".to_string());
argv.push(body.clone());
}
argv.push("--".to_string());
argv.push(spec.title.clone());
argv
}
#[cfg(test)]
#[path = "chore_tests.rs"]
mod tests;