@oneuptime/common 11.0.12 → 11.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/Models/DatabaseModels/DockerResource.ts +6 -6
  2. package/Models/DatabaseModels/StatusPage.ts +0 -42
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1782700000000-RemoveDeprecatedEnableSubscribersFromStatusPage.ts +35 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/1782800000000-WidenDockerResourceImageColumns.ts +45 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  6. package/Server/Infrastructure/Queue.ts +122 -0
  7. package/Server/Services/IncidentService.ts +19 -1
  8. package/Server/Types/Markdown.ts +103 -0
  9. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +4 -1
  10. package/Server/Utils/CodeRepository/CodeRepository.ts +78 -0
  11. package/Server/Utils/Execute.ts +6 -0
  12. package/Server/Utils/Logger.ts +66 -0
  13. package/Server/Utils/Monitor/MonitorIncident.ts +64 -14
  14. package/Server/Utils/Monitor/MonitorResource.ts +40 -10
  15. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +32 -6
  16. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +75 -0
  17. package/build/dist/Models/DatabaseModels/DockerResource.js +6 -6
  18. package/build/dist/Models/DatabaseModels/DockerResource.js.map +1 -1
  19. package/build/dist/Models/DatabaseModels/StatusPage.js +0 -44
  20. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  21. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782700000000-RemoveDeprecatedEnableSubscribersFromStatusPage.js +28 -0
  22. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782700000000-RemoveDeprecatedEnableSubscribersFromStatusPage.js.map +1 -0
  23. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782800000000-WidenDockerResourceImageColumns.js +30 -0
  24. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1782800000000-WidenDockerResourceImageColumns.js.map +1 -0
  25. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  26. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  27. package/build/dist/Server/Infrastructure/Queue.js +63 -0
  28. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  29. package/build/dist/Server/Services/IncidentService.js +17 -1
  30. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  31. package/build/dist/Server/Types/Markdown.js +87 -0
  32. package/build/dist/Server/Types/Markdown.js.map +1 -1
  33. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +3 -0
  34. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  35. package/build/dist/Server/Utils/CodeRepository/CodeRepository.js +60 -0
  36. package/build/dist/Server/Utils/CodeRepository/CodeRepository.js.map +1 -1
  37. package/build/dist/Server/Utils/Execute.js +1 -3
  38. package/build/dist/Server/Utils/Execute.js.map +1 -1
  39. package/build/dist/Server/Utils/Logger.js +49 -1
  40. package/build/dist/Server/Utils/Logger.js.map +1 -1
  41. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +70 -27
  42. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  43. package/build/dist/Server/Utils/Monitor/MonitorResource.js +36 -8
  44. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  45. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +29 -7
  46. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  47. package/package.json +1 -1
@@ -192,7 +192,7 @@ export default class DockerResource extends BaseModel {
192
192
  })
193
193
  @TableColumn({
194
194
  required: true,
195
- type: TableColumnType.ShortText,
195
+ type: TableColumnType.LongText,
196
196
  canReadOnRelationQuery: true,
197
197
  title: "Name",
198
198
  description:
@@ -200,8 +200,8 @@ export default class DockerResource extends BaseModel {
200
200
  })
201
201
  @Column({
202
202
  nullable: false,
203
- type: ColumnType.ShortText,
204
- length: ColumnLength.ShortText,
203
+ type: ColumnType.LongText,
204
+ length: ColumnLength.LongText,
205
205
  })
206
206
  public name?: string = undefined;
207
207
 
@@ -232,7 +232,7 @@ export default class DockerResource extends BaseModel {
232
232
  })
233
233
  @TableColumn({
234
234
  required: false,
235
- type: TableColumnType.ShortText,
235
+ type: TableColumnType.LongText,
236
236
  canReadOnRelationQuery: true,
237
237
  title: "Image Name",
238
238
  description:
@@ -240,8 +240,8 @@ export default class DockerResource extends BaseModel {
240
240
  })
241
241
  @Column({
242
242
  nullable: true,
243
- type: ColumnType.ShortText,
244
- length: ColumnLength.ShortText,
243
+ type: ColumnType.LongText,
244
+ length: ColumnLength.LongText,
245
245
  })
246
246
  public imageName?: string = undefined;
247
247
 
@@ -1243,48 +1243,6 @@ export default class StatusPage extends BaseModel {
1243
1243
  })
1244
1244
  public showScheduledEventLabelsOnStatusPage?: boolean = undefined;
1245
1245
 
1246
- // This column is Deprectaed.
1247
- @ColumnAccessControl({
1248
- create: [
1249
- Permission.ProjectOwner,
1250
- Permission.ProjectAdmin,
1251
- Permission.ProjectMember,
1252
- Permission.StatusPageAdmin,
1253
- Permission.StatusPageMember,
1254
- Permission.CreateProjectStatusPage,
1255
- ],
1256
- read: [
1257
- Permission.ProjectOwner,
1258
- Permission.ProjectAdmin,
1259
- Permission.ProjectMember,
1260
- Permission.Viewer,
1261
- Permission.StatusPageAdmin,
1262
- Permission.StatusPageMember,
1263
- Permission.StatusPageViewer,
1264
- Permission.ReadProjectStatusPage,
1265
- ],
1266
- update: [
1267
- Permission.ProjectOwner,
1268
- Permission.ProjectAdmin,
1269
- Permission.ProjectMember,
1270
- Permission.StatusPageAdmin,
1271
- Permission.StatusPageMember,
1272
- Permission.EditProjectStatusPage,
1273
- ],
1274
- })
1275
- @TableColumn({
1276
- isDefaultValueColumn: true,
1277
- type: TableColumnType.Boolean,
1278
- title: "Enable Subscribers",
1279
- description: "Can subscribers subscribe to this Status Page?",
1280
- defaultValue: true,
1281
- })
1282
- @Column({
1283
- type: ColumnType.Boolean,
1284
- default: true,
1285
- })
1286
- public enableSubscribers?: boolean = undefined;
1287
-
1288
1246
  @ColumnAccessControl({
1289
1247
  create: [
1290
1248
  Permission.ProjectOwner,
@@ -0,0 +1,35 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ /*
4
+ * Drops the deprecated StatusPage.enableSubscribers column.
5
+ *
6
+ * enableSubscribers was the old master "Can subscribers subscribe?" toggle,
7
+ * superseded by showSubscriberPageOnStatusPage + the per-channel
8
+ * enableEmailSubscribers / enableSmsSubscribers / enableSlackSubscribers flags.
9
+ * It was already marked deprecated in the model and gated nothing at runtime —
10
+ * its only consumer was a dashboard warning banner that wrongly claimed "no
11
+ * notifications will be sent" when it was off. That banner has been corrected,
12
+ * so this column now has no readers and is safe to remove.
13
+ *
14
+ * NOTE: the SQL below is exactly what `npm run generate-postgres-migration`
15
+ * produced for this model change; the generator additionally emitted unrelated
16
+ * cosmetic JSON-default churn on OnCallDutyPolicyScheduleLayer, which was
17
+ * stripped so this migration only does the intended drop.
18
+ */
19
+ export class RemoveDeprecatedEnableSubscribersFromStatusPage1782700000000
20
+ implements MigrationInterface
21
+ {
22
+ public name = "RemoveDeprecatedEnableSubscribersFromStatusPage1782700000000";
23
+
24
+ public async up(queryRunner: QueryRunner): Promise<void> {
25
+ await queryRunner.query(
26
+ `ALTER TABLE "StatusPage" DROP COLUMN "enableSubscribers"`,
27
+ );
28
+ }
29
+
30
+ public async down(queryRunner: QueryRunner): Promise<void> {
31
+ await queryRunner.query(
32
+ `ALTER TABLE "StatusPage" ADD "enableSubscribers" boolean NOT NULL DEFAULT true`,
33
+ );
34
+ }
35
+ }
@@ -0,0 +1,45 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ export class WidenDockerResourceImageColumns1782800000000
4
+ implements MigrationInterface
5
+ {
6
+ public name = "WidenDockerResourceImageColumns1782800000000";
7
+
8
+ public async up(queryRunner: QueryRunner): Promise<void> {
9
+ /*
10
+ * DockerResource."name" holds the FULL image reference for Image-kind
11
+ * rows, and "imageName" holds the container's image reference — both can
12
+ * exceed varchar(100) (registry host + path + tag + @sha256 digest). An
13
+ * oversized value raised "value too long for type character varying(100)"
14
+ * (22001) and aborted the ENTIRE Docker snapshot batch INSERT, dropping
15
+ * every container in the flush. Widen both to varchar(500) (LongText).
16
+ * Increasing a varchar length cap is a metadata-only change in Postgres
17
+ * (no table rewrite).
18
+ */
19
+ await queryRunner.query(
20
+ `ALTER TABLE "DockerResource" ALTER COLUMN "name" TYPE character varying(500)`,
21
+ );
22
+ await queryRunner.query(
23
+ `ALTER TABLE "DockerResource" ALTER COLUMN "imageName" TYPE character varying(500)`,
24
+ );
25
+ }
26
+
27
+ public async down(queryRunner: QueryRunner): Promise<void> {
28
+ /*
29
+ * Narrowing back to varchar(100) will fail if any stored value now
30
+ * exceeds 100 chars; truncate first so the down migration is safe.
31
+ */
32
+ await queryRunner.query(
33
+ `UPDATE "DockerResource" SET "name" = LEFT("name", 100) WHERE LENGTH("name") > 100`,
34
+ );
35
+ await queryRunner.query(
36
+ `UPDATE "DockerResource" SET "imageName" = LEFT("imageName", 100) WHERE "imageName" IS NOT NULL AND LENGTH("imageName") > 100`,
37
+ );
38
+ await queryRunner.query(
39
+ `ALTER TABLE "DockerResource" ALTER COLUMN "name" TYPE character varying(100)`,
40
+ );
41
+ await queryRunner.query(
42
+ `ALTER TABLE "DockerResource" ALTER COLUMN "imageName" TYPE character varying(100)`,
43
+ );
44
+ }
45
+ }
@@ -398,6 +398,8 @@ import { MigrationName1782310000000 } from "./1782310000000-MigrationName";
398
398
  import { RemoveIsTestedFromGlobalSsoAndOidc1782400000000 } from "./1782400000000-RemoveIsTestedFromGlobalSsoAndOidc";
399
399
  import { OptimizeTelemetryExceptionWritePath1782500000000 } from "./1782500000000-OptimizeTelemetryExceptionWritePath";
400
400
  import { AddArchiveToResources1782600000000 } from "./1782600000000-AddArchiveToResources";
401
+ import { RemoveDeprecatedEnableSubscribersFromStatusPage1782700000000 } from "./1782700000000-RemoveDeprecatedEnableSubscribersFromStatusPage";
402
+ import { WidenDockerResourceImageColumns1782800000000 } from "./1782800000000-WidenDockerResourceImageColumns";
401
403
 
402
404
  export default [
403
405
  InitialMigration,
@@ -800,4 +802,6 @@ export default [
800
802
  RemoveIsTestedFromGlobalSsoAndOidc1782400000000,
801
803
  OptimizeTelemetryExceptionWritePath1782500000000,
802
804
  AddArchiveToResources1782600000000,
805
+ RemoveDeprecatedEnableSubscribersFromStatusPage1782700000000,
806
+ WidenDockerResourceImageColumns1782800000000,
803
807
  ];
@@ -440,4 +440,126 @@ export default class Queue {
440
440
  return result;
441
441
  });
442
442
  }
443
+
444
+ /**
445
+ * Like getFailedJobs, but returns the FULL job for deep debugging: the job
446
+ * body (data), its options, return value, progress, all the timing/attempt
447
+ * metadata, and the per-job log lines BullMQ keeps (job.log()/getJobLogs).
448
+ * Used by the master-admin health dashboard / support bundle. The caller is
449
+ * responsible for redacting / size-capping before exposing this, since the
450
+ * job body can contain customer data.
451
+ */
452
+ @CaptureSpan()
453
+ public static async getFailedJobsWithDetails(
454
+ queueName: QueueName,
455
+ options?: {
456
+ start?: number;
457
+ end?: number;
458
+ },
459
+ ): Promise<
460
+ Array<{
461
+ id: string;
462
+ name: string;
463
+ data: JSONObject;
464
+ opts: JSONObject;
465
+ returnValue: unknown;
466
+ progress: number | Record<string, unknown> | null;
467
+ failedReason: string;
468
+ stackTrace: Array<string>;
469
+ logs: Array<string>;
470
+ attemptsMade: number;
471
+ attemptsStarted: number | null;
472
+ stalledCounter: number | null;
473
+ priority: number | null;
474
+ delay: number | null;
475
+ createdAt: Date | null;
476
+ processedOn: Date | null;
477
+ finishedOn: Date | null;
478
+ queueQualifiedName: string | null;
479
+ repeatJobKey: string | null;
480
+ deduplicationId: string | null;
481
+ processedBy: string | null;
482
+ parentKey: string | null;
483
+ }>
484
+ > {
485
+ const queue: BullQueue = this.getQueue(queueName);
486
+ const start: number = options?.start || 0;
487
+ const end: number = options?.end ?? 100;
488
+ const failed: Job[] = await queue.getFailed(start, end);
489
+
490
+ const results: Array<{
491
+ id: string;
492
+ name: string;
493
+ data: JSONObject;
494
+ opts: JSONObject;
495
+ returnValue: unknown;
496
+ progress: number | Record<string, unknown> | null;
497
+ failedReason: string;
498
+ stackTrace: Array<string>;
499
+ logs: Array<string>;
500
+ attemptsMade: number;
501
+ attemptsStarted: number | null;
502
+ stalledCounter: number | null;
503
+ priority: number | null;
504
+ delay: number | null;
505
+ createdAt: Date | null;
506
+ processedOn: Date | null;
507
+ finishedOn: Date | null;
508
+ queueQualifiedName: string | null;
509
+ repeatJobKey: string | null;
510
+ deduplicationId: string | null;
511
+ processedBy: string | null;
512
+ parentKey: string | null;
513
+ }> = [];
514
+
515
+ for (const job of failed) {
516
+ // Per-job log lines are best-effort — older jobs may have none.
517
+ let logs: Array<string> = [];
518
+
519
+ try {
520
+ if (job.id) {
521
+ const jobLogs: { logs: string[]; count: number } =
522
+ await queue.getJobLogs(job.id, 0, -1);
523
+ logs = jobLogs?.logs || [];
524
+ }
525
+ } catch (err) {
526
+ logger.debug(`Failed to read logs for job ${job.id} on ${queueName}`);
527
+ logger.debug(err);
528
+ }
529
+
530
+ results.push({
531
+ id: job.id || "unknown",
532
+ name: job.name || "unknown",
533
+ data: (job.data as JSONObject) || {},
534
+ opts: (job.opts as unknown as JSONObject) || {},
535
+ returnValue: job.returnvalue ?? null,
536
+ progress: (job.progress as number | Record<string, unknown>) ?? null,
537
+ failedReason: job.failedReason || "No reason provided",
538
+ stackTrace: job.stacktrace || [],
539
+ logs: logs,
540
+ attemptsMade: job.attemptsMade || 0,
541
+ attemptsStarted:
542
+ typeof job.attemptsStarted === "number" ? job.attemptsStarted : null,
543
+ stalledCounter:
544
+ typeof job.stalledCounter === "number" ? job.stalledCounter : null,
545
+ priority: typeof job.priority === "number" ? job.priority : null,
546
+ delay: typeof job.delay === "number" ? job.delay : null,
547
+ createdAt:
548
+ typeof job.timestamp === "number" ? new Date(job.timestamp) : null,
549
+ processedOn:
550
+ typeof job.processedOn === "number"
551
+ ? new Date(job.processedOn)
552
+ : null,
553
+ finishedOn:
554
+ typeof job.finishedOn === "number" ? new Date(job.finishedOn) : null,
555
+ queueQualifiedName: job.queueQualifiedName || null,
556
+ repeatJobKey: job.repeatJobKey || null,
557
+ deduplicationId: job.deduplicationId || null,
558
+ processedBy: job.processedBy || null,
559
+ parentKey: job.parentKey || null,
560
+ });
561
+ }
562
+
563
+ return results;
564
+ }
443
565
  }
@@ -518,6 +518,24 @@ export class Service extends DatabaseService<Model> {
518
518
  );
519
519
  }
520
520
 
521
+ /*
522
+ * Normalize a blank incident severity to "not provided". A stored empty
523
+ * ObjectID (`{"_type":"ObjectID","value":""}`) deserializes to a truthy
524
+ * `new ObjectID("")`, which is NOT `=== undefined` and is a truthy object —
525
+ * so it slips past the template fallback below AND
526
+ * DatabaseService.checkRequiredFields, then serializes to "" → NULL on the
527
+ * not-null `incidentSeverityId` column (Postgres 23502). Treating it as
528
+ * undefined lets the template fallback fill it in when available, otherwise
529
+ * the required-field check rejects with a clean "incidentSeverityId is
530
+ * required" error instead of an opaque database failure.
531
+ */
532
+ if (
533
+ createBy.data.incidentSeverityId &&
534
+ !createBy.data.incidentSeverityId.toString()
535
+ ) {
536
+ delete createBy.data.incidentSeverityId;
537
+ }
538
+
521
539
  // Determine the initial incident state
522
540
  let initialIncidentStateId: ObjectID | undefined = undefined;
523
541
 
@@ -608,7 +626,7 @@ export class Service extends DatabaseService<Model> {
608
626
 
609
627
  if (incidentTemplate) {
610
628
  if (
611
- createBy.data.incidentSeverityId === undefined &&
629
+ !createBy.data.incidentSeverityId?.toString() &&
612
630
  incidentTemplate.incidentSeverityId
613
631
  ) {
614
632
  createBy.data.incidentSeverityId =
@@ -7,6 +7,8 @@ export enum MarkdownContentType {
7
7
  Docs,
8
8
  Blog,
9
9
  Email,
10
+ // Compact rendering tuned for small surfaces (e.g. the blog validation modal).
11
+ BlogValidation,
10
12
  }
11
13
 
12
14
  export default class Markdown {
@@ -73,6 +75,7 @@ export default class Markdown {
73
75
  private static blogRenderer: Renderer | null = null;
74
76
  private static docsRenderer: Renderer | null = null;
75
77
  private static emailRenderer: Renderer | null = null;
78
+ private static blogValidationRenderer: Renderer | null = null;
76
79
 
77
80
  @CaptureSpan()
78
81
  public static async convertToHTML(
@@ -93,6 +96,10 @@ export default class Markdown {
93
96
  renderer = this.getEmailRenderer();
94
97
  }
95
98
 
99
+ if (contentType === MarkdownContentType.BlogValidation) {
100
+ renderer = this.getBlogValidationRenderer();
101
+ }
102
+
96
103
  /*
97
104
  * Escape raw HTML tokens in the markdown source to prevent XSS.
98
105
  * This only affects raw HTML that users type directly (e.g. <img onerror=...>),
@@ -132,6 +139,102 @@ export default class Markdown {
132
139
  return renderer;
133
140
  }
134
141
 
142
+ /*
143
+ * Compact renderer for small surfaces like the blog validation modal. Smaller,
144
+ * quieter headings (no oversized doc titles, no permalink anchors), tight
145
+ * paragraph/list rhythm, and subtle code styling so a long report stays
146
+ * readable in a constrained, scrollable panel.
147
+ */
148
+ private static getBlogValidationRenderer(): Renderer {
149
+ if (this.blogValidationRenderer !== null) {
150
+ return this.blogValidationRenderer;
151
+ }
152
+
153
+ const renderer: Renderer = new Renderer();
154
+
155
+ renderer.heading = function (text, level) {
156
+ if (level <= 2) {
157
+ return `<h3 class="mt-7 mb-3 pt-6 border-t border-gray-100 first:mt-0 first:pt-0 first:border-t-0 text-[0.7rem] font-semibold uppercase tracking-wider text-emerald-700">${text}</h3>`;
158
+ }
159
+ if (level === 3) {
160
+ return `<h4 class="mt-5 mb-1.5 text-sm font-semibold text-gray-900">${text}</h4>`;
161
+ }
162
+ return `<h5 class="mt-4 mb-1 text-sm font-semibold text-gray-700">${text}</h5>`;
163
+ };
164
+
165
+ renderer.paragraph = function (text) {
166
+ return `<p class="my-2.5 text-sm leading-relaxed text-gray-600">${text}</p>`;
167
+ };
168
+
169
+ renderer.list = function (body, ordered, start) {
170
+ const tag: string = ordered ? "ol" : "ul";
171
+ const cls: string = ordered
172
+ ? "list-decimal pl-5 my-2 space-y-1.5 text-sm text-gray-600 marker:text-gray-400"
173
+ : "list-disc pl-5 my-2 space-y-1.5 text-sm text-gray-600 marker:text-gray-300";
174
+ const startAttr: string =
175
+ ordered && start !== 1 ? ` start="${start}"` : "";
176
+ return `<${tag}${startAttr} class="${cls}">${body}</${tag}>`;
177
+ };
178
+ renderer.listitem = function (text) {
179
+ return `<li class="leading-relaxed pl-1">${text}</li>`;
180
+ };
181
+
182
+ renderer.strong = function (text) {
183
+ return `<strong class="font-semibold text-gray-800">${text}</strong>`;
184
+ };
185
+ renderer.em = function (text) {
186
+ return `<em class="italic">${text}</em>`;
187
+ };
188
+
189
+ renderer.blockquote = function (quote) {
190
+ return `<blockquote class="my-3 border-s-2 border-emerald-200 ps-3 text-sm text-gray-600">${quote}</blockquote>`;
191
+ };
192
+
193
+ renderer.hr = function () {
194
+ return '<hr class="my-5 border-t border-gray-100" />';
195
+ };
196
+
197
+ renderer.codespan = function (code) {
198
+ const escaped: string = Markdown.escapeHtml(code);
199
+ return `<code class="rounded bg-gray-100 px-1.5 py-0.5 text-[0.8125rem] text-gray-700 font-mono break-words">${escaped}</code>`;
200
+ };
201
+
202
+ renderer.code = function (code, language) {
203
+ const escaped: string = Markdown.escapeHtml(code);
204
+ return `<pre class="my-3 overflow-x-auto rounded-lg bg-gray-50 border border-gray-100 p-3 text-xs leading-relaxed"><code class="language-${language} font-mono text-gray-700">${escaped}</code></pre>`;
205
+ };
206
+
207
+ renderer.table = function (header, body) {
208
+ return `<div class="my-4 overflow-x-auto rounded-lg border border-gray-100"><table class="min-w-full text-sm text-left">${header}${body}</table></div>`;
209
+ };
210
+ renderer.tablerow = function (content) {
211
+ return `<tr class="border-b border-gray-100 last:border-b-0">${content}</tr>`;
212
+ };
213
+ renderer.tablecell = function (content, flags) {
214
+ const tag: string = flags.header ? "th" : "td";
215
+ const align: string = flags.align ? ` text-${flags.align}` : "";
216
+ const headerClass: string = flags.header
217
+ ? " font-semibold text-gray-900 bg-gray-50"
218
+ : " text-gray-600";
219
+ return `<${tag} class="px-3 py-2${align}${headerClass}">${content}</${tag}>`;
220
+ };
221
+
222
+ renderer.link = function (href, _title, text) {
223
+ if (!href) {
224
+ return text as string;
225
+ }
226
+ const isExternal: boolean = href.startsWith("http");
227
+ const rel: string = isExternal
228
+ ? ' target="_blank" rel="noopener noreferrer"'
229
+ : "";
230
+ return `<a href="${href}"${rel} class="text-emerald-700 font-medium underline decoration-emerald-200 underline-offset-2 hover:decoration-emerald-400 break-words">${text}</a>`;
231
+ };
232
+
233
+ this.blogValidationRenderer = renderer;
234
+
235
+ return renderer;
236
+ }
237
+
135
238
  private static slugify(text: string): string {
136
239
  return text
137
240
  .toLowerCase()
@@ -193,8 +193,11 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
193
193
  return record;
194
194
  }
195
195
 
196
- private escapeStringLiteral(raw: string): string {
196
+ private escapeStringLiteral(raw: string | undefined | null): string {
197
197
  // escape String literal based on https://clickhouse.com/docs/en/sql-reference/syntax#string
198
+ if (raw === undefined || raw === null) {
199
+ return "''";
200
+ }
198
201
  return `'${raw.replace(/'|\\/g, "\\$&")}'`;
199
202
  }
200
203
 
@@ -385,6 +385,84 @@ export default class CodeRepositoryUtil {
385
385
  return hash.trim();
386
386
  }
387
387
 
388
+ /**
389
+ * Returns the author (name + email) and the list of files changed for every
390
+ * non-merge commit in the repository, newest first. Optionally limited to a
391
+ * pathspec (e.g. "posts"). Used to derive per-directory contributors.
392
+ *
393
+ * Note: a shallow clone (`--depth 1`) only has the latest commit, so this
394
+ * returns a single contributor unless the repo was cloned with history.
395
+ */
396
+ @CaptureSpan()
397
+ public static async getCommitAuthorsWithFiles(data: {
398
+ repoPath: string;
399
+ path?: string | undefined;
400
+ }): Promise<
401
+ Array<{
402
+ authorName: string;
403
+ authorEmail: string;
404
+ files: Array<string>;
405
+ }>
406
+ > {
407
+ /*
408
+ * ASCII record (0x1e) / unit (0x1f) separators never occur in author names,
409
+ * emails or file paths, so they delimit commits and fields unambiguously.
410
+ */
411
+ const recordSeparator: string = "\x1e";
412
+ const unitSeparator: string = "\x1f";
413
+
414
+ const args: Array<string> = [
415
+ "log",
416
+ "--no-merges",
417
+ `--pretty=format:${recordSeparator}%an${unitSeparator}%ae`,
418
+ "--name-only",
419
+ ];
420
+
421
+ if (data.path) {
422
+ args.push("--", data.path);
423
+ }
424
+
425
+ const stdout: string = await Execute.executeCommandFile({
426
+ command: "git",
427
+ args,
428
+ cwd: path.resolve(data.repoPath),
429
+ // git log over a full repo can easily exceed the 1 MB default.
430
+ maxBuffer: 256 * 1024 * 1024,
431
+ });
432
+
433
+ const commits: Array<{
434
+ authorName: string;
435
+ authorEmail: string;
436
+ files: Array<string>;
437
+ }> = [];
438
+
439
+ for (const record of stdout.split(recordSeparator)) {
440
+ if (!record.trim()) {
441
+ continue;
442
+ }
443
+
444
+ const lines: Array<string> = record.split("\n");
445
+ const header: string = lines.shift() || "";
446
+ const [authorName, authorEmail] = header.split(unitSeparator);
447
+
448
+ const files: Array<string> = lines
449
+ .map((line: string) => {
450
+ return line.trim();
451
+ })
452
+ .filter((line: string) => {
453
+ return line.length > 0;
454
+ });
455
+
456
+ commits.push({
457
+ authorName: (authorName || "").trim(),
458
+ authorEmail: (authorEmail || "").trim(),
459
+ files,
460
+ });
461
+ }
462
+
463
+ return commits;
464
+ }
465
+
388
466
  @CaptureSpan()
389
467
  public static async listFilesInDirectory(data: {
390
468
  directoryPath: string;
@@ -54,6 +54,11 @@ export default class Execute {
54
54
  command: string;
55
55
  args: Array<string>;
56
56
  cwd: string;
57
+ /*
58
+ * Override the child_process default stdout/stderr cap (1 MB). Commands that
59
+ * can emit large output (e.g. `git log` over a whole repo) should raise this.
60
+ */
61
+ maxBuffer?: number | undefined;
57
62
  }): Promise<string> {
58
63
  return new Promise(
59
64
  (
@@ -65,6 +70,7 @@ export default class Execute {
65
70
  data.args,
66
71
  {
67
72
  cwd: data.cwd,
73
+ ...(data.maxBuffer ? { maxBuffer: data.maxBuffer } : {}),
68
74
  },
69
75
  (err: ExecException | null, stdout: string, stderr: string) => {
70
76
  if (err) {