feat: add rTime integration — time entries, sync bridge, and CLI commands
CI/CD / deploy (push) Successful in 35s
Details
CI/CD / deploy (push) Successful in 35s
Details
Add TimeEntry tracking to task files with `backlog task log` and `backlog task time` commands. Introduce rTime bridge layer with offline queue for pushing time entries to rSpace commitment pools. New `backlog rtime` command group for sync, pull, link, and import operations. Phase 1: TimeEntry types, parser/serializer, Core.updateTaskFromInput Phase 2: rTime API (handled in rspace-online repo) Phase 3: rtime-sync.ts bridge with offline queue Phase 4: Pull sync (rTime → backlog status updates) Phase 6: Import rTime tasks as local backlog tasks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
65bcb8b98f
commit
9648ec1a16
403
src/cli.ts
403
src/cli.ts
|
|
@ -1974,6 +1974,144 @@ taskCmd
|
|||
await viewTaskEnhanced(task, { startWithDetailFocus: true, core });
|
||||
});
|
||||
|
||||
taskCmd
|
||||
.command("log <taskId>")
|
||||
.description("log time spent on a task")
|
||||
.option("--hours <number>", "hours spent (decimal, e.g. 1.5)")
|
||||
.option("--skill <skill>", "skill category (e.g. tech, design, facilitation, outreach, logistics)")
|
||||
.option("--note <text>", "description of work done")
|
||||
.option("--date <date>", "date of work (YYYY-MM-DD, defaults to today)")
|
||||
.option("--push", "push time entry to rTime after logging")
|
||||
.action(async (taskId: string, options) => {
|
||||
const cwd = process.cwd();
|
||||
const core = new Core(cwd);
|
||||
const canonicalId = normalizeTaskId(taskId);
|
||||
const task = await core.loadTaskById(canonicalId);
|
||||
if (!task) {
|
||||
console.error(`Task ${taskId} not found.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.hours) {
|
||||
console.error("--hours is required.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const hours = Number(options.hours);
|
||||
if (Number.isNaN(hours) || hours <= 0) {
|
||||
console.error("Hours must be a positive number.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const date = options.date ?? new Date().toISOString().slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
console.error("Date must be in YYYY-MM-DD format.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTask = await core.editTask(canonicalId, {
|
||||
addTimeEntry: {
|
||||
date,
|
||||
hours,
|
||||
...(options.skill && { skill: String(options.skill) }),
|
||||
...(options.note && { note: String(options.note) }),
|
||||
},
|
||||
});
|
||||
|
||||
const totalHours = (updatedTask.timeEntries ?? []).reduce((sum, e) => sum + e.hours, 0);
|
||||
const skillSuffix = options.skill ? ` ${options.skill}` : "";
|
||||
const noteSuffix = options.note ? ` "${options.note}"` : "";
|
||||
console.log(`Logged ${hours}h on ${canonicalId}${skillSuffix}${noteSuffix} — total: ${totalHours}h`);
|
||||
|
||||
// Push to rTime if --push flag or autoPush config
|
||||
const config = await core.filesystem.loadConfig();
|
||||
const rtimeConfig = config?.rtime;
|
||||
if (options.push || rtimeConfig?.autoPush) {
|
||||
if (!rtimeConfig?.baseUrl) {
|
||||
console.log(" (rTime push skipped — no rtime.baseUrl configured)");
|
||||
} else {
|
||||
const { pushTimeEntry } = await import("./utils/rtime-sync.ts");
|
||||
const entry = updatedTask.timeEntries?.[updatedTask.timeEntries.length - 1];
|
||||
if (entry) {
|
||||
const result = await pushTimeEntry(updatedTask, entry, cwd, rtimeConfig);
|
||||
if (result.success) {
|
||||
console.log(` Pushed to rTime (commitment: ${result.commitmentId})`);
|
||||
} else {
|
||||
console.log(` Queued for rTime sync (${result.error})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
taskCmd
|
||||
.command("time [taskId]")
|
||||
.description("show time summary for a task or all tasks")
|
||||
.option("--by-skill", "group time entries by skill")
|
||||
.option("--plain", "plain text output")
|
||||
.action(async (taskId: string | undefined, options) => {
|
||||
const cwd = process.cwd();
|
||||
const core = new Core(cwd);
|
||||
|
||||
let tasks: Task[];
|
||||
if (taskId) {
|
||||
const canonicalId = normalizeTaskId(taskId);
|
||||
const task = await core.loadTaskById(canonicalId);
|
||||
if (!task) {
|
||||
console.error(`Task ${taskId} not found.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
tasks = [task];
|
||||
} else {
|
||||
tasks = await core.queryTasks();
|
||||
tasks = tasks.filter((t) => t.timeEntries && t.timeEntries.length > 0);
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log("No time entries found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.bySkill) {
|
||||
const skillTotals = new Map<string, number>();
|
||||
for (const task of tasks) {
|
||||
for (const entry of task.timeEntries ?? []) {
|
||||
const skill = entry.skill ?? "(none)";
|
||||
skillTotals.set(skill, (skillTotals.get(skill) ?? 0) + entry.hours);
|
||||
}
|
||||
}
|
||||
console.log("Time by skill:");
|
||||
let grandTotal = 0;
|
||||
for (const [skill, hours] of [...skillTotals.entries()].sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${skill.padEnd(16)} ${hours.toFixed(1)}h`);
|
||||
grandTotal += hours;
|
||||
}
|
||||
console.log(` ${"Total".padEnd(16)} ${grandTotal.toFixed(1)}h`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
const entries = task.timeEntries ?? [];
|
||||
if (entries.length === 0) continue;
|
||||
const totalHours = entries.reduce((sum, e) => sum + e.hours, 0);
|
||||
const estimated = task.estimatedHours !== undefined ? `${task.estimatedHours}h` : "n/a";
|
||||
console.log(`${task.id}: ${task.title}`);
|
||||
for (const entry of entries) {
|
||||
const skill = entry.skill ? ` ${entry.skill}` : "";
|
||||
const note = entry.note ? ` "${entry.note}"` : "";
|
||||
const synced = entry.rtimeCommitmentId ? " [synced]" : "";
|
||||
console.log(` ${entry.date} ${entry.hours.toFixed(1)}h${skill}${note}${synced}`);
|
||||
}
|
||||
console.log(` Total: ${totalHours.toFixed(1)}h (estimated: ${estimated})`);
|
||||
if (tasks.length > 1) console.log();
|
||||
}
|
||||
});
|
||||
|
||||
taskCmd
|
||||
.command("archive <taskId>")
|
||||
.description("archive a task")
|
||||
|
|
@ -2010,7 +2148,7 @@ taskCmd
|
|||
const core = new Core(cwd);
|
||||
|
||||
// Don't handle commands that should be handled by specific command handlers
|
||||
const reservedCommands = ["create", "list", "edit", "view", "archive", "demote"];
|
||||
const reservedCommands = ["create", "list", "edit", "view", "archive", "demote", "log", "time"];
|
||||
if (taskId && reservedCommands.includes(taskId)) {
|
||||
console.error(`Unknown command: ${taskId}`);
|
||||
taskCmd.help();
|
||||
|
|
@ -2223,6 +2361,269 @@ draftCmd
|
|||
await viewTaskEnhanced(draft, { startWithDetailFocus: true, core });
|
||||
});
|
||||
|
||||
// ── rTime integration commands ──
|
||||
|
||||
const rtimeCmd = program.command("rtime").description("rTime integration for time tracking and commitment pools");
|
||||
|
||||
rtimeCmd
|
||||
.command("sync")
|
||||
.description("flush offline queue of time entries to rTime")
|
||||
.action(async () => {
|
||||
const cwd = process.cwd();
|
||||
const core = new Core(cwd);
|
||||
const config = await core.filesystem.loadConfig();
|
||||
const rtimeConfig = config?.rtime;
|
||||
if (!rtimeConfig?.baseUrl) {
|
||||
console.error("rTime not configured. Add rtime.baseUrl to .backlog/config.yml");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const { flushQueue } = await import("./utils/rtime-sync.ts");
|
||||
const result = await flushQueue(cwd, rtimeConfig);
|
||||
if (result.flushed === 0 && result.remaining === 0) {
|
||||
console.log("Queue empty — nothing to sync.");
|
||||
} else {
|
||||
console.log(`Synced: ${result.flushed} pushed, ${result.failed} failed, ${result.remaining} remaining`);
|
||||
}
|
||||
});
|
||||
|
||||
rtimeCmd
|
||||
.command("status")
|
||||
.description("show rTime integration config and queue status")
|
||||
.action(async () => {
|
||||
const cwd = process.cwd();
|
||||
const core = new Core(cwd);
|
||||
const config = await core.filesystem.loadConfig();
|
||||
const rtimeConfig = config?.rtime;
|
||||
|
||||
console.log("rTime Integration Status");
|
||||
console.log("========================");
|
||||
if (!rtimeConfig) {
|
||||
console.log("Not configured. Add rtime section to .backlog/config.yml:");
|
||||
console.log(" rtime:");
|
||||
console.log(" baseUrl: https://rspace.online");
|
||||
console.log(" space: your-space");
|
||||
console.log(" memberName: Your Name");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` Base URL: ${rtimeConfig.baseUrl || "(not set)"}`);
|
||||
console.log(` Space: ${rtimeConfig.space || "demo"}`);
|
||||
console.log(` Member: ${rtimeConfig.memberName || "(not set)"}`);
|
||||
console.log(` Auto-push: ${rtimeConfig.autoPush ? "yes" : "no"}`);
|
||||
console.log(` Default skill: ${rtimeConfig.defaultSkill || "tech"}`);
|
||||
|
||||
const { loadQueue } = await import("./utils/rtime-sync.ts");
|
||||
const queue = await loadQueue(cwd);
|
||||
console.log(` Queue: ${queue.length} entries pending`);
|
||||
|
||||
if (rtimeConfig.baseUrl) {
|
||||
const token = rtimeConfig.token || process.env.RSPACE_TOKEN;
|
||||
if (token) {
|
||||
console.log(" Auth: token configured");
|
||||
} else {
|
||||
console.log(" Auth: no token (set RSPACE_TOKEN env var or rtime.token in config)");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rtimeCmd
|
||||
.command("pull [taskId]")
|
||||
.description("pull commitment statuses from rTime and update local task files")
|
||||
.action(async (taskId: string | undefined) => {
|
||||
const cwd = process.cwd();
|
||||
const core = new Core(cwd);
|
||||
const config = await core.filesystem.loadConfig();
|
||||
const rtimeConfig = config?.rtime;
|
||||
if (!rtimeConfig?.baseUrl) {
|
||||
console.error("rTime not configured. Add rtime.baseUrl to .backlog/config.yml");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const { pullStatus } = await import("./utils/rtime-sync.ts");
|
||||
const resolvedTaskId = taskId ? normalizeTaskId(taskId) : undefined;
|
||||
const result = await pullStatus(cwd, rtimeConfig, resolvedTaskId);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Pull failed: ${result.error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.logs.length === 0) {
|
||||
console.log("No external time logs found in rTime.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Group logs by backlog task ID
|
||||
const byTask = new Map<string, typeof result.logs>();
|
||||
for (const log of result.logs) {
|
||||
const existing = byTask.get(log.backlogTaskId) ?? [];
|
||||
existing.push(log);
|
||||
byTask.set(log.backlogTaskId, existing);
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
for (const [btid, logs] of byTask) {
|
||||
const task = await core.loadTaskById(btid);
|
||||
if (!task) continue;
|
||||
|
||||
let mutated = false;
|
||||
for (const log of logs) {
|
||||
if (!log.commitmentId) continue;
|
||||
// Find matching time entry and update its syncedAt / rtimeCommitmentId
|
||||
const entry = (task.timeEntries ?? []).find(
|
||||
(e) => Math.abs(e.hours - log.hours) < 0.01 && !e.rtimeCommitmentId,
|
||||
);
|
||||
if (entry) {
|
||||
entry.rtimeCommitmentId = log.commitmentId;
|
||||
entry.syncedAt = new Date().toISOString();
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (mutated) {
|
||||
await core.updateTask(task);
|
||||
updated++;
|
||||
console.log(` ${btid}: updated ${logs.length} entries (${logs.map((l) => l.status).join(", ")})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Pulled ${result.logs.length} logs, updated ${updated} tasks.`);
|
||||
});
|
||||
|
||||
rtimeCmd
|
||||
.command("link <taskId>")
|
||||
.description("link a backlog task to an rTime task")
|
||||
.option("--rtime-task <id>", "rTime task ID to link to")
|
||||
.action(async (taskId: string, options) => {
|
||||
const cwd = process.cwd();
|
||||
const core = new Core(cwd);
|
||||
const canonicalId = normalizeTaskId(taskId);
|
||||
const task = await core.loadTaskById(canonicalId);
|
||||
if (!task) {
|
||||
console.error(`Task ${taskId} not found.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await core.filesystem.loadConfig();
|
||||
const rtimeConfig = config?.rtime;
|
||||
if (!rtimeConfig?.baseUrl) {
|
||||
console.error("rTime not configured.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.rtimeTask) {
|
||||
// Manual link — update local task with rTime ref
|
||||
task.rtime = {
|
||||
...task.rtime,
|
||||
taskId: options.rtimeTask,
|
||||
space: rtimeConfig.space || "demo",
|
||||
};
|
||||
await core.updateTask(task);
|
||||
console.log(`Linked ${canonicalId} → rTime task ${options.rtimeTask}`);
|
||||
} else {
|
||||
// Auto-link — find or create rTime task
|
||||
const { ensureRtimeTask } = await import("./utils/rtime-sync.ts");
|
||||
const result = await ensureRtimeTask(task, rtimeConfig);
|
||||
if (result.error) {
|
||||
console.error(`Failed to link: ${result.error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
task.rtime = {
|
||||
...task.rtime,
|
||||
taskId: result.taskId,
|
||||
space: rtimeConfig.space || "demo",
|
||||
};
|
||||
await core.updateTask(task);
|
||||
console.log(`Linked ${canonicalId} → rTime task ${result.taskId}`);
|
||||
}
|
||||
});
|
||||
|
||||
rtimeCmd
|
||||
.command("import <rtimeTaskId>")
|
||||
.description("import an rTime task as a local backlog task")
|
||||
.action(async (rtimeTaskId: string) => {
|
||||
const cwd = process.cwd();
|
||||
const core = new Core(cwd);
|
||||
const config = await core.filesystem.loadConfig();
|
||||
const rtimeConfig = config?.rtime;
|
||||
if (!rtimeConfig?.baseUrl) {
|
||||
console.error("rTime not configured. Add rtime.baseUrl to .backlog/config.yml");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const base = rtimeConfig.baseUrl || "https://rspace.online";
|
||||
const space = rtimeConfig.space || "demo";
|
||||
const token = rtimeConfig.token || process.env.RSPACE_TOKEN || "";
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${base}/${space}/rtime/api/tasks/${rtimeTaskId}/export-to-backlog`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
console.error(`Failed to export from rTime: HTTP ${resp.status} — ${body}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as {
|
||||
title: string;
|
||||
description?: string;
|
||||
estimatedHours?: number;
|
||||
labels?: string[];
|
||||
notes?: string;
|
||||
acceptanceCriteria?: string[];
|
||||
rtime?: { taskId: string; space: string };
|
||||
};
|
||||
|
||||
await core.ensureConfigLoaded();
|
||||
const id = await core.generateNextId();
|
||||
const task: Task = {
|
||||
id,
|
||||
title: data.title,
|
||||
status: config?.defaultStatus || "To Do",
|
||||
assignee: config?.defaultAssignee ? [config.defaultAssignee] : [],
|
||||
createdDate: new Date().toISOString().slice(0, 16).replace("T", " "),
|
||||
labels: data.labels ?? [],
|
||||
dependencies: [],
|
||||
description: data.description,
|
||||
estimatedHours: data.estimatedHours,
|
||||
implementationNotes: data.notes || undefined,
|
||||
rtime: data.rtime,
|
||||
};
|
||||
|
||||
if (data.acceptanceCriteria && data.acceptanceCriteria.length > 0) {
|
||||
task.acceptanceCriteriaItems = data.acceptanceCriteria.map((text, i) => ({
|
||||
index: i + 1,
|
||||
text,
|
||||
checked: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const filepath = await core.createTask(task);
|
||||
console.log(`Imported rTime task as ${id}: ${data.title}`);
|
||||
console.log(`File: ${filepath}`);
|
||||
if (data.rtime) {
|
||||
console.log(`Linked to rTime task: ${data.rtime.taskId} (space: ${data.rtime.space})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
const boardCmd = program.command("board");
|
||||
|
||||
function addBoardOptions(cmd: Command) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type {
|
|||
TaskCreateInput,
|
||||
TaskListFilter,
|
||||
TaskUpdateInput,
|
||||
TimeEntry,
|
||||
} from "../types/index.ts";
|
||||
import { isLocalEditableTask } from "../types/index.ts";
|
||||
import { normalizeAssignee } from "../utils/assignee.ts";
|
||||
|
|
@ -904,6 +905,17 @@ export class Core {
|
|||
|
||||
task.acceptanceCriteriaItems = acceptanceCriteria;
|
||||
|
||||
if (input.addTimeEntry) {
|
||||
const entry: TimeEntry = {
|
||||
date: input.addTimeEntry.date,
|
||||
hours: input.addTimeEntry.hours,
|
||||
...(input.addTimeEntry.skill && { skill: input.addTimeEntry.skill }),
|
||||
...(input.addTimeEntry.note && { note: input.addTimeEntry.note }),
|
||||
};
|
||||
task.timeEntries = [...(task.timeEntries ?? []), entry];
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if (!mutated) {
|
||||
return task;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import type {
|
|||
Decision,
|
||||
Document,
|
||||
ParsedMarkdown,
|
||||
RtimeRef,
|
||||
StatusHistoryEntry,
|
||||
Task,
|
||||
TimeEntry,
|
||||
} from "../types/index.ts";
|
||||
import { AcceptanceCriteriaManager, extractStructuredSection, STRUCTURED_SECTION_KEYS } from "./structured-sections.ts";
|
||||
|
||||
|
|
@ -107,6 +109,39 @@ function parseStatusHistory(value: unknown): StatusHistoryEntry[] | undefined {
|
|||
return entries.length > 0 ? entries : undefined;
|
||||
}
|
||||
|
||||
function parseTimeEntries(value: unknown): TimeEntry[] | undefined {
|
||||
if (!value || !Array.isArray(value)) return undefined;
|
||||
const entries: TimeEntry[] = [];
|
||||
for (const item of value) {
|
||||
if (item && typeof item === "object" && "date" in item && "hours" in item) {
|
||||
const entry: TimeEntry = {
|
||||
date: String(item.date).slice(0, 10), // ensure YYYY-MM-DD
|
||||
hours: Number(item.hours),
|
||||
};
|
||||
if ("skill" in item && item.skill) entry.skill = String(item.skill);
|
||||
if ("note" in item && item.note) entry.note = String(item.note);
|
||||
if ("rtimeCommitmentId" in item && item.rtimeCommitmentId) {
|
||||
entry.rtimeCommitmentId = String(item.rtimeCommitmentId);
|
||||
}
|
||||
if ("syncedAt" in item && item.syncedAt) entry.syncedAt = String(item.syncedAt);
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
return entries.length > 0 ? entries : undefined;
|
||||
}
|
||||
|
||||
function parseRtimeRef(value: unknown): RtimeRef | undefined {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
const ref: RtimeRef = {};
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (obj.taskId) ref.taskId = String(obj.taskId);
|
||||
if (obj.space) ref.space = String(obj.space);
|
||||
if (Array.isArray(obj.commitmentIds)) {
|
||||
ref.commitmentIds = obj.commitmentIds.map(String);
|
||||
}
|
||||
return ref.taskId || ref.space || ref.commitmentIds ? ref : undefined;
|
||||
}
|
||||
|
||||
export function parseMarkdown(content: string): ParsedMarkdown {
|
||||
// Updated regex to handle both Windows (\r\n) and Unix (\n) line endings
|
||||
const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---/;
|
||||
|
|
@ -183,6 +218,8 @@ export function parseTask(content: string): Task {
|
|||
: frontmatter.deadline
|
||||
? normalizeDate(frontmatter.deadline)
|
||||
: undefined,
|
||||
timeEntries: parseTimeEntries(frontmatter.time_entries),
|
||||
rtime: parseRtimeRef(frontmatter.rtime),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export function serializeTask(task: Task): string {
|
|||
...(task.statusHistory && task.statusHistory.length > 0 && { status_history: task.statusHistory }),
|
||||
...(task.doToday && { do_today: task.doToday }),
|
||||
...(task.estimatedHours !== undefined && { estimated_hours: task.estimatedHours }),
|
||||
...(task.timeEntries && task.timeEntries.length > 0 && { time_entries: task.timeEntries }),
|
||||
...(task.rtime && { rtime: task.rtime }),
|
||||
};
|
||||
|
||||
let contentBody = task.rawContent ?? "";
|
||||
|
|
|
|||
|
|
@ -6,6 +6,23 @@ export interface StatusHistoryEntry {
|
|||
timestamp: string; // ISO format: "YYYY-MM-DD HH:mm"
|
||||
}
|
||||
|
||||
/** A time log entry recorded against a task */
|
||||
export interface TimeEntry {
|
||||
date: string; // "YYYY-MM-DD"
|
||||
hours: number; // decimal hours
|
||||
skill?: string; // maps to rTime skill (e.g. "tech", "design", "facilitation")
|
||||
note?: string;
|
||||
rtimeCommitmentId?: string; // set after push to rTime
|
||||
syncedAt?: string; // ISO timestamp of last sync
|
||||
}
|
||||
|
||||
/** Cross-references between a backlog task and an rTime task */
|
||||
export interface RtimeRef {
|
||||
taskId?: string; // rTime task ID
|
||||
space?: string; // rTime space slug
|
||||
commitmentIds?: string[]; // rTime commitment IDs created from time entries
|
||||
}
|
||||
|
||||
// Structured Acceptance Criterion (domain-level)
|
||||
export interface AcceptanceCriterion {
|
||||
index: number; // 1-based
|
||||
|
|
@ -54,6 +71,10 @@ export interface Task {
|
|||
estimatedHours?: number;
|
||||
/** Due date / deadline for the task (YYYY-MM-DD or YYYY-MM-DD HH:mm) */
|
||||
dueDate?: string;
|
||||
/** Time log entries recorded against this task */
|
||||
timeEntries?: TimeEntry[];
|
||||
/** Cross-references to rTime (commitment pool integration) */
|
||||
rtime?: RtimeRef;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -106,6 +127,8 @@ export interface TaskUpdateInput {
|
|||
rawContent?: string;
|
||||
doToday?: boolean;
|
||||
estimatedHours?: number;
|
||||
/** Append a time entry to the task's time log */
|
||||
addTimeEntry?: Omit<TimeEntry, "rtimeCommitmentId" | "syncedAt">;
|
||||
}
|
||||
|
||||
export interface TaskListFilter {
|
||||
|
|
@ -238,6 +261,16 @@ export interface BacklogConfig {
|
|||
allowedOrigins?: string[];
|
||||
};
|
||||
};
|
||||
/** rTime integration config for pushing time entries to rSpace commitment pools */
|
||||
rtime?: {
|
||||
baseUrl?: string;
|
||||
space?: string;
|
||||
memberName?: string;
|
||||
token?: string;
|
||||
defaultSkill?: string;
|
||||
autoPush?: boolean;
|
||||
skillMap?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ParsedMarkdown {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,321 @@
|
|||
/**
|
||||
* rTime bridge — push/pull time entries between backlog-md and rTime.
|
||||
*
|
||||
* Offline-first: entries queue in .backlog/rtime-queue.json when rTime
|
||||
* is unreachable, and replay on `backlog rtime sync`.
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
import type { BacklogConfig, Task, TimeEntry } from "../types/index.ts";
|
||||
|
||||
// ── Config types ──
|
||||
|
||||
export interface RtimeConfig {
|
||||
baseUrl?: string;
|
||||
space?: string;
|
||||
memberName?: string;
|
||||
token?: string;
|
||||
defaultSkill?: string;
|
||||
autoPush?: boolean;
|
||||
skillMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface QueueEntry {
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
entry: TimeEntry;
|
||||
queuedAt: string;
|
||||
}
|
||||
|
||||
// ── Skill resolution ──
|
||||
|
||||
const SKILL_ALIASES: Record<string, string> = {
|
||||
dev: "tech",
|
||||
development: "tech",
|
||||
engineering: "tech",
|
||||
code: "tech",
|
||||
coding: "tech",
|
||||
programming: "tech",
|
||||
ux: "design",
|
||||
ui: "design",
|
||||
graphics: "design",
|
||||
art: "design",
|
||||
comms: "outreach",
|
||||
marketing: "outreach",
|
||||
community: "outreach",
|
||||
ops: "logistics",
|
||||
admin: "logistics",
|
||||
coordination: "logistics",
|
||||
planning: "logistics",
|
||||
meeting: "facilitation",
|
||||
workshop: "facilitation",
|
||||
hosting: "facilitation",
|
||||
};
|
||||
|
||||
const VALID_SKILLS = ["facilitation", "design", "tech", "outreach", "logistics"];
|
||||
|
||||
/**
|
||||
* 3-tier skill resolution: exact match → config map → alias table.
|
||||
*/
|
||||
export function resolveSkill(skill: string | undefined, skillMap?: Record<string, string>): string | undefined {
|
||||
if (!skill) return undefined;
|
||||
const lower = skill.toLowerCase().trim();
|
||||
|
||||
// Tier 1: exact match
|
||||
if (VALID_SKILLS.includes(lower)) return lower;
|
||||
|
||||
// Tier 2: config skill map
|
||||
if (skillMap) {
|
||||
const mapped = skillMap[lower] ?? skillMap[skill];
|
||||
if (mapped && VALID_SKILLS.includes(mapped.toLowerCase())) return mapped.toLowerCase();
|
||||
}
|
||||
|
||||
// Tier 3: built-in alias table
|
||||
const alias = SKILL_ALIASES[lower];
|
||||
if (alias) return alias;
|
||||
|
||||
// Fallback: return as-is (rTime will validate)
|
||||
return lower;
|
||||
}
|
||||
|
||||
// ── Queue management ──
|
||||
|
||||
function queuePath(cwd: string): string {
|
||||
return join(cwd, ".backlog", "rtime-queue.json");
|
||||
}
|
||||
|
||||
export async function loadQueue(cwd: string): Promise<QueueEntry[]> {
|
||||
try {
|
||||
const file = Bun.file(queuePath(cwd));
|
||||
if (!(await file.exists())) return [];
|
||||
return await file.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQueue(cwd: string, queue: QueueEntry[]): Promise<void> {
|
||||
const path = queuePath(cwd);
|
||||
const dir = join(cwd, ".backlog");
|
||||
const { mkdirSync, existsSync } = await import("node:fs");
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
await Bun.write(path, JSON.stringify(queue, null, "\t"));
|
||||
}
|
||||
|
||||
async function enqueue(cwd: string, entry: QueueEntry): Promise<void> {
|
||||
const queue = await loadQueue(cwd);
|
||||
queue.push(entry);
|
||||
await saveQueue(cwd, queue);
|
||||
}
|
||||
|
||||
// ── API helpers ──
|
||||
|
||||
function getAuthHeaders(config: RtimeConfig): Record<string, string> {
|
||||
const token = config.token || process.env.RSPACE_TOKEN;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function getApiUrl(config: RtimeConfig, path: string): string {
|
||||
const base = config.baseUrl || "https://rspace.online";
|
||||
const space = config.space || "demo";
|
||||
return `${base}/${space}/rtime${path}`;
|
||||
}
|
||||
|
||||
// ── Push ──
|
||||
|
||||
export async function pushTimeEntry(
|
||||
task: Task,
|
||||
entry: TimeEntry,
|
||||
cwd: string,
|
||||
config: RtimeConfig,
|
||||
): Promise<{ success: boolean; commitmentId?: string; error?: string }> {
|
||||
const url = getApiUrl(config, "/api/external-time-logs");
|
||||
const skill = resolveSkill(entry.skill, config.skillMap) || config.defaultSkill || "tech";
|
||||
|
||||
const body = {
|
||||
backlogTaskId: task.id,
|
||||
backlogTaskTitle: task.title,
|
||||
memberName: config.memberName || "Unknown",
|
||||
hours: entry.hours,
|
||||
skill,
|
||||
note: entry.note,
|
||||
loggedAt: new Date(entry.date).getTime(),
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(config),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errBody = await resp.text();
|
||||
throw new Error(`HTTP ${resp.status}: ${errBody}`);
|
||||
}
|
||||
|
||||
const result = (await resp.json()) as { log: { id: string }; commitmentId: string };
|
||||
return { success: true, commitmentId: result.commitmentId };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// Queue for offline retry
|
||||
await enqueue(cwd, {
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
entry,
|
||||
queuedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return { success: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flush queue ──
|
||||
|
||||
export async function flushQueue(
|
||||
cwd: string,
|
||||
config: RtimeConfig,
|
||||
): Promise<{ flushed: number; failed: number; remaining: number }> {
|
||||
const queue = await loadQueue(cwd);
|
||||
if (queue.length === 0) return { flushed: 0, failed: 0, remaining: 0 };
|
||||
|
||||
const remaining: QueueEntry[] = [];
|
||||
let flushed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const item of queue) {
|
||||
const url = getApiUrl(config, "/api/external-time-logs");
|
||||
const skill = resolveSkill(item.entry.skill, config.skillMap) || config.defaultSkill || "tech";
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(config),
|
||||
body: JSON.stringify({
|
||||
backlogTaskId: item.taskId,
|
||||
backlogTaskTitle: item.taskTitle,
|
||||
memberName: config.memberName || "Unknown",
|
||||
hours: item.entry.hours,
|
||||
skill,
|
||||
note: item.entry.note,
|
||||
loggedAt: new Date(item.entry.date).getTime(),
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
flushed++;
|
||||
} else {
|
||||
remaining.push(item);
|
||||
failed++;
|
||||
}
|
||||
} catch {
|
||||
remaining.push(item);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
await saveQueue(cwd, remaining);
|
||||
return { flushed, failed, remaining: remaining.length };
|
||||
}
|
||||
|
||||
// ── Pull status ──
|
||||
|
||||
export async function pullStatus(
|
||||
_cwd: string,
|
||||
config: RtimeConfig,
|
||||
taskId?: string,
|
||||
): Promise<{
|
||||
logs: Array<{
|
||||
backlogTaskId: string;
|
||||
hours: number;
|
||||
skill: string;
|
||||
status: string;
|
||||
commitmentId?: string;
|
||||
}>;
|
||||
error?: string;
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
if (config.memberName) params.set("memberName", config.memberName);
|
||||
if (taskId) params.set("backlogTaskId", taskId);
|
||||
|
||||
const url = getApiUrl(config, `/api/external-time-logs?${params.toString()}`);
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
headers: getAuthHeaders(config),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return { logs: [], error: `HTTP ${resp.status}` };
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as {
|
||||
logs: Array<{
|
||||
backlogTaskId: string;
|
||||
hours: number;
|
||||
skill: string;
|
||||
status: string;
|
||||
commitmentId?: string;
|
||||
}>;
|
||||
};
|
||||
return { logs: data.logs };
|
||||
} catch (err) {
|
||||
return { logs: [], error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ensure rTime task ──
|
||||
|
||||
export async function ensureRtimeTask(task: Task, config: RtimeConfig): Promise<{ taskId?: string; error?: string }> {
|
||||
const url = getApiUrl(config, "/api/tasks");
|
||||
|
||||
try {
|
||||
// Check if a task already references this backlog task
|
||||
const listResp = await fetch(url, {
|
||||
headers: getAuthHeaders(config),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (listResp.ok) {
|
||||
const data = (await listResp.json()) as { tasks: Array<{ id: string; description: string; name: string }> };
|
||||
const existing = data.tasks.find(
|
||||
(t) => t.description?.includes(`[backlog:${task.id}]`) || t.name?.includes(task.id),
|
||||
);
|
||||
if (existing) return { taskId: existing.id };
|
||||
}
|
||||
|
||||
// Create new task
|
||||
const createResp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(config),
|
||||
body: JSON.stringify({
|
||||
name: task.title,
|
||||
description: `[backlog:${task.id}]`,
|
||||
needs: {},
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!createResp.ok) {
|
||||
return { error: `Failed to create rTime task: HTTP ${createResp.status}` };
|
||||
}
|
||||
|
||||
const result = (await createResp.json()) as { id: string };
|
||||
return { taskId: result.id };
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config loader ──
|
||||
|
||||
export function getRtimeConfig(backlogConfig: BacklogConfig | null): RtimeConfig | undefined {
|
||||
const config = backlogConfig as any;
|
||||
return config?.rtime;
|
||||
}
|
||||
Loading…
Reference in New Issue