@buildinternet/uploads 0.44.0 → 0.45.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/commands.js CHANGED
@@ -14,7 +14,7 @@ import { parseMetaFlags, validateMetaMap } from "./metadata.js";
14
14
  import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
15
15
  import { mergeSidecarMeta } from "./sidecar.js";
16
16
  import { ghAttachmentKeyForMode, ghBranchAttachmentKeyForMode, ghBranchKeyPrefix, ghKeyPrefix, ghPrivateKeyPrefix, ghPrivateBranchKeyPrefix, ghMetadataFromTarget, parseGhKey, parseGhPrivateKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, AUTO_RENDER_OPTIONS, GH_FALLBACK_AUTHOR_NOTE, normalizeGithubCoordinate, } from "./github.js";
17
- import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
17
+ import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, hasLinkCandidate, extractCandidateUrls, fetchAdoptionCandidateText, } from "./github-gh.js";
18
18
  import { deriveRepoFromGit, deriveRepoSlugFromGit } from "./keys.js";
19
19
  import { noProjectContextNudge } from "./project-context-nudge.js";
20
20
  import { resolvePutPrefix } from "./destinations.js";
@@ -564,6 +564,59 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
564
564
  `Posting via local gh in the meantime.\n`);
565
565
  }
566
566
  }
567
+ // Link adoption (issue #708): local-gh fallback parity with the bot's own
568
+ // adoption (issue #701, apps/api/src/github-link-adopt.ts). When the bot
569
+ // already handled this target the server already adopted for us, so this
570
+ // only runs once we've fallen through to gh. Scans the PR/issue body and
571
+ // every comment for pasted uploads.sh URLs and adopts each one that
572
+ // resolves (server-side, inside `POST .../github/attach`) to a file in
573
+ // THIS workspace's own bound-repo attachment prefix — copy, never move,
574
+ // same as the bot path. Best-effort end to end: any failure (not a git
575
+ // repo, `gh` unavailable/unauthenticated, config unreadable) degrades to
576
+ // "adopt nothing" rather than blocking the comment sync it rides along
577
+ // with.
578
+ let adoptedCount = 0;
579
+ let preAdoptionAttachmentCount;
580
+ try {
581
+ const root = run("git", ["rev-parse", "--show-toplevel"]).trim();
582
+ const { config: adoptConfig } = readLocalRepoCommentConfig(root);
583
+ const { options: adoptOptions } = resolveCommentOptions(adoptConfig, null);
584
+ if (adoptOptions.adoptLinkedFiles) {
585
+ const text = fetchAdoptionCandidateText(target, run);
586
+ if (hasLinkCandidate(text)) {
587
+ const urls = extractCandidateUrls(text);
588
+ if (urls.length > 0) {
589
+ // Baseline BEFORE this pass's adoptions land (mirrors the bot
590
+ // path's `gatherCommentBody` call before its own copies) — feeds
591
+ // the noise guard below without a lone adoption inflating its own
592
+ // count. Plain prefix only (not the private-prefix listing done
593
+ // for the final render below) — good enough for a guard decision.
594
+ preAdoptionAttachmentCount = (await client.listAll({ prefix: ghKeyPrefix(target) }))
595
+ .length;
596
+ for (const url of urls) {
597
+ try {
598
+ await client.attachExisting({
599
+ source: url,
600
+ repo: target.repo,
601
+ ...(target.kind === "pull" ? { pr: target.num } : { issue: target.num }),
602
+ });
603
+ adoptedCount++;
604
+ }
605
+ catch {
606
+ // Not a resolvable uploads.sh URL, belongs to a different
607
+ // workspace, or the source was deleted — silently dropped,
608
+ // matching the bot path's contract (a throw from
609
+ // `resolveAttachSourceKey` is caught per-URL there too).
610
+ }
611
+ }
612
+ }
613
+ }
614
+ }
615
+ }
616
+ catch {
617
+ // Not a git repo, `.uploads.yml` unreadable, or `gh` unavailable for the
618
+ // PR/comments fetch — degrade to no adoption this pass.
619
+ }
567
620
  // gh fallback: gather from this workspace's own data and post via local `gh`.
568
621
  // Note (issues #304, #365): this CLI process has no server-side
569
622
  // WorkspaceRecord in scope, so it cannot honor a workspace's
@@ -658,11 +711,21 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
658
711
  // bot identity, so this note would be wrong there.
659
712
  const body = `${attachmentsCommentBody(items, previewGalleries, marker, renderOptions, target)}\n${GH_FALLBACK_AUTHOR_NOTE}`;
660
713
  const count = items.length + previewGalleries.length;
714
+ // Noise guard (issue #708, mirrors the bot's `shouldSyncAfterAdopt`): a
715
+ // lone adopted link with nothing else already attached is already fully
716
+ // visible inline in the PR/comment — don't create a brand-new comment just
717
+ // to repeat it. `upsertAttachmentsComment` still PATCHes an existing
718
+ // managed comment unconditionally (its own `if (existing)` branch runs
719
+ // regardless of `createIfMissing`), so "a managed comment already exists"
720
+ // and "other attachments are already present" both heal/sync for free
721
+ // here without any extra condition — this only ever suppresses a fresh
722
+ // create.
723
+ const skipLoneAdoptionCreate = adoptedCount === 1 && (preAdoptionAttachmentCount ?? 0) === 0;
661
724
  // Empty (count 0) renders the neutral empty-state body but must not create a
662
725
  // comment — it only rewrites one that already exists (`action: "skipped"`
663
726
  // when none does).
664
727
  const { action } = upsertAttachmentsComment(target, body, run, marker, {
665
- createIfMissing: count > 0,
728
+ createIfMissing: count > 0 && !skipLoneAdoptionCreate,
666
729
  });
667
730
  return { action, count, via: "gh" };
668
731
  }
@@ -98,3 +98,30 @@ export declare function upsertAttachmentsComment(target: GhTarget, body: string,
98
98
  }): {
99
99
  action: "created" | "updated" | "skipped";
100
100
  };
101
+ /** Cheap, regex-only reject: no http(s) URL at all means nothing to scan for.
102
+ * Mirrors `hasLinkCandidate` in apps/api/src/github-link-adopt.ts. */
103
+ export declare function hasLinkCandidate(text: string): boolean;
104
+ /**
105
+ * Distinct http(s) URLs found in `text`, trailing prose punctuation
106
+ * stripped, order preserved, first occurrence wins on duplicates. Ported
107
+ * verbatim from apps/api/src/github-link-adopt.ts's `extractCandidateUrls`
108
+ * so the two paths recognize the same URL spellings — resolution itself
109
+ * (storage host / embed host / `/f/` page → key, and the bound-workspace
110
+ * check) happens server-side inside `POST .../github/attach`, so this file
111
+ * doesn't need its own copy of that logic.
112
+ */
113
+ export declare function extractCandidateUrls(text: string): string[];
114
+ /**
115
+ * Best-effort concatenation of a PR/issue's current body plus every comment's
116
+ * current body, for link-adoption scanning. Returns "" on any `gh` failure
117
+ * (not authenticated, network, repo/number not found) — adoption degrades to
118
+ * a no-op rather than blocking the comment sync it rides along with.
119
+ *
120
+ * Unlike the webhook path (which re-scans one specific body/comment ref per
121
+ * event), the CLI has no per-event ref to key off of — `uploads comment` is
122
+ * one ad-hoc invocation — so this scans the PR/issue body and every comment
123
+ * on the thread in one pass every time it runs. That's a documented
124
+ * divergence: harmless (adoption is idempotent) but does mean a link posted
125
+ * in comment #1 gets rescanned on every later `uploads comment` run too.
126
+ */
127
+ export declare function fetchAdoptionCandidateText(target: GhTarget, run: CommandRunner): string;
package/dist/github-gh.js CHANGED
@@ -344,6 +344,72 @@ function reconcileAfterCreate(target, body, run, marker, createdRaw) {
344
344
  return null;
345
345
  }
346
346
  }
347
+ // --- link adoption (issue #708, local-gh fallback parity with the bot's
348
+ // #701/apps/api/src/github-link-adopt.ts) ---
349
+ /** Cheap, regex-only reject: no http(s) URL at all means nothing to scan for.
350
+ * Mirrors `hasLinkCandidate` in apps/api/src/github-link-adopt.ts. */
351
+ export function hasLinkCandidate(text) {
352
+ return /https?:\/\//i.test(text);
353
+ }
354
+ const URL_RE = /https?:\/\/[^\s)"'<>\]]+/gi;
355
+ /**
356
+ * Distinct http(s) URLs found in `text`, trailing prose punctuation
357
+ * stripped, order preserved, first occurrence wins on duplicates. Ported
358
+ * verbatim from apps/api/src/github-link-adopt.ts's `extractCandidateUrls`
359
+ * so the two paths recognize the same URL spellings — resolution itself
360
+ * (storage host / embed host / `/f/` page → key, and the bound-workspace
361
+ * check) happens server-side inside `POST .../github/attach`, so this file
362
+ * doesn't need its own copy of that logic.
363
+ */
364
+ export function extractCandidateUrls(text) {
365
+ const seen = new Set();
366
+ const out = [];
367
+ for (const m of text.matchAll(URL_RE)) {
368
+ const url = m[0].replace(/[.,;:]+$/, "");
369
+ if (seen.has(url))
370
+ continue;
371
+ seen.add(url);
372
+ out.push(url);
373
+ }
374
+ return out;
375
+ }
376
+ /**
377
+ * Best-effort concatenation of a PR/issue's current body plus every comment's
378
+ * current body, for link-adoption scanning. Returns "" on any `gh` failure
379
+ * (not authenticated, network, repo/number not found) — adoption degrades to
380
+ * a no-op rather than blocking the comment sync it rides along with.
381
+ *
382
+ * Unlike the webhook path (which re-scans one specific body/comment ref per
383
+ * event), the CLI has no per-event ref to key off of — `uploads comment` is
384
+ * one ad-hoc invocation — so this scans the PR/issue body and every comment
385
+ * on the thread in one pass every time it runs. That's a documented
386
+ * divergence: harmless (adoption is idempotent) but does mean a link posted
387
+ * in comment #1 gets rescanned on every later `uploads comment` run too.
388
+ */
389
+ export function fetchAdoptionCandidateText(target, run) {
390
+ let body = "";
391
+ try {
392
+ body = run("gh", ["api", `repos/${target.repo}/issues/${target.num}`, "--jq", '.body // ""']);
393
+ }
394
+ catch {
395
+ // Not found / no access — fall through with an empty body; comments may
396
+ // still be readable.
397
+ }
398
+ let comments = "";
399
+ try {
400
+ comments = run("gh", [
401
+ "api",
402
+ `repos/${target.repo}/issues/${target.num}/comments?per_page=100`,
403
+ "--paginate",
404
+ "--jq",
405
+ '[.[].body] | join("\\n")',
406
+ ]);
407
+ }
408
+ catch {
409
+ // Same degrade — an empty comments blob just means nothing more to scan.
410
+ }
411
+ return `${body}\n${comments}`;
412
+ }
347
413
  /** PATCH one comment's body via stdin, so the body is never shell-interpolated. */
348
414
  function patchComment(target, run, id, body) {
349
415
  run("gh", ["api", `repos/${target.repo}/issues/comments/${id}`, "-X", "PATCH", "-F", "body=@-"], body);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,