decap-cms-backend-github 3.6.0 → 3.8.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/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;