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.
- package/README.md +106 -0
- package/dist/billing/assign.d.ts +11 -1
- package/dist/billing/assign.js +59 -1
- package/dist/billing/sessionize.d.ts +6 -0
- package/dist/billing/sessionize.js +24 -5
- package/dist/cli.js +1106 -8
- package/dist/db/schema.js +101 -0
- package/dist/db/store.d.ts +28 -0
- package/dist/db/store.js +120 -1
- package/dist/delivery/clients.d.ts +52 -0
- package/dist/delivery/clients.js +94 -0
- package/dist/delivery/github.d.ts +21 -0
- package/dist/delivery/github.js +123 -0
- package/dist/delivery/gitlab.d.ts +38 -0
- package/dist/delivery/gitlab.js +146 -0
- package/dist/delivery/report.d.ts +55 -0
- package/dist/delivery/report.js +124 -0
- package/dist/delivery/source.d.ts +68 -0
- package/dist/delivery/source.js +3 -0
- package/dist/export/csv.d.ts +14 -0
- package/dist/export/csv.js +78 -0
- package/dist/export/finance.d.ts +79 -0
- package/dist/export/finance.js +208 -0
- package/dist/i18n/en.js +171 -0
- package/dist/metrics/spend.d.ts +83 -0
- package/dist/metrics/spend.js +167 -0
- package/dist/metrics/undelivered.d.ts +104 -0
- package/dist/metrics/undelivered.js +199 -0
- package/dist/server.js +21 -1
- package/dist/watchers/git.d.ts +40 -0
- package/dist/watchers/git.js +87 -0
- package/node_modules/@estela/shared/dist/money.d.ts +2 -0
- package/node_modules/@estela/shared/dist/money.d.ts.map +1 -1
- package/node_modules/@estela/shared/dist/money.js +7 -7
- package/node_modules/@estela/shared/dist/money.js.map +1 -1
- package/node_modules/@estela/shared/dist/types.d.ts +2 -0
- package/node_modules/@estela/shared/dist/types.d.ts.map +1 -1
- package/node_modules/@estela/shared/dist/workitems.d.ts +13 -2
- package/node_modules/@estela/shared/dist/workitems.d.ts.map +1 -1
- package/node_modules/@estela/shared/dist/workitems.js +8 -2
- package/node_modules/@estela/shared/dist/workitems.js.map +1 -1
- package/package.json +1 -1
- package/web/app.css +5 -0
- package/web/app.js +42 -10
- package/web/i18n.js +10 -0
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,64 @@ 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: 21,
|
|
342
|
+
describe: "las merge requests de GitLab como gitlab:!45, aparte de la issue #45",
|
|
343
|
+
run: (db) => {
|
|
344
|
+
// En GitLab la issue #45 y la MR !45 son cosas distintas con el mismo
|
|
345
|
+
// número. Solo se tocan los merges, que se leen de git: ninguna imputación.
|
|
346
|
+
db.exec(`UPDATE merges SET ref = 'gitlab:!' || substr(ref, 8)
|
|
347
|
+
WHERE ref LIKE 'gitlab:%' AND ref NOT LIKE 'gitlab:!%'`);
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
version: 20,
|
|
352
|
+
describe: "alias de cliente para las etiquetas cliente:<nombre>",
|
|
353
|
+
run: (db) => {
|
|
354
|
+
db.exec(`CREATE TABLE IF NOT EXISTS client_aliases (
|
|
355
|
+
label TEXT PRIMARY KEY,
|
|
356
|
+
client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE
|
|
357
|
+
)`);
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
version: 19,
|
|
362
|
+
describe: "issues y PRs del gestor de tareas, para planificado frente a entregado",
|
|
363
|
+
run: (db) => {
|
|
364
|
+
db.exec(`CREATE TABLE IF NOT EXISTS tracker_items (
|
|
365
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
366
|
+
kind TEXT NOT NULL,
|
|
367
|
+
key TEXT NOT NULL,
|
|
368
|
+
number INTEGER NOT NULL,
|
|
369
|
+
title TEXT NOT NULL,
|
|
370
|
+
state TEXT NOT NULL,
|
|
371
|
+
completed INTEGER NOT NULL,
|
|
372
|
+
closed_at TEXT,
|
|
373
|
+
branch TEXT,
|
|
374
|
+
closes TEXT NOT NULL DEFAULT '',
|
|
375
|
+
labels TEXT NOT NULL DEFAULT '',
|
|
376
|
+
milestone TEXT,
|
|
377
|
+
url TEXT NOT NULL DEFAULT '',
|
|
378
|
+
fetched_at TEXT NOT NULL,
|
|
379
|
+
PRIMARY KEY (project_id, kind, key)
|
|
380
|
+
)`);
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
version: 18,
|
|
385
|
+
describe: "commits de merge, para atribuir el coste a su PR",
|
|
386
|
+
run: (db) => {
|
|
387
|
+
db.exec(`CREATE TABLE IF NOT EXISTS merges (
|
|
388
|
+
repo_path TEXT NOT NULL,
|
|
389
|
+
hash TEXT NOT NULL,
|
|
390
|
+
at TEXT NOT NULL,
|
|
391
|
+
source_branch TEXT,
|
|
392
|
+
ref TEXT,
|
|
393
|
+
subject TEXT NOT NULL,
|
|
394
|
+
PRIMARY KEY (repo_path, hash)
|
|
395
|
+
)`);
|
|
396
|
+
},
|
|
397
|
+
},
|
|
297
398
|
{
|
|
298
399
|
version: 17,
|
|
299
400
|
describe: "archivar proyectos",
|
package/dist/db/store.d.ts
CHANGED
|
@@ -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
|
|
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,38 @@
|
|
|
1
|
+
import type { Availability, FetchQuery, PlanSource, TrackerItem } from "./source.js";
|
|
2
|
+
/**
|
|
3
|
+
* GitLab, a través de la CLI `glab` que ya tienes instalada y con sesión.
|
|
4
|
+
*
|
|
5
|
+
* Como con GitHub: sin token propio, y en la carpeta del repo, así que `glab`
|
|
6
|
+
* deduce el proyecto y el servidor (también uno autoalojado) por el remoto.
|
|
7
|
+
* Se usa `glab api` y no `glab mr list`: la API devuelve siempre el mismo JSON
|
|
8
|
+
* y la paginación queda en manos de Estela.
|
|
9
|
+
*
|
|
10
|
+
* Dos diferencias con GitHub que se notan en las cifras:
|
|
11
|
+
* - Las issues (`#12`) y las merge requests (`!12`) se numeran aparte, así
|
|
12
|
+
* que una MR es `gitlab:!12` y una issue `gitlab:12`.
|
|
13
|
+
* - GitLab no dice si una issue se cerró hecha o sin hacer. Cerrada cuenta
|
|
14
|
+
* como entregada, salvo que se moviera a otro proyecto.
|
|
15
|
+
* - Qué issues cierra una MR se lee de su descripción ("Closes #12"), con las
|
|
16
|
+
* mismas palabras con las que GitLab las cierra. No se pregunta MR a MR.
|
|
17
|
+
*/
|
|
18
|
+
/** Ejecuta `glab` con esos argumentos en esa carpeta y devuelve su salida. Inyectable en los tests. */
|
|
19
|
+
export type GlabRunner = (args: readonly string[], cwd: string) => Promise<string>;
|
|
20
|
+
export declare const runGlab: GlabRunner;
|
|
21
|
+
export declare class GitLabPlanSource implements PlanSource {
|
|
22
|
+
private readonly cwd;
|
|
23
|
+
private readonly glab;
|
|
24
|
+
readonly system: "gitlab";
|
|
25
|
+
constructor(cwd: string, glab?: GlabRunner);
|
|
26
|
+
availability(): Promise<Availability>;
|
|
27
|
+
fetch(query: FetchQuery): Promise<TrackerItem[]>;
|
|
28
|
+
private list;
|
|
29
|
+
/** Todas las páginas de `projects/:fullpath/<path>`. */
|
|
30
|
+
private pages;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Las issues de este proyecto que cierra una descripción, con el patrón por
|
|
34
|
+
* defecto de GitLab: "Closes #12", "Fixes #12, #13", "Resolves issue #12 and #14",
|
|
35
|
+
* "Implements #12". Las de otro proyecto (`grupo/otro#12`) no.
|
|
36
|
+
*/
|
|
37
|
+
export declare function closingIssues(description: string): number[];
|
|
38
|
+
//# sourceMappingURL=gitlab.d.ts.map
|