decap-cms-backend-github 3.9.0-beta.0 → 3.9.0-beta.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.
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
@@ -1232,46 +1232,16 @@ export default class API {
1232
1232
  }
1233
1233
 
1234
1234
  /**
1235
- * Constants for note formatting to aid with PR comment to note conversion
1235
+ * How a notes issue is recognised on the host.
1236
1236
  */
1237
- static NOTE_STATUS_RESOLVED = 'RESOLVED';
1238
- static NOTE_STATUS_OPEN = 'OPEN';
1239
1237
  static NOTES_LABEL = 'decap-cms-notes';
1240
1238
  static NOTE_ISSUE_PREFIX = 'Notes: ';
1241
- // 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)
1242
- static NOTE_REGEX = /^<!-- DecapCMS Note - Status: (RESOLVED|OPEN) -->([\s\S]+)$/;
1243
-
1244
- /**
1245
- * Format a note for PR comment display
1246
- */
1247
- formatNoteForGithub(note) {
1248
- const status = note.resolved ? API.NOTE_STATUS_RESOLVED : API.NOTE_STATUS_OPEN;
1249
- return `<!-- DecapCMS Note - Status: ${status} -->
1250
- ${note.content}`;
1251
- }
1252
1239
 
1253
1240
  /**
1254
1241
  * Parse a GitHub comment into a Note object
1255
1242
  */
1256
1243
  parseCommentToNote(comment) {
1257
- if (!comment || !comment.body || !comment.user) {
1258
- throw new Error('Invalid comment structure');
1259
- }
1260
- const structuredMatch = comment.body.match(API.NOTE_REGEX);
1261
- const content = structuredMatch ? structuredMatch[2].trim() : comment.body;
1262
- const resolved = structuredMatch ? structuredMatch[1] === API.NOTE_STATUS_RESOLVED : false;
1263
- if (!content.trim()) {
1264
- throw new Error('Empty note content');
1265
- }
1266
- return {
1267
- id: comment.id.toString(),
1268
- author: comment.user.login,
1269
- avatarUrl: comment.user.avatar_url,
1270
- timestamp: comment.created_at,
1271
- content,
1272
- resolved,
1273
- entrySlug: ''
1274
- };
1244
+ return commentToNote(comment);
1275
1245
  }
1276
1246
 
1277
1247
  /**
@@ -1318,13 +1288,13 @@ ${note.content}`;
1318
1288
  */
1319
1289
  async getIssueWithETag(issueNumber, etag) {
1320
1290
  try {
1321
- const headers = {
1322
- Authorization: `${this.tokenKeyword} ${this.token}`
1323
- };
1324
- if (etag) {
1325
- headers['If-None-Match'] = etag;
1326
- }
1327
- const response = await fetch(`${this.apiRoot}${this.repoURL}/issues/${issueNumber}`, {
1291
+ // Raw fetch because `request` parses the body and drops the ETag header.
1292
+ // Still built through urlFor/requestHeaders so subclasses can scope it to
1293
+ // their own proxy; hand-building the URL and auth header breaks them.
1294
+ const headers = await this.requestHeaders(etag ? {
1295
+ 'If-None-Match': etag
1296
+ } : {});
1297
+ const response = await fetch(this.urlFor(`${this.repoURL}/issues/${issueNumber}`, {}), {
1328
1298
  headers
1329
1299
  });
1330
1300
  if (response.status === 304) {
@@ -1335,10 +1305,7 @@ ${note.content}`;
1335
1305
  if (response.status === 200) {
1336
1306
  const issue = await response.json();
1337
1307
  const newETag = response.headers.get('ETag');
1338
- const commentsResponse = await fetch(`${this.apiRoot}${this.repoURL}/issues/${issueNumber}/comments`, {
1339
- headers
1340
- });
1341
- const commentsRaw = await commentsResponse.json();
1308
+ const commentsRaw = await this.getIssueComments(issueNumber);
1342
1309
  const comments = commentsRaw.map(comment => ({
1343
1310
  id: comment.id,
1344
1311
  body: comment.body,
@@ -1386,14 +1353,23 @@ ${note.content}`;
1386
1353
  /**
1387
1354
  * Get comments from a GitHub issue
1388
1355
  */
1356
+ /**
1357
+ * Paged, because this endpoint defaults to 30 per page - a thread past that
1358
+ * silently lost its older notes, in the pane and in polling alike.
1359
+ *
1360
+ * Errors propagate deliberately. Returning an empty list on a failed request
1361
+ * is indistinguishable from a thread whose comments were all deleted: the
1362
+ * polling manager would diff against it, emit `comment_deleted` for every
1363
+ * note and blank the pane, then restore them on the next poll. Throwing
1364
+ * leaves the manager's last state alone and lets it retry.
1365
+ */
1389
1366
  async getIssueComments(issueNumber) {
1390
- try {
1391
- const response = await this.request(`${this.repoURL}/issues/${issueNumber}/comments`);
1392
- return Array.isArray(response) ? response : [];
1393
- } catch (error) {
1394
- console.error('Failed to get issue comments:', error);
1395
- return [];
1396
- }
1367
+ const response = await this.requestAllPages(`${this.repoURL}/issues/${issueNumber}/comments`, {
1368
+ params: {
1369
+ per_page: 100
1370
+ }
1371
+ });
1372
+ return Array.isArray(response) ? response : [];
1397
1373
  }
1398
1374
 
1399
1375
  /**
@@ -1404,7 +1380,7 @@ ${note.content}`;
1404
1380
  const response = await this.request(`${this.repoURL}/issues/${issueNumber}/comments`, {
1405
1381
  method: 'POST',
1406
1382
  body: JSON.stringify({
1407
- body: this.formatNoteForGithub(note)
1383
+ body: formatNoteBody(note)
1408
1384
  })
1409
1385
  });
1410
1386
  return response.id.toString();
@@ -1422,7 +1398,7 @@ ${note.content}`;
1422
1398
  await this.request(`${this.repoURL}/issues/comments/${commentId}`, {
1423
1399
  method: 'PATCH',
1424
1400
  body: JSON.stringify({
1425
- body: this.formatNoteForGithub(note)
1401
+ body: formatNoteBody(note)
1426
1402
  })
1427
1403
  });
1428
1404
  } catch (error) {
@@ -1511,11 +1487,8 @@ ${note.content}`;
1511
1487
  const comments = await this.getIssueComments(issue.number);
1512
1488
  const issueUrl = issue.html_url; // Get the issue URL once
1513
1489
 
1514
- // Add issueUrl to each note
1515
- return comments.map(comment => ({
1516
- ...this.parseCommentToNote(comment),
1517
- 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)
1518
- }));
1490
+ // Add issueUrl to each note (this info is picked up by the UI to direct users to the source of the Notes in Github)
1491
+ return commentsToNotes(comments, issueUrl, comment => this.parseCommentToNote(comment));
1519
1492
  } catch (error) {
1520
1493
  console.error('Failed to get entry notes:', error);
1521
1494
  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.9.0-beta.0",
4
+ "version": "3.9.0-beta.1",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -34,8 +34,8 @@
34
34
  "lodash": "^4.17.11",
35
35
  "prop-types": "^15.7.2",
36
36
  "react": "^19.1.0",
37
- "decap-cms-lib-util": "3.9.0-beta.0",
38
37
  "decap-cms-lib-auth": "3.3.3-beta.0",
38
+ "decap-cms-lib-util": "3.9.0-beta.1",
39
39
  "decap-cms-ui-default": "3.9.3-beta.0"
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,
@@ -1571,52 +1574,16 @@ export default class API {
1571
1574
  }
1572
1575
 
1573
1576
  /**
1574
- * Constants for note formatting to aid with PR comment to note conversion
1577
+ * How a notes issue is recognised on the host.
1575
1578
  */
1576
- private static readonly NOTE_STATUS_RESOLVED = 'RESOLVED';
1577
- private static readonly NOTE_STATUS_OPEN = 'OPEN';
1578
- private static readonly NOTES_LABEL = 'decap-cms-notes';
1579
- private static readonly NOTE_ISSUE_PREFIX = 'Notes: ';
1580
- // 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)
1581
- private static readonly NOTE_REGEX =
1582
- /^<!-- DecapCMS Note - Status: (RESOLVED|OPEN) -->([\s\S]+)$/;
1583
-
1584
- /**
1585
- * Format a note for PR comment display
1586
- */
1587
- private formatNoteForGithub(note: Note): string {
1588
- const status = note.resolved ? API.NOTE_STATUS_RESOLVED : API.NOTE_STATUS_OPEN;
1589
-
1590
- return `<!-- DecapCMS Note - Status: ${status} -->
1591
- ${note.content}`;
1592
- }
1579
+ static readonly NOTES_LABEL = 'decap-cms-notes';
1580
+ static readonly NOTE_ISSUE_PREFIX = 'Notes: ';
1593
1581
 
1594
1582
  /**
1595
1583
  * Parse a GitHub comment into a Note object
1596
1584
  */
1597
- parseCommentToNote(comment: GitHubIssue): Note {
1598
- if (!comment || !comment.body || !comment.user) {
1599
- throw new Error('Invalid comment structure');
1600
- }
1601
-
1602
- const structuredMatch = comment.body.match(API.NOTE_REGEX);
1603
-
1604
- const content = structuredMatch ? structuredMatch[2].trim() : comment.body;
1605
- const resolved = structuredMatch ? structuredMatch[1] === API.NOTE_STATUS_RESOLVED : false;
1606
-
1607
- if (!content.trim()) {
1608
- throw new Error('Empty note content');
1609
- }
1610
-
1611
- return {
1612
- id: comment.id.toString(),
1613
- author: comment.user.login,
1614
- avatarUrl: comment.user.avatar_url,
1615
- timestamp: comment.created_at,
1616
- content,
1617
- resolved,
1618
- entrySlug: '',
1619
- };
1585
+ parseCommentToNote(comment: CommentData): Note {
1586
+ return commentToNote(comment);
1620
1587
  }
1621
1588
 
1622
1589
  /**
@@ -1675,15 +1642,14 @@ ${note.content}`;
1675
1642
  | { status: 200; data: IssueState; etag: string | null }
1676
1643
  > {
1677
1644
  try {
1678
- const headers: Record<string, string> = {
1679
- Authorization: `${this.tokenKeyword} ${this.token}`,
1680
- };
1681
-
1682
- if (etag) {
1683
- headers['If-None-Match'] = etag;
1684
- }
1645
+ // Raw fetch because `request` parses the body and drops the ETag header.
1646
+ // Still built through urlFor/requestHeaders so subclasses can scope it to
1647
+ // their own proxy; hand-building the URL and auth header breaks them.
1648
+ const headers: Record<string, string> = await this.requestHeaders(
1649
+ etag ? { 'If-None-Match': etag } : {},
1650
+ );
1685
1651
 
1686
- const response = await fetch(`${this.apiRoot}${this.repoURL}/issues/${issueNumber}`, {
1652
+ const response = await fetch(this.urlFor(`${this.repoURL}/issues/${issueNumber}`, {}), {
1687
1653
  headers,
1688
1654
  });
1689
1655
 
@@ -1695,11 +1661,7 @@ ${note.content}`;
1695
1661
  const issue = await response.json();
1696
1662
  const newETag = response.headers.get('ETag');
1697
1663
 
1698
- const commentsResponse = await fetch(
1699
- `${this.apiRoot}${this.repoURL}/issues/${issueNumber}/comments`,
1700
- { headers },
1701
- );
1702
- const commentsRaw: GitHubIssue[] = await commentsResponse.json();
1664
+ const commentsRaw = await this.getIssueComments(issueNumber);
1703
1665
 
1704
1666
  const comments: CommentData[] = commentsRaw.map(comment => ({
1705
1667
  id: comment.id,
@@ -1749,16 +1711,23 @@ ${note.content}`;
1749
1711
  /**
1750
1712
  * Get comments from a GitHub issue
1751
1713
  */
1714
+ /**
1715
+ * Paged, because this endpoint defaults to 30 per page - a thread past that
1716
+ * silently lost its older notes, in the pane and in polling alike.
1717
+ *
1718
+ * Errors propagate deliberately. Returning an empty list on a failed request
1719
+ * is indistinguishable from a thread whose comments were all deleted: the
1720
+ * polling manager would diff against it, emit `comment_deleted` for every
1721
+ * note and blank the pane, then restore them on the next poll. Throwing
1722
+ * leaves the manager's last state alone and lets it retry.
1723
+ */
1752
1724
  private async getIssueComments(issueNumber: number): Promise<GitHubIssue[]> {
1753
- try {
1754
- const response: GitHubIssue[] = await this.request(
1755
- `${this.repoURL}/issues/${issueNumber}/comments`,
1756
- );
1757
- return Array.isArray(response) ? response : [];
1758
- } catch (error) {
1759
- console.error('Failed to get issue comments:', error);
1760
- return [];
1761
- }
1725
+ const response = await this.requestAllPages<GitHubIssue>(
1726
+ `${this.repoURL}/issues/${issueNumber}/comments`,
1727
+ { params: { per_page: 100 } },
1728
+ );
1729
+
1730
+ return Array.isArray(response) ? response : [];
1762
1731
  }
1763
1732
 
1764
1733
  /**
@@ -1771,7 +1740,7 @@ ${note.content}`;
1771
1740
  {
1772
1741
  method: 'POST',
1773
1742
  body: JSON.stringify({
1774
- body: this.formatNoteForGithub(note),
1743
+ body: formatNoteBody(note),
1775
1744
  }),
1776
1745
  },
1777
1746
  );
@@ -1791,7 +1760,7 @@ ${note.content}`;
1791
1760
  await this.request(`${this.repoURL}/issues/comments/${commentId}`, {
1792
1761
  method: 'PATCH',
1793
1762
  body: JSON.stringify({
1794
- body: this.formatNoteForGithub(note),
1763
+ body: formatNoteBody(note),
1795
1764
  }),
1796
1765
  });
1797
1766
  } catch (error) {
@@ -1884,11 +1853,8 @@ ${note.content}`;
1884
1853
  const comments = await this.getIssueComments(issue.number);
1885
1854
  const issueUrl = issue.html_url; // Get the issue URL once
1886
1855
 
1887
- // Add issueUrl to each note
1888
- return comments.map(comment => ({
1889
- ...this.parseCommentToNote(comment),
1890
- 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)
1891
- }));
1856
+ // Add issueUrl to each note (this info is picked up by the UI to direct users to the source of the Notes in Github)
1857
+ return commentsToNotes(comments, issueUrl, comment => this.parseCommentToNote(comment));
1892
1858
  } catch (error) {
1893
1859
  console.error('Failed to get entry notes:', error);
1894
1860
  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
 
@@ -878,4 +879,76 @@ describe('github API', () => {
878
879
  expect(api.request).toHaveBeenCalledTimes(1);
879
880
  expect(api.request).toHaveBeenCalledWith(`/repos/repo/commits/${sha}/status`);
880
881
  });
882
+
883
+ describe('parseCommentToNote', () => {
884
+ // The body encoding itself is lib-util's (see notesFormat.spec); what is
885
+ // GitHub's is how a comment maps onto a note when the marker is silent.
886
+ function api() {
887
+ return new API({ repo: 'owner/repo' });
888
+ }
889
+
890
+ function asComment(body) {
891
+ return {
892
+ id: 7,
893
+ body,
894
+ user: { login: 'decap-turbo[bot]', avatar_url: 'https://avatar' },
895
+ created_at: '2026-01-01T00:00:00Z',
896
+ };
897
+ }
898
+
899
+ it('falls back to the comment account when the note records no author', () => {
900
+ const note = api().parseCommentToNote(asComment('just a comment'));
901
+
902
+ expect(note.author).toBe('decap-turbo[bot]');
903
+ expect(note.authorId).toBeUndefined();
904
+ expect(note.avatarUrl).toBe('https://avatar');
905
+ expect(note.content).toBe('just a comment');
906
+ });
907
+
908
+ it('prefers the recorded author, and drops the poster avatar with it', () => {
909
+ // A recorded author means the account that posted is not the person, so
910
+ // its avatar would mislabel the note. The pane shows initials instead.
911
+ const body = formatNoteBody({
912
+ content: 'hello',
913
+ resolved: false,
914
+ author: 'Decap Tester',
915
+ authorId: 'u-1',
916
+ });
917
+
918
+ const note = api().parseCommentToNote(asComment(body));
919
+
920
+ expect(note.author).toBe('Decap Tester');
921
+ expect(note.authorId).toBe('u-1');
922
+ expect(note.avatarUrl).toBeUndefined();
923
+ });
924
+
925
+ it('rejects a note whose body is only the marker', () => {
926
+ expect(() =>
927
+ api().parseCommentToNote(asComment('<!-- DecapCMS Note {"resolved":false} -->\n ')),
928
+ ).toThrow('Empty note content');
929
+ });
930
+
931
+ it('rejects a malformed comment', () => {
932
+ expect(() => api().parseCommentToNote({ id: 1 })).toThrow('Invalid comment structure');
933
+ });
934
+ });
935
+
936
+ describe('getEntryNotes', () => {
937
+ it('skips a marker-only comment instead of blanking the whole thread', async () => {
938
+ const api = new API({ repo: 'owner/repo' });
939
+ const user = { login: 'ada', avatar_url: 'https://avatar' };
940
+
941
+ api.findEntryIssue = jest.fn().mockResolvedValue({ number: 3, html_url: 'https://issue' });
942
+ api.requestAllPages = jest.fn().mockResolvedValue([
943
+ { id: 1, body: 'first', user, created_at: '2026-01-01T00:00:00Z' },
944
+ { id: 2, body: '<!-- DecapCMS Note {"resolved":false} -->\n', user, created_at: '' },
945
+ { id: 3, body: 'third', user, created_at: '2026-01-03T00:00:00Z' },
946
+ ]);
947
+
948
+ const notes = await api.getEntryNotes('posts', 'my-post');
949
+
950
+ expect(notes.map(note => note.id)).toEqual(['1', '3']);
951
+ expect(notes[0].issueUrl).toBe('https://issue');
952
+ });
953
+ });
881
954
  });
@@ -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
  );