mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-31 06:31:58 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0c39b697f | ||
|
|
03fce1e299 | ||
|
|
7fc53cfad8 | ||
|
|
b33e7d5596 | ||
|
|
2f6af22e25 | ||
|
|
185946984b | ||
|
|
bcc9cf0120 | ||
|
|
2b455354f9 |
No files matched your search
@@ -251,6 +251,21 @@ jobs:
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# GitHub rejects SARIF files where a single result carries more than
|
||||
# 1000 relatedLocations, which Scout produces for widely-referenced OS
|
||||
# packages — every upload had been silently failing on this since May
|
||||
# ("rejecting SARIF, as there are more related locations per result than
|
||||
# allowed"). Cap them so uploads flow and stale alerts can auto-close.
|
||||
- name: Sanitize Scout SARIF (cap relatedLocations)
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if [ -f scout-results.sarif ]; then
|
||||
jq '(.runs[]?.results[]?) |= (if .relatedLocations then .relatedLocations |= .[:100] else . end)' \
|
||||
scout-results.sarif > scout-results.sanitized.sarif
|
||||
mv scout-results.sanitized.sarif scout-results.sarif
|
||||
fi
|
||||
|
||||
# Upload security scan results to GitHub Security tab
|
||||
- name: Upload Docker Scout scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
|
||||
+2
-1
@@ -32,7 +32,7 @@ FROM debian:trixie-slim AS git-lfs-builder
|
||||
RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
|
||||
wget ca-certificates git make \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ARG GO_VERSION=1.25.9
|
||||
ARG GO_VERSION=1.25.12
|
||||
ARG GIT_LFS_VERSION=3.7.1
|
||||
RUN ARCH="$(dpkg --print-architecture)" \
|
||||
&& wget -qO /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" \
|
||||
@@ -44,6 +44,7 @@ ENV GOTOOLCHAIN=local
|
||||
RUN git clone --branch "v${GIT_LFS_VERSION}" --depth 1 https://github.com/git-lfs/git-lfs.git /tmp/git-lfs \
|
||||
&& cd /tmp/git-lfs \
|
||||
&& go get golang.org/x/crypto@latest \
|
||||
&& go get golang.org/x/net@latest \
|
||||
&& go mod tidy \
|
||||
&& make \
|
||||
&& install -m 755 /tmp/git-lfs/bin/git-lfs /usr/local/bin/git-lfs
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.25.0",
|
||||
"version": "3.26.1",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { InlineDestinationEditor } from "./InlineDestinationEditor";
|
||||
import { MarqueeText } from "@/components/ui/marquee-text";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
@@ -755,25 +756,25 @@ export default function RepositoryTable({
|
||||
</div>
|
||||
|
||||
{/* Repository */}
|
||||
<div className="h-full py-3 flex items-center gap-2 flex-[2.3]">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{repo.name}
|
||||
<div className="h-full py-3 flex items-center gap-2 flex-[2.3] min-w-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium flex items-center gap-1 min-w-0">
|
||||
<MarqueeText>{repo.name}</MarqueeText>
|
||||
{repo.isStarred && (
|
||||
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" />
|
||||
<Star className="h-3 w-3 shrink-0 fill-yellow-500 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<MarqueeText className="text-xs text-muted-foreground">
|
||||
{repo.fullName}
|
||||
</div>
|
||||
</MarqueeText>
|
||||
</div>
|
||||
{repo.isPrivate && (
|
||||
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
<span className="ml-2 shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
{repo.isForked && (
|
||||
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
<span className="ml-2 shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||
Fork
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarqueeTextProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
/** Scroll speed in px/s while hovered */
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-line text that truncates with an ellipsis and, when the content
|
||||
* overflows, scrolls the hidden portion into view on hover (sliding back on
|
||||
* mouse leave). Animates text-indent rather than transform so the content
|
||||
* stays inline and native ellipsis rendering keeps working at rest.
|
||||
*/
|
||||
export function MarqueeText({ children, className, speed = 60 }: MarqueeTextProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [shift, setShift] = useState(0);
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const [settled, setSettled] = useState(true);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
// Measured on every hover so virtualized rows and resizes stay accurate
|
||||
const overflow = Math.max(0, el.scrollWidth - el.clientWidth);
|
||||
setShift(overflow);
|
||||
setHovering(true);
|
||||
if (overflow > 0) setSettled(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => setHovering(false), []);
|
||||
|
||||
const scrolling = hovering && shift > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTransitionEnd={() => {
|
||||
if (!hovering) setSettled(true);
|
||||
}}
|
||||
className={cn("min-w-0 overflow-hidden whitespace-nowrap", className)}
|
||||
style={{
|
||||
textOverflow: settled && !scrolling ? "ellipsis" : "clip",
|
||||
textIndent: scrolling ? -shift : 0,
|
||||
transition: scrolling
|
||||
? `text-indent ${shift / speed}s linear 0.3s`
|
||||
: "text-indent 0.3s ease-out",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import {
|
||||
applyConditionalRequests,
|
||||
conditionalRequestCacheKey,
|
||||
conditionalRequestTokenScope,
|
||||
InMemoryConditionalRequestStore,
|
||||
} from "@/lib/github-conditional-requests";
|
||||
|
||||
function jsonResponse(
|
||||
body: unknown,
|
||||
init: { status: number; etag?: string; link?: string },
|
||||
): Response {
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
if (init.etag) headers.etag = init.etag;
|
||||
if (init.link) headers.link = init.link;
|
||||
return new Response(init.status === 304 ? null : JSON.stringify(body), {
|
||||
status: init.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function clientWithFetch(fetch: (url: string, init: any) => Promise<Response>) {
|
||||
return new Octokit({
|
||||
auth: "test-token",
|
||||
request: { fetch: fetch as unknown as typeof globalThis.fetch },
|
||||
});
|
||||
}
|
||||
|
||||
describe("applyConditionalRequests", () => {
|
||||
test("replays If-None-Match and serves the cached body on 304", async () => {
|
||||
const etag = 'W/"abc123"';
|
||||
const pulls = [{ number: 1, title: "one" }];
|
||||
let calls = 0;
|
||||
let secondRequestIfNoneMatch: string | undefined;
|
||||
|
||||
const octokit = clientWithFetch(async (_url, init) => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return jsonResponse(pulls, { status: 200, etag });
|
||||
}
|
||||
secondRequestIfNoneMatch = init?.headers?.["if-none-match"];
|
||||
return jsonResponse(null, { status: 304, etag });
|
||||
});
|
||||
applyConditionalRequests(octokit, {
|
||||
store: new InMemoryConditionalRequestStore(),
|
||||
scope: "user-1",
|
||||
});
|
||||
|
||||
const first = await octokit.request("GET /repos/{owner}/{repo}/pulls", {
|
||||
owner: "o",
|
||||
repo: "r",
|
||||
state: "all",
|
||||
});
|
||||
const second = await octokit.request("GET /repos/{owner}/{repo}/pulls", {
|
||||
owner: "o",
|
||||
repo: "r",
|
||||
state: "all",
|
||||
});
|
||||
|
||||
expect(calls).toBe(2);
|
||||
expect(secondRequestIfNoneMatch).toBe(etag);
|
||||
// A 304 is transparently presented as a 200 carrying the cached body.
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.data).toEqual(first.data);
|
||||
expect(second.data).toEqual(pulls);
|
||||
});
|
||||
|
||||
test("does not attach conditional headers to non-GET requests", async () => {
|
||||
let sawIfNoneMatch = false;
|
||||
const octokit = clientWithFetch(async (_url, init) => {
|
||||
if (init?.headers?.["if-none-match"]) sawIfNoneMatch = true;
|
||||
return jsonResponse({ ok: true }, { status: 201 });
|
||||
});
|
||||
applyConditionalRequests(octokit, {
|
||||
store: new InMemoryConditionalRequestStore(),
|
||||
scope: "user-1",
|
||||
});
|
||||
|
||||
await octokit.request("POST /repos/{owner}/{repo}/pulls", {
|
||||
owner: "o",
|
||||
repo: "r",
|
||||
title: "x",
|
||||
head: "a",
|
||||
base: "b",
|
||||
});
|
||||
|
||||
expect(sawIfNoneMatch).toBe(false);
|
||||
});
|
||||
|
||||
test("makes a full request again when the response carries no ETag", async () => {
|
||||
let calls = 0;
|
||||
let secondRequestIfNoneMatch: string | undefined;
|
||||
const octokit = clientWithFetch(async (_url, init) => {
|
||||
calls += 1;
|
||||
if (calls >= 2) secondRequestIfNoneMatch = init?.headers?.["if-none-match"];
|
||||
return jsonResponse([{ number: 1 }], { status: 200 }); // no etag
|
||||
});
|
||||
applyConditionalRequests(octokit, {
|
||||
store: new InMemoryConditionalRequestStore(),
|
||||
scope: "user-1",
|
||||
});
|
||||
|
||||
await octokit.request("GET /repos/{owner}/{repo}/pulls", {
|
||||
owner: "o",
|
||||
repo: "r",
|
||||
});
|
||||
const second = await octokit.request("GET /repos/{owner}/{repo}/pulls", {
|
||||
owner: "o",
|
||||
repo: "r",
|
||||
});
|
||||
|
||||
expect(calls).toBe(2);
|
||||
expect(secondRequestIfNoneMatch).toBeUndefined();
|
||||
expect(second.status).toBe(200);
|
||||
});
|
||||
|
||||
test("keys cache entries per expanded URL so repos do not collide", async () => {
|
||||
// Each resource gets its own ETag; the stub returns 304 only when the
|
||||
// presented If-None-Match matches the ETag issued for that exact URL.
|
||||
const etagByUrl = new Map<string, string>();
|
||||
let notModifiedCount = 0;
|
||||
|
||||
const octokit = clientWithFetch(async (url, init) => {
|
||||
const ifNoneMatch = init?.headers?.["if-none-match"] as
|
||||
| string
|
||||
| undefined;
|
||||
let etag = etagByUrl.get(url);
|
||||
if (!etag) {
|
||||
etag = `W/"etag-${etagByUrl.size + 1}"`;
|
||||
etagByUrl.set(url, etag);
|
||||
}
|
||||
if (ifNoneMatch && ifNoneMatch === etag) {
|
||||
notModifiedCount += 1;
|
||||
return jsonResponse(null, { status: 304, etag });
|
||||
}
|
||||
return jsonResponse([{ url }], { status: 200, etag });
|
||||
});
|
||||
applyConditionalRequests(octokit, {
|
||||
store: new InMemoryConditionalRequestStore(),
|
||||
scope: "user-1",
|
||||
});
|
||||
|
||||
const get = (owner: string, repo: string) =>
|
||||
octokit.request("GET /repos/{owner}/{repo}/pulls", {
|
||||
owner,
|
||||
repo,
|
||||
state: "all",
|
||||
});
|
||||
|
||||
const first = await get("alpha", "one");
|
||||
await get("beta", "two");
|
||||
const third = await get("alpha", "one");
|
||||
|
||||
// The repeated first request must revalidate against its OWN ETag and come
|
||||
// back as a 304 cache hit, not be clobbered by the second repo's entry.
|
||||
expect(notModifiedCount).toBe(1);
|
||||
expect(third.status).toBe(200);
|
||||
expect(third.data).toEqual(first.data);
|
||||
});
|
||||
|
||||
test("isolates cached entries by scope", () => {
|
||||
const store = new InMemoryConditionalRequestStore();
|
||||
store.set(conditionalRequestCacheKey("user-1", "GET", "/x"), {
|
||||
etag: "a",
|
||||
data: 1,
|
||||
status: 200,
|
||||
});
|
||||
expect(
|
||||
store.get(conditionalRequestCacheKey("user-2", "GET", "/x")),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
store.get(conditionalRequestCacheKey("user-1", "GET", "/x"))?.etag,
|
||||
).toBe("a");
|
||||
});
|
||||
|
||||
test("evicts oldest entries once the byte budget is exceeded", () => {
|
||||
// Each entry is ~126 bytes (120-char body + quotes + etag), budget of 300
|
||||
// fits two: inserting the third must evict the oldest.
|
||||
const store = new InMemoryConditionalRequestStore(5000, 300);
|
||||
const entry = (etag: string) => ({
|
||||
etag,
|
||||
data: "x".repeat(120),
|
||||
status: 200,
|
||||
});
|
||||
store.set("k1", entry("e1"));
|
||||
store.set("k2", entry("e2"));
|
||||
store.set("k3", entry("e3"));
|
||||
expect(store.get("k1")).toBeUndefined();
|
||||
expect(store.get("k2")?.etag).toBe("e2");
|
||||
expect(store.get("k3")?.etag).toBe("e3");
|
||||
});
|
||||
|
||||
test("does not cache a single response larger than the byte budget", () => {
|
||||
const store = new InMemoryConditionalRequestStore(5000, 100);
|
||||
store.set("small", { etag: "e", data: "ok", status: 200 });
|
||||
store.set("huge", { etag: "e", data: "x".repeat(500), status: 200 });
|
||||
expect(store.get("huge")).toBeUndefined();
|
||||
// The oversized body must not have evicted entries that do fit.
|
||||
expect(store.get("small")?.etag).toBe("e");
|
||||
});
|
||||
|
||||
test("frees the byte budget when an entry is overwritten", () => {
|
||||
const store = new InMemoryConditionalRequestStore(5000, 300);
|
||||
const entry = (etag: string) => ({
|
||||
etag,
|
||||
data: "x".repeat(120),
|
||||
status: 200,
|
||||
});
|
||||
// Overwriting the same key must not double-count its bytes, so two more
|
||||
// distinct keys still fit alongside it.
|
||||
store.set("k1", entry("e1"));
|
||||
store.set("k1", entry("e1b"));
|
||||
store.set("k2", entry("e2"));
|
||||
expect(store.get("k1")?.etag).toBe("e1b");
|
||||
expect(store.get("k2")?.etag).toBe("e2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditionalRequestTokenScope", () => {
|
||||
test("is deterministic, distinct per token, and never leaks the token", () => {
|
||||
const a = conditionalRequestTokenScope("ghp_token_a");
|
||||
const b = conditionalRequestTokenScope("ghp_token_b");
|
||||
expect(a).toBe(conditionalRequestTokenScope("ghp_token_a"));
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).not.toContain("ghp_token_a");
|
||||
expect(a.startsWith("token:")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
|
||||
/**
|
||||
* A single cached GitHub response, retained so that a later conditional request
|
||||
* which comes back `304 Not Modified` can be served from memory instead of
|
||||
* downloading the full body again. Only the fields the mirror actually consumes
|
||||
* are stored.
|
||||
*/
|
||||
export interface CachedResponse {
|
||||
etag: string;
|
||||
data: unknown;
|
||||
status: number;
|
||||
/** Preserved so `octokit.paginate` can keep following pages on a cache hit. */
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface ConditionalRequestStore {
|
||||
get(key: string): CachedResponse | undefined;
|
||||
set(key: string, value: CachedResponse): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-lifetime, bounded in-memory store. Gitea Mirror runs as a long-lived
|
||||
* server whose scheduler re-syncs on an interval, so keeping ETags in memory is
|
||||
* enough to turn each repeated poll into a cheap `304`. Bounded both by entry
|
||||
* count and by total body bytes: a single 100-PR page can run to a few hundred
|
||||
* KB, so a count cap alone would let the store grow to gigabytes. Evicting an
|
||||
* entry only costs one full response on the next sync.
|
||||
*/
|
||||
export class InMemoryConditionalRequestStore implements ConditionalRequestStore {
|
||||
private readonly entries = new Map<
|
||||
string,
|
||||
{ value: CachedResponse; bytes: number }
|
||||
>();
|
||||
private totalBytes = 0;
|
||||
|
||||
constructor(
|
||||
private readonly maxEntries = 5000,
|
||||
private readonly maxTotalBytes = 64 * 1024 * 1024,
|
||||
) {}
|
||||
|
||||
get(key: string): CachedResponse | undefined {
|
||||
return this.entries.get(key)?.value;
|
||||
}
|
||||
|
||||
set(key: string, value: CachedResponse): void {
|
||||
const bytes = approximateResponseBytes(value);
|
||||
|
||||
// Delete-then-set so Map iteration order approximates LRU for eviction.
|
||||
const existing = this.entries.get(key);
|
||||
if (existing) {
|
||||
this.totalBytes -= existing.bytes;
|
||||
this.entries.delete(key);
|
||||
}
|
||||
|
||||
// A body larger than the whole budget would just evict everything else and
|
||||
// then fail to fit anyway; re-fetching it each sync is the cheaper failure.
|
||||
if (bytes > this.maxTotalBytes) return;
|
||||
|
||||
this.entries.set(key, { value, bytes });
|
||||
this.totalBytes += bytes;
|
||||
|
||||
while (
|
||||
this.entries.size > this.maxEntries ||
|
||||
this.totalBytes > this.maxTotalBytes
|
||||
) {
|
||||
const oldest = this.entries.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
const evicted = this.entries.get(oldest);
|
||||
this.entries.delete(oldest);
|
||||
if (evicted) this.totalBytes -= evicted.bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rough byte cost of a cached response: the serialized body plus headers we
|
||||
* retain. Precision doesn't matter here, only that eviction tracks real memory
|
||||
* pressure instead of entry count.
|
||||
*/
|
||||
function approximateResponseBytes(value: CachedResponse): number {
|
||||
const body = value.data === undefined ? "" : JSON.stringify(value.data) ?? "";
|
||||
return body.length + value.etag.length + (value.link?.length ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared across every client so ETags survive the per-sync re-creation of the
|
||||
* Octokit instance (a fresh client is built on each scheduled sync).
|
||||
*/
|
||||
export const defaultConditionalRequestStore =
|
||||
new InMemoryConditionalRequestStore();
|
||||
|
||||
export function conditionalRequestCacheKey(
|
||||
scope: string,
|
||||
method: string,
|
||||
url: string,
|
||||
): string {
|
||||
return `${scope} ${method.toUpperCase()} ${url}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache scope for clients created with only a token (e.g. the metadata
|
||||
* mirroring path builds clients via `createGitHubClient(token)` with no user
|
||||
* id or username). Without this, all such clients would share one "default"
|
||||
* scope across users. Hashed so the raw token never appears in cache keys.
|
||||
*/
|
||||
export function conditionalRequestTokenScope(token: string): string {
|
||||
return `token:${createHash("sha256").update(token).digest("hex").slice(0, 16)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds ETag-based conditional requests to an Octokit instance. For every GET it
|
||||
* replays the previously stored `If-None-Match`; GitHub then answers `304 Not
|
||||
* Modified` — which does not count against the token's primary rate limit —
|
||||
* whenever nothing changed, and the cached body is returned in place of a full
|
||||
* re-download. Non-GET requests are passed through untouched.
|
||||
*
|
||||
* The scope isolates cache entries per token/user so one account never reads
|
||||
* another's cached data.
|
||||
*/
|
||||
export function applyConditionalRequests(
|
||||
octokit: Octokit,
|
||||
options: { store?: ConditionalRequestStore; scope?: string } = {},
|
||||
): void {
|
||||
// Some tests stub Octokit without the hook system; skip wiring in that case.
|
||||
if (typeof (octokit as any)?.hook?.wrap !== "function") return;
|
||||
|
||||
const store = options.store ?? defaultConditionalRequestStore;
|
||||
const scope = options.scope ?? "default";
|
||||
|
||||
octokit.hook.wrap("request", async (request: any, requestOptions: any): Promise<any> => {
|
||||
const method = String(requestOptions.method ?? "GET").toUpperCase();
|
||||
if (method !== "GET") {
|
||||
return request(requestOptions);
|
||||
}
|
||||
|
||||
// Build the key from the expanded absolute URL, not the route template.
|
||||
// Inside the hook `requestOptions.url` is still `/repos/{owner}/{repo}/...`,
|
||||
// so keying on it would collapse every repo into a single entry per user +
|
||||
// endpoint and stop the 304 path from firing once more than one repo syncs.
|
||||
// `octokit.request.endpoint.parse` expands the route (owner/repo + query);
|
||||
// the chained `request` argument has no `.endpoint`, so it must come from
|
||||
// `octokit.request`.
|
||||
const parseEndpoint = (octokit as any)?.request?.endpoint?.parse;
|
||||
const expandedUrl =
|
||||
typeof parseEndpoint === "function"
|
||||
? parseEndpoint(requestOptions).url
|
||||
: requestOptions.url;
|
||||
const key = conditionalRequestCacheKey(scope, method, expandedUrl);
|
||||
const cached = store.get(key);
|
||||
if (cached?.etag) {
|
||||
requestOptions.headers = {
|
||||
...requestOptions.headers,
|
||||
"if-none-match": cached.etag,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request(requestOptions);
|
||||
const etag = response?.headers?.etag;
|
||||
if (etag && response.status >= 200 && response.status < 300) {
|
||||
store.set(key, {
|
||||
etag,
|
||||
data: response.data,
|
||||
status: response.status,
|
||||
link: response.headers?.link,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
// GitHub answered "not modified": reuse the stored body, presented as a
|
||||
// 200 so callers (including octokit.paginate) are unaffected.
|
||||
if (error?.status === 304 && cached) {
|
||||
return {
|
||||
status: 200,
|
||||
url: expandedUrl,
|
||||
headers: {
|
||||
...(error.response?.headers ?? {}),
|
||||
etag: cached.etag,
|
||||
...(cached.link ? { link: cached.link } : {}),
|
||||
},
|
||||
data: cached.data,
|
||||
} as any;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import type { GitRepo, RepoStatus } from "@/types/Repository";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { throttling } from "@octokit/plugin-throttling";
|
||||
import type { Config } from "@/types/config";
|
||||
import {
|
||||
applyConditionalRequests,
|
||||
conditionalRequestTokenScope,
|
||||
} from "@/lib/github-conditional-requests";
|
||||
// Conditionally import rate limit manager (not available in test environment)
|
||||
let RateLimitManager: any = null;
|
||||
let publishEvent: any = null;
|
||||
@@ -202,6 +206,14 @@ export function createGitHubClient(
|
||||
});
|
||||
}
|
||||
|
||||
// Reuse ETags across syncs so unchanged lists (e.g. pull requests) come back
|
||||
// as rate-limit-free 304s instead of full downloads on every scheduled poll.
|
||||
// Token-hash fallback keeps clients created with only a token (metadata
|
||||
// mirroring path) isolated per user instead of sharing one scope.
|
||||
applyConditionalRequests(octokit, {
|
||||
scope: userId ?? username ?? conditionalRequestTokenScope(token),
|
||||
});
|
||||
|
||||
return octokit;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user