From 9648ec1a1670a9b15118c178a669594529be6035 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 17:37:46 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20add=20rTime=20integration=20=E2=80=94?= =?UTF-8?q?=20time=20entries,=20sync=20bridge,=20and=20CLI=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/cli.ts | 403 ++++++++++++++++++++++++++++++++++++- src/core/backlog.ts | 12 ++ src/markdown/parser.ts | 37 ++++ src/markdown/serializer.ts | 2 + src/types/index.ts | 33 +++ src/utils/rtime-sync.ts | 321 +++++++++++++++++++++++++++++ 6 files changed, 807 insertions(+), 1 deletion(-) create mode 100644 src/utils/rtime-sync.ts diff --git a/src/cli.ts b/src/cli.ts index 66e8da7..cb30a67 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1974,6 +1974,144 @@ taskCmd await viewTaskEnhanced(task, { startWithDetailFocus: true, core }); }); +taskCmd + .command("log ") + .description("log time spent on a task") + .option("--hours ", "hours spent (decimal, e.g. 1.5)") + .option("--skill ", "skill category (e.g. tech, design, facilitation, outreach, logistics)") + .option("--note ", "description of work done") + .option("--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(); + 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 ") .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(); + 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 ") + .description("link a backlog task to an rTime task") + .option("--rtime-task ", "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 ") + .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 = { "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) { diff --git a/src/core/backlog.ts b/src/core/backlog.ts index 94da118..dfd2c18 100644 --- a/src/core/backlog.ts +++ b/src/core/backlog.ts @@ -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; } diff --git a/src/markdown/parser.ts b/src/markdown/parser.ts index 07a1b31..a971d44 100644 --- a/src/markdown/parser.ts +++ b/src/markdown/parser.ts @@ -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; + 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), }; } diff --git a/src/markdown/serializer.ts b/src/markdown/serializer.ts index 2c89ebf..e6a8801 100644 --- a/src/markdown/serializer.ts +++ b/src/markdown/serializer.ts @@ -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 ?? ""; diff --git a/src/types/index.ts b/src/types/index.ts index 9cd659c..795ed3b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; } 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; + }; } export interface ParsedMarkdown { diff --git a/src/utils/rtime-sync.ts b/src/utils/rtime-sync.ts new file mode 100644 index 0000000..1e83fc4 --- /dev/null +++ b/src/utils/rtime-sync.ts @@ -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; +} + +export interface QueueEntry { + taskId: string; + taskTitle: string; + entry: TimeEntry; + queuedAt: string; +} + +// ── Skill resolution ── + +const SKILL_ALIASES: Record = { + 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 | 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 { + 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 { + 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 { + const queue = await loadQueue(cwd); + queue.push(entry); + await saveQueue(cwd, queue); +} + +// ── API helpers ── + +function getAuthHeaders(config: RtimeConfig): Record { + const token = config.token || process.env.RSPACE_TOKEN; + const headers: Record = { "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; +}