use std::{collections::BTreeMap, env};
use onetaskgraph_live::artifact::{Run, Sweep, now_micros};
use onetaskgraph_live::{Credential, Exclusivity, Session, missing, required};
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)+));
}
};
}
#[allow(dead_code)]
mod cleanup;
use cleanup::{
ARTIFACT_PREFIX, ISSUE_PAGE_PROBE, LABEL_CREATE, PROJECT_STATUSES, SESSION_NAME, TEAM_STATES,
artifact_label, artifact_title, is_this_runs, linear, remove_artifacts, run_then_cleanup,
sweep_orphans,
};
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,
id: Run,
stamp_micros: u64,
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: u64| artifact_title(run.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.id, run.stamp_micros);
let only_label = artifact_label(run.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: onetaskgraph_linear::MAX_PAGE_SIZE,
},
)
.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.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(onetaskgraph_linear::MAX_PAGE_SIZE))
.await
.map_err(|error| format!("live document read failed: {error}"))?
.items
.into_iter()
.map(|document| document.title)
.filter(|title| is_this_runs(run.id, 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.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(())
}
#[tokio::test]
async fn real_linear_applies_every_declared_capability_and_leaves_no_residue() {
let live_required = required(
env::var(onetaskgraph_live::REQUIRED_VARIABLE)
.ok()
.as_deref(),
)
.unwrap_or_else(|error| panic!("the Linear live lane cannot run: {error}"));
let skip = |reason: &str| -> Option<String> {
match missing(live_required, SESSION_NAME, reason) {
Ok(reason) => {
eprintln!("skipped live Linear journey: {reason}");
None
}
Err(error) => panic!("the Linear live lane cannot run: {error}"),
}
};
let Some(key) = env::var("LINEAR_API_KEY").ok().and_then(Credential::new) else {
skip("LINEAR_API_KEY is not set");
return;
};
let Some(team) = env::var("LINEAR_WRITE_TEAM")
.ok()
.filter(|value| !value.trim().is_empty())
else {
skip(
"LINEAR_WRITE_TEAM is not set, and this lane writes only to the scratch team that \
name gives rather than discovering one; no mutation was sent. Set it to that \
team's key — in CI it is the LINEAR_WRITE_TEAM repository variable, and locally \
it is an environment variable",
);
return;
};
let session = Session::open(SESSION_NAME, key, Exclusivity::OneAtATime)
.unwrap_or_else(|declined| declined.refuse());
let key = session.credential().expose().to_owned();
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: onetaskgraph_linear::MAX_PAGE_SIZE,
}
);
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(),
id: Run::current(),
stamp_micros: now_micros(),
open_state,
done_state,
project_status,
};
let id = run.id;
run_then_cleanup(
|| drive_every_declared_capability(&run, source.as_ref(), &team_id),
|| async {
let mine = remove_artifacts(&key, &|prefix, name| is_this_runs(id, prefix, name)).await;
let orphans = sweep_orphans(&key, &Sweep::of(id, now_micros())).await;
match (mine, orphans) {
(Ok(()), Ok(())) => Ok(()),
(Err(mine), Ok(())) => Err(mine),
(Ok(()), Err(orphans)) => Err(format!(
"residue left by an earlier interrupted run could not be cleared: {orphans}"
)),
(Err(mine), Err(orphans)) => Err(format!(
"{mine}; additionally, residue left by an earlier interrupted run could \
not be cleared: {orphans}"
)),
}
},
)
.await
.unwrap_or_else(|error| panic!("Linear live capability journey failed: {error}"));
}