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/CHANGELOG.md +6 -0
- package/dist/decap-cms-backend-github.js +62 -62
- package/dist/decap-cms-backend-github.js.map +1 -1
- package/dist/esm/API.js +31 -58
- package/dist/esm/implementation.js +43 -20
- package/package.json +2 -2
- package/src/API.ts +36 -70
- package/src/__tests__/API.spec.js +73 -0
- package/src/__tests__/implementation.spec.js +50 -6
- package/src/implementation.tsx +48 -19
- package/dist/esm/polling.js +0 -391
- package/src/polling.ts +0 -472
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
}
|
|
1327
|
-
const response = await fetch(
|
|
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
|
|
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
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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,
|
|
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.
|
|
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
|
-
*
|
|
1577
|
+
* How a notes issue is recognised on the host.
|
|
1575
1578
|
*/
|
|
1576
|
-
|
|
1577
|
-
|
|
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:
|
|
1598
|
-
|
|
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
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
);
|