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,167 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.spendByUnit = spendByUnit;
4
+ exports.realFromMonths = realFromMonths;
5
+ const shared_1 = require("@estela/shared");
6
+ const amortize_js_1 = require("../billing/amortize.js");
7
+ const index_js_1 = require("../i18n/index.js");
8
+ const paths_js_1 = require("../paths.js");
9
+ /** Agentes cuyo transcript no trae tokens: su trabajo cuenta horas pero no coste. */
10
+ const UNPRICED_AGENTS = new Set(["copilot"]);
11
+ function spendByUnit(input, by) {
12
+ const reports = [];
13
+ // Como en `amortize`: el denominador nunca baja de lo imputado ese mes, o un
14
+ // mes sin turnos guardados le daría la cuota entera a cada unidad.
15
+ const assignedByMonth = new Map();
16
+ for (const e of input.entries) {
17
+ const month = (0, amortize_js_1.monthOf)(e.startedAt);
18
+ assignedByMonth.set(month, (assignedByMonth.get(month) ?? 0) + e.aiCost.microUsd);
19
+ }
20
+ for (const project of input.projects) {
21
+ const entries = input.entries.filter((e) => e.projectId === project.id);
22
+ if (entries.length === 0)
23
+ continue;
24
+ const prsByBranch = mergedPrs(input.merges, project.repoPaths);
25
+ const groups = new Map();
26
+ for (const entry of entries) {
27
+ const a = attribute(entry, project.tracker ?? null, prsByBranch);
28
+ const key = by === "branch" ? entry.branch : a.items.length ? a.items.join(",") : null;
29
+ const id = key ?? "\u0000";
30
+ let acc = groups.get(id);
31
+ if (!acc) {
32
+ acc = newAcc(key);
33
+ groups.set(id, acc);
34
+ }
35
+ add(acc, entry, a, by);
36
+ }
37
+ const units = [...groups.values()].map((acc) => finish(acc, input, assignedByMonth));
38
+ units.sort((x, y) => (x.key === null ? 1 : 0) - (y.key === null ? 1 : 0)
39
+ || y.consumption.microUsd - x.consumption.microUsd || y.seconds - x.seconds);
40
+ reports.push({ project, units, total: sumUnits(units) });
41
+ }
42
+ return reports;
43
+ }
44
+ /** PRs fusionados por rama, de los merges de los repos del proyecto. */
45
+ function mergedPrs(merges, repos) {
46
+ const out = new Map();
47
+ for (const m of merges) {
48
+ if (!m.sourceBranch || !m.ref)
49
+ continue;
50
+ if (!repos.some((r) => (0, paths_js_1.isSameOrInside)(m.repoPath, r) || (0, paths_js_1.isSameOrInside)(r, m.repoPath)))
51
+ continue;
52
+ const list = out.get(m.sourceBranch) ?? [];
53
+ if (!list.includes(m.ref))
54
+ list.push(m.ref);
55
+ out.set(m.sourceBranch, list);
56
+ }
57
+ return out;
58
+ }
59
+ /**
60
+ * Los tickets de una imputación y en qué se basan. Primero los que ya tiene
61
+ * (rama, commits o a mano); si no tiene ninguno, el PR con que se fusionó su
62
+ * rama. Un PR no sustituye a un ticket que ya se conoce: son cosas distintas.
63
+ */
64
+ function attribute(entry, tracker, prsByBranch) {
65
+ const prs = entry.branch ? prsByBranch.get(entry.branch) ?? [] : [];
66
+ const known = entry.workItems ?? [];
67
+ if (known.length) {
68
+ const fromBranch = new Set((0, shared_1.extractWorkItems)(entry.branch, [], tracker));
69
+ const itemBasis = entry.workItemsManual
70
+ ? ["manual"]
71
+ : known.map((k) => (fromBranch.has(k) ? "branch-name" : "commit-message"));
72
+ return { items: [...known], prs, itemBasis };
73
+ }
74
+ if (prs.length)
75
+ return { items: [...prs], prs, itemBasis: ["merge"] };
76
+ return { items: [], prs, itemBasis: [] };
77
+ }
78
+ function newAcc(key) {
79
+ return { key, branches: new Set(), items: new Set(), prs: new Set(), basis: new Set(),
80
+ seconds: 0, microUsd: 0, byMonth: new Map(), unpricedAgents: false };
81
+ }
82
+ function add(acc, entry, a, by) {
83
+ acc.seconds += entry.seconds;
84
+ acc.microUsd += entry.aiCost.microUsd;
85
+ // El mes de started_at, el mismo que usa `estela ai-cost` para repartir.
86
+ const month = (0, amortize_js_1.monthOf)(entry.startedAt);
87
+ acc.byMonth.set(month, (acc.byMonth.get(month) ?? 0) + entry.aiCost.microUsd);
88
+ if (entry.agents.some((x) => UNPRICED_AGENTS.has(x)))
89
+ acc.unpricedAgents = true;
90
+ if (entry.branch)
91
+ acc.branches.add(entry.branch);
92
+ // Por rama, los PRs van en su sitio: el del merge no se repite como ticket.
93
+ const fromMerge = a.itemBasis.length === 1 && a.itemBasis[0] === "merge";
94
+ if (by === "item" || !fromMerge)
95
+ for (const i of a.items)
96
+ acc.items.add(i);
97
+ for (const p of a.prs)
98
+ acc.prs.add(p);
99
+ if (by === "branch") {
100
+ if (entry.branch)
101
+ acc.basis.add(entry.agents.length ? "agent-branch" : "commit-branch");
102
+ }
103
+ else if (a.items.length) {
104
+ for (const b of a.itemBasis)
105
+ acc.basis.add(b);
106
+ }
107
+ }
108
+ /**
109
+ * Dinero real: la cuota del mes por la fracción de todo el consumo de ese mes
110
+ * que es de esta parte. La fórmula de `amortize`, solo que con una parte
111
+ * cualquiera (una rama, unas sesiones) en vez del proyecto; redondea por parte,
112
+ * así que la suma puede diferir en un céntimo de la de `ai-cost`.
113
+ *
114
+ * `partial`: algún mes con consumo no tenía cuota, y `real` cuenta solo el resto.
115
+ */
116
+ function realFromMonths(byMonth, subscriptions, monthTotal) {
117
+ let real = null;
118
+ let partial = false;
119
+ if (!subscriptions.length)
120
+ return { real, partial };
121
+ for (const [month, micro] of byMonth) {
122
+ const fee = (0, amortize_js_1.feeForMonth)(subscriptions, month);
123
+ if (!fee) {
124
+ if (micro > 0)
125
+ partial = true;
126
+ continue;
127
+ }
128
+ const total = Math.max(monthTotal(month), micro);
129
+ const part = total === 0 ? 0 : Math.round(fee.amount * (micro / total));
130
+ if (real && real.currency !== fee.currency) {
131
+ throw new TypeError((0, index_js_1.tr) `Suscripciones en monedas distintas en el periodo (${real.currency} y ${fee.currency}). ` +
132
+ (0, index_js_1.tr) `Unifica la moneda de las suscripciones: no se convierten automáticamente.`);
133
+ }
134
+ real = (0, shared_1.money)((real?.amount ?? 0) + part, fee.currency);
135
+ }
136
+ return { real, partial };
137
+ }
138
+ function finish(acc, input, assignedByMonth) {
139
+ const { real, partial } = realFromMonths(acc.byMonth, input.subscriptions, (month) => Math.max(input.monthTotals.get(month)?.microUsd ?? 0, assignedByMonth.get(month) ?? 0));
140
+ return {
141
+ key: acc.key,
142
+ branches: [...acc.branches].sort(),
143
+ items: [...acc.items],
144
+ prs: [...acc.prs],
145
+ basis: [...acc.basis],
146
+ seconds: acc.seconds,
147
+ consumption: { microUsd: acc.microUsd },
148
+ real,
149
+ realPartial: partial,
150
+ unpricedAgents: acc.unpricedAgents,
151
+ };
152
+ }
153
+ function sumUnits(units) {
154
+ let real = null;
155
+ for (const u of units) {
156
+ if (u.real)
157
+ real = (0, shared_1.money)((real?.amount ?? 0) + u.real.amount, u.real.currency);
158
+ }
159
+ return {
160
+ seconds: units.reduce((s, u) => s + u.seconds, 0),
161
+ consumption: { microUsd: units.reduce((s, u) => s + u.consumption.microUsd, 0) },
162
+ real,
163
+ realPartial: units.some((u) => u.realPartial),
164
+ unpricedAgents: units.some((u) => u.unpricedAgents),
165
+ };
166
+ }
167
+ //# sourceMappingURL=spend.js.map
@@ -0,0 +1,104 @@
1
+ import type { AiCost, AgentKind, Money, Subscription } from "@estela/shared";
2
+ /**
3
+ * Gasto de IA sin entrega: lo que no ha llegado (todavía) a la rama principal.
4
+ *
5
+ * Es una señal para revisar, no un juicio. Explorar, probar y descartar es
6
+ * trabajo, y una sesión en la que Claude cambia algo cinco veces antes de que
7
+ * lo des por bueno vale lo mismo aunque aún no tenga commit. Por eso nada de
8
+ * esto resta horas ni toca imputaciones: solo pone una cifra al lado.
9
+ *
10
+ * Se mide sobre los turnos, no sobre las imputaciones: una sesión es un rato
11
+ * de un chat, y una imputación es un día de una rama que puede juntar varias.
12
+ * Dos bloques que no se pisan:
13
+ *
14
+ * 1. Sesiones sin commit: un rato de trabajo de un chat (sin pausas de más de
15
+ * 30 min) en el que no hiciste ningún commit en ese repo, ni en los 30 min
16
+ * siguientes. Se separan las que luego sí tuvieron commit en su rama. Las
17
+ * anteriores a tu primer commit conocido en el repo no se juzgan: quizá
18
+ * commiteabas con otro correo.
19
+ * 2. Ramas sin fusionar: el resto del coste de una rama que sigue existiendo,
20
+ * no está integrada y lleva N días sin actividad. Una rama borrada sin
21
+ * merge registrado puede ser un squash: su estado es "desconocido" y no
22
+ * cuenta, porque contarla inflaría la cifra.
23
+ */
24
+ export interface UndeliveredTurn {
25
+ readonly projectId: string;
26
+ readonly sessionId: string;
27
+ readonly at: Date;
28
+ /** Raíz git (el repo principal, también para un worktree). */
29
+ readonly root: string;
30
+ readonly branch: string | null;
31
+ readonly microUsd: number;
32
+ readonly agent: AgentKind;
33
+ }
34
+ /** Un commit tuyo, con su raíz git ya resuelta igual que la de los turnos. */
35
+ export interface CommitFact {
36
+ readonly root: string;
37
+ readonly at: Date;
38
+ readonly branch: string | null;
39
+ }
40
+ export interface BranchFacts {
41
+ /** Integrada: la rama base, `git branch --merged` o un commit de merge. */
42
+ readonly merged: boolean;
43
+ /** `null` si el repo ya no se puede leer: no se sabe. */
44
+ readonly exists: boolean | null;
45
+ }
46
+ export interface UndeliveredInput {
47
+ readonly turns: readonly UndeliveredTurn[];
48
+ readonly commits: readonly CommitFact[];
49
+ readonly branchFacts: (root: string, branch: string) => BranchFacts;
50
+ readonly staleDays: number;
51
+ readonly now: Date;
52
+ /** Fechas locales; sin ellas, todo. Se mira la fecha de inicio de la sesión. */
53
+ readonly from?: string;
54
+ readonly to?: string;
55
+ readonly subscriptions: readonly Subscription[];
56
+ readonly monthTotals: ReadonlyMap<string, AiCost>;
57
+ }
58
+ export interface Spend {
59
+ readonly consumption: AiCost;
60
+ readonly real: Money | null;
61
+ readonly realPartial: boolean;
62
+ }
63
+ export type BranchState = "unmerged" | "open" | "unknown";
64
+ export interface BranchLine extends Spend {
65
+ readonly branch: string;
66
+ readonly state: BranchState;
67
+ /** Días desde la última actividad (turno o commit) en la rama. */
68
+ readonly idleDays: number;
69
+ }
70
+ export interface UndeliveredReport {
71
+ readonly projectId: string;
72
+ /** Todo el gasto del periodo en el proyecto: la base del porcentaje. */
73
+ readonly total: Spend;
74
+ /**
75
+ * Sesiones sin commit que no se pueden juzgar: no se conoce ningún commit tuyo
76
+ * en ese repo, o la sesión es anterior al primero. Puede ser que commitearas
77
+ * con otro correo (ver `estela author`), así que no cuentan.
78
+ */
79
+ readonly unverifiable: Spend & {
80
+ readonly count: number;
81
+ };
82
+ readonly sessionsWithoutCommit: Spend & {
83
+ readonly count: number;
84
+ /** De ellas, las que luego tuvieron commit en su rama (dentro de N días). */
85
+ readonly laterCommitted: Spend & {
86
+ readonly count: number;
87
+ };
88
+ };
89
+ /** Ramas que cuentan: sin fusionar tras N días. */
90
+ readonly unmerged: Spend & {
91
+ readonly branches: readonly BranchLine[];
92
+ };
93
+ /** No cuentan: abiertas hace menos de N días, o de estado desconocido. */
94
+ readonly notCounted: readonly BranchLine[];
95
+ /** Suma de los dos bloques que cuentan. */
96
+ readonly undelivered: Spend;
97
+ readonly unpricedAgents: boolean;
98
+ }
99
+ /** Ramas donde se integra: trabajar directamente en ellas ya es entregar. */
100
+ export declare const BASE_BRANCHES: Set<string>;
101
+ export declare function undeliveredSpend(input: UndeliveredInput): UndeliveredReport[];
102
+ /** El porcentaje de una parte sobre el total, en tanto por ciento. `null` sin total. */
103
+ export declare function shareOf(part: Spend, total: Spend): number | null;
104
+ //# sourceMappingURL=undelivered.d.ts.map
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BASE_BRANCHES = void 0;
4
+ exports.undeliveredSpend = undeliveredSpend;
5
+ exports.shareOf = shareOf;
6
+ const shared_1 = require("@estela/shared");
7
+ const amortize_js_1 = require("../billing/amortize.js");
8
+ const spend_js_1 = require("./spend.js");
9
+ /** El mismo hueco que separa bloques de trabajo (ver sessionize). */
10
+ const GAP_MS = 30 * 60_000;
11
+ const DAY_MS = 86_400_000;
12
+ const UNPRICED_AGENTS = new Set(["copilot"]);
13
+ /** Ramas donde se integra: trabajar directamente en ellas ya es entregar. */
14
+ exports.BASE_BRANCHES = new Set(["main", "master", "develop", "trunk"]);
15
+ function undeliveredSpend(input) {
16
+ const byProject = new Map();
17
+ for (const t of input.turns) {
18
+ const list = byProject.get(t.projectId) ?? [];
19
+ list.push(t);
20
+ byProject.set(t.projectId, list);
21
+ }
22
+ const reports = [];
23
+ for (const [projectId, turns] of byProject) {
24
+ const report = projectReport(projectId, turns, input);
25
+ if (report.total.consumption.microUsd > 0 || report.unpricedAgents)
26
+ reports.push(report);
27
+ }
28
+ return reports.sort((a, b) => a.projectId.localeCompare(b.projectId));
29
+ }
30
+ function projectReport(projectId, all, input) {
31
+ const inPeriod = (d) => {
32
+ const day = (0, shared_1.localDate)(d);
33
+ return (!input.from || day >= input.from) && (!input.to || day <= input.to);
34
+ };
35
+ const spend = new SpendAcc(input);
36
+ // Ratos de cada chat: mismo session_id y repo, cortados por pausas largas.
37
+ const sittings = [];
38
+ const bySession = new Map();
39
+ for (const t of all) {
40
+ const key = `${t.sessionId}\0${t.root}`;
41
+ const list = bySession.get(key) ?? [];
42
+ list.push(t);
43
+ bySession.set(key, list);
44
+ }
45
+ for (const list of bySession.values()) {
46
+ list.sort((a, b) => a.at.getTime() - b.at.getTime());
47
+ let current = [];
48
+ for (const t of list) {
49
+ const last = current[current.length - 1];
50
+ if (last && t.at.getTime() - last.at.getTime() > GAP_MS) {
51
+ sittings.push({ turns: current, root: last.root });
52
+ current = [];
53
+ }
54
+ current.push(t);
55
+ }
56
+ if (current.length)
57
+ sittings.push({ turns: current, root: current[0].root });
58
+ }
59
+ // El primer commit tuyo de cada repo: antes de él, no hay con qué comparar.
60
+ const firstCommit = new Map();
61
+ for (const c of input.commits) {
62
+ firstCommit.set(c.root, Math.min(firstCommit.get(c.root) ?? Infinity, c.at.getTime()));
63
+ }
64
+ const total = spend.empty();
65
+ const unverifiable = spend.empty();
66
+ let unverifiableCount = 0;
67
+ const noCommit = spend.empty();
68
+ const later = spend.empty();
69
+ let noCommitCount = 0;
70
+ let laterCount = 0;
71
+ const counted = new Set();
72
+ for (const s of sittings) {
73
+ const first = s.turns[0];
74
+ if (!inPeriod(first.at))
75
+ continue;
76
+ for (const t of s.turns)
77
+ spend.add(total, t);
78
+ const start = first.at.getTime();
79
+ const end = s.turns[s.turns.length - 1].at.getTime();
80
+ const committed = input.commits.some((c) => c.root === s.root && c.at.getTime() >= start && c.at.getTime() <= end + GAP_MS);
81
+ if (committed)
82
+ continue;
83
+ if (start < (firstCommit.get(s.root) ?? Infinity)) {
84
+ unverifiableCount++;
85
+ for (const t of s.turns) {
86
+ spend.add(unverifiable, t);
87
+ counted.add(t);
88
+ }
89
+ continue;
90
+ }
91
+ noCommitCount++;
92
+ for (const t of s.turns) {
93
+ spend.add(noCommit, t);
94
+ counted.add(t);
95
+ }
96
+ const branches = new Set(s.turns.map((t) => t.branch).filter((b) => !!b));
97
+ const afterwards = input.commits.some((c) => c.root === s.root && c.branch !== null
98
+ && branches.has(c.branch) && c.at.getTime() > end && c.at.getTime() <= end + input.staleDays * DAY_MS);
99
+ if (afterwards) {
100
+ laterCount++;
101
+ for (const t of s.turns)
102
+ spend.add(later, t);
103
+ }
104
+ }
105
+ // Ramas: el coste que no está ya en una sesión sin commit.
106
+ const lastActivity = new Map();
107
+ const touch = (root, branch, at) => {
108
+ const k = `${root}\0${branch}`;
109
+ lastActivity.set(k, Math.max(lastActivity.get(k) ?? 0, at.getTime()));
110
+ };
111
+ for (const t of all)
112
+ if (t.branch)
113
+ touch(t.root, t.branch, t.at);
114
+ const roots = new Set(all.map((t) => t.root));
115
+ for (const c of input.commits)
116
+ if (c.branch && roots.has(c.root))
117
+ touch(c.root, c.branch, c.at);
118
+ const lines = new Map();
119
+ for (const s of sittings) {
120
+ if (!inPeriod(s.turns[0].at))
121
+ continue;
122
+ for (const t of s.turns) {
123
+ if (counted.has(t) || !t.branch || t.branch === "HEAD" || exports.BASE_BRANCHES.has(t.branch))
124
+ continue;
125
+ const k = `${t.root}\0${t.branch}`;
126
+ let line = lines.get(k);
127
+ if (!line) {
128
+ line = { branch: t.branch, root: t.root, acc: spend.empty() };
129
+ lines.set(k, line);
130
+ }
131
+ spend.add(line.acc, t);
132
+ }
133
+ }
134
+ const unmergedAcc = spend.empty();
135
+ const unmergedLines = [];
136
+ const notCounted = [];
137
+ for (const [k, { branch, root, acc }] of lines) {
138
+ const facts = input.branchFacts(root, branch);
139
+ if (facts.merged)
140
+ continue;
141
+ const idleDays = Math.floor((input.now.getTime() - (lastActivity.get(k) ?? 0)) / DAY_MS);
142
+ const state = facts.exists !== true ? "unknown" : idleDays >= input.staleDays ? "unmerged" : "open";
143
+ const line = { branch, state, idleDays, ...spend.finish(acc) };
144
+ if (state === "unmerged") {
145
+ unmergedLines.push(line);
146
+ spend.merge(unmergedAcc, acc);
147
+ }
148
+ else {
149
+ notCounted.push(line);
150
+ }
151
+ }
152
+ const byCost = (a, b) => b.consumption.microUsd - a.consumption.microUsd;
153
+ const undelivered = spend.empty();
154
+ spend.merge(undelivered, noCommit);
155
+ spend.merge(undelivered, unmergedAcc);
156
+ return {
157
+ projectId,
158
+ total: spend.finish(total),
159
+ unverifiable: { ...spend.finish(unverifiable), count: unverifiableCount },
160
+ sessionsWithoutCommit: {
161
+ ...spend.finish(noCommit), count: noCommitCount,
162
+ laterCommitted: { ...spend.finish(later), count: laterCount },
163
+ },
164
+ unmerged: { ...spend.finish(unmergedAcc), branches: unmergedLines.sort(byCost) },
165
+ notCounted: notCounted.sort(byCost),
166
+ undelivered: spend.finish(undelivered),
167
+ unpricedAgents: total.unpriced,
168
+ };
169
+ }
170
+ /** Suma coste por mes, para repartir la cuota igual que `ai-cost`. */
171
+ class SpendAcc {
172
+ input;
173
+ constructor(input) {
174
+ this.input = input;
175
+ }
176
+ empty() { return { microUsd: 0, byMonth: new Map(), unpriced: false }; }
177
+ add(acc, t) {
178
+ acc.microUsd += t.microUsd;
179
+ const month = (0, amortize_js_1.monthOf)(t.at);
180
+ acc.byMonth.set(month, (acc.byMonth.get(month) ?? 0) + t.microUsd);
181
+ if (UNPRICED_AGENTS.has(t.agent))
182
+ acc.unpriced = true;
183
+ }
184
+ merge(into, from) {
185
+ into.microUsd += from.microUsd;
186
+ for (const [m, v] of from.byMonth)
187
+ into.byMonth.set(m, (into.byMonth.get(m) ?? 0) + v);
188
+ into.unpriced ||= from.unpriced;
189
+ }
190
+ finish(acc) {
191
+ const { real, partial } = (0, spend_js_1.realFromMonths)(acc.byMonth, this.input.subscriptions, (month) => this.input.monthTotals.get(month)?.microUsd ?? 0);
192
+ return { consumption: { microUsd: acc.microUsd }, real, realPartial: partial };
193
+ }
194
+ }
195
+ /** El porcentaje de una parte sobre el total, en tanto por ciento. `null` sin total. */
196
+ function shareOf(part, total) {
197
+ return total.consumption.microUsd === 0 ? null : (part.consumption.microUsd / total.consumption.microUsd) * 100;
198
+ }
199
+ //# sourceMappingURL=undelivered.js.map
package/dist/server.js CHANGED
@@ -129,6 +129,7 @@ async function importOnce(dbPath) {
129
129
  if (!root)
130
130
  continue;
131
131
  store.saveCommits(db, await (0, git_js_1.readCommits)(root, {}));
132
+ store.saveMerges(db, await (0, git_js_1.readMerges)(root, {}));
132
133
  }
133
134
  // Todo lo conocido, no solo lo leído ahora (ver knownTurns), y los correos
134
135
  // de todos esos repos: sin ellos, un día antiguo perdería sus commits.
@@ -757,10 +758,20 @@ function buildSummary(db, from, to) {
757
758
  SUM(approved) AS approved,
758
759
  MIN(local_date) AS first_day,
759
760
  MAX(local_date) AS last_day,
760
- COUNT(DISTINCT local_date) AS days
761
+ COUNT(DISTINCT local_date) AS days,
762
+ SUM(CASE WHEN invoice_id IS NOT NULL THEN seconds ELSE 0 END) AS invoiced,
763
+ SUM(CASE WHEN invoice_id IS NULL AND billable = 1 THEN seconds ELSE 0 END) AS unbilled,
764
+ SUM(CASE WHEN invoice_id IS NULL AND billable = 0 THEN seconds ELSE 0 END) AS not_billable
761
765
  FROM time_entries
762
766
  WHERE local_date >= ? AND local_date <= ? AND ${VISIBLE}
763
767
  GROUP BY project_id
768
+ `).all(from, to);
769
+ // En qué facturas está lo ya facturado del periodo, para nombrarlas.
770
+ const invoiceNumbers = db.prepare(`
771
+ SELECT DISTINCT t.project_id, i.number
772
+ FROM time_entries t JOIN invoices i ON i.id = t.invoice_id
773
+ WHERE t.local_date >= ? AND t.local_date <= ? AND ${VISIBLE.replace("project_id", "t.project_id")}
774
+ ORDER BY i.number
764
775
  `).all(from, to);
765
776
  const projects = new Map(store.listProjects(db).map((p) => [p.id, p]));
766
777
  const clients = new Map(store.listClients(db).map((c) => [c.id, c]));
@@ -783,6 +794,15 @@ function buildSummary(db, from, to) {
783
794
  aiMicroUsd: r.micro,
784
795
  amountMinor: rate ? Math.round((rate.amount * r.seconds) / 3600) : null,
785
796
  hourlyMinor: rate?.amount ?? null,
797
+ // Lo trabajado en el periodo, por dónde está: ya en una factura, pendiente
798
+ // de facturar (lo mismo que cuenta Informes, con su misma cuenta) o
799
+ // marcado como no facturable. Sin esto, el total del Resumen y lo
800
+ // pendiente de Informes parecen no cuadrar.
801
+ invoicedSeconds: r.invoiced,
802
+ invoices: invoiceNumbers.filter((i) => i.project_id === r.project_id).map((i) => i.number),
803
+ unbilledSeconds: r.unbilled,
804
+ unbilledMinor: rate ? Math.round((rate.amount * r.unbilled) / 3600) : null,
805
+ notBillableSeconds: r.not_billable,
786
806
  };
787
807
  }).sort((a, b) => b.seconds - a.seconds);
788
808
  // Actividad reciente entre todos los proyectos, para ver el pulso del conjunto.
@@ -4,6 +4,41 @@ export declare function readCommits(repoPath: string, options?: {
4
4
  authorEmail?: string;
5
5
  authorEmails?: readonly string[];
6
6
  }): Promise<CommitRecord[]>;
7
+ /** Un commit de merge: qué rama se integró y, si lo dice, con qué PR. */
8
+ export interface MergeRecord {
9
+ readonly repoPath: string;
10
+ readonly hash: string;
11
+ readonly at: Date;
12
+ readonly sourceBranch: string | null;
13
+ /** `github:12` o `gitlab:!45` (las MR de GitLab llevan `!`). `null` en un merge local sin PR. */
14
+ readonly ref: string | null;
15
+ readonly subject: string;
16
+ }
17
+ /**
18
+ * Commits de merge de todas las ramas.
19
+ *
20
+ * Es lo único en git que une una rama con su PR sin preguntar a GitHub: el
21
+ * mensaje lo escribe la plataforma con un formato fijo. Un squash no deja
22
+ * merge (solo "Título (#12)" en la rama principal), así que de él no se sabe
23
+ * qué rama fue.
24
+ */
25
+ export declare function readMerges(repoPath: string, options?: {
26
+ since?: Date;
27
+ }): Promise<MergeRecord[]>;
28
+ /**
29
+ * La rama y el PR de un mensaje de merge, con los formatos que escriben las
30
+ * plataformas y git:
31
+ *
32
+ * Merge pull request #12 from ana/feat/login (GitHub)
33
+ * Merge branch 'feat/login' into 'main' + "See merge request grupo/app!45" (GitLab)
34
+ * Merge branch 'feat/login' [into main] (git, sin PR)
35
+ *
36
+ * Cualquier otro mensaje (alguien lo reescribió) no se adivina.
37
+ */
38
+ export declare function parseMergeMessage(subject: string, body?: string): {
39
+ sourceBranch: string | null;
40
+ ref: string | null;
41
+ };
7
42
  /**
8
43
  * Raíz del repositorio.
9
44
  *
@@ -32,6 +67,11 @@ export declare function rootCommitFingerprint(path: string): Promise<string | nu
32
67
  * otros en `develop`— y equivocarse la marcaría todo como abierto.
33
68
  */
34
69
  export declare function mergedBranches(path: string): Promise<Set<string>>;
70
+ /**
71
+ * Ramas que existen hoy, locales y remotas, sin el prefijo del remoto. `null`
72
+ * si el repositorio no se puede leer (se borró o se movió): entonces no se sabe.
73
+ */
74
+ export declare function branchNames(path: string): Promise<Set<string> | null>;
35
75
  /** Rama donde el equipo integra. `develop` gana a `main` si existe. */
36
76
  export declare function integrationBranch(root: string): Promise<string | null>;
37
77
  /** Rama activa del repositorio. Ya no se usa para etiquetar commits. */
@@ -1,9 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.readCommits = readCommits;
4
+ exports.readMerges = readMerges;
5
+ exports.parseMergeMessage = parseMergeMessage;
4
6
  exports.repoRoot = repoRoot;
5
7
  exports.rootCommitFingerprint = rootCommitFingerprint;
6
8
  exports.mergedBranches = mergedBranches;
9
+ exports.branchNames = branchNames;
7
10
  exports.integrationBranch = integrationBranch;
8
11
  exports.currentBranch = currentBranch;
9
12
  exports.repoAuthors = repoAuthors;
@@ -102,6 +105,68 @@ async function readCommits(repoPath, options = {}) {
102
105
  }
103
106
  return commits;
104
107
  }
108
+ /**
109
+ * Commits de merge de todas las ramas.
110
+ *
111
+ * Es lo único en git que une una rama con su PR sin preguntar a GitHub: el
112
+ * mensaje lo escribe la plataforma con un formato fijo. Un squash no deja
113
+ * merge (solo "Título (#12)" en la rama principal), así que de él no se sabe
114
+ * qué rama fue.
115
+ */
116
+ async function readMerges(repoPath, options = {}) {
117
+ const root = await repoRoot(repoPath);
118
+ if (!root)
119
+ return [];
120
+ const format = RECORD + ["%H", "%aI", "%s", "%b"].join(SEP);
121
+ const args = ["log", "--all", "--merges", `--pretty=format:${format}`];
122
+ if (options.since)
123
+ args.push(`--since=${options.since.toISOString()}`);
124
+ let stdout;
125
+ try {
126
+ ({ stdout } = await run("git", args, { cwd: root, maxBuffer: 64 * 1024 * 1024 }));
127
+ }
128
+ catch {
129
+ return [];
130
+ }
131
+ const merges = [];
132
+ for (const chunk of stdout.split(RECORD)) {
133
+ const [hash, iso, subject = "", body = ""] = chunk.split(SEP);
134
+ if (!hash || !iso)
135
+ continue;
136
+ const at = new Date(iso);
137
+ if (Number.isNaN(at.getTime()))
138
+ continue;
139
+ const { sourceBranch, ref } = parseMergeMessage(subject.trim(), body);
140
+ merges.push({ repoPath: root, hash: hash.trim(), at, sourceBranch, ref, subject: subject.trim() });
141
+ }
142
+ return merges;
143
+ }
144
+ /**
145
+ * La rama y el PR de un mensaje de merge, con los formatos que escriben las
146
+ * plataformas y git:
147
+ *
148
+ * Merge pull request #12 from ana/feat/login (GitHub)
149
+ * Merge branch 'feat/login' into 'main' + "See merge request grupo/app!45" (GitLab)
150
+ * Merge branch 'feat/login' [into main] (git, sin PR)
151
+ *
152
+ * Cualquier otro mensaje (alguien lo reescribió) no se adivina.
153
+ */
154
+ function parseMergeMessage(subject, body = "") {
155
+ const github = /^Merge pull request #(\d+) from (\S+)/.exec(subject);
156
+ if (github) {
157
+ // "from <dueño>/<rama>": la rama puede llevar barras, el dueño no.
158
+ const from = github[2];
159
+ const slash = from.indexOf("/");
160
+ return { sourceBranch: slash === -1 ? from : from.slice(slash + 1), ref: `github:${github[1]}` };
161
+ }
162
+ const branch = /^Merge (?:remote-tracking )?branch '([^']+)'/.exec(subject);
163
+ if (branch) {
164
+ const gitlab = /See merge request \S*!(\d+)/.exec(body);
165
+ const name = branch[1].replace(/^origin\//, "");
166
+ return { sourceBranch: name, ref: gitlab ? `gitlab:!${gitlab[1]}` : null };
167
+ }
168
+ return { sourceBranch: null, ref: null };
169
+ }
105
170
  /**
106
171
  * Raíz del repositorio.
107
172
  *
@@ -174,6 +239,28 @@ async function mergedBranches(path) {
174
239
  }
175
240
  return merged;
176
241
  }
242
+ /**
243
+ * Ramas que existen hoy, locales y remotas, sin el prefijo del remoto. `null`
244
+ * si el repositorio no se puede leer (se borró o se movió): entonces no se sabe.
245
+ */
246
+ async function branchNames(path) {
247
+ const root = await repoRoot(path);
248
+ if (!root)
249
+ return null;
250
+ try {
251
+ const { stdout } = await run("git", ["for-each-ref", "--format=%(refname)", "refs/heads", "refs/remotes"], { cwd: root });
252
+ const names = new Set();
253
+ for (const line of stdout.split("\n")) {
254
+ const name = line.trim().replace(/^refs\/(heads|remotes\/[^/]+)\//, "");
255
+ if (name && name !== "HEAD")
256
+ names.add(name);
257
+ }
258
+ return names;
259
+ }
260
+ catch {
261
+ return null;
262
+ }
263
+ }
177
264
  /** Rama donde el equipo integra. `develop` gana a `main` si existe. */
178
265
  async function integrationBranch(root) {
179
266
  for (const candidate of ["develop", "main", "master"]) {