Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/agentflare-backend/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ pub enum Error {
Duplicate(String),
#[error("invalid state transition: {0}")]
InvalidTransition(String),
#[error("validation: {0}")]
Validation(String),
#[error(transparent)]
Database(rusqlite::Error),
}
Expand Down
156 changes: 156 additions & 0 deletions crates/agentflare-backend/src/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,21 @@ pub fn delete(conn: &Connection, id: &str) -> Result<()> {
}

pub fn add_label(conn: &Connection, item_id: &str, label_id: &str) -> Result<()> {
// A label may only be attached to an item in the same scope: a project-scoped
// label must share the item's project; a workspace-level label (project_id NULL)
// must share the item's workspace. This mirrors Plane's project-membership check
// and, because item::create routes through here, guards that path too.
let item = get(conn, item_id)?;
let label = crate::label::get(conn, label_id)?;
let in_scope = match &label.project_id {
Some(project_id) => project_id == &item.project_id,
None => label.workspace_id == workspace_id_for_project(conn, &item.project_id)?,
};
if !in_scope {
return Err(crate::error::Error::Validation(format!(
"label {label_id} is not in item {item_id}'s scope (project or workspace)"
)));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
conn.execute(
"INSERT OR IGNORE INTO item_labels (item_id, label_id) VALUES (?1, ?2)",
rusqlite::params![item_id, label_id],
Expand Down Expand Up @@ -719,6 +734,147 @@ mod tests {
assert!(list_labels(&conn, &item.id).unwrap().is_empty());
}

fn workspace_by_slug(conn: &Connection, slug: &str) -> String {
workspace::list(conn)
.unwrap()
.into_iter()
.find(|w| w.slug == slug)
.unwrap()
.id
}

#[test]
fn add_label_rejects_label_from_another_project() {
let conn = db::open_in_memory().unwrap();
let (pid1, sid1) = seed_project(&conn, "1");
let (pid2, _sid2) = seed_project(&conn, "2");
let item = create(
&conn,
CreateItem {
project_id: pid1,
state_id: sid1,
name: "Test".into(),
description: None,
priority: None,
parent_id: None,
assignee_agent: None,
sort_order: None,
external_source: None,
external_id: None,
metadata: None,
label_ids: vec![],
assignee_ids: vec![],
dependency_ids: vec![],
},
)
.unwrap();
let foreign = crate::label::create(
&conn,
crate::label::CreateLabel {
project_id: Some(pid2),
workspace_id: workspace_by_slug(&conn, "test2"),
name: "bug".into(),
color: None,
parent_id: None,
sort_order: None,
external_source: None,
external_id: None,
},
)
.unwrap();
let err = add_label(&conn, &item.id, &foreign.id).unwrap_err();
assert!(matches!(err, crate::error::Error::Validation(_)));
assert!(list_labels(&conn, &item.id).unwrap().is_empty());
}

#[test]
fn add_label_accepts_workspace_level_label_in_same_workspace() {
let conn = db::open_in_memory().unwrap();
let (pid1, sid1) = seed_project(&conn, "1");
let item = create(
&conn,
CreateItem {
project_id: pid1,
state_id: sid1,
name: "Test".into(),
description: None,
priority: None,
parent_id: None,
assignee_agent: None,
sort_order: None,
external_source: None,
external_id: None,
metadata: None,
label_ids: vec![],
assignee_ids: vec![],
dependency_ids: vec![],
},
)
.unwrap();
// Workspace-level label (project_id = None) in the item's workspace.
let global = crate::label::create(
&conn,
crate::label::CreateLabel {
project_id: None,
workspace_id: workspace_by_slug(&conn, "test1"),
name: "global".into(),
color: None,
parent_id: None,
sort_order: None,
external_source: None,
external_id: None,
},
)
.unwrap();
add_label(&conn, &item.id, &global.id).unwrap();
assert_eq!(list_labels(&conn, &item.id).unwrap().len(), 1);
}

#[test]
fn add_label_rejects_workspace_level_label_from_another_workspace() {
let conn = db::open_in_memory().unwrap();
let (pid1, sid1) = seed_project(&conn, "1");
let (_pid2, _sid2) = seed_project(&conn, "2");
let item = create(
&conn,
CreateItem {
project_id: pid1,
state_id: sid1,
name: "Test".into(),
description: None,
priority: None,
parent_id: None,
assignee_agent: None,
sort_order: None,
external_source: None,
external_id: None,
metadata: None,
label_ids: vec![],
assignee_ids: vec![],
dependency_ids: vec![],
},
)
.unwrap();
// Workspace-level label (project_id = None) but in a *different* workspace.
let foreign_global = crate::label::create(
&conn,
crate::label::CreateLabel {
project_id: None,
workspace_id: workspace_by_slug(&conn, "test2"),
name: "global".into(),
color: None,
parent_id: None,
sort_order: None,
external_source: None,
external_id: None,
},
)
.unwrap();
let err = add_label(&conn, &item.id, &foreign_global.id).unwrap_err();
assert!(matches!(err, crate::error::Error::Validation(_)));
assert!(list_labels(&conn, &item.id).unwrap().is_empty());
}

#[test]
fn add_and_remove_assignees() {
let conn = db::open_in_memory().unwrap();
Expand Down
58 changes: 56 additions & 2 deletions crates/agentflare-backend/src/label.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,37 @@ fn row_to_label(row: &rusqlite::Row) -> rusqlite::Result<Label> {
})
}

/// Next sort_order for a new label: `max(sort_order) + 10000` within the same
/// scope (project for project-scoped labels, workspace for workspace-level ones),
/// or 65535 when the scope has no labels yet. Mirrors Plane's append-on-create.
fn next_sort_order(conn: &Connection, project_id: Option<&str>, workspace_id: &str) -> Result<f64> {
let max: Option<f64> = match project_id {
Some(pid) => conn.query_row(
"SELECT MAX(sort_order) FROM labels WHERE project_id = ?1 AND deleted_at IS NULL",
rusqlite::params![pid],
|row| row.get(0),
)?,
None => conn.query_row(
"SELECT MAX(sort_order) FROM labels WHERE workspace_id = ?1 AND project_id IS NULL AND deleted_at IS NULL",
rusqlite::params![workspace_id],
|row| row.get(0),
)?,
};
Ok(max.map_or(65535.0, |m| m + 10000.0))
}

pub fn create(conn: &Connection, input: CreateLabel) -> Result<Label> {
let id = uuid::Uuid::now_v7().to_string();
let ts = now();
let color = input.color.unwrap_or_else(|| "#60646C".to_string());
let sort_order = input.sort_order.unwrap_or(65535.0);
conn.execute(
// Compute the auto-append sort_order and insert inside one transaction so two
// concurrent auto-append creates can't read the same MAX and collide.
let tx = conn.unchecked_transaction()?;
let sort_order = match input.sort_order {
Some(v) => v,
None => next_sort_order(&tx, input.project_id.as_deref(), &input.workspace_id)?,
};
tx.execute(
"INSERT INTO labels (id, project_id, workspace_id, name, color, parent_id, sort_order, external_source, external_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
Expand All @@ -84,6 +109,7 @@ pub fn create(conn: &Connection, input: CreateLabel) -> Result<Label> {
ts,
],
)?;
tx.commit()?;
get(conn, &id)
}

Expand Down Expand Up @@ -212,6 +238,34 @@ mod tests {
assert_eq!(got.id, label.id);
}

#[test]
fn create_auto_appends_sort_order() {
let conn = db::open_in_memory().unwrap();
let wid = seed_workspace(&conn);
let mk = |name: &str, sort_order: Option<f64>| {
create(
&conn,
CreateLabel {
project_id: None,
workspace_id: wid.clone(),
name: name.into(),
color: None,
parent_id: None,
sort_order,
external_source: None,
external_id: None,
},
)
.unwrap()
};
// First label in the scope seeds at 65535, each next appends +10000.
assert_eq!(mk("a", None).sort_order, 65535.0);
assert_eq!(mk("b", None).sort_order, 75535.0);
// An explicit sort_order is respected verbatim and doesn't shift the max.
assert_eq!(mk("c", Some(5.0)).sort_order, 5.0);
assert_eq!(mk("d", None).sort_order, 85535.0);
}

#[test]
fn duplicate_name_in_workspace_fails() {
let conn = db::open_in_memory().unwrap();
Expand Down
Loading
Loading