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