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
1,230 changes: 1,002 additions & 228 deletions Cargo.lock

Large diffs are not rendered by default.

76 changes: 75 additions & 1 deletion crates/ticgit-lib/src/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//! keys:
//! ticgit:<system-key> # ticketing-system metadata
//! ticgit:tickets:<uuid>:<field> # per-ticket fields
//! ticgit:writeups:<uuid>:<field> # per-writeup fields
//! ticgit:views:<name> # saved selections (set of UUIDs)
//! ```

Expand Down Expand Up @@ -46,6 +47,26 @@ pub fn ticket_field(id: &Uuid, field: &str) -> String {
format!("{NS}:tickets:{id}:{field}")
}

/// Prefix for the per-writeup field keyspace; pass to
/// `SessionTargetHandle::get_all_values` for project-wide writeup scans.
#[must_use]
pub fn writeups_prefix() -> String {
format!("{NS}:writeups")
}

/// All keys for a single writeup share this prefix; used for one-writeup
/// scans.
#[must_use]
pub fn writeup_prefix(id: &Uuid) -> String {
format!("{NS}:writeups:{id}")
}

/// A specific field on a specific writeup, e.g. `ticgit:writeups:<uuid>:title`.
#[must_use]
pub fn writeup_field(id: &Uuid, field: &str) -> String {
format!("{NS}:writeups:{id}:{field}")
}

/// A specific metadata field on a ticket, e.g. `ticgit:tickets:<uuid>:meta:branch`.
#[must_use]
pub fn ticket_meta_field(id: &Uuid, field: &str) -> String {
Expand Down Expand Up @@ -87,7 +108,11 @@ pub fn user_key(nick: &str) -> String {
pub fn parse_user_nick(key: &str) -> Option<&str> {
let prefix = format!("{NS}:users:");
let name = key.strip_prefix(&prefix)?;
if name.is_empty() { None } else { Some(name) }
if name.is_empty() {
None
} else {
Some(name)
}
}

/// If `key` is a per-ticket field key, returns `(ticket_uuid, field_name)`.
Expand All @@ -104,6 +129,20 @@ pub fn parse_ticket_field(key: &str) -> Option<(Uuid, &str)> {
Some((uuid, field))
}

/// If `key` is a per-writeup field key, returns `(writeup_uuid, field_name)`.
/// Returns `None` for system keys, ticket keys, or anything malformed.
#[must_use]
pub fn parse_writeup_field(key: &str) -> Option<(Uuid, &str)> {
let prefix = format!("{NS}:writeups:");
let rest = key.strip_prefix(&prefix)?;
let (uuid_part, field) = rest.split_once(':')?;
let uuid = Uuid::parse_str(uuid_part).ok()?;
if field.is_empty() {
return None;
}
Some((uuid, field))
}

/// If `key` is a saved-view key, returns the view name.
#[must_use]
pub fn parse_view_name(key: &str) -> Option<&str> {
Expand Down Expand Up @@ -140,6 +179,15 @@ mod tests {
ticket_field(&id, "state"),
"ticgit:tickets:00000000-0000-0000-0000-000000000001:state"
);
assert_eq!(writeups_prefix(), "ticgit:writeups");
assert_eq!(
writeup_prefix(&id),
"ticgit:writeups:00000000-0000-0000-0000-000000000001"
);
assert_eq!(
writeup_field(&id, "title"),
"ticgit:writeups:00000000-0000-0000-0000-000000000001:title"
);
assert_eq!(
ticket_meta_field(&id, "branch"),
"ticgit:tickets:00000000-0000-0000-0000-000000000001:meta:branch"
Expand Down Expand Up @@ -177,11 +225,37 @@ mod tests {
fn parse_ticket_field_rejects_non_ticket_keys() {
assert!(parse_ticket_field("ticgit:owners").is_none());
assert!(parse_ticket_field("ticgit:views:foo").is_none());
assert!(
parse_ticket_field("ticgit:writeups:00000000-0000-0000-0000-000000000001:title")
.is_none()
);
assert!(parse_ticket_field("ticgit:tickets").is_none());
assert!(parse_ticket_field("ticgit:tickets:not-a-uuid:title").is_none());
assert!(parse_ticket_field("foo:bar:baz").is_none());
}

#[test]
fn parse_writeup_field_round_trips_known_uuids() {
let id = fixed_uuid();
let key = writeup_field(&id, "versions");
let (got_id, field) = parse_writeup_field(&key).expect("should parse");
assert_eq!(got_id, id);
assert_eq!(field, "versions");
}

#[test]
fn parse_writeup_field_rejects_non_writeup_keys() {
assert!(parse_writeup_field("ticgit:owners").is_none());
assert!(parse_writeup_field("ticgit:views:foo").is_none());
assert!(
parse_writeup_field("ticgit:tickets:00000000-0000-0000-0000-000000000001:title")
.is_none()
);
assert!(parse_writeup_field("ticgit:writeups").is_none());
assert!(parse_writeup_field("ticgit:writeups:not-a-uuid:title").is_none());
assert!(parse_writeup_field("foo:bar:baz").is_none());
}

#[test]
fn parse_view_name_works() {
assert_eq!(parse_view_name("ticgit:views:mine"), Some("mine"));
Expand Down
8 changes: 8 additions & 0 deletions crates/ticgit-lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
//! ticgit:tickets:<uuid>:comments # list of JSON-encoded {author, body}
//! ticgit:tickets:<uuid>:created-at # RFC3339 string
//! ticgit:tickets:<uuid>:created-by # string (email)
//! ticgit:writeups:<uuid>:title # string
//! ticgit:writeups:<uuid>:status # string ("open" | "closed")
//! ticgit:writeups:<uuid>:tags # set
//! ticgit:writeups:<uuid>:authors # set of emails
//! ticgit:writeups:<uuid>:versions # list of markdown documents
//! ticgit:writeups:<uuid>:tickets # set of linked ticket UUIDs
//! ticgit:views:<name> # set of UUIDs (saved selection)
//! ticgit:owners # set of emails
//! ticgit:schema-version # string ("1")
Expand All @@ -30,6 +36,7 @@ pub mod keys;
pub mod query;
pub mod store;
pub mod ticket;
pub mod writeup;

#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
Expand All @@ -40,6 +47,7 @@ pub use store::TicketStore;
pub use ticket::{
validate_code_uri, Comment, NewTicketOpts, Ticket, TicketLifecycle, TicketState, TicketStatus,
};
pub use writeup::{NewWriteupOpts, Writeup, WriteupStatus, WriteupVersion};

/// Re-exported for callers who want to talk to git-meta directly.
pub use git_meta_lib::{MetaValue, Session, Target};
99 changes: 97 additions & 2 deletions crates/ticgit-lib/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ pub struct Filter {
pub status: Option<TicketStatus>,
pub state: Option<TicketState>,
pub tag: Option<String>,
pub tags: Vec<String>,
pub tag_match_all: bool,
pub assigned: Option<String>,
pub only_tagged: bool,
pub search: Option<SearchFilter>,
Expand All @@ -40,6 +42,7 @@ pub enum SearchScope {
/// `desc` flag.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey {
Priority,
Title,
State,
Assigned,
Expand All @@ -55,6 +58,7 @@ pub struct SortOrder {
impl SortKey {
pub fn parse(s: &str) -> Option<Self> {
match s {
"priority" | "prio" => Some(SortKey::Priority),
"title" => Some(SortKey::Title),
"state" => Some(SortKey::State),
"assigned" => Some(SortKey::Assigned),
Expand Down Expand Up @@ -160,8 +164,14 @@ pub fn apply(tickets: Vec<Ticket>, filter: &Filter) -> Vec<Ticket> {
return false;
}
}
if let Some(tag) = &filter.tag {
if !t.tags.contains(tag) {
let tags = filter_tags(filter);
if !tags.is_empty() {
let matches = if filter.tag_match_all {
tags.iter().all(|tag| t.tags.contains(*tag))
} else {
tags.iter().any(|tag| t.tags.contains(*tag))
};
if !matches {
return false;
}
}
Expand Down Expand Up @@ -206,6 +216,19 @@ pub fn apply(tickets: Vec<Ticket>, filter: &Filter) -> Vec<Ticket> {
tickets
}

fn filter_tags(filter: &Filter) -> Vec<&String> {
let mut tags = Vec::new();
if let Some(tag) = &filter.tag {
tags.push(tag);
}
for tag in &filter.tags {
if !tags.contains(&tag) {
tags.push(tag);
}
}
tags
}

fn contains(haystack: &str, needle: &str) -> bool {
haystack.to_ascii_lowercase().contains(needle)
}
Expand Down Expand Up @@ -242,6 +265,10 @@ fn status_rank(s: TicketStatus) -> u8 {

fn compare(a: &Ticket, b: &Ticket, key: SortKey, desc: bool) -> Ordering {
let ord = match key {
SortKey::Priority => priority_rank(a.priority)
.cmp(&priority_rank(b.priority))
.then_with(|| b.created_at.cmp(&a.created_at))
.then_with(|| a.id.cmp(&b.id)),
SortKey::Title => a.title.cmp(&b.title),
SortKey::State => status_rank(a.status)
.cmp(&status_rank(b.status))
Expand Down Expand Up @@ -288,6 +315,7 @@ mod tests {
status,
state,
assigned: assigned.map(String::from),
closed_by: None,
priority: None,
points: None,
milestone: None,
Expand Down Expand Up @@ -377,6 +405,73 @@ mod tests {
assert_eq!(out[0].title, "b");
}

#[test]
fn filter_by_any_tag() {
let mut bug = t(
"bug",
TicketStatus::Open,
TicketState::New,
Some("bug"),
None,
1,
);
bug.tags.insert("cli".into());
let ui = t(
"ui",
TicketStatus::Open,
TicketState::New,
Some("ui"),
None,
2,
);
let docs = t(
"docs",
TicketStatus::Open,
TicketState::New,
Some("docs"),
None,
3,
);
let f = Filter {
tags: vec!["bug".into(), "ui".into()],
tag_match_all: false,
..Default::default()
};
let out = apply(vec![bug, ui, docs], &f);
assert_eq!(out.len(), 2);
assert_eq!(out[0].title, "ui");
assert_eq!(out[1].title, "bug");
}

#[test]
fn filter_by_all_tags() {
let mut both = t(
"both",
TicketStatus::Open,
TicketState::New,
Some("bug"),
None,
1,
);
both.tags.insert("ui".into());
let bug = t(
"bug",
TicketStatus::Open,
TicketState::New,
Some("bug"),
None,
2,
);
let f = Filter {
tags: vec!["bug".into(), "ui".into()],
tag_match_all: true,
..Default::default()
};
let out = apply(vec![both, bug], &f);
assert_eq!(out.len(), 1);
assert_eq!(out[0].title, "both");
}

#[test]
fn filter_by_assigned() {
let input = vec![
Expand Down
Loading