@buildinternet/uploads 0.35.0 → 0.35.1

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.
@@ -74,6 +74,13 @@ export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: Co
74
74
  * deleted best-effort via `gh api -X DELETE`; a failed delete is swallowed
75
75
  * and never fails the caller's command, and the next sync retries anyway.
76
76
  *
77
+ * A create additionally re-hunts once it has written (issue #553, mirroring
78
+ * the bot path): find-or-create is not atomic, so a concurrent writer can
79
+ * create its own comment in the same window. Both writers independently agree
80
+ * the OLDEST marker comment wins, fold their body into it and delete the rest
81
+ * — including their own create — so the race converges instead of leaving a
82
+ * stale orphan behind for a PR that never syncs again.
83
+ *
77
84
  * On why this duplicates the bot path rather than deferring to it: the gh
78
85
  * fallback is a supported path, not a stopgap, so it is held at behavioral
79
86
  * parity deliberately. This file already reimplements the hunt, the legacy
package/dist/github-gh.js CHANGED
@@ -266,6 +266,13 @@ function findManagedComment(target, run, marker) {
266
266
  * deleted best-effort via `gh api -X DELETE`; a failed delete is swallowed
267
267
  * and never fails the caller's command, and the next sync retries anyway.
268
268
  *
269
+ * A create additionally re-hunts once it has written (issue #553, mirroring
270
+ * the bot path): find-or-create is not atomic, so a concurrent writer can
271
+ * create its own comment in the same window. Both writers independently agree
272
+ * the OLDEST marker comment wins, fold their body into it and delete the rest
273
+ * — including their own create — so the race converges instead of leaving a
274
+ * stale orphan behind for a PR that never syncs again.
275
+ *
269
276
  * On why this duplicates the bot path rather than deferring to it: the gh
270
277
  * fallback is a supported path, not a stopgap, so it is held at behavioral
271
278
  * parity deliberately. This file already reimplements the hunt, the legacy
@@ -281,26 +288,9 @@ function findManagedComment(target, run, marker) {
281
288
  export function upsertAttachmentsComment(target, body, run = execRunner, marker = ATTACHMENTS_MARKER, opts = {}) {
282
289
  const createIfMissing = opts.createIfMissing ?? true;
283
290
  const { comment: existing, extras } = findManagedComment(target, run, marker);
284
- const deleteExtras = () => {
285
- for (const extra of extras ?? []) {
286
- try {
287
- run("gh", ["api", `repos/${target.repo}/issues/comments/${extra.id}`, "-X", "DELETE"]);
288
- }
289
- catch {
290
- // Best effort only — a failed delete must never fail the caller's command.
291
- }
292
- }
293
- };
294
291
  if (existing) {
295
- run("gh", [
296
- "api",
297
- `repos/${target.repo}/issues/comments/${existing.id}`,
298
- "-X",
299
- "PATCH",
300
- "-F",
301
- "body=@-",
302
- ], body);
303
- deleteExtras();
292
+ patchComment(target, run, existing.id, body);
293
+ deleteComments(target, run, extras);
304
294
  return { action: "updated" };
305
295
  }
306
296
  // Patch-only (createIfMissing false, i.e. an empty body) with no existing
@@ -308,7 +298,65 @@ export function upsertAttachmentsComment(target, body, run = execRunner, marker
308
298
  if (!createIfMissing)
309
299
  return { action: "skipped" };
310
300
  // No existing marker hit means `extras` is necessarily empty here (see
311
- // `findManagedComment`) — nothing to delete after a create.
312
- run("gh", ["api", `repos/${target.repo}/issues/${target.num}/comments`, "-F", "body=@-"], body);
313
- return { action: "created" };
301
+ // `findManagedComment`) — nothing to delete before the create.
302
+ const created = run("gh", ["api", `repos/${target.repo}/issues/${target.num}/comments`, "-F", "body=@-"], body);
303
+ return reconcileAfterCreate(target, body, run, marker, created) ?? { action: "created" };
304
+ }
305
+ /**
306
+ * Re-hunt right after a create and collapse whatever a concurrent writer left
307
+ * behind (issue #553). Returns `{ action: "updated" }` when this run lost the
308
+ * race — its body has been folded into the older winning comment and its own
309
+ * create deleted — or null when nothing needed folding, including every
310
+ * failure: a verification problem must never fail a successful create.
311
+ *
312
+ * The winner is patched BEFORE any delete, so a failed fold leaves both
313
+ * comments (the next sync's hunt retries) rather than deleting the one that
314
+ * carries the current body.
315
+ */
316
+ function reconcileAfterCreate(target, body, run, marker, createdRaw) {
317
+ let createdId;
318
+ try {
319
+ createdId = JSON.parse(createdRaw).id;
320
+ }
321
+ catch {
322
+ // `gh` printed something unparseable — fall through to the hunt, which
323
+ // identifies the winner on its own.
324
+ }
325
+ try {
326
+ const { comment: winner, extras } = findManagedComment(target, run, marker);
327
+ // `extras` is undefined in legacy mode, where a second hit may belong to
328
+ // another workspace — the adopt-only contract holds here too.
329
+ if (!winner || !extras?.length)
330
+ return null;
331
+ if (winner.id === createdId) {
332
+ // Ours is the oldest and already carries the body we just wrote — only
333
+ // the other writer's duplicate needs to go.
334
+ deleteComments(target, run, extras);
335
+ return null;
336
+ }
337
+ patchComment(target, run, winner.id, body);
338
+ deleteComments(target, run, extras);
339
+ return { action: "updated" };
340
+ }
341
+ catch {
342
+ // A failed listing or fold leaves the freshly created comment in place —
343
+ // correct content, one duplicate, healed by the next sync's hunt.
344
+ return null;
345
+ }
346
+ }
347
+ /** PATCH one comment's body via stdin, so the body is never shell-interpolated. */
348
+ function patchComment(target, run, id, body) {
349
+ run("gh", ["api", `repos/${target.repo}/issues/comments/${id}`, "-X", "PATCH", "-F", "body=@-"], body);
350
+ }
351
+ /** Best-effort delete: a failed delete must never fail the caller's command,
352
+ * and the next sync's hunt retries anyway. */
353
+ function deleteComments(target, run, comments) {
354
+ for (const c of comments ?? []) {
355
+ try {
356
+ run("gh", ["api", `repos/${target.repo}/issues/comments/${c.id}`, "-X", "DELETE"]);
357
+ }
358
+ catch {
359
+ // Best effort only.
360
+ }
361
+ }
314
362
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.35.0",
3
+ "version": "0.35.1",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,