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/CHANGELOG.md +4 -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 +30 -57
- package/dist/esm/implementation.js +43 -20
- package/package.json +2 -2
- package/src/API.ts +33 -67
- 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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
}
|
|
1313
|
-
const response = await fetch(
|
|
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
|
|
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
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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.8.
|
|
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.
|
|
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:
|
|
1580
|
-
|
|
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
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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(
|
|
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
|
);
|