shilo-cli 0.7.0 → 0.9.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.
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ const config = require('../config');
4
+ const ui = require('../ui');
5
+ const sync = require('../sync');
6
+ const { resolveContext } = require('../context');
7
+
8
+ /**
9
+ * shilo review [versión] [--aprobado | --reparos | --rechazado] [-m <nota>] [--json]
10
+ *
11
+ * Revisión humana (Etapa 4). Sin veredicto, lista quién revisó esa versión y
12
+ * qué dijo. Con uno, deja constancia. La versión es `actual` si no se dice
13
+ * otra cosa, o lo que imprime `shilo log` (alcanza un prefijo de 7 caracteres).
14
+ *
15
+ * El positional es una versión, no una carpeta: el contexto se resuelve siempre
16
+ * sobre el directorio actual, igual que en `log`.
17
+ */
18
+ const FLAGS = { aprobado: 'aprobado', reparos: 'con_reparos', rechazado: 'rechazado' };
19
+
20
+ const ETIQUETAS = {
21
+ aprobado: 'aprobado',
22
+ con_reparos: 'con reparos',
23
+ rechazado: 'rechazado',
24
+ };
25
+
26
+ async function review(args) {
27
+ const version = typeof args._[0] === 'string' && args._[0].trim() ? args._[0].trim() : 'actual';
28
+ const elegidos = Object.keys(FLAGS).filter((f) => args[f]);
29
+ if (elegidos.length > 1) {
30
+ ui.error('Elegí un solo veredicto: --aprobado, --reparos o --rechazado.');
31
+ return 1;
32
+ }
33
+ const verdict = elegidos.length ? FLAGS[elegidos[0]] : null;
34
+ const note = typeof args.message === 'string' ? args.message.trim() : '';
35
+
36
+ if (!verdict && note) {
37
+ ui.error('La nota va con un veredicto: --aprobado, --reparos o --rechazado.');
38
+ return 1;
39
+ }
40
+ // La 017 lo exige igual; avisarlo acá ahorra un viaje y dice por qué.
41
+ if (verdict && verdict !== 'aprobado' && !note) {
42
+ ui.error('Un reparo o un rechazo necesita una nota que diga qué falta: -m "<nota>".');
43
+ return 1;
44
+ }
45
+
46
+ const ctx = resolveContext({ ...args, _: [] });
47
+ if (!ctx.ok) return ctx.code;
48
+ const { projectId, cfg, client, mgr } = ctx;
49
+
50
+ let result;
51
+ try {
52
+ const creds = await mgr.valid();
53
+ result = verdict
54
+ ? await client.review(projectId, { version, verdict, note }, creds)
55
+ : await client.listReviews(projectId, version, creds);
56
+ } catch (e) {
57
+ ui.error(e.message);
58
+ return 1;
59
+ } finally {
60
+ config.persistCredsIfChanged(cfg, mgr.current);
61
+ }
62
+
63
+ if (args.json) {
64
+ console.log(JSON.stringify({ projectId, version: result.version, reviews: result.reviews }, null, 2));
65
+ return 0;
66
+ }
67
+
68
+ const corto = result.version ? String(result.version).slice(0, 8) : version;
69
+ if (verdict) ui.success(`Revisión registrada: ${ETIQUETAS[verdict]} (${corto}).`);
70
+
71
+ ui.info(`Revisiones de ${ui.c.yellow(corto)}`);
72
+ ui.info('');
73
+ if (result.reviews.length === 0) {
74
+ ui.info('Nadie la revisó todavía.');
75
+ ui.info(ui.c.dim('Para revisarla: shilo review ' + (version === 'actual' ? '' : version + ' ') + '--aprobado'));
76
+ return 0;
77
+ }
78
+ for (const r of result.reviews) {
79
+ ui.info(`${pintar(r.verdict)} ${ui.c.bold(sync.describeAuthor(r.reviewer))} ${ui.c.dim(sync.formatDate(r.reviewed_at))}`);
80
+ if (r.note) ui.info(` ${r.note}`);
81
+ }
82
+ return 0;
83
+ }
84
+
85
+ function pintar(verdict) {
86
+ const texto = ETIQUETAS[verdict] || verdict;
87
+ if (verdict === 'aprobado') return ui.c.green(texto);
88
+ if (verdict === 'rechazado') return ui.c.red(texto);
89
+ return ui.c.yellow(texto);
90
+ }
91
+
92
+ /** Una marca corta para `shilo log`: la revisión más reciente manda. */
93
+ function marcaDeRevision(reviews) {
94
+ if (!Array.isArray(reviews) || reviews.length === 0) return '';
95
+ const ultima = reviews[0];
96
+ const extra = reviews.length > 1 ? ` +${reviews.length - 1}` : '';
97
+ return `${pintar(ultima.verdict)} por ${sync.describeAuthor(ultima.reviewer)}${extra}`;
98
+ }
99
+
100
+ module.exports = review;
101
+ module.exports.marcaDeRevision = marcaDeRevision;
@@ -6,19 +6,29 @@ const sync = require('../sync');
6
6
  const remote = require('../remote');
7
7
  const { resolveContext } = require('../context');
8
8
 
9
+ // A partir de cuántos días sin sincronizar se recuerda hacerlo.
10
+ const STALE_DAYS = 30;
11
+
9
12
  /**
10
13
  * shilo status [carpeta] [--project <uuid>] [--base-url <url>] [--short] [--json]
11
14
  *
12
- * Comando de sólo lectura: compara la carpeta local contra el remoto SIN bajar
13
- * contenido y sin escribir nada, ni local ni remoto. Contesta las dos preguntas
14
- * que uno se hace antes de arrancar a trabajar:
15
+ * Compara la carpeta local contra el remoto SIN bajar contenido y sin escribir
16
+ * nada en el servidor. Contesta las preguntas que uno se hace antes de arrancar
17
+ * a trabajar:
15
18
  *
16
19
  * - ¿el remoto tiene cambios que no tengo? (y de quién son)
17
20
  * - ¿tengo cambios locales sin subir?
21
+ * - ¿hay algo mío esperando aprobación?
22
+ *
23
+ * La comparación es por ruta + tamaño (un listado por carpeta, cero descargas)
24
+ * y, cuando el tamaño coincide, por el SHA-256 que el remoto publica en el
25
+ * listado (`sync.refineByHash`): sigue sin bajar nada.
18
26
  *
19
- * La comparación es por ruta + tamaño (un listado por carpeta, cero descargas).
20
- * Dos archivos del mismo tamaño se reportan como "iguales" aunque su contenido
21
- * pudiera diferir: para confirmar byte a byte están `shilo diff` y `push`.
27
+ * Lo único que escribe es `.shilo.json`, y sólo cuando la carpeta coincide
28
+ * entera con la versión actual: ahí anota la fecha y la `base`. Es el caso de
29
+ * una propuesta aprobada en la web, que si no dejaba "Al día" al lado de
30
+ * "última sincronización hace 2 meses" y al push siguiente partiendo de una
31
+ * base vieja.
22
32
  */
23
33
  async function status(args) {
24
34
  const ctx = resolveContext(args);
@@ -52,15 +62,46 @@ async function status(args) {
52
62
  const attribution = await sync.buildAuthorIndex(client, projectId, mgr, { commits: 50 });
53
63
  config.persistCredsIfChanged(cfg, mgr.current);
54
64
 
55
- const me = cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : null;
56
- const cmp = sync.compareTrees(local.files, tree.files);
65
+ // 2b) Propuestas abiertas (best-effort: un servidor viejo no las tiene). De
66
+ // paso dicen quién soy —con token personal la sesión no lo trae—, si soy
67
+ // el dueño, y cuál es la versión actual.
68
+ let listed = null;
69
+ try {
70
+ listed = await client.listProposals(projectId, await mgr.valid(), { state: 'abierta' });
71
+ config.persistCredsIfChanged(cfg, mgr.current);
72
+ } catch (_) {
73
+ listed = null;
74
+ }
75
+ const viewer = (listed && listed.viewer) || null;
76
+
77
+ const me =
78
+ cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : viewer ? viewer.id : null;
79
+ const cmp = sync.refineByHash(sync.compareTrees(local.files, tree.files), {
80
+ dir,
81
+ projectId,
82
+ baseURL,
83
+ });
57
84
 
58
85
  // 3) "Detrás del remoto" = lo que otro subió y yo no tengo igual: archivos
59
86
  // que faltan localmente, o que difieren y cambiaron después de mi lastSync.
87
+ //
88
+ // Lo que está ignorado localmente NO cuenta como "atrás": `walkLocal` no
89
+ // lo ve, así que quedaría en `onlyRemote` para siempre y el exit 2 —que
90
+ // significa "pull antes de trabajar"— se volvería ruido permanente. Se
91
+ // informa aparte: que alguien haya subido tu `settings.local.json` importa,
92
+ // pero no es trabajo que te falte bajar.
93
+ const isIgnoredPath = sync.makePathMatcher(dir);
94
+
60
95
  const behind = [];
96
+ const ignoredRemote = [];
61
97
  for (const item of cmp.onlyRemote) {
98
+ if (isIgnoredPath(item.repoPath)) {
99
+ ignoredRemote.push({ ...item, kind: 'ignorado local' });
100
+ continue;
101
+ }
62
102
  behind.push({ ...item, kind: 'falta local' });
63
103
  }
104
+ ignoredRemote.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
64
105
  for (const item of cmp.differing) {
65
106
  if (sync.changedSinceSync(item.remote, lastSync) !== false) {
66
107
  behind.push({ ...item, kind: 'difiere' });
@@ -85,6 +126,34 @@ async function status(args) {
85
126
  };
86
127
  };
87
128
 
129
+ // 4b) Propuestas abiertas: las mías (esperan aprobación) y, si soy el dueño,
130
+ // las de los demás (esperan mi decisión).
131
+ const openProposals = listed ? listed.proposals : [];
132
+ const mineOpen = me ? openProposals.filter((p) => p.author_id === me) : [];
133
+ const othersOpen = viewer && viewer.is_owner ? openProposals.filter((p) => p.author_id !== me) : [];
134
+ const projectInfo = (listed && listed.project) || null;
135
+ const owner = projectInfo && projectInfo.owner;
136
+ const ownerName = owner ? owner.display_name || owner.username : null;
137
+ const linkTo = (p) =>
138
+ projectInfo && projectInfo.path
139
+ ? `${baseURL.replace(/\/+$/, '')}${projectInfo.path}?propuesta=${p.id.slice(0, 8)}`
140
+ : null;
141
+
142
+ // 4c) Carpeta idéntica a la versión actual → anotarlo (ver cabecera).
143
+ const aligned = behind.length === 0 && ahead.length === 0 && tree.errors.length === 0;
144
+ let syncedAt = lastSync;
145
+ if (aligned && listed && listed.main) {
146
+ const stale = !savedRemote || savedRemote.base !== listed.main || !lastSync;
147
+ if (stale && remote.touchSync(dir, { projectId, baseURL, base: listed.main })) {
148
+ syncedAt = new Date().toISOString();
149
+ }
150
+ }
151
+
152
+ // 4d) Hace cuánto que esta carpeta no se alinea con el proyecto.
153
+ const daysSinceSync = syncedAt
154
+ ? Math.floor((Date.now() - new Date(syncedAt).getTime()) / 86400000)
155
+ : null;
156
+
88
157
  if (asJson) {
89
158
  console.log(
90
159
  JSON.stringify(
@@ -92,11 +161,16 @@ async function status(args) {
92
161
  projectId,
93
162
  baseURL,
94
163
  dir,
95
- lastSync: lastSync || null,
164
+ lastSync: syncedAt || null,
165
+ pendingProposals: mineOpen.map((p) => ({ id: p.id, title: p.title, createdAt: p.created_at, url: linkTo(p) })),
166
+ awaitingMyDecision: othersOpen.map((p) => ({ id: p.id, title: p.title, url: linkTo(p) })),
96
167
  remoteFiles: tree.files.length,
97
168
  localFiles: local.files.length,
98
169
  behind: behind.map((i) => ({ path: i.repoPath, kind: i.kind, lastCommit: decorate(i) })),
99
170
  ahead: ahead.map((i) => ({ path: i.repoPath, kind: i.kind })),
171
+ // Campo aparte a propósito: si fueran parte de `behind`, un script
172
+ // volvería a leer "estás desactualizado" por algo que nunca vas a bajar.
173
+ ignoredRemote: ignoredRemote.map((i) => ({ path: i.repoPath })),
100
174
  unchanged: cmp.same.length,
101
175
  listErrors: tree.errors,
102
176
  },
@@ -113,8 +187,8 @@ async function status(args) {
113
187
  }
114
188
 
115
189
  ui.info('');
116
- if (lastSync) {
117
- ui.info(ui.c.dim(`Última sincronización de esta carpeta: ${sync.formatDate(lastSync)}`));
190
+ if (syncedAt) {
191
+ ui.info(ui.c.dim(`Última sincronización de esta carpeta: ${sync.formatDate(syncedAt)}`));
118
192
  } else {
119
193
  ui.info(
120
194
  ui.c.dim(
@@ -125,8 +199,52 @@ async function status(args) {
125
199
  }
126
200
  ui.info('');
127
201
 
202
+ /** Los ignorados que existen en el remoto: una línea, o la lista con --show-ignored. */
203
+ const reportIgnored = () => {
204
+ if (ignoredRemote.length === 0) return;
205
+ ui.info(
206
+ ui.c.dim(
207
+ `${ignoredRemote.length} archivo(s) existen en el remoto pero están ignorados localmente` +
208
+ (args['show-ignored'] ? ':' : ' (--show-ignored para verlos).')
209
+ )
210
+ );
211
+ if (args['show-ignored']) {
212
+ for (const item of ignoredRemote) ui.info(` ${ui.c.dim('·')} ${ui.c.dim(item.repoPath)}`);
213
+ }
214
+ ui.info('');
215
+ };
216
+
217
+ /** Propuestas abiertas: lo primero que hay que saber, antes que la lista de archivos. */
218
+ const reportProposals = () => {
219
+ if (mineOpen.length > 0) {
220
+ const who = viewer && viewer.is_owner ? 'tu aprobación' : ownerName ? `la aprobación de ${ownerName}` : 'aprobación';
221
+ ui.info(ui.c.bold(`Tenés ${mineOpen.length} propuesta(s) esperando ${who}:`));
222
+ for (const p of mineOpen) {
223
+ const link = linkTo(p);
224
+ ui.info(
225
+ ` ${ui.c.yellow('⧗')} ${p.title || p.id.slice(0, 8)} ${ui.c.dim(`— ${sync.formatDate(p.created_at)}`)}` +
226
+ (link ? `\n ${ui.c.cyan(link)}` : '')
227
+ );
228
+ }
229
+ ui.info('');
230
+ }
231
+ if (othersOpen.length > 0) {
232
+ ui.info(ui.c.bold(`${othersOpen.length} propuesta(s) de otras personas esperan tu decisión:`));
233
+ for (const p of othersOpen) {
234
+ const by = p.author ? p.author.display_name || p.author.username : 'alguien';
235
+ const link = linkTo(p);
236
+ ui.info(` ${ui.c.yellow('⧗')} ${p.title || p.id.slice(0, 8)} ${ui.c.dim(`— de ${by}`)}` + (link ? `\n ${ui.c.cyan(link)}` : ''));
237
+ }
238
+ ui.info('');
239
+ }
240
+ };
241
+
242
+ reportProposals();
243
+
128
244
  if (behind.length === 0 && ahead.length === 0) {
129
- ui.success(`Al día: ${cmp.same.length} archivo(s) coinciden con el remoto.`);
245
+ ui.success(`Al día: ${cmp.same.length} archivo(s) coinciden con el proyecto.`);
246
+ ui.info('');
247
+ reportIgnored();
130
248
  return 0;
131
249
  }
132
250
 
@@ -148,7 +266,10 @@ async function status(args) {
148
266
  }
149
267
 
150
268
  if (ahead.length > 0) {
151
- ui.info(ui.c.bold(`Tenés ${ahead.length} cambio(s) local(es) sin subir:`));
269
+ ui.info(
270
+ ui.c.bold(`Tenés ${ahead.length} cambio(s) local(es) que todavía no son parte del proyecto`) +
271
+ (mineOpen.length > 0 ? ui.c.bold(' (pueden estar en tu propuesta pendiente):') : ui.c.bold(':'))
272
+ );
152
273
  for (const item of ahead) {
153
274
  const mark = item.kind === 'nuevo local' ? ui.c.green('+') : ui.c.yellow('~');
154
275
  ui.info(` ${mark} ${item.repoPath}`);
@@ -156,9 +277,11 @@ async function status(args) {
156
277
  ui.info('');
157
278
  }
158
279
 
280
+ reportIgnored();
281
+
159
282
  ui.info(
160
283
  ui.c.dim(
161
- ` ${cmp.same.length} sin cambios (por tamaño) ` +
284
+ ` ${cmp.same.length} sin cambios ` +
162
285
  `${local.ignored} ignorados ${local.skipped.length} saltados`
163
286
  )
164
287
  );
@@ -175,10 +298,21 @@ async function status(args) {
175
298
  ui.info(ui.c.dim('Mirá "shilo diff <archivo>" antes de decidir.'));
176
299
  } else if (behind.length > 0) {
177
300
  ui.info(ui.c.dim('Sugerido: "shilo pull --dry-run" para ver qué bajaría.'));
301
+ } else if (mineOpen.length > 0) {
302
+ ui.info(ui.c.dim('Tus cambios ya están guardados: falta que se apruebe la propuesta de arriba.'));
178
303
  } else {
179
304
  ui.info(ui.c.dim('Sugerido: "shilo push --dry-run" para ver qué subiría.'));
180
305
  }
181
306
 
307
+ // Recordatorio: sincronizar seguido da propuestas chicas y fáciles de revisar.
308
+ if (ahead.length > 0 && daysSinceSync !== null && daysSinceSync >= STALE_DAYS) {
309
+ ui.info('');
310
+ ui.warn(
311
+ `Hace ${daysSinceSync} días que esta carpeta no se sincroniza. Subí seguido: ` +
312
+ 'cuanto más chico el cambio, más fácil de revisar y menos choques con lo de otros.'
313
+ );
314
+ }
315
+
182
316
  // Exit code 2 = "estás desactualizado". Útil para encadenar en scripts:
183
317
  // `shilo status || echo "bajá antes de trabajar"`.
184
318
  return behind.length > 0 ? 2 : 0;