Skip to content

Commit 922d685

Browse files
committed
Release v1.4.2
1 parent 1688bf3 commit 922d685

55 files changed

Lines changed: 5458 additions & 3222 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

main.js

Lines changed: 3507 additions & 2678 deletions
Large diffs are not rendered by default.

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
"description": "Add semantic structure to your notes with the Discourse Graph protocol.",
77
"author": "Discourse Graphs",
88
"authorUrl": "https://discoursegraphs.com",
9-
"isDesktopOnly": false
9+
"isDesktopOnly": true
1010
}

scripts/publish.ts

Lines changed: 209 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from "fs";
33
import path from "path";
44
import { exec } from "child_process";
55
import 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

5155
const TARGET_REPO = "DiscourseGraphs/discourse-graph-obsidian";
5256
const 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+
59153
const 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

Comments
 (0)