handoff-mcp-server 0.10.0 → 0.11.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/Cargo.lock +2 -1
- package/Cargo.toml +2 -1
- package/README.md +112 -32
- package/package.json +2 -1
- package/skills/handoff/SKILL.md +223 -0
- package/skills/handoff-import/SKILL.md +238 -0
- package/skills/handoff-load/SKILL.md +37 -0
- package/skills/handoff-refer/SKILL.md +71 -0
- package/src/mcp/handlers/assignees.rs +254 -0
- package/src/mcp/handlers/auto_schedule.rs +498 -0
- package/src/mcp/handlers/bulk_update.rs +115 -0
- package/src/mcp/handlers/calendar.rs +196 -0
- package/src/mcp/handlers/capacity.rs +316 -0
- package/src/mcp/handlers/config.rs +212 -67
- package/src/mcp/handlers/config_crud.rs +183 -0
- package/src/mcp/handlers/get_session.rs +48 -0
- package/src/mcp/handlers/get_task.rs +1 -0
- package/src/mcp/handlers/import_context.rs +7 -0
- package/src/mcp/handlers/list_sessions.rs +129 -0
- package/src/mcp/handlers/list_tasks.rs +76 -10
- package/src/mcp/handlers/load_context.rs +16 -0
- package/src/mcp/handlers/log_time.rs +70 -0
- package/src/mcp/handlers/metrics.rs +185 -0
- package/src/mcp/handlers/milestones.rs +102 -0
- package/src/mcp/handlers/mod.rs +29 -0
- package/src/mcp/handlers/update_session.rs +1 -1
- package/src/mcp/handlers/update_task.rs +46 -2
- package/src/mcp/tools.rs +339 -3
- package/src/storage/config.rs +145 -1
- package/src/storage/mod.rs +42 -0
- package/src/storage/referrals.rs +1 -1
- package/src/storage/sessions.rs +95 -22
- package/src/storage/tasks.rs +67 -2
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
2
|
+
|
|
3
|
+
use anyhow::{Context, Result};
|
|
4
|
+
use chrono::{Datelike, Duration, NaiveDate, Utc};
|
|
5
|
+
use serde_json::{json, Value};
|
|
6
|
+
use toml_edit::DocumentMut;
|
|
7
|
+
|
|
8
|
+
use super::resolve_project_dir;
|
|
9
|
+
use crate::storage::ensure_handoff_exists;
|
|
10
|
+
use crate::storage::tasks::*;
|
|
11
|
+
|
|
12
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
13
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
14
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
15
|
+
let config_path = handoff.join("config.toml");
|
|
16
|
+
let tasks_dir = handoff.join("tasks");
|
|
17
|
+
|
|
18
|
+
let dry_run = arguments
|
|
19
|
+
.get("dry_run")
|
|
20
|
+
.and_then(|v| v.as_bool())
|
|
21
|
+
.unwrap_or(true);
|
|
22
|
+
let assignee_filter = arguments.get("assignee_filter").and_then(|v| v.as_str());
|
|
23
|
+
|
|
24
|
+
let calendar = parse_project_calendar(&config_path)?;
|
|
25
|
+
let assignee_calendars = parse_assignee_calendars(&config_path)?;
|
|
26
|
+
|
|
27
|
+
let (tree, _) = build_task_index(&tasks_dir, u32::MAX)?;
|
|
28
|
+
|
|
29
|
+
// Collect schedulable tasks (non-terminal, not pinned)
|
|
30
|
+
let mut schedulable: Vec<SchedulableTask> = Vec::new();
|
|
31
|
+
collect_schedulable(&tree, &tasks_dir, assignee_filter, &mut schedulable)?;
|
|
32
|
+
|
|
33
|
+
// Sort by dependencies then order
|
|
34
|
+
sort_by_deps(&mut schedulable);
|
|
35
|
+
|
|
36
|
+
// Schedule each task. The anchor defaults to today (UTC) but can be pinned
|
|
37
|
+
// via `start_date` for planning a future start (and for deterministic tests).
|
|
38
|
+
let start_date = match arguments.get("start_date").and_then(|v| v.as_str()) {
|
|
39
|
+
Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
|
40
|
+
.map_err(|_| anyhow::anyhow!("Invalid start_date '{s}' (expected YYYY-MM-DD)"))?,
|
|
41
|
+
None => {
|
|
42
|
+
let today = Utc::now().format("%Y-%m-%d").to_string();
|
|
43
|
+
NaiveDate::parse_from_str(&today, "%Y-%m-%d")?
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
let mut assignee_next_date: HashMap<String, NaiveDate> = HashMap::new();
|
|
48
|
+
let mut project_next_date = start_date;
|
|
49
|
+
let mut changes: Vec<Value> = Vec::new();
|
|
50
|
+
|
|
51
|
+
for task in &schedulable {
|
|
52
|
+
let cal = task
|
|
53
|
+
.assignee
|
|
54
|
+
.as_ref()
|
|
55
|
+
.and_then(|a| assignee_calendars.get(a))
|
|
56
|
+
.unwrap_or(&calendar);
|
|
57
|
+
|
|
58
|
+
let earliest = match &task.assignee {
|
|
59
|
+
Some(a) => assignee_next_date.get(a).copied().unwrap_or(start_date),
|
|
60
|
+
None => project_next_date,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Also respect dependency completion dates
|
|
64
|
+
let dep_earliest = task
|
|
65
|
+
.dep_due_dates
|
|
66
|
+
.iter()
|
|
67
|
+
.filter_map(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
|
|
68
|
+
.max()
|
|
69
|
+
.map(|d| d + Duration::days(1))
|
|
70
|
+
.unwrap_or(start_date);
|
|
71
|
+
|
|
72
|
+
let task_start = earliest.max(dep_earliest);
|
|
73
|
+
|
|
74
|
+
// Find next work day from task_start
|
|
75
|
+
let actual_start = next_work_day(task_start, cal);
|
|
76
|
+
|
|
77
|
+
// Calculate end date based on estimate_hours, drawing each day's capacity
|
|
78
|
+
// from the calendar's per-day hours (day_hours overrides).
|
|
79
|
+
let hours = task.estimate_hours.unwrap_or(8.0);
|
|
80
|
+
let actual_end = advance_by_hours(actual_start, hours, cal);
|
|
81
|
+
|
|
82
|
+
let new_start = actual_start.format("%Y-%m-%d").to_string();
|
|
83
|
+
let new_due = actual_end.format("%Y-%m-%d").to_string();
|
|
84
|
+
|
|
85
|
+
if task.old_start.as_deref() != Some(&new_start)
|
|
86
|
+
|| task.old_due.as_deref() != Some(&new_due)
|
|
87
|
+
{
|
|
88
|
+
changes.push(json!({
|
|
89
|
+
"task_id": task.id,
|
|
90
|
+
"old_start": task.old_start,
|
|
91
|
+
"new_start": new_start,
|
|
92
|
+
"old_due": task.old_due,
|
|
93
|
+
"new_due": new_due,
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Advance the next available date for this assignee
|
|
98
|
+
let next_available = actual_end + Duration::days(1);
|
|
99
|
+
match &task.assignee {
|
|
100
|
+
Some(a) => {
|
|
101
|
+
assignee_next_date.insert(a.clone(), next_available);
|
|
102
|
+
}
|
|
103
|
+
None => {
|
|
104
|
+
project_next_date = next_available;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Apply if not dry_run
|
|
109
|
+
if !dry_run {
|
|
110
|
+
if let Some(task_dir) = find_task_dir_by_id(&tasks_dir, &task.id)? {
|
|
111
|
+
if let Some((mut data, status)) = read_task(&task_dir)? {
|
|
112
|
+
let schedule = data.schedule.get_or_insert_with(Default::default);
|
|
113
|
+
schedule.start_date = Some(new_start);
|
|
114
|
+
schedule.due_date = Some(new_due);
|
|
115
|
+
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
116
|
+
|
|
117
|
+
if let Some((old_path, _)) = find_task_file(&task_dir)? {
|
|
118
|
+
std::fs::remove_file(&old_path)?;
|
|
119
|
+
}
|
|
120
|
+
write_task(&task_dir, &status, &data)?;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Summarize the assignee calendars that fed the computation (applied conditions).
|
|
127
|
+
let assignee_capacity: serde_json::Map<String, Value> = assignee_calendars
|
|
128
|
+
.iter()
|
|
129
|
+
.map(|(k, c)| {
|
|
130
|
+
(
|
|
131
|
+
k.clone(),
|
|
132
|
+
json!({
|
|
133
|
+
"work_hours_per_day": c.work_hours_per_day,
|
|
134
|
+
"closed_weekdays": c.closed_weekdays,
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
.collect();
|
|
139
|
+
|
|
140
|
+
// When changes are actually applied, record them on the active session(s) so
|
|
141
|
+
// the decision is part of the audit trail, not just this response.
|
|
142
|
+
let mut decision_recorded_in = 0usize;
|
|
143
|
+
if !dry_run && !changes.is_empty() {
|
|
144
|
+
let assignees_touched: std::collections::BTreeSet<&str> = schedulable
|
|
145
|
+
.iter()
|
|
146
|
+
.filter_map(|t| t.assignee.as_deref())
|
|
147
|
+
.collect();
|
|
148
|
+
let summary = format!(
|
|
149
|
+
"Auto-scheduled {} task(s) across {} assignee(s) from {}",
|
|
150
|
+
changes.len(),
|
|
151
|
+
assignees_touched.len().max(1),
|
|
152
|
+
start_date.format("%Y-%m-%d")
|
|
153
|
+
);
|
|
154
|
+
let decision = json!({
|
|
155
|
+
"decision": summary,
|
|
156
|
+
"reason": "handoff_auto_schedule applied computed start/due dates",
|
|
157
|
+
"confidence": "confirmed",
|
|
158
|
+
});
|
|
159
|
+
let sessions_dir = handoff.join("sessions");
|
|
160
|
+
decision_recorded_in =
|
|
161
|
+
crate::storage::sessions::append_decision_to_active_sessions(&sessions_dir, decision)?;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let result = json!({
|
|
165
|
+
"dry_run": dry_run,
|
|
166
|
+
"scheduled_count": schedulable.len(),
|
|
167
|
+
"changed_count": changes.len(),
|
|
168
|
+
"changes": changes,
|
|
169
|
+
"decision_recorded_in_sessions": decision_recorded_in,
|
|
170
|
+
"calendar_config": {
|
|
171
|
+
"work_hours_per_day": calendar.work_hours_per_day,
|
|
172
|
+
"closed_weekdays": calendar.closed_weekdays,
|
|
173
|
+
"day_hours": calendar.day_hours,
|
|
174
|
+
},
|
|
175
|
+
"assignee_capacity": assignee_capacity,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
serde_json::to_string_pretty(&result).map_err(Into::into)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
struct SchedulableTask {
|
|
182
|
+
id: String,
|
|
183
|
+
assignee: Option<String>,
|
|
184
|
+
estimate_hours: Option<f64>,
|
|
185
|
+
old_start: Option<String>,
|
|
186
|
+
old_due: Option<String>,
|
|
187
|
+
dependencies: Vec<String>,
|
|
188
|
+
dep_due_dates: Vec<String>,
|
|
189
|
+
order: Option<u32>,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
fn collect_schedulable(
|
|
193
|
+
tree: &[TaskIndex],
|
|
194
|
+
tasks_dir: &std::path::Path,
|
|
195
|
+
assignee_filter: Option<&str>,
|
|
196
|
+
result: &mut Vec<SchedulableTask>,
|
|
197
|
+
) -> Result<()> {
|
|
198
|
+
for node in tree {
|
|
199
|
+
if is_terminal_status(&node.status) {
|
|
200
|
+
collect_schedulable(&node.children, tasks_dir, assignee_filter, result)?;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Check if pinned
|
|
205
|
+
let pinned = node
|
|
206
|
+
.schedule
|
|
207
|
+
.as_ref()
|
|
208
|
+
.and_then(|s| s.pinned)
|
|
209
|
+
.unwrap_or(false);
|
|
210
|
+
if pinned {
|
|
211
|
+
collect_schedulable(&node.children, tasks_dir, assignee_filter, result)?;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check assignee filter
|
|
216
|
+
if let Some(filter) = assignee_filter {
|
|
217
|
+
if node.assignee.as_deref() != Some(filter) {
|
|
218
|
+
collect_schedulable(&node.children, tasks_dir, assignee_filter, result)?;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Get dependency due dates
|
|
224
|
+
let dep_due_dates: Vec<String> = node
|
|
225
|
+
.dependencies
|
|
226
|
+
.iter()
|
|
227
|
+
.filter_map(|dep_id| {
|
|
228
|
+
find_task_dir_by_id(tasks_dir, dep_id)
|
|
229
|
+
.ok()
|
|
230
|
+
.flatten()
|
|
231
|
+
.and_then(|dir| read_task(&dir).ok().flatten())
|
|
232
|
+
.and_then(|(data, _)| data.schedule.and_then(|s| s.due_date))
|
|
233
|
+
})
|
|
234
|
+
.collect();
|
|
235
|
+
|
|
236
|
+
result.push(SchedulableTask {
|
|
237
|
+
id: node.id.clone(),
|
|
238
|
+
assignee: node.assignee.clone(),
|
|
239
|
+
estimate_hours: node.schedule.as_ref().and_then(|s| s.estimate_hours),
|
|
240
|
+
old_start: node.schedule.as_ref().and_then(|s| s.start_date.clone()),
|
|
241
|
+
old_due: node.schedule.as_ref().and_then(|s| s.due_date.clone()),
|
|
242
|
+
dependencies: node.dependencies.clone(),
|
|
243
|
+
dep_due_dates,
|
|
244
|
+
order: node.order,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
collect_schedulable(&node.children, tasks_dir, assignee_filter, result)?;
|
|
248
|
+
}
|
|
249
|
+
Ok(())
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
fn sort_by_deps(tasks: &mut [SchedulableTask]) {
|
|
253
|
+
// Simple topological sort: tasks with no deps first, then by order
|
|
254
|
+
tasks.sort_by(|a, b| {
|
|
255
|
+
let a_has_deps = !a.dependencies.is_empty();
|
|
256
|
+
let b_has_deps = !b.dependencies.is_empty();
|
|
257
|
+
a_has_deps
|
|
258
|
+
.cmp(&b_has_deps)
|
|
259
|
+
.then_with(|| {
|
|
260
|
+
a.order
|
|
261
|
+
.unwrap_or(u32::MAX)
|
|
262
|
+
.cmp(&b.order.unwrap_or(u32::MAX))
|
|
263
|
+
})
|
|
264
|
+
.then_with(|| a.id.cmp(&b.id))
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
struct Calendar {
|
|
269
|
+
work_hours_per_day: f64,
|
|
270
|
+
closed_weekdays: Vec<u32>,
|
|
271
|
+
closed_dates: Vec<String>,
|
|
272
|
+
open_dates: Vec<String>,
|
|
273
|
+
/// Per-weekday-name or per-YYYY-MM-DD working-hour overrides.
|
|
274
|
+
day_hours: HashMap<String, f64>,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const WEEKDAY_NAMES: [&str; 7] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
278
|
+
|
|
279
|
+
impl Calendar {
|
|
280
|
+
/// Effective working hours for a specific date. A date-specific override in
|
|
281
|
+
/// `day_hours` takes precedence over a weekday-name override, which takes
|
|
282
|
+
/// precedence over `work_hours_per_day`. Mirrors capacity.rs.
|
|
283
|
+
fn hours_for(&self, date: &NaiveDate) -> f64 {
|
|
284
|
+
let date_str = date.format("%Y-%m-%d").to_string();
|
|
285
|
+
if let Some(h) = self.day_hours.get(&date_str) {
|
|
286
|
+
return *h;
|
|
287
|
+
}
|
|
288
|
+
let weekday_num = weekday_index(date);
|
|
289
|
+
let name = WEEKDAY_NAMES[weekday_num as usize];
|
|
290
|
+
if let Some(h) = self.day_hours.get(name) {
|
|
291
|
+
return *h;
|
|
292
|
+
}
|
|
293
|
+
self.work_hours_per_day
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
fn is_work_day(&self, date: &NaiveDate) -> bool {
|
|
297
|
+
let date_str = date.format("%Y-%m-%d").to_string();
|
|
298
|
+
|
|
299
|
+
if self.closed_dates.contains(&date_str) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if self.open_dates.contains(&date_str) {
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
!self.closed_weekdays.contains(&weekday_index(date))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
fn weekday_index(date: &NaiveDate) -> u32 {
|
|
312
|
+
match date.weekday() {
|
|
313
|
+
chrono::Weekday::Sun => 0,
|
|
314
|
+
chrono::Weekday::Mon => 1,
|
|
315
|
+
chrono::Weekday::Tue => 2,
|
|
316
|
+
chrono::Weekday::Wed => 3,
|
|
317
|
+
chrono::Weekday::Thu => 4,
|
|
318
|
+
chrono::Weekday::Fri => 5,
|
|
319
|
+
chrono::Weekday::Sat => 6,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
fn next_work_day(from: NaiveDate, cal: &Calendar) -> NaiveDate {
|
|
324
|
+
let mut date = from;
|
|
325
|
+
while !cal.is_work_day(&date) {
|
|
326
|
+
date += Duration::days(1);
|
|
327
|
+
}
|
|
328
|
+
date
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/// Advance from `start` (assumed to be a work day) consuming `hours` of effort,
|
|
332
|
+
/// drawing each day's capacity from `cal.hours_for(date)`. Returns the last work
|
|
333
|
+
/// day the task occupies. Respects per-day hour overrides (day_hours), so a task
|
|
334
|
+
/// spanning a half-capacity Friday takes an extra day. (referral ref-...004309 §5)
|
|
335
|
+
fn advance_by_hours(start: NaiveDate, hours: f64, cal: &Calendar) -> NaiveDate {
|
|
336
|
+
let mut date = start;
|
|
337
|
+
// Consume the first day's capacity.
|
|
338
|
+
let mut remaining = hours - cal.hours_for(&date).max(0.0);
|
|
339
|
+
// Guard against a zero-capacity calendar (would otherwise loop forever).
|
|
340
|
+
let mut guard = 0;
|
|
341
|
+
while remaining > 1e-9 && guard < 10_000 {
|
|
342
|
+
date = next_work_day(date + Duration::days(1), cal);
|
|
343
|
+
remaining -= cal.hours_for(&date).max(0.0);
|
|
344
|
+
guard += 1;
|
|
345
|
+
}
|
|
346
|
+
date
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
fn parse_project_calendar(config_path: &std::path::Path) -> Result<Calendar> {
|
|
350
|
+
let mut cal = Calendar {
|
|
351
|
+
work_hours_per_day: 8.0,
|
|
352
|
+
closed_weekdays: vec![0, 6], // Sun, Sat
|
|
353
|
+
closed_dates: Vec::new(),
|
|
354
|
+
open_dates: Vec::new(),
|
|
355
|
+
day_hours: HashMap::new(),
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
if !config_path.exists() {
|
|
359
|
+
return Ok(cal);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let raw = std::fs::read_to_string(config_path).with_context(|| "Failed to read config")?;
|
|
363
|
+
let doc: DocumentMut = raw.parse().with_context(|| "Failed to parse config")?;
|
|
364
|
+
|
|
365
|
+
if let Some(calendar) = doc.get("calendar").and_then(|v| v.as_table()) {
|
|
366
|
+
if let Some(h) = calendar
|
|
367
|
+
.get("work_hours_per_day")
|
|
368
|
+
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
|
|
369
|
+
{
|
|
370
|
+
cal.work_hours_per_day = h;
|
|
371
|
+
}
|
|
372
|
+
if let Some(arr) = calendar.get("closed_weekdays").and_then(|v| v.as_array()) {
|
|
373
|
+
cal.closed_weekdays = arr
|
|
374
|
+
.iter()
|
|
375
|
+
.filter_map(|v| {
|
|
376
|
+
v.as_integer()
|
|
377
|
+
.map(|i| i as u32)
|
|
378
|
+
.or_else(|| v.as_str().and_then(weekday_to_num))
|
|
379
|
+
})
|
|
380
|
+
.collect();
|
|
381
|
+
}
|
|
382
|
+
if let Some(arr) = calendar.get("closed_dates").and_then(|v| v.as_array()) {
|
|
383
|
+
cal.closed_dates = arr
|
|
384
|
+
.iter()
|
|
385
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
386
|
+
.collect();
|
|
387
|
+
}
|
|
388
|
+
if let Some(arr) = calendar.get("open_dates").and_then(|v| v.as_array()) {
|
|
389
|
+
cal.open_dates = arr
|
|
390
|
+
.iter()
|
|
391
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
392
|
+
.collect();
|
|
393
|
+
}
|
|
394
|
+
if let Some(dh) = calendar.get("day_hours").and_then(|v| v.as_table()) {
|
|
395
|
+
cal.day_hours = parse_day_hours(dh);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
Ok(cal)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/// Parse a `[*.day_hours]` table into a map of weekday-name/date -> hours.
|
|
403
|
+
fn parse_day_hours(table: &toml_edit::Table) -> HashMap<String, f64> {
|
|
404
|
+
let mut out = HashMap::new();
|
|
405
|
+
for (key, item) in table.iter() {
|
|
406
|
+
if let Some(h) = item
|
|
407
|
+
.as_value()
|
|
408
|
+
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
|
|
409
|
+
{
|
|
410
|
+
out.insert(key.to_string(), h);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
out
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
fn parse_assignee_calendars(config_path: &std::path::Path) -> Result<HashMap<String, Calendar>> {
|
|
417
|
+
let mut result = HashMap::new();
|
|
418
|
+
|
|
419
|
+
if !config_path.exists() {
|
|
420
|
+
return Ok(result);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let raw = std::fs::read_to_string(config_path).with_context(|| "Failed to read config")?;
|
|
424
|
+
let doc: DocumentMut = raw.parse().with_context(|| "Failed to parse config")?;
|
|
425
|
+
|
|
426
|
+
let base = parse_project_calendar(config_path)?;
|
|
427
|
+
|
|
428
|
+
if let Some(assignees) = doc.get("assignees").and_then(|v| v.as_table()) {
|
|
429
|
+
for (key, item) in assignees.iter() {
|
|
430
|
+
let a = match item.as_table() {
|
|
431
|
+
Some(t) => t,
|
|
432
|
+
None => continue,
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
let mut cal = Calendar {
|
|
436
|
+
work_hours_per_day: base.work_hours_per_day,
|
|
437
|
+
closed_weekdays: base.closed_weekdays.clone(),
|
|
438
|
+
closed_dates: base.closed_dates.clone(),
|
|
439
|
+
open_dates: base.open_dates.clone(),
|
|
440
|
+
day_hours: base.day_hours.clone(),
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
if let Some(h) = a
|
|
444
|
+
.get("work_hours_per_day")
|
|
445
|
+
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
|
|
446
|
+
{
|
|
447
|
+
cal.work_hours_per_day = h;
|
|
448
|
+
}
|
|
449
|
+
if let Some(arr) = a.get("closed_weekdays").and_then(|v| v.as_array()) {
|
|
450
|
+
cal.closed_weekdays = arr
|
|
451
|
+
.iter()
|
|
452
|
+
.filter_map(|v| {
|
|
453
|
+
v.as_integer()
|
|
454
|
+
.map(|i| i as u32)
|
|
455
|
+
.or_else(|| v.as_str().and_then(weekday_to_num))
|
|
456
|
+
})
|
|
457
|
+
.collect();
|
|
458
|
+
}
|
|
459
|
+
if let Some(arr) = a.get("closed_dates").and_then(|v| v.as_array()) {
|
|
460
|
+
for item in arr.iter() {
|
|
461
|
+
if let Some(s) = item.as_str() {
|
|
462
|
+
cal.closed_dates.push(s.to_string());
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if let Some(arr) = a.get("open_dates").and_then(|v| v.as_array()) {
|
|
467
|
+
for item in arr.iter() {
|
|
468
|
+
if let Some(s) = item.as_str() {
|
|
469
|
+
cal.open_dates.push(s.to_string());
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// Per-assignee day_hours override the inherited project values key-by-key.
|
|
474
|
+
if let Some(dh) = a.get("day_hours").and_then(|v| v.as_table()) {
|
|
475
|
+
for (k, v) in parse_day_hours(dh) {
|
|
476
|
+
cal.day_hours.insert(k, v);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
result.insert(key.to_string(), cal);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
Ok(result)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
fn weekday_to_num(s: &str) -> Option<u32> {
|
|
488
|
+
match s.to_lowercase().as_str() {
|
|
489
|
+
"sun" | "sunday" => Some(0),
|
|
490
|
+
"mon" | "monday" => Some(1),
|
|
491
|
+
"tue" | "tuesday" => Some(2),
|
|
492
|
+
"wed" | "wednesday" => Some(3),
|
|
493
|
+
"thu" | "thursday" => Some(4),
|
|
494
|
+
"fri" | "friday" => Some(5),
|
|
495
|
+
"sat" | "saturday" => Some(6),
|
|
496
|
+
_ => None,
|
|
497
|
+
}
|
|
498
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use chrono::Utc;
|
|
3
|
+
use serde_json::{json, Value};
|
|
4
|
+
|
|
5
|
+
use super::resolve_project_dir;
|
|
6
|
+
use crate::storage::ensure_handoff_exists;
|
|
7
|
+
use crate::storage::tasks::*;
|
|
8
|
+
|
|
9
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
10
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
11
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
12
|
+
let tasks_dir = handoff.join("tasks");
|
|
13
|
+
|
|
14
|
+
let updates = arguments
|
|
15
|
+
.get("updates")
|
|
16
|
+
.and_then(|v| v.as_array())
|
|
17
|
+
.ok_or_else(|| anyhow::anyhow!("'updates' parameter is required (array)"))?;
|
|
18
|
+
|
|
19
|
+
let mut applied: u32 = 0;
|
|
20
|
+
let mut errors: Vec<Value> = Vec::new();
|
|
21
|
+
|
|
22
|
+
for update in updates {
|
|
23
|
+
let task_id = match update.get("task_id").and_then(|v| v.as_str()) {
|
|
24
|
+
Some(id) => id,
|
|
25
|
+
None => {
|
|
26
|
+
errors.push(json!({"task_id": null, "error": "missing task_id"}));
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
if let Err(e) = apply_single_update(&tasks_dir, task_id, update) {
|
|
32
|
+
errors.push(json!({"task_id": task_id, "error": e.to_string()}));
|
|
33
|
+
} else {
|
|
34
|
+
applied += 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let result = json!({
|
|
39
|
+
"applied": applied,
|
|
40
|
+
"errors": errors,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
serde_json::to_string_pretty(&result).map_err(Into::into)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn apply_single_update(tasks_dir: &std::path::Path, task_id: &str, update: &Value) -> Result<()> {
|
|
47
|
+
let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
|
|
48
|
+
.ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
|
|
49
|
+
|
|
50
|
+
let (mut data, current_status) = read_task(&task_dir)?
|
|
51
|
+
.ok_or_else(|| anyhow::anyhow!("Task file not found for {task_id}"))?;
|
|
52
|
+
|
|
53
|
+
if let Some(priority) = update.get("priority").and_then(|v| v.as_str()) {
|
|
54
|
+
validate_priority(Some(priority))?;
|
|
55
|
+
data.priority = Some(priority.to_string());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if update.get("assignee").is_some() {
|
|
59
|
+
data.assignee = update
|
|
60
|
+
.get("assignee")
|
|
61
|
+
.and_then(|v| v.as_str())
|
|
62
|
+
.map(String::from);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if let Some(sched_val) = update.get("schedule") {
|
|
66
|
+
let schedule = data.schedule.get_or_insert_with(Default::default);
|
|
67
|
+
if let Some(sd) = sched_val.get("start_date").and_then(|v| v.as_str()) {
|
|
68
|
+
schedule.start_date = Some(sd.to_string());
|
|
69
|
+
}
|
|
70
|
+
if let Some(dd) = sched_val.get("due_date").and_then(|v| v.as_str()) {
|
|
71
|
+
schedule.due_date = Some(dd.to_string());
|
|
72
|
+
}
|
|
73
|
+
if let Some(eh) = sched_val.get("estimate_hours").and_then(|v| v.as_f64()) {
|
|
74
|
+
schedule.estimate_hours = Some(eh);
|
|
75
|
+
}
|
|
76
|
+
if let Some(ah) = sched_val.get("actual_hours").and_then(|v| v.as_f64()) {
|
|
77
|
+
schedule.actual_hours = Some(ah);
|
|
78
|
+
}
|
|
79
|
+
if let Some(rh) = sched_val.get("remaining_hours").and_then(|v| v.as_f64()) {
|
|
80
|
+
schedule.remaining_hours = Some(rh);
|
|
81
|
+
}
|
|
82
|
+
if let Some(ms) = sched_val.get("milestone").and_then(|v| v.as_str()) {
|
|
83
|
+
schedule.milestone = Some(ms.to_string());
|
|
84
|
+
}
|
|
85
|
+
if let Some(p) = sched_val.get("pinned").and_then(|v| v.as_bool()) {
|
|
86
|
+
schedule.pinned = Some(p);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let new_status = update
|
|
91
|
+
.get("status")
|
|
92
|
+
.and_then(|v| v.as_str())
|
|
93
|
+
.unwrap_or(¤t_status);
|
|
94
|
+
|
|
95
|
+
if !is_valid_status(new_status) {
|
|
96
|
+
anyhow::bail!("Invalid status: {new_status}");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if new_status == "done" && current_status != "done" {
|
|
100
|
+
validate_done_transition(&task_dir, &data)?;
|
|
101
|
+
data.completed_at = Some(Utc::now().to_rfc3339());
|
|
102
|
+
}
|
|
103
|
+
if new_status == "skipped" && current_status != "skipped" {
|
|
104
|
+
validate_skipped_transition(&task_dir, &data)?;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
108
|
+
|
|
109
|
+
if let Some((old_path, _)) = find_task_file(&task_dir)? {
|
|
110
|
+
std::fs::remove_file(&old_path)?;
|
|
111
|
+
}
|
|
112
|
+
write_task(&task_dir, new_status, &data)?;
|
|
113
|
+
|
|
114
|
+
Ok(())
|
|
115
|
+
}
|