@@ -3,6 +3,7 @@ import fs from "fs";
33import path from "path" ;
44import { exec } from "child_process" ;
55import util from "util" ;
6+ import crypto from "crypto" ;
67// https://linear.app/discourse-graphs/issue/ENG-766/upgrade-all-commonjs-to-esm
78// TODO if possible: change apps/obsidian to ESM. Use require until then.
89// import { Octokit } from "@octokit/core";
@@ -47,6 +48,9 @@ const REQUIRED_BUILD_FILES = [
4748 "manifest.json" ,
4849 "styles.css" ,
4950] as const ;
51+ const BLOB_UPLOAD_BATCH_SIZE = 10 ;
52+ const MAX_GITHUB_RETRIES = 5 ;
53+ const BASE_RETRY_DELAY_MS = 2_000 ;
5054
5155const TARGET_REPO = "DiscourseGraphs/discourse-graph-obsidian" ;
5256const OWNER = "DiscourseGraphs" ;
@@ -56,6 +60,96 @@ const log = (message: string): void => {
5660 console . log ( `[Obsidian Publisher] ${ message } ` ) ;
5761} ;
5862
63+ const sleep = async ( ms : number ) : Promise < void > =>
64+ new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
65+
66+ const isSecondaryRateLimitError = ( error : unknown ) : boolean => {
67+ const maybeError = error as {
68+ status ?: number ;
69+ response ?: { data ?: { message ?: string } } ;
70+ message ?: string ;
71+ } ;
72+ const message =
73+ maybeError ?. response ?. data ?. message ?. toLowerCase ( ) ??
74+ maybeError ?. message ?. toLowerCase ( ) ??
75+ "" ;
76+ return maybeError ?. status === 403 && message . includes ( "secondary rate limit" ) ;
77+ } ;
78+
79+ const getRetryDelayMs = ( error : unknown , attempt : number ) : number => {
80+ const maybeError = error as {
81+ response ?: { headers ?: Record < string , string | undefined > } ;
82+ } ;
83+ const retryAfterHeader = maybeError ?. response ?. headers ?. [ "retry-after" ] ;
84+ const retryAfterSeconds = Number ( retryAfterHeader ) ;
85+ if ( Number . isFinite ( retryAfterSeconds ) && retryAfterSeconds > 0 ) {
86+ return retryAfterSeconds * 1000 ;
87+ }
88+ return BASE_RETRY_DELAY_MS * 2 ** attempt ;
89+ } ;
90+
91+ const requestWithRetry = async < T = unknown > (
92+ request : ( ) => Promise < T > ,
93+ context : string ,
94+ ) : Promise < T > => {
95+ let attempt = 0 ;
96+
97+ while ( true ) {
98+ try {
99+ return await request ( ) ;
100+ } catch ( error ) {
101+ if ( ! isSecondaryRateLimitError ( error ) || attempt >= MAX_GITHUB_RETRIES ) {
102+ throw error ;
103+ }
104+
105+ const delayMs = getRetryDelayMs ( error , attempt ) ;
106+ log (
107+ `Secondary rate limit hit during ${ context } . Retrying in ${ Math . ceil ( delayMs / 1000 ) } s (attempt ${ attempt + 1 } /${ MAX_GITHUB_RETRIES } )...` ,
108+ ) ;
109+ await sleep ( delayMs ) ;
110+ attempt += 1 ;
111+ }
112+ }
113+ } ;
114+
115+ const getAllFiles = ( dir : string , baseDir : string = dir ) : string [ ] => {
116+ const files : string [ ] = [ ] ;
117+
118+ fs . readdirSync ( dir , { withFileTypes : true } ) . forEach ( ( entry ) => {
119+ const fullPath = path . join ( dir , entry . name ) ;
120+ const relativePath = path . relative ( baseDir , fullPath ) ;
121+
122+ if ( shouldExclude ( fullPath , baseDir ) ) {
123+ log ( `Excluding: ${ relativePath } ` ) ;
124+ return ;
125+ }
126+
127+ if ( entry . isDirectory ( ) ) {
128+ files . push ( ...getAllFiles ( fullPath , baseDir ) ) ;
129+ } else {
130+ files . push ( relativePath ) ;
131+ }
132+ } ) ;
133+
134+ return files ;
135+ } ;
136+
137+ const getGitBlobSha = ( content : Buffer ) : string => {
138+ const header = Buffer . from ( `blob ${ content . length } \0` , "utf8" ) ;
139+ return crypto
140+ . createHash ( "sha1" )
141+ . update ( Buffer . concat ( [ header , content ] ) )
142+ . digest ( "hex" ) ;
143+ } ;
144+
145+ const chunk = < T > ( items : T [ ] , size : number ) : T [ ] [ ] => {
146+ const chunks : T [ ] [ ] = [ ] ;
147+ for ( let i = 0 ; i < items . length ; i += size ) {
148+ chunks . push ( items . slice ( i , i + size ) ) ;
149+ }
150+ return chunks ;
151+ } ;
152+
59153const getEnvVar = ( name : string ) : string => {
60154 const value = process . env [ name ] ;
61155 if ( ! value ) {
@@ -306,125 +400,158 @@ const updateMainBranch = async (
306400 const repo = REPO ;
307401
308402 try {
309- const { data : ref } = await octokit . request (
310- "GET /repos/{owner}/{repo}/git/refs/{ref}" ,
311- {
312- owner,
313- repo,
314- ref : "heads/main" ,
315- } ,
403+ const { data : ref } = await requestWithRetry < any > (
404+ ( ) =>
405+ octokit . request ( "GET /repos/{owner}/{repo}/git/refs/{ref}" , {
406+ owner,
407+ repo,
408+ ref : "heads/main" ,
409+ } ) ,
410+ "fetching main branch ref" ,
316411 ) ;
317412
318413 if ( ! ref ?. object ?. sha ) {
319414 throw new Error ( "Failed to get main branch reference" ) ;
320415 }
321416 const currentSha = ref . object . sha ;
322417
323- const { data : currentCommit } = await octokit . request (
324- "GET /repos/{owner}/{repo}/git/commits/{commit_sha}" ,
325- {
326- owner,
327- repo,
328- commit_sha : currentSha ,
329- } ,
418+ const { data : currentCommit } = await requestWithRetry < any > (
419+ ( ) =>
420+ octokit . request ( "GET /repos/{owner}/{repo}/git/commits/{commit_sha}" , {
421+ owner,
422+ repo,
423+ commit_sha : currentSha ,
424+ } ) ,
425+ "fetching current main commit" ,
330426 ) ;
331427
332428 if ( ! currentCommit ?. tree ?. sha ) {
333429 throw new Error ( "Failed to get current commit tree" ) ;
334430 }
335431 const currentTreeSha = currentCommit . tree . sha ;
432+ const { data : existingTree } = await requestWithRetry < any > (
433+ ( ) =>
434+ octokit . request ( "GET /repos/{owner}/{repo}/git/trees/{tree_sha}" , {
435+ owner,
436+ repo,
437+ tree_sha : currentTreeSha ,
438+ recursive : "1" ,
439+ } ) ,
440+ "fetching recursive main tree" ,
441+ ) ;
336442
337- const getAllFiles = ( dir : string , baseDir : string = dir ) : string [ ] => {
338- const files : string [ ] = [ ] ;
339-
340- fs . readdirSync ( dir , { withFileTypes : true } ) . forEach ( ( entry ) => {
341- const fullPath = path . join ( dir , entry . name ) ;
342- const relativePath = path . relative ( baseDir , fullPath ) ;
343-
344- if ( shouldExclude ( fullPath , baseDir ) ) {
345- log ( `Excluding: ${ relativePath } ` ) ;
346- return ;
347- }
348-
349- if ( entry . isDirectory ( ) ) {
350- files . push ( ...getAllFiles ( fullPath , baseDir ) ) ;
351- } else {
352- files . push ( relativePath ) ;
353- }
354- } ) ;
355-
356- return files ;
357- } ;
443+ const existingBlobShasByPath = new Map (
444+ ( existingTree . tree ?? [ ] )
445+ . filter (
446+ ( entry : any ) : entry is { path : string ; sha : string ; type : string } =>
447+ Boolean ( entry . path && entry . sha && entry . type === "blob" ) ,
448+ )
449+ . map ( ( entry : { path : string ; sha : string } ) => [ entry . path , entry . sha ] ) ,
450+ ) ;
358451
359452 const allFiles = getAllFiles ( tempDir ) ;
360453 log ( `Found ${ allFiles . length } files to update` ) ;
361-
362- const blobPromises = allFiles . map ( async ( filePath ) => {
454+ const filesToUpdate = allFiles . filter ( ( filePath ) => {
363455 const fullPath = path . join ( tempDir , filePath ) ;
364456 const content = fs . readFileSync ( fullPath ) ;
457+ const normalizedPath = filePath . replace ( / \\ / g, "/" ) ;
458+ const existingSha = existingBlobShasByPath . get ( normalizedPath ) ;
459+ return getGitBlobSha ( content ) !== existingSha ;
460+ } ) ;
365461
366- const { data : blob } = await octokit . request (
367- "POST /repos/{owner}/{repo}/git/blobs" ,
368- {
369- owner,
370- repo,
371- content : content . toString ( "base64" ) ,
372- encoding : "base64" ,
373- } ,
462+ log (
463+ `Detected ${ filesToUpdate . length } changed files (${ allFiles . length - filesToUpdate . length } unchanged skipped)` ,
464+ ) ;
465+
466+ if ( filesToUpdate . length === 0 ) {
467+ log ( "No changes detected on main branch; skipping commit update" ) ;
468+ return ;
469+ }
470+
471+ const blobBatchChunks = chunk ( filesToUpdate , BLOB_UPLOAD_BATCH_SIZE ) ;
472+ const blobs : Array < { path : string ; sha : string } > = [ ] ;
473+
474+ for ( const [ batchIndex , blobBatch ] of blobBatchChunks . entries ( ) ) {
475+ log (
476+ `Uploading blob batch ${ batchIndex + 1 } /${ blobBatchChunks . length } (${ blobBatch . length } files)...` ,
374477 ) ;
375478
376- if ( ! blob ?. sha ) {
377- throw new Error ( `Failed to create blob for ${ filePath } ` ) ;
378- }
479+ const batchBlobs = await Promise . all (
480+ blobBatch . map ( async ( filePath ) => {
481+ const fullPath = path . join ( tempDir , filePath ) ;
482+ const content = fs . readFileSync ( fullPath ) ;
483+
484+ const { data : blob } = await requestWithRetry < any > (
485+ ( ) =>
486+ octokit . request ( "POST /repos/{owner}/{repo}/git/blobs" , {
487+ owner,
488+ repo,
489+ content : content . toString ( "base64" ) ,
490+ encoding : "base64" ,
491+ } ) ,
492+ `creating blob for ${ filePath } ` ,
493+ ) ;
379494
380- return {
381- path : filePath . replace ( / \\ / g, "/" ) , // Normalize path separators for GitHub
382- sha : blob . sha ,
383- } ;
384- } ) ;
495+ if ( ! blob ?. sha ) {
496+ throw new Error ( `Failed to create blob for ${ filePath } ` ) ;
497+ }
385498
386- const blobs = await Promise . all ( blobPromises ) ;
499+ return {
500+ path : filePath . replace ( / \\ / g, "/" ) , // Normalize path separators for GitHub
501+ sha : blob . sha ,
502+ } ;
503+ } ) ,
504+ ) ;
387505
388- const { data : newTree } = await octokit . request (
389- "POST /repos/{owner}/{repo}/git/trees" ,
390- {
391- owner,
392- repo,
393- base_tree : currentTreeSha ,
394- tree : blobs . map ( ( blob ) => ( {
395- path : blob . path ,
396- mode : "100644" as const ,
397- type : "blob" as const ,
398- sha : blob . sha ,
399- } ) ) ,
400- } ,
506+ blobs . push ( ...batchBlobs ) ;
507+ }
508+
509+ const { data : newTree } = await requestWithRetry < any > (
510+ ( ) =>
511+ octokit . request ( "POST /repos/{owner}/{repo}/git/trees" , {
512+ owner,
513+ repo,
514+ base_tree : currentTreeSha ,
515+ tree : blobs . map ( ( blob ) => ( {
516+ path : blob . path ,
517+ mode : "100644" as const ,
518+ type : "blob" as const ,
519+ sha : blob . sha ,
520+ } ) ) ,
521+ } ) ,
522+ "creating updated git tree" ,
401523 ) ;
402524
403525 if ( ! newTree ?. sha ) {
404526 throw new Error ( "Failed to create new tree" ) ;
405527 }
406528
407- const { data : newCommit } = await octokit . request (
408- "POST /repos/{owner}/{repo}/git/commits" ,
409- {
410- owner,
411- repo,
412- message : `Release v${ version } ` ,
413- tree : newTree . sha ,
414- parents : [ currentSha ] ,
415- } ,
529+ const { data : newCommit } = await requestWithRetry < any > (
530+ ( ) =>
531+ octokit . request ( "POST /repos/{owner}/{repo}/git/commits" , {
532+ owner,
533+ repo,
534+ message : `Release v${ version } ` ,
535+ tree : newTree . sha ,
536+ parents : [ currentSha ] ,
537+ } ) ,
538+ "creating release commit" ,
416539 ) ;
417540
418541 if ( ! newCommit ?. sha ) {
419542 throw new Error ( "Failed to create new commit" ) ;
420543 }
421544
422- await octokit . request ( "PATCH /repos/{owner}/{repo}/git/refs/{ref}" , {
423- owner,
424- repo,
425- ref : "heads/main" ,
426- sha : newCommit . sha ,
427- } ) ;
545+ await requestWithRetry (
546+ ( ) =>
547+ octokit . request ( "PATCH /repos/{owner}/{repo}/git/refs/{ref}" , {
548+ owner,
549+ repo,
550+ ref : "heads/main" ,
551+ sha : newCommit . sha ,
552+ } ) ,
553+ "updating main branch reference" ,
554+ ) ;
428555
429556 log ( `Successfully updated main branch with commit: ${ newCommit . sha } ` ) ;
430557 log ( `Updated ${ blobs . length } files` ) ;
0 commit comments