use std::future::Future;
use std::sync::OnceLock;
use std::time::Duration;
use onetaskgraph_live::artifact::{Run, Stamp, Sweep};
use serde_json::{Value, json};
const LINEAR: &str = "https://api.linear.app/graphql";
static ENDPOINT: OnceLock<String> = OnceLock::new();
pub fn against(url: &str) {
ENDPOINT
.set(url.to_owned())
.expect("one endpoint per process");
}
fn endpoint() -> &'static str {
ENDPOINT.get().map_or(LINEAR, String::as_str)
}
pub async fn linear(key: &str, query: &str, variables: Value, what: &str) -> Result<Value, String> {
let body: Value = reqwest::Client::new()
.post(endpoint())
.header("Authorization", key)
.json(&json!({"query":query,"variables":variables}))
.send()
.await
.map_err(|error| format!("{what} could not reach Linear: {error}"))?
.error_for_status()
.map_err(|error| format!("{what} failed: {error}"))?
.json()
.await
.map_err(|error| format!("{what} returned invalid JSON: {error}"))?;
if let Some(errors) = body
.get("errors")
.filter(|errors| !errors.as_array().is_some_and(Vec::is_empty))
{
return Err(format!("{what} was rejected by Linear: {errors}"));
}
body.get("data")
.cloned()
.ok_or_else(|| format!("{what} returned no data: {body}"))
}
pub const SESSION_NAME: &str = "Linear";
pub const ARTIFACT_PREFIX: &str = "onetaskgraph live cleanup ";
pub const LABEL_PREFIX: &str = "otg-live-";
pub fn artifact_title(run: Run, stamp_micros: u64) -> String {
format!("{ARTIFACT_PREFIX}{}", Stamp::new(run, stamp_micros))
}
pub fn artifact_label(run: Run, stamp_micros: u64) -> String {
format!("{LABEL_PREFIX}{}", Stamp::new(run, stamp_micros))
}
pub fn is_this_runs(run: Run, prefix: &str, name: &str) -> bool {
Stamp::read(name.strip_prefix(prefix).unwrap_or("")).is_some_and(|stamp| stamp.run() == run)
}
pub const TEAM_STATES: &str = "query($key:String!){teams(filter:{key:{eqIgnoreCase:$key}}){nodes{id key states(first:100){nodes{id name type}}}}}";
pub const PROJECT_STATUSES: &str = "query{projectStatuses{nodes{id name type}}}";
pub const LABEL_CREATE: &str = "mutation($input:IssueLabelCreateInput!){issueLabelCreate(input:$input){success issueLabel{id name}}}";
pub const LABEL_DELETE: &str = "mutation($id:String!){issueLabelDelete(id:$id){success}}";
pub const LABELS_PAGE: &str = "query($first:Int!,$after:String){issueLabels(first:$first,after:$after){nodes{id name} pageInfo{hasNextPage endCursor}}}";
pub const ISSUES_BY_TITLE: &str = "query($first:Int!,$after:String,$prefix:String!){issues(first:$first,after:$after,filter:{title:{startsWith:$prefix}}){nodes{id title} pageInfo{hasNextPage endCursor}}}";
pub const PROJECTS_BY_NAME: &str = "query($first:Int!,$after:String,$prefix:String!){projects(first:$first,after:$after,filter:{name:{startsWith:$prefix}}){nodes{id name} pageInfo{hasNextPage endCursor}}}";
pub const ISSUE_PAGE_PROBE: &str = "query($first:Int!){issues(first:$first){nodes{id}}}";
pub const DOCUMENTS_BY_TITLE: &str = "query($first:Int!,$after:String,$prefix:String!){documents(first:$first,after:$after,filter:{title:{startsWith:$prefix}}){nodes{id title} pageInfo{hasNextPage endCursor}}}";
async fn walk(
key: &str,
query: &str,
connection: &str,
name_field: &str,
prefix: Option<&str>,
what: &str,
) -> Result<Vec<(String, String)>, String> {
let mut after = Value::Null;
let mut found = Vec::new();
for _ in 0..50 {
let mut variables = json!({"first":onetaskgraph_linear::MAX_PAGE_SIZE,"after":after});
if let Some(prefix) = prefix {
variables["prefix"] = Value::String(prefix.to_owned());
}
let data = linear(key, query, variables, what).await?;
let page = data
.get(connection)
.ok_or_else(|| format!("{what} returned no {connection} connection"))?;
for node in page
.get("nodes")
.and_then(Value::as_array)
.ok_or_else(|| format!("{what} returned {connection}.nodes that is not an array"))?
{
let (Some(id), Some(name)) = (
node.get("id").and_then(Value::as_str),
node.get(name_field).and_then(Value::as_str),
) else {
return Err(format!(
"{what} returned a {connection} node with no id or name"
));
};
found.push((id.to_owned(), name.to_owned()));
}
if page
.pointer("/pageInfo/hasNextPage")
.and_then(Value::as_bool)
!= Some(true)
{
return Ok(found);
}
let next = page
.pointer("/pageInfo/endCursor")
.and_then(Value::as_str)
.ok_or_else(|| format!("{what} has no advancing cursor"))?;
if after.as_str() == Some(next) {
return Err(format!("{what} cursor did not advance"));
}
after = Value::String(next.to_owned());
}
Err(format!("{what} did not terminate"))
}
pub struct Residue {
pub listing: &'static str,
pub connection: &'static str,
pub name_field: &'static str,
pub prefix: &'static str,
pub narrowed: bool,
pub delete: &'static str,
pub confirm: &'static str,
pub kind: &'static str,
}
pub static RESIDUE: [Residue; 4] = [
Residue {
listing: ISSUES_BY_TITLE,
connection: "issues",
name_field: "title",
prefix: ARTIFACT_PREFIX,
narrowed: true,
delete: onetaskgraph_linear::graphql::ISSUE_DELETE,
confirm: "/issueDelete/success",
kind: "issue",
},
Residue {
listing: PROJECTS_BY_NAME,
connection: "projects",
name_field: "name",
prefix: ARTIFACT_PREFIX,
narrowed: true,
delete: onetaskgraph_linear::graphql::PROJECT_DELETE,
confirm: "/projectDelete/success",
kind: "project",
},
Residue {
listing: DOCUMENTS_BY_TITLE,
connection: "documents",
name_field: "title",
prefix: ARTIFACT_PREFIX,
narrowed: true,
delete: onetaskgraph_linear::graphql::DOCUMENT_DELETE,
confirm: "/documentDelete/success",
kind: "document",
},
Residue {
listing: LABELS_PAGE,
connection: "issueLabels",
name_field: "name",
prefix: LABEL_PREFIX,
narrowed: false,
delete: LABEL_DELETE,
confirm: "/issueLabelDelete/success",
kind: "label",
},
];
async fn find_artifacts(
key: &str,
matches: &dyn Fn(&str, &str) -> bool,
) -> Result<Vec<(&'static Residue, String, String)>, String> {
let mut found = Vec::new();
for residue in &RESIDUE {
let listed = walk(
key,
residue.listing,
residue.connection,
residue.name_field,
residue.narrowed.then_some(residue.prefix),
&format!("live {} residue lookup", residue.kind),
)
.await?;
found.extend(
listed
.into_iter()
.filter(|(_, name)| matches(residue.prefix, name))
.map(|(id, name)| (residue, id, name)),
);
}
Ok(found)
}
pub async fn remove_artifacts(
key: &str,
matches: &dyn Fn(&str, &str) -> bool,
) -> Result<(), String> {
let mut refused = Vec::new();
for _ in 0..3 {
let found = find_artifacts(key, matches).await?;
if found.is_empty() {
return Ok(());
}
refused.clear();
for (residue, id, name) in found {
match linear(
key,
residue.delete,
json!({ "id": id }),
&format!("live {} cleanup", residue.kind),
)
.await
{
Ok(data) if data.pointer(residue.confirm) == Some(&Value::Bool(true)) => {}
Ok(_) => refused.push(format!(
"Linear did not confirm deleting {} {name:?}",
residue.kind
)),
Err(problem) => refused.push(problem),
}
}
if refused.is_empty() {
return Ok(());
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
Err(format!(
"live cleanup left {}; Linear refused: {}",
find_artifacts(key, matches)
.await?
.into_iter()
.map(|(residue, _, name)| format!("{} {name:?}", residue.kind))
.collect::<Vec<_>>()
.join(", "),
refused.join("; ")
))
}
pub async fn sweep_orphans(key: &str, sweep: &Sweep) -> Result<(), String> {
remove_artifacts(key, &|prefix, name| sweep.names_an_orphan(prefix, name)).await
}
pub async fn run_then_cleanup<J, JF, C, CF>(journey: J, cleanup: C) -> Result<(), String>
where
J: FnOnce() -> JF,
JF: Future<Output = Result<(), String>>,
C: FnOnce() -> CF,
CF: Future<Output = Result<(), String>>,
{
let journey_result = journey().await;
let cleanup_result = cleanup().await;
match (journey_result, cleanup_result) {
(Ok(()), Ok(())) => Ok(()),
(Err(journey), Ok(())) => Err(journey),
(Ok(()), Err(cleanup)) => Err(format!("live cleanup failed: {cleanup}")),
(Err(journey), Err(cleanup)) => Err(format!(
"{journey}; additionally, live cleanup failed: {cleanup}"
)),
}
}