Compare commits

...
8 Commits
Author SHA1 Message Date
Arunavo Ray e0c39b697f chore: bump version to 3.26.1 2026-08-07 13:23:51 +05:30
Arunavo Ray 03fce1e299 fix(ui): keep repo name and path on one line with hover marquee
Long repository names and owner/repo paths in the repositories table
wrapped to multiple lines, breaking row alignment. Both now truncate
with an ellipsis and scroll horizontally on hover to reveal the hidden
part.

The new MarqueeText component animates text-indent instead of a
transform because ellipsis rendering only works on inline content, and
a transform would require an inline-block wrapper.
2026-08-07 13:23:48 +05:30
Arunavo Ray 7fc53cfad8 Revert "fix(security): upgrade better-auth family to 1.7.0-rc.4"
This reverts commit 2f6af22e25.
2026-08-06 07:19:13 +05:30
Arunavo Ray b33e7d5596 fix(security): patch image CVEs and unbreak Scout SARIF uploads
- Build git-lfs with Go 1.25.12 (stdlib CVEs fixed in 1.25.10) and pull
  golang.org/x/net past the fix for CVE-2026-39821; x/net 0.54.0 was
  flagged critical in the image scan.
- Cap SARIF relatedLocations at 100 per result before upload. GitHub
  rejects SARIF with >1000 related locations per result, and Docker
  Scout exceeds that for common OS packages, so every upload since May
  had failed silently (continue-on-error) and the Security tab was
  frozen on a stale scan. With uploads flowing again, already-fixed
  alerts (samlify, libgnutls via the existing apt-get upgrade) close on
  the next scan.
2026-08-06 07:00:03 +05:30
Arunavo Ray 2f6af22e25 fix(security): upgrade better-auth family to 1.7.0-rc.4
Fixes the @better-auth/oauth-provider advisory (unbound resource
indicators could yield access tokens for unauthorized audiences,
Dependabot #55). No patched 1.6.x exists; 1.7.0-rc.4 is the first
patched line.

The 1.7 oauth-provider expects a wider schema: new nullable columns on
oauth_clients / oauth_access_tokens / oauth_refresh_tokens /
oauth_consents (back-channel logout, DPoP, resource indicators, refresh
token rotation) and three new tables (oauth_resources,
oauth_client_resources, oauth_client_assertions). Migration 0014 is
purely additive; validate-migrations gains the matching upgrade fixture.
2026-08-06 07:00:03 +05:30
Arunavo Ray 185946984b chore: bump version to 3.26.0 2026-08-06 06:43:47 +05:30
Arunavo Ray bcc9cf0120 feat(github): cap ETag cache by size and scope tokenless clients
Follow-up to #356. The conditional-request store was bounded only by
entry count; a single 100-PR page can run to a few hundred KB, so 5000
entries could grow to gigabytes. The store now also tracks approximate
body bytes (64MB budget by default) and evicts oldest-first until both
caps hold. A body larger than the whole budget is not cached at all.

Clients created with only a token (the metadata mirroring path) all
shared the "default" cache scope across users. They now fall back to a
SHA-256 hash of the token, so distinct tokens never share entries and
the raw token never appears in cache keys.
2026-08-06 06:43:40 +05:30
Josh FreeandCopilot 2b455354f9 feat(github): reuse ETags across syncs via conditional requests (#356)
* feat(github): reuse ETags across syncs via conditional requests

The mirror re-lists every repository's pull requests on each scheduled
sync with `state: "all"`, and the Octokit client was created with the
throttling plugin but no conditional-request/ETag layer. Every sync
therefore re-downloaded unchanged data as full 200s and spent full
rate-limit budget.

Add an ETag cache wired into `createGitHubClient`: for each GET it
replays the previously stored `If-None-Match`, so GitHub returns
`304 Not Modified` (which does not count against the token's primary
rate limit) when nothing changed, and the cached body is reused. The
store is process-lifetime and scoped per user so ETags survive the
per-sync client re-creation without leaking data across tokens. Non-GET
requests are untouched.

Refs #355

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79

* test(github): cover conditional-request ETag cache

Add unit tests driving a real Octokit instance with a stubbed fetch:
the second GET replays `If-None-Match`, a `304` is transparently served
from cache as a 200 with the same body, responses without an ETag are
re-fetched, non-GET requests are never made conditional, and cache
entries are isolated by scope.

Refs #355

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79

* fix: key conditional-request cache by expanded URL

The request hook built the cache key from requestOptions.url, which is
still the route template (/repos/{owner}/{repo}/pulls) at hook time.
Every repo therefore shared one entry per user + endpoint, so with more
than one repo per user the stored ETag never matched and the 304 path
stopped firing. Expand the route via octokit.request.endpoint.parse
before building the key, and add a two-repo regression test that fails
on the shared-key behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
2026-08-06 06:36:31 +05:30
8 changed files with 517 additions and 11 deletions

No files matched your search

+15
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)}
+57
View File
@@ -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>
);
}
+231
View File
@@ -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);
});
});
+189
View File
@@ -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;
}
});
}
+12
View File
@@ -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;
}