handoff-mcp-server 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -4
- package/bin/handoff-mcp.js +56 -13
- package/bin/resolve-binary.js +122 -0
- package/package.json +14 -9
- package/Cargo.lock +0 -686
- package/Cargo.toml +0 -30
- package/scripts/cargo-env.sh +0 -29
- package/scripts/handoff-memory-hook.py +0 -208
- package/scripts/install-local.sh +0 -109
- package/scripts/postinstall.js +0 -50
- package/scripts/sync-plugin-skills.sh +0 -35
- package/scripts/sync-plugin-version.sh +0 -85
- package/scripts/sync-workflow-inline.sh +0 -138
- package/src/cli.rs +0 -551
- package/src/context/injection.rs +0 -276
- package/src/context/mod.rs +0 -129
- package/src/lib.rs +0 -5
- package/src/main.rs +0 -157
- package/src/mcp/handlers/assignees.rs +0 -254
- package/src/mcp/handlers/auto_schedule.rs +0 -489
- package/src/mcp/handlers/bulk_update.rs +0 -155
- package/src/mcp/handlers/calendar.rs +0 -196
- package/src/mcp/handlers/capacity.rs +0 -318
- package/src/mcp/handlers/check_criterion.rs +0 -70
- package/src/mcp/handlers/config.rs +0 -402
- package/src/mcp/handlers/config_crud.rs +0 -183
- package/src/mcp/handlers/dashboard.rs +0 -214
- package/src/mcp/handlers/docs.rs +0 -2288
- package/src/mcp/handlers/docs_query.rs +0 -1335
- package/src/mcp/handlers/fork_session.rs +0 -91
- package/src/mcp/handlers/get_session.rs +0 -48
- package/src/mcp/handlers/get_task.rs +0 -53
- package/src/mcp/handlers/import_context.rs +0 -470
- package/src/mcp/handlers/init.rs +0 -28
- package/src/mcp/handlers/list_sessions.rs +0 -187
- package/src/mcp/handlers/list_tasks.rs +0 -308
- package/src/mcp/handlers/load_context.rs +0 -361
- package/src/mcp/handlers/log_time.rs +0 -67
- package/src/mcp/handlers/memory.rs +0 -961
- package/src/mcp/handlers/merge_sessions.rs +0 -103
- package/src/mcp/handlers/metrics.rs +0 -196
- package/src/mcp/handlers/milestones.rs +0 -102
- package/src/mcp/handlers/mod.rs +0 -140
- package/src/mcp/handlers/refer.rs +0 -307
- package/src/mcp/handlers/referrals.rs +0 -74
- package/src/mcp/handlers/save_context.rs +0 -354
- package/src/mcp/handlers/task_checklist.rs +0 -507
- package/src/mcp/handlers/timer.rs +0 -529
- package/src/mcp/handlers/update_session.rs +0 -197
- package/src/mcp/handlers/update_task.rs +0 -452
- package/src/mcp/mod.rs +0 -6
- package/src/mcp/protocol.rs +0 -41
- package/src/mcp/resources.rs +0 -57
- package/src/mcp/router.rs +0 -154
- package/src/mcp/tools.rs +0 -1522
- package/src/mcp/types.rs +0 -108
- package/src/setup.rs +0 -1212
- package/src/storage/config.rs +0 -578
- package/src/storage/docs/frontmatter.rs +0 -509
- package/src/storage/docs/mod.rs +0 -835
- package/src/storage/docs/model.rs +0 -708
- package/src/storage/docs/reassemble.rs +0 -167
- package/src/storage/docs/split.rs +0 -377
- package/src/storage/git.rs +0 -47
- package/src/storage/memory/injected.rs +0 -340
- package/src/storage/memory/mod.rs +0 -236
- package/src/storage/memory/model.rs +0 -127
- package/src/storage/mod.rs +0 -96
- package/src/storage/referrals.rs +0 -248
- package/src/storage/sessions.rs +0 -859
- package/src/storage/tasks.rs +0 -957
- package/templates/claude-md-section.md +0 -12
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
use anyhow::{Context, Result};
|
|
2
|
-
use serde_json::{json, Value};
|
|
3
|
-
|
|
4
|
-
use super::resolve_project_dir;
|
|
5
|
-
use crate::storage::ensure_handoff_exists;
|
|
6
|
-
|
|
7
|
-
pub fn handle(arguments: &Value) -> Result<String> {
|
|
8
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
9
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
10
|
-
let sessions_dir = handoff.join("sessions");
|
|
11
|
-
|
|
12
|
-
let status_filter = arguments.get("status_filter").and_then(|v| v.as_str());
|
|
13
|
-
let timeline_filter = arguments.get("timeline").and_then(|v| v.as_str());
|
|
14
|
-
let limit = arguments
|
|
15
|
-
.get("limit")
|
|
16
|
-
.and_then(|v| v.as_u64())
|
|
17
|
-
.unwrap_or(20) as usize;
|
|
18
|
-
let include_children = arguments
|
|
19
|
-
.get("include_children")
|
|
20
|
-
.and_then(|v| v.as_bool())
|
|
21
|
-
.unwrap_or(false);
|
|
22
|
-
|
|
23
|
-
if !sessions_dir.exists() {
|
|
24
|
-
return serde_json::to_string_pretty(&json!([])).map_err(Into::into);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
let mut sessions: Vec<Value> = Vec::new();
|
|
28
|
-
|
|
29
|
-
for entry in std::fs::read_dir(&sessions_dir)
|
|
30
|
-
.with_context(|| format!("Failed to read sessions dir: {}", sessions_dir.display()))?
|
|
31
|
-
{
|
|
32
|
-
let entry = entry?;
|
|
33
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
34
|
-
if !name.ends_with(".json") {
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
let status = extract_session_status(&name);
|
|
39
|
-
if let Some(filter) = status_filter {
|
|
40
|
-
if status != filter {
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
let content = match std::fs::read_to_string(entry.path()) {
|
|
46
|
-
Ok(c) => c,
|
|
47
|
-
Err(_) => continue,
|
|
48
|
-
};
|
|
49
|
-
let data: Value = match serde_json::from_str(&content) {
|
|
50
|
-
Ok(d) => d,
|
|
51
|
-
Err(_) => continue,
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
let id = data
|
|
55
|
-
.get("id")
|
|
56
|
-
.and_then(|v| v.as_str())
|
|
57
|
-
.map(String::from)
|
|
58
|
-
.unwrap_or_else(|| synthesize_id_from_filename(&name));
|
|
59
|
-
|
|
60
|
-
let timeline = data.get("timeline").and_then(|v| v.as_str());
|
|
61
|
-
if let Some(tl_filter) = timeline_filter {
|
|
62
|
-
if timeline.is_none_or(|tl| tl != tl_filter) {
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
let summary = data.get("summary").and_then(|v| v.as_str()).unwrap_or("");
|
|
68
|
-
let label = data.get("label").and_then(|v| v.as_str());
|
|
69
|
-
let parent_session_id = data.get("parent_session_id").and_then(|v| v.as_str());
|
|
70
|
-
let started_at = data.get("started_at").and_then(|v| v.as_str());
|
|
71
|
-
let ended_at = data.get("ended_at").and_then(|v| v.as_str());
|
|
72
|
-
let branch = data.get("branch").and_then(|v| v.as_str());
|
|
73
|
-
let commit = data.get("commit").and_then(|v| v.as_str());
|
|
74
|
-
|
|
75
|
-
let decisions_count = data
|
|
76
|
-
.get("decisions")
|
|
77
|
-
.and_then(|v| v.as_array())
|
|
78
|
-
.map(|a| a.len())
|
|
79
|
-
.unwrap_or(0);
|
|
80
|
-
|
|
81
|
-
let checklist = data.get("checklist").and_then(|v| v.as_array());
|
|
82
|
-
let checklist_count = checklist.map(|a| a.len()).unwrap_or(0);
|
|
83
|
-
let checklist_checked = checklist
|
|
84
|
-
.map(|a| {
|
|
85
|
-
a.iter()
|
|
86
|
-
.filter(|item| {
|
|
87
|
-
item.get("checked")
|
|
88
|
-
.and_then(|v| v.as_bool())
|
|
89
|
-
.unwrap_or(false)
|
|
90
|
-
})
|
|
91
|
-
.count()
|
|
92
|
-
})
|
|
93
|
-
.unwrap_or(0);
|
|
94
|
-
|
|
95
|
-
let mut entry = json!({
|
|
96
|
-
"id": id,
|
|
97
|
-
"status": status,
|
|
98
|
-
"summary": summary,
|
|
99
|
-
"started_at": started_at,
|
|
100
|
-
"ended_at": ended_at,
|
|
101
|
-
"branch": branch,
|
|
102
|
-
"commit": commit,
|
|
103
|
-
"decisions_count": decisions_count,
|
|
104
|
-
"checklist_progress": format!("{}/{}", checklist_checked, checklist_count),
|
|
105
|
-
});
|
|
106
|
-
if let Some(tl) = timeline {
|
|
107
|
-
entry["timeline"] = json!(tl);
|
|
108
|
-
}
|
|
109
|
-
if let Some(lbl) = label {
|
|
110
|
-
entry["label"] = json!(lbl);
|
|
111
|
-
}
|
|
112
|
-
if let Some(pid) = parent_session_id {
|
|
113
|
-
entry["parent_session_id"] = json!(pid);
|
|
114
|
-
}
|
|
115
|
-
sessions.push(entry);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
sessions.sort_by(|a, b| {
|
|
119
|
-
let a_time = a.get("ended_at").and_then(|v| v.as_str()).unwrap_or("");
|
|
120
|
-
let b_time = b.get("ended_at").and_then(|v| v.as_str()).unwrap_or("");
|
|
121
|
-
b_time.cmp(a_time)
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
let all_sessions_for_children = if include_children {
|
|
125
|
-
sessions.clone()
|
|
126
|
-
} else {
|
|
127
|
-
Vec::new()
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
sessions.truncate(limit);
|
|
131
|
-
|
|
132
|
-
if include_children {
|
|
133
|
-
for session in &mut sessions {
|
|
134
|
-
let sid = session.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
|
135
|
-
if sid.is_empty() {
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
let children: Vec<Value> = all_sessions_for_children
|
|
139
|
-
.iter()
|
|
140
|
-
.filter(|s| {
|
|
141
|
-
s.get("parent_session_id")
|
|
142
|
-
.and_then(|v| v.as_str())
|
|
143
|
-
.is_some_and(|pid| pid == sid)
|
|
144
|
-
})
|
|
145
|
-
.map(|s| {
|
|
146
|
-
json!({
|
|
147
|
-
"id": s.get("id").and_then(|v| v.as_str()).unwrap_or(""),
|
|
148
|
-
"summary": s.get("summary").and_then(|v| v.as_str()).unwrap_or(""),
|
|
149
|
-
"status": s.get("status").and_then(|v| v.as_str()).unwrap_or(""),
|
|
150
|
-
"label": s.get("label").and_then(|v| v.as_str()),
|
|
151
|
-
})
|
|
152
|
-
})
|
|
153
|
-
.collect();
|
|
154
|
-
if !children.is_empty() {
|
|
155
|
-
session["children"] = json!(children);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
serde_json::to_string_pretty(&sessions).map_err(Into::into)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
fn extract_session_status(filename: &str) -> &str {
|
|
164
|
-
let name = filename.strip_suffix(".json").unwrap_or(filename);
|
|
165
|
-
if name.ends_with(".open") {
|
|
166
|
-
"open"
|
|
167
|
-
} else if name.ends_with(".active") {
|
|
168
|
-
"active"
|
|
169
|
-
} else if name.ends_with(".paused") {
|
|
170
|
-
"paused"
|
|
171
|
-
} else if name.ends_with(".closed") {
|
|
172
|
-
"closed"
|
|
173
|
-
} else {
|
|
174
|
-
"unknown"
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
fn synthesize_id_from_filename(filename: &str) -> String {
|
|
179
|
-
let name = filename.strip_suffix(".json").unwrap_or(filename);
|
|
180
|
-
let base = name
|
|
181
|
-
.strip_suffix(".open")
|
|
182
|
-
.or_else(|| name.strip_suffix(".active"))
|
|
183
|
-
.or_else(|| name.strip_suffix(".paused"))
|
|
184
|
-
.or_else(|| name.strip_suffix(".closed"))
|
|
185
|
-
.unwrap_or(name);
|
|
186
|
-
format!("s-{}", &base[..std::cmp::min(base.len(), 20)])
|
|
187
|
-
}
|
|
@@ -1,308 +0,0 @@
|
|
|
1
|
-
use std::path::{Path, PathBuf};
|
|
2
|
-
|
|
3
|
-
use anyhow::{Context, Result};
|
|
4
|
-
use serde_json::{json, Value};
|
|
5
|
-
|
|
6
|
-
use super::resolve_project_dir;
|
|
7
|
-
use crate::storage::config::read_config;
|
|
8
|
-
use crate::storage::ensure_handoff_exists;
|
|
9
|
-
use crate::storage::tasks::{build_task_index, TaskIndex, TaskSummary};
|
|
10
|
-
|
|
11
|
-
/// Maximum depth (relative to the base project dir) scanned for nested
|
|
12
|
-
/// `.handoff/` child projects.
|
|
13
|
-
const MAX_CHILD_SCAN_DEPTH: usize = 5;
|
|
14
|
-
|
|
15
|
-
/// Directory names skipped while scanning for child projects.
|
|
16
|
-
const DEFAULT_SCAN_EXCLUDES: &[&str] = &["node_modules", ".git", "target", "dist", ".next"];
|
|
17
|
-
|
|
18
|
-
pub fn handle(arguments: &Value) -> Result<String> {
|
|
19
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
20
|
-
|
|
21
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
22
|
-
let tasks_dir = handoff.join("tasks");
|
|
23
|
-
let config_path = handoff.join("config.toml");
|
|
24
|
-
|
|
25
|
-
let done_task_limit = if config_path.exists() {
|
|
26
|
-
read_config(&config_path)
|
|
27
|
-
.map(|c| c.settings.done_task_limit)
|
|
28
|
-
.unwrap_or(10)
|
|
29
|
-
} else {
|
|
30
|
-
10
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
let (tree, summary) = build_task_index(&tasks_dir, done_task_limit)?;
|
|
34
|
-
|
|
35
|
-
let status_filter = arguments.get("status_filter").and_then(|v| v.as_str());
|
|
36
|
-
let assignee_filter = arguments.get("assignee_filter").and_then(|v| v.as_str());
|
|
37
|
-
let milestone_filter = arguments.get("milestone_filter").and_then(|v| v.as_str());
|
|
38
|
-
let priority_filter = arguments.get("priority_filter").and_then(|v| v.as_str());
|
|
39
|
-
let label_filter = arguments.get("label_filter").and_then(|v| v.as_str());
|
|
40
|
-
|
|
41
|
-
let filters = Filters {
|
|
42
|
-
status: status_filter,
|
|
43
|
-
assignee: assignee_filter,
|
|
44
|
-
milestone: milestone_filter,
|
|
45
|
-
priority: priority_filter,
|
|
46
|
-
label: label_filter,
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
let filtered_tree = if filters.any_active() {
|
|
50
|
-
filter_tree(&tree, &filters)
|
|
51
|
-
} else {
|
|
52
|
-
tree
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
let include_children = arguments
|
|
56
|
-
.get("include_children")
|
|
57
|
-
.and_then(|v| v.as_bool())
|
|
58
|
-
.unwrap_or(false);
|
|
59
|
-
|
|
60
|
-
if !include_children {
|
|
61
|
-
let result = serde_json::json!({
|
|
62
|
-
"task_tree": filtered_tree,
|
|
63
|
-
"task_summary": summary,
|
|
64
|
-
});
|
|
65
|
-
return serde_json::to_string_pretty(&result).context("Failed to serialize task list");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Determine own project name for annotation (defaults to config, fallback to "root").
|
|
69
|
-
let own_project_name = if config_path.exists() {
|
|
70
|
-
read_config(&config_path)
|
|
71
|
-
.map(|c| c.project.name)
|
|
72
|
-
.unwrap_or_else(|_| "root".to_string())
|
|
73
|
-
} else {
|
|
74
|
-
"root".to_string()
|
|
75
|
-
};
|
|
76
|
-
let own_project_dir = project_dir.to_string_lossy().to_string();
|
|
77
|
-
|
|
78
|
-
let mut aggregated: Vec<Value> =
|
|
79
|
-
annotate_tasks_json(filtered_tree, &own_project_name, &own_project_dir);
|
|
80
|
-
let mut total_summary = summary;
|
|
81
|
-
|
|
82
|
-
for (child_name, child_dir) in discover_child_projects(&project_dir, MAX_CHILD_SCAN_DEPTH) {
|
|
83
|
-
let child_handoff = child_dir.join(".handoff");
|
|
84
|
-
let child_tasks_dir = child_handoff.join("tasks");
|
|
85
|
-
let child_config_path = child_handoff.join("config.toml");
|
|
86
|
-
let child_done_task_limit = read_config(&child_config_path)
|
|
87
|
-
.map(|c| c.settings.done_task_limit)
|
|
88
|
-
.unwrap_or(10);
|
|
89
|
-
|
|
90
|
-
let (child_tree, child_summary) =
|
|
91
|
-
match build_task_index(&child_tasks_dir, child_done_task_limit) {
|
|
92
|
-
Ok(v) => v,
|
|
93
|
-
Err(_) => continue,
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
let child_filtered = if filters.any_active() {
|
|
97
|
-
filter_tree(&child_tree, &filters)
|
|
98
|
-
} else {
|
|
99
|
-
child_tree
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
let child_dir_str = child_dir.to_string_lossy().to_string();
|
|
103
|
-
aggregated.extend(annotate_tasks_json(
|
|
104
|
-
child_filtered,
|
|
105
|
-
&child_name,
|
|
106
|
-
&child_dir_str,
|
|
107
|
-
));
|
|
108
|
-
merge_summary(&mut total_summary, &child_summary);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
let result = serde_json::json!({
|
|
112
|
-
"task_tree": aggregated,
|
|
113
|
-
"task_summary": total_summary,
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
serde_json::to_string_pretty(&result).context("Failed to serialize task list")
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
fn merge_summary(base: &mut TaskSummary, other: &TaskSummary) {
|
|
120
|
-
base.total += other.total;
|
|
121
|
-
for (status, count) in &other.by_status {
|
|
122
|
-
*base.by_status.entry(status.clone()).or_insert(0) += count;
|
|
123
|
-
}
|
|
124
|
-
base.overdue_count += other.overdue_count;
|
|
125
|
-
if let Some(other_est) = other.total_estimate_hours {
|
|
126
|
-
base.total_estimate_hours = Some(base.total_estimate_hours.unwrap_or(0.0) + other_est);
|
|
127
|
-
}
|
|
128
|
-
if let Some(other_act) = other.total_actual_hours {
|
|
129
|
-
base.total_actual_hours = Some(base.total_actual_hours.unwrap_or(0.0) + other_act);
|
|
130
|
-
}
|
|
131
|
-
if base.total > 0 {
|
|
132
|
-
let done = *base.by_status.get("done").unwrap_or(&0) as f64;
|
|
133
|
-
let skipped = *base.by_status.get("skipped").unwrap_or(&0) as f64;
|
|
134
|
-
base.completion_rate = Some((done + skipped) / base.total as f64);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/// Annotate a task tree with `project_name`/`project_dir` and a composite
|
|
139
|
-
/// `task_ref` disambiguator (`{project_name}-{project_dir_hash}:{id}`) for
|
|
140
|
-
/// cross-project display purposes.
|
|
141
|
-
///
|
|
142
|
-
/// The original `id` field is deliberately left **unmodified**: it is the
|
|
143
|
-
/// value accepted by `handoff_get_task`/`handoff_update_task`/etc. (scoped by
|
|
144
|
-
/// the sibling `project_dir` field), and it is also what `dependencies`
|
|
145
|
-
/// entries reference. Rewriting `id` would make it unusable with every other
|
|
146
|
-
/// tool and would desync it from `dependencies`, which still contain raw ids.
|
|
147
|
-
///
|
|
148
|
-
/// `project_name` alone is free-form user text with no cross-directory
|
|
149
|
-
/// uniqueness guarantee, so two child projects can share the same name. The
|
|
150
|
-
/// `task_ref` additionally embeds a short hash of `project_dir` (which is
|
|
151
|
-
/// unique per filesystem location) to guarantee it never collides even when
|
|
152
|
-
/// `project_name` is duplicated.
|
|
153
|
-
fn annotate_tasks_json(tasks: Vec<TaskIndex>, project_name: &str, project_dir: &str) -> Vec<Value> {
|
|
154
|
-
let dir_hash = short_hash(project_dir);
|
|
155
|
-
tasks
|
|
156
|
-
.into_iter()
|
|
157
|
-
.map(|t| {
|
|
158
|
-
let mut v = serde_json::to_value(&t).unwrap_or_default();
|
|
159
|
-
if let Some(obj) = v.as_object_mut() {
|
|
160
|
-
obj.insert("project_name".to_string(), json!(project_name));
|
|
161
|
-
obj.insert("project_dir".to_string(), json!(project_dir));
|
|
162
|
-
if let Some(id) = obj.get("id").and_then(|v| v.as_str()).map(String::from) {
|
|
163
|
-
obj.insert(
|
|
164
|
-
"task_ref".to_string(),
|
|
165
|
-
json!(format!("{project_name}-{dir_hash}:{id}")),
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
if let Some(children) = obj.remove("children") {
|
|
169
|
-
if let Ok(child_tasks) = serde_json::from_value::<Vec<TaskIndex>>(children) {
|
|
170
|
-
obj.insert(
|
|
171
|
-
"children".to_string(),
|
|
172
|
-
json!(annotate_tasks_json(child_tasks, project_name, project_dir)),
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
v
|
|
178
|
-
})
|
|
179
|
-
.collect()
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/// Deterministic short hash (FNV-1a, hex-encoded) used to disambiguate
|
|
183
|
-
/// composite task IDs across child projects that share a `project_name`.
|
|
184
|
-
/// Not a dependency addition — plain FNV-1a is sufficient since this only
|
|
185
|
-
/// needs to be stable and collision-resistant for distinct directory paths,
|
|
186
|
-
/// not cryptographically secure.
|
|
187
|
-
fn short_hash(input: &str) -> String {
|
|
188
|
-
const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
|
|
189
|
-
const FNV_PRIME: u64 = 0x100000001b3;
|
|
190
|
-
let mut hash = FNV_OFFSET_BASIS;
|
|
191
|
-
for byte in input.as_bytes() {
|
|
192
|
-
hash ^= u64::from(*byte);
|
|
193
|
-
hash = hash.wrapping_mul(FNV_PRIME);
|
|
194
|
-
}
|
|
195
|
-
format!("{hash:08x}")
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
fn discover_child_projects(base: &Path, max_depth: usize) -> Vec<(String, PathBuf)> {
|
|
199
|
-
let mut results = Vec::new();
|
|
200
|
-
scan_children(base, 1, max_depth, &mut results);
|
|
201
|
-
results
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
fn scan_children(dir: &Path, depth: usize, max_depth: usize, results: &mut Vec<(String, PathBuf)>) {
|
|
205
|
-
if depth > max_depth {
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
let entries = match std::fs::read_dir(dir) {
|
|
209
|
-
Ok(e) => e,
|
|
210
|
-
Err(_) => return,
|
|
211
|
-
};
|
|
212
|
-
for entry in entries.flatten() {
|
|
213
|
-
if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
let name = entry.file_name();
|
|
217
|
-
let name_str = name.to_string_lossy();
|
|
218
|
-
if name_str.starts_with('.') || DEFAULT_SCAN_EXCLUDES.contains(&name_str.as_ref()) {
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
221
|
-
let path = entry.path();
|
|
222
|
-
let config_path = path.join(".handoff").join("config.toml");
|
|
223
|
-
if config_path.exists() {
|
|
224
|
-
if let Ok(config) = read_config(&config_path) {
|
|
225
|
-
results.push((config.project.name.clone(), path.clone()));
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
scan_children(&path, depth + 1, max_depth, results);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
struct Filters<'a> {
|
|
233
|
-
status: Option<&'a str>,
|
|
234
|
-
assignee: Option<&'a str>,
|
|
235
|
-
milestone: Option<&'a str>,
|
|
236
|
-
priority: Option<&'a str>,
|
|
237
|
-
label: Option<&'a str>,
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
impl Filters<'_> {
|
|
241
|
-
fn any_active(&self) -> bool {
|
|
242
|
-
self.status.is_some()
|
|
243
|
-
|| self.assignee.is_some()
|
|
244
|
-
|| self.milestone.is_some()
|
|
245
|
-
|| self.priority.is_some()
|
|
246
|
-
|| self.label.is_some()
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
fn matches(&self, node: &TaskIndex, data: Option<&crate::storage::tasks::TaskData>) -> bool {
|
|
250
|
-
if let Some(status) = self.status {
|
|
251
|
-
if node.status != status {
|
|
252
|
-
return false;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
if let Some(assignee) = self.assignee {
|
|
256
|
-
if node.assignee.as_deref() != Some(assignee) {
|
|
257
|
-
return false;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
if let Some(milestone) = self.milestone {
|
|
261
|
-
let ms = node.schedule.as_ref().and_then(|s| s.milestone.as_deref());
|
|
262
|
-
if ms != Some(milestone) {
|
|
263
|
-
return false;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
if let Some(priority) = self.priority {
|
|
267
|
-
if let Some(d) = data {
|
|
268
|
-
if d.priority.as_deref() != Some(priority) {
|
|
269
|
-
return false;
|
|
270
|
-
}
|
|
271
|
-
} else {
|
|
272
|
-
return false;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
if let Some(label) = self.label {
|
|
276
|
-
if let Some(d) = data {
|
|
277
|
-
if !d.labels.iter().any(|l| l == label) {
|
|
278
|
-
return false;
|
|
279
|
-
}
|
|
280
|
-
} else {
|
|
281
|
-
return false;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
true
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
fn filter_tree(tree: &[TaskIndex], filters: &Filters) -> Vec<TaskIndex> {
|
|
289
|
-
tree.iter()
|
|
290
|
-
.filter_map(|node| {
|
|
291
|
-
let children = filter_tree(&node.children, filters);
|
|
292
|
-
if filters.matches(node, None) || !children.is_empty() {
|
|
293
|
-
Some(TaskIndex {
|
|
294
|
-
id: node.id.clone(),
|
|
295
|
-
title: node.title.clone(),
|
|
296
|
-
status: node.status.clone(),
|
|
297
|
-
schedule: node.schedule.clone(),
|
|
298
|
-
dependencies: node.dependencies.clone(),
|
|
299
|
-
order: node.order,
|
|
300
|
-
assignee: node.assignee.clone(),
|
|
301
|
-
children,
|
|
302
|
-
})
|
|
303
|
-
} else {
|
|
304
|
-
None
|
|
305
|
-
}
|
|
306
|
-
})
|
|
307
|
-
.collect()
|
|
308
|
-
}
|