use std::{collections::BTreeMap, env, future::Future};
use onetaskgraph_plugin_api::{
Capabilities, DependencyEdge, DependencyEndpoint, DependencyKind, DependencySupport, Direction,
Document, DocumentQuery, ItemKind, ItemWrite, Label, LabelFilter, Location, NativeId,
PageRequest, Project, ProjectFilter, ProjectQuery, SecretResolver, SourceName, SourcePlugin,
Status, StatusCategory, Support, Task, TaskQuery, TaskSource, TextFields, TextQuery,
};
use secrecy::SecretString;
use serde_json::{Value, json};
struct Environment;
impl SecretResolver for Environment {
fn get(&self, var: &str) -> Option<SecretString> {
env::var(var).ok().map(SecretString::from)
}
}
macro_rules! ensure {
($condition:expr, $($message:tt)+) => {
if !$condition {
return Err(format!($($message)+));
}
};
}
async fn linear(key: &str, query: &str, variables: Value, what: &str) -> Result<Value, String> {
let body: Value = reqwest::Client::new()
.post("https://api.linear.app/graphql")
.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}"))
}
const ARTIFACT_PREFIX: &str = "onetaskgraph live cleanup ";
const LABEL_PREFIX: &str = "onetaskgraph-live-";
fn artifact_title(process_id: u32, stamp_micros: i64) -> String {
format!("{ARTIFACT_PREFIX}{process_id}-{stamp_micros}")
}
fn artifact_label(process_id: u32, stamp_micros: i64) -> String {
format!("{LABEL_PREFIX}{process_id}-{stamp_micros}")
}
fn is_artifact(prefix: &str, name: &str) -> bool {
let Some(suffix) = name.strip_prefix(prefix) else {
return false;
};
let Some((process_id, stamp_micros)) = suffix.split_once('-') else {
return false;
};
[process_id, stamp_micros]
.iter()
.all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
}
const TEAM_STATES: &str = "query($key:String!){teams(filter:{key:{eqIgnoreCase:$key}}){nodes{id key states(first:100){nodes{id name type}}}}}";
const PROJECT_STATUSES: &str = "query{projectStatuses{nodes{id name type}}}";
const LABEL_CREATE: &str = "mutation($input:IssueLabelCreateInput!){issueLabelCreate(input:$input){success issueLabel{id name}}}";
const LABEL_DELETE: &str = "mutation($id:String!){issueLabelDelete(id:$id){success}}";
const LABELS_PAGE: &str = "query($first:Int!,$after:String){issueLabels(first:$first,after:$after){nodes{id name} pageInfo{hasNextPage endCursor}}}";
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}}}";
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}}}";
const ISSUE_PAGE_PROBE: &str = "query($first:Int!){issues(first:$first){nodes{id}}}";
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":250,"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"))
}
async fn remove_artifacts(key: &str, matches: &dyn Fn(&str, &str) -> bool) -> Result<(), String> {
let issues = walk(
key,
ISSUES_BY_TITLE,
"issues",
"title",
Some(ARTIFACT_PREFIX),
"live issue residue lookup",
)
.await?;
for (id, title) in issues
.iter()
.filter(|(_, title)| matches(ARTIFACT_PREFIX, title))
{
let data = linear(
key,
onetaskgraph_linear::graphql::ISSUE_DELETE,
json!({"id":id}),
"live issue cleanup",
)
.await?;
if data.pointer("/issueDelete/success") != Some(&Value::Bool(true)) {
return Err(format!("Linear did not confirm deleting issue {title:?}"));
}
}
let projects = walk(
key,
PROJECTS_BY_NAME,
"projects",
"name",
Some(ARTIFACT_PREFIX),
"live project residue lookup",
)
.await?;
for (id, name) in projects
.iter()
.filter(|(_, name)| matches(ARTIFACT_PREFIX, name))
{
let data = linear(
key,
onetaskgraph_linear::graphql::PROJECT_DELETE,
json!({"id":id}),
"live project cleanup",
)
.await?;
if data.pointer("/projectDelete/success") != Some(&Value::Bool(true)) {
return Err(format!("Linear did not confirm deleting project {name:?}"));
}
}
let documents = walk(
key,
DOCUMENTS_BY_TITLE,
"documents",
"title",
Some(ARTIFACT_PREFIX),
"live document residue lookup",
)
.await?;
for (id, title) in documents
.iter()
.filter(|(_, title)| matches(ARTIFACT_PREFIX, title))
{
let data = linear(
key,
onetaskgraph_linear::graphql::DOCUMENT_DELETE,
json!({"id":id}),
"live document cleanup",
)
.await?;
if data.pointer("/documentDelete/success") != Some(&Value::Bool(true)) {
return Err(format!(
"Linear did not confirm deleting document {title:?}"
));
}
}
let labels = walk(
key,
LABELS_PAGE,
"issueLabels",
"name",
None,
"live label lookup",
)
.await?;
for (id, name) in labels
.iter()
.filter(|(_, name)| matches(LABEL_PREFIX, name))
{
let data = linear(key, LABEL_DELETE, json!({"id":id}), "live label cleanup").await?;
if data.pointer("/issueLabelDelete/success") != Some(&Value::Bool(true)) {
return Err(format!("Linear did not confirm deleting label {name:?}"));
}
}
Ok(())
}
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}"
)),
}
}
async fn fixture_states(key: &str, team_key: &str) -> Result<(String, String), String> {
let data = linear(
key,
TEAM_STATES,
json!({"key":team_key}),
"live workflow state discovery",
)
.await?;
let states = data
.pointer("/teams/nodes/0/states/nodes")
.and_then(Value::as_array)
.ok_or_else(|| {
format!("LINEAR_WRITE_TEAM={team_key} names no team this credential can see")
})?;
let named = |wanted: &str| {
states
.iter()
.find(|state| state.get("type").and_then(Value::as_str) == Some(wanted))
.and_then(|state| state.get("name").and_then(Value::as_str))
.map(str::to_owned)
};
match (named("unstarted"), named("completed")) {
(Some(open), Some(done)) => Ok((open, done)),
_ => Err(format!(
"team {team_key} has no workflow state of type unstarted and one of type \
completed, which this lane needs to file two issues a status filter can \
separate; add them to that team or point LINEAR_WRITE_TEAM at a scratch team \
that has them"
)),
}
}
async fn fixture_project_status(key: &str) -> Result<String, String> {
let data = linear(
key,
PROJECT_STATUSES,
json!({}),
"live project status discovery",
)
.await?;
let statuses = data
.pointer("/projectStatuses/nodes")
.and_then(Value::as_array)
.ok_or_else(|| "live project status discovery returned no statuses".to_owned())?;
let names = statuses
.iter()
.filter_map(|status| status.get("name").and_then(Value::as_str))
.collect::<Vec<_>>();
names
.iter()
.find(|name| {
names
.iter()
.filter(|other| other.eq_ignore_ascii_case(name))
.count()
== 1
})
.map(|name| (*name).to_owned())
.ok_or_else(|| {
"this workspace has no project status name that resolves uniquely, and the \
source refuses one two statuses answer to"
.to_owned()
})
}
async fn create_label(key: &str, team: &str, name: &str) -> Result<(), String> {
let data = linear(
key,
LABEL_CREATE,
json!({"input":{"name":name,"teamId":team,"color":"#bec2c8"}}),
"live label creation",
)
.await?;
if data
.pointer("/issueLabelCreate/issueLabel/name")
.and_then(Value::as_str)
!= Some(name)
{
return Err(format!(
"Linear did not confirm creating the label {name:?}"
));
}
Ok(())
}
fn label(name: &str) -> Label {
Label {
id: NativeId("live-source-label".into()),
name: name.to_owned(),
color: None,
}
}
fn blocks(far: &NativeId, kind: ItemKind) -> DependencyEdge {
DependencyEdge {
from: DependencyEndpoint::from_native(NativeId("live-source-item".into()), kind),
to: DependencyEndpoint::from_native(far.clone(), kind),
kind: DependencyKind::Blocks,
}
}
fn page(limit: u32) -> PageRequest {
PageRequest {
cursor: None,
limit,
}
}
fn sorted(mut titles: Vec<String>) -> Vec<String> {
titles.sort();
titles
}
async fn task_titles(
source: &dyn TaskSource,
query: &TaskQuery,
what: &str,
) -> Result<Vec<String>, String> {
Ok(sorted(
source
.query_tasks(query, &page(50))
.await
.map_err(|error| format!("live {what} failed: {error}"))?
.items
.into_iter()
.map(|task| task.title)
.collect(),
))
}
struct LiveRun {
key: String,
process_id: u32,
stamp_micros: i64,
open_state: String,
done_state: String,
project_status: String,
}
async fn drive_every_declared_capability(
run: &LiveRun,
source: &dyn TaskSource,
team_id: &str,
) -> Result<(), String> {
let title = |offset: i64| artifact_title(run.process_id, run.stamp_micros + offset);
let (alpha, beta) = (title(0), title(1));
let (first, second, orphan) = (title(2), title(3), title(4));
let run_label = artifact_label(run.process_id, run.stamp_micros);
let only_label = artifact_label(run.process_id, run.stamp_micros + 1);
create_label(&run.key, team_id, &run_label).await?;
create_label(&run.key, team_id, &only_label).await?;
let open = Status {
category: StatusCategory::Todo,
name: run.open_state.clone(),
};
let done = Status {
category: StatusCategory::Done,
name: run.done_state.clone(),
};
let project_status = Status {
category: StatusCategory::Todo,
name: run.project_status.clone(),
};
let scoped = || TaskQuery {
labels: LabelFilter {
any_of: vec![run_label.clone()],
..LabelFilter::default()
},
..TaskQuery::default()
};
let project = |name: &str| Project {
id: NativeId("live-source-item".into()),
title: name.to_owned(),
content: Some("temporary credentialed write; the live lane removes this".into()),
status: project_status.clone(),
labels: vec![],
url: None,
location: None,
created_at: None,
updated_at: None,
metadata: BTreeMap::new(),
repositories: vec![],
};
let task = |name: &str, status: &Status, under: Option<&NativeId>, labels: Vec<Label>| Task {
id: NativeId("live-source-item".into()),
title: name.to_owned(),
content: Some("temporary credentialed write; the live lane removes this".into()),
status: status.clone(),
labels,
project: under.cloned(),
url: None,
location: None,
created_at: None,
updated_at: None,
metadata: BTreeMap::new(),
repositories: vec![],
};
let alpha_id = source
.write_project(&ItemWrite {
target: None,
item: project(&alpha),
depends_on: vec![],
})
.await
.map_err(|error| format!("live project write of {alpha:?} failed: {error}"))?;
let beta_id = source
.write_project(&ItemWrite {
target: None,
item: project(&beta),
depends_on: vec![blocks(&alpha_id, ItemKind::Project)],
})
.await
.map_err(|error| format!("live project write of {beta:?} failed: {error}"))?;
let first_id = source
.write_task(&ItemWrite {
target: None,
item: task(
&first,
&open,
Some(&alpha_id),
vec![label(&run_label), label(&only_label)],
),
depends_on: vec![],
})
.await
.map_err(|error| format!("live task write of {first:?} failed: {error}"))?;
let second_id = source
.write_task(&ItemWrite {
target: None,
item: task(&second, &open, Some(&beta_id), vec![label(&run_label)]),
depends_on: vec![blocks(&first_id, ItemKind::Task)],
})
.await
.map_err(|error| format!("live task write of {second:?} failed: {error}"))?;
let orphan_id = source
.write_task(&ItemWrite {
target: None,
item: task(&orphan, &done, None, vec![label(&run_label)]),
depends_on: vec![],
})
.await
.map_err(|error| format!("live task write of {orphan:?} failed: {error}"))?;
let mut settled = false;
for _ in 0..20 {
if task_titles(source, &scoped(), "fixture settling read")
.await?
.len()
== 3
{
settled = true;
break;
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
let all_three = sorted(vec![first.clone(), second.clone(), orphan.clone()]);
ensure!(
settled,
"the live fixture never became readable: Linear never returned all three issues \
labelled {run_label}"
);
ensure!(
task_titles(source, &scoped(), "scoped read").await? == all_three,
"the three issues this run created did not come back as {all_three:?}"
);
let mut listed = Vec::new();
let mut cursor = None;
loop {
let step = source
.query_projects(
&ProjectQuery::default(),
&PageRequest { cursor, limit: 250 },
)
.await
.map_err(|error| format!("live project listing failed: {error}"))?;
listed.extend(step.items.into_iter().map(|project| project.title));
cursor = step.next;
if cursor.is_none() {
break;
}
ensure!(listed.len() < 100_000, "the project walk must terminate");
}
ensure!(
listed.contains(&alpha) && listed.contains(&beta),
"the two projects this run created are not in Linear's own project listing"
);
let under = |id: &NativeId| TaskQuery {
project: ProjectFilter::Is(id.clone()),
..TaskQuery::default()
};
let under_alpha = task_titles(source, &under(&alpha_id), "project filter").await?;
ensure!(
under_alpha == vec![first.clone()],
"the issues of one of this run's two projects came back as {under_alpha:?}"
);
let under_beta = task_titles(source, &under(&beta_id), "project filter").await?;
ensure!(
under_beta == vec![second.clone()],
"the issues of the other of this run's two projects came back as {under_beta:?}"
);
let orphans = task_titles(
source,
&TaskQuery {
project: ProjectFilter::Orphans,
..scoped()
},
"orphan selection",
)
.await?;
ensure!(
orphans == vec![orphan.clone()],
"this run's issues belonging to no project came back as {orphans:?}"
);
let carrying = task_titles(
source,
&TaskQuery {
labels: LabelFilter {
any_of: vec![only_label.clone()],
..LabelFilter::default()
},
..TaskQuery::default()
},
"label filter",
)
.await?;
ensure!(
carrying == vec![first.clone()],
"this run's issues carrying its second label came back as {carrying:?}"
);
let without = task_titles(
source,
&TaskQuery {
labels: LabelFilter {
any_of: vec![run_label.clone()],
none_of: vec![only_label.clone()],
..LabelFilter::default()
},
..TaskQuery::default()
},
"label exclusion",
)
.await?;
ensure!(
without == sorted(vec![second.clone(), orphan.clone()]),
"this run's issues not carrying its second label came back as {without:?}"
);
let todo = task_titles(
source,
&TaskQuery {
statuses: vec![StatusCategory::Todo],
..scoped()
},
"status filter",
)
.await?;
ensure!(
todo == sorted(vec![first.clone(), second.clone()]),
"this run's unstarted issues came back as {todo:?}"
);
let finished = task_titles(
source,
&TaskQuery {
statuses: vec![StatusCategory::Done],
..scoped()
},
"status filter",
)
.await?;
ensure!(
finished == vec![orphan.clone()],
"this run's completed issue came back as {finished:?}"
);
for fields in [
TextFields::Title,
TextFields::Content,
TextFields::TitleOrContent,
] {
let searched = task_titles(
source,
&TaskQuery {
text: Some(TextQuery {
terms: first.clone(),
fields,
}),
..scoped()
},
"ignored search",
)
.await?;
ensure!(
searched == all_three,
"a {fields:?} search this source declares unsupported narrowed the result to \
{searched:?} instead of returning the wider set"
);
}
let task_edge = DependencyEdge {
from: DependencyEndpoint::from_native(second_id.clone(), ItemKind::Task),
to: DependencyEndpoint::from_native(first_id.clone(), ItemKind::Task),
kind: DependencyKind::Blocks,
};
let project_edge = DependencyEdge {
from: DependencyEndpoint::from_native(beta_id.clone(), ItemKind::Project),
to: DependencyEndpoint::from_native(alpha_id.clone(), ItemKind::Project),
kind: DependencyKind::Blocks,
};
for (near, direction, expected, level) in [
(&second_id, Direction::DependsOn, &task_edge, "task"),
(&first_id, Direction::DependedOnBy, &task_edge, "task"),
(&beta_id, Direction::DependsOn, &project_edge, "project"),
(&alpha_id, Direction::DependedOnBy, &project_edge, "project"),
] {
let read = if level == "task" {
source.task_dependencies(near, direction, &page(50)).await
} else {
source
.project_dependencies(near, direction, &page(50))
.await
}
.map_err(|error| format!("live {level} {direction:?} dependency read failed: {error}"))?;
ensure!(
read.items == vec![expected.clone()],
"the {level} {direction:?} read of {} returned {:?}",
near.0,
read.items
);
}
let whole = source
.query_tasks(&scoped(), &page(50))
.await
.map_err(|error| format!("live whole-page read failed: {error}"))?
.items
.into_iter()
.map(|task| task.title)
.collect::<Vec<_>>();
let mut walked = Vec::new();
let mut cursor = None;
loop {
let step = source
.query_tasks(&scoped(), &PageRequest { cursor, limit: 1 })
.await
.map_err(|error| format!("live paged read failed: {error}"))?;
ensure!(
step.items.len() <= 1,
"a page of one returned {} rows",
step.items.len()
);
walked.extend(step.items.into_iter().map(|task| task.title));
cursor = step.next;
if cursor.is_none() {
break;
}
ensure!(
walked.len() <= 10,
"the paged walk over this run's own three issues must terminate"
);
}
ensure!(
walked == whole,
"a walk in pages of one reached {walked:?} where one whole page reports {whole:?}"
);
let ceiling = source.capabilities().max_page_size;
let clamped = sorted(
source
.query_tasks(
&scoped(),
&PageRequest {
cursor: None,
limit: ceiling + 1,
},
)
.await
.map_err(|error| {
format!(
"a limit one above the declared ceiling was refused rather than clamped: \
{error}"
)
})?
.items
.into_iter()
.map(|task| task.title)
.collect(),
);
ensure!(
clamped == all_three,
"a limit above the declared ceiling returned {clamped:?} rather than this run's own \
three issues"
);
linear(
&run.key,
ISSUE_PAGE_PROBE,
json!({"first":ceiling}),
"page size probe at the declared maximum",
)
.await?;
let written = source
.get_task(&first_id)
.await
.map_err(|error| format!("live task read-back failed: {error}"))?
.ok_or_else(|| "the written issue was not readable by its own id".to_owned())?;
ensure!(
written.title == first && written.status.category == StatusCategory::Todo,
"the live write did not round-trip its title and status: {written:?}"
);
let closed_back = source
.get_task(&orphan_id)
.await
.map_err(|error| format!("live completed-issue read-back failed: {error}"))?
.ok_or_else(|| "the completed issue was not readable by its own id".to_owned())?;
ensure!(
closed_back.status.category == StatusCategory::Done,
"the live write filed under done read back as {:?}",
closed_back.status.category
);
drive_documents(run, source, &alpha_id, &title(5), &title(6)).await
}
async fn drive_documents(
run: &LiveRun,
source: &dyn TaskSource,
under: &NativeId,
filed: &str,
loose: &str,
) -> Result<(), String> {
let document = |title: &str, project: Option<&NativeId>| Document {
id: NativeId("live-source-item".into()),
title: title.to_owned(),
content: Some("temporary credentialed write; the live lane removes this".into()),
project: project.cloned(),
labels: vec![],
url: None,
location: None,
created_at: None,
updated_at: None,
metadata: [("caller.count".to_owned(), json!(3))]
.into_iter()
.collect(),
repositories: vec![],
};
let write = |item: Document| ItemWrite {
target: None,
item,
depends_on: vec![],
};
let filed_id = source
.write_document(&write(document(filed, Some(under))))
.await
.map_err(|error| format!("live document write of {filed:?} failed: {error}"))?;
let loose_id = source
.write_document(&write(document(loose, None)))
.await
.map_err(|error| format!("live document write of {loose:?} failed: {error}"))?;
let read = source
.get_document(&filed_id)
.await
.map_err(|error| format!("live document read-back failed: {error}"))?
.ok_or_else(|| "the written document was not readable by its own id".to_owned())?;
ensure!(
read.title == filed,
"the live document write did not round-trip its title: {read:?}"
);
ensure!(
read.content.as_deref() == Some("temporary credentialed write; the live lane removes this"),
"the visible body a read reports is the text a person wrote: {:?}",
read.content
);
ensure!(
read.metadata.get("caller.count") == Some(&json!(3)),
"a caller's key did not round-trip with its JSON type: {:?}",
read.metadata
);
ensure!(
read.labels.is_empty(),
"Linear's own document type has no labels, so a read reports none: {:?}",
read.labels
);
ensure!(
matches!(&read.location, Some(Location::Url(url)) if url.starts_with("https://")),
"a document says where it is, as a link: {:?}",
read.location
);
ensure!(
read.project.as_ref() == Some(under),
"the document filed under this run's project read back filed under {:?}",
read.project
);
let refusal = source
.write_document(&write(Document {
labels: vec![label(&artifact_label(run.process_id, run.stamp_micros))],
..document(filed, Some(under))
}))
.await;
ensure!(
refusal
.as_ref()
.err()
.is_some_and(|error| error.to_string().contains("labels")),
"a document write carrying a label must be refused by name, and was {refusal:?}"
);
let titles = |query: DocumentQuery| async move {
let mut found = source
.query_documents(&query, &page(250))
.await
.map_err(|error| format!("live document read failed: {error}"))?
.items
.into_iter()
.map(|document| document.title)
.filter(|title| is_artifact(ARTIFACT_PREFIX, title))
.collect::<Vec<_>>();
found.sort();
Ok::<_, String>(found)
};
let both = sorted(vec![filed.to_owned(), loose.to_owned()]);
ensure!(
titles(DocumentQuery::default()).await? == both,
"the two documents this run created did not come back as {both:?}"
);
ensure!(
titles(DocumentQuery {
project: ProjectFilter::Is(under.clone()),
..DocumentQuery::default()
})
.await?
== vec![filed.to_owned()],
"a document listing narrowed to this run's project kept the wrong documents"
);
ensure!(
titles(DocumentQuery {
project: ProjectFilter::Orphans,
..DocumentQuery::default()
})
.await?
== vec![loose.to_owned()],
"a document listing narrowed to the orphans kept the wrong documents"
);
ensure!(
titles(DocumentQuery {
labels: LabelFilter {
any_of: vec![artifact_label(run.process_id, run.stamp_micros)],
..LabelFilter::default()
},
..DocumentQuery::default()
})
.await?
.is_empty(),
"no Linear document carries a label, so a query demanding one keeps nothing"
);
for id in [&filed_id, &loose_id] {
source
.delete_document(id)
.await
.map_err(|error| format!("live document removal failed: {error}"))?;
}
ensure!(
source
.get_document(&loose_id)
.await
.is_ok_and(|held| held.is_none()),
"a document this run removed is still readable"
);
Ok(())
}
#[ignore = "the live lane: run it with `just test-live onetaskgraph-linear`"]
#[tokio::test]
async fn real_linear_applies_every_declared_capability_and_leaves_no_residue() {
let Some(key) = env::var("LINEAR_API_KEY")
.ok()
.filter(|value| !value.trim().is_empty())
else {
eprintln!("skipped live Linear journey: LINEAR_API_KEY is missing");
return;
};
let Some(team) = env::var("LINEAR_WRITE_TEAM")
.ok()
.filter(|value| !value.trim().is_empty())
else {
eprintln!(
"skipped live Linear journey: LINEAR_WRITE_TEAM is missing, and this lane writes \
only to the scratch team that name gives rather than discovering one; no mutation \
was sent"
);
return;
};
let source = onetaskgraph_linear::Plugin
.build(
&SourceName::new("live").unwrap(),
&json!({"team":team}),
&Environment,
)
.unwrap_or_else(|error| panic!("the Linear live lane cannot use this team: {error}"));
assert!(source.health().await.unwrap().reachable);
assert_eq!(
source.capabilities(),
Capabilities {
projects: Support::Native,
documents: Support::Native,
orphan_tasks: Support::Native,
filter_by_label: Support::Native,
filter_by_status: Support::Native,
search_title: Support::Unsupported,
search_content: Support::Unsupported,
task_dependencies: DependencySupport::BothDirections,
project_dependencies: DependencySupport::BothDirections,
max_page_size: 250,
}
);
remove_artifacts(&key, &is_artifact)
.await
.unwrap_or_else(|error| {
panic!("live residue left by an earlier interrupted run could not be cleared: {error}")
});
let team_id = linear(&key, TEAM_STATES, json!({"key":team}), "live team lookup")
.await
.and_then(|data| {
data.pointer("/teams/nodes/0/id")
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| {
format!("LINEAR_WRITE_TEAM={team} names no team this credential can see")
})
})
.unwrap_or_else(|error| {
panic!("the Linear live lane cannot reach its scratch team: {error}")
});
let (open_state, done_state) = fixture_states(&key, &team)
.await
.unwrap_or_else(|error| panic!("the Linear live lane cannot file its fixture: {error}"));
let project_status = fixture_project_status(&key)
.await
.unwrap_or_else(|error| panic!("the Linear live lane cannot file its projects: {error}"));
let run = LiveRun {
key: key.clone(),
process_id: std::process::id(),
stamp_micros: chrono::Utc::now().timestamp_micros(),
open_state,
done_state,
project_status,
};
let mine = format!("{}-", run.process_id);
let is_this_runs = move |prefix: &str, name: &str| {
is_artifact(prefix, name) && name.starts_with(&format!("{prefix}{mine}"))
};
run_then_cleanup(
|| drive_every_declared_capability(&run, source.as_ref(), &team_id),
|| remove_artifacts(&key, &is_this_runs),
)
.await
.unwrap_or_else(|error| panic!("Linear live capability journey failed: {error}"));
}