decap-cms-backend-github 3.7.0 → 3.8.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/src/polling.ts ADDED
@@ -0,0 +1,472 @@
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
+ import type { Note, IssueState, CommentData, IssueChange } from 'decap-cms-lib-util';
11
+
12
+ interface WatchedIssue {
13
+ issueNumber: number;
14
+ collection: string;
15
+ slug: string;
16
+ etag: string | null;
17
+ lastState: IssueState | null;
18
+ onUpdate?: (notes: Note[], changes: IssueChange[]) => void;
19
+ onChange?: (change: IssueChange) => void;
20
+ retryCount?: number;
21
+ maxRetries?: number;
22
+ }
23
+
24
+ export interface GitHubNotesAPI {
25
+ getIssueState(issueNumber: number): Promise<IssueState>;
26
+ getIssueWithETag(
27
+ issueNumber: number,
28
+ etag: string | null,
29
+ ): Promise<
30
+ | { status: 304; data?: never; etag?: never }
31
+ | { status: 200; data: IssueState; etag: string | null }
32
+ >;
33
+ parseCommentToNote(comment: CommentData): Note;
34
+ findEntryIssue(collection: string, slug: string): Promise<{ number: number } | null>;
35
+ }
36
+
37
+ // Redux action types
38
+ export const NOTES_POLLING_START = 'NOTES_POLLING_START';
39
+ export const NOTES_POLLING_STOP = 'NOTES_POLLING_STOP';
40
+ export const NOTES_POLLING_UPDATE = 'NOTES_POLLING_UPDATE';
41
+ export const NOTES_CHANGE_DETECTED = 'NOTES_CHANGE_DETECTED';
42
+
43
+ export class ETagPollingManager {
44
+ private currentWatch: WatchedIssue | null = null;
45
+ private currentIssueKey: string | null = null;
46
+ private pollingInterval = 15000;
47
+ private intervalId: NodeJS.Timeout | null = null;
48
+ private isDocumentVisible = true;
49
+ private api: GitHubNotesAPI;
50
+ private isPolling = false;
51
+ private pendingRetryTimeout: NodeJS.Timeout | null = null;
52
+
53
+ constructor(api: GitHubNotesAPI, pollingInterval = 15000) {
54
+ this.api = api;
55
+ this.pollingInterval = pollingInterval;
56
+ this.setupVisibilityListener();
57
+ }
58
+
59
+ /**
60
+ * Setup Page Visibility API listener
61
+ * Pauses polling when tab is hidden
62
+ */
63
+ private setupVisibilityListener() {
64
+ if (typeof document !== 'undefined') {
65
+ document.addEventListener('visibilitychange', () => {
66
+ this.isDocumentVisible = !document.hidden;
67
+
68
+ if (this.isDocumentVisible) {
69
+ this.startPolling();
70
+ this.checkAllIssuesNow();
71
+ } else {
72
+ this.stopPolling();
73
+ }
74
+ });
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Start watching an issue for changes
80
+ * This will automatically stop watching any previously watched issue
81
+ *
82
+ * @param issueNumber - GitHub issue number
83
+ * @param collection - Collection name
84
+ * @param slug - Entry slug
85
+ * @returns Function to stop watching
86
+ */
87
+ async watchIssue(
88
+ issueNumber: number,
89
+ collection: string,
90
+ slug: string,
91
+ callbacks: {
92
+ onUpdate?: (notes: Note[], changes: IssueChange[]) => void;
93
+ onChange?: (change: IssueChange) => void;
94
+ },
95
+ initialState: IssueState | null = null,
96
+ ): Promise<() => void> {
97
+ const issueKey = this.getIssueKey(collection, slug);
98
+
99
+ // STOP ANY EXISTING WATCH FIRST
100
+ if (this.currentWatch) {
101
+ this.stopCurrentWatch();
102
+ }
103
+
104
+ // Get initial state if not provided
105
+ if (!initialState) {
106
+ try {
107
+ initialState = await this.api.getIssueState(issueNumber);
108
+ } catch (error) {
109
+ console.error('[DecapNotes Polling] Failed to get initial state:', error);
110
+ }
111
+ }
112
+
113
+ this.currentWatch = {
114
+ issueNumber,
115
+ collection,
116
+ slug,
117
+ etag: null,
118
+ lastState: initialState,
119
+ onUpdate: callbacks.onUpdate,
120
+ onChange: callbacks.onChange,
121
+ retryCount: 0,
122
+ maxRetries: 5,
123
+ };
124
+
125
+ this.currentIssueKey = issueKey;
126
+
127
+ // Start polling if not already running
128
+ if (!this.intervalId && this.isDocumentVisible) {
129
+ this.startPolling();
130
+ }
131
+
132
+ // Do an immediate check
133
+ this.checkCurrentIssue();
134
+
135
+ // Return unwatch function
136
+ return () => this.stopCurrentWatch();
137
+ }
138
+
139
+ /**
140
+ * Watch issue with retry logic for newly created issues
141
+ */
142
+ async watchIssueWithRetry(
143
+ collection: string,
144
+ slug: string,
145
+ callbacks: {
146
+ onUpdate?: (notes: Note[], changes: IssueChange[]) => void;
147
+ onChange?: (change: IssueChange) => void;
148
+ },
149
+ maxRetries = 5,
150
+ retryDelay = 2000,
151
+ ): Promise<() => void> {
152
+ const issueKey = this.getIssueKey(collection, slug);
153
+
154
+ // STOP ANY EXISTING WATCH FIRST
155
+ if (this.currentWatch) {
156
+ this.stopCurrentWatch();
157
+ }
158
+
159
+ const attemptWatch = async (attempt: number): Promise<() => void> => {
160
+ try {
161
+ const issue = await this.api.findEntryIssue(collection, slug);
162
+
163
+ if (issue) {
164
+ return await this.watchIssue(issue.number, collection, slug, callbacks);
165
+ }
166
+
167
+ if (attempt < maxRetries) {
168
+ return new Promise((resolve, reject) => {
169
+ this.pendingRetryTimeout = setTimeout(async () => {
170
+ this.pendingRetryTimeout = null;
171
+ try {
172
+ const unwatchFn = await attemptWatch(attempt + 1);
173
+ resolve(unwatchFn);
174
+ } catch (error) {
175
+ reject(error);
176
+ }
177
+ }, retryDelay);
178
+ });
179
+ }
180
+
181
+ console.log(
182
+ `[DecapNotes Polling] No issue found for ${issueKey} after ${maxRetries} attempts. This is expected if there are no notes for this entry yet.`,
183
+ );
184
+ // Return a no-op unwatch function
185
+ return () => {
186
+ /* no-op */
187
+ };
188
+ } catch (error) {
189
+ console.error(`[DecapNotes Polling] Error finding issue for ${issueKey}:`, error);
190
+
191
+ if (attempt < maxRetries) {
192
+ return new Promise((resolve, reject) => {
193
+ this.pendingRetryTimeout = setTimeout(async () => {
194
+ this.pendingRetryTimeout = null;
195
+ try {
196
+ const unwatchFn = await attemptWatch(attempt + 1);
197
+ resolve(unwatchFn);
198
+ } catch (err) {
199
+ reject(err);
200
+ }
201
+ }, retryDelay);
202
+ });
203
+ }
204
+
205
+ throw error;
206
+ }
207
+ };
208
+
209
+ return attemptWatch(1);
210
+ }
211
+
212
+ /**
213
+ * Stop watching the current issue - complete cleanup
214
+ */
215
+ private stopCurrentWatch() {
216
+ if (!this.currentWatch) {
217
+ return;
218
+ }
219
+
220
+ // Clear any pending retry timeout
221
+ if (this.pendingRetryTimeout) {
222
+ clearTimeout(this.pendingRetryTimeout);
223
+ this.pendingRetryTimeout = null;
224
+ }
225
+
226
+ // Clear current watch
227
+ this.currentWatch = null;
228
+ this.currentIssueKey = null;
229
+
230
+ // Stop polling since there's nothing to watch
231
+ this.stopPolling();
232
+ }
233
+
234
+ /**
235
+ * Start the polling loop
236
+ */
237
+ private startPolling() {
238
+ if (this.intervalId || !this.isDocumentVisible || !this.currentWatch) return;
239
+
240
+ console.log(
241
+ `[DecapNotes Polling] Starting polling loop (${this.pollingInterval}ms interval) for ${this.currentIssueKey}`,
242
+ );
243
+
244
+ this.intervalId = setInterval(() => {
245
+ this.pollAllIssues();
246
+ }, this.pollingInterval);
247
+ }
248
+
249
+ /**
250
+ * Stop the polling loop
251
+ */
252
+ private stopPolling() {
253
+ if (this.intervalId) {
254
+ console.log('[DecapNotes Polling] Stopping polling loop');
255
+ clearInterval(this.intervalId);
256
+ this.intervalId = null;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Poll current watched issue
262
+ */
263
+ private async pollAllIssues() {
264
+ await this.checkCurrentIssue();
265
+ }
266
+
267
+ /**
268
+ * Check current issue for changes using ETag
269
+ */
270
+ private async checkCurrentIssue() {
271
+ if (!this.currentWatch) {
272
+ return;
273
+ }
274
+
275
+ if (this.isPolling) {
276
+ return;
277
+ }
278
+
279
+ this.isPolling = true;
280
+
281
+ try {
282
+ const watch = this.currentWatch;
283
+
284
+ const response = await this.api.getIssueWithETag(watch.issueNumber, watch.etag);
285
+
286
+ if (response.status === 304) {
287
+ return;
288
+ }
289
+
290
+ if (response.status === 200) {
291
+ const newState: IssueState = response.data;
292
+ const newETag = response.etag;
293
+
294
+ // Update ETag
295
+ watch.etag = newETag || null;
296
+
297
+ // Detect specific changes
298
+ const changes = this.detectChanges(watch.lastState, newState);
299
+
300
+ if (changes.length > 0) {
301
+ // Convert comments to notes
302
+ const newNotes = newState.comments.map(comment => ({
303
+ ...this.api.parseCommentToNote(comment),
304
+ issueUrl: newState.html_url,
305
+ }));
306
+
307
+ if (watch.onUpdate) {
308
+ watch.onUpdate(newNotes, changes);
309
+ }
310
+
311
+ if (watch.onChange) {
312
+ changes.forEach(change => {
313
+ watch.onChange!(change);
314
+ });
315
+ }
316
+ }
317
+
318
+ // Update stored state
319
+ watch.lastState = newState;
320
+ }
321
+ } catch (error) {
322
+ if (error && typeof error === 'object' && 'status' in error && error.status !== 304) {
323
+ console.error(`[DecapNotes Polling] Error checking ${this.currentIssueKey}:`, error);
324
+ }
325
+ } finally {
326
+ this.isPolling = false;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Immediately check current issue
332
+ */
333
+ private async checkAllIssuesNow() {
334
+ await this.checkCurrentIssue();
335
+ }
336
+
337
+ /**
338
+ * Manually trigger a check - only works if this is the current entry
339
+ */
340
+ async checkIssueNow(collection: string, slug: string) {
341
+ const issueKey = this.getIssueKey(collection, slug);
342
+
343
+ if (this.currentIssueKey !== issueKey) {
344
+ console.warn(
345
+ `[DecapNotes Polling] Cannot check ${issueKey} - currently watching ${this.currentIssueKey}`,
346
+ );
347
+ return;
348
+ }
349
+
350
+ await this.checkCurrentIssue();
351
+ }
352
+
353
+ /**
354
+ * Detect what changed between two states
355
+ */
356
+ private detectChanges(previous: IssueState | null, current: IssueState): IssueChange[] {
357
+ if (!previous) {
358
+ return [];
359
+ }
360
+
361
+ const changes: IssueChange[] = [];
362
+
363
+ // New comments
364
+ const newComments = current.comments.filter(
365
+ comment => !previous.comments.some(prev => prev.id === comment.id),
366
+ );
367
+ newComments.forEach(comment => {
368
+ changes.push({
369
+ type: 'comment_added',
370
+ data: comment,
371
+ timestamp: comment.created_at,
372
+ });
373
+ });
374
+
375
+ // Updated comments
376
+ current.comments.forEach(comment => {
377
+ const prevComment = previous.comments.find(prev => prev.id === comment.id);
378
+ if (prevComment && prevComment.updated_at !== comment.updated_at) {
379
+ changes.push({
380
+ type: 'comment_updated',
381
+ data: comment,
382
+ previousData: prevComment,
383
+ timestamp: comment.updated_at,
384
+ });
385
+ }
386
+ });
387
+
388
+ // Deleted comments
389
+ const deletedComments = previous.comments.filter(
390
+ prevComment => !current.comments.some(comment => comment.id === prevComment.id),
391
+ );
392
+ deletedComments.forEach(comment => {
393
+ changes.push({
394
+ type: 'comment_deleted',
395
+ data: comment,
396
+ timestamp: new Date().toISOString(),
397
+ });
398
+ });
399
+
400
+ // Issue state changed
401
+ if (previous.state !== current.state) {
402
+ changes.push({
403
+ type: 'issue_state_changed',
404
+ data: { from: previous.state, to: current.state },
405
+ timestamp: current.updated_at,
406
+ });
407
+ }
408
+
409
+ // Labels changed
410
+ if (this.hasLabelsChanged(previous.labels, current.labels)) {
411
+ changes.push({
412
+ type: 'issue_labels_changed',
413
+ data: { from: previous.labels, to: current.labels },
414
+ timestamp: current.updated_at,
415
+ });
416
+ }
417
+
418
+ return changes;
419
+ }
420
+
421
+ /**
422
+ * Check if labels changed
423
+ */
424
+ private hasLabelsChanged(
425
+ previous: Array<{ name: string }>,
426
+ current: Array<{ name: string }>,
427
+ ): boolean {
428
+ if (previous.length !== current.length) return true;
429
+ const prevNames = previous.map(l => l.name).sort();
430
+ const currNames = current.map(l => l.name).sort();
431
+ return prevNames.join(',') !== currNames.join(',');
432
+ }
433
+
434
+ /**
435
+ * Get issue key for storage
436
+ */
437
+ private getIssueKey(collection: string, slug: string): string {
438
+ return `${collection}/${slug}`;
439
+ }
440
+
441
+ /**
442
+ * Get polling status
443
+ */
444
+ getStatus() {
445
+ return {
446
+ isPolling: this.intervalId !== null,
447
+ currentWatch: this.currentIssueKey,
448
+ watchedCount: this.currentWatch ? 1 : 0,
449
+ pollingInterval: this.pollingInterval,
450
+ isDocumentVisible: this.isDocumentVisible,
451
+ hasPendingRetry: this.pendingRetryTimeout !== null,
452
+ };
453
+ }
454
+
455
+ /**
456
+ * Clean up - stop all polling
457
+ */
458
+ destroy() {
459
+ console.log('[DecapNotes Polling] Destroying polling manager');
460
+
461
+ // Clear pending retry
462
+ if (this.pendingRetryTimeout) {
463
+ clearTimeout(this.pendingRetryTimeout);
464
+ this.pendingRetryTimeout = null;
465
+ }
466
+
467
+ // Stop current watch
468
+ this.stopCurrentWatch();
469
+ }
470
+ }
471
+
472
+ export default ETagPollingManager;
package/LICENSE DELETED
@@ -1,22 +0,0 @@
1
- Copyright (c) 2016 Netlify <decap@p-m.si>
2
-
3
- MIT License
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining
6
- a copy of this software and associated documentation files (the
7
- "Software"), to deal in the Software without restriction, including
8
- without limitation the rights to use, copy, modify, merge, publish,
9
- distribute, sublicense, and/or sell copies of the Software, and to
10
- permit persons to whom the Software is furnished to do so, subject to
11
- the following conditions:
12
-
13
- The above copyright notice and this permission notice shall be
14
- included in all copies or substantial portions of the Software.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
- MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
- LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
- OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
- WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -1,24 +0,0 @@
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
- }