Commit Graph
809 Commits
Author SHA1 Message Date
ARUNAVO RAYandGitHub 83b59874a8 feat(cleanup): reconcile the destination with the repository database (#382)
Compare what Gitea or Forgejo holds under every owner this account
mirrors into with the repositories table. Mirrors of the configured
source that have no row are reported as untracked and can be adopted;
rows marked mirrored whose repository is gone are confirmed with a
direct check and can be reset to imported so the next run recreates
them. Native repositories and mirrors of other hosts are listed and
never touched, and nothing is deleted or archived. Exposed as
POST /api/cleanup/reconcile and as a dialog on the Automation tab.

Closes #284

Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
2026-09-02 16:16:57 +05:30
ARUNAVO RAYandGitHub a1ff0433df fix(security): clear the image scan alerts (#381)
Build git-lfs with Go 1.25.14, stop serving the runner stage from the
layer cache so Debian security updates land, move typescript and
@astrojs/check out of the shipped dependencies, bump the nested copies
of browserslist, js-yaml, nanoid and sharp to patched versions, and
update browserslist in the website lockfile.

Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
2026-09-02 16:16:46 +05:30
Arunavo Ray 26f78e06b8 chore: bump version to 3.30.0
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
v3.30.0
2026-09-02 14:59:27 +05:30
ARUNAVO RAYandGitHub 83dd74dd52 feat(auth): API keys for programmatic access (#380)
* feat(auth): API keys for programmatic access

Adds the @better-auth/api-key plugin so scripts and CI pipelines can call
the existing endpoints with an x-api-key header instead of a session
cookie. Keys are owned by a user, hashed at rest, prefixed gm_, never
expire unless an expiry is chosen, and are not rate limited. A small
guard plugin refuses key management calls that arrive with a key, so a
leaked key cannot mint or revoke keys.

New API Keys section on the Authentication tab with create, show once,
copy and revoke. Migration 0017 adds the api_keys table with the
validator fixture. docs/API.md documents the header and the calls
automation needs. An e2e spec covers create, use, refuse and revoke over
HTTP. bun.nix regenerated for the new package.

Closes #314

Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX

* fix(e2e): send Origin on cookie-authenticated key management calls

Better Auth rejects a cookie-authenticated POST without an Origin header (403 MISSING_OR_NULL_ORIGIN). Browsers always send one, the Playwright request context does not, so the spec sets it on the create and delete calls. Also asserts the guard's 403 code and documents the Origin requirement for scripts that manage keys with a session.

Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
2026-09-02 14:57:58 +05:30
ARUNAVO RAYandGitHub 8d22774b0a feat(releases): limit release assets to the newest N releases (#379)
Adds a release asset limit next to the release limit. The release limit
still decides how many of the newest releases exist in Gitea at all. The
new asset limit decides how many of those also get their assets uploaded;
older releases inside the release limit are created with their notes and
tag only. Unset means assets for every mirrored release, which is what
happened before, so existing setups do not change. Zero means release
notes only. The limit inherits global to organization to repository
through the existing limit-key machinery, and lowering it never deletes
assets that were already uploaded; it only gates what a sync uploads.

Settings gets a second box next to the release limit, the overrides dialog
gets a matching row for organizations and repositories, and the env loader
reads RELEASE_ASSET_LIMIT. The README explains both limits and gains a
troubleshooting entry for the read-only push error people hit on pull
mirrors.

Closes #311
Closes #59

Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
2026-09-02 14:40:59 +05:30
ARUNAVO RAYandGitHub 3ff05ec7f3 chore(deps): upgrade better-auth family to 1.7.2 (#378)
better-auth, @better-auth/oauth-provider and @better-auth/sso move from
1.6.23 to 1.7.2. This is the first stable line with the oauth-provider fix
for unbound resource indicators (Dependabot alert #55); no 1.6.x release
has it.

1.7 keys accounts by (issuer, accountId) and looks the credential account
up with issuer "local:credential" on sign-in, so migration 0016 adds the
column and backfills existing rows: local accounts get local:credential,
SSO accounts take their provider's issuer, other OAuth accounts get the
local:oauth namespace. It also adds jwks.alg and jwks.crv, the 1.7
oauth-provider columns, and the oauth_resources, oauth_client_resources
and oauth_client_assertions tables.

A database that booted the reverted 1.7.0-rc.4 build (on main for a few
minutes on 2026-08-06, and any dev database from then) already has part of
that schema without a 0016 record, which makes 0016 fail on every boot.
repairStrandedBetterAuth17Schema drops the leftovers before migrate runs,
in the same way the #312 repair handles stranded SSO columns; the
migration validator exercises it.

createOAuthClient no longer takes "type"; the SSO applications route sends
application_type (web or native) and reads it back with the legacy column
as fallback. bun.nix is regenerated. reset-users writes the issuer.

Supersedes #374.

Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
2026-09-02 14:07:08 +05:30
ARUNAVO RAYandGitHub d211e54877 feat: mirror from GitLab and Gitea/Forgejo sources (#376)
Adds a Source dropdown to the configuration card (GitHub, GitLab beta,
Gitea/Forgejo) backed by a source provider interface with three adapters.
Discovery, mirroring, recovery and cleanup go through the provider;
repositories record their source (migration 0015); clone credentials are
built per host and a repository from another host is refused at the
mirror sites. Issues, pull requests, releases and star lists stay GitHub
only. The destination card gets a Gitea/Forgejo dropdown, and both hosts
lock once repositories exist, with an explicit confirmation to change.

Closes #375. Helps #371.

Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
2026-09-02 13:42:34 +05:30
Arunavo Ray a579934539 chore: bump version to 3.29.1
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
v3.29.1
2026-09-02 12:02:19 +05:30
ARUNAVO RAYandGitHub f767a0a2b3 fix: stop treating live mirror jobs as interrupted (#372) (#373)
A job that had just started, with in_progress=1 and no checkpoint yet,
matched findInterruptedJobs immediately. Since #297 the middleware runs
that check on every request, so a live job was "resumed" by recovery
while the original process was still working on the same repositories.
The request-level 15 second timeout then released the in-flight latch
without cancelling recovery, and later requests logged misleading
"already in progress" and "completed with some issues" lines.

- Move the liveness rule into interrupted-job-detection.ts, as both a
  predicate and the SQL condition used by findInterruptedJobs. A job
  with no checkpoint is only interrupted once it is older than the 10
  minute checkpoint window (or has no recorded start at all).
- Stamp an initial checkpoint on in-progress jobs at creation.
- Refresh the checkpoint every 2 minutes from processWithResilience so a
  single long item cannot make a live job look interrupted. The refresh
  only touches rows still in progress.
- Keep the middleware recovery latch held until the recovery promise
  settles, not until the request stops waiting, and clear the timeout
  timer so it no longer leaves a dangling rejection.

Tests cover the predicate, the SQL against an in-memory SQLite database,
and the wiring into helpers, concurrency, and middleware.

Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
2026-09-02 11:59:37 +05:30
Arunavo Ray b1d2ea4d6d chore: bump version to 3.29.0
Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
v3.29.0
2026-09-01 10:07:51 +05:30
Arunavo Ray e7270a6ca3 feat(ui): rebuild the activity rows and add current-state stats
A row spent three stacked lines on a status, a repo name and a Show
Details button, leaving most of its width empty. It now follows the
dashboard's Recent Activity shape - status circle, label, time - and uses
the width for the repository or organization and the message, with the
whole row toggling the details pane. Rows went from about 170px to 69px,
so ten fit where six did.

The status to icon and colour mapping was a chain of ternaries written
out twice, once for mobile and once for desktop; it is now one table,
shared with the new stat chips.

Those chips fill the empty half of the filter row and report the current
state of each repository or organization rather than counting events, so
a repo that failed and then synced counts once, as synced. Each one
filters the log to that status.

Two virtualizer bugs surfaced while measuring the new rows:

- measureElement reads the row index from a data-index attribute that was
  never set, so every measurement was dropped and rows only ever used
  estimateSize. That is why the old estimates were hand-tuned to the
  markup.
- Expanding a row called virtualizer.measure(), which discards every
  measurement rather than re-measuring the row that changed.

Search filtering also ran twice, once in ActivityLog and again in
ActivityList over the list it had already filtered. It happens once now,
which is also what lets the toolbar show a count that matches the list.

Also drops the status dot from the dashboard repository rows, which
repeated the status pill beside it without its label.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 10:07:47 +05:30
Arunavo Ray 4ac2c4323c fix(ui): hand over to the compact toolbar at lg, and let lists size themselves
All three list pages switched from the compact toolbar to the full one at
sm (640px), so a tablet in portrait got the desktop row with six controls
and the search box squeezed down to its icon. The compact layout already
carries every filter in its drawer, so the handover moves to lg (1024px)
and tablets get a usable search box.

Lists also sized themselves off the viewport with hard-coded pixel
offsets - max-h-[calc(100dvh-231px)] and -276px. Those went stale the
moment a toolbar changed height, which is what put a second scrollbar on
the page next to the table's own. Both now flex into the space the
toolbar leaves, so the visible row count follows the window instead of a
fixed guess: the repository table shows 13 rows at 800px and 23 at
1400px, where it used to stop at the same count either way.

Also drops the bottom status bar from both lists. Its count moves up into
the empty left half of the filter row, and its live indicator was saying
what the header's LIVE button already says.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 10:07:35 +05:30
Arunavo Ray cadeb64dce fix(ui): give the app a single scroll region
main was min-h-screen, so it could grow past the viewport, while the
content section sized itself with h-[calc(100dvh-4.55rem)] - a guess at
the header's height. Whenever the header came out taller than that
guess, the body scrolled behind the section's own scroll and every page
showed two scrollbars.

The shell is now pinned to the viewport and the section takes whatever
the header actually leaves, so the magic offset is gone and the height
is correct whatever the header does.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 10:07:25 +05:30
Arunavo Ray 7fcce46821 feat(ui): accept a GitHub URL in the add repository and organization dialogs
Both dialogs asked for the name and owner as separate fields, so adding
something you were looking at on GitHub meant reading the URL and
retyping it in pieces.

Each dialog now has a GitHub URL field above an OR divider; filling it
populates the fields below. Pasting a URL into the name field splits it
as well, rather than dropping the whole URL into one box.

Parsing handles what people actually have on the clipboard: browser
URLs, clone URLs, SSH remotes, the owner/repo shorthand, a bare host,
and deep links into a repo. It drops the .git suffix, tolerates trailing
slashes, query strings and angle-bracket wrapping, and rejects reserved
GitHub paths like /settings so they cannot be read as an account name.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 09:47:13 +05:30
Arunavo Ray 64e3b18e1e fix(ui): rework the repositories filter bar
The toolbar carried six controls plus Mirror All and overflowed on
narrower desktop windows, and the owner and organization pickers did not
match the dropdowns beside them.

- Moves the status, mirror options and sort dropdowns down to the row
  that carries "Showing N of M" and Clear filters, right-aligned. They
  live in RepositoryTable now because that is where the filtered count
  is computed, and the row renders in both the loading and loaded states
  so the controls do not appear only once data arrives.
- Makes the owner and organization triggers match the Select triggers.
  They kept a double-chevron icon and the outline Button styling; the
  Select trigger classes are now exported from ui/select so both use one
  definition instead of a copied class string. They stay comboboxes
  rather than Selects because those lists grow with the repository count
  and need the search box.
- Widens both to 200px (w-50), which fits longer owner names.
- Hides the moved dropdowns below sm. The mobile filter sheet already
  covers all five filters, so on mobile they were duplicates sitting
  loose on the page.

Also fixes hasAnyFilter, which used val?.toString(), yielding undefined
for an unset filter. undefined !== "" is true, so an unfiltered view
reported "Showing 697 of 697 repositories / Clear filters" and a
"Filters applied" footer with nothing filtered.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 09:47:05 +05:30
Arunavo Ray b3e8c69566 fix(ui): make the dashboard Repositories and Recent Activity cards match
The two cards sat at whatever height their own content needed, so they
ended at different points whenever the lists were different lengths.
That is the normal state: repositories and mirror jobs come from
independent tables, and activity accumulates on every sync while the
repository list only grows when a repo is added.

- Drops items-start from the row so the columns stretch to the row
  height, in both the skeleton and the loaded state so the layout does
  not shift on load.
- Adds h-full to both cards. Without it the column stretches but the
  card inside still hugs its content, so removing items-start alone
  changes nothing on screen.
- Aligns the activity row padding with the repository rows (py-3 to
  py-3.5) so the rows line up when the two lists do have equal counts.

Verified in the browser at 8 vs 8 and 8 vs 5 items: both cards measure
657px in each case.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 09:30:34 +05:30
Arunavo Ray f06f6695bb chore: bump version to 3.28.2
Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
v3.28.2
2026-09-01 09:08:09 +05:30
Arunavo Ray a000073a68 fix(ui): rebuild the Identity Providers card to match Sign-in Methods
The card used shadcn Card/CardHeader while its neighbour used the plain
settings markup, so the two headers were different heights and their
dividers did not line up. Rebuilt it on the same markup: icon, title,
divider, body, footer.

- Drops the header description, which wrapped under the Add provider
  button and collided with it.
- Moves Add provider into the header row. The header uses py-3 rather
  than py-4 because the h-8 button makes the row taller than a text-only
  one; both headers now measure 56px.
- Removes mr-2 from the Plus icons. The button already applies its own
  gap, so mr-2 stacked an extra 8px and pushed the label off-centre.
- Gives the provider detail labels weight and a fixed 96px column.
  Everything was one muted weight with no hierarchy, and "Organization:"
  overflowed the old 80px column so its value broke the alignment.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 09:08:05 +05:30
Arunavo Ray 2355284d27 chore: bump version to 3.28.1
Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
v3.28.1
2026-09-01 08:57:39 +05:30
Arunavo Ray 6f3727c47e fix(ui): tighten settings card labels so they fit on one line
Database Maintenance footer said "Last cleanup"/"Next cleanup", which
wrapped onto two lines next to a full timestamp. The card is already
titled Database Maintenance, so "Last run"/"Next run" says the same
thing and fits.

Same for the Smart backup tile: "Snapshot only on history rewrites"
wrapped, and "history" is redundant since a force-push is the only
rewrite the sync sees.

Claude-Session: https://claude.ai/code/session_01QRhZnpehnrVtnPRqJYDdVQ
2026-09-01 08:57:36 +05:30
Arunavo Ray 1bb096aa93 chore: bump version to 3.28.0 v3.28.0 2026-08-24 09:26:07 +05:30
ARUNAVO RAYandGitHub feb13ff58a feat(mirror): per-repo release limit, description sync on every sync, tidier mobile cards (#370)
Follow-up to #362 and #365 for the remaining points raised in #361.

- Add `releaseLimit` to the per-repository and per-organization mirror
  overrides (repo > org > global > 10), surface it in the Mirror Options
  dialog with gating and validation, and pass the resolved value into the
  release mirror. Tags need no equivalent: Gitea's git mirror fetches every
  ref and they cost nothing beyond the commits already in the clone.
- Reconcile the Gitea description and topics with GitHub on every sync, not
  only at migration time. Read both live in one call, write the refreshed
  description back to the local row, and skip writes that would not change
  anything. Mirrors created before #224 or whose upstream description
  changed since now pick it up on the next sync.
- Rework the mobile repository card: name, Custom badge and menu on one
  vertically centred row, secondary badges on their own row below, status
  and last sync stacked on the right.
2026-08-24 09:10:45 +05:30
ARUNAVO RAYandGitHub 41592cb8fd fix(ci): skip Docker Scout on fork PRs and add a Trivy scan for every PR (#369)
Fork PRs (and Dependabot PRs) never receive repository secrets, so the
Docker Hub login ran with empty credentials and Docker Scout then died
with "user githubactions not entitled to use Docker Scout". Every
external contributor got a red docker check they could not do anything
about (#368, #356, #357, #359).

- Gate the Docker Hub login and both Scout PR steps on the PR coming
  from this repo, the same guard the login/push/comment steps already use.
- Add a Trivy scan of the locally loaded image on every PR. It needs no
  credentials, so forks get real vulnerability feedback. Output goes to
  the job summary because GITHUB_TOKEN is read-only on fork PRs.
- Only upload the Scout SARIF when a file exists, so fork PRs stop
  logging "Path does not exist: scout-results.sarif".
- Update the workflows README, which still described a docker-scan.yml
  that no longer exists.

Not using pull_request_target on purpose: it would run untrusted PR code
with the Docker Hub token available.
2026-08-24 07:52:41 +05:30
Arunavo Ray 8446811e8d chore: bump version to 3.27.2 v3.27.2 2026-08-21 17:37:40 +05:30
ARUNAVO RAYandGitHub 0c41fac9c0 fix(auth): trust registered SSO provider origins and honor deleteFromGitea (#366) (#367)
Three fixes for the two problems reported in #366:

1. Auto-trust registered SSO identity provider origins. better-auth
   1.6.23 (shipped in v3.21.0) added SSRF hardening to the SSO plugin:
   sign-in rejects IdP endpoints whose hostnames resolve to private
   addresses unless the origin is in trustedOrigins. Homelab split-DNS
   setups (IdP domain resolving to a LAN IP from inside the container)
   broke on every sign-in with a 400. Registering a provider is an
   explicit operator action, so its issuer and endpoint origins are now
   added to trusted origins automatically.

2. Surface SSO sign-in errors in the login form. The auth client
   resolves with { data, error } instead of throwing, so server-side
   rejections were silently swallowed - the button flipped back from
   "Redirecting..." with no feedback and nothing in the logs.

3. Honor deleteFromGitea (CLEANUP_DELETE_FROM_GITEA). It was documented
   as "Delete repositories from Gitea" defaulting to false, but cleanup
   never read it and always archived/deleted orphans on the Gitea side.
   It now gates the Gitea-side operation: when disabled (default),
   orphans are only marked archived or removed in gitea-mirror's own
   database and the Gitea/Forgejo copies stay untouched.

Verified end to end against a live server: sign-in with a private-IP
IdP returns 400 discovery_private_host on v3.27.1 and a 200 with the
authorization URL on this branch; the login form now shows the server
error as a toast; delete cleanup with the flag off removes only the DB
row while the flag on contacts Gitea.
2026-08-21 17:37:32 +05:30
Arunavo Ray f18fc4bfe6 chore: bump version to 3.27.1 v3.27.1 2026-08-21 07:09:35 +05:30
ARUNAVO RAYandGitHub d63d55f52d fix(ui): surface Mirror Options on mobile repo cards and desktop org menu (#365)
The per-repo overrides from #361 were reachable only from the desktop
repositories table. The mobile card layout had no dropdown at all, and
the organizations page had the inverse gap: the item existed in the
mobile card menu but not the desktop one.

Mobile repo cards get a three-dot menu in the header opening the same
MirrorOverridesDialog, plus the Custom badge shown when a repository
overrides something. The organizations desktop dropdown gains the same
Mirror Options item the mobile variant already had.
2026-08-21 07:09:32 +05:30
ARUNAVO RAYandGitHub 2867076d71 docs(www): align use-case articles with the actual configuration UI (#364)
Four older articles directed readers to UI elements by names that no
longer exist. Verified against src/components/config/:

  "Content & Data" section        -> "Mirror Content"
  "Mirror metadata" toggle        -> "Repository metadata"
  "Git LFS (Large File Storage)"  -> "Git LFS objects"
  "Mirror releases"               -> "Releases & tags"
  "Preserve structure" strategy   -> "Preserve Structure"

The new articles from #363 already used current names or generic
wording, so only the older four needed changes.
2026-08-20 06:41:41 +05:30
ARUNAVO RAYandGitHub 7a935ff1b1 feat(www): SEO overhaul with sitemap, metadata, interlinking, and five new articles (#363)
* feat(www): generated sitemap, article metadata, and de-orphaned comparison page

The hand-written sitemap listed one URL of 24 and was dated January 2025.
astro.config now sets `site` and uses @astrojs/sitemap, so every built
page lands in sitemap-index.xml on each build. robots.txt points at the
new index and drops the duplicate user-agent block.

Use-case layouts gain the OG/Twitter tags the docs layout already had,
and all three article layouts now emit TechArticle JSON-LD.

Nothing on the site linked to /comparison/github-backup-tools/. It now
has a header nav item, a footer link, and a homepage CTA next to the
use-cases button. The footer Documentation link points at the site docs
instead of the GitHub tree, and footer internal links no longer open in
new tabs.

* feat(www): five new articles and an interlinking pass

New guides, each grounded in shipped behavior:

- Migrate from GitHub to Gitea (parallel-run migration playbook)
- Mirror Git LFS repositories (why one bad LFS repo fails its whole
  migration, and the per-repo override from v3.27.0 that fixes it)
- GitHub API rate limits (ETag conditional requests making 304s free)
- Mirror issues and pull requests (what transfers, the PR-to-issue
  conversion, why processing is sequential)
- GitHub backup on Unraid (based on the RayLabsHQ CA template)

All five are registered in the use-cases index; appended after the
existing entries so the homepage's featured three stay unchanged.

Interlinking pass across the existing articles: contextual inline
links where the text already referenced a topic, plus a short related
guides section per article. The comparison page's next steps now cover
the new guides, and the docs quickstart bridges to the use-case section.
Every article now carries 4 to 9 internal links; a build-time check
found 0 broken links across 440.
2026-08-20 06:33:04 +05:30
Arunavo Ray 85b72e9366 chore: bump version to 3.27.0 v3.27.0 2026-08-20 06:03:34 +05:30
ARUNAVO RAYandGitHub 0f700c7197 feat: per-repository and per-organization mirror option overrides (#362)
* feat: per-repository and per-organization mirror option overrides

Closes the gap reported in #361: a repository whose LFS fetch fails could
not be mirrored at all, because LFS was a single global switch. Gitea runs
the LFS fetch inside its own migration, so a failure aborts the whole
migration with nothing to salvage. The only fix available to us is to stop
asking for LFS on that repository.

Mirror options now resolve across three tiers, per flag, most specific
first: repository override, then organization override, then the global
config. NULL at a tier means inherit, so overriding LFS on one repo leaves
its other options alone.

Adds resolveMirrorOptions(), which replaces 24 scattered reads of
config.giteaConfig.* across three near-identical blocks: the two mirror
paths in gitea.ts and the sync path in gitea-enhanced.ts. The third was
easy to miss and mattered: without it, overrides would have been honored
on first mirror and silently ignored on every scheduled sync afterwards.

The starredCodeOnly clamp is folded into the resolver and deliberately
outranks explicit overrides, preserving the existing behavior that starred
repos mirror code only.

Editing is on the objects themselves, via the existing three-dot menus on
the Repositories and Organizations pages, not in Configuration, which keeps
holding the global defaults. A repositories filter and a row badge make it
possible to find which repos deviate.

Malformed override JSON degrades to "inherit" rather than throwing, so a
bad value can never break a mirror run.

* fix(ui): disable mirror toggles that cannot take effect, and explain why

A toggle the runtime will ignore should not look editable. Three cases
were doing exactly that, so they now share one mechanism:
getMirrorOverrideGating() returns a reason string per flag, and the dialog
disables the control and prints that reason underneath.

Starred clamp. When a repo is starred and starredCodeOnly is set, the
resolver forces every metadata flag off regardless of the override. The
dialog previously let the user set them anyway. The clamped set now lives
in STARRED_CLAMPED_KEYS, shared by the resolver and the gating helper so
the flags the UI disables cannot drift from the ones the runtime clamps.
LFS is deliberately excluded from that set, since turning LFS off per
repository is the point of #361, and a test pins that.

Labels. shouldMirrorLabels is `mirrorLabels && !mirrorIssues` in both
mirror paths, so labels cannot take effect while issues are mirrored. That
gate reacts live to the in-progress edit rather than only the saved state.

Inherit hint. For a repository the hint now reflects global -> org instead
of global alone, via a new name-keyed GET
/api/organizations/mirror-overrides that reuses
loadOrganizationMirrorOverrides. Personal repos skip the fetch, a failed
fetch degrades to the global values rather than blocking the dialog, and
the hint is suppressed while in flight so it is never briefly wrong.

Widens the UI to mirrorLabels and mirrorMilestones. mirrorMetadata stays
out: no mirror path reads it, so a per-object override would resolve
correctly and then do nothing. See the report for that finding.

useGiteaConfig additionally returns advancedOptions so callers can read
starredCodeOnly without a second request.

* fix(ui): read inherited mirror flags from mirrorOptions, not giteaConfig

/api/config does not return the mirror flags on giteaConfig. On the way
out, mapDbToUiConfig reshapes them into a separate mirrorOptions object
using different names (mirrorLFS, not lfs) and nesting the metadata flags
under metadataComponents with short names (issues, not mirrorIssues).

The dialog was written against the DB shape, so every lookup returned
undefined. Coerced to false, that is indistinguishable from a real "off",
which produced two symptoms: every inherit hint read "currently off"
regardless of the actual global config, and the labels gate never fired
because the effective issues value it keys on came from the same dead
source.

Adds mirrorOptionsToFlags() as the single conversion point, matching the
derivation in mapUiToDbConfig exactly, including that mirrorMetadata is a
master switch over the metadata components while lfs and mirrorReleases
sit outside it. Both dialogs now go through it, and useGiteaConfig returns
mirrorOptions alongside advancedOptions.

Server-side mirroring was never affected: the resolver reads config
straight from the DB, where the flags really do live on giteaConfig.

The existing tests could not catch this because they build a synthetic
config already in DB shape, so a client/server mismatch is invisible to
them. The new tests push flags through the real config-mapper in both
directions and assert the derived flags equal what mapUiToDbConfig would
persist, so they fail if that mapping changes again. One test pins the
root cause directly: that giteaConfig in the API payload carries no flags.

* fix(ui): surface the mirror-options filter on desktop, and on organizations

The overrides filter only existed inside the mobile filter drawer, so on
desktop the Repositories page showed a "Custom" badge on overridden rows
with no way to filter to them. Being able to answer "which repos deviate
from my defaults" on this page is the reason the Configuration-page
listing was dropped, so desktop could not do the one job it was given.

Adds the control to the desktop filter row next to status and sort, using
the bare Select-with-placeholder style those use rather than the drawer's
labelled markup. The mobile control is unchanged.

The Organizations page had a wider version of the same gap: it renders the
"Custom options" badge but never had this filter on either layout, and
OrganizationsList did not filter on it at all. Added the predicate plus
the control in both its layouts, so the two pages behave the same.

Also folds hasOverrides into activeFilterCount on both pages and into the
Organizations clear-all reset. Without that the mobile filter badge
undercounted an active overrides filter, and clearing filters left it set.

* fix(ui): stop double-applying the mirrorMetadata switch when reading config

mirrorOptionsToFlags ANDed each metadata component with mirrorMetadata on
the way in. That is a write-path rule: mapUiToDbConfig already applies it
when persisting, so a config saved through the settings UI has it baked
into the stored flag. Applying it again on read double-applies it.

The read path does not need it. mapDbToUiConfig puts the raw stored values
into metadataComponents, so metadataComponents.issues is exactly
giteaConfig.mirrorIssues, which is the field gitea.ts and
gitea-enhanced.ts read. Mapping the components straight through is 1:1
with runtime behavior.

Double-applying was invisible while the stored state was self-consistent,
since false && false is still false. It diverged when the state did not
come from the UI write path, which env vars allow: MIRROR_METADATA=false
with MIRROR_ISSUES=true stores mirrorMetadata:false, mirrorIssues:true.
The runtime mirrors issues; the dialog reported off. Measured against a
config in that state, five flags misreported.

I previously described this as an unrecoverable loss in the API shape.
That was wrong. Both values reach the client separately and unmerged, so
the payload carries full information and the loss was one we introduced.

Rewrites the test that asserted the derived flags match what
mapUiToDbConfig would persist. Comparing against the write derivation is
what pinned the bug in place. It now asserts the property that matters,
that flags derived from the API payload equal the stored flags the runtime
reads, plus a case checking agreement with resolveMirrorOptions on an
inconsistent config. Both fail if the AND returns.
2026-08-20 06:03:19 +05:30
Arunavo Ray a295a73066 fix(deps): patch nanoid, js-yaml and postcss in the www lockfile
Clears three Dependabot alerts on the marketing site:

  nanoid   3.3.16 -> 3.3.18  (infinite loop when size is zero)
  js-yaml  4.3.0  -> 4.3.1   (quadratic CPU on !!omap resolution)
  postcss  8.5.19 -> 8.5.26  (attacker-controlled source parsing)

All three are transitive and moved within their existing ranges, so the
change is version and integrity hashes only with no graph churn.
Verified with a frozen-lockfile install and a full site build.
2026-08-17 14:16:02 +05:30
Arunavo Ray 67cd0e2c60 docs: restore star history chart with scoped access token
GitHub restricted the stargazers API to repo admins and collaborators,
which broke the unauthenticated chart. Star History now needs a token
sealed into the image URL.

Uses a fine-grained token limited to this repository instead of a
classic public_repo token. The stargazers endpoint requires Contents
write, and classic public_repo grants that across every public repo the
account can push to, so scoping it to one repo keeps the blast radius
small. Legend moved to top-left to sit clear of the curve.
2026-08-17 14:11:31 +05:30
ARUNAVO RAYandGitHub c9edc2132b Update star history links and image sources 2026-08-16 21:19:36 +05:30
Arunavo Ray d4db47c76e chore: bump version to 3.26.2 v3.26.2 2026-08-07 17:03:41 +05:30
Arunavo Ray 91eb195494 fix(ui): smooth marquee scrolling and widen its hover target
Animating text-indent forced a relayout every frame, which made the
marquee visibly choppy. The scroll is now a compositor-driven transform.
The content stays plain inline text while at rest so the native ellipsis
still renders, and swaps to an inline-block only for the duration of the
animation.

The hover target is now the whole repository cell instead of just the
text, via a shared MarqueeTrigger wrapper. Name and path scroll together
at the same speed, so the motion reads as one block.
2026-08-07 13:38:47 +05:30
Arunavo Ray e0c39b697f chore: bump version to 3.26.1 v3.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 v3.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
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
Arunavo Ray 428c97441c chore: bump version to 3.25.0 v3.25.0 2026-08-03 11:59:44 +05:30
Arunavo Ray edf9c14be0 fix: honor DATABASE_URL for the SQLite database location
The variable was defined and documented but never read; the database
path was hardcoded to <cwd>/data/gitea-mirror.db. It now accepts
sqlite://, the legacy file: scheme, or a plain path, with relative
paths resolving against the working directory and the parent directory
created on demand. Defaults are unchanged, including the compose files
that pass the historical default value through. Docs updated to match.
2026-08-03 11:59:44 +05:30
ARUNAVO RAYandGitHub 5c49191d61 Move canonical docs to the website (#354)
* docs: move canonical documentation to the website

The website now hosts the full documentation at /docs with a proper docs
layout: sidebar navigation, on-page table of contents, mobile nav, theme
support, canonical and OG meta, and overflow-safe code blocks and tables.
Ten pages, all rewritten from the current code rather than copied from
the old in-app docs: quickstart, deployment (Docker, Helm, Nix, LXC,
bare metal), configuration, environment variable reference,
notifications (all four providers including webhook payload signing),
authentication (including header auth), force-push protection,
architecture, advanced, and custom CA certificates.

This fixes every inaccuracy found in the docs audit: the wrong
raylabs/gitea-mirror image name, JWT_SECRET presented as the live auth
secret instead of BETTER_AUTH_SECRET, the missing auth env vars, both
wrong DATABASE_URL defaults, the contradicting starred-org default, and
health endpoint fields the API deliberately does not return.

The in-app /docs pages are retired: a stub redirects old bookmarks to
the website, and the sidebar and 404 links point there directly. The
markdown files under docs/ stay as the versioned offline reference;
NOTIFICATIONS.md now covers Gotify and Webhook. README links the docs
site, mentions notifications, and drops stale version markers. The app
viewport meta gains initial-scale=1.

* docs: mark new-repo notification as unimplemented, bump helm appVersion to 3.24.0
2026-08-03 11:56:51 +05:30
Arunavo Ray 63a4c9359a chore: bump version to 3.24.0 v3.24.0 2026-08-03 11:29:01 +05:30
ARUNAVO RAYandGitHub 882e147504 Redesign dashboard and configuration screens (#353)
* feat(ui): redesign dashboard and configuration screens

New settings design language built from the design/giteamirror.pen file:
cards with icon headers and status footers, header-level enable switches,
toggle switches instead of checkboxes, uppercase section titles, selection
tiles with icon chips and a check on the active option, segmented controls,
and an indigo accent. Implemented via shared primitives in
src/components/config/settings-ui.tsx and applied across:

- Automation: header switches, schedule card, one-line auto-mirror copy
  with info tooltip, full-width Repository Cleanup card with Skip/Archive/
  Delete tiles and dry run row
- Notifications: segmented provider picker (ntfy/Apprise/Gotify/Webhook),
  events card with per-event switches
- Connections: GitHub/Gitea connection cards with token creation guide and
  field helpers, Repository Selection and Mirror Content cards covering
  every mirror option, Organization Structure card with strategy tiles,
  destructive update protection tiles (BETA label removed)
- Authentication: sign-in methods status card, identity providers restyle
- Dashboard: flatter stat cards, icon panel headers, indigo view-all links

All existing state handling, autosave and API behavior is unchanged.
Light mode keeps working via theme tokens. README and website screenshots
regenerated, docs references to renamed cards updated.

* fix(ui): design polish pass from local review

- Recent Activity rows get status icon circles (check, sync, sparkles, alert)
- Connections tab restructured: connection cards share a stretched grid row
  so GitHub and Gitea stay equal height; Mirror Content moved to the right
  column; forms split into placeable cards via a part prop
- Token guide panel: link moved to header as icon, larger text; redundant
  card footers removed (scopes line, test-connection hint)
- Repository Selection gains a footer note; retention explanation moved
  below the selector
- Authentication tab matches the design: side-by-side cards, row dividers,
  disabled state-reflecting switches with info hints, footers; SSO dialog
  restyled (segmented protocol tabs, field labels, indigo primary)
- Import GitHub Data button is the indigo primary; disabled state is muted
- Time format menu redesigned (locale pill, live examples, live clock in
  the trigger); theme switcher moved to sidebar as icon segmented control,
  system preference now persists correctly; legacy ModeToggle removed
- Automation timezone pill no longer shows stored legacy UTC as a choice
- Config tab bar wraps 2x2 on narrow screens instead of overflowing
- README, website and PR screenshots regenerated
2026-08-03 11:27:45 +05:30
Arunavo Ray da75f10f26 chore: bump version to 3.23.0 v3.23.0 2026-08-03 08:06:31 +05:30