@buildinternet/uploads 0.42.0 → 0.42.2
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 +3 -3
- package/dist/cli-help.js +2 -2
- package/dist/commands/hook.js +1 -0
- package/dist/commands/install.js +2 -2
- package/dist/comment-config.d.ts +7 -54
- package/dist/comment-config.generated.d.ts +45 -0
- package/dist/comment-config.generated.js +165 -0
- package/dist/comment-config.js +7 -174
- package/dist/comment-render.generated.d.ts +151 -0
- package/dist/comment-render.generated.js +534 -0
- package/dist/github.d.ts +2 -140
- package/dist/github.js +4 -492
- package/dist/hooks-install.d.ts +10 -2
- package/dist/hooks-install.js +31 -3
- package/dist/mcp/output-schemas.d.ts +37 -0
- package/dist/mcp/output-schemas.js +338 -0
- package/dist/mcp/server.d.ts +52 -0
- package/dist/mcp/server.js +65 -0
- package/dist/mcp/tools.js +69 -2
- package/package.json +1 -1
package/dist/github.d.ts
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
3
|
-
/** "owner/name" */
|
|
4
|
-
repo: string;
|
|
5
|
-
kind: GhTargetKind;
|
|
6
|
-
num: number;
|
|
7
|
-
}
|
|
1
|
+
import { type GhTarget } from "./comment-render.generated.js";
|
|
2
|
+
export { AUTO_RENDER_OPTIONS, ATTACHMENT_IMAGE_WIDTH_DEFAULT, ATTACHMENT_IMAGE_WIDTH_PAIR, ATTACHMENT_IMAGE_WIDTH_PORTRAIT, ATTACHMENT_IMAGE_WIDTH_WIDE, ATTACHMENTS_MARKER, GH_PRIVATE_ROOT, MAX_INLINE_ATTACHMENT_IMAGES, attachmentDensityForCount, attachmentImageWidth, attachmentPairWidth, attachmentsCommentBody, attachmentsMarker, ghKeyPrefix, ghPrivateAttachmentKey, ghPrivateBranchAttachmentKey, ghPrivateBranchKeyPrefix, ghPrivateKeyPrefix, parseGhPrivateKey, type AttachmentDensity, type AttachmentItem, type CommentRenderOptions, type GalleryCommentItem, type GhTarget, type GhTargetKind, } from "./comment-render.generated.js";
|
|
8
3
|
/** A normalized GitHub issue/PR coordinate used for gallery references. */
|
|
9
4
|
export interface GithubCoordinate {
|
|
10
5
|
coordinate: string;
|
|
@@ -30,37 +25,6 @@ export declare function normalizeGithubCoordinate(value: string): GithubCoordina
|
|
|
30
25
|
* actually named "private", not a private-prefix key.
|
|
31
26
|
*/
|
|
32
27
|
export declare function parseGhKey(key: string): GhTarget | undefined;
|
|
33
|
-
/** Literal root under which every private-repo attachment key lives. */
|
|
34
|
-
export declare const GH_PRIVATE_ROOT = "gh/private/";
|
|
35
|
-
/**
|
|
36
|
-
* Private-repo key prefix: `gh/private/<32-hex-id>/<kind>/<num>/`.
|
|
37
|
-
* Deliberately omits the repo (unlike `ghKeyPrefix`) — the id is a random,
|
|
38
|
-
* unguessable per-repo prefix rather than an owner/name path, so callers
|
|
39
|
-
* that need the repo back must read `gh.repo` metadata (see
|
|
40
|
-
* `parseGhPrivateKey`, which cannot recover it from the key alone).
|
|
41
|
-
*/
|
|
42
|
-
export declare function ghPrivateKeyPrefix(prefixId: string, target: GhTarget): string;
|
|
43
|
-
/** Private-repo attachment key: `ghPrivateKeyPrefix` + the sanitized filename. */
|
|
44
|
-
export declare function ghPrivateAttachmentKey(prefixId: string, target: GhTarget, filename: string): string;
|
|
45
|
-
/**
|
|
46
|
-
* Private-repo branch-staged key prefix: `gh/private/<32-hex-id>/branch/`.
|
|
47
|
-
* Unlike `ghBranchKeyPrefix`, there is deliberately NO branch-name segment —
|
|
48
|
-
* the branch name itself is not embedded in a private-repo key.
|
|
49
|
-
*/
|
|
50
|
-
export declare function ghPrivateBranchKeyPrefix(prefixId: string): string;
|
|
51
|
-
/** Private-repo branch-staged attachment key: `ghPrivateBranchKeyPrefix` + the sanitized filename. */
|
|
52
|
-
export declare function ghPrivateBranchAttachmentKey(prefixId: string, filename: string): string;
|
|
53
|
-
/**
|
|
54
|
-
* Inverse of `ghPrivateKeyPrefix`: parse the prefix id/kind/number back out
|
|
55
|
-
* of a private-repo attachment key, or undefined for any other key shape.
|
|
56
|
-
* Cannot recover the repo — callers that need it read `gh.repo` metadata.
|
|
57
|
-
*/
|
|
58
|
-
export declare function parseGhPrivateKey(key: string): {
|
|
59
|
-
prefixId: string;
|
|
60
|
-
kind: GhTargetKind;
|
|
61
|
-
num: number;
|
|
62
|
-
} | undefined;
|
|
63
|
-
export declare function ghKeyPrefix(target: GhTarget): string;
|
|
64
28
|
/**
|
|
65
29
|
* Stable attachment key: same filename → same key → same public URL, so
|
|
66
30
|
* re-uploading updates every existing embed. Deliberately NO content hash
|
|
@@ -122,108 +86,6 @@ export declare function ghMetadataForBranch(repo: string, branch: string, now?:
|
|
|
122
86
|
* lookup key.
|
|
123
87
|
*/
|
|
124
88
|
export declare function ghMetadataFromTarget(target: GhTarget): Record<string, string>;
|
|
125
|
-
/** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
|
|
126
|
-
export declare const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
|
|
127
|
-
/**
|
|
128
|
-
* Per-workspace marker (`<!-- uploads.sh:attachments ws=<workspace> -->`) so
|
|
129
|
-
* two workspaces managing the same repo don't clobber each other's comment.
|
|
130
|
-
* Falls back to the shared legacy marker when `workspace` is missing or does
|
|
131
|
-
* not look like a safe slug — degrade, don't guess or risk breaking the
|
|
132
|
-
* comment's HTML.
|
|
133
|
-
*/
|
|
134
|
-
export declare function attachmentsMarker(workspace?: string): string;
|
|
135
|
-
/** Max attachments embedded as inline `<img>` tags before the rest collapse
|
|
136
|
-
* into a `<details>` link list. Keeps very large threads from becoming a wall
|
|
137
|
-
* of images. */
|
|
138
|
-
export declare const MAX_INLINE_ATTACHMENT_IMAGES = 16;
|
|
139
|
-
/**
|
|
140
|
-
* Per-render knobs for the managed comment (issue #307), sourced from repo
|
|
141
|
-
* comment config. `imageWidth: "auto"` uses per-item filename heuristics plus
|
|
142
|
-
* density-aware sizing (solo/sparse/dense from the inlined count); `"full"`
|
|
143
|
-
* omits the `width` attribute entirely; a number overrides every width site.
|
|
144
|
-
*/
|
|
145
|
-
export interface CommentRenderOptions {
|
|
146
|
-
imageWidth: "auto" | "full" | number;
|
|
147
|
-
maxInlineImages: number;
|
|
148
|
-
metaPath: boolean;
|
|
149
|
-
metaState: boolean;
|
|
150
|
-
note: string | null;
|
|
151
|
-
}
|
|
152
|
-
/** Today's behavior, expressed as options — the default for every caller that
|
|
153
|
-
* hasn't opted into repo comment config. */
|
|
154
|
-
export declare const AUTO_RENDER_OPTIONS: CommentRenderOptions;
|
|
155
|
-
export interface AttachmentItem {
|
|
156
|
-
key: string;
|
|
157
|
-
url: string | null;
|
|
158
|
-
/** Prefer for `<img src>` on GitHub (Camo-friendly host). Falls back to `url`. */
|
|
159
|
-
embedUrl?: string | null;
|
|
160
|
-
/** Canonical `/f/` file-page URL (server-computed). Preferred click-through target; falls back to `url`. */
|
|
161
|
-
pageUrl?: string | null;
|
|
162
|
-
/**
|
|
163
|
-
* The only canonical metadata the managed comment renders (issue #365).
|
|
164
|
-
* Deliberately two named fields rather than `Record<string, string>`: the
|
|
165
|
-
* comment is posted publicly, and keeping the set narrow at the type level
|
|
166
|
-
* mirrors the server-side query filter that never fetches EXIF-derived
|
|
167
|
-
* keys like `device`/`software` for this path.
|
|
168
|
-
*/
|
|
169
|
-
meta?: {
|
|
170
|
-
path?: string;
|
|
171
|
-
state?: string;
|
|
172
|
-
};
|
|
173
|
-
/**
|
|
174
|
-
* Poster frame for a video (issue #299), server-computed like `embedUrl` —
|
|
175
|
-
* never taken from client-settable metadata. Absent means "no poster", and
|
|
176
|
-
* the renderer falls back to the bullet link.
|
|
177
|
-
*/
|
|
178
|
-
posterUrl?: string | null;
|
|
179
|
-
/** Derived video facts used for the caption and display width. */
|
|
180
|
-
videoMeta?: {
|
|
181
|
-
durationSeconds?: number;
|
|
182
|
-
width?: number;
|
|
183
|
-
height?: number;
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
/** A public gallery linked to the PR or issue whose managed comment is syncing. */
|
|
187
|
-
export interface GalleryCommentItem {
|
|
188
|
-
title: string;
|
|
189
|
-
/** Canonical URL returned by the API; callers must not synthesize it. */
|
|
190
|
-
url: string;
|
|
191
|
-
/** A bounded set of available images; each links to its item page when known, else the gallery. */
|
|
192
|
-
previews?: {
|
|
193
|
-
url: string;
|
|
194
|
-
alt: string;
|
|
195
|
-
embedUrl?: string | null;
|
|
196
|
-
itemUrl?: string;
|
|
197
|
-
}[];
|
|
198
|
-
}
|
|
199
|
-
/**
|
|
200
|
-
* How crowded the managed comment is. Sparse comments (one shot, a single
|
|
201
|
-
* before/after) get larger embeds; dense comments keep compact historical sizes.
|
|
202
|
-
*/
|
|
203
|
-
export type AttachmentDensity = "solo" | "sparse" | "dense";
|
|
204
|
-
/** Dense (historical) default max width for images in the managed comment. */
|
|
205
|
-
export declare const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
|
|
206
|
-
/** Portrait / device mockups — keep phones readable, not full-column. */
|
|
207
|
-
export declare const ATTACHMENT_IMAGE_WIDTH_PORTRAIT = 280;
|
|
208
|
-
/** Wide UI / browser chrome. */
|
|
209
|
-
export declare const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
|
|
210
|
-
/** Dense pair-cell cap (side-by-side before/after). */
|
|
211
|
-
export declare const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
|
|
212
|
-
/** Map an inlined-media count onto a density tier. */
|
|
213
|
-
export declare function attachmentDensityForCount(inlinedCount: number): AttachmentDensity;
|
|
214
|
-
/** Pair-cell cap for the given density. */
|
|
215
|
-
export declare function attachmentPairWidth(density?: AttachmentDensity): number;
|
|
216
|
-
/**
|
|
217
|
-
* Display width for a GitHub comment embed. Filenames are a weak but practical
|
|
218
|
-
* signal (we don't re-fetch dimensions when rebuilding the comment). `density`
|
|
219
|
-
* only affects managed-comment auto layout; other callers leave it `"dense"`.
|
|
220
|
-
*/
|
|
221
|
-
export declare function attachmentImageWidth(filename: string, density?: AttachmentDensity): number;
|
|
222
|
-
/**
|
|
223
|
-
* Render the one marker-owned GitHub comment. When there are no galleries this
|
|
224
|
-
* intentionally preserves the legacy attachment-only body byte-for-byte.
|
|
225
|
-
*/
|
|
226
|
-
export declare function attachmentsCommentBody(items: AttachmentItem[], galleries?: GalleryCommentItem[], marker?: string, options?: CommentRenderOptions): string;
|
|
227
89
|
/**
|
|
228
90
|
* Extra footer line appended only on the local-`gh` fallback path (never on
|
|
229
91
|
* bot-authored comments). Short note that the App isn't on this repo yet.
|
package/dist/github.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { inferContentType } from "./embed.js";
|
|
2
1
|
import { sanitizeKeySegment } from "./keys.js";
|
|
3
2
|
import { UsageError } from "./cli-args.js";
|
|
4
3
|
import { isMetaValueSafe } from "./metadata.js";
|
|
4
|
+
import { ghPrivateAttachmentKey, ghPrivateBranchAttachmentKey, parseGhPrivateKey, } from "./comment-render.generated.js";
|
|
5
|
+
export { AUTO_RENDER_OPTIONS, ATTACHMENT_IMAGE_WIDTH_DEFAULT, ATTACHMENT_IMAGE_WIDTH_PAIR, ATTACHMENT_IMAGE_WIDTH_PORTRAIT, ATTACHMENT_IMAGE_WIDTH_WIDE, ATTACHMENTS_MARKER, GH_PRIVATE_ROOT, MAX_INLINE_ATTACHMENT_IMAGES, attachmentDensityForCount, attachmentImageWidth, attachmentPairWidth, attachmentsCommentBody, attachmentsMarker, ghKeyPrefix, ghPrivateAttachmentKey, ghPrivateBranchAttachmentKey, ghPrivateBranchKeyPrefix, ghPrivateKeyPrefix, parseGhPrivateKey, } from "./comment-render.generated.js";
|
|
5
6
|
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
6
7
|
export function isValidRepo(repo) {
|
|
7
8
|
return REPO_RE.test(repo);
|
|
@@ -76,70 +77,14 @@ export function parseGhKey(key) {
|
|
|
76
77
|
const [, owner, name, kind, num] = match;
|
|
77
78
|
return { repo: `${owner}/${name}`, kind: kind, num: Number(num) };
|
|
78
79
|
}
|
|
79
|
-
/** Literal root under which every private-repo attachment key lives. */
|
|
80
|
-
export const GH_PRIVATE_ROOT = "gh/private/";
|
|
81
|
-
/** Strict shape for a randomized private-repo prefix id: 32 lowercase hex chars. */
|
|
82
|
-
const PRIVATE_PREFIX_ID_RE = /^[0-9a-f]{32}$/;
|
|
83
|
-
/**
|
|
84
|
-
* Guard every private-key builder against a malformed `prefixId` — a
|
|
85
|
-
* caller bug here must never silently produce a guessable-ish key.
|
|
86
|
-
*/
|
|
87
|
-
function assertPrivatePrefixId(prefixId) {
|
|
88
|
-
if (!PRIVATE_PREFIX_ID_RE.test(prefixId)) {
|
|
89
|
-
throw new Error(`invalid private prefix id: "${prefixId}" must be 32 lowercase hex characters`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Private-repo key prefix: `gh/private/<32-hex-id>/<kind>/<num>/`.
|
|
94
|
-
* Deliberately omits the repo (unlike `ghKeyPrefix`) — the id is a random,
|
|
95
|
-
* unguessable per-repo prefix rather than an owner/name path, so callers
|
|
96
|
-
* that need the repo back must read `gh.repo` metadata (see
|
|
97
|
-
* `parseGhPrivateKey`, which cannot recover it from the key alone).
|
|
98
|
-
*/
|
|
99
|
-
export function ghPrivateKeyPrefix(prefixId, target) {
|
|
100
|
-
assertPrivatePrefixId(prefixId);
|
|
101
|
-
return `${GH_PRIVATE_ROOT}${prefixId}/${target.kind}/${target.num}/`;
|
|
102
|
-
}
|
|
103
|
-
/** Private-repo attachment key: `ghPrivateKeyPrefix` + the sanitized filename. */
|
|
104
|
-
export function ghPrivateAttachmentKey(prefixId, target, filename) {
|
|
105
|
-
return `${ghPrivateKeyPrefix(prefixId, target)}${sanitizeKeySegment(filename)}`;
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* Private-repo branch-staged key prefix: `gh/private/<32-hex-id>/branch/`.
|
|
109
|
-
* Unlike `ghBranchKeyPrefix`, there is deliberately NO branch-name segment —
|
|
110
|
-
* the branch name itself is not embedded in a private-repo key.
|
|
111
|
-
*/
|
|
112
|
-
export function ghPrivateBranchKeyPrefix(prefixId) {
|
|
113
|
-
assertPrivatePrefixId(prefixId);
|
|
114
|
-
return `${GH_PRIVATE_ROOT}${prefixId}/branch/`;
|
|
115
|
-
}
|
|
116
|
-
/** Private-repo branch-staged attachment key: `ghPrivateBranchKeyPrefix` + the sanitized filename. */
|
|
117
|
-
export function ghPrivateBranchAttachmentKey(prefixId, filename) {
|
|
118
|
-
return `${ghPrivateBranchKeyPrefix(prefixId)}${sanitizeKeySegment(filename)}`;
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* Inverse of `ghPrivateKeyPrefix`: parse the prefix id/kind/number back out
|
|
122
|
-
* of a private-repo attachment key, or undefined for any other key shape.
|
|
123
|
-
* Cannot recover the repo — callers that need it read `gh.repo` metadata.
|
|
124
|
-
*/
|
|
125
|
-
export function parseGhPrivateKey(key) {
|
|
126
|
-
const match = /^gh\/private\/([0-9a-f]{32})\/(pull|issues)\/([1-9][0-9]*)\/./.exec(key);
|
|
127
|
-
if (!match)
|
|
128
|
-
return undefined;
|
|
129
|
-
const [, prefixId, kind, num] = match;
|
|
130
|
-
return { prefixId, kind: kind, num: Number(num) };
|
|
131
|
-
}
|
|
132
|
-
export function ghKeyPrefix(target) {
|
|
133
|
-
const [owner, name] = target.repo.split("/");
|
|
134
|
-
return `gh/${sanitizeKeySegment(owner)}/${sanitizeKeySegment(name)}/${target.kind}/${target.num}/`;
|
|
135
|
-
}
|
|
136
80
|
/**
|
|
137
81
|
* Stable attachment key: same filename → same key → same public URL, so
|
|
138
82
|
* re-uploading updates every existing embed. Deliberately NO content hash
|
|
139
83
|
* (unlike buildScreenshotKey).
|
|
140
84
|
*/
|
|
141
85
|
export function ghAttachmentKey(target, filename) {
|
|
142
|
-
|
|
86
|
+
const [owner, name] = target.repo.split("/");
|
|
87
|
+
return `gh/${sanitizeKeySegment(owner)}/${sanitizeKeySegment(name)}/${target.kind}/${target.num}/${sanitizeKeySegment(filename)}`;
|
|
143
88
|
}
|
|
144
89
|
/**
|
|
145
90
|
* Branch-staged attachment key prefix: `gh/<owner>/<repo>/branch/<branch>/`.
|
|
@@ -222,439 +167,6 @@ export function ghMetadataFromTarget(target) {
|
|
|
222
167
|
"gh.ref": `${repo}#${target.num}`,
|
|
223
168
|
};
|
|
224
169
|
}
|
|
225
|
-
/** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
|
|
226
|
-
export const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
|
|
227
|
-
/** Workspace slugs are `[a-z0-9-]`-ish; only markers built from a slug matching
|
|
228
|
-
* this are trusted as a distinct namespace — anything else (empty, unsafe
|
|
229
|
-
* chars) degrades to the shared legacy marker rather than emitting untrusted
|
|
230
|
-
* text into comment HTML. */
|
|
231
|
-
const WORKSPACE_SLUG_RE = /^[a-z0-9-]{1,64}$/;
|
|
232
|
-
/**
|
|
233
|
-
* Per-workspace marker (`<!-- uploads.sh:attachments ws=<workspace> -->`) so
|
|
234
|
-
* two workspaces managing the same repo don't clobber each other's comment.
|
|
235
|
-
* Falls back to the shared legacy marker when `workspace` is missing or does
|
|
236
|
-
* not look like a safe slug — degrade, don't guess or risk breaking the
|
|
237
|
-
* comment's HTML.
|
|
238
|
-
*/
|
|
239
|
-
export function attachmentsMarker(workspace) {
|
|
240
|
-
if (workspace && WORKSPACE_SLUG_RE.test(workspace)) {
|
|
241
|
-
return `<!-- uploads.sh:attachments ws=${workspace} -->`;
|
|
242
|
-
}
|
|
243
|
-
return ATTACHMENTS_MARKER;
|
|
244
|
-
}
|
|
245
|
-
/** Max attachments embedded as inline `<img>` tags before the rest collapse
|
|
246
|
-
* into a `<details>` link list. Keeps very large threads from becoming a wall
|
|
247
|
-
* of images. */
|
|
248
|
-
export const MAX_INLINE_ATTACHMENT_IMAGES = 16;
|
|
249
|
-
/** Today's behavior, expressed as options — the default for every caller that
|
|
250
|
-
* hasn't opted into repo comment config. */
|
|
251
|
-
export const AUTO_RENDER_OPTIONS = {
|
|
252
|
-
imageWidth: "auto",
|
|
253
|
-
maxInlineImages: MAX_INLINE_ATTACHMENT_IMAGES,
|
|
254
|
-
metaPath: true,
|
|
255
|
-
metaState: true,
|
|
256
|
-
note: null,
|
|
257
|
-
};
|
|
258
|
-
/** Dense (historical) default max width for images in the managed comment. */
|
|
259
|
-
export const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
|
|
260
|
-
/** Portrait / device mockups — keep phones readable, not full-column. */
|
|
261
|
-
export const ATTACHMENT_IMAGE_WIDTH_PORTRAIT = 280;
|
|
262
|
-
/** Wide UI / browser chrome. */
|
|
263
|
-
export const ATTACHMENT_IMAGE_WIDTH_WIDE = 640;
|
|
264
|
-
/** Dense pair-cell cap (side-by-side before/after). */
|
|
265
|
-
export const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
|
|
266
|
-
/** Per-density widths for `imageWidth: "auto"`. Dense reuses the exports above. */
|
|
267
|
-
const WIDTH_BY_DENSITY = {
|
|
268
|
-
solo: { default: 720, portrait: 360, wide: 800, pair: 400 },
|
|
269
|
-
sparse: { default: 560, portrait: 300, wide: 720, pair: 380 },
|
|
270
|
-
dense: {
|
|
271
|
-
default: ATTACHMENT_IMAGE_WIDTH_DEFAULT,
|
|
272
|
-
portrait: ATTACHMENT_IMAGE_WIDTH_PORTRAIT,
|
|
273
|
-
wide: ATTACHMENT_IMAGE_WIDTH_WIDE,
|
|
274
|
-
pair: ATTACHMENT_IMAGE_WIDTH_PAIR,
|
|
275
|
-
},
|
|
276
|
-
};
|
|
277
|
-
/** Map an inlined-media count onto a density tier. */
|
|
278
|
-
export function attachmentDensityForCount(inlinedCount) {
|
|
279
|
-
if (inlinedCount <= 1)
|
|
280
|
-
return "solo";
|
|
281
|
-
if (inlinedCount <= 3)
|
|
282
|
-
return "sparse";
|
|
283
|
-
return "dense";
|
|
284
|
-
}
|
|
285
|
-
/** Pair-cell cap for the given density. */
|
|
286
|
-
export function attachmentPairWidth(density = "dense") {
|
|
287
|
-
return WIDTH_BY_DENSITY[density].pair;
|
|
288
|
-
}
|
|
289
|
-
/**
|
|
290
|
-
* Display width for a GitHub comment embed. Filenames are a weak but practical
|
|
291
|
-
* signal (we don't re-fetch dimensions when rebuilding the comment). `density`
|
|
292
|
-
* only affects managed-comment auto layout; other callers leave it `"dense"`.
|
|
293
|
-
*/
|
|
294
|
-
export function attachmentImageWidth(filename, density = "dense") {
|
|
295
|
-
const table = WIDTH_BY_DENSITY[density];
|
|
296
|
-
const n = filename.toLowerCase();
|
|
297
|
-
if (/(?:^|[-_.])(browser|desktop|dashboard|wide)(?:[-_.]|$)/.test(n))
|
|
298
|
-
return table.wide;
|
|
299
|
-
if (/(?:^|[-_.])(phone|iphone|ipad|pixel|android|mobile|device)(?:[-_.]|$)/.test(n) ||
|
|
300
|
-
/iphone|pixel-?\d/.test(n)) {
|
|
301
|
-
return table.portrait;
|
|
302
|
-
}
|
|
303
|
-
return table.default;
|
|
304
|
-
}
|
|
305
|
-
/** `m:ss` under an hour, `h:mm:ss` at or above one. */
|
|
306
|
-
function formatDuration(seconds) {
|
|
307
|
-
const total = Math.floor(seconds);
|
|
308
|
-
const s = total % 60;
|
|
309
|
-
const m = Math.floor(total / 60) % 60;
|
|
310
|
-
const h = Math.floor(total / 3600);
|
|
311
|
-
const ss = String(s).padStart(2, "0");
|
|
312
|
-
if (h === 0)
|
|
313
|
-
return `${m}:${ss}`;
|
|
314
|
-
return `${h}:${String(m).padStart(2, "0")}:${ss}`;
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* Display width for a video poster. Real dimensions only *select* among the
|
|
318
|
-
* density table's tiers — a raw 1920 would blow out the comment column — and
|
|
319
|
-
* the result is capped at the real width so a small clip is never upscaled.
|
|
320
|
-
*/
|
|
321
|
-
function posterImageWidth(videoMeta, filename, density = "dense") {
|
|
322
|
-
const w = videoMeta?.width ?? 0;
|
|
323
|
-
const h = videoMeta?.height ?? 0;
|
|
324
|
-
if (w <= 0 || h <= 0)
|
|
325
|
-
return attachmentImageWidth(filename, density);
|
|
326
|
-
const table = WIDTH_BY_DENSITY[density];
|
|
327
|
-
const chosen = h > w ? table.portrait : w / h >= 16 / 9 ? table.wide : table.default;
|
|
328
|
-
return Math.min(chosen, w);
|
|
329
|
-
}
|
|
330
|
-
function escapeHtmlAttr(s) {
|
|
331
|
-
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
332
|
-
}
|
|
333
|
-
function escapeHtmlText(s) {
|
|
334
|
-
return escapeHtmlAttr(s).replace(/'/g, "'").replace(/>/g, ">");
|
|
335
|
-
}
|
|
336
|
-
/**
|
|
337
|
-
* Backslash-escape the markdown metacharacters that can appear in a metadata
|
|
338
|
-
* value. `~` is in the set because GitHub's strikethrough extension treats a
|
|
339
|
-
* matching pair of ONE or two tildes as markup, so an unescaped `/a~b~c` would
|
|
340
|
-
* render with `b` struck through.
|
|
341
|
-
*/
|
|
342
|
-
function escapeMarkdownText(s) {
|
|
343
|
-
return s.replace(/([\\`*_[\]~])/g, "\\$1");
|
|
344
|
-
}
|
|
345
|
-
/**
|
|
346
|
-
* Collect path then state for a caption (issue #365). Bare `/` and
|
|
347
|
-
* whitespace-only values are omitted (issue #375). Empty when nothing usable.
|
|
348
|
-
*/
|
|
349
|
-
function metaCaptionValues(meta, options) {
|
|
350
|
-
const values = [];
|
|
351
|
-
const path = meta?.path?.trim();
|
|
352
|
-
if (options.metaPath && path && path !== "/")
|
|
353
|
-
values.push(path);
|
|
354
|
-
const state = meta?.state?.trim();
|
|
355
|
-
if (options.metaState && state)
|
|
356
|
-
values.push(state);
|
|
357
|
-
return values;
|
|
358
|
-
}
|
|
359
|
-
/**
|
|
360
|
-
* Format path/state as code tokens. HTML → `<code>…</code>`; markdown →
|
|
361
|
-
* `` `…` `` (backslash-escape if the value itself contains a backtick).
|
|
362
|
-
* Returns `""` when there is nothing to say.
|
|
363
|
-
*/
|
|
364
|
-
function formatMetaCaption(meta, options, mode) {
|
|
365
|
-
const values = metaCaptionValues(meta, options);
|
|
366
|
-
if (values.length === 0)
|
|
367
|
-
return "";
|
|
368
|
-
if (mode === "html") {
|
|
369
|
-
return values.map((v) => `<code>${escapeHtmlText(v)}</code>`).join(" · ");
|
|
370
|
-
}
|
|
371
|
-
return values
|
|
372
|
-
.map((v) => {
|
|
373
|
-
const esc = escapeHtmlText(v);
|
|
374
|
-
return esc.includes("`") ? escapeMarkdownText(esc) : `\`${esc}\``;
|
|
375
|
-
})
|
|
376
|
-
.join(" · ");
|
|
377
|
-
}
|
|
378
|
-
/** Resolved pixel width for an image site, or `null` meaning "omit the width
|
|
379
|
-
* attribute". `"auto"` defers to the caller's per-item heuristic (`autoPx`);
|
|
380
|
-
* `"full"` always omits; a number always wins. */
|
|
381
|
-
function resolvedWidth(autoPx, options) {
|
|
382
|
-
if (options.imageWidth === "auto")
|
|
383
|
-
return autoPx;
|
|
384
|
-
if (options.imageWidth === "full")
|
|
385
|
-
return null;
|
|
386
|
-
return options.imageWidth;
|
|
387
|
-
}
|
|
388
|
-
/** Every `<img>` tag in the managed comment goes through here so the
|
|
389
|
-
* omit-width-attribute case ("full") can't drift between call sites.
|
|
390
|
-
*
|
|
391
|
-
* `escapedAlt`/`escapedSrc` must already be attribute-escaped (via
|
|
392
|
-
* `escapeHtmlAttr`) by the caller — this function interpolates them as-is
|
|
393
|
-
* and does not escape them itself. */
|
|
394
|
-
function imgTag(w, escapedAlt, escapedSrc) {
|
|
395
|
-
const widthAttr = w === null ? "" : ` width="${w}"`;
|
|
396
|
-
return `<img${widthAttr} alt="${escapedAlt}" src="${escapedSrc}">`;
|
|
397
|
-
}
|
|
398
|
-
/** Extract the filename stem's before/after token (issue #419 fallback pairing).
|
|
399
|
-
* `base` is the stem lowercased with the token removed; `null` when the stem
|
|
400
|
-
* carries no recognizable before/after token. Requires a separator (`-`, `_`,
|
|
401
|
-
* or `.`) between the token and the rest of the name — except when the token
|
|
402
|
-
* IS the whole stem (`before.png`) — so `beforehand.png` doesn't false-match. */
|
|
403
|
-
// Token bounded by `-`, `_`, `.`, or stem start/end, so `hero-before.webp`
|
|
404
|
-
// and `paired-view-before-desktop.webp` match but `beforehand.webp` does
|
|
405
|
-
// not. Mirrors before-after.ts's TOKEN_RE (file page), applied to the stem.
|
|
406
|
-
const STEM_TOKEN_RE = /(^|[-_.])(before|after)($|[-_.])/i;
|
|
407
|
-
function filenameStemToken(name) {
|
|
408
|
-
const dot = name.lastIndexOf(".");
|
|
409
|
-
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
410
|
-
const m = STEM_TOKEN_RE.exec(stem);
|
|
411
|
-
if (!m)
|
|
412
|
-
return null;
|
|
413
|
-
const state = m[2].toLowerCase();
|
|
414
|
-
const tokenStart = m.index + m[1].length;
|
|
415
|
-
const tokenEnd = tokenStart + m[2].length;
|
|
416
|
-
// Base = stem with the token and one adjoining delimiter removed, so
|
|
417
|
-
// `paired-view-before-desktop` and `paired-view-after-desktop` both
|
|
418
|
-
// collapse to `paired-view-desktop` and group together.
|
|
419
|
-
const base = m[1].length > 0
|
|
420
|
-
? stem.slice(0, m.index) + stem.slice(tokenEnd)
|
|
421
|
-
: stem.slice(tokenEnd + m[3].length);
|
|
422
|
-
return { base: base.toLowerCase(), state };
|
|
423
|
-
}
|
|
424
|
-
/**
|
|
425
|
-
* Pair up attachments for the before/after side-by-side row (issue #419).
|
|
426
|
-
* `isImageAt[i]` mirrors the renderer's own image test — only images pair;
|
|
427
|
-
* videos and non-image links render exactly as before.
|
|
428
|
-
*
|
|
429
|
-
* Priority order, checked independently per candidate item so rule 2 only
|
|
430
|
-
* ever claims items rule 1 left untouched:
|
|
431
|
-
* 1. Same `path` metadata (trimmed, not bare `/`), one item `state=before`
|
|
432
|
-
* and one `state=after`. Ambiguous groups (more than one of a state)
|
|
433
|
-
* don't pair — no way to know which side goes with which.
|
|
434
|
-
* 2. No usable `path` metadata: filename stems that differ only by a
|
|
435
|
-
* before/after token, same extension. Same ambiguity rule.
|
|
436
|
-
*/
|
|
437
|
-
function pairAttachments(items, isImageAt) {
|
|
438
|
-
const partnerOf = new Map();
|
|
439
|
-
const roleOf = new Map();
|
|
440
|
-
const pair = (beforeIdx, afterIdx) => {
|
|
441
|
-
partnerOf.set(beforeIdx, afterIdx);
|
|
442
|
-
partnerOf.set(afterIdx, beforeIdx);
|
|
443
|
-
roleOf.set(beforeIdx, "before");
|
|
444
|
-
roleOf.set(afterIdx, "after");
|
|
445
|
-
};
|
|
446
|
-
// Priority 1: same path metadata, exactly one before + one after.
|
|
447
|
-
const pathGroups = new Map();
|
|
448
|
-
items.forEach((item, i) => {
|
|
449
|
-
if (!isImageAt[i])
|
|
450
|
-
return;
|
|
451
|
-
const path = item.meta?.path?.trim();
|
|
452
|
-
if (!path || path === "/")
|
|
453
|
-
return;
|
|
454
|
-
const state = item.meta?.state?.trim().toLowerCase();
|
|
455
|
-
if (state !== "before" && state !== "after")
|
|
456
|
-
return;
|
|
457
|
-
const g = pathGroups.get(path) ?? { before: [], after: [] };
|
|
458
|
-
g[state].push(i);
|
|
459
|
-
pathGroups.set(path, g);
|
|
460
|
-
});
|
|
461
|
-
for (const g of pathGroups.values()) {
|
|
462
|
-
if (g.before.length === 1 && g.after.length === 1)
|
|
463
|
-
pair(g.before[0], g.after[0]);
|
|
464
|
-
}
|
|
465
|
-
// Priority 2: no usable path metadata — filename stem token, same extension.
|
|
466
|
-
const stemGroups = new Map();
|
|
467
|
-
items.forEach((item, i) => {
|
|
468
|
-
if (!isImageAt[i] || partnerOf.has(i))
|
|
469
|
-
return;
|
|
470
|
-
const path = item.meta?.path?.trim();
|
|
471
|
-
if (path && path !== "/")
|
|
472
|
-
return; // usable path metadata — rule 1 owns this item
|
|
473
|
-
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
474
|
-
const tok = filenameStemToken(name);
|
|
475
|
-
if (!tok)
|
|
476
|
-
return;
|
|
477
|
-
const dot = name.lastIndexOf(".");
|
|
478
|
-
const ext = dot > 0 ? name.slice(dot).toLowerCase() : "";
|
|
479
|
-
const key = `${tok.base}${ext}`;
|
|
480
|
-
const g = stemGroups.get(key) ?? { before: [], after: [] };
|
|
481
|
-
g[tok.state].push(i);
|
|
482
|
-
stemGroups.set(key, g);
|
|
483
|
-
});
|
|
484
|
-
for (const g of stemGroups.values()) {
|
|
485
|
-
if (g.before.length === 1 && g.after.length === 1)
|
|
486
|
-
pair(g.before[0], g.after[0]);
|
|
487
|
-
}
|
|
488
|
-
return { partnerOf, roleOf };
|
|
489
|
-
}
|
|
490
|
-
function renderPairCell(item, label, options, density) {
|
|
491
|
-
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
492
|
-
const src = item.embedUrl ?? item.url;
|
|
493
|
-
const link = item.pageUrl ?? item.url;
|
|
494
|
-
const autoPx = Math.min(attachmentImageWidth(name, density), attachmentPairWidth(density));
|
|
495
|
-
const w = resolvedWidth(autoPx, options);
|
|
496
|
-
const alt = escapeHtmlAttr(name);
|
|
497
|
-
const href = escapeHtmlAttr((link ?? src));
|
|
498
|
-
const imgSrc = escapeHtmlAttr(src);
|
|
499
|
-
const caption = formatMetaCaption(item.meta, options, "html");
|
|
500
|
-
const captionHtml = caption ? `<br><sub>${caption}</sub>` : "";
|
|
501
|
-
return `<td align="center"><sub><strong>${label}</strong></sub><br><a href="${href}">${imgTag(w, alt, imgSrc)}</a>${captionHtml}</td>`;
|
|
502
|
-
}
|
|
503
|
-
/** One side-by-side before/after row (issue #419). */
|
|
504
|
-
function renderPairRow(beforeItem, afterItem, options, density) {
|
|
505
|
-
return `<table><tr>${renderPairCell(beforeItem, "Before", options, density)}${renderPairCell(afterItem, "After", options, density)}</tr></table>`;
|
|
506
|
-
}
|
|
507
|
-
/** How many image/poster items will fit under `maxInlineImages` (for density). */
|
|
508
|
-
function countInlinableMedia(sorted, maxInlineImages) {
|
|
509
|
-
let count = 0;
|
|
510
|
-
for (const item of sorted) {
|
|
511
|
-
if (count >= maxInlineImages)
|
|
512
|
-
break;
|
|
513
|
-
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
514
|
-
const src = item.embedUrl ?? item.url;
|
|
515
|
-
const isImage = Boolean(src) && inferContentType(name).startsWith("image/");
|
|
516
|
-
const isPoster = Boolean(item.posterUrl) && inferContentType(name).startsWith("video/");
|
|
517
|
-
if (isImage || isPoster)
|
|
518
|
-
count++;
|
|
519
|
-
}
|
|
520
|
-
return count;
|
|
521
|
-
}
|
|
522
|
-
/**
|
|
523
|
-
* Render the one marker-owned GitHub comment. When there are no galleries this
|
|
524
|
-
* intentionally preserves the legacy attachment-only body byte-for-byte.
|
|
525
|
-
*/
|
|
526
|
-
export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMENTS_MARKER, options = AUTO_RENDER_OPTIONS) {
|
|
527
|
-
const sorted = items.toSorted((a, b) => a.key.localeCompare(b.key));
|
|
528
|
-
const sortedGalleries = galleries.toSorted((a, b) => a.title.localeCompare(b.title) || a.url.localeCompare(b.url));
|
|
529
|
-
const lines = [marker];
|
|
530
|
-
if (options.note)
|
|
531
|
-
lines.push(options.note, "");
|
|
532
|
-
if (sortedGalleries.length > 0) {
|
|
533
|
-
lines.push("### 🖼️ Galleries", "");
|
|
534
|
-
for (const gallery of sortedGalleries) {
|
|
535
|
-
const href = escapeHtmlAttr(gallery.url);
|
|
536
|
-
lines.push(`#### <a href="${href}">${escapeHtmlText(gallery.title)}</a>`);
|
|
537
|
-
for (const preview of gallery.previews ?? []) {
|
|
538
|
-
const previewHref = preview.itemUrl ? escapeHtmlAttr(preview.itemUrl) : href;
|
|
539
|
-
const previewSrc = escapeHtmlAttr(preview.embedUrl ?? preview.url);
|
|
540
|
-
const previewW = resolvedWidth(320, options);
|
|
541
|
-
lines.push(`<a href="${previewHref}">${imgTag(previewW, escapeHtmlAttr(preview.alt), previewSrc)}</a>`);
|
|
542
|
-
}
|
|
543
|
-
lines.push(`<sub><a href="${href}">Open gallery</a></sub>`, "");
|
|
544
|
-
}
|
|
545
|
-
lines.push("");
|
|
546
|
-
}
|
|
547
|
-
const isImageAt = sorted.map((item) => {
|
|
548
|
-
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
549
|
-
const src = item.embedUrl ?? item.url;
|
|
550
|
-
return Boolean(src) && inferContentType(name).startsWith("image/");
|
|
551
|
-
});
|
|
552
|
-
const { partnerOf, roleOf } = pairAttachments(sorted, isImageAt);
|
|
553
|
-
// One screenshot → large; a wall of shots → compact historical sizes.
|
|
554
|
-
const density = options.imageWidth === "auto"
|
|
555
|
-
? attachmentDensityForCount(countInlinableMedia(sorted, options.maxInlineImages))
|
|
556
|
-
: "dense";
|
|
557
|
-
const consumedByPair = new Set();
|
|
558
|
-
let inlinedImages = 0;
|
|
559
|
-
const overflowImages = [];
|
|
560
|
-
for (let idx = 0; idx < sorted.length; idx++) {
|
|
561
|
-
if (consumedByPair.has(idx))
|
|
562
|
-
continue;
|
|
563
|
-
const item = sorted[idx];
|
|
564
|
-
const partnerIdx = partnerOf.get(idx);
|
|
565
|
-
if (partnerIdx !== undefined) {
|
|
566
|
-
const partner = sorted[partnerIdx];
|
|
567
|
-
if (inlinedImages + 2 <= options.maxInlineImages) {
|
|
568
|
-
inlinedImages += 2;
|
|
569
|
-
consumedByPair.add(partnerIdx);
|
|
570
|
-
const beforeItem = roleOf.get(idx) === "before" ? item : partner;
|
|
571
|
-
const afterItem = roleOf.get(idx) === "before" ? partner : item;
|
|
572
|
-
lines.push(renderPairRow(beforeItem, afterItem, options, density), "");
|
|
573
|
-
continue;
|
|
574
|
-
}
|
|
575
|
-
// Cap already full for a two-image row — degrade this pair to two
|
|
576
|
-
// ordinary overflow entries rather than only half-rendering the row.
|
|
577
|
-
overflowImages.push(item, partner);
|
|
578
|
-
consumedByPair.add(partnerIdx);
|
|
579
|
-
continue;
|
|
580
|
-
}
|
|
581
|
-
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
582
|
-
const stable = item.url;
|
|
583
|
-
const src = item.embedUrl ?? item.url;
|
|
584
|
-
const link = item.pageUrl ?? stable; // click-through: file page when known, else raw
|
|
585
|
-
const isImage = Boolean(src) && inferContentType(name).startsWith("image/");
|
|
586
|
-
const isPosterVideo = Boolean(item.posterUrl) && inferContentType(name).startsWith("video/");
|
|
587
|
-
const inlines = isImage || isPosterVideo;
|
|
588
|
-
if (inlines && inlinedImages >= options.maxInlineImages) {
|
|
589
|
-
// Cap hit — defer to the collapsed overflow list below rather than
|
|
590
|
-
// embedding every remaining image inline.
|
|
591
|
-
overflowImages.push(item);
|
|
592
|
-
continue;
|
|
593
|
-
}
|
|
594
|
-
if (isPosterVideo) {
|
|
595
|
-
inlinedImages++;
|
|
596
|
-
const autoPx = posterImageWidth(item.videoMeta, name, density);
|
|
597
|
-
const w = resolvedWidth(autoPx, options);
|
|
598
|
-
const href = escapeHtmlAttr(link ?? item.posterUrl);
|
|
599
|
-
lines.push(`<a href="${href}">${imgTag(w, escapeHtmlAttr(name), escapeHtmlAttr(item.posterUrl))}</a>`);
|
|
600
|
-
// GitHub strips <video>, so a still frame needs an explicit affordance
|
|
601
|
-
// or it reads as a screenshot.
|
|
602
|
-
const parts = ["▶ Play video"];
|
|
603
|
-
if (item.videoMeta?.durationSeconds != null) {
|
|
604
|
-
parts.push(formatDuration(item.videoMeta.durationSeconds));
|
|
605
|
-
}
|
|
606
|
-
const metaCap = formatMetaCaption(item.meta, options, "html");
|
|
607
|
-
if (metaCap)
|
|
608
|
-
parts.push(metaCap);
|
|
609
|
-
lines.push(`<sub>${parts.join(" · ")}</sub>`, "");
|
|
610
|
-
}
|
|
611
|
-
else if (isImage) {
|
|
612
|
-
inlinedImages++;
|
|
613
|
-
// Markdown ![]() has no width control — phone frames become full-column giants.
|
|
614
|
-
// img src uses embed host when available (Camo revalidates); click-through prefers the file page.
|
|
615
|
-
const autoPx = attachmentImageWidth(name, density);
|
|
616
|
-
const w = resolvedWidth(autoPx, options);
|
|
617
|
-
const alt = escapeHtmlAttr(name);
|
|
618
|
-
const href = escapeHtmlAttr(link ?? src);
|
|
619
|
-
const imgSrc = escapeHtmlAttr(src);
|
|
620
|
-
lines.push(`<a href="${href}">${imgTag(w, alt, imgSrc)}</a>`);
|
|
621
|
-
const caption = formatMetaCaption(item.meta, options, "html");
|
|
622
|
-
if (caption)
|
|
623
|
-
lines.push(`<sub>${caption}</sub>`);
|
|
624
|
-
lines.push("");
|
|
625
|
-
}
|
|
626
|
-
else if (link) {
|
|
627
|
-
const cap = formatMetaCaption(item.meta, options, "markdown");
|
|
628
|
-
lines.push(`- [${name}](${link})${cap ? ` · ${cap}` : ""}`);
|
|
629
|
-
}
|
|
630
|
-
else {
|
|
631
|
-
const cap = formatMetaCaption(item.meta, options, "markdown");
|
|
632
|
-
lines.push(`- ${name}${cap ? ` · ${cap}` : ""}`);
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
if (overflowImages.length > 0) {
|
|
636
|
-
const n = overflowImages.length;
|
|
637
|
-
lines.push(`<details><summary>${n} more attachment${n === 1 ? "" : "s"}</summary>`, "");
|
|
638
|
-
for (const item of overflowImages) {
|
|
639
|
-
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
640
|
-
const link = item.pageUrl ?? item.url;
|
|
641
|
-
const cap = formatMetaCaption(item.meta, options, "markdown");
|
|
642
|
-
const suffix = cap ? ` · ${cap}` : "";
|
|
643
|
-
lines.push(link ? `- [${name}](${link})${suffix}` : `- ${name}${suffix}`);
|
|
644
|
-
}
|
|
645
|
-
lines.push("", "</details>", "");
|
|
646
|
-
}
|
|
647
|
-
// Emptied state: a PR/issue whose attachments and galleries were all removed
|
|
648
|
-
// still keeps its managed comment (a later push can repopulate it) — show a
|
|
649
|
-
// neutral resting state rather than a bare footer. Only the truly-empty case
|
|
650
|
-
// (no attachments, no galleries); a galleries-only comment must not get this.
|
|
651
|
-
if (sorted.length === 0 && sortedGalleries.length === 0) {
|
|
652
|
-
lines.push("_No attachments are currently associated with this pull request._", "");
|
|
653
|
-
}
|
|
654
|
-
lines.push('<sub>Maintained by <a href="https://uploads.sh">uploads.sh</a> — re-uploading a file with the same name updates it everywhere it is embedded.</sub>');
|
|
655
|
-
lines.push('<sub>Add media: <code>uploads put <file> --pr <N> --comment</code> (or <code>--issue <N></code>) · <a href="https://uploads.sh/docs/github-app">docs</a></sub>');
|
|
656
|
-
return lines.join("\n");
|
|
657
|
-
}
|
|
658
170
|
/**
|
|
659
171
|
* Extra footer line appended only on the local-`gh` fallback path (never on
|
|
660
172
|
* bot-authored comments). Short note that the App isn't on this repo yet.
|