estela 0.3.4 → 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 (39) 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 +91 -0
  8. package/dist/db/store.d.ts +28 -0
  9. package/dist/db/store.js +120 -1
  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 +166 -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 +1 -0
  28. package/dist/watchers/git.d.ts +40 -0
  29. package/dist/watchers/git.js +87 -0
  30. package/node_modules/@estela/shared/dist/money.d.ts +2 -0
  31. package/node_modules/@estela/shared/dist/money.d.ts.map +1 -1
  32. package/node_modules/@estela/shared/dist/money.js +7 -7
  33. package/node_modules/@estela/shared/dist/money.js.map +1 -1
  34. package/node_modules/@estela/shared/dist/types.d.ts +2 -0
  35. package/node_modules/@estela/shared/dist/types.d.ts.map +1 -1
  36. package/node_modules/@estela/shared/dist/workitems.d.ts +10 -0
  37. package/node_modules/@estela/shared/dist/workitems.d.ts.map +1 -1
  38. package/node_modules/@estela/shared/dist/workitems.js.map +1 -1
  39. package/package.json +1 -1
package/dist/db/schema.js CHANGED
@@ -124,6 +124,49 @@ CREATE TABLE IF NOT EXISTS commits (
124
124
  );
125
125
  CREATE INDEX IF NOT EXISTS idx_commits_at ON commits(at);
126
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
+
127
170
  CREATE TABLE IF NOT EXISTS time_entries (
128
171
  id TEXT PRIMARY KEY,
129
172
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
@@ -294,6 +337,54 @@ CREATE TABLE IF NOT EXISTS claude_plans (
294
337
  * empezar no es una opción de mantenimiento.
295
338
  */
296
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
+ },
297
388
  {
298
389
  version: 17,
299
390
  describe: "archivar proyectos",
@@ -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
  /**
@@ -128,6 +130,32 @@ export declare function firstTurnByAgent(db: DatabaseSync): Map<AgentKind, Date>
128
130
  * sobrescriben.
129
131
  */
130
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
+ };
131
159
  export declare function logScan(db: DatabaseSync, source: string, report: ParseReport): void;
132
160
  /**
133
161
  * Guarda una imputación.
package/dist/db/store.js CHANGED
@@ -33,6 +33,15 @@ exports.quotaTurns = quotaTurns;
33
33
  exports.turnsBetween = turnsBetween;
34
34
  exports.firstTurnByAgent = firstTurnByAgent;
35
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;
36
45
  exports.logScan = logScan;
37
46
  exports.adjustEntrySeconds = adjustEntrySeconds;
38
47
  exports.clearEntryAdjustment = clearEntryAdjustment;
@@ -172,7 +181,9 @@ function getProject(db, id) {
172
181
  */
173
182
  function setProjectTracker(db, projectId, tracker) {
174
183
  const changes = db.prepare("UPDATE projects SET tracker = ? WHERE id = ?")
175
- .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;
176
187
  if (changes === 0)
177
188
  throw new Error((0, index_js_1.tr) `No existe el proyecto "${projectId}"`);
178
189
  }
@@ -493,6 +504,113 @@ function saveCommits(db, commits) {
493
504
  }
494
505
  return inserted;
495
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
+ }
496
614
  function logScan(db, source, report) {
497
615
  db.prepare(`
498
616
  INSERT INTO scan_log (
@@ -710,6 +828,7 @@ function rowToEntry(r) {
710
828
  kind: r["kind"] ?? "development",
711
829
  branch: r["branch"] ?? null,
712
830
  workItems: String(r["work_items"] || "").split(",").filter(Boolean),
831
+ workItemsManual: Boolean(r["work_items_manual"]),
713
832
  });
714
833
  }
715
834
  /** Guarda la factura y marca sus imputaciones. Todo o nada. */
@@ -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
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GitHubPlanSource = exports.runGh = void 0;
4
+ const node_child_process_1 = require("node:child_process");
5
+ const node_util_1 = require("node:util");
6
+ const run = (0, node_util_1.promisify)(node_child_process_1.execFile);
7
+ const runGh = async (args, cwd) => {
8
+ const { stdout } = await run("gh", [...args], { cwd, timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
9
+ return stdout;
10
+ };
11
+ exports.runGh = runGh;
12
+ // Los campos que se piden a gh. Cambiarlos cambia lo que se guarda.
13
+ const ISSUE_FIELDS = "number,title,state,stateReason,closedAt,labels,milestone,url";
14
+ const PR_FIELDS = "number,title,state,mergedAt,closedAt,headRefName,labels,milestone,closingIssuesReferences,url";
15
+ const LIMIT = "1000";
16
+ class GitHubPlanSource {
17
+ cwd;
18
+ gh;
19
+ system = "github";
20
+ constructor(cwd, gh = exports.runGh) {
21
+ this.cwd = cwd;
22
+ this.gh = gh;
23
+ }
24
+ async availability() {
25
+ try {
26
+ await this.gh(["--version"], this.cwd);
27
+ }
28
+ catch (error) {
29
+ return { ok: false, reason: "missing", detail: errorText(error) };
30
+ }
31
+ try {
32
+ await this.gh(["auth", "status"], this.cwd);
33
+ }
34
+ catch (error) {
35
+ return { ok: false, reason: "auth", detail: errorText(error) };
36
+ }
37
+ try {
38
+ await this.gh(["repo", "view", "--json", "nameWithOwner"], this.cwd);
39
+ }
40
+ catch (error) {
41
+ return { ok: false, reason: "repo", detail: errorText(error) };
42
+ }
43
+ return { ok: true };
44
+ }
45
+ async fetch(query) {
46
+ const lists = [
47
+ this.gh(["pr", "list", "--state", "merged", "--limit", LIMIT, "--search", `merged:${query.from}..${query.to}`,
48
+ "--json", PR_FIELDS], this.cwd),
49
+ this.gh(["issue", "list", "--state", "closed", "--limit", LIMIT, "--search", `closed:${query.from}..${query.to}`,
50
+ "--json", ISSUE_FIELDS], this.cwd),
51
+ ];
52
+ const plan = query.plan;
53
+ if (plan?.label) {
54
+ lists.push(this.gh(["issue", "list", "--state", "all", "--limit", LIMIT, "--label", plan.label,
55
+ "--json", ISSUE_FIELDS], this.cwd));
56
+ lists.push(this.gh(["pr", "list", "--state", "all", "--limit", LIMIT, "--label", plan.label,
57
+ "--json", PR_FIELDS], this.cwd));
58
+ }
59
+ else if (plan?.milestone) {
60
+ lists.push(this.gh(["issue", "list", "--state", "all", "--limit", LIMIT, "--milestone", plan.milestone,
61
+ "--json", ISSUE_FIELDS], this.cwd));
62
+ lists.push(this.gh(["pr", "list", "--state", "all", "--limit", LIMIT, "--search", `milestone:"${plan.milestone}"`,
63
+ "--json", PR_FIELDS], this.cwd));
64
+ }
65
+ const prefix = query.clientLabelPrefix?.toLowerCase();
66
+ if (prefix) {
67
+ // Los PR en curso: uno sin etiqueta que cierra la issue de un cliente
68
+ // también es trabajo para ese cliente.
69
+ lists.push(this.gh(["pr", "list", "--state", "open", "--limit", LIMIT, "--json", PR_FIELDS], this.cwd));
70
+ // GitHub no busca etiquetas por prefijo: primero cuáles existen.
71
+ const labels = JSON.parse(await this.gh(["label", "list", "--limit", LIMIT, "--json", "name"], this.cwd) || "[]").map((l) => l.name).filter((n) => n.toLowerCase().startsWith(prefix));
72
+ for (const label of labels) {
73
+ lists.push(this.gh(["issue", "list", "--state", "all", "--limit", LIMIT, "--label", label,
74
+ "--json", ISSUE_FIELDS], this.cwd));
75
+ lists.push(this.gh(["pr", "list", "--state", "all", "--limit", LIMIT, "--label", label,
76
+ "--json", PR_FIELDS], this.cwd));
77
+ }
78
+ }
79
+ // Una issue o un PR puede salir en dos listas: se queda una vez.
80
+ const items = new Map();
81
+ for (const text of await Promise.all(lists)) {
82
+ for (const raw of JSON.parse(text || "[]")) {
83
+ const item = normalize(raw);
84
+ items.set(`${item.kind}:${item.key}`, item);
85
+ }
86
+ }
87
+ return [...items.values()];
88
+ }
89
+ }
90
+ exports.GitHubPlanSource = GitHubPlanSource;
91
+ /** Un PR trae headRefName; una issue, no. */
92
+ function normalize(raw) {
93
+ const isPr = raw.headRefName !== undefined;
94
+ const state = raw.state.toUpperCase();
95
+ const date = (s) => (s ? new Date(s) : null);
96
+ if (isPr) {
97
+ const merged = state === "MERGED" || !!raw.mergedAt;
98
+ return {
99
+ key: `github:${raw.number}`, kind: "pr", number: raw.number, title: raw.title,
100
+ state: merged ? "merged" : state === "OPEN" ? "open" : "closed",
101
+ completed: merged, closedAt: date(raw.mergedAt) ?? date(raw.closedAt),
102
+ branch: raw.headRefName || null,
103
+ closes: (raw.closingIssuesReferences ?? []).map((i) => `github:${i.number}`),
104
+ labels: (raw.labels ?? []).map((l) => l.name), milestone: raw.milestone?.title ?? null, url: raw.url,
105
+ };
106
+ }
107
+ const closed = state === "CLOSED";
108
+ return {
109
+ key: `github:${raw.number}`, kind: "issue", number: raw.number, title: raw.title,
110
+ state: closed ? "closed" : "open",
111
+ // "NOT_PLANNED" o "DUPLICATE" es cerrar sin hacer. Sin motivo (issues antiguas), se da por hecha.
112
+ completed: closed && (!raw.stateReason || raw.stateReason.toUpperCase() === "COMPLETED"),
113
+ closedAt: date(raw.closedAt), branch: null, closes: [],
114
+ labels: (raw.labels ?? []).map((l) => l.name), milestone: raw.milestone?.title ?? null, url: raw.url,
115
+ };
116
+ }
117
+ function errorText(error) {
118
+ const e = error;
119
+ if (e.code === "ENOENT")
120
+ return "gh no está instalado";
121
+ return (e.stderr || e.message || String(error)).trim().split("\n")[0] ?? "";
122
+ }
123
+ //# sourceMappingURL=github.js.map
@@ -0,0 +1,55 @@
1
+ import type { AiCost, Money, Subscription, TimeEntry } from "@estela/shared";
2
+ import type { MergeRecord } from "../watchers/git.js";
3
+ import type { PlanFilter, TrackerItem } from "./source.js";
4
+ /**
5
+ * Planificado frente a entregado, con el coste de IA de cada parte.
6
+ *
7
+ * - Planificado y entregado: lo del plan que se cerró como hecho, o cuyo PR
8
+ * se fusionó, dentro del periodo.
9
+ * - Planificado sin entregar: el resto del plan, con su estado.
10
+ * - Entregado sin planificar: PRs fusionados e issues cerradas como hechas en
11
+ * el periodo que no son del plan ni lo cierran.
12
+ *
13
+ * El coste de una issue o un PR es el de las imputaciones que se le unen:
14
+ * las de la rama del PR, las que llevan su número (rama, commit o a mano) y,
15
+ * para una issue, las de los PRs que la cierran. Es el coste de toda esa
16
+ * entrega, aunque parte se trabajara antes del periodo. Una imputación que se
17
+ * une a dos cosas a la vez cuenta en las dos líneas, pero una sola vez en el
18
+ * total de cada bloque.
19
+ */
20
+ export interface DeliveryInput {
21
+ readonly items: readonly TrackerItem[];
22
+ readonly plan: PlanFilter | null;
23
+ readonly from: string;
24
+ readonly to: string;
25
+ readonly entries: readonly TimeEntry[];
26
+ readonly merges: readonly MergeRecord[];
27
+ readonly subscriptions: readonly Subscription[];
28
+ readonly monthTotals: ReadonlyMap<string, AiCost>;
29
+ }
30
+ export interface Cost {
31
+ readonly seconds: number;
32
+ readonly consumption: AiCost;
33
+ readonly real: Money | null;
34
+ readonly realPartial: boolean;
35
+ }
36
+ export interface DeliveryLine extends Cost {
37
+ readonly item: TrackerItem;
38
+ /** PRs del periodo que cierran esta issue. */
39
+ readonly deliveredBy: readonly TrackerItem[];
40
+ /** Cómo se unió el coste: "branch" (rama del PR) y/o "item" (número en la rama, un commit o a mano). */
41
+ readonly basis: readonly ("branch" | "item")[];
42
+ }
43
+ export interface DeliveryBlock extends Cost {
44
+ readonly lines: readonly DeliveryLine[];
45
+ }
46
+ export interface DeliveryReport {
47
+ /** `null` sin plan configurado: solo hay entregado. */
48
+ readonly planned: {
49
+ readonly delivered: DeliveryBlock;
50
+ readonly pending: DeliveryBlock;
51
+ } | null;
52
+ readonly unplanned: DeliveryBlock;
53
+ }
54
+ export declare function deliveryReport(input: DeliveryInput): DeliveryReport;
55
+ //# sourceMappingURL=report.d.ts.map