estela 0.3.3 → 0.4.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.
Files changed (44) hide show
  1. package/README.md +97 -0
  2. package/dist/billing/assign.d.ts +11 -1
  3. package/dist/billing/assign.js +59 -1
  4. package/dist/billing/sessionize.d.ts +6 -0
  5. package/dist/billing/sessionize.js +24 -5
  6. package/dist/cli.js +1084 -8
  7. package/dist/db/schema.js +100 -0
  8. package/dist/db/store.d.ts +46 -0
  9. package/dist/db/store.js +179 -5
  10. package/dist/delivery/clients.d.ts +52 -0
  11. package/dist/delivery/clients.js +94 -0
  12. package/dist/delivery/github.d.ts +21 -0
  13. package/dist/delivery/github.js +123 -0
  14. package/dist/delivery/report.d.ts +55 -0
  15. package/dist/delivery/report.js +124 -0
  16. package/dist/delivery/source.d.ts +68 -0
  17. package/dist/delivery/source.js +3 -0
  18. package/dist/export/csv.d.ts +14 -0
  19. package/dist/export/csv.js +78 -0
  20. package/dist/export/finance.d.ts +79 -0
  21. package/dist/export/finance.js +208 -0
  22. package/dist/i18n/en.js +171 -0
  23. package/dist/metrics/spend.d.ts +83 -0
  24. package/dist/metrics/spend.js +167 -0
  25. package/dist/metrics/undelivered.d.ts +104 -0
  26. package/dist/metrics/undelivered.js +199 -0
  27. package/dist/server.js +68 -8
  28. package/dist/setup.js +1 -1
  29. package/dist/watchers/git.d.ts +40 -0
  30. package/dist/watchers/git.js +87 -0
  31. package/node_modules/@estela/shared/dist/money.d.ts +2 -0
  32. package/node_modules/@estela/shared/dist/money.d.ts.map +1 -1
  33. package/node_modules/@estela/shared/dist/money.js +7 -7
  34. package/node_modules/@estela/shared/dist/money.js.map +1 -1
  35. package/node_modules/@estela/shared/dist/types.d.ts +7 -0
  36. package/node_modules/@estela/shared/dist/types.d.ts.map +1 -1
  37. package/node_modules/@estela/shared/dist/types.js.map +1 -1
  38. package/node_modules/@estela/shared/dist/workitems.d.ts +10 -0
  39. package/node_modules/@estela/shared/dist/workitems.d.ts.map +1 -1
  40. package/node_modules/@estela/shared/dist/workitems.js.map +1 -1
  41. package/package.json +1 -1
  42. package/web/app.css +4 -0
  43. package/web/app.js +67 -0
  44. package/web/i18n.js +24 -0
package/dist/db/schema.js CHANGED
@@ -47,6 +47,8 @@ CREATE TABLE IF NOT EXISTS projects (
47
47
  ai_budget_micro_usd INTEGER,
48
48
  -- NULL = activo. Ver la migración 12 para por qué cerrar no bloquea nada.
49
49
  closed_at TEXT,
50
+ -- NULL = visible. Archivado: fuera del panel y sin captar horas.
51
+ archived_at TEXT,
50
52
  -- Gestor de tareas en JSON ({"system":"jira","prefixes":["PROJ"]}). NULL =
51
53
  -- sin configurar: solo se reconocen los tickets inequívocos.
52
54
  tracker TEXT
@@ -122,6 +124,49 @@ CREATE TABLE IF NOT EXISTS commits (
122
124
  );
123
125
  CREATE INDEX IF NOT EXISTS idx_commits_at ON commits(at);
124
126
 
127
+ -- Qué cliente de Estela es cada nombre de las etiquetas "cliente:<nombre>"
128
+ -- del gestor de tareas. Sin fila, se prueba con el id y el nombre del cliente.
129
+ CREATE TABLE IF NOT EXISTS client_aliases (
130
+ label TEXT PRIMARY KEY,
131
+ client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE
132
+ );
133
+
134
+ -- Issues y PRs leídos del gestor de tareas (hoy, GitHub con gh) por
135
+ -- "estela delivery". Se guardan para repetir el informe sin conexión y para
136
+ -- saber que la rama de un PR fusionado con squash está entregada.
137
+ CREATE TABLE IF NOT EXISTS tracker_items (
138
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
139
+ kind TEXT NOT NULL,
140
+ key TEXT NOT NULL,
141
+ number INTEGER NOT NULL,
142
+ title TEXT NOT NULL,
143
+ state TEXT NOT NULL,
144
+ completed INTEGER NOT NULL,
145
+ closed_at TEXT,
146
+ branch TEXT,
147
+ closes TEXT NOT NULL DEFAULT '',
148
+ labels TEXT NOT NULL DEFAULT '',
149
+ milestone TEXT,
150
+ url TEXT NOT NULL DEFAULT '',
151
+ fetched_at TEXT NOT NULL,
152
+ PRIMARY KEY (project_id, kind, key)
153
+ );
154
+
155
+ -- Commits de merge: lo único en git que dice qué rama se integró y con qué PR
156
+ -- ("Merge pull request #12 from ana/login"). La tabla commits los descarta
157
+ -- (--no-merges) porque no son trabajo; aquí se guardan solo para atribuir.
158
+ CREATE TABLE IF NOT EXISTS merges (
159
+ repo_path TEXT NOT NULL,
160
+ hash TEXT NOT NULL,
161
+ at TEXT NOT NULL,
162
+ -- Rama integrada, tal como la escribe el mensaje. NULL si no lo dice.
163
+ source_branch TEXT,
164
+ -- PR o merge request (github:12, gitlab:45). NULL en un merge local.
165
+ ref TEXT,
166
+ subject TEXT NOT NULL,
167
+ PRIMARY KEY (repo_path, hash)
168
+ );
169
+
125
170
  CREATE TABLE IF NOT EXISTS time_entries (
126
171
  id TEXT PRIMARY KEY,
127
172
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
@@ -292,6 +337,61 @@ CREATE TABLE IF NOT EXISTS claude_plans (
292
337
  * empezar no es una opción de mantenimiento.
293
338
  */
294
339
  const MIGRATIONS = [
340
+ {
341
+ version: 20,
342
+ describe: "alias de cliente para las etiquetas cliente:<nombre>",
343
+ run: (db) => {
344
+ db.exec(`CREATE TABLE IF NOT EXISTS client_aliases (
345
+ label TEXT PRIMARY KEY,
346
+ client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE
347
+ )`);
348
+ },
349
+ },
350
+ {
351
+ version: 19,
352
+ describe: "issues y PRs del gestor de tareas, para planificado frente a entregado",
353
+ run: (db) => {
354
+ db.exec(`CREATE TABLE IF NOT EXISTS tracker_items (
355
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
356
+ kind TEXT NOT NULL,
357
+ key TEXT NOT NULL,
358
+ number INTEGER NOT NULL,
359
+ title TEXT NOT NULL,
360
+ state TEXT NOT NULL,
361
+ completed INTEGER NOT NULL,
362
+ closed_at TEXT,
363
+ branch TEXT,
364
+ closes TEXT NOT NULL DEFAULT '',
365
+ labels TEXT NOT NULL DEFAULT '',
366
+ milestone TEXT,
367
+ url TEXT NOT NULL DEFAULT '',
368
+ fetched_at TEXT NOT NULL,
369
+ PRIMARY KEY (project_id, kind, key)
370
+ )`);
371
+ },
372
+ },
373
+ {
374
+ version: 18,
375
+ describe: "commits de merge, para atribuir el coste a su PR",
376
+ run: (db) => {
377
+ db.exec(`CREATE TABLE IF NOT EXISTS merges (
378
+ repo_path TEXT NOT NULL,
379
+ hash TEXT NOT NULL,
380
+ at TEXT NOT NULL,
381
+ source_branch TEXT,
382
+ ref TEXT,
383
+ subject TEXT NOT NULL,
384
+ PRIMARY KEY (repo_path, hash)
385
+ )`);
386
+ },
387
+ },
388
+ {
389
+ version: 17,
390
+ describe: "archivar proyectos",
391
+ run: (db) => {
392
+ addColumn(db, "projects", "archived_at", "TEXT");
393
+ },
394
+ },
295
395
  {
296
396
  version: 16,
297
397
  describe: "rutas de Windows iguales vengan de donde vengan",
@@ -2,6 +2,8 @@ import type { DatabaseSync } from "node:sqlite";
2
2
  import type { AgentKind, AgentTurn, AiConsentDecision, AiConsentRecord, Client, CloudAccount, CommitRecord, Invoice, InvoiceLine, ParseReport, Project, ProjectSync, RatePeriod, Subscription, TimeEntry, WorkItemTracker } from "@estela/shared";
3
3
  import type { ConsumptionRow } from "../billing/amortize.js";
4
4
  import type { QuotaTurn } from "../metrics/quota.js";
5
+ import type { TrackerItem } from "../delivery/source.js";
6
+ import type { MergeRecord } from "../watchers/git.js";
5
7
  export declare function upsertClient(db: DatabaseSync, client: Client): void;
6
8
  export declare function upsertProject(db: DatabaseSync, project: Omit<Project, "closedAt">): void;
7
9
  /**
@@ -32,6 +34,19 @@ export declare function setProjectTracker(db: DatabaseSync, projectId: string, t
32
34
  export declare function closeProject(db: DatabaseSync, id: string, at?: Date): void;
33
35
  /** Reabre un proyecto cerrado. Sin efecto si ya estaba abierto. */
34
36
  export declare function reopenProject(db: DatabaseSync, id: string): void;
37
+ /** Archiva un proyecto: sale del panel y deja de captar. No borra nada. */
38
+ export declare function archiveProject(db: DatabaseSync, id: string, at?: Date): void;
39
+ export declare function restoreProject(db: DatabaseSync, id: string): void;
40
+ export declare function listArchivedProjects(db: DatabaseSync): Project[];
41
+ /** Por qué no se puede eliminar un proyecto, o null si se puede. */
42
+ export declare function projectDeleteBlocker(db: DatabaseSync, id: string): "invoiced" | "shared" | null;
43
+ /**
44
+ * Elimina un proyecto y sus imputaciones, tarifas y vínculos. Los turnos y los
45
+ * commits se quedan: son de la máquina, no del proyecto, y si el repo se vuelve
46
+ * a registrar sus horas reaparecen. Lo facturado o compartido no se elimina.
47
+ */
48
+ export declare function deleteProject(db: DatabaseSync, id: string): void;
49
+ /** Los proyectos a la vista. Los archivados, con `listArchivedProjects`. */
35
50
  export declare function listProjects(db: DatabaseSync): Project[];
36
51
  export declare function listClients(db: DatabaseSync): Client[];
37
52
  export declare function getRates(db: DatabaseSync, projectId: string): RatePeriod[];
@@ -76,6 +91,11 @@ export declare function listPublications(db: DatabaseSync): Publication[];
76
91
  export declare function authorsByRepo(db: DatabaseSync): Map<string, string[]>;
77
92
  /** Encuentra el proyecto al que pertenece una ruta de repositorio. */
78
93
  export declare function projectForRepo(db: DatabaseSync, repoPath: string): string | null;
94
+ /**
95
+ * Como `projectForRepo`, contando los archivados. Para lo que ofrece registrar
96
+ * un repo: uno archivado ya tiene dueño, y ofrecerlo otra vez lo resucitaría.
97
+ */
98
+ export declare function ownerOfRepo(db: DatabaseSync, repoPath: string): string | null;
79
99
  /**
80
100
  * Todos los repositorios que ya están vinculados a algún proyecto, sin
81
101
  * importar cómo llegaron ahí (un transcript de agente, `estela project add`,
@@ -110,6 +130,32 @@ export declare function firstTurnByAgent(db: DatabaseSync): Map<AgentKind, Date>
110
130
  * sobrescriben.
111
131
  */
112
132
  export declare function saveCommits(db: DatabaseSync, commits: readonly CommitRecord[]): number;
133
+ /** Guarda commits de merge. Devuelve cuántos son nuevos. */
134
+ export declare function saveMerges(db: DatabaseSync, merges: readonly MergeRecord[]): number;
135
+ export declare function listMerges(db: DatabaseSync): MergeRecord[];
136
+ /**
137
+ * El nombre de una etiqueta de cliente, comparable: sin mayúsculas, acentos
138
+ * ni separadores. "Acme Retail", "acme-retail" y "ACME_RETAIL" son el mismo.
139
+ */
140
+ export declare function aliasKey(label: string): string;
141
+ export declare function setClientAlias(db: DatabaseSync, label: string, clientId: string): void;
142
+ export declare function removeClientAlias(db: DatabaseSync, label: string): boolean;
143
+ export declare function listClientAliases(db: DatabaseSync): {
144
+ label: string;
145
+ clientId: string;
146
+ }[];
147
+ /**
148
+ * El cliente de Estela de una etiqueta: un alias, o si no, un cliente cuyo id
149
+ * o nombre coincida. `null` si no se sabe: no se adivina.
150
+ */
151
+ export declare function clientForLabel(db: DatabaseSync, label: string): string | null;
152
+ /** Guarda lo leído del gestor de tareas. Lo nuevo sustituye a lo anterior de la misma clave. */
153
+ export declare function saveTrackerItems(db: DatabaseSync, projectId: string, items: readonly TrackerItem[], at?: Date): void;
154
+ /** Lo guardado de un proyecto, y cuándo se leyó por última vez. */
155
+ export declare function listTrackerItems(db: DatabaseSync, projectId: string): {
156
+ items: TrackerItem[];
157
+ fetchedAt: Date | null;
158
+ };
113
159
  export declare function logScan(db: DatabaseSync, source: string, report: ParseReport): void;
114
160
  /**
115
161
  * Guarda una imputación.
package/dist/db/store.js CHANGED
@@ -10,6 +10,11 @@ exports.getProject = getProject;
10
10
  exports.setProjectTracker = setProjectTracker;
11
11
  exports.closeProject = closeProject;
12
12
  exports.reopenProject = reopenProject;
13
+ exports.archiveProject = archiveProject;
14
+ exports.restoreProject = restoreProject;
15
+ exports.listArchivedProjects = listArchivedProjects;
16
+ exports.projectDeleteBlocker = projectDeleteBlocker;
17
+ exports.deleteProject = deleteProject;
13
18
  exports.listProjects = listProjects;
14
19
  exports.listClients = listClients;
15
20
  exports.getRates = getRates;
@@ -21,12 +26,22 @@ exports.forgetPublication = forgetPublication;
21
26
  exports.listPublications = listPublications;
22
27
  exports.authorsByRepo = authorsByRepo;
23
28
  exports.projectForRepo = projectForRepo;
29
+ exports.ownerOfRepo = ownerOfRepo;
24
30
  exports.allProjectRepoPaths = allProjectRepoPaths;
25
31
  exports.saveTurns = saveTurns;
26
32
  exports.quotaTurns = quotaTurns;
27
33
  exports.turnsBetween = turnsBetween;
28
34
  exports.firstTurnByAgent = firstTurnByAgent;
29
35
  exports.saveCommits = saveCommits;
36
+ exports.saveMerges = saveMerges;
37
+ exports.listMerges = listMerges;
38
+ exports.aliasKey = aliasKey;
39
+ exports.setClientAlias = setClientAlias;
40
+ exports.removeClientAlias = removeClientAlias;
41
+ exports.listClientAliases = listClientAliases;
42
+ exports.clientForLabel = clientForLabel;
43
+ exports.saveTrackerItems = saveTrackerItems;
44
+ exports.listTrackerItems = listTrackerItems;
30
45
  exports.logScan = logScan;
31
46
  exports.adjustEntrySeconds = adjustEntrySeconds;
32
47
  exports.clearEntryAdjustment = clearEntryAdjustment;
@@ -155,6 +170,7 @@ function getProject(db, id) {
155
170
  aiCostPolicy: row["ai_cost_policy"],
156
171
  kind: row["kind"] ?? "client",
157
172
  closedAt: row["closed_at"] ? new Date(row["closed_at"]) : null,
173
+ archivedAt: row["archived_at"] ? new Date(row["archived_at"]) : null,
158
174
  tracker: row["tracker"] ? JSON.parse(row["tracker"]) : null,
159
175
  };
160
176
  }
@@ -165,7 +181,9 @@ function getProject(db, id) {
165
181
  */
166
182
  function setProjectTracker(db, projectId, tracker) {
167
183
  const changes = db.prepare("UPDATE projects SET tracker = ? WHERE id = ?")
168
- .run(tracker ? JSON.stringify({ system: tracker.system, prefixes: tracker.prefixes }) : null, projectId).changes;
184
+ .run(tracker ? JSON.stringify({ system: tracker.system, prefixes: tracker.prefixes,
185
+ ...(tracker.planLabel ? { planLabel: tracker.planLabel } : {}),
186
+ ...(tracker.clientLabel ? { clientLabel: tracker.clientLabel } : {}) }) : null, projectId).changes;
169
187
  if (changes === 0)
170
188
  throw new Error((0, index_js_1.tr) `No existe el proyecto "${projectId}"`);
171
189
  }
@@ -183,8 +201,45 @@ function closeProject(db, id, at = new Date()) {
183
201
  function reopenProject(db, id) {
184
202
  db.prepare("UPDATE projects SET closed_at = NULL WHERE id = ?").run(id);
185
203
  }
204
+ /** Archiva un proyecto: sale del panel y deja de captar. No borra nada. */
205
+ function archiveProject(db, id, at = new Date()) {
206
+ db.prepare("UPDATE projects SET archived_at = ? WHERE id = ? AND archived_at IS NULL").run(iso(at), id);
207
+ }
208
+ function restoreProject(db, id) {
209
+ db.prepare("UPDATE projects SET archived_at = NULL WHERE id = ?").run(id);
210
+ }
211
+ function listArchivedProjects(db) {
212
+ const rows = db.prepare("SELECT id FROM projects WHERE archived_at IS NOT NULL ORDER BY name").all();
213
+ return rows.map((r) => getProject(db, r.id));
214
+ }
215
+ /** Por qué no se puede eliminar un proyecto, o null si se puede. */
216
+ function projectDeleteBlocker(db, id) {
217
+ const count = (sql) => db.prepare(sql).get(id).n;
218
+ if (count("SELECT COUNT(*) AS n FROM invoices WHERE project_id = ?") > 0)
219
+ return "invoiced";
220
+ if (count("SELECT COUNT(*) AS n FROM time_entries WHERE project_id = ? AND invoice_id IS NOT NULL") > 0)
221
+ return "invoiced";
222
+ // Lo que hay fuera de esta máquina se quedaría sin dueño aquí.
223
+ if (count("SELECT COUNT(*) AS n FROM publications WHERE project_id = ?") > 0)
224
+ return "shared";
225
+ if (count("SELECT COUNT(*) AS n FROM project_sync WHERE project_id = ?") > 0)
226
+ return "shared";
227
+ return null;
228
+ }
229
+ /**
230
+ * Elimina un proyecto y sus imputaciones, tarifas y vínculos. Los turnos y los
231
+ * commits se quedan: son de la máquina, no del proyecto, y si el repo se vuelve
232
+ * a registrar sus horas reaparecen. Lo facturado o compartido no se elimina.
233
+ */
234
+ function deleteProject(db, id) {
235
+ const blocker = projectDeleteBlocker(db, id);
236
+ if (blocker)
237
+ throw new Error(`project ${id} cannot be deleted: ${blocker}`);
238
+ db.prepare("DELETE FROM projects WHERE id = ?").run(id);
239
+ }
240
+ /** Los proyectos a la vista. Los archivados, con `listArchivedProjects`. */
186
241
  function listProjects(db) {
187
- const rows = db.prepare("SELECT id FROM projects ORDER BY name").all();
242
+ const rows = db.prepare("SELECT id FROM projects WHERE archived_at IS NULL ORDER BY name").all();
188
243
  return rows.map((r) => getProject(db, r.id)).filter(Boolean);
189
244
  }
190
245
  function listClients(db) {
@@ -299,8 +354,18 @@ function authorsByRepo(db) {
299
354
  }
300
355
  /** Encuentra el proyecto al que pertenece una ruta de repositorio. */
301
356
  function projectForRepo(db, repoPath) {
302
- const rows = db.prepare("SELECT project_id, repo_path FROM project_repos")
303
- .all();
357
+ return matchRepo(db, repoPath, `SELECT r.project_id, r.repo_path FROM project_repos r
358
+ JOIN projects p ON p.id = r.project_id WHERE p.archived_at IS NULL`);
359
+ }
360
+ /**
361
+ * Como `projectForRepo`, contando los archivados. Para lo que ofrece registrar
362
+ * un repo: uno archivado ya tiene dueño, y ofrecerlo otra vez lo resucitaría.
363
+ */
364
+ function ownerOfRepo(db, repoPath) {
365
+ return matchRepo(db, repoPath, "SELECT project_id, repo_path FROM project_repos");
366
+ }
367
+ function matchRepo(db, repoPath, sql) {
368
+ const rows = db.prepare(sql).all();
304
369
  // El prefijo más largo gana: permite anidar un subproyecto dentro de un monorepo.
305
370
  // isSameOrInside y no startsWith: en Windows la misma carpeta llega con \ o
306
371
  // con /, y con la unidad en mayúscula o minúscula.
@@ -439,6 +504,113 @@ function saveCommits(db, commits) {
439
504
  }
440
505
  return inserted;
441
506
  }
507
+ /** Guarda commits de merge. Devuelve cuántos son nuevos. */
508
+ function saveMerges(db, merges) {
509
+ const stmt = db.prepare(`
510
+ INSERT OR IGNORE INTO merges (repo_path, hash, at, source_branch, ref, subject)
511
+ VALUES (?, ?, ?, ?, ?, ?)
512
+ `);
513
+ let inserted = 0;
514
+ db.exec("BEGIN");
515
+ try {
516
+ for (const m of merges) {
517
+ inserted += Number(stmt.run((0, paths_js_1.normalizePath)(m.repoPath), m.hash, iso(m.at), m.sourceBranch, m.ref, m.subject).changes);
518
+ }
519
+ db.exec("COMMIT");
520
+ }
521
+ catch (error) {
522
+ db.exec("ROLLBACK");
523
+ throw error;
524
+ }
525
+ return inserted;
526
+ }
527
+ function listMerges(db) {
528
+ return db.prepare("SELECT * FROM merges ORDER BY at").all()
529
+ .map((r) => ({
530
+ repoPath: r["repo_path"],
531
+ hash: r["hash"],
532
+ at: new Date(r["at"]),
533
+ sourceBranch: r["source_branch"] ?? null,
534
+ ref: r["ref"] ?? null,
535
+ subject: r["subject"],
536
+ }));
537
+ }
538
+ /**
539
+ * El nombre de una etiqueta de cliente, comparable: sin mayúsculas, acentos
540
+ * ni separadores. "Acme Retail", "acme-retail" y "ACME_RETAIL" son el mismo.
541
+ */
542
+ function aliasKey(label) {
543
+ return label.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "");
544
+ }
545
+ function setClientAlias(db, label, clientId) {
546
+ db.prepare(`INSERT INTO client_aliases (label, client_id) VALUES (?, ?)
547
+ ON CONFLICT(label) DO UPDATE SET client_id = excluded.client_id`).run(aliasKey(label), clientId);
548
+ }
549
+ function removeClientAlias(db, label) {
550
+ return db.prepare("DELETE FROM client_aliases WHERE label = ?").run(aliasKey(label)).changes > 0;
551
+ }
552
+ function listClientAliases(db) {
553
+ return db.prepare("SELECT label, client_id FROM client_aliases ORDER BY label").all().map((r) => ({ label: r.label, clientId: r.client_id }));
554
+ }
555
+ /**
556
+ * El cliente de Estela de una etiqueta: un alias, o si no, un cliente cuyo id
557
+ * o nombre coincida. `null` si no se sabe: no se adivina.
558
+ */
559
+ function clientForLabel(db, label) {
560
+ const key = aliasKey(label);
561
+ if (!key)
562
+ return null;
563
+ const alias = db.prepare("SELECT client_id FROM client_aliases WHERE label = ?").get(key);
564
+ if (alias)
565
+ return alias.client_id;
566
+ const match = listClients(db).filter((c) => aliasKey(c.id) === key || aliasKey(c.name) === key);
567
+ return match.length === 1 ? match[0].id : null;
568
+ }
569
+ /** Guarda lo leído del gestor de tareas. Lo nuevo sustituye a lo anterior de la misma clave. */
570
+ function saveTrackerItems(db, projectId, items, at = new Date()) {
571
+ const stmt = db.prepare(`
572
+ INSERT INTO tracker_items (project_id, kind, key, number, title, state, completed, closed_at, branch,
573
+ closes, labels, milestone, url, fetched_at)
574
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
575
+ ON CONFLICT(project_id, kind, key) DO UPDATE SET
576
+ number = excluded.number, title = excluded.title, state = excluded.state,
577
+ completed = excluded.completed, closed_at = excluded.closed_at, branch = excluded.branch,
578
+ closes = excluded.closes, labels = excluded.labels, milestone = excluded.milestone,
579
+ url = excluded.url, fetched_at = excluded.fetched_at
580
+ `);
581
+ db.exec("BEGIN");
582
+ try {
583
+ for (const i of items) {
584
+ // Las etiquetas pueden llevar comas: se separan con saltos de línea.
585
+ stmt.run(projectId, i.kind, i.key, i.number, i.title, i.state, i.completed ? 1 : 0, i.closedAt ? iso(i.closedAt) : null, i.branch, i.closes.join(","), i.labels.join("\n"), i.milestone, i.url, iso(at));
586
+ }
587
+ db.exec("COMMIT");
588
+ }
589
+ catch (error) {
590
+ db.exec("ROLLBACK");
591
+ throw error;
592
+ }
593
+ }
594
+ /** Lo guardado de un proyecto, y cuándo se leyó por última vez. */
595
+ function listTrackerItems(db, projectId) {
596
+ const rows = db.prepare("SELECT * FROM tracker_items WHERE project_id = ?").all(projectId);
597
+ let fetchedAt = null;
598
+ const items = rows.map((r) => {
599
+ const f = r["fetched_at"];
600
+ if (!fetchedAt || f > fetchedAt)
601
+ fetchedAt = f;
602
+ return {
603
+ key: r["key"], kind: r["kind"], number: r["number"],
604
+ title: r["title"], state: r["state"], completed: Boolean(r["completed"]),
605
+ closedAt: r["closed_at"] ? new Date(r["closed_at"]) : null,
606
+ branch: r["branch"] ?? null,
607
+ closes: String(r["closes"] || "").split(",").filter(Boolean),
608
+ labels: String(r["labels"] || "").split("\n").filter(Boolean),
609
+ milestone: r["milestone"] ?? null, url: r["url"],
610
+ };
611
+ });
612
+ return { items, fetchedAt: fetchedAt ? new Date(fetchedAt) : null };
613
+ }
442
614
  function logScan(db, source, report) {
443
615
  db.prepare(`
444
616
  INSERT INTO scan_log (
@@ -656,6 +828,7 @@ function rowToEntry(r) {
656
828
  kind: r["kind"] ?? "development",
657
829
  branch: r["branch"] ?? null,
658
830
  workItems: String(r["work_items"] || "").split(",").filter(Boolean),
831
+ workItemsManual: Boolean(r["work_items_manual"]),
659
832
  });
660
833
  }
661
834
  /** Guarda la factura y marca sus imputaciones. Todo o nada. */
@@ -951,7 +1124,8 @@ function listTeamSyncedProjects(db) {
951
1124
  function listPersonalSyncCandidates(db) {
952
1125
  const rows = db.prepare(`
953
1126
  SELECT id FROM projects
954
- WHERE id NOT IN (SELECT project_id FROM project_sync WHERE scope = 'team')
1127
+ WHERE archived_at IS NULL
1128
+ AND id NOT IN (SELECT project_id FROM project_sync WHERE scope = 'team')
955
1129
  `).all();
956
1130
  return rows.map((r) => r.id);
957
1131
  }
@@ -0,0 +1,52 @@
1
+ import type { AiCost, Subscription, TimeEntry } from "@estela/shared";
2
+ import type { MergeRecord } from "../watchers/git.js";
3
+ import type { Cost } from "./report.js";
4
+ import type { TrackerItem } from "./source.js";
5
+ /**
6
+ * Lo pedido por clientes frente al resto: a qué peticiones fue el gasto de IA
7
+ * de un periodo.
8
+ *
9
+ * Una issue o un PR es una petición de un cliente si lleva una etiqueta
10
+ * `cliente:<nombre>`. Las horas se unen a issues y PRs como en el resto del
11
+ * informe de entregas (la rama del PR y su número en ramas, commits o a mano),
12
+ * y un PR hereda las etiquetas de las issues que cierra.
13
+ *
14
+ * Nada se reparte: una hora unida a peticiones de dos clientes va a un grupo
15
+ * propio de los dos, y una hora sin petición va a "el resto". Y aquí no hay
16
+ * ingresos ni contratos, que Estela no conoce: solo qué gasto sirvió a qué
17
+ * petición.
18
+ */
19
+ /** Quién pidió algo, ya resuelto contra los clientes de Estela. */
20
+ export interface Requester {
21
+ /** Clave estable: el id del cliente, o `?nombre` si no se sabe cuál es. */
22
+ readonly id: string;
23
+ /** El cliente de Estela, o `null` si la etiqueta no se pudo mapear. */
24
+ readonly clientId: string | null;
25
+ readonly name: string;
26
+ }
27
+ export interface ClientRequestInput {
28
+ readonly items: readonly TrackerItem[];
29
+ /** Las imputaciones del periodo. */
30
+ readonly entries: readonly TimeEntry[];
31
+ readonly merges: readonly MergeRecord[];
32
+ /** `cliente:` — se compara sin mayúsculas. */
33
+ readonly prefix: string;
34
+ /** De lo que va detrás del prefijo, a quién es. */
35
+ readonly requesterOf: (name: string) => Requester;
36
+ readonly subscriptions: readonly Subscription[];
37
+ readonly monthTotals: ReadonlyMap<string, AiCost>;
38
+ }
39
+ export interface RequestGroup extends Cost {
40
+ /** Uno, o varios si las mismas horas sirvieron a peticiones de varios. */
41
+ readonly requesters: readonly Requester[];
42
+ /** Las peticiones a las que se unieron sus horas. */
43
+ readonly items: readonly TrackerItem[];
44
+ }
45
+ export interface ClientRequestReport {
46
+ readonly groups: readonly RequestGroup[];
47
+ /** Horas sin ninguna petición de cliente. */
48
+ readonly rest: Cost;
49
+ readonly total: Cost;
50
+ }
51
+ export declare function clientRequests(input: ClientRequestInput): ClientRequestReport;
52
+ //# sourceMappingURL=clients.d.ts.map
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clientRequests = clientRequests;
4
+ const amortize_js_1 = require("../billing/amortize.js");
5
+ const spend_js_1 = require("../metrics/spend.js");
6
+ function clientRequests(input) {
7
+ const prefix = input.prefix.toLowerCase();
8
+ const byKey = new Map();
9
+ const prsByBranch = new Map();
10
+ for (const i of input.items) {
11
+ byKey.set(i.key, [...(byKey.get(i.key) ?? []), i]);
12
+ if (i.kind === "pr" && i.branch)
13
+ prsByBranch.set(i.branch, [...(prsByBranch.get(i.branch) ?? []), i]);
14
+ }
15
+ const prOfBranch = new Map();
16
+ for (const m of input.merges)
17
+ if (m.sourceBranch && m.ref)
18
+ prOfBranch.set(m.sourceBranch, m.ref);
19
+ /** Las issues y PRs de una imputación, con las issues que cierran sus PRs. */
20
+ const linked = (e) => {
21
+ const out = new Set();
22
+ if (e.branch)
23
+ for (const pr of prsByBranch.get(e.branch) ?? [])
24
+ out.add(pr);
25
+ const keys = [...(e.workItems ?? [])];
26
+ const merged = e.branch ? prOfBranch.get(e.branch) : undefined;
27
+ if (merged)
28
+ keys.push(merged);
29
+ for (const k of keys)
30
+ for (const i of byKey.get(k) ?? [])
31
+ out.add(i);
32
+ for (const i of [...out]) {
33
+ if (i.kind !== "pr")
34
+ continue;
35
+ for (const k of i.closes)
36
+ for (const issue of byKey.get(k) ?? [])
37
+ if (issue.kind === "issue")
38
+ out.add(issue);
39
+ }
40
+ return [...out];
41
+ };
42
+ const requestersOf = (i) => i.labels
43
+ .filter((l) => l.toLowerCase().startsWith(prefix))
44
+ .map((l) => l.slice(prefix.length).trim())
45
+ .filter(Boolean)
46
+ .map(input.requesterOf);
47
+ const assignedByMonth = new Map();
48
+ for (const e of input.entries) {
49
+ const m = (0, amortize_js_1.monthOf)(e.startedAt);
50
+ assignedByMonth.set(m, (assignedByMonth.get(m) ?? 0) + e.aiCost.microUsd);
51
+ }
52
+ const cost = (entries) => {
53
+ const byMonth = new Map();
54
+ for (const e of entries)
55
+ byMonth.set((0, amortize_js_1.monthOf)(e.startedAt), (byMonth.get((0, amortize_js_1.monthOf)(e.startedAt)) ?? 0) + e.aiCost.microUsd);
56
+ const { real, partial } = (0, spend_js_1.realFromMonths)(byMonth, input.subscriptions, (m) => Math.max(input.monthTotals.get(m)?.microUsd ?? 0, assignedByMonth.get(m) ?? 0));
57
+ return {
58
+ seconds: entries.reduce((s, e) => s + e.seconds, 0),
59
+ consumption: { microUsd: entries.reduce((s, e) => s + e.aiCost.microUsd, 0) },
60
+ real, realPartial: partial,
61
+ };
62
+ };
63
+ const groups = new Map();
64
+ const rest = [];
65
+ for (const e of input.entries) {
66
+ const requested = linked(e).map((i) => ({ i, who: requestersOf(i) })).filter((x) => x.who.length > 0);
67
+ const who = new Map();
68
+ for (const { who: list } of requested)
69
+ for (const r of list)
70
+ who.set(r.id, r);
71
+ if (who.size === 0) {
72
+ rest.push(e);
73
+ continue;
74
+ }
75
+ const requesters = [...who.values()].sort((a, b) => a.name.localeCompare(b.name));
76
+ const key = requesters.map((r) => r.id).join("+");
77
+ let g = groups.get(key);
78
+ if (!g) {
79
+ g = { requesters, items: new Set(), entries: [] };
80
+ groups.set(key, g);
81
+ }
82
+ g.entries.push(e);
83
+ for (const { i } of requested)
84
+ g.items.add(i);
85
+ }
86
+ return {
87
+ groups: [...groups.values()]
88
+ .map((g) => ({ requesters: g.requesters, items: [...g.items].sort((a, b) => a.number - b.number), ...cost(g.entries) }))
89
+ .sort((a, b) => b.consumption.microUsd - a.consumption.microUsd),
90
+ rest: cost(rest),
91
+ total: cost(input.entries),
92
+ };
93
+ }
94
+ //# sourceMappingURL=clients.js.map
@@ -0,0 +1,21 @@
1
+ import type { Availability, FetchQuery, PlanSource, TrackerItem } from "./source.js";
2
+ /**
3
+ * GitHub, a través de la CLI `gh` que ya tienes instalada y con sesión.
4
+ *
5
+ * Sin token propio ni llamada directa a la API: `gh` ya sabe quién eres y a
6
+ * qué repos tienes acceso, y quien no lo tenga instalado ve el informe sin
7
+ * esta parte. Se ejecuta en la carpeta del repo, así que `gh` deduce el repo
8
+ * de GitHub por su remoto.
9
+ */
10
+ /** Ejecuta `gh` con esos argumentos en esa carpeta y devuelve su salida. Inyectable en los tests. */
11
+ export type GhRunner = (args: readonly string[], cwd: string) => Promise<string>;
12
+ export declare const runGh: GhRunner;
13
+ export declare class GitHubPlanSource implements PlanSource {
14
+ private readonly cwd;
15
+ private readonly gh;
16
+ readonly system: "github";
17
+ constructor(cwd: string, gh?: GhRunner);
18
+ availability(): Promise<Availability>;
19
+ fetch(query: FetchQuery): Promise<TrackerItem[]>;
20
+ }
21
+ //# sourceMappingURL=github.d.ts.map