fix(github): auto-retry GitHub calls on 401 and expose unauthorized state
fetchLatestSha and queryFileContent were silently catching every error (including expired-token 401s) and returning null, so the freshness badge could stay on "offline" with no UI hint that re-auth was needed. Wrap both calls in runWithAuthRetry to recover from refreshable tokens transparently, and switch fetchLatestSha to a tagged result so the freshness hook can distinguish auth failure from network failure.
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
import { getOctokit } from "@/modules/repo/services/octo"
|
||||
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
|
||||
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
||||
import { confirmMessage, errorMessage } from "@/utils/notif"
|
||||
|
||||
const isConflictStatus = (status: number) => status === 409 || status === 422
|
||||
const isUnauthorizedStatus = (status: number | undefined) => status === 401
|
||||
|
||||
export type FetchShaResult =
|
||||
| { kind: "ok"; sha: string | null }
|
||||
| { kind: "unauthorized" }
|
||||
| { kind: "offline" }
|
||||
|
||||
export const useGitHubContent = ({
|
||||
user,
|
||||
@@ -11,23 +17,23 @@ export const useGitHubContent = ({
|
||||
user: string
|
||||
repo: string
|
||||
}) => {
|
||||
const fetchLatestSha = async (path: string): Promise<string | null> => {
|
||||
const fetchLatestSha = async (path: string): Promise<FetchShaResult> => {
|
||||
try {
|
||||
const octokit = await getOctokit()
|
||||
const response = await octokit.request(
|
||||
"GET /repos/{owner}/{repo}/contents/{+path}",
|
||||
{
|
||||
const response = await runWithAuthRetry((octokit) =>
|
||||
octokit.request("GET /repos/{owner}/{repo}/contents/{+path}", {
|
||||
owner: user,
|
||||
repo,
|
||||
path,
|
||||
headers: { "X-GitHub-Api-Version": "2026-03-10" }
|
||||
}
|
||||
})
|
||||
)
|
||||
const data = response?.data
|
||||
if (Array.isArray(data) || !data) return null
|
||||
return "sha" in data ? data.sha : null
|
||||
} catch {
|
||||
return null
|
||||
if (Array.isArray(data) || !data) return { kind: "ok", sha: null }
|
||||
return { kind: "ok", sha: "sha" in data ? data.sha : null }
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number })?.status
|
||||
if (isUnauthorizedStatus(status)) return { kind: "unauthorized" }
|
||||
return { kind: "offline" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export type FreshnessStatus =
|
||||
| "verified"
|
||||
| "outdated"
|
||||
| "offline"
|
||||
| "unauthorized"
|
||||
|
||||
const MIN_SPINNER_MS = 400
|
||||
|
||||
@@ -43,14 +44,16 @@ export const useNoteFreshness = ({
|
||||
const startedAt = performance.now()
|
||||
|
||||
let next: FreshnessStatus
|
||||
const remoteSha = await fetchLatestSha(path.value)
|
||||
if (remoteSha === null) {
|
||||
const result = await fetchLatestSha(path.value)
|
||||
if (result.kind === "unauthorized") {
|
||||
next = "unauthorized"
|
||||
} else if (result.kind === "offline" || result.sha === null) {
|
||||
next = "offline"
|
||||
} else {
|
||||
latestSha.value = remoteSha
|
||||
latestSha.value = result.sha
|
||||
lastCheckedAt.value = new Date()
|
||||
const local = await expectedSha()
|
||||
next = remoteSha === local ? "verified" : "outdated"
|
||||
next = result.sha === local ? "verified" : "outdated"
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - startedAt
|
||||
@@ -60,13 +63,27 @@ export const useNoteFreshness = ({
|
||||
status.value = next
|
||||
}
|
||||
|
||||
const resolveRemoteSha = async (
|
||||
path: string
|
||||
): Promise<{ sha: string | null; failureStatus: FreshnessStatus | null }> => {
|
||||
if (latestSha.value) return { sha: latestSha.value, failureStatus: null }
|
||||
const result = await fetchLatestSha(path)
|
||||
if (result.kind === "unauthorized") {
|
||||
return { sha: null, failureStatus: "unauthorized" }
|
||||
}
|
||||
if (result.kind === "offline" || result.sha === null) {
|
||||
return { sha: null, failureStatus: "offline" }
|
||||
}
|
||||
return { sha: result.sha, failureStatus: null }
|
||||
}
|
||||
|
||||
const pullLatest = async (): Promise<string | null> => {
|
||||
if (!path.value) return null
|
||||
const usedCachedSha = latestSha.value !== null
|
||||
const remoteSha = latestSha.value ?? (await fetchLatestSha(path.value))
|
||||
const { sha: remoteSha, failureStatus } = await resolveRemoteSha(path.value)
|
||||
if (!remoteSha) {
|
||||
console.warn("pullLatest: could not resolve remote sha", { path: path.value })
|
||||
status.value = "offline"
|
||||
if (failureStatus) status.value = failureStatus
|
||||
return null
|
||||
}
|
||||
const fileContent = await queryFileContent(user, repo, remoteSha)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
||||
import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
|
||||
import { UserSettings } from "@/modules/repo/interfaces/UserSettings"
|
||||
import { getOctokit } from "@/modules/repo/services/octo"
|
||||
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
|
||||
|
||||
export const getFiles = async (
|
||||
owner: string,
|
||||
@@ -131,14 +131,12 @@ export const queryFileContent = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const octokit = await getOctokit()
|
||||
const file = await octokit.request(
|
||||
"GET /repos/{owner}/{repo}/git/blobs/{file_sha}",
|
||||
{
|
||||
const file = await runWithAuthRetry((octokit) =>
|
||||
octokit.request("GET /repos/{owner}/{repo}/git/blobs/{file_sha}", {
|
||||
owner: user,
|
||||
repo: repo,
|
||||
file_sha: sha
|
||||
}
|
||||
})
|
||||
)
|
||||
return file?.data.content ?? null
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user