decap-cms-backend-github 3.5.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
@@ -93,7 +93,8 @@ export default class API {
93
93
  }
94
94
  return this._userPromise.then(user => ({
95
95
  name: user.name || 'Unknown',
96
- login: user.login
96
+ login: user.login,
97
+ email: user.email ?? undefined
97
98
  }));
98
99
  }
99
100
  async hasWriteAccess() {
@@ -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;
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.5.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": "0c1b7c7d63ba6ddb3de3692fbb6b16ac6ebb44d5"
47
+ "gitHead": "45c9f5b9a1a12f74321ce4658b71ec88d6365ec1"
48
48
  }
package/src/API.ts CHANGED
@@ -253,13 +253,14 @@ export default class API {
253
253
 
254
254
  static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Decap CMS';
255
255
 
256
- user(): Promise<{ name: string; login: string }> {
256
+ user(): Promise<{ name: string; login: string; email?: string }> {
257
257
  if (!this._userPromise) {
258
258
  this._userPromise = this.getUser({ token: this.token });
259
259
  }
260
260
  return this._userPromise.then(user => ({
261
261
  name: user.name || 'Unknown',
262
262
  login: user.login,
263
+ email: user.email ?? undefined,
263
264
  }));
264
265
  }
265
266
 
@@ -1 +0,0 @@
1
- export {};