decap-cms-backend-github 3.9.0-beta.0 → 3.9.0-beta.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.
@@ -19,11 +19,12 @@ import {
19
19
  contentKeyFromBranch,
20
20
  unsentRequest,
21
21
  branchFromContentKey,
22
+ NotesPollingManager,
23
+ markOwnNotes,
22
24
  } from 'decap-cms-lib-util';
23
25
 
24
26
  import AuthenticationPage from './AuthenticationPage';
25
27
  import API, { API_NAME } from './API';
26
- import { ETagPollingManager } from './polling';
27
28
  import GraphQLAPI from './GraphQLAPI';
28
29
 
29
30
  import type { Endpoints } from '@octokit/types';
@@ -92,7 +93,7 @@ export default class GitHub implements Implementation {
92
93
  [key: string]: Promise<boolean>;
93
94
  };
94
95
  _mediaDisplayURLSem?: Semaphore;
95
- pollingManager?: ETagPollingManager;
96
+ pollingManager?: NotesPollingManager;
96
97
  unwatchFunctions: Map<string, () => void> = new Map();
97
98
 
98
99
  constructor(config: Config, options = {}) {
@@ -386,7 +387,7 @@ export default class GitHub implements Implementation {
386
387
  // }
387
388
 
388
389
  if (this.api && !this.pollingManager) {
389
- this.pollingManager = new ETagPollingManager(this.api, 15000);
390
+ this.pollingManager = new NotesPollingManager(this.api, 15000);
390
391
  }
391
392
  // Authorized user
392
393
  return { ...user, token: state.token as string, useOpenAuthoring: this.useOpenAuthoring };
@@ -751,24 +752,55 @@ export default class GitHub implements Implementation {
751
752
 
752
753
  // Notes implementation, which is an abstraction to Github's PR issue comments.
753
754
 
755
+ /**
756
+ * Who the signed-in editor is, as a note records them: a display name for the
757
+ * pane and a stable id for the ownership check behind Edit/Resolve/Delete.
758
+ */
759
+ async noteAuthorIdentity(): Promise<{ author: string; authorId?: string }> {
760
+ const currentUser = await this.currentUser({ token: this.token! });
761
+ return {
762
+ author: currentUser.login || currentUser.name || '',
763
+ // No id on purpose: GitHub reports the author's current login on every
764
+ // read, so ownership follows a rename. Recording it here would freeze it.
765
+ authorId: undefined,
766
+ };
767
+ }
768
+
769
+ /**
770
+ * Resolves each note's `isOwn` here rather than in the pane, because only the
771
+ * backend knows how its identities compare. Falls back to the display name
772
+ * for notes with no recorded id.
773
+ */
774
+ private async markOwnNotes(notes: Note[]): Promise<Note[]> {
775
+ return markOwnNotes(notes, await this.noteAuthorIdentity());
776
+ }
777
+
754
778
  // Notes implementation using GitHub Issues
755
779
  async getNotes(collection: string, slug: string): Promise<Note[]> {
756
780
  try {
757
781
  const notes = await this.api!.getEntryNotes(collection, slug);
758
- return notes.map(note => ({ ...note, entrySlug: slug }));
782
+ return this.markOwnNotes(notes.map(note => ({ ...note, entrySlug: slug })));
759
783
  } catch (error) {
760
784
  console.error('Failed to get notes:', error);
761
785
  return [];
762
786
  }
763
787
  }
764
788
 
765
- async addNote(collection: string, slug: string, noteData: Omit<Note, 'id'>): Promise<Note> {
789
+ async addNote(
790
+ collection: string,
791
+ slug: string,
792
+ noteData: Omit<Note, 'id'>,
793
+ entryTitle?: string,
794
+ ): Promise<Note> {
766
795
  const currentUser = await this.currentUser({ token: this.token! });
796
+ const identity = await this.noteAuthorIdentity();
767
797
 
768
798
  const note: Note = {
769
799
  ...noteData,
770
800
  id: 'temp-' + Date.now(),
771
- author: currentUser.login || currentUser.name || '',
801
+ author: identity.author,
802
+ authorId: identity.authorId,
803
+ isOwn: true,
772
804
  avatarUrl: currentUser.avatar_url,
773
805
  entrySlug: slug,
774
806
  timestamp: noteData.timestamp || new Date().toISOString(),
@@ -776,16 +808,6 @@ export default class GitHub implements Implementation {
776
808
  issueUrl: undefined,
777
809
  };
778
810
 
779
- // Get entry title for better issue naming
780
- let entryTitle: string | undefined;
781
- try {
782
- const entryData = await this.getEntry(`${collection}/${slug}.md`);
783
- const titleMatch = entryData.data.match(/^title:\s*["']?([^"'\n]+)["']?/m);
784
- entryTitle = titleMatch ? titleMatch[1] : undefined;
785
- } catch (error) {
786
- // Entry not found or error reading, use undefined title
787
- }
788
-
789
811
  const { commentId, issueUrl } = await this.api!.addNoteToEntry(
790
812
  collection,
791
813
  slug,
@@ -883,7 +905,14 @@ export default class GitHub implements Implementation {
883
905
  const unwatchFn = await this.pollingManager.watchIssueWithRetry(
884
906
  collection,
885
907
  slug,
886
- callbacks,
908
+ {
909
+ ...callbacks,
910
+ // The polling manager rebuilds notes straight from the issue's
911
+ // comments, so they arrive without the ownership flag getNotes adds.
912
+ // Without this, a poll silently strips Edit/Resolve/Delete off the
913
+ // editor's own notes ~15s after they appear.
914
+ prepareNotes: notes => this.markOwnNotes(notes),
915
+ },
887
916
  5, // maxRetries - will try up to 5 times
888
917
  2000, // retryDelay - 2 seconds between attempts
889
918
  );
@@ -908,9 +937,9 @@ export default class GitHub implements Implementation {
908
937
  if (unwatchFn) {
909
938
  unwatchFn();
910
939
  this.unwatchFunctions.delete(issueKey);
911
- } else {
912
- console.log(`[DecapNotes Polling] No active polling found for ${issueKey}`);
913
940
  }
941
+
942
+ this.pollingManager?.stopWatching(collection, slug);
914
943
  }
915
944
 
916
945
  /**
@@ -1,391 +0,0 @@
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;