decap-cms-backend-github 3.4.0 → 3.6.0

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
@@ -91,14 +91,18 @@ export default class API {
91
91
  token: this.token
92
92
  });
93
93
  }
94
- return this._userPromise;
94
+ return this._userPromise.then(user => ({
95
+ name: user.name || 'Unknown',
96
+ login: user.login,
97
+ email: user.email ?? undefined
98
+ }));
95
99
  }
96
100
  async hasWriteAccess() {
97
101
  try {
98
102
  const result = await this.request(this.repoURL);
99
103
  // update config repoOwner to avoid case sensitivity issues with GitHub
100
104
  this.repoOwner = result.owner.login;
101
- return result.permissions.push;
105
+ return result.permissions?.push ?? false;
102
106
  } catch (error) {
103
107
  console.error('Problem fetching repo data from GitHub');
104
108
  throw error;
@@ -402,7 +406,7 @@ export default class API {
402
406
  const [{
403
407
  files
404
408
  }, pullRequestAuthor] = await Promise.all([this.getDifferences(this.branch, pullRequest.head.sha), this.getPullRequestAuthor(pullRequest)]);
405
- const diffs = await Promise.all(files.map(file => this.diffFromFile(file)));
409
+ const diffs = await Promise.all((files || []).map(file => this.diffFromFile(file)));
406
410
  const label = pullRequest.labels.find(l => isCMSLabel(l.name, this.cmsLabelPrefix));
407
411
  const status = labelToStatus(label.name, this.cmsLabelPrefix);
408
412
  const updatedAt = pullRequest.updated_at;
@@ -450,8 +454,8 @@ export default class API {
450
454
  commit
451
455
  } = result[0];
452
456
  return {
453
- author: commit.author.name || commit.author.email,
454
- updatedOn: commit.author.date
457
+ author: commit.author?.name || commit.author?.email || '',
458
+ updatedOn: commit.author?.date || ''
455
459
  };
456
460
  } catch (e) {
457
461
  return {
@@ -502,12 +506,12 @@ export default class API {
502
506
  });
503
507
  return result.tree
504
508
  // filter only files and up to the required depth
505
- .filter(file => file.type === 'blob' && file.path.split('/').length <= depth).map(file => ({
509
+ .filter(file => file.type === 'blob' && file.path && file.path.split('/').length <= depth).map(file => ({
506
510
  type: file.type,
507
511
  id: file.sha,
508
512
  name: basename(file.path),
509
513
  path: `${folder}/${file.path}`,
510
- size: file.size
514
+ size: file.size || 0
511
515
  }));
512
516
  } catch (err) {
513
517
  if (err && err.status === 404) {
@@ -668,7 +672,7 @@ export default class API {
668
672
  const resp = await this.request(`${this.originRepoURL}/commits/${sha}/status`);
669
673
  return resp.statuses.map(s => ({
670
674
  context: s.context,
671
- target_url: s.target_url,
675
+ target_url: s.target_url || '',
672
676
  state: s.state === GitHubCommitStatusState.Success ? PreviewState.Success : PreviewState.Other
673
677
  }));
674
678
  }
@@ -744,7 +748,7 @@ export default class API {
744
748
  return {
745
749
  path: diff.filename,
746
750
  newFile: diff.status === 'added',
747
- sha: diff.sha,
751
+ sha: diff.sha || '',
748
752
  // media files diffs don't have a patch attribute, except svg files
749
753
  // renamed files don't have a patch attribute too
750
754
  binary: diff.status !== 'renamed' && !diff.patch || diff.filename.endsWith('.svg')
@@ -769,7 +773,7 @@ export default class API {
769
773
  const {
770
774
  files: diffFiles
771
775
  } = await this.getDifferences(this.branch, await this.getHeadReference(branch));
772
- const diffs = await Promise.all(diffFiles.map(file => this.diffFromFile(file)));
776
+ const diffs = await Promise.all((diffFiles || []).map(file => this.diffFromFile(file)));
773
777
  // mark media files to remove
774
778
  const mediaFilesToRemove = [];
775
779
  for (const diff of diffs.filter(d => d.binary)) {
@@ -824,7 +828,15 @@ export default class API {
824
828
  } = commit.commit;
825
829
 
826
830
  // create a new commit from the updated tree
827
- const newCommit = await this.createCommit(message, tree.sha, [baseCommit.sha], author, committer);
831
+ const newCommit = await this.createCommit(message, tree.sha, [baseCommit.sha], author ? {
832
+ name: author.name || '',
833
+ email: author.email || '',
834
+ date: author.date
835
+ } : undefined, committer ? {
836
+ name: committer.name || '',
837
+ email: committer.email || '',
838
+ date: committer.date
839
+ } : undefined);
828
840
  return newCommit;
829
841
  } else {
830
842
  return commit;
@@ -124,11 +124,18 @@ export default class GitHub {
124
124
  token
125
125
  }) {
126
126
  if (!this._currentUserPromise) {
127
- this._currentUserPromise = fetch(`${this.apiRoot}/user`, {
128
- headers: {
129
- Authorization: `${this.tokenKeyword} ${token}`
130
- }
131
- }).then(res => res.json());
127
+ this._currentUserPromise = (async () => {
128
+ const res = await fetch(`${this.apiRoot}/user`, {
129
+ headers: {
130
+ Authorization: `${this.tokenKeyword} ${token}`
131
+ }
132
+ });
133
+ const user = await res.json();
134
+ return {
135
+ ...user,
136
+ name: user.name || 'Unknown'
137
+ };
138
+ })();
132
139
  }
133
140
  return this._currentUserPromise;
134
141
  }
@@ -255,7 +262,7 @@ export default class GitHub {
255
262
  useOpenAuthoring: this.useOpenAuthoring,
256
263
  initialWorkflowStatus: this.options.initialWorkflowStatus,
257
264
  baseUrl: this.baseUrl,
258
- getUser: this.currentUser
265
+ getUser: args => this.currentUser(args)
259
266
  });
260
267
  const user = await this.api.user();
261
268
  const isCollab = await this.api.hasWriteAccess().catch(error => {
@@ -0,0 +1,391 @@
1
+ /**
2
+ * GitHub Notes Polling System
3
+ *
4
+ * ETag-based polling manager that efficiently checks for changes in GitHub Issues
5
+ * Used for a real-time feel of notes updates without excessive API calls leveraging conditional requests (304) that don't count on Github rate limits.
6
+ *
7
+ * @module polling
8
+ */
9
+
10
+ // Redux action types
11
+ export const NOTES_POLLING_START = 'NOTES_POLLING_START';
12
+ export const NOTES_POLLING_STOP = 'NOTES_POLLING_STOP';
13
+ export const NOTES_POLLING_UPDATE = 'NOTES_POLLING_UPDATE';
14
+ export const NOTES_CHANGE_DETECTED = 'NOTES_CHANGE_DETECTED';
15
+ export class ETagPollingManager {
16
+ currentWatch = null;
17
+ currentIssueKey = null;
18
+ pollingInterval = 15000;
19
+ intervalId = null;
20
+ isDocumentVisible = true;
21
+ isPolling = false;
22
+ pendingRetryTimeout = null;
23
+ constructor(api, pollingInterval = 15000) {
24
+ this.api = api;
25
+ this.pollingInterval = pollingInterval;
26
+ this.setupVisibilityListener();
27
+ }
28
+
29
+ /**
30
+ * Setup Page Visibility API listener
31
+ * Pauses polling when tab is hidden
32
+ */
33
+ setupVisibilityListener() {
34
+ if (typeof document !== 'undefined') {
35
+ document.addEventListener('visibilitychange', () => {
36
+ this.isDocumentVisible = !document.hidden;
37
+ if (this.isDocumentVisible) {
38
+ this.startPolling();
39
+ this.checkAllIssuesNow();
40
+ } else {
41
+ this.stopPolling();
42
+ }
43
+ });
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Start watching an issue for changes
49
+ * This will automatically stop watching any previously watched issue
50
+ *
51
+ * @param issueNumber - GitHub issue number
52
+ * @param collection - Collection name
53
+ * @param slug - Entry slug
54
+ * @returns Function to stop watching
55
+ */
56
+ async watchIssue(issueNumber, collection, slug, callbacks, initialState = null) {
57
+ const issueKey = this.getIssueKey(collection, slug);
58
+
59
+ // STOP ANY EXISTING WATCH FIRST
60
+ if (this.currentWatch) {
61
+ this.stopCurrentWatch();
62
+ }
63
+
64
+ // Get initial state if not provided
65
+ if (!initialState) {
66
+ try {
67
+ initialState = await this.api.getIssueState(issueNumber);
68
+ } catch (error) {
69
+ console.error('[DecapNotes Polling] Failed to get initial state:', error);
70
+ }
71
+ }
72
+ this.currentWatch = {
73
+ issueNumber,
74
+ collection,
75
+ slug,
76
+ etag: null,
77
+ lastState: initialState,
78
+ onUpdate: callbacks.onUpdate,
79
+ onChange: callbacks.onChange,
80
+ retryCount: 0,
81
+ maxRetries: 5
82
+ };
83
+ this.currentIssueKey = issueKey;
84
+
85
+ // Start polling if not already running
86
+ if (!this.intervalId && this.isDocumentVisible) {
87
+ this.startPolling();
88
+ }
89
+
90
+ // Do an immediate check
91
+ this.checkCurrentIssue();
92
+
93
+ // Return unwatch function
94
+ return () => this.stopCurrentWatch();
95
+ }
96
+
97
+ /**
98
+ * Watch issue with retry logic for newly created issues
99
+ */
100
+ async watchIssueWithRetry(collection, slug, callbacks, maxRetries = 5, retryDelay = 2000) {
101
+ const issueKey = this.getIssueKey(collection, slug);
102
+
103
+ // STOP ANY EXISTING WATCH FIRST
104
+ if (this.currentWatch) {
105
+ this.stopCurrentWatch();
106
+ }
107
+ const attemptWatch = async attempt => {
108
+ try {
109
+ const issue = await this.api.findEntryIssue(collection, slug);
110
+ if (issue) {
111
+ return await this.watchIssue(issue.number, collection, slug, callbacks);
112
+ }
113
+ if (attempt < maxRetries) {
114
+ return new Promise((resolve, reject) => {
115
+ this.pendingRetryTimeout = setTimeout(async () => {
116
+ this.pendingRetryTimeout = null;
117
+ try {
118
+ const unwatchFn = await attemptWatch(attempt + 1);
119
+ resolve(unwatchFn);
120
+ } catch (error) {
121
+ reject(error);
122
+ }
123
+ }, retryDelay);
124
+ });
125
+ }
126
+ console.log(`[DecapNotes Polling] No issue found for ${issueKey} after ${maxRetries} attempts. This is expected if there are no notes for this entry yet.`);
127
+ // Return a no-op unwatch function
128
+ return () => {
129
+ /* no-op */
130
+ };
131
+ } catch (error) {
132
+ console.error(`[DecapNotes Polling] Error finding issue for ${issueKey}:`, error);
133
+ if (attempt < maxRetries) {
134
+ return new Promise((resolve, reject) => {
135
+ this.pendingRetryTimeout = setTimeout(async () => {
136
+ this.pendingRetryTimeout = null;
137
+ try {
138
+ const unwatchFn = await attemptWatch(attempt + 1);
139
+ resolve(unwatchFn);
140
+ } catch (err) {
141
+ reject(err);
142
+ }
143
+ }, retryDelay);
144
+ });
145
+ }
146
+ throw error;
147
+ }
148
+ };
149
+ return attemptWatch(1);
150
+ }
151
+
152
+ /**
153
+ * Stop watching the current issue - complete cleanup
154
+ */
155
+ stopCurrentWatch() {
156
+ if (!this.currentWatch) {
157
+ return;
158
+ }
159
+
160
+ // Clear any pending retry timeout
161
+ if (this.pendingRetryTimeout) {
162
+ clearTimeout(this.pendingRetryTimeout);
163
+ this.pendingRetryTimeout = null;
164
+ }
165
+
166
+ // Clear current watch
167
+ this.currentWatch = null;
168
+ this.currentIssueKey = null;
169
+
170
+ // Stop polling since there's nothing to watch
171
+ this.stopPolling();
172
+ }
173
+
174
+ /**
175
+ * Start the polling loop
176
+ */
177
+ startPolling() {
178
+ if (this.intervalId || !this.isDocumentVisible || !this.currentWatch) return;
179
+ console.log(`[DecapNotes Polling] Starting polling loop (${this.pollingInterval}ms interval) for ${this.currentIssueKey}`);
180
+ this.intervalId = setInterval(() => {
181
+ this.pollAllIssues();
182
+ }, this.pollingInterval);
183
+ }
184
+
185
+ /**
186
+ * Stop the polling loop
187
+ */
188
+ stopPolling() {
189
+ if (this.intervalId) {
190
+ console.log('[DecapNotes Polling] Stopping polling loop');
191
+ clearInterval(this.intervalId);
192
+ this.intervalId = null;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Poll current watched issue
198
+ */
199
+ async pollAllIssues() {
200
+ await this.checkCurrentIssue();
201
+ }
202
+
203
+ /**
204
+ * Check current issue for changes using ETag
205
+ */
206
+ async checkCurrentIssue() {
207
+ if (!this.currentWatch) {
208
+ return;
209
+ }
210
+ if (this.isPolling) {
211
+ return;
212
+ }
213
+ this.isPolling = true;
214
+ try {
215
+ const watch = this.currentWatch;
216
+ const response = await this.api.getIssueWithETag(watch.issueNumber, watch.etag);
217
+ if (response.status === 304) {
218
+ return;
219
+ }
220
+ if (response.status === 200) {
221
+ const newState = response.data;
222
+ const newETag = response.etag;
223
+
224
+ // Update ETag
225
+ watch.etag = newETag || null;
226
+
227
+ // Detect specific changes
228
+ const changes = this.detectChanges(watch.lastState, newState);
229
+ if (changes.length > 0) {
230
+ // Convert comments to notes
231
+ const newNotes = newState.comments.map(comment => ({
232
+ ...this.api.parseCommentToNote(comment),
233
+ issueUrl: newState.html_url
234
+ }));
235
+ if (watch.onUpdate) {
236
+ watch.onUpdate(newNotes, changes);
237
+ }
238
+ if (watch.onChange) {
239
+ changes.forEach(change => {
240
+ watch.onChange(change);
241
+ });
242
+ }
243
+ }
244
+
245
+ // Update stored state
246
+ watch.lastState = newState;
247
+ }
248
+ } catch (error) {
249
+ if (error && typeof error === 'object' && 'status' in error && error.status !== 304) {
250
+ console.error(`[DecapNotes Polling] Error checking ${this.currentIssueKey}:`, error);
251
+ }
252
+ } finally {
253
+ this.isPolling = false;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Immediately check current issue
259
+ */
260
+ async checkAllIssuesNow() {
261
+ await this.checkCurrentIssue();
262
+ }
263
+
264
+ /**
265
+ * Manually trigger a check - only works if this is the current entry
266
+ */
267
+ async checkIssueNow(collection, slug) {
268
+ const issueKey = this.getIssueKey(collection, slug);
269
+ if (this.currentIssueKey !== issueKey) {
270
+ console.warn(`[DecapNotes Polling] Cannot check ${issueKey} - currently watching ${this.currentIssueKey}`);
271
+ return;
272
+ }
273
+ await this.checkCurrentIssue();
274
+ }
275
+
276
+ /**
277
+ * Detect what changed between two states
278
+ */
279
+ detectChanges(previous, current) {
280
+ if (!previous) {
281
+ return [];
282
+ }
283
+ const changes = [];
284
+
285
+ // New comments
286
+ const newComments = current.comments.filter(comment => !previous.comments.some(prev => prev.id === comment.id));
287
+ newComments.forEach(comment => {
288
+ changes.push({
289
+ type: 'comment_added',
290
+ data: comment,
291
+ timestamp: comment.created_at
292
+ });
293
+ });
294
+
295
+ // Updated comments
296
+ current.comments.forEach(comment => {
297
+ const prevComment = previous.comments.find(prev => prev.id === comment.id);
298
+ if (prevComment && prevComment.updated_at !== comment.updated_at) {
299
+ changes.push({
300
+ type: 'comment_updated',
301
+ data: comment,
302
+ previousData: prevComment,
303
+ timestamp: comment.updated_at
304
+ });
305
+ }
306
+ });
307
+
308
+ // Deleted comments
309
+ const deletedComments = previous.comments.filter(prevComment => !current.comments.some(comment => comment.id === prevComment.id));
310
+ deletedComments.forEach(comment => {
311
+ changes.push({
312
+ type: 'comment_deleted',
313
+ data: comment,
314
+ timestamp: new Date().toISOString()
315
+ });
316
+ });
317
+
318
+ // Issue state changed
319
+ if (previous.state !== current.state) {
320
+ changes.push({
321
+ type: 'issue_state_changed',
322
+ data: {
323
+ from: previous.state,
324
+ to: current.state
325
+ },
326
+ timestamp: current.updated_at
327
+ });
328
+ }
329
+
330
+ // Labels changed
331
+ if (this.hasLabelsChanged(previous.labels, current.labels)) {
332
+ changes.push({
333
+ type: 'issue_labels_changed',
334
+ data: {
335
+ from: previous.labels,
336
+ to: current.labels
337
+ },
338
+ timestamp: current.updated_at
339
+ });
340
+ }
341
+ return changes;
342
+ }
343
+
344
+ /**
345
+ * Check if labels changed
346
+ */
347
+ hasLabelsChanged(previous, current) {
348
+ if (previous.length !== current.length) return true;
349
+ const prevNames = previous.map(l => l.name).sort();
350
+ const currNames = current.map(l => l.name).sort();
351
+ return prevNames.join(',') !== currNames.join(',');
352
+ }
353
+
354
+ /**
355
+ * Get issue key for storage
356
+ */
357
+ getIssueKey(collection, slug) {
358
+ return `${collection}/${slug}`;
359
+ }
360
+
361
+ /**
362
+ * Get polling status
363
+ */
364
+ getStatus() {
365
+ return {
366
+ isPolling: this.intervalId !== null,
367
+ currentWatch: this.currentIssueKey,
368
+ watchedCount: this.currentWatch ? 1 : 0,
369
+ pollingInterval: this.pollingInterval,
370
+ isDocumentVisible: this.isDocumentVisible,
371
+ hasPendingRetry: this.pendingRetryTimeout !== null
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Clean up - stop all polling
377
+ */
378
+ destroy() {
379
+ console.log('[DecapNotes Polling] Destroying polling manager');
380
+
381
+ // Clear pending retry
382
+ if (this.pendingRetryTimeout) {
383
+ clearTimeout(this.pendingRetryTimeout);
384
+ this.pendingRetryTimeout = null;
385
+ }
386
+
387
+ // Stop current watch
388
+ this.stopCurrentWatch();
389
+ }
390
+ }
391
+ export default ETagPollingManager;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Rate limit info type and callback
3
+ */
4
+
5
+ /**
6
+ * Extracts rate limit information from response headers
7
+ */
8
+ export function extractRateLimitInfo(headers) {
9
+ const used = headers.get('x-ratelimit-used');
10
+ const limit = headers.get('x-ratelimit-limit');
11
+ const remaining = headers.get('x-ratelimit-remaining');
12
+ const reset = headers.get('x-ratelimit-reset');
13
+ const resource = headers.get('x-ratelimit-resource');
14
+ if (!used || !limit || !remaining || !reset || !resource) {
15
+ return null;
16
+ }
17
+ return {
18
+ used: parseInt(used, 10),
19
+ limit: parseInt(limit, 10),
20
+ remaining: parseInt(remaining, 10),
21
+ reset: parseInt(reset, 10),
22
+ resource
23
+ };
24
+ }
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.4.0",
4
+ "version": "3.6.0",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-backend-github",
7
7
  "bugs": "https://github.com/decaporg/decap-cms/issues",
@@ -44,5 +44,5 @@
44
44
  "browser": {
45
45
  "path": "path-browserify"
46
46
  },
47
- "gitHead": "d3465f53b7f056ad5d872948a07eaa8e4ae63315"
47
+ "gitHead": "45c9f5b9a1a12f74321ce4658b71ec88d6365ec1"
48
48
  }