@buildinternet/uploads 0.50.1 → 0.52.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.
- package/dist/client.d.ts +25 -0
- package/dist/client.js +21 -0
- package/dist/commands/screenshot.js +17 -2
- package/dist/commands.d.ts +27 -1
- package/dist/commands.js +116 -47
- package/dist/comment-render.generated.d.ts +8 -0
- package/dist/comment-render.generated.js +46 -18
- package/dist/embed.d.ts +14 -1
- package/dist/embed.js +14 -22
- package/dist/github-gh.d.ts +25 -0
- package/dist/github-gh.js +49 -0
- package/dist/mcp/tools.js +66 -11
- package/dist/optimize.js +22 -26
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -326,6 +326,22 @@ export interface PromoteSkip {
|
|
|
326
326
|
export interface PromoteBranchAttachmentsResult {
|
|
327
327
|
promoted: string[];
|
|
328
328
|
skipped: PromoteSkip[];
|
|
329
|
+
/** Branch-name lineage the sweep covered, current name first (issue #920).
|
|
330
|
+
* Present only when the branch was renamed at least once. */
|
|
331
|
+
lineage?: string[];
|
|
332
|
+
}
|
|
333
|
+
/** `POST /v1/workspaces/:workspace/github/branch-rename` request (server contract, issue #920). */
|
|
334
|
+
export interface RegisterBranchRenameOptions {
|
|
335
|
+
repo: string;
|
|
336
|
+
/** Previous branch name (`git branch -m <from> <to>`). */
|
|
337
|
+
from: string;
|
|
338
|
+
/** Name the branch was renamed to. */
|
|
339
|
+
to: string;
|
|
340
|
+
}
|
|
341
|
+
/** `POST /v1/workspaces/:workspace/github/branch-rename` response. `recorded:
|
|
342
|
+
* false` means the pair was already known (or the server has no such route). */
|
|
343
|
+
export interface RegisterBranchRenameResult {
|
|
344
|
+
recorded: boolean;
|
|
329
345
|
}
|
|
330
346
|
/**
|
|
331
347
|
* `POST /v1/workspaces/:workspace/github/attach` request/response (server
|
|
@@ -758,6 +774,15 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
|
|
|
758
774
|
* that doesn't have this route yet, as "nothing promoted").
|
|
759
775
|
*/
|
|
760
776
|
promoteBranchAttachments(opts: PromoteBranchAttachmentsOptions): Promise<PromoteBranchAttachmentsResult>;
|
|
777
|
+
/**
|
|
778
|
+
* Record one `git branch -m` step so promote sweeps the branch's whole
|
|
779
|
+
* name lineage (issue #920). Degrade-safe on the one failure that is
|
|
780
|
+
* expected in the wild: a 404 from an older/self-hosted worker without
|
|
781
|
+
* this route collapses to `{ recorded: false }` — nothing recorded, same
|
|
782
|
+
* as `promoteBranchAttachments`' callers treat a missing route. Other
|
|
783
|
+
* failures throw; the CLI's `registerRenamesBestEffort` swallows those.
|
|
784
|
+
*/
|
|
785
|
+
registerBranchRename(opts: RegisterBranchRenameOptions): Promise<RegisterBranchRenameResult>;
|
|
761
786
|
/**
|
|
762
787
|
* Attach an already-uploaded object to a PR/issue via a server-side copy
|
|
763
788
|
* (issue #702) — see `AttachExistingOptions`. Throws `UploadsError` on
|
package/dist/client.js
CHANGED
|
@@ -724,6 +724,27 @@ export function createUploadsClient(config) {
|
|
|
724
724
|
headers: { "Content-Type": "application/json" },
|
|
725
725
|
});
|
|
726
726
|
},
|
|
727
|
+
/**
|
|
728
|
+
* Record one `git branch -m` step so promote sweeps the branch's whole
|
|
729
|
+
* name lineage (issue #920). Degrade-safe on the one failure that is
|
|
730
|
+
* expected in the wild: a 404 from an older/self-hosted worker without
|
|
731
|
+
* this route collapses to `{ recorded: false }` — nothing recorded, same
|
|
732
|
+
* as `promoteBranchAttachments`' callers treat a missing route. Other
|
|
733
|
+
* failures throw; the CLI's `registerRenamesBestEffort` swallows those.
|
|
734
|
+
*/
|
|
735
|
+
async registerBranchRename(opts) {
|
|
736
|
+
try {
|
|
737
|
+
return await request("POST", `${config.apiUrl}/v1/workspaces/${encodeURIComponent(config.workspace)}/github/branch-rename`, {
|
|
738
|
+
body: new TextEncoder().encode(JSON.stringify(opts)),
|
|
739
|
+
headers: { "Content-Type": "application/json" },
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
catch (err) {
|
|
743
|
+
if (err instanceof UploadsError && err.status === 404)
|
|
744
|
+
return { recorded: false };
|
|
745
|
+
throw err;
|
|
746
|
+
}
|
|
747
|
+
},
|
|
727
748
|
/**
|
|
728
749
|
* Attach an already-uploaded object to a PR/issue via a server-side copy
|
|
729
750
|
* (issue #702) — see `AttachExistingOptions`. Throws `UploadsError` on
|
|
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { extractDashValue, flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
|
|
4
4
|
import { writeCommandHelp } from "../cli-style.js";
|
|
5
|
-
import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, resolvePutStagingTarget, putStagingNoteText, resolveStageBindingWarning, mergeStagingMeta, writeReplacedNote, resolveGhPrefixSafe, resolveAutoPrTarget, resolvePutNudgeContext, putNudgeText, autoPrNoteText, } from "../commands.js";
|
|
5
|
+
import { branchFromFlags, derivedMetaEnabled, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, stateAppMetaFromFlags, warnNearMissMeta, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, resolvePutStagingTarget, putStagingNoteText, resolveStageBindingWarning, mergeStagingMeta, registerRenamesBestEffort, writeReplacedNote, resolveGhPrefixSafe, resolveAutoPrTarget, resolvePutNudgeContext, putNudgeText, autoPrNoteText, } from "../commands.js";
|
|
6
6
|
import { resolvePutDefaults } from "../config.js";
|
|
7
7
|
import { loadDefaultsRaw, resolveScreenshotDefaults } from "../config-file.js";
|
|
8
8
|
import { resolvePutPrefix } from "../destinations.js";
|
|
@@ -351,7 +351,7 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
|
|
|
351
351
|
// maps to exactly one open PR behaves as if --pr <n> had been passed —
|
|
352
352
|
// stable key + managed comment sync — instead of the #469 auto-staging
|
|
353
353
|
// default below. Mirrors put's #700 handling exactly (resolveAutoPrTarget).
|
|
354
|
-
const
|
|
354
|
+
const autoPrMatch = ghTarget || branchArg !== undefined
|
|
355
355
|
? undefined
|
|
356
356
|
: resolveAutoPrTarget({
|
|
357
357
|
ghTarget,
|
|
@@ -365,6 +365,7 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
|
|
|
365
365
|
repoArg: flagString(parsed.flags, "--repo") ?? putDefaults.repo,
|
|
366
366
|
run,
|
|
367
367
|
});
|
|
368
|
+
const autoPrTarget = autoPrMatch?.target;
|
|
368
369
|
const effectiveGhTarget = ghTarget ?? autoPrTarget;
|
|
369
370
|
// Auto branch staging (issue #469 lever 1): mirrors bare `put`'s auto-staging
|
|
370
371
|
// (issue #403). When no --branch/--pr/--issue/--key/--ref/--prefix/--destination
|
|
@@ -428,9 +429,23 @@ loadAnnotateModule = () => import("../annotate/index.js")) {
|
|
|
428
429
|
if (effectiveGhTarget) {
|
|
429
430
|
metadata = { ...withFacts, ...ghMetadataFromTargetWithTitle(effectiveGhTarget, run) };
|
|
430
431
|
validateMetaMap(metadata);
|
|
432
|
+
// The #700 auto-PR match suppresses staging, so the staging branch below
|
|
433
|
+
// never runs — but this capture still comes from a branch that may have
|
|
434
|
+
// been renamed while files were staged under the old name. Register the
|
|
435
|
+
// lineage here too (issue #920), reusing the branch the match already
|
|
436
|
+
// resolved. Only for the auto-PR match: an explicit --pr/--issue names
|
|
437
|
+
// no branch of its own.
|
|
438
|
+
if (autoPrMatch && !dryRun && !noUpload) {
|
|
439
|
+
await registerRenamesBestEffort(ctx.client, run, autoPrMatch.target.repo, autoPrMatch.branch);
|
|
440
|
+
}
|
|
431
441
|
}
|
|
432
442
|
else if (stagingTarget !== undefined) {
|
|
433
443
|
metadata = mergeStagingMeta(withFacts, stagingTarget);
|
|
444
|
+
// Same rename registration as `put`/`attach --branch` staging (issue
|
|
445
|
+
// #920): best-effort, never fails the capture.
|
|
446
|
+
if (!dryRun && !noUpload) {
|
|
447
|
+
await registerRenamesBestEffort(ctx.client, run, stagingTarget.repo, stagingTarget.branch);
|
|
448
|
+
}
|
|
434
449
|
}
|
|
435
450
|
else if (Object.keys(withFacts).length > 0) {
|
|
436
451
|
validateMetaMap(withFacts);
|
package/dist/commands.d.ts
CHANGED
|
@@ -394,6 +394,23 @@ export declare function uploadPuts(opts: {
|
|
|
394
394
|
width?: number;
|
|
395
395
|
concurrency?: number;
|
|
396
396
|
}): Promise<UploadBatchResult<PutUploadItem>>;
|
|
397
|
+
/**
|
|
398
|
+
* Best-effort branch-rename registration (issue #920): reads this branch's
|
|
399
|
+
* reflog for `git branch -m` steps and tells the server about each one, so a
|
|
400
|
+
* later promote sweeps the branch's whole name lineage instead of only its
|
|
401
|
+
* current name. No-op when the reflog has no rename (the common case) and
|
|
402
|
+
* when the client predates the route. Every failure is swallowed — this runs
|
|
403
|
+
* alongside staging and promote, and must never fail either. Set
|
|
404
|
+
* `UPLOADS_DEBUG=1` to see what was skipped.
|
|
405
|
+
*
|
|
406
|
+
* `opts.explicit` marks a branch the user named themselves (`--from-branch`
|
|
407
|
+
* / the MCP `fromBranch` argument): that is the manual escape hatch (PR
|
|
408
|
+
* #919), and its lineage belongs to a different name than the one we are
|
|
409
|
+
* standing on, so nothing is registered for it.
|
|
410
|
+
*/
|
|
411
|
+
export declare function registerRenamesBestEffort(client: UploadsClient, run: CommandRunner, repo: string, branch: string, opts?: {
|
|
412
|
+
explicit?: boolean;
|
|
413
|
+
}): Promise<void>;
|
|
397
414
|
export declare function runAttach(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
|
|
398
415
|
/**
|
|
399
416
|
* One source of truth for the "staged, but not going to auto-attach" advisory
|
|
@@ -465,6 +482,11 @@ export declare function resolvePutNudgeContext(opts: {
|
|
|
465
482
|
branch: string;
|
|
466
483
|
pr: number | undefined;
|
|
467
484
|
} | undefined;
|
|
485
|
+
/** A #700 auto-PR match: the PR the current branch maps to, plus that branch. */
|
|
486
|
+
export interface AutoPrMatch {
|
|
487
|
+
target: GhTarget;
|
|
488
|
+
branch: string;
|
|
489
|
+
}
|
|
468
490
|
/**
|
|
469
491
|
* Auto-PR context (issue #700): when a bare put/screenshot has no explicit
|
|
470
492
|
* destination flag at all (`--pr`/`--issue`/`--key`/`--ref`/`--prefix`/
|
|
@@ -481,6 +503,10 @@ export declare function resolvePutNudgeContext(opts: {
|
|
|
481
503
|
* default branch, or with `--no-git`; any failure (not a repo, detached
|
|
482
504
|
* HEAD, gh missing/unauthenticated/timed out, no open PR) degrades to
|
|
483
505
|
* undefined so the caller falls back to its normal staging/dated behavior.
|
|
506
|
+
*
|
|
507
|
+
* The match carries the `branch` it was resolved from, so callers that need
|
|
508
|
+
* the current branch (the #920 rename registration) reuse it instead of
|
|
509
|
+
* spawning `git rev-parse` a second time.
|
|
484
510
|
*/
|
|
485
511
|
export declare function resolveAutoPrTarget(opts: {
|
|
486
512
|
ghTarget: GhTarget | undefined;
|
|
@@ -495,7 +521,7 @@ export declare function resolveAutoPrTarget(opts: {
|
|
|
495
521
|
noAutoPr: boolean;
|
|
496
522
|
repoArg: string | undefined;
|
|
497
523
|
run: CommandRunner;
|
|
498
|
-
}):
|
|
524
|
+
}): AutoPrMatch | undefined;
|
|
499
525
|
/**
|
|
500
526
|
* Bare-put branch-staging trigger (issue #403): put on a non-default git
|
|
501
527
|
* branch stages to the branch prefix by default — the branch becomes the
|
package/dist/commands.js
CHANGED
|
@@ -4,7 +4,7 @@ import { mapBounded } from "./async.js";
|
|
|
4
4
|
import { createUploadsClient, } from "./client.js";
|
|
5
5
|
import { parseCommandArgs, flagString, flagBool, flagInt, flagValues, UsageError, } from "./cli-args.js";
|
|
6
6
|
import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
|
|
7
|
-
import { buildUploadMarkdown } from "./embed.js";
|
|
7
|
+
import { buildUploadMarkdown, fileKindFromName } from "./embed.js";
|
|
8
8
|
import { readLocalRepoCommentConfig, resolveCommentOptions } from "./comment-config.js";
|
|
9
9
|
import { urlForGithubEmbed } from "./public-urls.js";
|
|
10
10
|
import { UploadsError } from "./errors.js";
|
|
@@ -15,7 +15,7 @@ import { parseMetaFlags, validateMetaMap } from "./metadata.js";
|
|
|
15
15
|
import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
|
|
16
16
|
import { mergeSidecarMeta } from "./sidecar.js";
|
|
17
17
|
import { ghAttachmentKeyForMode, ghBranchAttachmentKeyForMode, ghBranchKeyPrefix, ghKeyPrefix, ghPrivateKeyPrefix, ghPrivateBranchKeyPrefix, ghMetadataFromTarget, parseGhKey, parseGhPrivateKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, AUTO_RENDER_OPTIONS, GH_FALLBACK_AUTHOR_NOTE, normalizeGithubCoordinate, } from "./github.js";
|
|
18
|
-
import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, hasLinkCandidate, extractCandidateUrls, fetchAdoptionCandidateText, } from "./github-gh.js";
|
|
18
|
+
import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveCurrentBranchSafe, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, hasLinkCandidate, extractCandidateUrls, fetchAdoptionCandidateText, renameLineageFromReflog, } from "./github-gh.js";
|
|
19
19
|
import { deriveRepoFromGit, deriveRepoSlugFromGit } from "./keys.js";
|
|
20
20
|
import { noProjectContextNudge } from "./project-context-nudge.js";
|
|
21
21
|
import { resolvePutPrefix } from "./destinations.js";
|
|
@@ -110,6 +110,10 @@ Still images (PNG/JPEG/…) are optimized to WebP by default (long edge capped,
|
|
|
110
110
|
high quality; EXIF stripped) so GitHub embeds stay lean. Original bytes are kept
|
|
111
111
|
when they are already smaller, animated, or not an image. Use --no-optimize to
|
|
112
112
|
upload as-is, or --keep-exif when image metadata matters for the discussion.
|
|
113
|
+
Non-media files (PDF, zip, gzip, logs, JSON, CSV, markdown) upload as-is and
|
|
114
|
+
show up in the managed comment as links. HTML and SVG are rejected. Uploads are
|
|
115
|
+
public, so scrub secrets and tokens out of logs, JSON, and other text before
|
|
116
|
+
uploading.
|
|
113
117
|
|
|
114
118
|
Optional --frame wraps the image in a device/browser chrome before optimize
|
|
115
119
|
(default off). See: uploads put --help frames
|
|
@@ -421,13 +425,23 @@ export async function prepareImageForUpload(bytes, filename, opts) {
|
|
|
421
425
|
return { ...optimized, frame: frameMeta };
|
|
422
426
|
}
|
|
423
427
|
/**
|
|
424
|
-
* Merge an image's own EXIF-derived facts under any explicit metadata
|
|
428
|
+
* Merge an image's own EXIF-derived facts under any explicit metadata, when
|
|
429
|
+
* the caller asked for them and the filename could plausibly be an image —
|
|
430
|
+
* only an `image/*` extension, or none at all (an extension-less
|
|
431
|
+
* screenshot), is worth an `imageFactsFromBytes` probe
|
|
432
|
+
* (`sharp(bytes).metadata()` under the hood); a known non-image extension (a
|
|
433
|
+
* 25 MB zip, a `.log`, a PDF…) skips it rather than paying a sharp call that
|
|
434
|
+
* can only come back empty.
|
|
435
|
+
*
|
|
425
436
|
* Best-effort by contract: `imageFactsFromBytes` never rejects, and a full key
|
|
426
437
|
* budget drops the derived pairs rather than failing the upload. Returns the
|
|
427
438
|
* input untouched (including `undefined`) when there is nothing to add, so a
|
|
428
439
|
* metadata-free upload stays metadata-free.
|
|
429
440
|
*/
|
|
430
|
-
async function
|
|
441
|
+
async function maybeDeriveImageFacts(enabled, sourceName, bytes, metadata) {
|
|
442
|
+
const kind = fileKindFromName(sourceName);
|
|
443
|
+
if (!enabled || (kind !== "image" && kind !== "unknown"))
|
|
444
|
+
return metadata;
|
|
431
445
|
const facts = await imageFactsFromBytes(bytes);
|
|
432
446
|
if (Object.keys(facts).length === 0)
|
|
433
447
|
return metadata;
|
|
@@ -444,9 +458,7 @@ async function mergeImageFacts(bytes, metadata) {
|
|
|
444
458
|
*/
|
|
445
459
|
export async function uploadPreparedImage(client, bytes, sourceName, opts) {
|
|
446
460
|
// Read EXIF from the original bytes before the optimizer strips it.
|
|
447
|
-
const metadata = opts.deriveImageFacts
|
|
448
|
-
? await mergeImageFacts(bytes, opts.metadata)
|
|
449
|
-
: opts.metadata;
|
|
461
|
+
const metadata = await maybeDeriveImageFacts(opts.deriveImageFacts, sourceName, bytes, opts.metadata);
|
|
450
462
|
const prepared = await prepareImageForUpload(bytes, sourceName, {
|
|
451
463
|
frameId: opts.frame.frameId,
|
|
452
464
|
frameUrl: opts.frame.frameUrl,
|
|
@@ -809,11 +821,13 @@ takes effect with zero files and cannot combine with --branch/--issue/
|
|
|
809
821
|
--no-promote. Promotion never applies to issues. Staged files stay findable
|
|
810
822
|
with "uploads find gh.branch=<branch>" either way.
|
|
811
823
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
824
|
+
A branch renamed with "git branch -m" is followed automatically: the rename
|
|
825
|
+
is read from the branch reflog and registered, so promotion sweeps the older
|
|
826
|
+
names too. That needs one uploads run after the rename. If the branch was
|
|
827
|
+
renamed without one, or was deleted, pass "--from-branch <old-name>" with
|
|
828
|
+
"--pr <num>". With no file arguments, this promotes the stale branch prefix
|
|
829
|
+
and refreshes the managed comment. With file or existing-key arguments, it
|
|
830
|
+
promotes the stale prefix before the normal attach flow.
|
|
817
831
|
|
|
818
832
|
Options:
|
|
819
833
|
--pr <num> Attach to this pull request
|
|
@@ -953,9 +967,7 @@ async function uploadAttachmentBatch(opts) {
|
|
|
953
967
|
const baseMetadata = mergeSidecarMeta(file, bytes, opts.metadata);
|
|
954
968
|
// Same EXIF promotion uploadPreparedImage does; attach keeps its own
|
|
955
969
|
// per-file tail (it builds keys differently), so it opts in here too.
|
|
956
|
-
const metadata = opts.deriveImageFacts
|
|
957
|
-
? await mergeImageFacts(bytes, baseMetadata)
|
|
958
|
-
: baseMetadata;
|
|
970
|
+
const metadata = await maybeDeriveImageFacts(opts.deriveImageFacts, sourceName, bytes, baseMetadata);
|
|
959
971
|
const prepared = await prepareImageForUpload(bytes, sourceName, {
|
|
960
972
|
...opts.frame,
|
|
961
973
|
optimize: opts.optimize,
|
|
@@ -1174,6 +1186,45 @@ export async function uploadPuts(opts) {
|
|
|
1174
1186
|
}
|
|
1175
1187
|
return { uploads, failures, firstError, sentMetadata };
|
|
1176
1188
|
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Best-effort branch-rename registration (issue #920): reads this branch's
|
|
1191
|
+
* reflog for `git branch -m` steps and tells the server about each one, so a
|
|
1192
|
+
* later promote sweeps the branch's whole name lineage instead of only its
|
|
1193
|
+
* current name. No-op when the reflog has no rename (the common case) and
|
|
1194
|
+
* when the client predates the route. Every failure is swallowed — this runs
|
|
1195
|
+
* alongside staging and promote, and must never fail either. Set
|
|
1196
|
+
* `UPLOADS_DEBUG=1` to see what was skipped.
|
|
1197
|
+
*
|
|
1198
|
+
* `opts.explicit` marks a branch the user named themselves (`--from-branch`
|
|
1199
|
+
* / the MCP `fromBranch` argument): that is the manual escape hatch (PR
|
|
1200
|
+
* #919), and its lineage belongs to a different name than the one we are
|
|
1201
|
+
* standing on, so nothing is registered for it.
|
|
1202
|
+
*/
|
|
1203
|
+
export async function registerRenamesBestEffort(client, run, repo, branch, opts = {}) {
|
|
1204
|
+
if (opts.explicit)
|
|
1205
|
+
return;
|
|
1206
|
+
let lineage;
|
|
1207
|
+
try {
|
|
1208
|
+
lineage = renameLineageFromReflog(run, branch);
|
|
1209
|
+
}
|
|
1210
|
+
catch {
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
if (lineage.length === 0)
|
|
1214
|
+
return;
|
|
1215
|
+
for (const step of lineage) {
|
|
1216
|
+
try {
|
|
1217
|
+
await client.registerBranchRename({ repo, from: step.from, to: step.to });
|
|
1218
|
+
}
|
|
1219
|
+
catch (err) {
|
|
1220
|
+
if (process.env.UPLOADS_DEBUG === "1") {
|
|
1221
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1222
|
+
process.stderr.write(`debug: could not register branch rename ${step.from} -> ${step.to}: ${detail}\n`);
|
|
1223
|
+
}
|
|
1224
|
+
return; // one failure means the route is unavailable; don't retry the rest
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1177
1228
|
/**
|
|
1178
1229
|
* Best-effort call to `POST /v1/workspaces/:workspace/github/promote` (server contract,
|
|
1179
1230
|
* PR #310). Degrade-safe like `syncAttachmentsComment`'s bot path: an older
|
|
@@ -1189,11 +1240,20 @@ async function attemptPromoteBranch(client, target, branch) {
|
|
|
1189
1240
|
return undefined;
|
|
1190
1241
|
}
|
|
1191
1242
|
}
|
|
1192
|
-
/**
|
|
1243
|
+
/**
|
|
1244
|
+
* Human-mode note(s) for a promotion that actually promoted something. When
|
|
1245
|
+
* the server-side sweep followed a rename it adds a second line naming the
|
|
1246
|
+
* older branch names (issue #920): `lineage` is current-name-first, so the
|
|
1247
|
+
* older names are its tail.
|
|
1248
|
+
*/
|
|
1193
1249
|
function promotionNote(promotion, branch) {
|
|
1194
1250
|
const n = promotion.promoted.length;
|
|
1195
1251
|
const branchSuffix = branch ? ` from branch ${branch}` : "";
|
|
1196
|
-
|
|
1252
|
+
const promoted = `>> promoted ${n} staged attachment${n === 1 ? "" : "s"}${branchSuffix}\n`;
|
|
1253
|
+
const older = (promotion.lineage ?? []).slice(1);
|
|
1254
|
+
if (older.length === 0)
|
|
1255
|
+
return promoted;
|
|
1256
|
+
return `${promoted}>> followed rename from ${older.join(", ")}\n`;
|
|
1197
1257
|
}
|
|
1198
1258
|
export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
1199
1259
|
const parsed = parseCommandArgs(args);
|
|
@@ -1344,16 +1404,11 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
1344
1404
|
let promotion;
|
|
1345
1405
|
let promotedBranch;
|
|
1346
1406
|
if (target.kind === "pull" && !parsed.flags.has("--no-promote")) {
|
|
1347
|
-
promotedBranch = fromBranch;
|
|
1348
|
-
if (promotedBranch === undefined) {
|
|
1349
|
-
try {
|
|
1350
|
-
promotedBranch = resolveCurrentBranch(run);
|
|
1351
|
-
}
|
|
1352
|
-
catch {
|
|
1353
|
-
promotedBranch = undefined;
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1407
|
+
promotedBranch = fromBranch ?? resolveCurrentBranchSafe(run);
|
|
1356
1408
|
if (promotedBranch !== undefined) {
|
|
1409
|
+
await registerRenamesBestEffort(ctx.client, run, target.repo, promotedBranch, {
|
|
1410
|
+
explicit: fromBranch !== undefined,
|
|
1411
|
+
});
|
|
1357
1412
|
promotion = await attemptPromoteBranch(ctx.client, target, promotedBranch);
|
|
1358
1413
|
}
|
|
1359
1414
|
}
|
|
@@ -1449,6 +1504,9 @@ async function runAttachBranch(ctx, parsed, branch, run) {
|
|
|
1449
1504
|
process.stderr.write(`>> uploading ${n} file${n === 1 ? "" : "s"} (staged for branch ${branch})\n`);
|
|
1450
1505
|
}
|
|
1451
1506
|
const target = { repo, branch };
|
|
1507
|
+
// Register any `git branch -m` steps behind this name (issue #920) so the
|
|
1508
|
+
// promote that runs when the PR opens sweeps the older names too.
|
|
1509
|
+
await registerRenamesBestEffort(ctx.client, run, repo, branch);
|
|
1452
1510
|
const { uploads, failures, firstError } = await uploadBranchAttachments({
|
|
1453
1511
|
client: ctx.client,
|
|
1454
1512
|
target,
|
|
@@ -1567,7 +1625,11 @@ async function runAttachPromoteOnly(ctx, parsed, run) {
|
|
|
1567
1625
|
if (target.kind !== "pull") {
|
|
1568
1626
|
throw new UsageError("--from-branch only promotes into a pull request");
|
|
1569
1627
|
}
|
|
1570
|
-
const
|
|
1628
|
+
const explicitFromBranch = flagString(parsed.flags, "--from-branch");
|
|
1629
|
+
const branch = explicitFromBranch ?? resolveCurrentBranch(run);
|
|
1630
|
+
await registerRenamesBestEffort(ctx.client, run, target.repo, branch, {
|
|
1631
|
+
explicit: explicitFromBranch !== undefined,
|
|
1632
|
+
});
|
|
1571
1633
|
const promotion = await attemptPromoteBranch(ctx.client, target, branch);
|
|
1572
1634
|
let comment;
|
|
1573
1635
|
let commentError;
|
|
@@ -1656,13 +1718,9 @@ export function resolvePutNudgeContext(opts) {
|
|
|
1656
1718
|
try {
|
|
1657
1719
|
if (deriveRepoFromGit(run) === undefined)
|
|
1658
1720
|
return undefined; // not a (usable) git repo
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
branch = resolveCurrentBranch(run);
|
|
1662
|
-
}
|
|
1663
|
-
catch {
|
|
1721
|
+
const branch = resolveCurrentBranchSafe(run);
|
|
1722
|
+
if (branch === undefined)
|
|
1664
1723
|
return undefined; // detached HEAD, or git unavailable
|
|
1665
|
-
}
|
|
1666
1724
|
const defaultBranch = resolveDefaultBranch(run);
|
|
1667
1725
|
const onDefaultBranch = defaultBranch
|
|
1668
1726
|
? branch === defaultBranch
|
|
@@ -1704,6 +1762,10 @@ export function resolvePutNudgeContext(opts) {
|
|
|
1704
1762
|
* default branch, or with `--no-git`; any failure (not a repo, detached
|
|
1705
1763
|
* HEAD, gh missing/unauthenticated/timed out, no open PR) degrades to
|
|
1706
1764
|
* undefined so the caller falls back to its normal staging/dated behavior.
|
|
1765
|
+
*
|
|
1766
|
+
* The match carries the `branch` it was resolved from, so callers that need
|
|
1767
|
+
* the current branch (the #920 rename registration) reuse it instead of
|
|
1768
|
+
* spawning `git rev-parse` a second time.
|
|
1707
1769
|
*/
|
|
1708
1770
|
export function resolveAutoPrTarget(opts) {
|
|
1709
1771
|
const { ghTarget, keyHint, refArg, prefixArg, destinationArg, branchArg, noGit, noAutoPr, repoArg, run, } = opts;
|
|
@@ -1716,13 +1778,9 @@ export function resolveAutoPrTarget(opts) {
|
|
|
1716
1778
|
try {
|
|
1717
1779
|
if (deriveRepoFromGit(run) === undefined)
|
|
1718
1780
|
return undefined; // not a (usable) git repo
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
branch = resolveCurrentBranch(run);
|
|
1722
|
-
}
|
|
1723
|
-
catch {
|
|
1781
|
+
const branch = resolveCurrentBranchSafe(run);
|
|
1782
|
+
if (branch === undefined)
|
|
1724
1783
|
return undefined; // detached HEAD, or git unavailable
|
|
1725
|
-
}
|
|
1726
1784
|
const defaultBranch = resolveDefaultBranch(run);
|
|
1727
1785
|
const onDefaultBranch = defaultBranch
|
|
1728
1786
|
? branch === defaultBranch
|
|
@@ -1733,7 +1791,7 @@ export function resolveAutoPrTarget(opts) {
|
|
|
1733
1791
|
// this must never be felt as a hang.
|
|
1734
1792
|
const timed = run === execRunner ? timedExecRunner(PUT_NUDGE_GH_TIMEOUT_MS) : run;
|
|
1735
1793
|
const repo = resolveRepo(repoArg, timed);
|
|
1736
|
-
return resolveCurrentPullRequest(repo, timed);
|
|
1794
|
+
return { target: resolveCurrentPullRequest(repo, timed), branch };
|
|
1737
1795
|
}
|
|
1738
1796
|
catch {
|
|
1739
1797
|
return undefined; // gh/git unavailable, no open PR, or repo unresolvable
|
|
@@ -1766,13 +1824,9 @@ export function resolvePutStagingTarget(opts) {
|
|
|
1766
1824
|
try {
|
|
1767
1825
|
if (deriveRepoFromGit(run) === undefined)
|
|
1768
1826
|
return undefined; // not a (usable) git repo
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
branch = resolveCurrentBranch(run);
|
|
1772
|
-
}
|
|
1773
|
-
catch {
|
|
1827
|
+
const branch = resolveCurrentBranchSafe(run);
|
|
1828
|
+
if (branch === undefined)
|
|
1774
1829
|
return undefined; // detached HEAD, or git unavailable
|
|
1775
|
-
}
|
|
1776
1830
|
const defaultBranch = resolveDefaultBranch(run);
|
|
1777
1831
|
const onDefaultBranch = defaultBranch
|
|
1778
1832
|
? branch === defaultBranch
|
|
@@ -1993,7 +2047,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1993
2047
|
// resolveAutoPrTarget. Supersedes both the #403 staging default and the
|
|
1994
2048
|
// #393 nudge for this case; computed before the gh.* metadata resolution
|
|
1995
2049
|
// below since it takes over that resolution entirely.
|
|
1996
|
-
const
|
|
2050
|
+
const autoPrMatch = ghTarget
|
|
1997
2051
|
? undefined
|
|
1998
2052
|
: resolveAutoPrTarget({
|
|
1999
2053
|
ghTarget,
|
|
@@ -2006,6 +2060,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
2006
2060
|
repoArg: flagString(parsed.flags, "--repo") ?? defaults.repo,
|
|
2007
2061
|
run,
|
|
2008
2062
|
});
|
|
2063
|
+
const autoPrTarget = autoPrMatch?.target;
|
|
2009
2064
|
const effectiveGhTarget = ghTarget ?? autoPrTarget;
|
|
2010
2065
|
// Comment sync runs by default with --pr/--issue (matches `attach`); opt
|
|
2011
2066
|
// out with --no-comment. --comment is accepted as a redundant no-op for
|
|
@@ -2137,9 +2192,23 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
2137
2192
|
validateMetaMap(merged); // enforce 24-key/8KB caps on the merged map (matches attach)
|
|
2138
2193
|
metadata = merged;
|
|
2139
2194
|
attachedRef = merged["gh.ref"];
|
|
2195
|
+
// Auto-PR (#700) suppresses staging, so the staging branch below never
|
|
2196
|
+
// runs — but this put still comes from a branch that may have been
|
|
2197
|
+
// renamed while files were staged under the old name. Register the
|
|
2198
|
+
// lineage here too (issue #920), reusing the branch the match already
|
|
2199
|
+
// resolved. Only for the auto-PR match: an explicit --pr/--issue names
|
|
2200
|
+
// no branch of its own.
|
|
2201
|
+
if (autoPrMatch && !dryRun) {
|
|
2202
|
+
await registerRenamesBestEffort(ctx.client, run, autoPrMatch.target.repo, autoPrMatch.branch);
|
|
2203
|
+
}
|
|
2140
2204
|
}
|
|
2141
2205
|
else if (stagingTarget) {
|
|
2142
2206
|
metadata = mergeStagingMeta(userMeta, stagingTarget);
|
|
2207
|
+
// Branch staging: register any rename behind this name (issue #920) so
|
|
2208
|
+
// the PR-time promote finds files staged under the older names too.
|
|
2209
|
+
if (!dryRun) {
|
|
2210
|
+
await registerRenamesBestEffort(ctx.client, run, stagingTarget.repo, stagingTarget.branch);
|
|
2211
|
+
}
|
|
2143
2212
|
}
|
|
2144
2213
|
else {
|
|
2145
2214
|
// gh.* additionally needs git, which the shared derived gate ignores.
|
|
@@ -47,6 +47,14 @@ export declare function parseGhPrivateKey(key: string): {
|
|
|
47
47
|
kind: GhTargetKind;
|
|
48
48
|
num: number;
|
|
49
49
|
} | undefined;
|
|
50
|
+
export declare function inferContentType(filename: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* Coarse family a filename belongs to, for the callers that only branch on
|
|
53
|
+
* "is this an image / a video / something else" — `unknown` when the
|
|
54
|
+
* extension maps to nothing (an extension-less screenshot, say), which those
|
|
55
|
+
* callers treat as "might be an image, probe it".
|
|
56
|
+
*/
|
|
57
|
+
export declare function fileKindFromName(filename: string): "image" | "video" | "file" | "unknown";
|
|
50
58
|
/** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
|
|
51
59
|
export declare const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
|
|
52
60
|
/**
|
|
@@ -69,27 +69,55 @@ export function parseGhPrivateKey(key) {
|
|
|
69
69
|
return { prefixId, kind: kind, num: Number(num) };
|
|
70
70
|
}
|
|
71
71
|
/** GitHub-embed helper (content type). Copied from packages/uploads/src/embed.ts. */
|
|
72
|
-
|
|
72
|
+
const CONTENT_TYPE_BY_EXTENSION = {
|
|
73
|
+
png: "image/png",
|
|
74
|
+
jpg: "image/jpeg",
|
|
75
|
+
jpeg: "image/jpeg",
|
|
76
|
+
gif: "image/gif",
|
|
77
|
+
webp: "image/webp",
|
|
78
|
+
avif: "image/avif",
|
|
79
|
+
svg: "image/svg+xml",
|
|
80
|
+
mp4: "video/mp4",
|
|
81
|
+
webm: "video/webm",
|
|
82
|
+
mov: "video/quicktime",
|
|
83
|
+
pdf: "application/pdf",
|
|
84
|
+
zip: "application/zip",
|
|
85
|
+
gz: "application/gzip",
|
|
86
|
+
tgz: "application/gzip",
|
|
87
|
+
txt: "text/plain",
|
|
88
|
+
text: "text/plain",
|
|
89
|
+
log: "text/plain",
|
|
90
|
+
jsonl: "text/plain",
|
|
91
|
+
ndjson: "text/plain",
|
|
92
|
+
yaml: "text/plain",
|
|
93
|
+
yml: "text/plain",
|
|
94
|
+
md: "text/markdown",
|
|
95
|
+
markdown: "text/markdown",
|
|
96
|
+
csv: "text/csv",
|
|
97
|
+
json: "application/json",
|
|
98
|
+
xml: "application/xml",
|
|
99
|
+
};
|
|
100
|
+
export function inferContentType(filename) {
|
|
73
101
|
const ext = filename.includes(".")
|
|
74
102
|
? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase()
|
|
75
103
|
: "";
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
return CONTENT_TYPE_BY_EXTENSION[ext] ?? "application/octet-stream";
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Coarse family a filename belongs to, for the callers that only branch on
|
|
108
|
+
* "is this an image / a video / something else" — `unknown` when the
|
|
109
|
+
* extension maps to nothing (an extension-less screenshot, say), which those
|
|
110
|
+
* callers treat as "might be an image, probe it".
|
|
111
|
+
*/
|
|
112
|
+
export function fileKindFromName(filename) {
|
|
113
|
+
const type = inferContentType(filename);
|
|
114
|
+
if (type === "application/octet-stream")
|
|
115
|
+
return "unknown";
|
|
116
|
+
if (type.startsWith("image/"))
|
|
117
|
+
return "image";
|
|
118
|
+
if (type.startsWith("video/"))
|
|
119
|
+
return "video";
|
|
120
|
+
return "file";
|
|
93
121
|
}
|
|
94
122
|
/** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
|
|
95
123
|
export const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
|
package/dist/embed.d.ts
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
/** GitHub-embed helpers (content type + markdown). */
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The filename→type map and its two readers live in
|
|
4
|
+
* packages/comment-render/src/index.ts (inlined here as
|
|
5
|
+
* `comment-render.generated.ts`), so the CLI and the managed-comment renderer
|
|
6
|
+
* cannot drift apart. Re-exported from this module because every existing
|
|
7
|
+
* caller imports them from `./embed.js`. That map mirrors the upload table in
|
|
8
|
+
* apps/api/src/guards.ts; keep the two in step. `svg` maps to
|
|
9
|
+
* `image/svg+xml` here same as any other type (issue #929): the server no
|
|
10
|
+
* longer rejects it outright — it accepts SVG only on a storage lane
|
|
11
|
+
* verified to serve it behind a sandboxing CSP (`apps/api/src/active-content.ts`),
|
|
12
|
+
* so whether a given upload actually lands depends on that lane's state,
|
|
13
|
+
* not on anything this map decides.
|
|
14
|
+
*/
|
|
15
|
+
export { fileKindFromName, inferContentType } from "./comment-render.generated.js";
|
|
3
16
|
export declare function buildMarkdown(url: string, opts: {
|
|
4
17
|
alt: string;
|
|
5
18
|
width?: number;
|
package/dist/embed.js
CHANGED
|
@@ -1,26 +1,18 @@
|
|
|
1
1
|
/** GitHub-embed helpers (content type + markdown). */
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
case "svg":
|
|
17
|
-
return "image/svg+xml";
|
|
18
|
-
case "mp4":
|
|
19
|
-
return "video/mp4";
|
|
20
|
-
default:
|
|
21
|
-
return "application/octet-stream";
|
|
22
|
-
}
|
|
23
|
-
}
|
|
2
|
+
/**
|
|
3
|
+
* The filename→type map and its two readers live in
|
|
4
|
+
* packages/comment-render/src/index.ts (inlined here as
|
|
5
|
+
* `comment-render.generated.ts`), so the CLI and the managed-comment renderer
|
|
6
|
+
* cannot drift apart. Re-exported from this module because every existing
|
|
7
|
+
* caller imports them from `./embed.js`. That map mirrors the upload table in
|
|
8
|
+
* apps/api/src/guards.ts; keep the two in step. `svg` maps to
|
|
9
|
+
* `image/svg+xml` here same as any other type (issue #929): the server no
|
|
10
|
+
* longer rejects it outright — it accepts SVG only on a storage lane
|
|
11
|
+
* verified to serve it behind a sandboxing CSP (`apps/api/src/active-content.ts`),
|
|
12
|
+
* so whether a given upload actually lands depends on that lane's state,
|
|
13
|
+
* not on anything this map decides.
|
|
14
|
+
*/
|
|
15
|
+
export { fileKindFromName, inferContentType } from "./comment-render.generated.js";
|
|
24
16
|
export function buildMarkdown(url, opts) {
|
|
25
17
|
if (opts.width) {
|
|
26
18
|
const alt = opts.alt.replace(/"/g, """);
|
package/dist/github-gh.d.ts
CHANGED
|
@@ -19,6 +19,31 @@ export declare function resolveRepo(explicit: string | undefined, run?: CommandR
|
|
|
19
19
|
export declare function resolveCurrentPullRequest(repo: string, run?: CommandRunner): GhTarget;
|
|
20
20
|
/** Resolve the current git branch (`--branch` with no value). Throws UsageError on detached HEAD or outside a git repo. */
|
|
21
21
|
export declare function resolveCurrentBranch(run?: CommandRunner): string;
|
|
22
|
+
/**
|
|
23
|
+
* `resolveCurrentBranch` for best-effort callers: undefined instead of a
|
|
24
|
+
* throw when the branch can't be determined (detached HEAD, not a git repo,
|
|
25
|
+
* git unavailable). Use it wherever a missing branch means "skip this step",
|
|
26
|
+
* not "fail the command".
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveCurrentBranchSafe(run?: CommandRunner): string | undefined;
|
|
29
|
+
/** One `git branch -m` step read out of a branch's reflog: `from` was renamed to `to`. */
|
|
30
|
+
export interface BranchRenameStep {
|
|
31
|
+
from: string;
|
|
32
|
+
to: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Rename lineage for `branch`, read from its own reflog (issue #920). `git
|
|
36
|
+
* branch -m old new` writes `Branch: renamed refs/heads/old to
|
|
37
|
+
* refs/heads/new` into the NEW branch's reflog, and chained renames
|
|
38
|
+
* accumulate there, newest entry first — so the parsed pairs are reversed
|
|
39
|
+
* into oldest-first order, which is the order the server wants them
|
|
40
|
+
* registered.
|
|
41
|
+
*
|
|
42
|
+
* Best-effort by construction: any git failure (not a repo, no reflog for
|
|
43
|
+
* the branch, git missing) and any non-rename reflog line yield an empty
|
|
44
|
+
* array. Callers must treat "no lineage" as the normal case.
|
|
45
|
+
*/
|
|
46
|
+
export declare function renameLineageFromReflog(run?: CommandRunner, branch?: string): BranchRenameStep[];
|
|
22
47
|
/**
|
|
23
48
|
* Best-effort default-branch name via the local `origin/HEAD` ref (no
|
|
24
49
|
* network call — just reads the ref git already cached from the last
|
package/dist/github-gh.js
CHANGED
|
@@ -114,6 +114,55 @@ export function resolveCurrentBranch(run = execRunner) {
|
|
|
114
114
|
}
|
|
115
115
|
return branch;
|
|
116
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* `resolveCurrentBranch` for best-effort callers: undefined instead of a
|
|
119
|
+
* throw when the branch can't be determined (detached HEAD, not a git repo,
|
|
120
|
+
* git unavailable). Use it wherever a missing branch means "skip this step",
|
|
121
|
+
* not "fail the command".
|
|
122
|
+
*/
|
|
123
|
+
export function resolveCurrentBranchSafe(run = execRunner) {
|
|
124
|
+
try {
|
|
125
|
+
return resolveCurrentBranch(run);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Rename lineage for `branch`, read from its own reflog (issue #920). `git
|
|
133
|
+
* branch -m old new` writes `Branch: renamed refs/heads/old to
|
|
134
|
+
* refs/heads/new` into the NEW branch's reflog, and chained renames
|
|
135
|
+
* accumulate there, newest entry first — so the parsed pairs are reversed
|
|
136
|
+
* into oldest-first order, which is the order the server wants them
|
|
137
|
+
* registered.
|
|
138
|
+
*
|
|
139
|
+
* Best-effort by construction: any git failure (not a repo, no reflog for
|
|
140
|
+
* the branch, git missing) and any non-rename reflog line yield an empty
|
|
141
|
+
* array. Callers must treat "no lineage" as the normal case.
|
|
142
|
+
*/
|
|
143
|
+
export function renameLineageFromReflog(run = execRunner, branch) {
|
|
144
|
+
if (!branch)
|
|
145
|
+
return [];
|
|
146
|
+
let out;
|
|
147
|
+
try {
|
|
148
|
+
out = run("git", ["reflog", "show", "--format=%gs", `refs/heads/${branch}`]);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return []; // no reflog, not a repo, git unavailable — nothing to follow
|
|
152
|
+
}
|
|
153
|
+
const steps = [];
|
|
154
|
+
for (const line of out.split("\n")) {
|
|
155
|
+
const match = /^Branch: renamed refs\/heads\/(.+) to refs\/heads\/(.+)$/.exec(line.trim());
|
|
156
|
+
if (!match)
|
|
157
|
+
continue;
|
|
158
|
+
const from = match[1].trim();
|
|
159
|
+
const to = match[2].trim();
|
|
160
|
+
if (!from || !to || from === to)
|
|
161
|
+
continue;
|
|
162
|
+
steps.push({ from, to });
|
|
163
|
+
}
|
|
164
|
+
return steps.reverse(); // reflog is newest-first; register oldest-first
|
|
165
|
+
}
|
|
117
166
|
/**
|
|
118
167
|
* Best-effort default-branch name via the local `origin/HEAD` ref (no
|
|
119
168
|
* network call — just reads the ref git already cached from the last
|
package/dist/mcp/tools.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createUploadsClient } from "../client.js";
|
|
2
|
-
import { buildDoctorReport, ghListPrefixes, ghMergedList, makeGhTarget, mergeStagingMeta, resolveAutoPrTarget, resolveGhPrefixSafe, resolvePutNudgeContext, resolvePutStagingTarget, resolveStaged, syncAttachmentsComment, autoPrNoteText, putNudgeText, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
|
|
1
|
+
import { createUploadsClient, } from "../client.js";
|
|
2
|
+
import { buildDoctorReport, ghListPrefixes, ghMergedList, makeGhTarget, mergeStagingMeta, resolveAutoPrTarget, resolveGhPrefixSafe, resolvePutNudgeContext, registerRenamesBestEffort, resolvePutStagingTarget, resolveStaged, syncAttachmentsComment, autoPrNoteText, putNudgeText, uploadAttachments, uploadPreparedImage, uploadPuts, } from "../commands.js";
|
|
3
3
|
import { resolveFrameId } from "../frame.js";
|
|
4
4
|
import { resolveConfig, resolvePutDefaults, } from "../config.js";
|
|
5
5
|
import { resolvePutPrefix } from "../destinations.js";
|
|
@@ -9,7 +9,7 @@ import { safeCaptureFacts } from "../capture-facts.js";
|
|
|
9
9
|
import { deriveRepoSlugFromGit } from "../keys.js";
|
|
10
10
|
import { validateMetaMap } from "../metadata.js";
|
|
11
11
|
import { mergeDerivedMeta } from "../metadata-vocab.js";
|
|
12
|
-
import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentBranch, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
|
|
12
|
+
import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentBranch, resolveCurrentBranchSafe, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
|
|
13
13
|
import { appProp, canonicalMetaFromArgs, METADATA_PATH_CUE, metadataArgWithCanonical, metadataProp, optBool, optPosInt, optString, optStringArray, optStringRecord, stateProp, usage, } from "./args.js";
|
|
14
14
|
import { batchFailureMessage, mcpDestroyPublic, mcpNoAuth, mcpOAuthAny, mcpOAuthDelete, mcpOAuthRead, mcpOAuthWrite, mcpRead, mcpWriteInternal, mcpWritePublic, stdioOutputSchemas, withOutputSchemas, ToolBatchError, } from "./server.js";
|
|
15
15
|
import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
|
|
@@ -131,6 +131,25 @@ export function createUploadsMcpTools(opts) {
|
|
|
131
131
|
}
|
|
132
132
|
return { comment, commentError };
|
|
133
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* Best-effort branch promote for the stdio `attach` tool (CLI `attach --pr`
|
|
136
|
+
* parity, issue #920). Registers any `git branch -m` steps first so the
|
|
137
|
+
* sweep follows renames, then promotes. Never throws: a failure surfaces as
|
|
138
|
+
* `promoteError` so the uploads themselves still return.
|
|
139
|
+
*/
|
|
140
|
+
const attemptPromote = async (client, target, branch) => {
|
|
141
|
+
try {
|
|
142
|
+
const promotion = await client.promoteBranchAttachments({
|
|
143
|
+
repo: target.repo,
|
|
144
|
+
num: target.num,
|
|
145
|
+
branch,
|
|
146
|
+
});
|
|
147
|
+
return { promotion };
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
return { promoteError: err instanceof Error ? err.message : String(err) };
|
|
151
|
+
}
|
|
152
|
+
};
|
|
134
153
|
const tools = [
|
|
135
154
|
{
|
|
136
155
|
name: "gallery_create",
|
|
@@ -273,7 +292,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
273
292
|
title: "Upload file",
|
|
274
293
|
annotations: mcpDestroyPublic,
|
|
275
294
|
securitySchemes: mcpOAuthWrite,
|
|
276
|
-
description: "Upload one or more files and get a public URL plus GitHub-ready markdown. Prefer `embedUrl` in GitHub markdown. Pass `contentUrl` for a public HTTPS file, or http://localhost on this machine, instead of a local path. With `pr`/`issue`, keys are stable and the managed comment is synced. All uploads are public.",
|
|
295
|
+
description: "Upload one or more files and get a public URL plus GitHub-ready markdown. Prefer `embedUrl` in GitHub markdown. Pass `contentUrl` for a public HTTPS file, or http://localhost on this machine, instead of a local path. With `pr`/`issue`, keys are stable and the managed comment is synced. All uploads are public. Accepts images (PNG, JPEG, GIF, WebP, AVIF), video (MP4, WebM, MOV), PDF, zip, gzip, and text (plain, markdown, CSV, JSON). SVG and XML are accepted only on storage lanes verified to serve them sandboxed (see the workspace's storage settings); HTML is rejected.",
|
|
277
296
|
inputSchema: {
|
|
278
297
|
type: "object",
|
|
279
298
|
properties: {
|
|
@@ -466,7 +485,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
466
485
|
// had been passed (stable key + managed comment sync) instead of
|
|
467
486
|
// the #403 staging default below. Never throws — see
|
|
468
487
|
// resolveAutoPrTarget.
|
|
469
|
-
const autoPrTarget = target
|
|
488
|
+
const autoPrTarget = (target
|
|
470
489
|
? undefined
|
|
471
490
|
: resolveAutoPrTarget({
|
|
472
491
|
ghTarget: target,
|
|
@@ -478,7 +497,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
478
497
|
noAutoPr,
|
|
479
498
|
repoArg: optString(args, "repo") ?? defaults.repo,
|
|
480
499
|
run,
|
|
481
|
-
});
|
|
500
|
+
}))?.target;
|
|
482
501
|
const effectiveTarget = target ?? autoPrTarget;
|
|
483
502
|
// Bare-put branch staging (issue #403): local stdio MCP put mirrors
|
|
484
503
|
// the CLI default — no pr/issue/key/ref/prefix/destination, not
|
|
@@ -858,7 +877,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
858
877
|
// behaves as if `pr` had been passed (stable key + managed comment
|
|
859
878
|
// sync) instead of the #469 auto-staging default below. Never
|
|
860
879
|
// throws — see resolveAutoPrTarget.
|
|
861
|
-
const autoPrTarget = target
|
|
880
|
+
const autoPrTarget = (target
|
|
862
881
|
? undefined
|
|
863
882
|
: resolveAutoPrTarget({
|
|
864
883
|
ghTarget: target,
|
|
@@ -870,7 +889,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
870
889
|
noAutoPr,
|
|
871
890
|
repoArg: optString(args, "repo") ?? defaults.repo,
|
|
872
891
|
run,
|
|
873
|
-
});
|
|
892
|
+
}))?.target;
|
|
874
893
|
const effectiveTarget = target ?? autoPrTarget;
|
|
875
894
|
// Auto branch staging (issue #469 lever 1): mirrors the CLI screenshot
|
|
876
895
|
// command and the put tool above (issue #403) — no pr/issue/key/ref/
|
|
@@ -1064,7 +1083,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
1064
1083
|
title: "Attach to GitHub",
|
|
1065
1084
|
annotations: mcpDestroyPublic,
|
|
1066
1085
|
securitySchemes: mcpOAuthWrite,
|
|
1067
|
-
description: "Upload one or more files as stable PR/issue attachments (in parallel) and maintain a managed GitHub comment. Returns `uploads` and `failures` (one bad file does not abort the batch). Each success has `url`, `embedUrl`, and `markdown` (prefer embedUrl for GitHub). With no pr/issue, targets the current branch PR. Attachments are public and keys are predictable; upload only non-sensitive media.",
|
|
1086
|
+
description: "Upload one or more files as stable PR/issue attachments (in parallel) and maintain a managed GitHub comment. Returns `uploads` and `failures` (one bad file does not abort the batch). Each success has `url`, `embedUrl`, and `markdown` (prefer embedUrl for GitHub). With no pr/issue, targets the current branch PR, and files already staged for that branch are promoted into it (`promotion`/`promoteError`; renamed branches are followed automatically, or name the old branch with fromBranch). Attachments are public and keys are predictable; upload only non-sensitive media.",
|
|
1068
1087
|
inputSchema: {
|
|
1069
1088
|
type: "object",
|
|
1070
1089
|
properties: {
|
|
@@ -1078,6 +1097,14 @@ export function createUploadsMcpTools(opts) {
|
|
|
1078
1097
|
type: "boolean",
|
|
1079
1098
|
description: "Upload only; don't create/update the managed comment.",
|
|
1080
1099
|
},
|
|
1100
|
+
noPromote: {
|
|
1101
|
+
type: "boolean",
|
|
1102
|
+
description: "Skip promoting files staged for the current branch into the PR.",
|
|
1103
|
+
},
|
|
1104
|
+
fromBranch: {
|
|
1105
|
+
type: "string",
|
|
1106
|
+
description: "Promote files staged under this branch name instead of the current branch (for a branch renamed before the PR opened).",
|
|
1107
|
+
},
|
|
1081
1108
|
contentType: {
|
|
1082
1109
|
type: "string",
|
|
1083
1110
|
description: "Override the Content-Type (applied to every file; ignored when optimize rewrites).",
|
|
@@ -1109,6 +1136,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
1109
1136
|
examples: [
|
|
1110
1137
|
{ files: ["./after.png"], pr: 12, state: "after" },
|
|
1111
1138
|
{ files: ["./before.png", "./after.png"], pr: 12 },
|
|
1139
|
+
{ files: ["./after.png"], pr: 12, fromBranch: "old-branch-name" },
|
|
1112
1140
|
],
|
|
1113
1141
|
},
|
|
1114
1142
|
async handler(args) {
|
|
@@ -1118,6 +1146,13 @@ export function createUploadsMcpTools(opts) {
|
|
|
1118
1146
|
const explicitTarget = ghTargetFromArgs(args, run);
|
|
1119
1147
|
const target = explicitTarget ??
|
|
1120
1148
|
resolveCurrentPullRequest(resolveRepo(optString(args, "repo"), run), run);
|
|
1149
|
+
const fromBranch = optString(args, "fromBranch");
|
|
1150
|
+
if (fromBranch !== undefined && optBool(args, "noPromote")) {
|
|
1151
|
+
usage("fromBranch cannot be combined with noPromote");
|
|
1152
|
+
}
|
|
1153
|
+
if (fromBranch !== undefined && target.kind !== "pull") {
|
|
1154
|
+
usage("fromBranch only promotes into a pull request");
|
|
1155
|
+
}
|
|
1121
1156
|
const { config, client } = await clientFor(args);
|
|
1122
1157
|
const contentType = optString(args, "contentType");
|
|
1123
1158
|
const defaults = resolvePutDefaults({ envFile: globals.envFile });
|
|
@@ -1154,10 +1189,30 @@ export function createUploadsMcpTools(opts) {
|
|
|
1154
1189
|
failures,
|
|
1155
1190
|
});
|
|
1156
1191
|
}
|
|
1192
|
+
// Auto-promote (CLI `attach --pr` parity, issue #920): before the
|
|
1193
|
+
// comment sync, best-effort promote this workspace's branch-staged
|
|
1194
|
+
// files into the PR, following any `git branch -m` rename. Never for
|
|
1195
|
+
// issues, never with noPromote, and silently skipped when the current
|
|
1196
|
+
// branch can't be resolved — this must not fail the attach.
|
|
1197
|
+
let promotion;
|
|
1198
|
+
let promoteError;
|
|
1199
|
+
if (target.kind === "pull" && !optBool(args, "noPromote")) {
|
|
1200
|
+
const branch = fromBranch ?? resolveCurrentBranchSafe(run);
|
|
1201
|
+
if (branch !== undefined) {
|
|
1202
|
+
await registerRenamesBestEffort(client, run, target.repo, branch, {
|
|
1203
|
+
explicit: fromBranch !== undefined,
|
|
1204
|
+
});
|
|
1205
|
+
({ promotion, promoteError } = await attemptPromote(client, target, branch));
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
const promoteFields = {
|
|
1209
|
+
...(promotion ? { promotion } : {}),
|
|
1210
|
+
...(promoteError ? { promoteError } : {}),
|
|
1211
|
+
};
|
|
1157
1212
|
if (optBool(args, "noComment"))
|
|
1158
|
-
return { target, uploads, failures };
|
|
1213
|
+
return { target, uploads, failures, ...promoteFields };
|
|
1159
1214
|
const { comment, commentError } = await syncComment(client, target, config.workspace);
|
|
1160
|
-
return { target, uploads, failures, comment, commentError };
|
|
1215
|
+
return { target, uploads, failures, ...promoteFields, comment, commentError };
|
|
1161
1216
|
},
|
|
1162
1217
|
},
|
|
1163
1218
|
{
|
package/dist/optimize.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* SVG, video, and non-images are left unchanged.
|
|
7
7
|
*/
|
|
8
8
|
import sharp from "sharp";
|
|
9
|
+
import { fileKindFromName, inferContentType } from "./embed.js";
|
|
9
10
|
/** Longest edge in pixels (screenshots beyond this rarely help PR review). */
|
|
10
11
|
export const DEFAULT_OPTIMIZE_MAX_EDGE = 2400;
|
|
11
12
|
/** WebP quality tuned for UI screenshots (text/chrome stay sharp enough). */
|
|
@@ -68,11 +69,25 @@ function passthrough(bytes, filename, contentType, skippedReason) {
|
|
|
68
69
|
*/
|
|
69
70
|
export async function optimizeImageForUpload(bytes, filename, opts = {}) {
|
|
70
71
|
const originalBytes = bytes.byteLength;
|
|
72
|
+
// The filename's own claim, used by every passthrough exit below.
|
|
73
|
+
const guessed = inferContentType(filename);
|
|
71
74
|
if (opts.enabled === false) {
|
|
72
|
-
return passthrough(bytes, filename,
|
|
75
|
+
return passthrough(bytes, filename, guessed, "disabled");
|
|
73
76
|
}
|
|
74
77
|
if (originalBytes === 0) {
|
|
75
|
-
return passthrough(bytes, filename,
|
|
78
|
+
return passthrough(bytes, filename, guessed, "empty");
|
|
79
|
+
}
|
|
80
|
+
// A known non-image extension (log, pdf, zip, video…) never needs sharp —
|
|
81
|
+
// and must be checked before `looksLikeSvg` below, since that check sniffs
|
|
82
|
+
// the leading bytes for an XML/SVG prologue with no regard for the actual
|
|
83
|
+
// extension: a `.log` or `.json` file that happens to start with `<?xml`
|
|
84
|
+
// (a JUnit report, say) would otherwise misreport as `skippedReason: "svg"`
|
|
85
|
+
// / `image/svg+xml`. An unknown extension is still "might be an image": it
|
|
86
|
+
// falls through to the SVG sniff and the sharp probe below, so an
|
|
87
|
+
// extension-less screenshot is optimized as before.
|
|
88
|
+
const kind = fileKindFromName(filename);
|
|
89
|
+
if (kind === "file" || kind === "video") {
|
|
90
|
+
return passthrough(bytes, filename, guessed, "not_image");
|
|
76
91
|
}
|
|
77
92
|
if (looksLikeSvg(bytes, filename)) {
|
|
78
93
|
return passthrough(bytes, filename, "image/svg+xml", "svg");
|
|
@@ -92,14 +107,14 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
|
|
|
92
107
|
meta = await image.metadata();
|
|
93
108
|
}
|
|
94
109
|
catch {
|
|
95
|
-
return passthrough(bytes, filename,
|
|
110
|
+
return passthrough(bytes, filename, guessed, "not_image");
|
|
96
111
|
}
|
|
97
112
|
if (!meta.format) {
|
|
98
|
-
return passthrough(bytes, filename,
|
|
113
|
+
return passthrough(bytes, filename, guessed, "not_image");
|
|
99
114
|
}
|
|
100
115
|
// Animated GIF/WebP: keep as-is (re-encoding often breaks or balloons size).
|
|
101
116
|
if ((meta.pages ?? 1) > 1) {
|
|
102
|
-
return passthrough(bytes, filename, meta.format === "gif" ? "image/gif" :
|
|
117
|
+
return passthrough(bytes, filename, meta.format === "gif" ? "image/gif" : guessed, "animated");
|
|
103
118
|
}
|
|
104
119
|
if (meta.format === "gif") {
|
|
105
120
|
// Single-frame GIF can convert; multi-page already returned above.
|
|
@@ -134,10 +149,10 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
|
|
|
134
149
|
}
|
|
135
150
|
}
|
|
136
151
|
catch {
|
|
137
|
-
return passthrough(bytes, filename,
|
|
152
|
+
return passthrough(bytes, filename, guessed, "encode_failed");
|
|
138
153
|
}
|
|
139
154
|
if (encoded.byteLength >= originalBytes) {
|
|
140
|
-
return passthrough(bytes, filename,
|
|
155
|
+
return passthrough(bytes, filename, guessed, "not_smaller");
|
|
141
156
|
}
|
|
142
157
|
const outFilename = withImageExtension(filename, format === "jpeg" ? "jpg" : "webp");
|
|
143
158
|
return {
|
|
@@ -149,25 +164,6 @@ export async function optimizeImageForUpload(bytes, filename, opts = {}) {
|
|
|
149
164
|
outputBytes: encoded.byteLength,
|
|
150
165
|
};
|
|
151
166
|
}
|
|
152
|
-
function guessContentType(filename) {
|
|
153
|
-
switch (extensionOf(filename)) {
|
|
154
|
-
case "png":
|
|
155
|
-
return "image/png";
|
|
156
|
-
case "jpg":
|
|
157
|
-
case "jpeg":
|
|
158
|
-
return "image/jpeg";
|
|
159
|
-
case "gif":
|
|
160
|
-
return "image/gif";
|
|
161
|
-
case "webp":
|
|
162
|
-
return "image/webp";
|
|
163
|
-
case "avif":
|
|
164
|
-
return "image/avif";
|
|
165
|
-
case "svg":
|
|
166
|
-
return "image/svg+xml";
|
|
167
|
-
default:
|
|
168
|
-
return "application/octet-stream";
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
167
|
/** Rewrite an object key's trailing image extension to match optimized output. */
|
|
172
168
|
export function rewriteKeyExtension(key, filename) {
|
|
173
169
|
const ext = extensionOf(filename);
|