use std::path::{Path, PathBuf};
use std::process::Output;
use serde_json::{Value, json};
use crate::common::{Sandbox, stderr, stdout};
use crate::fixtures::{LINEAR_TEAM_STATES, LinearWorkspace, document, linear_workspace};
fn eng_mapping() -> Value {
json!({
"backlog": "Proposed",
"draft": "Backlog",
"todo": "Todo",
"queued": "Queued",
"in-progress": "In Progress",
"unknown": "Needs Attention",
"done": "Done",
"cancelled": "Canceled",
})
}
const CATEGORIES: [&str; 8] = [
"draft",
"backlog",
"todo",
"queued",
"in-progress",
"unknown",
"done",
"cancelled",
];
fn run(sandbox: &Sandbox, arguments: &[&str]) -> Output {
sandbox
.command()
.args(arguments)
.assert()
.get_output()
.clone()
}
fn exits(sandbox: &Sandbox, arguments: &[&str], code: i32) -> Output {
let output = run(sandbox, arguments);
assert_eq!(
output.status.code(),
Some(code),
"`onetaskgraph {}` exited {:?}\nstdout:\n{}\nstderr:\n{}",
arguments.join(" "),
output.status.code(),
stdout(&output),
stderr(&output)
);
output
}
fn answered(sandbox: &Sandbox, arguments: &[&str]) -> Value {
let output = exits(sandbox, arguments, 0);
serde_json::from_str(&stdout(&output)).unwrap_or_else(|error| {
panic!(
"`onetaskgraph {}` wrote no JSON ({error}):\n{}",
arguments.join(" "),
stdout(&output)
)
})
}
fn shown(sandbox: &Sandbox, id: &str) -> Value {
answered(sandbox, &["--json", "task", "show", id])["items"][0]["item"].clone()
}
fn status(sandbox: &Sandbox, id: &str) -> Value {
let item = shown(sandbox, id);
json!([item["status"]["category"], item["status"]["name"]])
}
fn type_of(name: &str) -> &'static str {
LINEAR_TEAM_STATES
.iter()
.find(|state| state.1 == name)
.unwrap_or_else(|| panic!("the team has no state {name}"))
.2
}
fn issue(id: &str, state: &str, extra: Value) -> Value {
let kind = type_of(state);
let category = match kind {
"unstarted" => "todo",
"started" => "in-progress",
"completed" => "done",
"canceled" => "cancelled",
"backlog" => "backlog",
_ => "unknown",
};
let mut row = json!({
"id": id, "title": format!("Issue {id}"), "content": format!("The body of {id}."),
"status": {"name": state, "category": category},
"_linear_state": {"name": state, "type": kind},
"labels": [], "priority": "none",
});
for (key, value) in extra.as_object().expect("an object") {
row[key] = value.clone();
}
row
}
fn held(tasks: Vec<Value>, projects: Vec<Value>, documents: Vec<Value>) -> Value {
json!({
"tasks": tasks, "projects": projects, "documents": documents, "labels": [],
"task_dependencies": [], "project_dependencies": [],
})
}
fn project(id: &str) -> Value {
json!({"id": id, "title": format!("Project {id}"), "content": format!("About {id}."),
"status": {"category": "in-progress", "name": "Doing"}, "labels": []})
}
fn folder(sandbox: &Sandbox, name: &str, files: &[(&str, &str)]) -> PathBuf {
let root = sandbox.subdirectory(name);
for kind in ["tasks", "projects", "documents"] {
std::fs::create_dir_all(root.join(kind)).expect("a folder");
}
for (relative, text) in files {
std::fs::write(root.join(relative), text).expect("a file");
}
root
}
fn markdown(root: &Path) -> Value {
let words = CATEGORIES
.iter()
.map(|category| ((*category).to_owned(), json!(category)))
.collect::<serde_json::Map<_, _>>();
json!({"plugin": "local-md", "config": {"root": root, "status_mapping": words}})
}
fn linear(config: &Value, extra: Value) -> Value {
let mut config = config.clone();
for (key, value) in extra.as_object().expect("an object") {
config[key] = value.clone();
}
json!({"plugin": "linear", "config": config})
}
fn mutations_since(workspace: &LinearWorkspace, from: usize) -> Vec<String> {
workspace
.served()
.into_iter()
.skip(from)
.filter(|(query, _)| query.trim_start().starts_with("mutation"))
.map(|(query, _)| {
query
.split_once('{')
.and_then(|(_, rest)| rest.trim_start().split('(').next())
.unwrap_or_default()
.to_owned()
})
.collect()
}
fn body(sandbox: &Sandbox, text: &str) -> String {
let path = sandbox.subdirectory("bodies").join("body.md");
std::fs::write(&path, text).expect("a body file");
path.to_string_lossy().into_owned()
}
fn scoped_workspace(sandbox: &Sandbox) -> (Value, LinearWorkspace) {
let mut scope = project("LP-1");
scope["metadata"] = json!({"onetaskgraph.origin": "plan:PX"});
linear_workspace(
sandbox,
held(
vec![
issue("I-IN", "Todo", json!({"project": "LP-1"})),
issue("I-OUT", "Todo", json!({"project": "LP-2"})),
issue("I-NONE", "Todo", json!({})),
],
vec![scope, project("LP-2")],
vec![
json!({"id": "D-IN", "title": "In", "content": "in", "project": "LP-1", "labels": []}),
json!({"id": "D-OUT", "title": "Out", "content": "out", "project": "LP-2", "labels": []}),
],
),
)
}
fn ids(answer: &Value) -> Vec<String> {
answer["items"]
.as_array()
.expect("a page")
.iter()
.map(|item| item["id"].as_str().expect("an id").to_owned())
.collect()
}
#[test]
fn a_project_scoped_linear_source_reads_and_writes_that_project_alone() {
let sandbox = Sandbox::new();
let (config, workspace) = scoped_workspace(&sandbox);
let plan = folder(
&sandbox,
"plan",
&[
(
"tasks/F-1.md",
"---\ntitle: From the plan\nstatus: todo\n---\nbody\n",
),
(
"projects/PX.md",
"---\ntitle: The scope's own\nstatus: todo\n---\nbody\n",
),
(
"projects/PY.md",
"---\ntitle: Another project\nstatus: todo\n---\nbody\n",
),
("documents/D-1.md", "---\ntitle: A plan note\n---\nnote\n"),
],
);
sandbox.project_document(&document(&json!({
"scoped": linear(&config, json!({"project": "LP-1"})),
"wide": linear(&config, json!({})),
"plan": markdown(&plan),
})));
assert_eq!(
ids(&answered(
&sandbox,
&["--json", "task", "list", "--source", "scoped"]
)),
["scoped:I-IN"]
);
assert_eq!(
ids(&answered(
&sandbox,
&["--json", "project", "list", "--source", "scoped"]
)),
["scoped:LP-1"]
);
assert_eq!(
ids(&answered(
&sandbox,
&["--json", "document", "list", "--source", "scoped"]
)),
["scoped:D-IN"]
);
for (verb, id) in [
("task", "scoped:I-OUT"),
("project", "scoped:LP-2"),
("document", "scoped:D-OUT"),
] {
exits(&sandbox, &[verb, "show", id], 1);
}
assert_eq!(
ids(&answered(
&sandbox,
&[
"--json",
"document",
"list",
"--source",
"scoped",
"--project",
"LP-2"
]
)),
Vec::<String>::new()
);
assert_eq!(
ids(&answered(
&sandbox,
&["--json", "task", "list", "--source", "wide"]
)),
["wide:I-IN", "wide:I-OUT", "wide:I-NONE"]
);
assert_eq!(
ids(&answered(
&sandbox,
&["--json", "project", "list", "--source", "wide"]
)),
["wide:LP-1", "wide:LP-2"]
);
let copied = answered(
&sandbox,
&["--json", "task", "copy", "plan:F-1", "--to", "scoped"],
);
let destination = copied["items"][0]["destination"]
.as_str()
.expect("a destination")
.split_once(':')
.expect("source:native")
.1
.to_owned();
assert_eq!(workspace.project_of(&destination).as_deref(), Some("LP-1"));
assert!(
ids(&answered(
&sandbox,
&["--json", "task", "list", "--source", "scoped"]
))
.contains(&format!("scoped:{destination}")),
"the copy reads back through the scoped source"
);
let file = body(&sandbox, "A created body.");
answered(
&sandbox,
&[
"--json",
"task",
"create",
"scoped",
"--project",
"LP-1",
"--title",
"Scoped",
"--body-file",
&file,
],
);
let created = workspace.issue_titled("Scoped").expect("created");
assert_eq!(workspace.project_of(&created).as_deref(), Some("LP-1"));
let from = workspace.served().len();
let refused = exits(
&sandbox,
&[
"task",
"create",
"scoped",
"--project",
"LP-2",
"--title",
"Elsewhere",
"--body-file",
&file,
],
1,
);
let said = stderr(&refused);
assert!(said.contains("LP-1") && said.contains("LP-2"), "{said}");
assert_eq!(mutations_since(&workspace, from), Vec::<String>::new());
let copied = answered(
&sandbox,
&["--json", "document", "copy", "plan:D-1", "--to", "scoped"],
);
let destination = copied["items"][0]["destination"]
.as_str()
.expect("a destination")
.split_once(':')
.expect("source:native")
.1
.to_owned();
assert_eq!(
workspace.document_project(&destination).as_deref(),
Some("LP-1")
);
let from = workspace.served().len();
let refused = exits(
&sandbox,
&[
"document",
"create",
"scoped",
"--project",
"LP-2",
"--title",
"Elsewhere",
"--body-file",
&file,
],
1,
);
let said = stderr(&refused);
assert!(said.contains("LP-1") && said.contains("LP-2"), "{said}");
assert_eq!(mutations_since(&workspace, from), Vec::<String>::new());
answered(
&sandbox,
&[
"--json",
"document",
"create",
"scoped",
"--project",
"LP-1",
"--title",
"Scoped note",
"--body-file",
&file,
],
);
let created = workspace.document_titled("Scoped note").expect("created");
assert_eq!(
workspace.document_project(&created).as_deref(),
Some("LP-1")
);
let updated = answered(
&sandbox,
&["--json", "project", "copy", "plan:PX", "--to", "scoped"],
);
assert_eq!(
updated["items"][0]["destination"], "scoped:LP-1",
"{updated:#}"
);
let from = workspace.served().len();
let refused = exits(
&sandbox,
&["project", "copy", "plan:PY", "--to", "scoped"],
1,
);
let said = stderr(&refused);
assert!(said.contains("scoped to the Linear project LP-1"), "{said}");
assert_eq!(mutations_since(&workspace, from), Vec::<String>::new());
}
fn documents_since(workspace: &LinearWorkspace, from: usize) -> Vec<String> {
workspace
.served()
.into_iter()
.skip(from)
.map(|(query, _)| query)
.collect()
}
#[test]
fn a_project_scoped_linear_source_writes_a_status_to_the_issue_it_names_wherever_it_is_filed() {
let sandbox = Sandbox::new();
let (config, workspace) = scoped_workspace(&sandbox);
let plan = folder(
&sandbox,
"plan",
&[(
"projects/PY.md",
"---\ntitle: Another project\nstatus: done\n---\nbody\n",
)],
);
sandbox.project_document(&document(&json!({
"scoped": linear(&config, json!({"project": "LP-1"})),
"plan": markdown(&plan),
})));
let resolution = onetaskgraph_linear::graphql::RESOLUTION.to_owned();
let mutation = onetaskgraph_linear::graphql::ISSUE_UPDATE_READ.to_owned();
let from = workspace.served().len();
let answer = answered(
&sandbox,
&["--json", "task", "status", "set", "scoped:I-OUT", "done"],
);
assert_eq!(answer["id"], "scoped:I-OUT", "{answer:#}");
assert_eq!(
answer["status"],
json!({"category": "done", "name": "Shipped"}),
"{answer:#}"
);
assert_eq!(
documents_since(&workspace, from),
[resolution.clone(), mutation.clone()]
);
assert_eq!(workspace.state_of("I-OUT").as_deref(), Some("Shipped"));
let from = workspace.served().len();
answered(
&sandbox,
&[
"--json",
"task",
"update",
"scoped:I-OUT",
"--status",
"in-progress",
],
);
assert_eq!(
documents_since(&workspace, from),
[resolution.clone(), mutation.clone()]
);
assert_eq!(workspace.state_of("I-OUT").as_deref(), Some("Doing"));
let from = workspace.served().len();
answered(
&sandbox,
&["--json", "task", "status", "set", "scoped:I-IN", "done"],
);
assert_eq!(documents_since(&workspace, from), [resolution, mutation]);
assert_eq!(workspace.state_of("I-IN").as_deref(), Some("Shipped"));
exits(&sandbox, &["task", "show", "scoped:I-OUT"], 1);
assert_eq!(
ids(&answered(
&sandbox,
&["--json", "task", "list", "--source", "scoped"]
)),
["scoped:I-IN"]
);
let from = workspace.served().len();
let refused = exits(
&sandbox,
&["project", "copy", "plan:PY", "--to", "scoped", "--no-tasks"],
1,
);
let said = stderr(&refused);
assert!(said.contains("scoped to the Linear project LP-1"), "{said}");
assert_eq!(mutations_since(&workspace, from), Vec::<String>::new());
let written = [
onetaskgraph_linear::graphql::RESOLUTION,
onetaskgraph_linear::graphql::PROJECT_LABEL,
onetaskgraph_linear::graphql::PROJECT_CREATE,
onetaskgraph_linear::graphql::PROJECT_REWRITE,
];
assert!(
documents_since(&workspace, from)
.iter()
.all(|query| !written.contains(&query.as_str())),
"write_project sent a request before refusing"
);
}
fn slotted_workspace(sandbox: &Sandbox) -> (Value, LinearWorkspace) {
let held_slot = "Prose a person wrote,\n\n spaced their own way. \n\n<!-- onetaskgraph.metadata\n{\"caller.kept\":[1,{\"deep\":true}]}\n\\-->";
let mut task = issue("I-M", "Todo", json!({"project": "LP-1"}));
task["_linear_description"] = json!(held_slot);
let mut item = project("LP-1");
item["_linear_description"] = json!(held_slot);
linear_workspace(
sandbox,
held(
vec![task],
vec![item],
vec![
json!({"id": "D-M", "title": "A document", "content": "", "project": "LP-1",
"labels": [], "_linear_description": held_slot}),
],
),
)
}
fn split_slot(field: &str) -> (String, Value) {
let start = field
.rfind("<!-- onetaskgraph.metadata")
.expect("the field ends in a slot");
let slot = &field[start + "<!-- onetaskgraph.metadata".len()..];
let encoded = match slot.strip_prefix(" `") {
Some(span) => span.trim_end().trim_end_matches("` -->"),
None => slot
.trim_start_matches('\n')
.trim_end_matches("-->")
.trim_end_matches('\\')
.trim_end_matches('\n'),
};
(
field[..start].to_owned(),
serde_json::from_str(encoded).expect("the slot holds JSON"),
)
}
#[test]
fn a_metadata_set_moves_only_the_slot_and_a_render_only_the_body_and_its_provenance_on_linear() {
let sandbox = Sandbox::new();
let (config, workspace) = slotted_workspace(&sandbox);
let plan = folder(&sandbox, "plan", &[]);
sandbox.project_document(&document(&json!({
"linear": linear(&config, json!({})),
"plan": markdown(&plan),
})));
for (verb, kind, id) in [
("task", "tasks", "I-M"),
("project", "projects", "LP-1"),
("document", "documents", "D-M"),
] {
let before = workspace.long_form(kind, id).expect("held");
let (above_before, slot_before) = split_slot(&before);
let from = workspace.served().len();
let answer = answered(
&sandbox,
&[
"--json",
verb,
"metadata",
"set",
&format!("linear:{id}"),
"myapp.review",
r#"{"approved":true}"#,
],
);
assert_eq!(
answer["value"],
json!({"approved": true}),
"{verb}: {answer}"
);
let after = workspace.long_form(kind, id).expect("held");
let (above_after, slot_after) = split_slot(&after);
assert_eq!(
above_after, above_before,
"{verb}: every byte above the slot"
);
let mut expected = slot_before.clone();
expected["myapp.review"] = json!({"approved": true});
assert_eq!(slot_after, expected, "{verb}: the slot gained one key");
assert_eq!(
mutations_since(&workspace, from).len(),
1,
"{verb}: one write"
);
let from = workspace.served().len();
answered(
&sandbox,
&[
"--json",
verb,
"metadata",
"set",
&format!("linear:{id}"),
"myapp.review",
r#"{"approved":true}"#,
],
);
assert_eq!(mutations_since(&workspace, from), Vec::<String>::new());
}
let template = sandbox.subdirectory("templates").join("note.md");
std::fs::write(&template, "A rendered body.\n").expect("a template");
let template = template.to_string_lossy().into_owned();
for (verb, kind, id) in [("task", "tasks", "I-M"), ("document", "documents", "D-M")] {
let (_, slot_before) = split_slot(&workspace.long_form(kind, id).expect("held"));
let regenerated = answered(
&sandbox,
&[
"--json",
verb,
"render",
&format!("linear:{id}"),
"--template",
&template,
"--no-interactive",
],
);
assert_eq!(regenerated["changed"], true, "{verb}: {regenerated}");
let after = workspace.long_form(kind, id).expect("held");
let (above_after, slot_after) = split_slot(&after);
assert_eq!(above_after, "A rendered body.\n\n\n", "{verb}: the body");
let mut kept = slot_after.clone();
let provenance = kept
.as_object_mut()
.expect("a slot")
.remove("onetaskgraph.template")
.expect("the provenance is recorded");
assert_eq!(provenance["template"], json!(template), "{verb}");
assert_eq!(kept, slot_before, "{verb}: every other slot entry kept");
}
let copied = answered(
&sandbox,
&["--json", "task", "copy", "linear:I-M", "--to", "plan"],
);
assert_eq!(copied["items"][0]["link"], "recorded", "{copied:#}");
let (_, slot) = split_slot(&workspace.long_form("tasks", "I-M").expect("held"));
assert_eq!(
slot["onetaskgraph.copies"],
json!({"plan": copied["items"][0]["destination"]})
);
let again = answered(
&sandbox,
&["--json", "task", "copy", "linear:I-M", "--to", "plan"],
);
assert_eq!(again["items"][0]["via"], "link", "{again:#}");
}
#[test]
fn a_linear_ticket_is_delivered_from_another_source_through_its_mapped_states() {
let sandbox = Sandbox::new();
let (config, workspace) = linear_workspace(
&sandbox,
held(
vec![
issue("L-T", "Todo", json!({})),
issue("L-P", "Proposed", json!({})),
issue("L-B", "Backlog", json!({})),
],
Vec::new(),
Vec::new(),
),
);
let plan = folder(
&sandbox,
"plan",
&[
(
"tasks/D-1.md",
"---\ntitle: Deliverer\nstatus: todo\n---\nbody\n",
),
(
"tasks/D-2.md",
"---\ntitle: Idle deliverer\nstatus: todo\n---\nbody\n",
),
],
);
sandbox.project_document(&document(&json!({
"linear": linear(&config, json!({"status_mapping": eng_mapping()})),
"plan": markdown(&plan),
})));
answered(
&sandbox,
&[
"--json",
"task",
"update",
"plan:D-1",
"--delivers",
"linear:L-T",
],
);
assert_eq!(
shown(&sandbox, "linear:L-T")["delivered_by"],
json!(["plan:D-1"])
);
let (_, slot) = split_slot(&workspace.long_form("tasks", "L-T").expect("held"));
assert_eq!(slot["onetaskgraph.delivered_by"], json!(["plan:D-1"]));
for (deliverer, ticket) in [
("queued", "Queued"),
("in-progress", "In Progress"),
("done", "Done"),
] {
let set = answered(
&sandbox,
&["--json", "task", "status", "set", "plan:D-1", deliverer],
);
assert_eq!(set["delivered"][0]["outcome"], "written", "{set:#}");
assert_eq!(
workspace.state_of("L-T").as_deref(),
Some(ticket),
"a {deliverer} deliverer"
);
assert_eq!(status(&sandbox, "linear:L-T"), json!([deliverer, ticket]));
}
answered(
&sandbox,
&[
"--json",
"task",
"update",
"plan:D-2",
"--delivers",
"linear:L-P",
"--delivers",
"linear:L-B",
],
);
let set = answered(
&sandbox,
&["--json", "task", "status", "set", "plan:D-2", "in-progress"],
);
assert_eq!(
set["delivered"]
.as_array()
.expect("entries")
.iter()
.map(|entry| entry["outcome"].clone())
.collect::<Vec<_>>(),
[json!("left"), json!("left")],
"{set:#}"
);
assert_eq!(workspace.state_of("L-P").as_deref(), Some("Proposed"));
assert_eq!(workspace.state_of("L-B").as_deref(), Some("Backlog"));
answered(
&sandbox,
&[
"--json",
"task",
"update",
"linear:L-P",
"--delivers",
"plan:D-2",
],
);
let (_, slot) = split_slot(&workspace.long_form("tasks", "L-P").expect("held"));
assert_eq!(slot["onetaskgraph.delivers"], json!(["plan:D-2"]));
assert_eq!(
shown(&sandbox, "linear:L-P")["delivers"],
json!(["plan:D-2"])
);
}
fn searched_workspace(sandbox: &Sandbox) -> (Value, LinearWorkspace) {
let slot = |json: &str| format!("\n\n<!-- onetaskgraph.metadata\n{json}\n-->");
let mut matching = issue(
"S-MATCH",
"Todo",
json!({"title": "Alpha follow-up", "priority": "high"}),
);
matching["_linear_description"] = json!(format!(
"The body.{}",
slot(
r#"{"caller.key":"v","onetaskgraph.origin":"plan:ORIG-1","orchestrator.follow-up":{"root_cause":"stale-cache"}}"#
)
));
let mut prose = issue(
"S-PROSE",
"Todo",
json!({"title": "Second", "priority": "low"}),
);
prose["_linear_description"] = json!(
"Quotes \"caller.key\":\"v\", \"onetaskgraph.origin\":\"plan:ORIG-1\" and \
\"root_cause\":\"stale-cache\" in its prose, and the word zebra."
);
let mut other = issue(
"S-OTHER",
"Todo",
json!({"title": "Third", "priority": "low"}),
);
other["_linear_description"] = json!(format!(
"Another body.{}",
slot(
r#"{"caller.key":"w","onetaskgraph.origin":"plan:ORIG-2","orchestrator.follow-up":{"root_cause":"other"}}"#
)
));
let mut legacy = issue("S-LEGACY", "Todo", json!({"title": "Fourth"}));
legacy["_linear_description"] = json!(
"Legacy body.\n\n<!-- onetaskgraph.metadata\n{\"caller.key\": \"v\", \
\"onetaskgraph.origin\": \"plan:ORIG-1\"}\n-->"
);
let mut workspace = held(vec![matching, prose, other, legacy], Vec::new(), Vec::new());
workspace["comments"] = json!([
{"task": "S-MATCH", "comment": {"id": "C-1", "body": "recent",
"created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-10-01T12:00:00Z"}},
{"task": "S-OTHER", "comment": {"id": "C-2", "body": "old",
"created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z"}},
]);
linear_workspace(sandbox, workspace)
}
#[test]
fn the_follow_up_searches_are_pushed_to_linear_and_return_exactly_what_matches() {
let sandbox = Sandbox::new();
let (config, workspace) = searched_workspace(&sandbox);
let plan = folder(&sandbox, "plan", &[]);
sandbox.project_document(&document(&json!({
"linear": linear(&config, json!({})),
"plan": markdown(&plan),
})));
for (arguments, predicate, expected) in [
(
vec!["--metadata", "caller.key=v"],
"metadata",
vec!["S-MATCH", "S-LEGACY"],
),
(
vec![
"--metadata",
"orchestrator.follow-up/root_cause=stale-cache",
],
"metadata",
vec!["S-MATCH"],
),
(
vec!["--origin", "plan:ORIG-1"],
"origin",
vec!["S-MATCH", "S-LEGACY"],
),
(vec!["--priority", "high"], "priority", vec!["S-MATCH"]),
(
vec!["--commented-since", "2026-10-01T00:00:00Z"],
"commented-since",
vec!["S-MATCH"],
),
(
vec!["--search", "ALPHA", "--in", "title"],
"search-title",
vec!["S-MATCH"],
),
(
vec!["--search", "zebra", "--in", "content"],
"search-content",
vec!["S-PROSE"],
),
(
vec!["--search", "stale-cache", "--in", "content"],
"search-content",
vec!["S-PROSE"],
),
] {
let mut full = vec!["--json", "task", "list", "--source", "linear"];
full.extend_from_slice(&arguments);
let from = workspace.served().len();
let answer = answered(&sandbox, &full);
assert_eq!(
ids(&answer),
expected
.iter()
.map(|id| format!("linear:{id}"))
.collect::<Vec<_>>(),
"{arguments:?}"
);
assert!(
answer["plan"]["per_source"][0]["pushed_down"]
.as_array()
.expect("a plan")
.contains(&json!(predicate)),
"{arguments:?}: {:#}",
answer["plan"]
);
let filter = workspace
.served()
.into_iter()
.skip(from)
.find(|(query, _)| query == onetaskgraph_linear::graphql::ISSUES)
.expect("one issue read")
.1["filter"]
.to_string();
let member = match predicate {
"metadata" | "origin" => "\"description\":{\"contains\"",
"priority" => "\"priority\":{\"in\"",
"commented-since" => "\"comments\":{\"some\"",
"search-title" => "\"title\":{\"containsIgnoreCase\"",
_ => "\"description\":{\"containsIgnoreCase\"",
};
assert!(filter.contains(member), "{arguments:?}: {filter}");
}
}
#[test]
fn a_cross_source_edge_is_established_both_ways_and_never_taken_from_another_team_or_project() {
for scope in [None, Some("LP-1")] {
let sandbox = Sandbox::new();
let decoy_slot =
"Decoy.\n\n<!-- onetaskgraph.metadata\n{\"onetaskgraph.origin\":\"plan:F-1\"}\n-->";
let mut other_team = issue("X-TEAM", "Todo", json!({"team": "OTHER"}));
other_team["_linear_description"] = json!(decoy_slot);
let mut other_project = issue("X-PROJECT", "Todo", json!({"project": "LP-2"}));
other_project["_linear_description"] = json!(decoy_slot);
let mut tasks = vec![
other_team,
issue("L-1", "Todo", json!({"project": "LP-1"})),
issue("L-2", "Todo", json!({"project": "LP-1"})),
];
if scope.is_some() {
tasks.insert(1, other_project);
}
let (config, workspace) = linear_workspace(
&sandbox,
held(tasks, vec![project("LP-1"), project("LP-2")], Vec::new()),
);
let extra = scope.map_or_else(|| json!({}), |scope| json!({"project": scope}));
let linear_source = linear(&config, extra);
let plan = folder(
&sandbox,
"plan",
&[
(
"tasks/F-1.md",
"---\ntitle: Upstream\nstatus: todo\n---\nbody\n",
),
(
"tasks/F-2.md",
"---\ntitle: Waits on upstream\nstatus: todo\ndepends_on: [F-1]\n---\nbody\n",
),
],
);
sandbox.project_document(&document(&json!({
"linear": linear_source,
"plan": markdown(&plan),
})));
answered(
&sandbox,
&[
"--json",
"task",
"update",
"linear:L-2",
"--depends-on",
"linear:L-1",
],
);
let copied = answered(
&sandbox,
&["--json", "task", "copy", "plan:F-2", "--to", "linear"],
);
let near = copied["items"][0]["destination"]
.as_str()
.expect("a destination")
.to_owned();
let far: Vec<Value> = answered(&sandbox, &["--json", "task", "deps", &near])["items"]
.as_array()
.expect("edges")
.iter()
.map(|edge| edge["to"]["id"].clone())
.collect();
assert_eq!(far, [json!("plan:F-1")], "{scope:?}");
for decoy in ["X-TEAM", "X-PROJECT"] {
assert!(
!workspace
.long_form("tasks", near.split_once(':').expect("qualified").1)
.expect("held")
.contains(decoy),
"{scope:?}: {decoy}"
);
}
let copied = answered(
&sandbox,
&["--json", "task", "copy", "linear:L-2", "--to", "plan"],
);
let near = copied["items"][0]["destination"]
.as_str()
.expect("a destination")
.to_owned();
let far: Vec<Value> = answered(&sandbox, &["--json", "task", "deps", &near])["items"]
.as_array()
.expect("edges")
.iter()
.map(|edge| edge["to"]["id"].clone())
.collect();
assert_eq!(far, [json!("linear:L-1")], "{scope:?}");
let upstream = answered(
&sandbox,
&["--json", "task", "copy", "plan:F-1", "--to", "linear"],
);
let upstream = upstream["items"][0]["destination"].clone();
let again = answered(
&sandbox,
&["--json", "task", "copy", "plan:F-2", "--to", "linear"],
);
let near = again["items"][0]["destination"]
.as_str()
.expect("a destination")
.to_owned();
let far: Vec<Value> = answered(&sandbox, &["--json", "task", "deps", &near])["items"]
.as_array()
.expect("edges")
.iter()
.map(|edge| edge["to"]["id"].clone())
.collect();
assert_eq!(far, [upstream], "{scope:?}");
}
}