@coldtea/pr-lens-cli 0.8.1 → 0.10.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.
Files changed (72) hide show
  1. package/dist/canvas/api.d.ts +104 -1
  2. package/dist/canvas/api.d.ts.map +1 -1
  3. package/dist/canvas/api.js +48 -1
  4. package/dist/canvas/api.js.map +1 -1
  5. package/dist/canvas/live.d.ts +15 -0
  6. package/dist/canvas/live.d.ts.map +1 -0
  7. package/dist/canvas/live.js +417 -0
  8. package/dist/canvas/live.js.map +1 -0
  9. package/dist/canvas/registry.d.ts +8 -0
  10. package/dist/canvas/registry.d.ts.map +1 -1
  11. package/dist/canvas/registry.js +2 -0
  12. package/dist/canvas/registry.js.map +1 -1
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +1 -0
  15. package/dist/cli.js.map +1 -1
  16. package/dist/commands/analyze.d.ts.map +1 -1
  17. package/dist/commands/analyze.js +25 -6
  18. package/dist/commands/analyze.js.map +1 -1
  19. package/dist/commands/auth.d.ts +2 -0
  20. package/dist/commands/auth.d.ts.map +1 -1
  21. package/dist/commands/auth.js +1 -1
  22. package/dist/commands/auth.js.map +1 -1
  23. package/dist/commands/canvas.d.ts.map +1 -1
  24. package/dist/commands/canvas.js +29 -2
  25. package/dist/commands/canvas.js.map +1 -1
  26. package/dist/commands/comment.d.ts +1 -1
  27. package/dist/commands/comment.d.ts.map +1 -1
  28. package/dist/commands/comment.js +16 -2
  29. package/dist/commands/comment.js.map +1 -1
  30. package/dist/commands/render.d.ts +1 -1
  31. package/dist/commands/render.d.ts.map +1 -1
  32. package/dist/commands/render.js +9 -4
  33. package/dist/commands/render.js.map +1 -1
  34. package/dist/comment.d.ts +4 -0
  35. package/dist/comment.d.ts.map +1 -1
  36. package/dist/comment.js +193 -33
  37. package/dist/comment.js.map +1 -1
  38. package/dist/config-file.d.ts +7 -2
  39. package/dist/config-file.d.ts.map +1 -1
  40. package/dist/config-file.js +14 -2
  41. package/dist/config-file.js.map +1 -1
  42. package/dist/errors.d.ts +1 -1
  43. package/dist/errors.d.ts.map +1 -1
  44. package/dist/errors.js.map +1 -1
  45. package/dist/git.d.ts +17 -0
  46. package/dist/git.d.ts.map +1 -1
  47. package/dist/git.js +37 -2
  48. package/dist/git.js.map +1 -1
  49. package/dist/skill-content.generated.d.ts +1 -1
  50. package/dist/skill-content.generated.d.ts.map +1 -1
  51. package/dist/skill-content.generated.js +1 -1
  52. package/dist/skill-content.generated.js.map +1 -1
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.d.ts.map +1 -1
  55. package/dist/version.js +1 -1
  56. package/dist/version.js.map +1 -1
  57. package/package.json +3 -3
  58. package/src/canvas/api.ts +109 -0
  59. package/src/canvas/live.ts +523 -0
  60. package/src/canvas/registry.ts +2 -0
  61. package/src/cli.ts +1 -0
  62. package/src/commands/analyze.ts +30 -6
  63. package/src/commands/auth.ts +1 -1
  64. package/src/commands/canvas.ts +36 -2
  65. package/src/commands/comment.ts +18 -2
  66. package/src/commands/render.ts +18 -5
  67. package/src/comment.ts +249 -34
  68. package/src/config-file.ts +14 -2
  69. package/src/errors.ts +3 -0
  70. package/src/git.ts +38 -3
  71. package/src/skill-content.generated.ts +1 -1
  72. package/src/version.ts +1 -1
package/src/comment.ts CHANGED
@@ -1,4 +1,10 @@
1
- import { assertNever } from "@coldtea/pr-lens-schema";
1
+ import {
2
+ assertNever,
3
+ surfaceFor,
4
+ type CommentSurface,
5
+ type MarkdownDialect,
6
+ type Provider,
7
+ } from "@coldtea/pr-lens-schema";
2
8
  import type { GraphDoc, Lens, RenderAsset, RenderManifest, View } from "@coldtea/pr-lens-schema";
3
9
  import { PrLensCliError } from "./errors.js";
4
10
 
@@ -9,6 +15,25 @@ import { PrLensCliError } from "./errors.js";
9
15
  */
10
16
  export const COMMENT_MARKER = "<!-- pr-lens -->";
11
17
 
18
+ /**
19
+ * Bitbucket renders no HTML, and an HTML comment there risks appearing as
20
+ * text. A link-reference definition is the markdown-native invisible line:
21
+ * consumed by the renderer, still greppable in the source.
22
+ */
23
+ const BITBUCKET_COMMENT_MARKER = "[pr-lens]: #pr-lens";
24
+
25
+ export const commentMarker = (target: Provider): string => {
26
+ switch (target) {
27
+ case "github":
28
+ case "gitlab":
29
+ return COMMENT_MARKER;
30
+ case "bitbucket":
31
+ return BITBUCKET_COMMENT_MARKER;
32
+ default:
33
+ return assertNever(target, "Unhandled provider");
34
+ }
35
+ };
36
+
12
37
  const PROJECT_URL = "https://github.com/coldteadotai/pr-lens";
13
38
  const COLDTEA_URL = "https://coldtea.ai";
14
39
 
@@ -18,6 +43,8 @@ export type CommentOptions = {
18
43
  /** Prefix for assets the manifest records as local paths, e.g. a raw content URL. */
19
44
  assetBaseUrl: string | undefined;
20
45
  branding: boolean;
46
+ /** Which forge will render the comment. Defaults to GitHub, today's output. */
47
+ target?: Provider;
21
48
  };
22
49
 
23
50
  const escape = (value: string): string =>
@@ -27,23 +54,65 @@ const escape = (value: string): string =>
27
54
  .replaceAll(">", "&gt;")
28
55
  .replaceAll('"', "&quot;");
29
56
 
57
+ /**
58
+ * Which characters, at the start of a rendered word, a forge turns into a
59
+ * notification or a cross-link after markdown has run. GitHub reads @ and #;
60
+ * GitLab additionally reads ! (merge requests), ~ (labels), % (milestones),
61
+ * $ (snippets) and & (epics). The guard is a zero-width space after the
62
+ * sigil, so a diff's say-so never pages a person or links an issue.
63
+ */
64
+ const sigilsFor = (dialect: Exclude<MarkdownDialect, "python-markdown">): RegExp => {
65
+ switch (dialect) {
66
+ case "gfm":
67
+ return /([@#])(?=[\w-])/g;
68
+ // The lookahead runs on the escaped string, so a quoted reference like
69
+ // ~"multi word" appears as ~&quot;… — matched as the entity.
70
+ case "glfm":
71
+ return /([@#!~%$])(?=[\w-]|&quot;)/g;
72
+ default:
73
+ return assertNever(dialect, "Unhandled dialect");
74
+ }
75
+ };
76
+
30
77
  /**
31
78
  * Model-authored prose, rendered as the words it is.
32
79
  *
33
80
  * Every string in the document was written by a model reading a diff, and a
34
81
  * pull request can carry whatever text an author likes into that diff. So
35
- * none of it may reach GitHub as markup: escaping the HTML is only half the
36
- * job, because markdown would still turn `[Security update](http://…)` into a
37
- * link that looks like ours. Each string therefore lands inside an HTML
38
- * element — inside one, markdown is not parsed at all — on a single line, so
39
- * a blank line cannot end the block and let the rest through.
82
+ * none of it may reach the forge as markup: escaping the HTML is only half
83
+ * the job, because markdown would still turn `[Security update](http://…)`
84
+ * into a link that looks like ours. Each string therefore lands inside an
85
+ * HTML element — inside one, markdown is not parsed at all — on a single
86
+ * line, so a blank line cannot end the block and let the rest through.
40
87
  *
41
- * The zero-width space after an @ or a # is the last piece: those are matched
42
- * after markdown, on the rendered text, and would otherwise notify a person
43
- * or cross-link an issue on the say-so of a diff.
88
+ * The zero-width space after a reference sigil is the last piece: those are
89
+ * matched after markdown, on the rendered text, and would otherwise notify a
90
+ * person or cross-link an issue on the say-so of a diff. GitLab's & becomes
91
+ * &amp; during escaping, so its guard runs on the entity.
92
+ */
93
+ const text = (value: string, dialect: Exclude<MarkdownDialect, "python-markdown">): string => {
94
+ const guarded = escape(value.replace(/\s+/g, " ").trim()).replace(sigilsFor(dialect), "$1&#8203;");
95
+ return dialect === "glfm" ? guarded.replace(/&amp;(?=[\w-]|&quot;)/g, "&amp;&#8203;") : guarded;
96
+ };
97
+
98
+ /**
99
+ * The same job for a forge that renders no HTML: there is no element to hide
100
+ * inside, so every character Python-Markdown can act on is backslash-escaped
101
+ * instead — its documented escapable set. What that set cannot cover is
102
+ * broken with a zero-width space instead: doubled tildes (strikethrough via
103
+ * the del extension), reference sigils, and an opening `<`, which is not
104
+ * escapable and would otherwise hand a raw tag to whatever HTML subset the
105
+ * forge's renderer lets through. An entity would risk rendering as text on
106
+ * a forge that escapes ampersands, so the space is the literal character.
44
107
  */
45
- const text = (value: string): string =>
46
- escape(value.replace(/\s+/g, " ").trim()).replace(/([@#])(?=[\w-])/g, "$1&#8203;");
108
+ const proseMd = (value: string): string =>
109
+ value
110
+ .replace(/\s+/g, " ")
111
+ .trim()
112
+ .replace(/[\\`*_{}[\]()>#+\-.!]/g, "\\$&")
113
+ .replace(/~~/g, "~\u200B~")
114
+ .replace(/<(?=[A-Za-z/!?])/g, "<\u200B")
115
+ .replace(/([@#])(?=[\w-])/g, "$1\u200B");
47
116
 
48
117
  const href = (asset: RenderAsset, assetBaseUrl: string | undefined): string => {
49
118
  if (asset.url !== undefined) return asset.url;
@@ -57,36 +126,71 @@ const href = (asset: RenderAsset, assetBaseUrl: string | undefined): string => {
57
126
  return `${assetBaseUrl.replace(/\/+$/, "")}/${asset.path.replace(/^\/+/, "")}`;
58
127
  };
59
128
 
60
- type ThemePair = { light: RenderAsset | undefined; dark: RenderAsset | undefined };
129
+ /** A bare markdown destination: the characters that would end or nest it are percent-encoded. */
130
+ const hrefMd = (asset: RenderAsset, assetBaseUrl: string | undefined): string =>
131
+ href(asset, assetBaseUrl).replace(/[<>() ]/g, (found) => `%${found.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
132
+
133
+ type ThemePair = {
134
+ light: RenderAsset | undefined;
135
+ dark: RenderAsset | undefined;
136
+ neutral: RenderAsset | undefined;
137
+ };
138
+
139
+ const EMPTY_PAIR: ThemePair = { light: undefined, dark: undefined, neutral: undefined };
61
140
 
62
141
  const pairsByLens = (assets: readonly RenderAsset[]): Map<Lens, ThemePair> => {
63
142
  const pairs = new Map<Lens, ThemePair>();
64
143
  for (const asset of assets) {
65
- const pair = pairs.get(asset.lens) ?? { light: undefined, dark: undefined };
144
+ const pair = pairs.get(asset.lens) ?? EMPTY_PAIR;
66
145
  pairs.set(asset.lens, { ...pair, [asset.theme]: asset });
67
146
  }
68
147
  return pairs;
69
148
  };
70
149
 
150
+ /**
151
+ * The one asset a single-image surface shows.
152
+ *
153
+ * `neutral` first, because it is the render made for exactly this and reads
154
+ * on a light page and a dark one alike. Falling back to a half of the pair is
155
+ * for a manifest rendered before neutral existed, or one rendered `--theme
156
+ * light` by hand: better a diagram tuned for the wrong ground than no
157
+ * diagram, but it is a fallback and not the intent.
158
+ */
159
+ const single = (pair: ThemePair): RenderAsset | undefined =>
160
+ pair.neutral ?? pair.light ?? pair.dark;
161
+
71
162
  /**
72
163
  * A `<picture>` is what makes one comment readable in both GitHub themes: the
73
164
  * dark source is swapped in by the browser, with the light asset as the `img`
74
165
  * every other reader — email, mobile, an old client — falls back to.
75
166
  *
167
+ * GitLab strips `picture` and `source`, so there one image stands alone
168
+ * rather than trusting a sanitizer to unwrap gracefully — and that image is
169
+ * the neutral render, which reads on either ground, instead of a half of the
170
+ * pair that reads well on one.
171
+ *
76
172
  * The whole thing is a link to the image itself, because a comment column is
77
173
  * about 830 pixels wide and a diagram of a system with several lanes is
78
174
  * several times that. It arrives scaled to fit, which is right for scanning,
79
175
  * and one click gives a reader the size the labels were drawn at.
80
176
  */
81
- const picture = (pair: ThemePair, alt: string, assetBaseUrl: string | undefined): string => {
82
- const fallback = pair.light ?? pair.dark;
83
- if (fallback === undefined) return "";
177
+ const picture = (
178
+ pair: ThemePair,
179
+ alt: string,
180
+ assetBaseUrl: string | undefined,
181
+ surface: HtmlSurface,
182
+ ): string => {
183
+ // A paired surface shows the light half as the `img` every non-swapping
184
+ // client falls back to; a single-image surface shows the neutral render.
185
+ const paired = surface.themeStrategy === "pair" && pair.light !== undefined && pair.dark !== undefined;
186
+ const shownAsset = paired ? pair.light : single(pair);
187
+ if (shownAsset === undefined) return "";
84
188
 
85
- const source = escape(href(fallback, assetBaseUrl));
86
- const image = `<img alt="${text(alt)}" src="${source}" width="${fallback.width}">`;
189
+ const source = escape(href(shownAsset, assetBaseUrl));
190
+ const image = `<img alt="${text(alt, surface.dialect)}" src="${source}" width="${shownAsset.width}">`;
87
191
 
88
192
  const shown =
89
- pair.dark === undefined || pair.light === undefined
193
+ !paired || pair.dark === undefined
90
194
  ? image
91
195
  : [
92
196
  "<picture>",
@@ -109,11 +213,11 @@ const lensLabel = (lens: Lens): string => {
109
213
  }
110
214
  };
111
215
 
112
- const statsLine = (graph: GraphDoc): string => {
216
+ const statChips = (graph: GraphDoc): string[] => {
113
217
  const { stats } = graph;
114
- if (stats === undefined) return "";
218
+ if (stats === undefined) return [];
115
219
 
116
- const chips = [
220
+ return [
117
221
  stats.filesChanged === undefined
118
222
  ? undefined
119
223
  : `${stats.filesChanged} ${stats.filesChanged === 1 ? "file" : "files"}`,
@@ -121,27 +225,32 @@ const statsLine = (graph: GraphDoc): string => {
121
225
  stats.deletions === undefined ? undefined : `−${stats.deletions}`,
122
226
  ...stats.chips.map((chip) => `${chip.label} ${chip.value}`),
123
227
  ].filter((chip): chip is string => chip !== undefined);
228
+ };
124
229
 
125
- return `<p>${chips.map((chip) => `<code>${text(chip)}</code>`).join(" · ")}</p>`;
230
+ const statsLine = (graph: GraphDoc, surface: HtmlSurface): string => {
231
+ const chips = statChips(graph);
232
+ if (chips.length === 0) return "";
233
+ return `<p>${chips.map((chip) => `<code>${text(chip, surface.dialect)}</code>`).join(" · ")}</p>`;
126
234
  };
127
235
 
128
236
  const viewSection = (
129
237
  view: View,
130
238
  assets: Map<string, RenderAsset[]>,
131
239
  assetBaseUrl: string | undefined,
240
+ surface: HtmlSurface,
132
241
  ): string => {
133
242
  const own = assets.get(view.id) ?? [];
134
243
  const pair = pairsByLens(own).get(view.lens);
135
244
 
136
245
  const body = [
137
- view.summary === undefined ? "" : `<p>${text(view.summary)}</p>`,
138
- pair === undefined ? "" : picture(pair, view.title, assetBaseUrl),
139
- ...view.children.map((child) => viewSection(child, assets, assetBaseUrl)),
246
+ view.summary === undefined ? "" : `<p>${text(view.summary, surface.dialect)}</p>`,
247
+ pair === undefined ? "" : picture(pair, view.title, assetBaseUrl, surface),
248
+ ...view.children.map((child) => viewSection(child, assets, assetBaseUrl, surface)),
140
249
  ].filter((part) => part !== "");
141
250
 
142
251
  return [
143
252
  `<details${view.defaultOpen ? " open" : ""}>`,
144
- `<summary><b>${text(view.title)}</b></summary>`,
253
+ `<summary><b>${text(view.title, surface.dialect)}</b></summary>`,
145
254
  "",
146
255
  ...body.flatMap((part) => [part, ""]),
147
256
  "</details>",
@@ -160,27 +269,44 @@ const byView = (manifest: RenderManifest): Map<string, RenderAsset[]> => {
160
269
  return grouped;
161
270
  };
162
271
 
163
- export const composeComment = (options: CommentOptions): string => {
272
+ /** The two HTML-rendering surfaces differ only in theme pairing and sigils. */
273
+ type HtmlSurface = {
274
+ dialect: Exclude<MarkdownDialect, "python-markdown">;
275
+ themeStrategy: CommentSurface["themeStrategy"];
276
+ };
277
+
278
+ const composeHtml = (
279
+ options: CommentOptions,
280
+ surface: HtmlSurface,
281
+ marker: string,
282
+ keptViews: number,
283
+ ): string => {
164
284
  const { graph, manifest, assetBaseUrl, branding } = options;
165
285
  const assets = byView(manifest);
166
286
  const roots = pairsByLens(assets.get(ROOT) ?? []);
167
287
 
168
288
  const diagrams = graph.lenses.flatMap((lens) => {
169
289
  const pair = roots.get(lens);
170
- return pair === undefined ? [] : [picture(pair, `${graph.title} — ${lensLabel(lens)}`, assetBaseUrl)];
290
+ return pair === undefined
291
+ ? []
292
+ : [picture(pair, `${graph.title} — ${lensLabel(lens)}`, assetBaseUrl, surface)];
171
293
  });
172
294
 
295
+ const omitted = graph.views.length - keptViews;
173
296
  const footer = branding
174
297
  ? `<sub>◈ Rendered by <a href="${PROJECT_URL}">PR Lens</a> · from the team behind <a href="${COLDTEA_URL}">Coldtea</a></sub>`
175
298
  : "";
176
299
 
177
300
  return [
178
- COMMENT_MARKER,
179
- `<h3>${text(graph.title)}</h3>`,
180
- graph.summary === undefined ? "" : `<p>${text(graph.summary)}</p>`,
181
- statsLine(graph),
301
+ marker,
302
+ `<h3>${text(graph.title, surface.dialect)}</h3>`,
303
+ graph.summary === undefined ? "" : `<p>${text(graph.summary, surface.dialect)}</p>`,
304
+ statsLine(graph, surface),
182
305
  ...diagrams,
183
- ...graph.views.map((view) => viewSection(view, assets, assetBaseUrl)),
306
+ ...graph.views.slice(0, keptViews).map((view) => viewSection(view, assets, assetBaseUrl, surface)),
307
+ omitted === 0
308
+ ? ""
309
+ : `<sub>${omitted} drill-down ${omitted === 1 ? "section" : "sections"} did not fit this comment.</sub>`,
184
310
  footer === "" ? "" : "---",
185
311
  footer,
186
312
  ]
@@ -188,3 +314,92 @@ export const composeComment = (options: CommentOptions): string => {
188
314
  .join("\n\n")
189
315
  .concat("\n");
190
316
  };
317
+
318
+ const diagramMd = (pair: ThemePair, alt: string, assetBaseUrl: string | undefined): string => {
319
+ // With no HTML there is no theme pairing at all, so the neutral render is
320
+ // the only one that serves a reader whichever theme they are in.
321
+ const shown = single(pair);
322
+ if (shown === undefined) return "";
323
+
324
+ const source = hrefMd(shown, assetBaseUrl);
325
+ return `[![${proseMd(alt)}](${source})](${source})`;
326
+ };
327
+
328
+ /** The drill-down tree flattened: with no collapsible to nest in, order carries the hierarchy. */
329
+ const viewSectionsMd = (
330
+ views: readonly View[],
331
+ assets: Map<string, RenderAsset[]>,
332
+ assetBaseUrl: string | undefined,
333
+ ): string[] =>
334
+ views.flatMap((view) => {
335
+ const pair = pairsByLens(assets.get(view.id) ?? []).get(view.lens);
336
+ return [
337
+ `**${proseMd(view.title)}**`,
338
+ ...(view.summary === undefined ? [] : [proseMd(view.summary)]),
339
+ ...(pair === undefined ? [] : [diagramMd(pair, view.title, assetBaseUrl)]),
340
+ ...viewSectionsMd(view.children, assets, assetBaseUrl),
341
+ ];
342
+ });
343
+
344
+ const composeMarkdown = (options: CommentOptions, marker: string, keptViews: number): string => {
345
+ const { graph, manifest, assetBaseUrl, branding } = options;
346
+ const assets = byView(manifest);
347
+ const roots = pairsByLens(assets.get(ROOT) ?? []);
348
+
349
+ const diagrams = graph.lenses.flatMap((lens) => {
350
+ const pair = roots.get(lens);
351
+ return pair === undefined ? [] : [diagramMd(pair, `${graph.title} — ${lensLabel(lens)}`, assetBaseUrl)];
352
+ });
353
+
354
+ const chips = statChips(graph);
355
+ const omitted = graph.views.length - keptViews;
356
+
357
+ return [
358
+ marker,
359
+ `### ${proseMd(graph.title)}`,
360
+ graph.summary === undefined ? "" : proseMd(graph.summary),
361
+ chips.length === 0 ? "" : chips.map((chip) => proseMd(chip)).join(" · "),
362
+ ...diagrams,
363
+ ...viewSectionsMd(graph.views.slice(0, keptViews), assets, assetBaseUrl),
364
+ omitted === 0
365
+ ? ""
366
+ : `*${omitted} drill-down ${omitted === 1 ? "section" : "sections"} did not fit this comment.*`,
367
+ branding
368
+ ? `*Rendered by [PR Lens](${PROJECT_URL}) · from the team behind [Coldtea](${COLDTEA_URL})*`
369
+ : "",
370
+ ]
371
+ .filter((block) => block !== "")
372
+ .join("\n\n")
373
+ .concat("\n");
374
+ };
375
+
376
+ export const composeComment = (options: CommentOptions): string => {
377
+ const target = options.target ?? "github";
378
+ const surface = surfaceFor(target);
379
+ const marker = commentMarker(target);
380
+
381
+ const compose = (keptViews: number): string => {
382
+ switch (surface.dialect) {
383
+ case "gfm":
384
+ case "glfm":
385
+ return composeHtml(
386
+ options,
387
+ { dialect: surface.dialect, themeStrategy: surface.themeStrategy },
388
+ marker,
389
+ keptViews,
390
+ );
391
+ case "python-markdown":
392
+ return composeMarkdown(options, marker, keptViews);
393
+ default:
394
+ return assertNever(surface.dialect, "Unhandled dialect");
395
+ }
396
+ };
397
+
398
+ // A body over the forge's limit would be rejected outright, so trailing
399
+ // drill-down sections are shed until it fits: the headline, the numbers and
400
+ // the root diagrams are worth more than the deepest view.
401
+ for (let keptViews = options.graph.views.length; ; keptViews -= 1) {
402
+ const body = compose(keptViews);
403
+ if (body.length <= surface.maxChars || keptViews === 0) return body;
404
+ }
405
+ };
@@ -3,8 +3,20 @@ import { access } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { readConfig } from "./document.js";
5
5
 
6
- /** Where a repository puts its corrections, in the order they are looked for. */
7
- export const CONFIG_PATHS = [".github/pr-lens.yml", ".github/pr-lens.yaml"] as const;
6
+ /**
7
+ * Where a repository puts its corrections, in the order they are looked for.
8
+ * `.github/` stays first so no existing repository changes behavior; the
9
+ * `.gitlab/` and root spellings exist because a GitLab or Bitbucket
10
+ * repository has no `.github/` directory to put a config in.
11
+ */
12
+ export const CONFIG_PATHS = [
13
+ ".github/pr-lens.yml",
14
+ ".github/pr-lens.yaml",
15
+ ".gitlab/pr-lens.yml",
16
+ ".gitlab/pr-lens.yaml",
17
+ "pr-lens.yml",
18
+ "pr-lens.yaml",
19
+ ] as const;
8
20
 
9
21
  export type LoadedConfig = { path: string; config: Config };
10
22
 
package/src/errors.ts CHANGED
@@ -23,6 +23,9 @@ export type CliErrorCode =
23
23
  | "CANVAS_RATE_LIMITED"
24
24
  | "CANVAS_UNAVAILABLE"
25
25
  | "CANVAS_REGISTRY_EXPOSED"
26
+ | "LIVE_UNOPENED"
27
+ | "LIVE_ENDED"
28
+ | "LIVE_UNKNOWN_PLACE"
26
29
  | "AUTH_REQUIRED"
27
30
  | "MACHINE_REVOKED"
28
31
  | "APP_UNAVAILABLE";
package/src/git.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { assertNever, type Provider } from "@coldtea/pr-lens-schema";
1
2
  import { execFile } from "node:child_process";
2
3
  import { promisify } from "node:util";
3
4
  import { PrLensCliError } from "./errors.js";
@@ -115,7 +116,41 @@ export const collectDiff = async (
115
116
  };
116
117
  };
117
118
 
118
- export type RepoSlug = { owner: string; name: string; host: string };
119
+ export type RepoSlug = { owner: string; name: string; host: string; provider: Provider };
120
+
121
+ /**
122
+ * Which forge a host belongs to. bitbucket.org is exact because Bitbucket
123
+ * Cloud has one host; anything with "gitlab" in the name is taken to be a
124
+ * self-managed GitLab, since those hosts follow no other convention; and
125
+ * everything else is treated as GitHub, which keeps today's behavior for
126
+ * every repository the CLI already handles. A self-managed GitLab on a host
127
+ * that does not say so is what `--forge` exists for.
128
+ */
129
+ export const providerForHost = (host: string): Provider => {
130
+ const lowered = host.toLowerCase();
131
+ if (lowered === "bitbucket.org") return "bitbucket";
132
+ if (lowered.includes("gitlab")) return "gitlab";
133
+ return "github";
134
+ };
135
+
136
+ /**
137
+ * Where a pull request lives on each forge. The number is the same concept
138
+ * everywhere — GitLab's iid, Bitbucket's id — but each forge spells the path
139
+ * differently, and a wrong spelling is a 404 in the reader's face.
140
+ */
141
+ export const pullRequestUrl = (slug: RepoSlug, pr: number): string => {
142
+ const repository = `https://${slug.host}/${slug.owner}/${slug.name}`;
143
+ switch (slug.provider) {
144
+ case "github":
145
+ return `${repository}/pull/${pr}`;
146
+ case "gitlab":
147
+ return `${repository}/-/merge_requests/${pr}`;
148
+ case "bitbucket":
149
+ return `${repository}/pull-requests/${pr}`;
150
+ default:
151
+ return assertNever(slug.provider, "Unhandled provider");
152
+ }
153
+ };
119
154
 
120
155
  const REMOTE_URL = /^(?:(?:ssh|git|https?):\/\/)?(?:[^@/]+@)?([^/:]+)[:/](.+?)(?:\.git)?\/?$/;
121
156
 
@@ -131,7 +166,7 @@ export const parseRemoteUrl = (url: string): RepoSlug | undefined => {
131
166
  const owner = segments.slice(0, -1).join("/");
132
167
  if (name === undefined || owner === "") return undefined;
133
168
 
134
- return { owner, name, host };
169
+ return { owner, name, host, provider: providerForHost(host) };
135
170
  };
136
171
 
137
172
  export const parseRepoSlug = (slug: string): RepoSlug | undefined => {
@@ -139,7 +174,7 @@ export const parseRepoSlug = (slug: string): RepoSlug | undefined => {
139
174
  const name = segments.at(-1);
140
175
  const owner = segments.slice(0, -1).join("/");
141
176
  if (name === undefined || owner === "" || segments.length !== 2) return undefined;
142
- return { owner, name, host: "github.com" };
177
+ return { owner, name, host: "github.com", provider: "github" };
143
178
  };
144
179
 
145
180
  export const remoteSlug = async (repo: string, remote: string): Promise<RepoSlug | undefined> => {