estela 0.3.4 → 0.4.1

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 (45) hide show
  1. package/README.md +106 -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 +1106 -8
  7. package/dist/db/schema.js +101 -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/gitlab.d.ts +38 -0
  15. package/dist/delivery/gitlab.js +146 -0
  16. package/dist/delivery/report.d.ts +55 -0
  17. package/dist/delivery/report.js +124 -0
  18. package/dist/delivery/source.d.ts +68 -0
  19. package/dist/delivery/source.js +3 -0
  20. package/dist/export/csv.d.ts +14 -0
  21. package/dist/export/csv.js +78 -0
  22. package/dist/export/finance.d.ts +79 -0
  23. package/dist/export/finance.js +208 -0
  24. package/dist/i18n/en.js +171 -0
  25. package/dist/metrics/spend.d.ts +83 -0
  26. package/dist/metrics/spend.js +167 -0
  27. package/dist/metrics/undelivered.d.ts +104 -0
  28. package/dist/metrics/undelivered.js +199 -0
  29. package/dist/server.js +21 -1
  30. package/dist/watchers/git.d.ts +40 -0
  31. package/dist/watchers/git.js +87 -0
  32. package/node_modules/@estela/shared/dist/money.d.ts +2 -0
  33. package/node_modules/@estela/shared/dist/money.d.ts.map +1 -1
  34. package/node_modules/@estela/shared/dist/money.js +7 -7
  35. package/node_modules/@estela/shared/dist/money.js.map +1 -1
  36. package/node_modules/@estela/shared/dist/types.d.ts +2 -0
  37. package/node_modules/@estela/shared/dist/types.d.ts.map +1 -1
  38. package/node_modules/@estela/shared/dist/workitems.d.ts +13 -2
  39. package/node_modules/@estela/shared/dist/workitems.d.ts.map +1 -1
  40. package/node_modules/@estela/shared/dist/workitems.js +8 -2
  41. package/node_modules/@estela/shared/dist/workitems.js.map +1 -1
  42. package/package.json +1 -1
  43. package/web/app.css +5 -0
  44. package/web/app.js +42 -10
  45. package/web/i18n.js +10 -0
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GitLabPlanSource = exports.runGlab = void 0;
4
+ exports.closingIssues = closingIssues;
5
+ const node_child_process_1 = require("node:child_process");
6
+ const node_util_1 = require("node:util");
7
+ const shared_1 = require("@estela/shared");
8
+ const run = (0, node_util_1.promisify)(node_child_process_1.execFile);
9
+ const runGlab = async (args, cwd) => {
10
+ const { stdout } = await run("glab", [...args], { cwd, timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
11
+ return stdout;
12
+ };
13
+ exports.runGlab = runGlab;
14
+ const PER_PAGE = 100;
15
+ /** Tope de páginas por lista: 10.000 elementos. Más es otro tipo de proyecto. */
16
+ const MAX_PAGES = 100;
17
+ class GitLabPlanSource {
18
+ cwd;
19
+ glab;
20
+ system = "gitlab";
21
+ constructor(cwd, glab = exports.runGlab) {
22
+ this.cwd = cwd;
23
+ this.glab = glab;
24
+ }
25
+ async availability() {
26
+ try {
27
+ await this.glab(["--version"], this.cwd);
28
+ }
29
+ catch (error) {
30
+ return { ok: false, reason: "missing", detail: errorText(error) };
31
+ }
32
+ try {
33
+ await this.glab(["auth", "status"], this.cwd);
34
+ }
35
+ catch (error) {
36
+ return { ok: false, reason: "auth", detail: errorText(error) };
37
+ }
38
+ try {
39
+ await this.glab(["api", "projects/:fullpath"], this.cwd);
40
+ }
41
+ catch (error) {
42
+ return { ok: false, reason: "repo", detail: errorText(error) };
43
+ }
44
+ return { ok: true };
45
+ }
46
+ async fetch(query) {
47
+ // GitLab no filtra por fecha de merge o de cierre: se piden las tocadas
48
+ // desde el día antes (cerrar o fusionar también es tocar) y se filtra aquí.
49
+ const since = new Date(new Date(`${query.from}T00:00:00Z`).getTime() - 86_400_000).toISOString();
50
+ const inPeriod = (i) => !!i.closedAt && (0, shared_1.localDate)(i.closedAt) >= query.from && (0, shared_1.localDate)(i.closedAt) <= query.to;
51
+ const lists = [
52
+ this.list("merge_requests", { state: "merged", updated_after: since }).then((l) => l.filter(inPeriod)),
53
+ this.list("issues", { state: "closed", updated_after: since }).then((l) => l.filter(inPeriod)),
54
+ ];
55
+ const plan = query.plan;
56
+ if (plan?.label) {
57
+ lists.push(this.list("issues", { labels: plan.label }));
58
+ lists.push(this.list("merge_requests", { labels: plan.label }));
59
+ }
60
+ else if (plan?.milestone) {
61
+ lists.push(this.list("issues", { milestone: plan.milestone }));
62
+ lists.push(this.list("merge_requests", { milestone: plan.milestone }));
63
+ }
64
+ const prefix = query.clientLabelPrefix?.toLowerCase();
65
+ if (prefix) {
66
+ // Las MR en curso: una sin etiqueta que cierra la issue de un cliente
67
+ // también es trabajo para ese cliente.
68
+ lists.push(this.list("merge_requests", { state: "opened" }));
69
+ // GitLab no busca etiquetas por prefijo: primero cuáles existen.
70
+ const labels = (await this.pages("labels", {}))
71
+ .map((l) => l.name).filter((n) => n.toLowerCase().startsWith(prefix));
72
+ for (const label of labels) {
73
+ lists.push(this.list("issues", { labels: label }));
74
+ lists.push(this.list("merge_requests", { labels: label }));
75
+ }
76
+ }
77
+ // Una issue o una MR puede salir en dos listas: se queda una vez.
78
+ const items = new Map();
79
+ for (const list of await Promise.all(lists))
80
+ for (const item of list)
81
+ items.set(`${item.kind}:${item.key}`, item);
82
+ return [...items.values()];
83
+ }
84
+ async list(kind, params) {
85
+ const rows = await this.pages(kind, params);
86
+ return rows.map((r) => (kind === "issues" ? normalizeIssue(r) : normalizeMr(r)));
87
+ }
88
+ /** Todas las páginas de `projects/:fullpath/<path>`. */
89
+ async pages(path, params) {
90
+ const out = [];
91
+ for (let page = 1; page <= MAX_PAGES; page++) {
92
+ const qs = new URLSearchParams({ ...params, per_page: String(PER_PAGE), page: String(page) });
93
+ const rows = JSON.parse((await this.glab(["api", `projects/:fullpath/${path}?${qs}`], this.cwd)) || "[]");
94
+ out.push(...rows);
95
+ if (rows.length < PER_PAGE)
96
+ break;
97
+ }
98
+ return out;
99
+ }
100
+ }
101
+ exports.GitLabPlanSource = GitLabPlanSource;
102
+ const date = (s) => (s ? new Date(s) : null);
103
+ const labelsOf = (raw) => (raw.labels ?? []).map((l) => (typeof l === "string" ? l : l.name));
104
+ function normalizeMr(raw) {
105
+ const merged = raw.state === "merged" || !!raw.merged_at;
106
+ return {
107
+ key: `gitlab:!${raw.iid}`, kind: "pr", number: raw.iid, title: raw.title,
108
+ state: merged ? "merged" : raw.state === "opened" ? "open" : "closed",
109
+ completed: merged, closedAt: date(raw.merged_at) ?? date(raw.closed_at),
110
+ branch: raw.source_branch || null,
111
+ closes: closingIssues(raw.description ?? "").map((n) => `gitlab:${n}`),
112
+ labels: labelsOf(raw), milestone: raw.milestone?.title ?? null, url: raw.web_url,
113
+ };
114
+ }
115
+ function normalizeIssue(raw) {
116
+ const closed = raw.state === "closed";
117
+ return {
118
+ key: `gitlab:${raw.iid}`, kind: "issue", number: raw.iid, title: raw.title,
119
+ state: closed ? "closed" : "open",
120
+ // GitLab no distingue hecha de "no se hará". Movida a otro proyecto, no es de aquí.
121
+ completed: closed && !raw.moved_to_id,
122
+ closedAt: date(raw.closed_at), branch: null, closes: [],
123
+ labels: labelsOf(raw), milestone: raw.milestone?.title ?? null, url: raw.web_url,
124
+ };
125
+ }
126
+ /**
127
+ * Las issues de este proyecto que cierra una descripción, con el patrón por
128
+ * defecto de GitLab: "Closes #12", "Fixes #12, #13", "Resolves issue #12 and #14",
129
+ * "Implements #12". Las de otro proyecto (`grupo/otro#12`) no.
130
+ */
131
+ function closingIssues(description) {
132
+ const out = new Set();
133
+ const verbs = /\b(?:clos(?:e[sd]?|ing)|fix(?:e[sd]|ing)?|resolv(?:e[sd]?|ing)|implement(?:s|ed|ing)?):?((?:\s*,?\s*(?:and\s+)?(?:issues?\s+)?#\d+\b)+)/gi;
134
+ for (const m of description.matchAll(verbs)) {
135
+ for (const n of m[1].matchAll(/(?<![\w/])#(\d+)/g))
136
+ out.add(Number(n[1]));
137
+ }
138
+ return [...out];
139
+ }
140
+ function errorText(error) {
141
+ const e = error;
142
+ if (e.code === "ENOENT")
143
+ return "glab no está instalado";
144
+ return (e.stderr || e.message || String(error)).trim().split("\n")[0] ?? "";
145
+ }
146
+ //# sourceMappingURL=gitlab.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
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deliveryReport = deliveryReport;
4
+ const shared_1 = require("@estela/shared");
5
+ const amortize_js_1 = require("../billing/amortize.js");
6
+ const spend_js_1 = require("../metrics/spend.js");
7
+ function deliveryReport(input) {
8
+ const inPeriod = (d) => {
9
+ if (!d)
10
+ return false;
11
+ const day = (0, shared_1.localDate)(d);
12
+ return day >= input.from && day <= input.to;
13
+ };
14
+ const isPlanned = (i) => !!input.plan && ((input.plan.label !== undefined && i.labels.includes(input.plan.label)) ||
15
+ (input.plan.milestone !== undefined && i.milestone === input.plan.milestone));
16
+ const prsDelivered = input.items.filter((i) => i.kind === "pr" && i.completed && inPeriod(i.closedAt));
17
+ const closersOf = (issue) => prsDelivered.filter((pr) => pr.closes.includes(issue.key));
18
+ const deliveredInPeriod = (i) => i.kind === "pr"
19
+ ? i.completed && inPeriod(i.closedAt)
20
+ : (i.completed && inPeriod(i.closedAt)) || closersOf(i).length > 0;
21
+ // Lo que se cerró antes del periodo ya no es plan de este periodo.
22
+ const planned = input.items.filter((i) => isPlanned(i) && !(i.closedAt && (0, shared_1.localDate)(i.closedAt) < input.from
23
+ && !deliveredInPeriod(i)));
24
+ const plannedKeys = new Set(planned.map((i) => i.key));
25
+ const unplanned = input.items.filter((i) => {
26
+ if (isPlanned(i) || !deliveredInPeriod(i))
27
+ return false;
28
+ // Un PR que entrega algo del plan es la entrega de ese plan, no un extra.
29
+ if (i.kind === "pr" && i.closes.some((k) => plannedKeys.has(k)))
30
+ return false;
31
+ // Una issue cerrada por un PR extra ya va con su PR.
32
+ if (i.kind === "issue" && closersOf(i).length > 0)
33
+ return false;
34
+ return true;
35
+ });
36
+ const costs = new EntryIndex(input);
37
+ const line = (item) => {
38
+ const deliveredBy = item.kind === "issue" ? closersOf(item) : [];
39
+ const { ids, basis } = costs.entriesOf(item, deliveredBy);
40
+ return { item, deliveredBy, basis, ...costs.cost(ids) };
41
+ };
42
+ const block = (items) => {
43
+ const lines = items.map(line).sort((a, b) => b.consumption.microUsd - a.consumption.microUsd
44
+ || a.item.number - b.item.number);
45
+ const ids = new Set();
46
+ for (const i of items)
47
+ for (const id of costs.entriesOf(i, i.kind === "issue" ? closersOf(i) : []).ids)
48
+ ids.add(id);
49
+ return { lines, ...costs.cost(ids) };
50
+ };
51
+ return {
52
+ planned: input.plan ? {
53
+ delivered: block(planned.filter(deliveredInPeriod)),
54
+ pending: block(planned.filter((i) => !deliveredInPeriod(i))),
55
+ } : null,
56
+ unplanned: block(unplanned),
57
+ };
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ /** Qué imputaciones se unen a cada issue o PR, y lo que cuestan. */
61
+ class EntryIndex {
62
+ input;
63
+ byId = new Map();
64
+ byBranch = new Map();
65
+ byItem = new Map();
66
+ assignedByMonth = new Map();
67
+ constructor(input) {
68
+ this.input = input;
69
+ const push = (map, k, id) => {
70
+ const list = map.get(k) ?? [];
71
+ if (!list.includes(id))
72
+ list.push(id);
73
+ map.set(k, list);
74
+ };
75
+ // El PR de un merge registrado en git también une su rama con su número.
76
+ const prOfBranch = new Map();
77
+ for (const m of input.merges)
78
+ if (m.sourceBranch && m.ref)
79
+ prOfBranch.set(m.sourceBranch, m.ref);
80
+ for (const e of input.entries) {
81
+ this.byId.set(e.id, e);
82
+ if (e.branch)
83
+ push(this.byBranch, e.branch, e.id);
84
+ for (const k of e.workItems ?? [])
85
+ push(this.byItem, k, e.id);
86
+ const pr = e.branch ? prOfBranch.get(e.branch) : undefined;
87
+ if (pr)
88
+ push(this.byItem, pr, e.id);
89
+ const month = (0, amortize_js_1.monthOf)(e.startedAt);
90
+ this.assignedByMonth.set(month, (this.assignedByMonth.get(month) ?? 0) + e.aiCost.microUsd);
91
+ }
92
+ }
93
+ entriesOf(item, deliveredBy) {
94
+ const ids = new Set();
95
+ const basis = new Set();
96
+ for (const i of [item, ...deliveredBy]) {
97
+ const fromBranch = i.kind === "pr" && i.branch ? this.byBranch.get(i.branch) ?? [] : [];
98
+ const fromItem = this.byItem.get(i.key) ?? [];
99
+ if (fromBranch.length)
100
+ basis.add("branch");
101
+ if (fromItem.length)
102
+ basis.add("item");
103
+ for (const id of [...fromBranch, ...fromItem])
104
+ ids.add(id);
105
+ }
106
+ // Siempre en el mismo orden: la rama primero.
107
+ return { ids, basis: ["branch", "item"].filter((b) => basis.has(b)) };
108
+ }
109
+ cost(ids) {
110
+ let seconds = 0;
111
+ let micro = 0;
112
+ const byMonth = new Map();
113
+ for (const id of ids) {
114
+ const e = this.byId.get(id);
115
+ seconds += e.seconds;
116
+ micro += e.aiCost.microUsd;
117
+ const month = (0, amortize_js_1.monthOf)(e.startedAt);
118
+ byMonth.set(month, (byMonth.get(month) ?? 0) + e.aiCost.microUsd);
119
+ }
120
+ const { real, partial } = (0, spend_js_1.realFromMonths)(byMonth, this.input.subscriptions, (month) => Math.max(this.input.monthTotals.get(month)?.microUsd ?? 0, this.assignedByMonth.get(month) ?? 0));
121
+ return { seconds, consumption: { microUsd: micro }, real, realPartial: partial };
122
+ }
123
+ }
124
+ //# sourceMappingURL=report.js.map
@@ -0,0 +1,68 @@
1
+ import type { TrackerSystem } from "@estela/shared";
2
+ /**
3
+ * De dónde sale lo planificado y lo entregado de un periodo.
4
+ *
5
+ * Una interfaz y no GitHub a pelo: el plan de un equipo vive en GitHub, en
6
+ * Linear o en Jira, y lo que Estela hace con él (compararlo con lo entregado y
7
+ * ponerle el coste de IA) es lo mismo en los tres. Hoy solo existe GitHub.
8
+ *
9
+ * Una fuente es la única parte de Estela que habla con un servicio externo, y
10
+ * solo cuando quien la usa ejecuta el comando en un proyecto que la tiene
11
+ * configurada. Lo que lee se guarda en la base (tracker_items) para poder
12
+ * repetir el informe sin conexión.
13
+ */
14
+ /** Una issue o un PR, ya normalizado. */
15
+ export interface TrackerItem {
16
+ /** Clave como las de los tickets de las imputaciones: `github:12`. */
17
+ readonly key: string;
18
+ readonly kind: "issue" | "pr";
19
+ readonly number: number;
20
+ readonly title: string;
21
+ readonly state: "open" | "closed" | "merged";
22
+ /** Cerrada como hecha (issue) o fusionado (PR). Cerrar como "no se hará" no es entregar. */
23
+ readonly completed: boolean;
24
+ /** Cuándo se cerró o se fusionó. */
25
+ readonly closedAt: Date | null;
26
+ /** Rama del PR. Es lo que une un squash con el trabajo de su rama. */
27
+ readonly branch: string | null;
28
+ /** Issues que cierra este PR (`github:12`). */
29
+ readonly closes: readonly string[];
30
+ readonly labels: readonly string[];
31
+ readonly milestone: string | null;
32
+ readonly url: string;
33
+ }
34
+ /** Qué cuenta como planificado: una etiqueta o un milestone. */
35
+ export interface PlanFilter {
36
+ readonly label?: string;
37
+ readonly milestone?: string;
38
+ }
39
+ export interface FetchQuery {
40
+ /** Fechas locales, inclusive. */
41
+ readonly from: string;
42
+ readonly to: string;
43
+ readonly plan: PlanFilter | null;
44
+ /**
45
+ * Prefijo de las etiquetas de cliente (`cliente:`). Con él se traen también
46
+ * las issues y PRs que las llevan, en cualquier estado: lo pedido por un
47
+ * cliente cuesta mientras se trabaja, no solo al entregarse.
48
+ */
49
+ readonly clientLabelPrefix?: string;
50
+ }
51
+ export type Availability = {
52
+ readonly ok: true;
53
+ } | {
54
+ readonly ok: false;
55
+ readonly reason: "missing" | "auth" | "repo";
56
+ readonly detail: string;
57
+ };
58
+ export interface PlanSource {
59
+ readonly system: TrackerSystem;
60
+ /** Si se puede leer ahora: instalado, con sesión y con un repo que la fuente entienda. */
61
+ availability(): Promise<Availability>;
62
+ /**
63
+ * Lo planificado (según el filtro, en cualquier estado) más lo entregado en
64
+ * el periodo (PRs fusionados e issues cerradas).
65
+ */
66
+ fetch(query: FetchQuery): Promise<TrackerItem[]>;
67
+ }
68
+ //# sourceMappingURL=source.d.ts.map
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=source.js.map
@@ -1,9 +1,23 @@
1
1
  import type { Client, Invoice, Project, TimeEntry } from "@estela/shared";
2
2
  import { formatMoney } from "@estela/shared";
3
+ import type { SpendBy, SpendProject, SpendProjectReport } from "../metrics/spend.js";
4
+ import { type UndeliveredReport } from "../metrics/undelivered.js";
3
5
  export declare function timeEntriesToCsv(entries: readonly TimeEntry[], project: Project, client: Client, hourlyRateOf: (at: Date) => {
4
6
  amount: number;
5
7
  currency: string;
6
8
  } | null): string;
7
9
  export declare function invoiceToCsv(invoice: Invoice, client: Client, project: Project): string;
10
+ /**
11
+ * `estela ai-cost --by … --csv`: una fila por rama o ticket. "Sin atribuir"
12
+ * va con la unidad vacía y atribuido = no, para que una tabla dinámica no la
13
+ * confunda con una rama que se llame así.
14
+ */
15
+ export declare function spendToCsv(reports: readonly SpendProjectReport[], by: SpendBy): string;
16
+ /**
17
+ * `estela ai-cost --undelivered --csv`: una fila por bloque y por rama. La
18
+ * columna "cuenta" dice si suma al gasto sin entrega: las ramas abiertas o de
19
+ * estado desconocido se listan, pero no cuentan.
20
+ */
21
+ export declare function undeliveredToCsv(reports: readonly UndeliveredReport[], projects: ReadonlyMap<string, SpendProject>): string;
8
22
  export { formatMoney };
9
23
  //# sourceMappingURL=csv.d.ts.map
@@ -3,10 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatMoney = void 0;
4
4
  exports.timeEntriesToCsv = timeEntriesToCsv;
5
5
  exports.invoiceToCsv = invoiceToCsv;
6
+ exports.spendToCsv = spendToCsv;
7
+ exports.undeliveredToCsv = undeliveredToCsv;
6
8
  const shared_1 = require("@estela/shared");
7
9
  Object.defineProperty(exports, "formatMoney", { enumerable: true, get: function () { return shared_1.formatMoney; } });
8
10
  const localize_js_1 = require("../billing/localize.js");
9
11
  const index_js_1 = require("../i18n/index.js");
12
+ const undelivered_js_1 = require("../metrics/undelivered.js");
10
13
  /**
11
14
  * Export a CSV.
12
15
  *
@@ -33,6 +36,14 @@ const COLUMNS = {
33
36
  lineHeader: ["concepto", "horas", "tarifa", "importe"],
34
37
  subtotal: "subtotal", aiBilled: "coste_ia_repercutido", total: "total",
35
38
  aiUsage: "consumo_ia_usd_equiv_api",
39
+ spend: ["proyecto", "cliente", "unidad", "atribuido", "ramas", "tickets", "pr", "segun", "horas",
40
+ "coste_ia_usd_equiv_api", "gasto_real", "moneda", "gasto_real_parcial", "incluye_copilot_sin_coste"],
41
+ undelivered: ["proyecto", "cliente", "bloque", "detalle", "cuenta", "dias_sin_actividad",
42
+ "coste_ia_usd_equiv_api", "gasto_real", "moneda", "gasto_real_parcial", "pct_del_gasto"],
43
+ blocks: { total: "gasto_del_periodo", sessions: "sesiones_sin_commit", later: "de_ellas_con_commit_despues",
44
+ unverifiable: "sesiones_sin_datos_de_commits",
45
+ unmerged: "rama_sin_fusionar", open: "rama_abierta", unknown: "rama_desconocida",
46
+ undelivered: "sin_entrega_total" },
36
47
  },
37
48
  en: {
38
49
  entries: ["date", "start", "end", "hours", "description", "project", "client",
@@ -44,6 +55,14 @@ const COLUMNS = {
44
55
  lineHeader: ["item", "hours", "rate", "amount"],
45
56
  subtotal: "subtotal", aiBilled: "ai_cost_passed_on", total: "total",
46
57
  aiUsage: "ai_usage_usd_api_equiv",
58
+ spend: ["project", "client", "unit", "attributed", "branches", "tickets", "pr", "basis", "hours",
59
+ "ai_cost_usd_api_equiv", "real_spend", "currency", "real_spend_partial", "includes_unpriced_copilot"],
60
+ undelivered: ["project", "client", "block", "detail", "counts", "idle_days",
61
+ "ai_cost_usd_api_equiv", "real_spend", "currency", "real_spend_partial", "pct_of_spend"],
62
+ blocks: { total: "period_spend", sessions: "sessions_without_commit", later: "of_which_committed_later",
63
+ unverifiable: "sessions_without_commit_data",
64
+ unmerged: "unmerged_branch", open: "open_branch", unknown: "unknown_branch",
65
+ undelivered: "undelivered_total" },
47
66
  },
48
67
  };
49
68
  /** Escapa un campo. Sin esto, un commit con una coma parte la fila en dos. */
@@ -113,4 +132,63 @@ function invoiceToCsv(invoice, client, project) {
113
132
  lines.push([c.aiUsage, "", "", (invoice.aiCost.microUsd / 1_000_000).toFixed(4)]);
114
133
  return rows(lines);
115
134
  }
135
+ /**
136
+ * `estela ai-cost --by … --csv`: una fila por rama o ticket. "Sin atribuir"
137
+ * va con la unidad vacía y atribuido = no, para que una tabla dinámica no la
138
+ * confunda con una rama que se llame así.
139
+ */
140
+ function spendToCsv(reports, by) {
141
+ const c = COLUMNS[(0, index_js_1.getLang)()];
142
+ const lines = [[...c.spend]];
143
+ for (const r of reports) {
144
+ for (const u of r.units) {
145
+ lines.push([
146
+ r.project.name,
147
+ r.project.clientName,
148
+ u.key === null ? "" : by === "branch" ? u.key : u.items.map(shared_1.formatWorkItem).join(" "),
149
+ u.key === null ? c.no : c.yes,
150
+ u.branches.join(" "),
151
+ u.items.map(shared_1.formatWorkItem).join(" "),
152
+ u.prs.map(shared_1.formatWorkItem).join(" "),
153
+ u.basis.join(" "),
154
+ (u.seconds / 3600).toFixed(4),
155
+ (u.consumption.microUsd / 1_000_000).toFixed(4),
156
+ u.real ? (u.real.amount / 10 ** shared_1.MINOR_UNIT_DIGITS[u.real.currency]).toFixed(shared_1.MINOR_UNIT_DIGITS[u.real.currency]) : "",
157
+ u.real?.currency ?? "",
158
+ u.realPartial ? c.yes : c.no,
159
+ u.unpricedAgents ? c.yes : c.no,
160
+ ]);
161
+ }
162
+ }
163
+ return rows(lines);
164
+ }
165
+ /**
166
+ * `estela ai-cost --undelivered --csv`: una fila por bloque y por rama. La
167
+ * columna "cuenta" dice si suma al gasto sin entrega: las ramas abiertas o de
168
+ * estado desconocido se listan, pero no cuentan.
169
+ */
170
+ function undeliveredToCsv(reports, projects) {
171
+ const c = COLUMNS[(0, index_js_1.getLang)()];
172
+ const lines = [[...c.undelivered]];
173
+ const amount = (s) => s.real
174
+ ? (s.real.amount / 10 ** shared_1.MINOR_UNIT_DIGITS[s.real.currency]).toFixed(shared_1.MINOR_UNIT_DIGITS[s.real.currency]) : "";
175
+ for (const r of reports) {
176
+ const p = projects.get(r.projectId);
177
+ const push = (block, detail, counts, idle, s) => lines.push([
178
+ p?.name ?? r.projectId, p?.clientName ?? "", block, detail, counts ? c.yes : c.no, idle ?? "",
179
+ (s.consumption.microUsd / 1_000_000).toFixed(4), amount(s), s.real?.currency ?? "",
180
+ s.realPartial ? c.yes : c.no, (0, undelivered_js_1.shareOf)(s, r.total)?.toFixed(2) ?? "",
181
+ ]);
182
+ push(c.blocks.total, "", false, null, r.total);
183
+ push(c.blocks.sessions, String(r.sessionsWithoutCommit.count), true, null, r.sessionsWithoutCommit);
184
+ push(c.blocks.later, String(r.sessionsWithoutCommit.laterCommitted.count), false, null, r.sessionsWithoutCommit.laterCommitted);
185
+ push(c.blocks.unverifiable, String(r.unverifiable.count), false, null, r.unverifiable);
186
+ for (const b of r.unmerged.branches)
187
+ push(c.blocks.unmerged, b.branch, true, b.idleDays, b);
188
+ for (const b of r.notCounted)
189
+ push(b.state === "open" ? c.blocks.open : c.blocks.unknown, b.branch, false, b.idleDays, b);
190
+ push(c.blocks.undelivered, "", false, null, r.undelivered);
191
+ }
192
+ return rows(lines);
193
+ }
116
194
  //# sourceMappingURL=csv.js.map
@@ -0,0 +1,79 @@
1
+ import type { Currency } from "@estela/shared";
2
+ import type { Cost } from "../delivery/report.js";
3
+ /**
4
+ * El informe para finanzas: el gasto de IA de un periodo contado para alguien
5
+ * que no programa.
6
+ *
7
+ * Los titulares hablan de dinero pagado, clientes y entregas; "tokens",
8
+ * "sesiones" y "precio de API" solo aparecen al final, en cómo se calcula.
9
+ * Junta lo que ya calculan los demás informes (ai-cost --by, --undelivered,
10
+ * delivery) sin calcular nada nuevo: aquí solo se ordena y se escribe.
11
+ */
12
+ export interface FinanceProject {
13
+ readonly id: string;
14
+ readonly name: string;
15
+ readonly cost: Cost;
16
+ }
17
+ export interface FinanceClient {
18
+ readonly id: string;
19
+ readonly name: string;
20
+ readonly cost: Cost;
21
+ readonly projects: readonly FinanceProject[];
22
+ }
23
+ export interface FinanceDelivery {
24
+ readonly label: string;
25
+ readonly cost: Cost;
26
+ }
27
+ export interface FinanceDeliveries {
28
+ readonly projectName: string;
29
+ readonly clientName: string;
30
+ /** "github"/"gitlab": lo leído del gestor con `estela delivery`; "git": solo merges y squash. */
31
+ readonly source: "github" | "gitlab" | "git";
32
+ readonly fetchedAt: Date | null;
33
+ readonly delivered: readonly FinanceDelivery[];
34
+ readonly plan: {
35
+ readonly label: string;
36
+ readonly delivered: number;
37
+ readonly total: number;
38
+ readonly pending: Cost;
39
+ readonly unplanned: Cost;
40
+ } | null;
41
+ readonly requests: readonly {
42
+ readonly name: string;
43
+ readonly mapped: boolean;
44
+ readonly cost: Cost;
45
+ }[] | null;
46
+ readonly requestsRest: Cost | null;
47
+ }
48
+ export interface FinanceUndelivered {
49
+ readonly projectName: string;
50
+ readonly total: Cost;
51
+ readonly undelivered: Cost;
52
+ readonly withoutCommit: Cost;
53
+ readonly unmergedBranches: Cost;
54
+ readonly unmergedCount: number;
55
+ }
56
+ export interface FinanceSummary {
57
+ readonly from: string;
58
+ readonly to: string;
59
+ /** La moneda de la cuota; `null` sin suscripción registrada (todo va a precio de API). */
60
+ readonly currency: Currency | null;
61
+ /** Todo el gasto de la máquina en el periodo, con o sin proyecto. */
62
+ readonly paid: Cost;
63
+ readonly unassigned: Cost;
64
+ readonly clients: readonly FinanceClient[];
65
+ readonly deliveries: readonly FinanceDeliveries[];
66
+ readonly undelivered: readonly FinanceUndelivered[];
67
+ readonly staleDays: number;
68
+ /** Hubo trabajo con Copilot, cuyo coste no se conoce. */
69
+ readonly unpricedAgents: boolean;
70
+ }
71
+ /** "octubre de 2026" si el periodo es un mes entero; si no, las dos fechas. */
72
+ export declare function periodName(from: string, to: string): string;
73
+ export declare function financeToMarkdown(s: FinanceSummary): string;
74
+ /** Suma dos costes. El importe real se suma solo si los dos están en la misma moneda. */
75
+ export declare function sum(a: Cost, b: Cost): Cost;
76
+ /** Una fila por cifra, con la sección delante: se filtra en una hoja de cálculo. */
77
+ export declare function financeToCsv(s: FinanceSummary): string;
78
+ export declare function financeToJson(s: FinanceSummary): unknown;
79
+ //# sourceMappingURL=finance.d.ts.map