decap-cms-backend-github 3.8.2 → 3.8.3

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/esm/API.js CHANGED
@@ -8,7 +8,7 @@ import trimStart from 'lodash/trimStart';
8
8
  import trim from 'lodash/trim';
9
9
  import { oneLine } from 'common-tags';
10
10
  import { dirname } from 'path';
11
- import { getAllResponses, APIError, EditorialWorkflowError, localForage, basename, readFileMetadata, CMS_BRANCH_PREFIX, generateContentKey, DEFAULT_PR_BODY, MERGE_COMMIT_MESSAGE, PreviewState, parseContentKey, branchFromContentKey, isCMSLabel, labelToStatus, statusToLabel, contentKeyFromBranch, requestWithBackoff, unsentRequest, throwOnConflictingBranches } from 'decap-cms-lib-util';
11
+ import { formatNoteBody, commentToNote, commentsToNotes, getAllResponses, APIError, EditorialWorkflowError, localForage, basename, readFileMetadata, CMS_BRANCH_PREFIX, generateContentKey, DEFAULT_PR_BODY, MERGE_COMMIT_MESSAGE, PreviewState, parseContentKey, branchFromContentKey, isCMSLabel, labelToStatus, statusToLabel, contentKeyFromBranch, requestWithBackoff, unsentRequest, throwOnConflictingBranches } from 'decap-cms-lib-util';
12
12
  export const API_NAME = 'GitHub';
13
13
  const {
14
14
  fetchWithTimeout: fetch
@@ -1220,44 +1220,14 @@ export default class API {
1220
1220
  /**
1221
1221
  * Constants for note formatting to aid with PR comment to note conversion
1222
1222
  */
1223
- static NOTE_STATUS_RESOLVED = 'RESOLVED';
1224
- static NOTE_STATUS_OPEN = 'OPEN';
1225
1223
  static NOTES_LABEL = 'decap-cms-notes';
1226
1224
  static NOTE_ISSUE_PREFIX = 'Notes: ';
1227
- // In Github we hide Decap Notes metadata in a HTML comment, that way we can track status of whether or not a note has been resolved (similar to GDocs)
1228
- static NOTE_REGEX = /^<!-- DecapCMS Note - Status: (RESOLVED|OPEN) -->([\s\S]+)$/;
1229
-
1230
- /**
1231
- * Format a note for PR comment display
1232
- */
1233
- formatNoteForGithub(note) {
1234
- const status = note.resolved ? API.NOTE_STATUS_RESOLVED : API.NOTE_STATUS_OPEN;
1235
- return `<!-- DecapCMS Note - Status: ${status} -->
1236
- ${note.content}`;
1237
- }
1238
1225
 
1239
1226
  /**
1240
1227
  * Parse a GitHub comment into a Note object
1241
1228
  */
1242
1229
  parseCommentToNote(comment) {
1243
- if (!comment || !comment.body || !comment.user) {
1244
- throw new Error('Invalid comment structure');
1245
- }
1246
- const structuredMatch = comment.body.match(API.NOTE_REGEX);
1247
- const content = structuredMatch ? structuredMatch[2].trim() : comment.body;
1248
- const resolved = structuredMatch ? structuredMatch[1] === API.NOTE_STATUS_RESOLVED : false;
1249
- if (!content.trim()) {
1250
- throw new Error('Empty note content');
1251
- }
1252
- return {
1253
- id: comment.id.toString(),
1254
- author: comment.user.login,
1255
- avatarUrl: comment.user.avatar_url,
1256
- timestamp: comment.created_at,
1257
- content,
1258
- resolved,
1259
- entrySlug: ''
1260
- };
1230
+ return commentToNote(comment);
1261
1231
  }
1262
1232
 
1263
1233
  /**
@@ -1304,13 +1274,13 @@ ${note.content}`;
1304
1274
  */
1305
1275
  async getIssueWithETag(issueNumber, etag) {
1306
1276
  try {
1307
- const headers = {
1308
- Authorization: `${this.tokenKeyword} ${this.token}`
1309
- };
1310
- if (etag) {
1311
- headers['If-None-Match'] = etag;
1312
- }
1313
- const response = await fetch(`${this.apiRoot}${this.repoURL}/issues/${issueNumber}`, {
1277
+ // Raw fetch because `request` parses the body and drops the ETag header.
1278
+ // Still built through urlFor/requestHeaders so subclasses can scope it to
1279
+ // their own proxy; hand-building the URL and auth header breaks them.
1280
+ const headers = await this.requestHeaders(etag ? {
1281
+ 'If-None-Match': etag
1282
+ } : {});
1283
+ const response = await fetch(this.urlFor(`${this.repoURL}/issues/${issueNumber}`, {}), {
1314
1284
  headers
1315
1285
  });
1316
1286
  if (response.status === 304) {
@@ -1321,10 +1291,7 @@ ${note.content}`;
1321
1291
  if (response.status === 200) {
1322
1292
  const issue = await response.json();
1323
1293
  const newETag = response.headers.get('ETag');
1324
- const commentsResponse = await fetch(`${this.apiRoot}${this.repoURL}/issues/${issueNumber}/comments`, {
1325
- headers
1326
- });
1327
- const commentsRaw = await commentsResponse.json();
1294
+ const commentsRaw = await this.getIssueComments(issueNumber);
1328
1295
  const comments = commentsRaw.map(comment => ({
1329
1296
  id: comment.id,
1330
1297
  body: comment.body,
@@ -1372,14 +1339,23 @@ ${note.content}`;
1372
1339
  /**
1373
1340
  * Get comments from a GitHub issue
1374
1341
  */
1342
+ /**
1343
+ * Paged, because this endpoint defaults to 30 per page - a thread past that
1344
+ * silently lost its older notes, in the pane and in polling alike.
1345
+ *
1346
+ * Errors propagate deliberately. Returning an empty list on a failed request
1347
+ * is indistinguishable from a thread whose comments were all deleted: the
1348
+ * polling manager would diff against it, emit `comment_deleted` for every
1349
+ * note and blank the pane, then restore them on the next poll. Throwing
1350
+ * leaves the manager's last state alone and lets it retry.
1351
+ */
1375
1352
  async getIssueComments(issueNumber) {
1376
- try {
1377
- const response = await this.request(`${this.repoURL}/issues/${issueNumber}/comments`);
1378
- return Array.isArray(response) ? response : [];
1379
- } catch (error) {
1380
- console.error('Failed to get issue comments:', error);
1381
- return [];
1382
- }
1353
+ const response = await this.requestAllPages(`${this.repoURL}/issues/${issueNumber}/comments`, {
1354
+ params: {
1355
+ per_page: 100
1356
+ }
1357
+ });
1358
+ return Array.isArray(response) ? response : [];
1383
1359
  }
1384
1360
 
1385
1361
  /**
@@ -1390,7 +1366,7 @@ ${note.content}`;
1390
1366
  const response = await this.request(`${this.repoURL}/issues/${issueNumber}/comments`, {
1391
1367
  method: 'POST',
1392
1368
  body: JSON.stringify({
1393
- body: this.formatNoteForGithub(note)
1369
+ body: formatNoteBody(note)
1394
1370
  })
1395
1371
  });
1396
1372
  return response.id.toString();
@@ -1408,7 +1384,7 @@ ${note.content}`;
1408
1384
  await this.request(`${this.repoURL}/issues/comments/${commentId}`, {
1409
1385
  method: 'PATCH',
1410
1386
  body: JSON.stringify({
1411
- body: this.formatNoteForGithub(note)
1387
+ body: formatNoteBody(note)
1412
1388
  })
1413
1389
  });
1414
1390
  } catch (error) {
@@ -1497,11 +1473,8 @@ ${note.content}`;
1497
1473
  const comments = await this.getIssueComments(issue.number);
1498
1474
  const issueUrl = issue.html_url; // Get the issue URL once
1499
1475
 
1500
- // Add issueUrl to each note
1501
- return comments.map(comment => ({
1502
- ...this.parseCommentToNote(comment),
1503
- issueUrl // Add the issue URL to each note (this info is picked up by the UI to direct users to the source of the Notes in Github)
1504
- }));
1476
+ // Add issueUrl to each note (this info is picked up by the UI to direct users to the source of the Notes in Github)
1477
+ return commentsToNotes(comments, issueUrl, comment => this.parseCommentToNote(comment));
1505
1478
  } catch (error) {
1506
1479
  console.error('Failed to get entry notes:', error);
1507
1480
  return [];
@@ -1,10 +1,9 @@
1
1
  import semaphore from 'semaphore';
2
2
  import trimStart from 'lodash/trimStart';
3
3
  import { stripIndent } from 'common-tags';
4
- import { CURSOR_COMPATIBILITY_SYMBOL, Cursor, asyncLock, basename, getBlobSHA, entriesByFolder, entriesByFiles, unpublishedEntries, getMediaDisplayURL, getMediaAsBlob, filterByExtension, getPreviewStatus, runWithLock, blobToFileObj, contentKeyFromBranch, unsentRequest, branchFromContentKey } from 'decap-cms-lib-util';
4
+ import { CURSOR_COMPATIBILITY_SYMBOL, Cursor, asyncLock, basename, getBlobSHA, entriesByFolder, entriesByFiles, unpublishedEntries, getMediaDisplayURL, getMediaAsBlob, filterByExtension, getPreviewStatus, runWithLock, blobToFileObj, contentKeyFromBranch, unsentRequest, branchFromContentKey, NotesPollingManager, markOwnNotes } from 'decap-cms-lib-util';
5
5
  import AuthenticationPage from './AuthenticationPage';
6
6
  import API, { API_NAME } from './API';
7
- import { ETagPollingManager } from './polling';
8
7
  import GraphQLAPI from './GraphQLAPI';
9
8
  import { jsx as _jsx } from "@emotion/react/jsx-runtime";
10
9
  const MAX_CONCURRENT_DOWNLOADS = 10;
@@ -292,7 +291,7 @@ export default class GitHub {
292
291
  // }
293
292
 
294
293
  if (this.api && !this.pollingManager) {
295
- this.pollingManager = new ETagPollingManager(this.api, 15000);
294
+ this.pollingManager = new NotesPollingManager(this.api, 15000);
296
295
  }
297
296
  // Authorized user
298
297
  return {
@@ -624,43 +623,61 @@ export default class GitHub {
624
623
 
625
624
  // Notes implementation, which is an abstraction to Github's PR issue comments.
626
625
 
626
+ /**
627
+ * Who the signed-in editor is, as a note records them: a display name for the
628
+ * pane and a stable id for the ownership check behind Edit/Resolve/Delete.
629
+ */
630
+ async noteAuthorIdentity() {
631
+ const currentUser = await this.currentUser({
632
+ token: this.token
633
+ });
634
+ return {
635
+ author: currentUser.login || currentUser.name || '',
636
+ // No id on purpose: GitHub reports the author's current login on every
637
+ // read, so ownership follows a rename. Recording it here would freeze it.
638
+ authorId: undefined
639
+ };
640
+ }
641
+
642
+ /**
643
+ * Resolves each note's `isOwn` here rather than in the pane, because only the
644
+ * backend knows how its identities compare. Falls back to the display name
645
+ * for notes with no recorded id.
646
+ */
647
+ async markOwnNotes(notes) {
648
+ return markOwnNotes(notes, await this.noteAuthorIdentity());
649
+ }
650
+
627
651
  // Notes implementation using GitHub Issues
628
652
  async getNotes(collection, slug) {
629
653
  try {
630
654
  const notes = await this.api.getEntryNotes(collection, slug);
631
- return notes.map(note => ({
655
+ return this.markOwnNotes(notes.map(note => ({
632
656
  ...note,
633
657
  entrySlug: slug
634
- }));
658
+ })));
635
659
  } catch (error) {
636
660
  console.error('Failed to get notes:', error);
637
661
  return [];
638
662
  }
639
663
  }
640
- async addNote(collection, slug, noteData) {
664
+ async addNote(collection, slug, noteData, entryTitle) {
641
665
  const currentUser = await this.currentUser({
642
666
  token: this.token
643
667
  });
668
+ const identity = await this.noteAuthorIdentity();
644
669
  const note = {
645
670
  ...noteData,
646
671
  id: 'temp-' + Date.now(),
647
- author: currentUser.login || currentUser.name || '',
672
+ author: identity.author,
673
+ authorId: identity.authorId,
674
+ isOwn: true,
648
675
  avatarUrl: currentUser.avatar_url,
649
676
  entrySlug: slug,
650
677
  timestamp: noteData.timestamp || new Date().toISOString(),
651
678
  resolved: noteData.resolved || false,
652
679
  issueUrl: undefined
653
680
  };
654
-
655
- // Get entry title for better issue naming
656
- let entryTitle;
657
- try {
658
- const entryData = await this.getEntry(`${collection}/${slug}.md`);
659
- const titleMatch = entryData.data.match(/^title:\s*["']?([^"'\n]+)["']?/m);
660
- entryTitle = titleMatch ? titleMatch[1] : undefined;
661
- } catch (error) {
662
- // Entry not found or error reading, use undefined title
663
- }
664
681
  const {
665
682
  commentId,
666
683
  issueUrl
@@ -730,7 +747,14 @@ export default class GitHub {
730
747
  this.unwatchFunctions.delete(issueKey);
731
748
  }
732
749
  try {
733
- const unwatchFn = await this.pollingManager.watchIssueWithRetry(collection, slug, callbacks, 5,
750
+ const unwatchFn = await this.pollingManager.watchIssueWithRetry(collection, slug, {
751
+ ...callbacks,
752
+ // The polling manager rebuilds notes straight from the issue's
753
+ // comments, so they arrive without the ownership flag getNotes adds.
754
+ // Without this, a poll silently strips Edit/Resolve/Delete off the
755
+ // editor's own notes ~15s after they appear.
756
+ prepareNotes: notes => this.markOwnNotes(notes)
757
+ }, 5,
734
758
  // maxRetries - will try up to 5 times
735
759
  2000 // retryDelay - 2 seconds between attempts
736
760
  );
@@ -754,9 +778,8 @@ export default class GitHub {
754
778
  if (unwatchFn) {
755
779
  unwatchFn();
756
780
  this.unwatchFunctions.delete(issueKey);
757
- } else {
758
- console.log(`[DecapNotes Polling] No active polling found for ${issueKey}`);
759
781
  }
782
+ this.pollingManager?.stopWatching(collection, slug);
760
783
  }
761
784
 
762
785
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "decap-cms-backend-github",
3
3
  "description": "GitHub backend for Decap CMS",
4
- "version": "3.8.2",
4
+ "version": "3.8.3",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -35,7 +35,7 @@
35
35
  "prop-types": "^15.7.2",
36
36
  "react": "^19.1.0",
37
37
  "decap-cms-lib-auth": "3.3.2",
38
- "decap-cms-lib-util": "3.8.2",
38
+ "decap-cms-lib-util": "3.8.3",
39
39
  "decap-cms-ui-default": "3.9.2"
40
40
  },
41
41
  "browser": {
package/src/API.ts CHANGED
@@ -9,6 +9,9 @@ import trim from 'lodash/trim';
9
9
  import { oneLine } from 'common-tags';
10
10
  import { dirname } from 'path';
11
11
  import {
12
+ formatNoteBody,
13
+ commentToNote,
14
+ commentsToNotes,
12
15
  getAllResponses,
13
16
  APIError,
14
17
  EditorialWorkflowError,
@@ -1555,50 +1558,14 @@ export default class API {
1555
1558
  /**
1556
1559
  * Constants for note formatting to aid with PR comment to note conversion
1557
1560
  */
1558
- private static readonly NOTE_STATUS_RESOLVED = 'RESOLVED';
1559
- private static readonly NOTE_STATUS_OPEN = 'OPEN';
1560
1561
  private static readonly NOTES_LABEL = 'decap-cms-notes';
1561
1562
  private static readonly NOTE_ISSUE_PREFIX = 'Notes: ';
1562
- // In Github we hide Decap Notes metadata in a HTML comment, that way we can track status of whether or not a note has been resolved (similar to GDocs)
1563
- private static readonly NOTE_REGEX =
1564
- /^<!-- DecapCMS Note - Status: (RESOLVED|OPEN) -->([\s\S]+)$/;
1565
-
1566
- /**
1567
- * Format a note for PR comment display
1568
- */
1569
- private formatNoteForGithub(note: Note): string {
1570
- const status = note.resolved ? API.NOTE_STATUS_RESOLVED : API.NOTE_STATUS_OPEN;
1571
-
1572
- return `<!-- DecapCMS Note - Status: ${status} -->
1573
- ${note.content}`;
1574
- }
1575
1563
 
1576
1564
  /**
1577
1565
  * Parse a GitHub comment into a Note object
1578
1566
  */
1579
- parseCommentToNote(comment: GitHubIssue): Note {
1580
- if (!comment || !comment.body || !comment.user) {
1581
- throw new Error('Invalid comment structure');
1582
- }
1583
-
1584
- const structuredMatch = comment.body.match(API.NOTE_REGEX);
1585
-
1586
- const content = structuredMatch ? structuredMatch[2].trim() : comment.body;
1587
- const resolved = structuredMatch ? structuredMatch[1] === API.NOTE_STATUS_RESOLVED : false;
1588
-
1589
- if (!content.trim()) {
1590
- throw new Error('Empty note content');
1591
- }
1592
-
1593
- return {
1594
- id: comment.id.toString(),
1595
- author: comment.user.login,
1596
- avatarUrl: comment.user.avatar_url,
1597
- timestamp: comment.created_at,
1598
- content,
1599
- resolved,
1600
- entrySlug: '',
1601
- };
1567
+ parseCommentToNote(comment: CommentData): Note {
1568
+ return commentToNote(comment);
1602
1569
  }
1603
1570
 
1604
1571
  /**
@@ -1657,15 +1624,14 @@ ${note.content}`;
1657
1624
  | { status: 200; data: IssueState; etag: string | null }
1658
1625
  > {
1659
1626
  try {
1660
- const headers: Record<string, string> = {
1661
- Authorization: `${this.tokenKeyword} ${this.token}`,
1662
- };
1663
-
1664
- if (etag) {
1665
- headers['If-None-Match'] = etag;
1666
- }
1627
+ // Raw fetch because `request` parses the body and drops the ETag header.
1628
+ // Still built through urlFor/requestHeaders so subclasses can scope it to
1629
+ // their own proxy; hand-building the URL and auth header breaks them.
1630
+ const headers: Record<string, string> = await this.requestHeaders(
1631
+ etag ? { 'If-None-Match': etag } : {},
1632
+ );
1667
1633
 
1668
- const response = await fetch(`${this.apiRoot}${this.repoURL}/issues/${issueNumber}`, {
1634
+ const response = await fetch(this.urlFor(`${this.repoURL}/issues/${issueNumber}`, {}), {
1669
1635
  headers,
1670
1636
  });
1671
1637
 
@@ -1677,11 +1643,7 @@ ${note.content}`;
1677
1643
  const issue = await response.json();
1678
1644
  const newETag = response.headers.get('ETag');
1679
1645
 
1680
- const commentsResponse = await fetch(
1681
- `${this.apiRoot}${this.repoURL}/issues/${issueNumber}/comments`,
1682
- { headers },
1683
- );
1684
- const commentsRaw: GitHubIssue[] = await commentsResponse.json();
1646
+ const commentsRaw = await this.getIssueComments(issueNumber);
1685
1647
 
1686
1648
  const comments: CommentData[] = commentsRaw.map(comment => ({
1687
1649
  id: comment.id,
@@ -1731,16 +1693,23 @@ ${note.content}`;
1731
1693
  /**
1732
1694
  * Get comments from a GitHub issue
1733
1695
  */
1696
+ /**
1697
+ * Paged, because this endpoint defaults to 30 per page - a thread past that
1698
+ * silently lost its older notes, in the pane and in polling alike.
1699
+ *
1700
+ * Errors propagate deliberately. Returning an empty list on a failed request
1701
+ * is indistinguishable from a thread whose comments were all deleted: the
1702
+ * polling manager would diff against it, emit `comment_deleted` for every
1703
+ * note and blank the pane, then restore them on the next poll. Throwing
1704
+ * leaves the manager's last state alone and lets it retry.
1705
+ */
1734
1706
  private async getIssueComments(issueNumber: number): Promise<GitHubIssue[]> {
1735
- try {
1736
- const response: GitHubIssue[] = await this.request(
1737
- `${this.repoURL}/issues/${issueNumber}/comments`,
1738
- );
1739
- return Array.isArray(response) ? response : [];
1740
- } catch (error) {
1741
- console.error('Failed to get issue comments:', error);
1742
- return [];
1743
- }
1707
+ const response = await this.requestAllPages<GitHubIssue>(
1708
+ `${this.repoURL}/issues/${issueNumber}/comments`,
1709
+ { params: { per_page: 100 } },
1710
+ );
1711
+
1712
+ return Array.isArray(response) ? response : [];
1744
1713
  }
1745
1714
 
1746
1715
  /**
@@ -1753,7 +1722,7 @@ ${note.content}`;
1753
1722
  {
1754
1723
  method: 'POST',
1755
1724
  body: JSON.stringify({
1756
- body: this.formatNoteForGithub(note),
1725
+ body: formatNoteBody(note),
1757
1726
  }),
1758
1727
  },
1759
1728
  );
@@ -1773,7 +1742,7 @@ ${note.content}`;
1773
1742
  await this.request(`${this.repoURL}/issues/comments/${commentId}`, {
1774
1743
  method: 'PATCH',
1775
1744
  body: JSON.stringify({
1776
- body: this.formatNoteForGithub(note),
1745
+ body: formatNoteBody(note),
1777
1746
  }),
1778
1747
  });
1779
1748
  } catch (error) {
@@ -1866,11 +1835,8 @@ ${note.content}`;
1866
1835
  const comments = await this.getIssueComments(issue.number);
1867
1836
  const issueUrl = issue.html_url; // Get the issue URL once
1868
1837
 
1869
- // Add issueUrl to each note
1870
- return comments.map(comment => ({
1871
- ...this.parseCommentToNote(comment),
1872
- issueUrl, // Add the issue URL to each note (this info is picked up by the UI to direct users to the source of the Notes in Github)
1873
- }));
1838
+ // Add issueUrl to each note (this info is picked up by the UI to direct users to the source of the Notes in Github)
1839
+ return commentsToNotes(comments, issueUrl, comment => this.parseCommentToNote(comment));
1874
1840
  } catch (error) {
1875
1841
  console.error('Failed to get entry notes:', error);
1876
1842
  return [];
@@ -1,4 +1,5 @@
1
1
  import { Base64 } from 'js-base64';
2
+ import { formatNoteBody } from 'decap-cms-lib-util';
2
3
 
3
4
  import API from '../API';
4
5
 
@@ -842,4 +843,76 @@ describe('github API', () => {
842
843
  expect(api.request).toHaveBeenCalledTimes(1);
843
844
  expect(api.request).toHaveBeenCalledWith(`/repos/repo/commits/${sha}/status`);
844
845
  });
846
+
847
+ describe('parseCommentToNote', () => {
848
+ // The body encoding itself is lib-util's (see notesFormat.spec); what is
849
+ // GitHub's is how a comment maps onto a note when the marker is silent.
850
+ function api() {
851
+ return new API({ repo: 'owner/repo' });
852
+ }
853
+
854
+ function asComment(body) {
855
+ return {
856
+ id: 7,
857
+ body,
858
+ user: { login: 'decap-turbo[bot]', avatar_url: 'https://avatar' },
859
+ created_at: '2026-01-01T00:00:00Z',
860
+ };
861
+ }
862
+
863
+ it('falls back to the comment account when the note records no author', () => {
864
+ const note = api().parseCommentToNote(asComment('just a comment'));
865
+
866
+ expect(note.author).toBe('decap-turbo[bot]');
867
+ expect(note.authorId).toBeUndefined();
868
+ expect(note.avatarUrl).toBe('https://avatar');
869
+ expect(note.content).toBe('just a comment');
870
+ });
871
+
872
+ it('prefers the recorded author, and drops the poster avatar with it', () => {
873
+ // A recorded author means the account that posted is not the person, so
874
+ // its avatar would mislabel the note. The pane shows initials instead.
875
+ const body = formatNoteBody({
876
+ content: 'hello',
877
+ resolved: false,
878
+ author: 'Decap Tester',
879
+ authorId: 'u-1',
880
+ });
881
+
882
+ const note = api().parseCommentToNote(asComment(body));
883
+
884
+ expect(note.author).toBe('Decap Tester');
885
+ expect(note.authorId).toBe('u-1');
886
+ expect(note.avatarUrl).toBeUndefined();
887
+ });
888
+
889
+ it('rejects a note whose body is only the marker', () => {
890
+ expect(() =>
891
+ api().parseCommentToNote(asComment('<!-- DecapCMS Note {"resolved":false} -->\n ')),
892
+ ).toThrow('Empty note content');
893
+ });
894
+
895
+ it('rejects a malformed comment', () => {
896
+ expect(() => api().parseCommentToNote({ id: 1 })).toThrow('Invalid comment structure');
897
+ });
898
+ });
899
+
900
+ describe('getEntryNotes', () => {
901
+ it('skips a marker-only comment instead of blanking the whole thread', async () => {
902
+ const api = new API({ repo: 'owner/repo' });
903
+ const user = { login: 'ada', avatar_url: 'https://avatar' };
904
+
905
+ api.findEntryIssue = jest.fn().mockResolvedValue({ number: 3, html_url: 'https://issue' });
906
+ api.requestAllPages = jest.fn().mockResolvedValue([
907
+ { id: 1, body: 'first', user, created_at: '2026-01-01T00:00:00Z' },
908
+ { id: 2, body: '<!-- DecapCMS Note {"resolved":false} -->\n', user, created_at: '' },
909
+ { id: 3, body: 'third', user, created_at: '2026-01-03T00:00:00Z' },
910
+ ]);
911
+
912
+ const notes = await api.getEntryNotes('posts', 'my-post');
913
+
914
+ expect(notes.map(note => note.id)).toEqual(['1', '3']);
915
+ expect(notes[0].issueUrl).toBe('https://issue');
916
+ });
917
+ });
845
918
  });
@@ -392,6 +392,9 @@ describe('github backend implementation', () => {
392
392
  const gitHubImplementation = new GitHubImplementation(configWithNotes);
393
393
  gitHubImplementation.api = mockAPI;
394
394
 
395
+ gitHubImplementation.token = 'test-token';
396
+ gitHubImplementation.currentUser = jest.fn().mockResolvedValue({ login: 'user1' });
397
+
395
398
  const mockNotes = [
396
399
  {
397
400
  id: '1',
@@ -411,6 +414,7 @@ describe('github backend implementation', () => {
411
414
  {
412
415
  ...mockNotes[0],
413
416
  entrySlug: 'my-post',
417
+ isOwn: true,
414
418
  },
415
419
  ]);
416
420
  expect(mockAPI.getEntryNotes).toHaveBeenCalledWith('posts', 'my-post');
@@ -450,9 +454,13 @@ describe('github backend implementation', () => {
450
454
  commentId: 'comment-123',
451
455
  issueUrl: 'https://github.com/owner/repo/issues/1',
452
456
  });
453
- mockAPI.readFile.mockResolvedValue('title: My Post Title\n\nContent');
454
457
 
455
- const result = await gitHubImplementation.addNote('posts', 'my-post', noteData);
458
+ const result = await gitHubImplementation.addNote(
459
+ 'posts',
460
+ 'my-post',
461
+ noteData,
462
+ 'My Post Title',
463
+ );
456
464
 
457
465
  expect(result).toMatchObject({
458
466
  text: 'New note',
@@ -471,9 +479,15 @@ describe('github backend implementation', () => {
471
479
  }),
472
480
  'My Post Title',
473
481
  );
482
+ // The title arrives from the caller; nothing is read back from the repo.
483
+ expect(mockAPI.readFile).not.toHaveBeenCalled();
474
484
  });
475
485
 
476
- it('should handle missing entry title gracefully', async () => {
486
+ // The caller cannot always name the entry a note added from outside the
487
+ // open editor has no draft to read a title off — so the thread falls back
488
+ // to `collection/slug`, which is what it always did when the (broken)
489
+ // lookup failed.
490
+ it('passes no title through when the caller has none', async () => {
477
491
  const gitHubImplementation = new GitHubImplementation(config);
478
492
  gitHubImplementation.api = mockAPI;
479
493
  gitHubImplementation.token = 'test-token';
@@ -491,7 +505,6 @@ describe('github backend implementation', () => {
491
505
  commentId: 'comment-123',
492
506
  issueUrl: 'https://github.com/owner/repo/issues/1',
493
507
  });
494
- mockAPI.readFile.mockRejectedValue(new Error('Not found'));
495
508
 
496
509
  const result = await gitHubImplementation.addNote('posts', 'my-post', noteData);
497
510
 
@@ -509,6 +522,8 @@ describe('github backend implementation', () => {
509
522
  it('should update an existing note', async () => {
510
523
  const gitHubImplementation = new GitHubImplementation(config);
511
524
  gitHubImplementation.api = mockAPI;
525
+ gitHubImplementation.token = 'test-token';
526
+ gitHubImplementation.currentUser = jest.fn().mockResolvedValue({ login: 'user1' });
512
527
 
513
528
  const existingNotes = [
514
529
  {
@@ -532,6 +547,7 @@ describe('github backend implementation', () => {
532
547
  ...existingNotes[0],
533
548
  text: 'Updated text',
534
549
  resolved: true,
550
+ isOwn: true,
535
551
  });
536
552
  expect(mockAPI.updateEntryNote).toHaveBeenCalledWith('note-1', result);
537
553
  });
@@ -714,15 +730,37 @@ describe('github backend implementation', () => {
714
730
  onChange: jest.fn(),
715
731
  };
716
732
 
733
+ gitHubImplementation.token = 'test-token';
734
+ gitHubImplementation.currentUser = jest.fn().mockResolvedValue({ login: 'user1' });
735
+
717
736
  await gitHubImplementation.startNotesPolling('posts', 'my-post', callbacks);
718
737
 
719
738
  expect(gitHubImplementation.pollingManager.watchIssueWithRetry).toHaveBeenCalledWith(
720
739
  'posts',
721
740
  'my-post',
722
- callbacks,
741
+ expect.objectContaining({
742
+ onUpdate: callbacks.onUpdate,
743
+ onChange: callbacks.onChange,
744
+ prepareNotes: expect.any(Function),
745
+ }),
723
746
  5,
724
747
  2000,
725
748
  );
749
+
750
+ // The polling manager rebuilds notes from the issue's comments, so they
751
+ // arrive with no ownership flag. Without prepareNotes a poll would strip
752
+ // Edit/Resolve/Delete off the editor's own notes ~15s after they show.
753
+ const [, , passedCallbacks] =
754
+ gitHubImplementation.pollingManager.watchIssueWithRetry.mock.calls[0];
755
+ const prepared = await passedCallbacks.prepareNotes([
756
+ { id: '1', author: 'user1', content: 'mine', resolved: false },
757
+ { id: '2', author: 'someone-else', content: 'theirs', resolved: false },
758
+ ]);
759
+
760
+ expect(prepared).toEqual([
761
+ expect.objectContaining({ id: '1', isOwn: true }),
762
+ expect.objectContaining({ id: '2', isOwn: false }),
763
+ ]);
726
764
  });
727
765
 
728
766
  it('should not start polling if already watching same entry', async () => {
@@ -796,10 +834,16 @@ describe('github backend implementation', () => {
796
834
 
797
835
  await gitHubImplementation.startNotesPolling('posts', 'my-post', callbacks);
798
836
 
837
+ // The callbacks are passed straight through; prepareNotes is added so
838
+ // polled notes get the same ownership flag getNotes applies.
799
839
  expect(gitHubImplementation.pollingManager.watchIssueWithRetry).toHaveBeenCalledWith(
800
840
  'posts',
801
841
  'my-post',
802
- callbacks,
842
+ expect.objectContaining({
843
+ onUpdate: callbacks.onUpdate,
844
+ onChange: callbacks.onChange,
845
+ prepareNotes: expect.any(Function),
846
+ }),
803
847
  5,
804
848
  2000,
805
849
  );