estela 0.4.0 → 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 +10 -1
- package/dist/cli.js +58 -36
- package/dist/db/schema.js +11 -1
- package/dist/delivery/gitlab.d.ts +38 -0
- package/dist/delivery/gitlab.js +146 -0
- package/dist/export/finance.d.ts +2 -2
- package/dist/export/finance.js +4 -4
- package/dist/i18n/en.js +15 -10
- package/dist/server.js +20 -1
- package/dist/watchers/git.d.ts +1 -1
- package/dist/watchers/git.js +1 -1
- package/node_modules/@estela/shared/dist/workitems.d.ts +3 -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/README.md
CHANGED
|
@@ -274,7 +274,7 @@ estela delivery --project shop --milestone "Sprint 4" --json
|
|
|
274
274
|
Three blocks, each with its hours and AI cost: what was planned and delivered in
|
|
275
275
|
the period (an issue closed as completed, or closed by a merged PR), what was
|
|
276
276
|
planned and not delivered, and what was delivered without being planned. Planned
|
|
277
|
-
means it carries a GitHub label or milestone. Each line's cost is that of the
|
|
277
|
+
means it carries a GitHub or GitLab label or milestone. Each line's cost is that of the
|
|
278
278
|
hours linked to it: the PR's branch (squash merges included) and its number in
|
|
279
279
|
branches, commits or set by hand.
|
|
280
280
|
|
|
@@ -284,6 +284,15 @@ and `--offline` avoids it. What it reads is saved, so the report works offline a
|
|
|
284
284
|
`--undelivered` knows which branches were squash-merged. Without `gh` the command
|
|
285
285
|
says so and shows what git says was delivered.
|
|
286
286
|
|
|
287
|
+
GitLab works the same way, with `glab`, the official GitLab CLI (self-hosted
|
|
288
|
+
servers too, because `glab` works out the server from the repo's remote):
|
|
289
|
+
`estela project tracker --project shop --system gitlab --plan-label planned`.
|
|
290
|
+
Merge requests are shown as `!45`, separately from issue `#45`. Two differences
|
|
291
|
+
in the figures: GitLab doesn't say whether an issue was closed done or closed
|
|
292
|
+
won't-do, so a closed issue counts as delivered (unless it was moved to another
|
|
293
|
+
project); and which issues an MR closes is read from its description (`Closes #12`,
|
|
294
|
+
`Fixes`, `Resolves`, `Implements`), the same words GitLab uses to close them.
|
|
295
|
+
|
|
287
296
|
### What each client asked for
|
|
288
297
|
|
|
289
298
|
```sh
|
package/dist/cli.js
CHANGED
|
@@ -87,6 +87,7 @@ const git_js_1 = require("./watchers/git.js");
|
|
|
87
87
|
const identity_js_1 = require("./watchers/identity.js");
|
|
88
88
|
const project_of_js_1 = require("./project-of.js");
|
|
89
89
|
const github_js_1 = require("./delivery/github.js");
|
|
90
|
+
const gitlab_js_1 = require("./delivery/gitlab.js");
|
|
90
91
|
const report_js_1 = require("./delivery/report.js");
|
|
91
92
|
const clients_js_1 = require("./delivery/clients.js");
|
|
92
93
|
const index_js_1 = require("./i18n/index.js");
|
|
@@ -143,9 +144,10 @@ estela — registro de horas para desarrollo asistido por IA
|
|
|
143
144
|
Planificado frente a entregado en un periodo (por defecto, este mes),
|
|
144
145
|
con el coste de IA de cada parte: lo del plan que se entregó, lo que
|
|
145
146
|
no, y lo entregado que no estaba en el plan. Lee las issues y los PR
|
|
146
|
-
de GitHub con "gh"
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
de GitHub con "gh" (--system github) o las issues y las MR de GitLab
|
|
148
|
+
con "glab" (--system gitlab); es la única llamada fuera de tu
|
|
149
|
+
máquina, y --offline la evita. Sin ellos usa lo leído la última vez
|
|
150
|
+
o, si nunca se leyó, lo entregado según git.
|
|
149
151
|
estela delivery --by client [--project <id>] [--month YYYY-MM | --from …]
|
|
150
152
|
El gasto de IA del periodo en peticiones de cada cliente frente al
|
|
151
153
|
resto: una issue o un PR con la etiqueta "cliente:<nombre>" es una
|
|
@@ -402,10 +404,10 @@ estela — time tracking for AI-assisted development
|
|
|
402
404
|
Planned versus delivered in a period (this month by default), with
|
|
403
405
|
the AI cost of each part: what was planned and delivered, what
|
|
404
406
|
wasn't, and what was delivered without being planned. It reads
|
|
405
|
-
GitHub issues and PRs with "gh"
|
|
406
|
-
|
|
407
|
-
it. Without
|
|
408
|
-
what git says was delivered.
|
|
407
|
+
GitHub issues and PRs with "gh" (--system github) or GitLab issues
|
|
408
|
+
and MRs with "glab" (--system gitlab); it's the only call off your
|
|
409
|
+
machine, and --offline avoids it. Without them it uses what was read
|
|
410
|
+
last time or, if never read, what git says was delivered.
|
|
409
411
|
estela delivery --by client [--project <id>] [--month YYYY-MM | --from …]
|
|
410
412
|
The period's AI spend on each client's requests versus the rest: an
|
|
411
413
|
issue or PR labelled "cliente:<name>" is that client's request, and a
|
|
@@ -2404,8 +2406,8 @@ function shareDenominators(db) {
|
|
|
2404
2406
|
* `estela delivery` — planificado frente a entregado en un periodo, con el
|
|
2405
2407
|
* coste de IA de cada parte. Ver delivery/report.ts.
|
|
2406
2408
|
*
|
|
2407
|
-
* La única llamada fuera de esta máquina de todo Estela sin cuenta: `gh
|
|
2408
|
-
* solo en un proyecto con GitHub
|
|
2409
|
+
* La única llamada fuera de esta máquina de todo Estela sin cuenta: `gh` o
|
|
2410
|
+
* `glab`, y solo en un proyecto con GitHub o GitLab como gestor. Sin ellos, o con
|
|
2409
2411
|
* --offline, se usa lo leído la última vez; si nunca se leyó, lo entregado
|
|
2410
2412
|
* según git.
|
|
2411
2413
|
*/
|
|
@@ -2453,7 +2455,7 @@ async function cmdDelivery(args, dbPath) {
|
|
|
2453
2455
|
}
|
|
2454
2456
|
}
|
|
2455
2457
|
/**
|
|
2456
|
-
* Las issues y los PRs de un proyecto: de GitHub con gh si se puede; si no, lo
|
|
2458
|
+
* Las issues y los PRs de un proyecto: de GitHub con gh o de GitLab con glab si se puede; si no, lo
|
|
2457
2459
|
* guardado la última vez; y si nunca se leyó, lo entregado según git.
|
|
2458
2460
|
*/
|
|
2459
2461
|
async function loadTrackerItems(db, project, args, query) {
|
|
@@ -2461,34 +2463,37 @@ async function loadTrackerItems(db, project, args, query) {
|
|
|
2461
2463
|
let items = null;
|
|
2462
2464
|
let origin = "";
|
|
2463
2465
|
const repo = project.repoPaths.find((p) => (0, node_fs_1.existsSync)(p));
|
|
2464
|
-
const
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2466
|
+
const system = project.tracker?.system;
|
|
2467
|
+
const remote = system === "github" || system === "gitlab" ? system : null;
|
|
2468
|
+
const name = remote === "gitlab" ? "GitLab" : "GitHub";
|
|
2469
|
+
const cli = remote === "gitlab" ? "glab" : "gh";
|
|
2470
|
+
if (!remote) {
|
|
2471
|
+
notes.push((0, index_js_1.tr) `Este proyecto no tiene GitHub ni GitLab como gestor, así que no se lee el plan. Para leerlo:`);
|
|
2472
|
+
notes.push((0, index_js_1.tr) ` estela project tracker --project ${project.id} --system github|gitlab --plan-label <etiqueta>`);
|
|
2468
2473
|
}
|
|
2469
2474
|
else if (!repo) {
|
|
2470
|
-
notes.push((0, index_js_1.tr) `La carpeta del repo de este proyecto ya no existe:
|
|
2475
|
+
notes.push((0, index_js_1.tr) `La carpeta del repo de este proyecto ya no existe: ${cli} no tiene dónde mirar.`);
|
|
2471
2476
|
}
|
|
2472
2477
|
else if ("offline" in args.flags) {
|
|
2473
|
-
notes.push((0, index_js_1.tr) `--offline: no se consulta
|
|
2478
|
+
notes.push((0, index_js_1.tr) `--offline: no se consulta ${name}.`);
|
|
2474
2479
|
}
|
|
2475
2480
|
else {
|
|
2476
|
-
const source = new github_js_1.GitHubPlanSource(repo);
|
|
2481
|
+
const source = remote === "gitlab" ? new gitlab_js_1.GitLabPlanSource(repo) : new github_js_1.GitHubPlanSource(repo);
|
|
2477
2482
|
const available = await source.availability();
|
|
2478
2483
|
if (available.ok) {
|
|
2479
2484
|
items = await source.fetch(query);
|
|
2480
2485
|
store.saveTrackerItems(db, project.id, items);
|
|
2481
|
-
origin = (0, index_js_1.tr)
|
|
2486
|
+
origin = (0, index_js_1.tr) `${name} (${cli}), leído ahora`;
|
|
2482
2487
|
}
|
|
2483
2488
|
else {
|
|
2484
|
-
notes.push(unavailableText(available));
|
|
2489
|
+
notes.push(unavailableText(available, remote));
|
|
2485
2490
|
}
|
|
2486
2491
|
}
|
|
2487
|
-
if (!items &&
|
|
2492
|
+
if (!items && remote) {
|
|
2488
2493
|
const cached = store.listTrackerItems(db, project.id);
|
|
2489
2494
|
if (cached.items.length && cached.fetchedAt) {
|
|
2490
2495
|
items = cached.items;
|
|
2491
|
-
origin = (0, index_js_1.tr)
|
|
2496
|
+
origin = (0, index_js_1.tr) `${name}, lo guardado el ${(0, shared_1.localDate)(cached.fetchedAt)} (sin conexión)`;
|
|
2492
2497
|
}
|
|
2493
2498
|
}
|
|
2494
2499
|
if (items)
|
|
@@ -2500,7 +2505,7 @@ async function loadTrackerItems(db, project, args, query) {
|
|
|
2500
2505
|
* `estela summary` — el informe para finanzas: gasto, clientes, entregas y lo
|
|
2501
2506
|
* que no llegó a entregarse, en Markdown, CSV o JSON. Ver export/finance.ts.
|
|
2502
2507
|
*
|
|
2503
|
-
* No llama a nada fuera de la máquina: de GitHub usa lo que ya leyó
|
|
2508
|
+
* No llama a nada fuera de la máquina: de GitHub o GitLab usa lo que ya leyó
|
|
2504
2509
|
* `estela delivery`, y si no hay nada, lo que dice git.
|
|
2505
2510
|
*/
|
|
2506
2511
|
async function cmdSummary(args, dbPath) {
|
|
@@ -2595,13 +2600,15 @@ async function cmdSummary(args, dbPath) {
|
|
|
2595
2600
|
for (const p of projects)
|
|
2596
2601
|
if ((entriesOf.get(p.id) ?? []).some((e) => e.agents.includes("copilot")))
|
|
2597
2602
|
unpriced = true;
|
|
2598
|
-
// Entregas y peticiones de clientes: lo guardado de GitHub, o lo que dice git.
|
|
2603
|
+
// Entregas y peticiones de clientes: lo guardado de GitHub o GitLab, o lo que dice git.
|
|
2599
2604
|
const deliveries = [];
|
|
2600
2605
|
const merges = store.listMerges(db);
|
|
2601
2606
|
for (const p of projects) {
|
|
2602
2607
|
const project = store.getProject(db, p.id);
|
|
2603
2608
|
const cached = store.listTrackerItems(db, p.id);
|
|
2609
|
+
// Lo guardado es de un gestor u otro según sus claves (github:12, gitlab:12).
|
|
2604
2610
|
const fromGitHub = cached.items.length > 0;
|
|
2611
|
+
const tracker = cached.items[0]?.key.startsWith("gitlab:") ? "gitlab" : "github";
|
|
2605
2612
|
const items = fromGitHub ? cached.items : deliveredFromGit(db, project.repoPaths);
|
|
2606
2613
|
const planLabel = fromGitHub ? project.tracker?.planLabel : undefined;
|
|
2607
2614
|
const report = (0, report_js_1.deliveryReport)({ items, plan: planLabel ? { label: planLabel } : null, from, to,
|
|
@@ -2626,7 +2633,7 @@ async function cmdSummary(args, dbPath) {
|
|
|
2626
2633
|
if (!lines.length && !report.planned)
|
|
2627
2634
|
continue;
|
|
2628
2635
|
deliveries.push({
|
|
2629
|
-
projectName: p.name, clientName: p.clientName, source: fromGitHub ?
|
|
2636
|
+
projectName: p.name, clientName: p.clientName, source: fromGitHub ? tracker : "git",
|
|
2630
2637
|
fetchedAt: cached.fetchedAt, delivered: lines.map((l) => ({ label: itemLabel(l.item), cost: l })),
|
|
2631
2638
|
plan: report.planned && planLabel ? {
|
|
2632
2639
|
label: planLabel, delivered: report.planned.delivered.lines.length,
|
|
@@ -2697,7 +2704,7 @@ async function deliveryByClient(db, args, project, title, from, to) {
|
|
|
2697
2704
|
const prefix = project.tracker?.clientLabel ?? DEFAULT_CLIENT_LABEL;
|
|
2698
2705
|
const { items, origin, notes, fromGit } = await loadTrackerItems(db, project, args, { from, to, plan: null, clientLabelPrefix: prefix });
|
|
2699
2706
|
if (fromGit)
|
|
2700
|
-
notes.push((0, index_js_1.tr) `Sin GitHub no hay etiquetas: no se sabe qué pidió cada cliente, y todo va al resto.`);
|
|
2707
|
+
notes.push((0, index_js_1.tr) `Sin GitHub ni GitLab no hay etiquetas: no se sabe qué pidió cada cliente, y todo va al resto.`);
|
|
2701
2708
|
const requesterOf = (name) => {
|
|
2702
2709
|
const clientId = store.clientForLabel(db, name);
|
|
2703
2710
|
const c = clientId ? store.getClient(db, clientId) : null;
|
|
@@ -2769,7 +2776,14 @@ function deliveryPeriod(args) {
|
|
|
2769
2776
|
throw new UserError((0, index_js_1.tr) `Con --to hace falta --from.`);
|
|
2770
2777
|
return { from: `${today.slice(0, 7)}-01`, to: `${today.slice(0, 7)}-31` };
|
|
2771
2778
|
}
|
|
2772
|
-
function unavailableText(a) {
|
|
2779
|
+
function unavailableText(a, system) {
|
|
2780
|
+
if (system === "gitlab") {
|
|
2781
|
+
switch (a.reason) {
|
|
2782
|
+
case "missing": return (0, index_js_1.tr) `glab no está instalado: sin él no se puede leer el plan de GitLab. Instálalo desde https://gitlab.com/gitlab-org/cli y ejecuta: glab auth login`;
|
|
2783
|
+
case "auth": return (0, index_js_1.tr) `glab no tiene sesión iniciada. Ejecuta: glab auth login`;
|
|
2784
|
+
case "repo": return (0, index_js_1.tr) `glab no reconoce un proyecto de GitLab en la carpeta del proyecto (${a.detail}).`;
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2773
2787
|
switch (a.reason) {
|
|
2774
2788
|
case "missing": return (0, index_js_1.tr) `gh no está instalado: sin él no se puede leer el plan de GitHub. Instálalo desde https://cli.github.com y ejecuta: gh auth login`;
|
|
2775
2789
|
case "auth": return (0, index_js_1.tr) `gh no tiene sesión iniciada. Ejecuta: gh auth login`;
|
|
@@ -2777,20 +2791,20 @@ function unavailableText(a) {
|
|
|
2777
2791
|
}
|
|
2778
2792
|
}
|
|
2779
2793
|
/**
|
|
2780
|
-
* Lo entregado que se sabe sin GitHub: los PR
|
|
2781
|
-
* mensaje dice el número y la rama) y los squash
|
|
2782
|
-
* plan: eso solo lo sabe el gestor.
|
|
2794
|
+
* Lo entregado que se sabe sin GitHub ni GitLab: los PR y las MR fusionados
|
|
2795
|
+
* con merge (su mensaje dice el número y la rama) y los squash de GitHub con
|
|
2796
|
+
* "(#N)" al final. No hay plan: eso solo lo sabe el gestor.
|
|
2783
2797
|
*/
|
|
2784
2798
|
function deliveredFromGit(db, repoPaths) {
|
|
2785
2799
|
const inProject = (path) => repoPaths.some((r) => (0, paths_js_1.isSameOrInside)(path, r) || (0, paths_js_1.isSameOrInside)(r, path));
|
|
2786
2800
|
const items = new Map();
|
|
2787
2801
|
const pr = (key, title, at, branch) => ({
|
|
2788
|
-
key, kind: "pr", number: Number(key.split(":")[1]), title, state: "merged", completed: true,
|
|
2802
|
+
key, kind: "pr", number: Number(key.split(":")[1].replace("!", "")), title, state: "merged", completed: true,
|
|
2789
2803
|
closedAt: at, branch, closes: [], labels: [], milestone: null, url: "",
|
|
2790
2804
|
});
|
|
2791
2805
|
for (const m of store.listMerges(db)) {
|
|
2792
2806
|
// El mensaje del merge solo repite el número: la rama dice más.
|
|
2793
|
-
if (m.ref
|
|
2807
|
+
if (m.ref && /^(github|gitlab):/.test(m.ref) && inProject(m.repoPath)) {
|
|
2794
2808
|
items.set(m.ref, pr(m.ref, m.sourceBranch ?? m.subject, m.at, m.sourceBranch));
|
|
2795
2809
|
}
|
|
2796
2810
|
}
|
|
@@ -2811,7 +2825,15 @@ function planText(plan) {
|
|
|
2811
2825
|
return plan.label ? (0, index_js_1.tr) `etiqueta "${plan.label}"` : (0, index_js_1.tr) `milestone "${plan.milestone}"`;
|
|
2812
2826
|
}
|
|
2813
2827
|
function itemLabel(i) {
|
|
2814
|
-
return `${i.kind === "pr" ? (
|
|
2828
|
+
return `${i.kind === "pr" ? prLabel(i) : `#${i.number}`} ${i.title}`;
|
|
2829
|
+
}
|
|
2830
|
+
/** Una clave de merge: `PR #10` en GitHub, `MR !10` en GitLab. */
|
|
2831
|
+
function refLabel(key) {
|
|
2832
|
+
return key.startsWith("gitlab:!") ? (0, index_js_1.tr) `MR ${(0, shared_1.formatWorkItem)(key)}` : (0, index_js_1.tr) `PR ${(0, shared_1.formatWorkItem)(key)}`;
|
|
2833
|
+
}
|
|
2834
|
+
/** `PR #10` en GitHub, `MR !10` en GitLab. */
|
|
2835
|
+
function prLabel(i) {
|
|
2836
|
+
return i.key.startsWith("gitlab:") ? (0, index_js_1.tr) `MR !${i.number}` : (0, index_js_1.tr) `PR #${i.number}`;
|
|
2815
2837
|
}
|
|
2816
2838
|
function printDelivery(r, v) {
|
|
2817
2839
|
console.log((0, index_js_1.tr) `Planificado frente a entregado · ${v.title} · ${v.from} → ${v.to}`);
|
|
@@ -2839,7 +2861,7 @@ function printDelivery(r, v) {
|
|
|
2839
2861
|
detail.push(l.item.state === "open" ? (0, index_js_1.tr) `abierto` : (0, index_js_1.tr) `cerrado sin fusionar`);
|
|
2840
2862
|
}
|
|
2841
2863
|
else if (l.deliveredBy.length) {
|
|
2842
|
-
detail.push((0, index_js_1.tr) `entregada por ${l.deliveredBy.map(
|
|
2864
|
+
detail.push((0, index_js_1.tr) `entregada por ${l.deliveredBy.map(prLabel).join(", ")}`);
|
|
2843
2865
|
}
|
|
2844
2866
|
else if (l.item.completed && when) {
|
|
2845
2867
|
detail.push((0, index_js_1.tr) `cerrada ${when}`);
|
|
@@ -2995,7 +3017,7 @@ async function collectUndelivered(db, projects, staleDays, from, to) {
|
|
|
2995
3017
|
merged.add(m.sourceBranch);
|
|
2996
3018
|
facts.set(root, { merged, exists: await (0, git_js_1.branchNames)(root) });
|
|
2997
3019
|
}
|
|
2998
|
-
// Lo leído de GitHub con estela delivery: la rama de un PR fusionado está
|
|
3020
|
+
// Lo leído de GitHub o GitLab con estela delivery: la rama de un PR fusionado está
|
|
2999
3021
|
// entregada, aunque fuera con squash y git no lo sepa.
|
|
3000
3022
|
for (const p of projects) {
|
|
3001
3023
|
const prRoots = new Set(turns.filter((t) => t.projectId === p.id).map((t) => t.root));
|
|
@@ -3139,7 +3161,7 @@ function unitLabel(u, by) {
|
|
|
3139
3161
|
if (by === "branch")
|
|
3140
3162
|
return u.key;
|
|
3141
3163
|
const fromMerge = u.basis.length === 1 && u.basis[0] === "merge";
|
|
3142
|
-
return u.items.map((i) => (fromMerge ? (
|
|
3164
|
+
return u.items.map((i) => (fromMerge ? refLabel(i) : (0, shared_1.formatWorkItem)(i))).join(", ");
|
|
3143
3165
|
}
|
|
3144
3166
|
function printSpend(reports, by, args, hasSubs) {
|
|
3145
3167
|
const title = by === "branch" ? (0, index_js_1.tr) `Coste de IA por rama` : (0, index_js_1.tr) `Coste de IA por ticket o PR`;
|
|
@@ -3164,7 +3186,7 @@ function printSpend(reports, by, args, hasSubs) {
|
|
|
3164
3186
|
if (u.items.length)
|
|
3165
3187
|
detail.push(u.items.map(shared_1.formatWorkItem).join(", "));
|
|
3166
3188
|
if (u.prs.length)
|
|
3167
|
-
detail.push(u.prs.map(
|
|
3189
|
+
detail.push(u.prs.map(refLabel).join(", "));
|
|
3168
3190
|
}
|
|
3169
3191
|
else if (u.branches.length) {
|
|
3170
3192
|
detail.push((0, index_js_1.tr) `ramas: ${u.branches.join(", ")}`);
|
package/dist/db/schema.js
CHANGED
|
@@ -161,7 +161,7 @@ CREATE TABLE IF NOT EXISTS merges (
|
|
|
161
161
|
at TEXT NOT NULL,
|
|
162
162
|
-- Rama integrada, tal como la escribe el mensaje. NULL si no lo dice.
|
|
163
163
|
source_branch TEXT,
|
|
164
|
-
-- PR o merge request (github:12, gitlab
|
|
164
|
+
-- PR o merge request (github:12, gitlab:!45). NULL en un merge local.
|
|
165
165
|
ref TEXT,
|
|
166
166
|
subject TEXT NOT NULL,
|
|
167
167
|
PRIMARY KEY (repo_path, hash)
|
|
@@ -337,6 +337,16 @@ CREATE TABLE IF NOT EXISTS claude_plans (
|
|
|
337
337
|
* empezar no es una opción de mantenimiento.
|
|
338
338
|
*/
|
|
339
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
|
+
},
|
|
340
350
|
{
|
|
341
351
|
version: 20,
|
|
342
352
|
describe: "alias de cliente para las etiquetas cliente:<nombre>",
|
|
@@ -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
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GitLabPlanSource = exports.runGlab = void 0;
|
|
4
|
+
exports.closingIssues = closingIssues;
|
|
5
|
+
const node_child_process_1 = require("node:child_process");
|
|
6
|
+
const node_util_1 = require("node:util");
|
|
7
|
+
const shared_1 = require("@estela/shared");
|
|
8
|
+
const run = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
9
|
+
const runGlab = async (args, cwd) => {
|
|
10
|
+
const { stdout } = await run("glab", [...args], { cwd, timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
11
|
+
return stdout;
|
|
12
|
+
};
|
|
13
|
+
exports.runGlab = runGlab;
|
|
14
|
+
const PER_PAGE = 100;
|
|
15
|
+
/** Tope de páginas por lista: 10.000 elementos. Más es otro tipo de proyecto. */
|
|
16
|
+
const MAX_PAGES = 100;
|
|
17
|
+
class GitLabPlanSource {
|
|
18
|
+
cwd;
|
|
19
|
+
glab;
|
|
20
|
+
system = "gitlab";
|
|
21
|
+
constructor(cwd, glab = exports.runGlab) {
|
|
22
|
+
this.cwd = cwd;
|
|
23
|
+
this.glab = glab;
|
|
24
|
+
}
|
|
25
|
+
async availability() {
|
|
26
|
+
try {
|
|
27
|
+
await this.glab(["--version"], this.cwd);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
return { ok: false, reason: "missing", detail: errorText(error) };
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
await this.glab(["auth", "status"], this.cwd);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return { ok: false, reason: "auth", detail: errorText(error) };
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
await this.glab(["api", "projects/:fullpath"], this.cwd);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
return { ok: false, reason: "repo", detail: errorText(error) };
|
|
43
|
+
}
|
|
44
|
+
return { ok: true };
|
|
45
|
+
}
|
|
46
|
+
async fetch(query) {
|
|
47
|
+
// GitLab no filtra por fecha de merge o de cierre: se piden las tocadas
|
|
48
|
+
// desde el día antes (cerrar o fusionar también es tocar) y se filtra aquí.
|
|
49
|
+
const since = new Date(new Date(`${query.from}T00:00:00Z`).getTime() - 86_400_000).toISOString();
|
|
50
|
+
const inPeriod = (i) => !!i.closedAt && (0, shared_1.localDate)(i.closedAt) >= query.from && (0, shared_1.localDate)(i.closedAt) <= query.to;
|
|
51
|
+
const lists = [
|
|
52
|
+
this.list("merge_requests", { state: "merged", updated_after: since }).then((l) => l.filter(inPeriod)),
|
|
53
|
+
this.list("issues", { state: "closed", updated_after: since }).then((l) => l.filter(inPeriod)),
|
|
54
|
+
];
|
|
55
|
+
const plan = query.plan;
|
|
56
|
+
if (plan?.label) {
|
|
57
|
+
lists.push(this.list("issues", { labels: plan.label }));
|
|
58
|
+
lists.push(this.list("merge_requests", { labels: plan.label }));
|
|
59
|
+
}
|
|
60
|
+
else if (plan?.milestone) {
|
|
61
|
+
lists.push(this.list("issues", { milestone: plan.milestone }));
|
|
62
|
+
lists.push(this.list("merge_requests", { milestone: plan.milestone }));
|
|
63
|
+
}
|
|
64
|
+
const prefix = query.clientLabelPrefix?.toLowerCase();
|
|
65
|
+
if (prefix) {
|
|
66
|
+
// Las MR en curso: una sin etiqueta que cierra la issue de un cliente
|
|
67
|
+
// también es trabajo para ese cliente.
|
|
68
|
+
lists.push(this.list("merge_requests", { state: "opened" }));
|
|
69
|
+
// GitLab no busca etiquetas por prefijo: primero cuáles existen.
|
|
70
|
+
const labels = (await this.pages("labels", {}))
|
|
71
|
+
.map((l) => l.name).filter((n) => n.toLowerCase().startsWith(prefix));
|
|
72
|
+
for (const label of labels) {
|
|
73
|
+
lists.push(this.list("issues", { labels: label }));
|
|
74
|
+
lists.push(this.list("merge_requests", { labels: label }));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Una issue o una MR puede salir en dos listas: se queda una vez.
|
|
78
|
+
const items = new Map();
|
|
79
|
+
for (const list of await Promise.all(lists))
|
|
80
|
+
for (const item of list)
|
|
81
|
+
items.set(`${item.kind}:${item.key}`, item);
|
|
82
|
+
return [...items.values()];
|
|
83
|
+
}
|
|
84
|
+
async list(kind, params) {
|
|
85
|
+
const rows = await this.pages(kind, params);
|
|
86
|
+
return rows.map((r) => (kind === "issues" ? normalizeIssue(r) : normalizeMr(r)));
|
|
87
|
+
}
|
|
88
|
+
/** Todas las páginas de `projects/:fullpath/<path>`. */
|
|
89
|
+
async pages(path, params) {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
92
|
+
const qs = new URLSearchParams({ ...params, per_page: String(PER_PAGE), page: String(page) });
|
|
93
|
+
const rows = JSON.parse((await this.glab(["api", `projects/:fullpath/${path}?${qs}`], this.cwd)) || "[]");
|
|
94
|
+
out.push(...rows);
|
|
95
|
+
if (rows.length < PER_PAGE)
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
exports.GitLabPlanSource = GitLabPlanSource;
|
|
102
|
+
const date = (s) => (s ? new Date(s) : null);
|
|
103
|
+
const labelsOf = (raw) => (raw.labels ?? []).map((l) => (typeof l === "string" ? l : l.name));
|
|
104
|
+
function normalizeMr(raw) {
|
|
105
|
+
const merged = raw.state === "merged" || !!raw.merged_at;
|
|
106
|
+
return {
|
|
107
|
+
key: `gitlab:!${raw.iid}`, kind: "pr", number: raw.iid, title: raw.title,
|
|
108
|
+
state: merged ? "merged" : raw.state === "opened" ? "open" : "closed",
|
|
109
|
+
completed: merged, closedAt: date(raw.merged_at) ?? date(raw.closed_at),
|
|
110
|
+
branch: raw.source_branch || null,
|
|
111
|
+
closes: closingIssues(raw.description ?? "").map((n) => `gitlab:${n}`),
|
|
112
|
+
labels: labelsOf(raw), milestone: raw.milestone?.title ?? null, url: raw.web_url,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function normalizeIssue(raw) {
|
|
116
|
+
const closed = raw.state === "closed";
|
|
117
|
+
return {
|
|
118
|
+
key: `gitlab:${raw.iid}`, kind: "issue", number: raw.iid, title: raw.title,
|
|
119
|
+
state: closed ? "closed" : "open",
|
|
120
|
+
// GitLab no distingue hecha de "no se hará". Movida a otro proyecto, no es de aquí.
|
|
121
|
+
completed: closed && !raw.moved_to_id,
|
|
122
|
+
closedAt: date(raw.closed_at), branch: null, closes: [],
|
|
123
|
+
labels: labelsOf(raw), milestone: raw.milestone?.title ?? null, url: raw.web_url,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Las issues de este proyecto que cierra una descripción, con el patrón por
|
|
128
|
+
* defecto de GitLab: "Closes #12", "Fixes #12, #13", "Resolves issue #12 and #14",
|
|
129
|
+
* "Implements #12". Las de otro proyecto (`grupo/otro#12`) no.
|
|
130
|
+
*/
|
|
131
|
+
function closingIssues(description) {
|
|
132
|
+
const out = new Set();
|
|
133
|
+
const verbs = /\b(?:clos(?:e[sd]?|ing)|fix(?:e[sd]|ing)?|resolv(?:e[sd]?|ing)|implement(?:s|ed|ing)?):?((?:\s*,?\s*(?:and\s+)?(?:issues?\s+)?#\d+\b)+)/gi;
|
|
134
|
+
for (const m of description.matchAll(verbs)) {
|
|
135
|
+
for (const n of m[1].matchAll(/(?<![\w/])#(\d+)/g))
|
|
136
|
+
out.add(Number(n[1]));
|
|
137
|
+
}
|
|
138
|
+
return [...out];
|
|
139
|
+
}
|
|
140
|
+
function errorText(error) {
|
|
141
|
+
const e = error;
|
|
142
|
+
if (e.code === "ENOENT")
|
|
143
|
+
return "glab no está instalado";
|
|
144
|
+
return (e.stderr || e.message || String(error)).trim().split("\n")[0] ?? "";
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=gitlab.js.map
|
package/dist/export/finance.d.ts
CHANGED
|
@@ -27,8 +27,8 @@ export interface FinanceDelivery {
|
|
|
27
27
|
export interface FinanceDeliveries {
|
|
28
28
|
readonly projectName: string;
|
|
29
29
|
readonly clientName: string;
|
|
30
|
-
/** "github": lo leído
|
|
31
|
-
readonly source: "github" | "git";
|
|
30
|
+
/** "github"/"gitlab": lo leído del gestor con `estela delivery`; "git": solo merges y squash. */
|
|
31
|
+
readonly source: "github" | "gitlab" | "git";
|
|
32
32
|
readonly fetchedAt: Date | null;
|
|
33
33
|
readonly delivered: readonly FinanceDelivery[];
|
|
34
34
|
readonly plan: {
|
package/dist/export/finance.js
CHANGED
|
@@ -83,9 +83,9 @@ function financeToMarkdown(s) {
|
|
|
83
83
|
out.push((0, index_js_1.tr) `| … y ${d.delivered.length - 15} más | | |`);
|
|
84
84
|
out.push("");
|
|
85
85
|
}
|
|
86
|
-
out.push(d.source
|
|
87
|
-
? (0, index_js_1.tr) `_Fuente: GitHub, leído el ${d.fetchedAt ? d.fetchedAt.toISOString().slice(0, 10) : "—"} con estela delivery._`
|
|
88
|
-
: (0, index_js_1.tr) `_Fuente: el historial de git (PR fusionados). Sin GitHub no se sabe qué estaba planificado._`, "");
|
|
86
|
+
out.push(d.source !== "git"
|
|
87
|
+
? (0, index_js_1.tr) `_Fuente: ${d.source === "gitlab" ? "GitLab" : "GitHub"}, leído el ${d.fetchedAt ? d.fetchedAt.toISOString().slice(0, 10) : "—"} con estela delivery._`
|
|
88
|
+
: (0, index_js_1.tr) `_Fuente: el historial de git (PR fusionados). Sin GitHub ni GitLab no se sabe qué estaba planificado._`, "");
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
const conPeticiones = s.deliveries.filter((d) => d.requests?.length);
|
|
@@ -118,7 +118,7 @@ function financeToMarkdown(s) {
|
|
|
118
118
|
: (0, index_js_1.tr) `- **Gasto:** tokens a precio de lista de cada modelo. Si se paga una cuota fija, el gasto real es la cuota: regístrala con estela subscription add.`);
|
|
119
119
|
out.push((0, index_js_1.tr) `- **Valor de uso a precio de lista:** ${(0, shared_1.formatAiCost)(s.paid.consumption)} en todo el periodo.`);
|
|
120
120
|
out.push((0, index_js_1.tr) `- **Horas:** el tiempo entre mensajes de cada sesión de trabajo, sin contar pausas de más de 30 minutos. Dos tareas a la vez se reparten el mismo rato.`);
|
|
121
|
-
out.push((0, index_js_1.tr) `- **Entregas:** las horas se unen a una issue o un PR por la rama del PR y por su número en ramas o commits. Lo que no se une a nada no se reparte.`);
|
|
121
|
+
out.push((0, index_js_1.tr) `- **Entregas:** las horas se unen a una issue o un PR (una MR en GitLab) por la rama del PR y por su número en ramas o commits. Lo que no se une a nada no se reparte.`);
|
|
122
122
|
out.push((0, index_js_1.tr) `- **Sin entregar:** sesiones sin ningún commit y ramas sin fusionar tras ${s.staleDays} días. Lo que no se puede saber (una rama borrada que pudo fusionarse con squash) no cuenta.`);
|
|
123
123
|
if (s.unpricedAgents)
|
|
124
124
|
out.push((0, index_js_1.tr) `- Copilot no registra tokens: sus horas cuentan, pero su coste no se conoce y no está sumado.`);
|
package/dist/i18n/en.js
CHANGED
|
@@ -640,6 +640,7 @@ exports.EN = {
|
|
|
640
640
|
"Sin atribuir (sin rama)": "Unattributed (no branch)",
|
|
641
641
|
"Sin atribuir (sin ticket ni PR)": "Unattributed (no ticket or PR)",
|
|
642
642
|
"PR {0}": "PR {0}",
|
|
643
|
+
"MR {0}": "MR {0}",
|
|
643
644
|
"Coste de IA por rama": "AI cost by branch",
|
|
644
645
|
"Coste de IA por ticket o PR": "AI cost by ticket or PR",
|
|
645
646
|
"\nNo hay horas en ese periodo.": "\nNo hours in that period.",
|
|
@@ -688,12 +689,16 @@ exports.EN = {
|
|
|
688
689
|
"\"{0}\" ya no tiene etiqueta de plan.": "\"{0}\" no longer has a plan label.",
|
|
689
690
|
"Falta la etiqueta: --plan-label <etiqueta> (o none para quitarla).": "Missing the label: --plan-label <label> (or none to remove it).",
|
|
690
691
|
"--label y --milestone van por separado: elige una forma de marcar el plan.": "--label and --milestone go separately: pick one way to mark the plan.",
|
|
691
|
-
"Este proyecto no tiene GitHub como gestor, así que no se lee el plan. Para leerlo:": "This project
|
|
692
|
-
" estela project tracker --project {0} --system github --plan-label <etiqueta>": " estela project tracker --project {0} --system github --plan-label <label>",
|
|
693
|
-
"La carpeta del repo de este proyecto ya no existe:
|
|
694
|
-
"--offline: no se consulta
|
|
695
|
-
"
|
|
696
|
-
"
|
|
692
|
+
"Este proyecto no tiene GitHub ni GitLab como gestor, así que no se lee el plan. Para leerlo:": "This project uses neither GitHub nor GitLab as its tracker, so the plan isn't read. To read it:",
|
|
693
|
+
" estela project tracker --project {0} --system github|gitlab --plan-label <etiqueta>": " estela project tracker --project {0} --system github|gitlab --plan-label <label>",
|
|
694
|
+
"La carpeta del repo de este proyecto ya no existe: {0} no tiene dónde mirar.": "This project's repo folder no longer exists: {0} has nowhere to look.",
|
|
695
|
+
"--offline: no se consulta {0}.": "--offline: {0} isn't queried.",
|
|
696
|
+
"{0} ({1}), leído ahora": "{0} ({1}), read just now",
|
|
697
|
+
"{0}, lo guardado el {1} (sin conexión)": "{0}, as saved on {1} (offline)",
|
|
698
|
+
"glab no está instalado: sin él no se puede leer el plan de GitLab. Instálalo desde https://gitlab.com/gitlab-org/cli y ejecuta: glab auth login": "glab isn't installed: without it the GitLab plan can't be read. Install it from https://gitlab.com/gitlab-org/cli and run: glab auth login",
|
|
699
|
+
"glab no tiene sesión iniciada. Ejecuta: glab auth login": "glab isn't logged in. Run: glab auth login",
|
|
700
|
+
"glab no reconoce un proyecto de GitLab en la carpeta del proyecto ({0}).": "glab doesn't recognise a GitLab project in the project's folder ({0}).",
|
|
701
|
+
"MR !{0}": "MR !{0}",
|
|
697
702
|
"git: los PR de los commits de merge y de los squash con (#N)": "git: the PRs from merge commits and from squashes ending in (#N)",
|
|
698
703
|
"Sin etiqueta ni milestone de plan solo se enseña lo entregado. Indícalo con --label o --milestone,": "With no plan label or milestone only what was delivered is shown. Set one with --label or --milestone,",
|
|
699
704
|
"o déjalo fijo: estela project tracker --project {0} --plan-label <etiqueta>": "or keep it: estela project tracker --project {0} --plan-label <label>",
|
|
@@ -737,7 +742,7 @@ exports.EN = {
|
|
|
737
742
|
"La etiqueta necesita al menos una letra o un número.": "The label needs at least one letter or digit.",
|
|
738
743
|
"La etiqueta \"{0}\" es {1}.": "The \"{0}\" label is {1}.",
|
|
739
744
|
"--by en delivery solo admite client.": "--by in delivery only takes client.",
|
|
740
|
-
"Sin GitHub no hay etiquetas: no se sabe qué pidió cada cliente, y todo va al resto.": "Without GitHub there are no labels: there's no telling what each client asked for, so it all goes to the rest.",
|
|
745
|
+
"Sin GitHub ni GitLab no hay etiquetas: no se sabe qué pidió cada cliente, y todo va al resto.": "Without GitHub or GitLab there are no labels: there's no telling what each client asked for, so it all goes to the rest.",
|
|
741
746
|
"Peticiones de clientes · {0} · {1} → {2}": "Client requests · {0} · {1} → {2}",
|
|
742
747
|
"Etiqueta: \"{0}<nombre>\" · Fuente: {1}": "Label: \"{0}<name>\" · Source: {1}",
|
|
743
748
|
"Estela no conoce ingresos ni contratos: esto solo une el gasto de IA con quién pidió el trabajo.": "Estela knows nothing about revenue or contracts: this only links AI spend to who asked for the work.",
|
|
@@ -771,8 +776,8 @@ exports.EN = {
|
|
|
771
776
|
"Del plan ({0}) se entregaron {1} de {2}. Lo pendiente lleva {3} de gasto; lo entregado fuera del plan, {4}.": "Of the plan ({0}), {1} of {2} were delivered. What's pending has {3} of spend; what was delivered outside the plan, {4}.",
|
|
772
777
|
"| Entrega | Horas | Gasto |": "| Delivery | Hours | Spend |",
|
|
773
778
|
"| … y {0} más | | |": "| … and {0} more | | |",
|
|
774
|
-
"_Fuente:
|
|
775
|
-
"_Fuente: el historial de git (PR fusionados). Sin GitHub no se sabe qué estaba planificado._": "_Source: the git history (merged PRs). Without GitHub there's no telling what was planned._",
|
|
779
|
+
"_Fuente: {0}, leído el {1} con estela delivery._": "_Source: {0}, read on {1} with estela delivery._",
|
|
780
|
+
"_Fuente: el historial de git (PR fusionados). Sin GitHub ni GitLab no se sabe qué estaba planificado._": "_Source: the git history (merged PRs). Without GitHub or GitLab there's no telling what was planned._",
|
|
776
781
|
"## Trabajo pedido por clientes": "## Work requested by clients",
|
|
777
782
|
"Qué parte del gasto fue a peticiones concretas de un cliente. No incluye ingresos ni contratos: Estela no los conoce.": "How much of the spend went to a client's specific requests. It includes no revenue or contracts: Estela doesn't know them.",
|
|
778
783
|
"| Proyecto | Quién lo pidió | Horas | Gasto |": "| Project | Requested by | Hours | Spend |",
|
|
@@ -787,7 +792,7 @@ exports.EN = {
|
|
|
787
792
|
"- **Gasto:** tokens a precio de lista de cada modelo. Si se paga una cuota fija, el gasto real es la cuota: regístrala con estela subscription add.": "- **Spend:** tokens at each model's list price. If a flat fee is paid, the real spend is the fee: record it with estela subscription add.",
|
|
788
793
|
"- **Valor de uso a precio de lista:** {0} en todo el periodo.": "- **Usage value at list price:** {0} for the whole period.",
|
|
789
794
|
"- **Horas:** el tiempo entre mensajes de cada sesión de trabajo, sin contar pausas de más de 30 minutos. Dos tareas a la vez se reparten el mismo rato.": "- **Hours:** the time between messages in each work session, leaving out breaks over 30 minutes. Two tasks at once share the same stretch of time.",
|
|
790
|
-
"- **Entregas:** las horas se unen a una issue o un PR por la rama del PR y por su número en ramas o commits. Lo que no se une a nada no se reparte.": "- **Deliveries:** hours are linked to an issue or PR by the PR's branch and by its number in branches or commits. Whatever links to nothing isn't spread around.",
|
|
795
|
+
"- **Entregas:** las horas se unen a una issue o un PR (una MR en GitLab) por la rama del PR y por su número en ramas o commits. Lo que no se une a nada no se reparte.": "- **Deliveries:** hours are linked to an issue or PR (an MR on GitLab) by the PR's branch and by its number in branches or commits. Whatever links to nothing isn't spread around.",
|
|
791
796
|
"- **Sin entregar:** sesiones sin ningún commit y ramas sin fusionar tras {0} días. Lo que no se puede saber (una rama borrada que pudo fusionarse con squash) no cuenta.": "- **Not delivered:** sessions with no commit and branches unmerged after {0} days. What can't be known (a deleted branch that may have been squash-merged) isn't counted.",
|
|
792
797
|
"- Copilot no registra tokens: sus horas cuentan, pero su coste no se conoce y no está sumado.": "- Copilot doesn't record tokens: its hours count, but its cost is unknown and not added.",
|
|
793
798
|
"- † Algún mes no tenía cuota registrada: el importe solo cuenta los meses con cuota.": "- † Some month had no subscription recorded: the amount only counts the months with a fee.",
|
package/dist/server.js
CHANGED
|
@@ -758,10 +758,20 @@ function buildSummary(db, from, to) {
|
|
|
758
758
|
SUM(approved) AS approved,
|
|
759
759
|
MIN(local_date) AS first_day,
|
|
760
760
|
MAX(local_date) AS last_day,
|
|
761
|
-
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
|
|
762
765
|
FROM time_entries
|
|
763
766
|
WHERE local_date >= ? AND local_date <= ? AND ${VISIBLE}
|
|
764
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
|
|
765
775
|
`).all(from, to);
|
|
766
776
|
const projects = new Map(store.listProjects(db).map((p) => [p.id, p]));
|
|
767
777
|
const clients = new Map(store.listClients(db).map((c) => [c.id, c]));
|
|
@@ -784,6 +794,15 @@ function buildSummary(db, from, to) {
|
|
|
784
794
|
aiMicroUsd: r.micro,
|
|
785
795
|
amountMinor: rate ? Math.round((rate.amount * r.seconds) / 3600) : null,
|
|
786
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,
|
|
787
806
|
};
|
|
788
807
|
}).sort((a, b) => b.seconds - a.seconds);
|
|
789
808
|
// Actividad reciente entre todos los proyectos, para ver el pulso del conjunto.
|
package/dist/watchers/git.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface MergeRecord {
|
|
|
10
10
|
readonly hash: string;
|
|
11
11
|
readonly at: Date;
|
|
12
12
|
readonly sourceBranch: string | null;
|
|
13
|
-
/** `github:12` o `gitlab
|
|
13
|
+
/** `github:12` o `gitlab:!45` (las MR de GitLab llevan `!`). `null` en un merge local sin PR. */
|
|
14
14
|
readonly ref: string | null;
|
|
15
15
|
readonly subject: string;
|
|
16
16
|
}
|
package/dist/watchers/git.js
CHANGED
|
@@ -163,7 +163,7 @@ function parseMergeMessage(subject, body = "") {
|
|
|
163
163
|
if (branch) {
|
|
164
164
|
const gitlab = /See merge request \S*!(\d+)/.exec(body);
|
|
165
165
|
const name = branch[1].replace(/^origin\//, "");
|
|
166
|
-
return { sourceBranch: name, ref: gitlab ? `gitlab
|
|
166
|
+
return { sourceBranch: name, ref: gitlab ? `gitlab:!${gitlab[1]}` : null };
|
|
167
167
|
}
|
|
168
168
|
return { sourceBranch: null, ref: null };
|
|
169
169
|
}
|
|
@@ -42,11 +42,12 @@ export interface WorkItemTracker {
|
|
|
42
42
|
* el gestor no se sabe de quién es.
|
|
43
43
|
*/
|
|
44
44
|
export declare function extractWorkItems(branch: string | null, subjects: readonly string[], tracker?: WorkItemTracker | null): string[];
|
|
45
|
-
/** Cómo se enseña una clave: `PROJ-12`, `AB#1234`, `#12
|
|
45
|
+
/** Cómo se enseña una clave: `PROJ-12`, `AB#1234`, `#12`, `!45` (merge request de GitLab). */
|
|
46
46
|
export declare function formatWorkItem(key: string): string;
|
|
47
47
|
export declare function splitWorkItem(key: string): [TrackerSystem, string];
|
|
48
48
|
/**
|
|
49
|
-
* Lo que escribe alguien a mano (`PROJ-12`, `AB#1234`, `#12`, `1234
|
|
49
|
+
* Lo que escribe alguien a mano (`PROJ-12`, `AB#1234`, `#12`, `1234`, y `!45`
|
|
50
|
+
* para una merge request de GitLab) a clave
|
|
50
51
|
* normalizada, con el gestor del proyecto para lo ambiguo. `null` si no se
|
|
51
52
|
* puede saber de qué gestor es.
|
|
52
53
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workitems.d.ts","sourceRoot":"","sources":["../src/workitems.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEnE,eAAO,MAAM,eAAe,EAAE,SAAS,aAAa,EAA0C,CAAC;AAE/F,2DAA2D;AAC3D,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAWD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,QAAQ,EAAE,SAAS,MAAM,EAAE,EAC3B,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,GAC/B,MAAM,EAAE,CAqBV;AA0CD,
|
|
1
|
+
{"version":3,"file":"workitems.d.ts","sourceRoot":"","sources":["../src/workitems.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEnE,eAAO,MAAM,eAAe,EAAE,SAAS,aAAa,EAA0C,CAAC;AAE/F,2DAA2D;AAC3D,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAWD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,QAAQ,EAAE,SAAS,MAAM,EAAE,EAC3B,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,GAC/B,MAAM,EAAE,CAqBV;AA0CD,8FAA8F;AAC9F,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAMlD;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,aAAa,EAAE,MAAM,CAAC,CAGlE;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAW3F"}
|
|
@@ -103,11 +103,13 @@ function branchNumber(branch) {
|
|
|
103
103
|
return null;
|
|
104
104
|
return m[1].replace(/^0+/, "") || null;
|
|
105
105
|
}
|
|
106
|
-
/** Cómo se enseña una clave: `PROJ-12`, `AB#1234`, `#12
|
|
106
|
+
/** Cómo se enseña una clave: `PROJ-12`, `AB#1234`, `#12`, `!45` (merge request de GitLab). */
|
|
107
107
|
function formatWorkItem(key) {
|
|
108
108
|
const [system, id] = splitWorkItem(key);
|
|
109
109
|
if (system === "azure")
|
|
110
110
|
return `AB#${id}`;
|
|
111
|
+
if (system === "gitlab" && id.startsWith("!"))
|
|
112
|
+
return id;
|
|
111
113
|
if (system === "github" || system === "gitlab")
|
|
112
114
|
return `#${id}`;
|
|
113
115
|
return id;
|
|
@@ -117,7 +119,8 @@ function splitWorkItem(key) {
|
|
|
117
119
|
return [key.slice(0, i), key.slice(i + 1)];
|
|
118
120
|
}
|
|
119
121
|
/**
|
|
120
|
-
* Lo que escribe alguien a mano (`PROJ-12`, `AB#1234`, `#12`, `1234
|
|
122
|
+
* Lo que escribe alguien a mano (`PROJ-12`, `AB#1234`, `#12`, `1234`, y `!45`
|
|
123
|
+
* para una merge request de GitLab) a clave
|
|
121
124
|
* normalizada, con el gestor del proyecto para lo ambiguo. `null` si no se
|
|
122
125
|
* puede saber de qué gestor es.
|
|
123
126
|
*/
|
|
@@ -129,6 +132,9 @@ function parseWorkItem(text, tracker) {
|
|
|
129
132
|
m = /^([A-Za-z][A-Za-z0-9]+)-(\d+)$/.exec(t);
|
|
130
133
|
if (m)
|
|
131
134
|
return `jira:${m[1].toUpperCase()}-${m[2]}`;
|
|
135
|
+
m = /^!(\d+)$/.exec(t);
|
|
136
|
+
if (m)
|
|
137
|
+
return tracker?.system === "gitlab" ? `gitlab:!${m[1]}` : null;
|
|
132
138
|
m = /^#?(\d+)$/.exec(t);
|
|
133
139
|
if (m && tracker && tracker.system !== "jira")
|
|
134
140
|
return `${tracker.system}:${m[1]}`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workitems.js","sourceRoot":"","sources":["../src/workitems.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;GAWG;;;AA6CH,4CAyBC;AA2CD,
|
|
1
|
+
{"version":3,"file":"workitems.js","sourceRoot":"","sources":["../src/workitems.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;GAWG;;;AA6CH,4CAyBC;AA2CD,wCAMC;AAED,sCAGC;AAQD,sCAWC;AA3IY,QAAA,eAAe,GAA6B,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC;AAuB/F,0EAA0E;AAC1E,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC;IACvB,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK;IACpE,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO;CACnE,CAAC,CAAC;AAEH,wEAAwE;AACxE,MAAM,kBAAkB,GAAG,IAAI,GAAG,CAAC,CAAC,SAAS,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,SAAS,CAAC,CAAC,CAAC;AAE3F;;;;;;;;GAQG;AACH,SAAgB,gBAAgB,CAC9B,MAAqB,EACrB,QAA2B,EAC3B,OAAgC;IAEhC,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,MAAM,GAAG,GAAG,CAAC,GAAW,EAAE,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAE5E,MAAM,IAAI,GAAG,MAAM,IAAI,EAAE,CAAC;IAC1B,KAAK,MAAM,GAAG,IAAI,SAAS,CAAC,IAAI,CAAC;QAAE,GAAG,CAAC,GAAG,CAAC,CAAC;IAC5C,KAAK,MAAM,GAAG,IAAI,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;QAAE,GAAG,CAAC,GAAG,CAAC,CAAC;IACpD,IAAI,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;QACzC,MAAM,CAAC,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;QAC7B,IAAI,CAAC;YAAE,GAAG,CAAC,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC,CAAC;IACvC,CAAC;IAED,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,KAAK,MAAM,GAAG,IAAI,SAAS,CAAC,OAAO,CAAC;YAAE,GAAG,CAAC,GAAG,CAAC,CAAC;QAC/C,IAAI,OAAO,EAAE,MAAM,KAAK,MAAM;YAAE,KAAK,MAAM,GAAG,IAAI,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;gBAAE,GAAG,CAAC,GAAG,CAAC,CAAC;QACvF,IAAI,OAAO,EAAE,MAAM,KAAK,QAAQ,IAAI,OAAO,EAAE,MAAM,KAAK,QAAQ,EAAE,CAAC;YACjE,mEAAmE;YACnE,KAAK,MAAM,CAAC,IAAI,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAC;gBAAE,GAAG,CAAC,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAC7F,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,SAAS,CAAC,IAAY;IAC7B,OAAO,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;AAC1E,CAAC;AAED,SAAS,QAAQ,CAAC,IAAY,EAAE,OAAgC;IAC9D,IAAI,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM;QAAE,OAAO,EAAE,CAAC;IACpD,MAAM,QAAQ,GAAG,OAAO,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,CAAC;IAErE,IAAI,QAAQ,CAAC,MAAM,EAAE,CAAC;QACpB,0EAA0E;QAC1E,MAAM,YAAY,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAChG,IAAI,CAAC,YAAY;YAAE,OAAO,EAAE,CAAC;QAC7B,MAAM,EAAE,GAAG,IAAI,MAAM,CAAC,oBAAoB,YAAY,yBAAyB,EAAE,IAAI,CAAC,CAAC;QACvF,OAAO,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAE,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IAClF,CAAC;IAED,OAAO,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,wDAAwD,CAAC,CAAC;SAChF,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAE,CAAC,CAAC;SACnC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;AACxC,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,YAAY,CAAC,MAAc;IAClC,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACjD,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IACzC,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IACzB,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1F,MAAM,CAAC,GAAG,sBAAsB,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAC9C,IAAI,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IACpB,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IACxD,OAAO,CAAC,CAAC,CAAC,CAAE,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,IAAI,CAAC;AAC1C,CAAC;AAED,8FAA8F;AAC9F,SAAgB,cAAc,CAAC,GAAW;IACxC,MAAM,CAAC,MAAM,EAAE,EAAE,CAAC,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;IACxC,IAAI,MAAM,KAAK,OAAO;QAAE,OAAO,MAAM,EAAE,EAAE,CAAC;IAC1C,IAAI,MAAM,KAAK,QAAQ,IAAI,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC;IACzD,IAAI,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,QAAQ;QAAE,OAAO,IAAI,EAAE,EAAE,CAAC;IAChE,OAAO,EAAE,CAAC;AACZ,CAAC;AAED,SAAgB,aAAa,CAAC,GAAW;IACvC,MAAM,CAAC,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAC3B,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAkB,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;GAKG;AACH,SAAgB,aAAa,CAAC,IAAY,EAAE,OAAgC;IAC1E,MAAM,CAAC,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;IACtB,IAAI,CAAC,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC9B,IAAI,CAAC;QAAE,OAAO,SAAS,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IAC9B,CAAC,GAAG,gCAAgC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC7C,IAAI,CAAC;QAAE,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAE,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACpD,CAAC,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACvB,IAAI,CAAC;QAAE,OAAO,OAAO,EAAE,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;IACtE,CAAC,GAAG,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACxB,IAAI,CAAC,IAAI,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM;QAAE,OAAO,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IAClF,OAAO,IAAI,CAAC;AACd,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "estela",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Time tracking you never fill in: rebuilds your billable hours from Claude Code, Codex and GitHub Copilot sessions and your Git history, and splits what the AI cost per project.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"time-tracking",
|
package/web/app.css
CHANGED
|
@@ -433,6 +433,11 @@ main.wide { max-width: 1180px; }
|
|
|
433
433
|
font-size: .83rem; color: var(--ink-3); margin-top: 3px;
|
|
434
434
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
435
435
|
}
|
|
436
|
+
/* Lo facturado y lo pendiente del periodo: debajo, en pequeño, porque es la
|
|
437
|
+
explicación del total y no otro total. */
|
|
438
|
+
.prow-split {
|
|
439
|
+
font-size: .8rem; color: var(--ink-3); margin-top: 4px; font-variant-numeric: tabular-nums;
|
|
440
|
+
}
|
|
436
441
|
.prow-figs { text-align: right; flex: none; }
|
|
437
442
|
.prow-hours {
|
|
438
443
|
font-size: 1.05rem; font-weight: 580; font-variant-numeric: tabular-nums;
|
package/web/app.js
CHANGED
|
@@ -11,7 +11,11 @@ const state = {
|
|
|
11
11
|
date: localToday(),
|
|
12
12
|
day: null,
|
|
13
13
|
summary: null,
|
|
14
|
+
// Cada pantalla con su periodo: compartirlo hacía que "Todo lo pendiente"
|
|
15
|
+
// de Informes cambiara en silencio el Resumen a 3 meses, con "Este mes"
|
|
16
|
+
// todavía marcado.
|
|
14
17
|
period: "month",
|
|
18
|
+
reportsPeriod: "month",
|
|
15
19
|
open: new Set(),
|
|
16
20
|
};
|
|
17
21
|
|
|
@@ -427,7 +431,7 @@ function haceCuanto(days) {
|
|
|
427
431
|
// ── Resumen: todos los proyectos a la vez ──────────────────────────────
|
|
428
432
|
|
|
429
433
|
async function loadSummary() {
|
|
430
|
-
const { from, to } = periodRange(state.period
|
|
434
|
+
const { from, to } = periodRange(state.period);
|
|
431
435
|
// La cuota es siempre de esta semana, sea cual sea el periodo elegido: si
|
|
432
436
|
// fallara, el resumen se pinta igual y la tarjeta simplemente no sale.
|
|
433
437
|
const [summary, quota] = await Promise.all([
|
|
@@ -441,7 +445,10 @@ async function loadSummary() {
|
|
|
441
445
|
|
|
442
446
|
function renderSummary() {
|
|
443
447
|
const s = state.summary;
|
|
448
|
+
// Las fechas, siempre: un total sin su periodo al lado se compara con otro
|
|
449
|
+
// que tiene uno distinto y parece que no cuadra.
|
|
444
450
|
$("#summary-range").textContent =
|
|
451
|
+
`${tr("summary.range", { from: s.from, to: s.to })} · ` +
|
|
445
452
|
`${tr("summary.projects", { n: s.activeProjects })} · ${tr("summary.days", { n: s.activeDays })}`;
|
|
446
453
|
|
|
447
454
|
// Con varias monedas se enseñan todas: un guion parecería que falta un dato.
|
|
@@ -490,7 +497,7 @@ function renderSummary() {
|
|
|
490
497
|
$("#summary-body").innerHTML = parts.join("");
|
|
491
498
|
|
|
492
499
|
$("#approve-all")?.addEventListener("click", async () => {
|
|
493
|
-
const { from, to } = periodRange(state.period
|
|
500
|
+
const { from, to } = periodRange(state.period);
|
|
494
501
|
try {
|
|
495
502
|
const r = await api("/api/approve-range", {
|
|
496
503
|
method: "POST", body: JSON.stringify({ from, to }),
|
|
@@ -646,6 +653,7 @@ function renderProjectRow(p) {
|
|
|
646
653
|
<div class="prow-name">${esc(p.projectName)} ${flag}${notBillable}</div>
|
|
647
654
|
<div class="prow-meta">${esc(p.clientName)}${rate ? ` · ${rate}/h` : ""} ·
|
|
648
655
|
${tr("project.days", { n: p.days })} · ${tr("project.lastSeen", { date: esc(p.lastDay) })}</div>
|
|
656
|
+
${billingSplit(p)}
|
|
649
657
|
</div>
|
|
650
658
|
<div class="prow-figs">
|
|
651
659
|
<div class="prow-hours">${fmtDuration(p.seconds)}</div>
|
|
@@ -654,6 +662,29 @@ function renderProjectRow(p) {
|
|
|
654
662
|
</div>`;
|
|
655
663
|
}
|
|
656
664
|
|
|
665
|
+
/* De lo trabajado en el periodo, qué ya está en una factura y qué falta por
|
|
666
|
+
facturar. Lo pendiente es la misma cifra que da Informes para esas fechas:
|
|
667
|
+
sin esta línea, el total del Resumen (que incluye lo facturado) y lo
|
|
668
|
+
pendiente de Informes parecen dos cuentas que no cuadran. Solo sale cuando
|
|
669
|
+
hay mezcla; si todo está pendiente, el total ya lo dice. */
|
|
670
|
+
function billingSplit(p) {
|
|
671
|
+
if (p.kind !== "client") return "";
|
|
672
|
+
if (!p.invoicedSeconds && !p.notBillableSeconds) return "";
|
|
673
|
+
const parts = [];
|
|
674
|
+
if (p.invoicedSeconds) {
|
|
675
|
+
parts.push(tr("project.split.invoiced", { duration: fmtDuration(p.invoicedSeconds),
|
|
676
|
+
invoices: p.invoices?.length ? ` (${p.invoices.join(", ")})` : "" }));
|
|
677
|
+
}
|
|
678
|
+
if (p.unbilledSeconds) {
|
|
679
|
+
const money = p.unbilledMinor === null ? "" : ` · ${fmtMoney(p.unbilledMinor, p.currency)}`;
|
|
680
|
+
parts.push(tr("project.split.pending", { duration: fmtDuration(p.unbilledSeconds), amount: money }));
|
|
681
|
+
} else {
|
|
682
|
+
parts.push(tr("project.split.nothingPending"));
|
|
683
|
+
}
|
|
684
|
+
if (p.notBillableSeconds) parts.push(tr("project.split.notBillable", { duration: fmtDuration(p.notBillableSeconds) }));
|
|
685
|
+
return `<div class="prow-split">${esc(parts.join(" · "))}</div>`;
|
|
686
|
+
}
|
|
687
|
+
|
|
657
688
|
function renderActivity(c) {
|
|
658
689
|
const when = new Date(c.at).toLocaleDateString(localeTag(), { day: "numeric", month: "short" });
|
|
659
690
|
return `
|
|
@@ -1409,7 +1440,7 @@ state.reportsOpen = new Set();
|
|
|
1409
1440
|
async function loadReports() {
|
|
1410
1441
|
// Mismo periodo que Resumen: si las dos pantallas contestan a la misma
|
|
1411
1442
|
// pregunta, tienen que dar el mismo número.
|
|
1412
|
-
const { from, to } = periodRange(state.
|
|
1443
|
+
const { from, to } = periodRange(state.reportsPeriod);
|
|
1413
1444
|
const [overview, facturado] = await Promise.all([
|
|
1414
1445
|
api(`/api/overview?from=${from}&to=${to}`),
|
|
1415
1446
|
// Lo emitido no depende del periodo elegido: una factura de agosto sigue
|
|
@@ -1426,10 +1457,10 @@ function renderReports() {
|
|
|
1426
1457
|
const parts = [];
|
|
1427
1458
|
|
|
1428
1459
|
parts.push(`<div class="filters" id="rep-periods">
|
|
1429
|
-
<button type="button" class="period${state.
|
|
1430
|
-
<button type="button" class="period${state.
|
|
1431
|
-
<button type="button" class="period${state.
|
|
1432
|
-
<button type="button" class="period${state.
|
|
1460
|
+
<button type="button" class="period${state.reportsPeriod === "week" ? " is-on" : ""}" data-period="week">${tr("period.week")}</button>
|
|
1461
|
+
<button type="button" class="period${state.reportsPeriod === "month" ? " is-on" : ""}" data-period="month">${tr("period.month")}</button>
|
|
1462
|
+
<button type="button" class="period${state.reportsPeriod === "quarter" ? " is-on" : ""}" data-period="quarter">${tr("period.quarter")}</button>
|
|
1463
|
+
<button type="button" class="period${state.reportsPeriod === "all" ? " is-on" : ""}" data-period="all">${tr("period.all")}</button>
|
|
1433
1464
|
</div>`);
|
|
1434
1465
|
|
|
1435
1466
|
const fuera = overview.outsideRange;
|
|
@@ -1550,7 +1581,7 @@ function renderReports() {
|
|
|
1550
1581
|
|
|
1551
1582
|
document.querySelectorAll("#rep-periods .period").forEach((btn) => {
|
|
1552
1583
|
btn.addEventListener("click", () => {
|
|
1553
|
-
state.
|
|
1584
|
+
state.reportsPeriod = btn.dataset.period; state.reportsAll = false; loadReports();
|
|
1554
1585
|
});
|
|
1555
1586
|
});
|
|
1556
1587
|
|
|
@@ -1710,10 +1741,11 @@ function showView(name) {
|
|
|
1710
1741
|
if (name === "projects") loadProjects();
|
|
1711
1742
|
}
|
|
1712
1743
|
|
|
1713
|
-
|
|
1744
|
+
// Solo los del Resumen: los de Equipo e Informes llevan su propio periodo.
|
|
1745
|
+
document.querySelectorAll("#periods .period").forEach((btn) => {
|
|
1714
1746
|
btn.addEventListener("click", () => {
|
|
1715
1747
|
state.period = btn.dataset.period;
|
|
1716
|
-
document.querySelectorAll(".period").forEach((b) => b.classList.toggle("is-on", b === btn));
|
|
1748
|
+
document.querySelectorAll("#periods .period").forEach((b) => b.classList.toggle("is-on", b === btn));
|
|
1717
1749
|
loadSummary();
|
|
1718
1750
|
});
|
|
1719
1751
|
});
|
package/web/i18n.js
CHANGED
|
@@ -128,6 +128,7 @@
|
|
|
128
128
|
"toast.blocksApproved": { one: "1 bloque aprobado", other: "{n} bloques aprobados" },
|
|
129
129
|
|
|
130
130
|
// ── Resumen ──
|
|
131
|
+
"summary.range": "{from} → {to}",
|
|
131
132
|
"summary.projects": { one: "1 proyecto con actividad", other: "{n} proyectos con actividad" },
|
|
132
133
|
"summary.days": { one: "1 día trabajado", other: "{n} días trabajados" },
|
|
133
134
|
"summary.value": "valor del trabajo",
|
|
@@ -162,6 +163,10 @@
|
|
|
162
163
|
"project.pendingTitle": "Ir al día más reciente con bloques sin revisar",
|
|
163
164
|
"project.days": { one: "1 día", other: "{n} días" },
|
|
164
165
|
"project.lastSeen": "última vez {date}",
|
|
166
|
+
"project.split.invoiced": "Ya facturado: {duration}{invoices}",
|
|
167
|
+
"project.split.pending": "Pendiente de facturar: {duration}{amount}",
|
|
168
|
+
"project.split.nothingPending": "Nada pendiente de facturar",
|
|
169
|
+
"project.split.notBillable": "No facturable: {duration}",
|
|
165
170
|
|
|
166
171
|
// ── Equipo ──
|
|
167
172
|
"team.people": { one: "1 persona", other: "{n} personas" },
|
|
@@ -446,6 +451,7 @@
|
|
|
446
451
|
"toast.blocksApproved": { one: "1 block approved", other: "{n} blocks approved" },
|
|
447
452
|
|
|
448
453
|
// ── Summary ──
|
|
454
|
+
"summary.range": "{from} → {to}",
|
|
449
455
|
"summary.projects": { one: "1 project with activity", other: "{n} projects with activity" },
|
|
450
456
|
"summary.days": { one: "1 day worked", other: "{n} days worked" },
|
|
451
457
|
"summary.value": "value of work",
|
|
@@ -480,6 +486,10 @@
|
|
|
480
486
|
"project.pendingTitle": "Go to the most recent day with unreviewed blocks",
|
|
481
487
|
"project.days": { one: "1 day", other: "{n} days" },
|
|
482
488
|
"project.lastSeen": "last {date}",
|
|
489
|
+
"project.split.invoiced": "Already invoiced: {duration}{invoices}",
|
|
490
|
+
"project.split.pending": "Not yet invoiced: {duration}{amount}",
|
|
491
|
+
"project.split.nothingPending": "Nothing left to invoice",
|
|
492
|
+
"project.split.notBillable": "Not billable: {duration}",
|
|
483
493
|
|
|
484
494
|
// ── Team ──
|
|
485
495
|
"team.people": { one: "1 person", other: "{n} people" },
|