mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-11-03 07:42:19 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf6c8a08e8 | ||
|
|
f0814c14fc | ||
|
|
56d0ef9020 | ||
|
|
5b1ef47e20 | ||
|
|
15bb4a671c | ||
|
|
8edf91b570 | ||
|
|
e5b4f751b3 | ||
|
|
a9ae6047c9 | ||
|
|
73afe9a8ed | ||
|
|
1ce97e4d49 | ||
|
|
d45ef33c1c | ||
|
|
4f96e7dfbf |
No files matched your search
@@ -141,6 +141,9 @@ DOCKER_TAG=latest
|
||||
# Release and Metadata
|
||||
# MIRROR_RELEASES=false # Mirror GitHub releases
|
||||
# RELEASE_LIMIT=10 # Maximum number of releases to mirror per repository
|
||||
# RELEASE_TYPE=all # Which releases to mirror: all, or stable (skip pre-releases)
|
||||
# RELEASE_INCLUDE_TAGS=v* # Only mirror releases whose tag matches these comma separated patterns
|
||||
# RELEASE_EXCLUDE_TAGS=*-rc.* # Skip releases whose tag matches; exclude wins over include
|
||||
# MIRROR_WIKI=false # Mirror wiki content
|
||||
|
||||
# Issue Tracking (requires MIRROR_METADATA=true)
|
||||
|
||||
+4
-2
@@ -17,8 +17,9 @@ pnpm-debug.log*
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# database files
|
||||
data/gitea-mirror.db
|
||||
# runtime data (database, repo backups); keep the README
|
||||
data/*
|
||||
!data/README.md
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
@@ -53,3 +54,4 @@ tests/e2e/git-repos/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
.playwright-mcp/
|
||||
/.vs
|
||||
@@ -23,6 +23,17 @@ docker scout cves --vex-location .vex --vex-author developer@arunavoray.dev \
|
||||
--only-vex-affected ghcr.io/raylabshq/gitea-mirror:latest
|
||||
```
|
||||
|
||||
## Product IDs
|
||||
|
||||
Each statement lists the image under every name Scout scans it as. A docker
|
||||
package URL carries the registry in the `repository_url` qualifier, and one
|
||||
without it means Docker Hub, so the published image needs
|
||||
`pkg:docker/raylabshq/gitea-mirror?repository_url=ghcr.io`. Writing the
|
||||
registry into the path (`pkg:docker/ghcr.io/raylabshq/gitea-mirror`) never
|
||||
matches. The PR scan of the locally built `gitea-mirror:scan` matches
|
||||
`pkg:docker/gitea-mirror`. Leaving the version out applies a statement to
|
||||
every tag.
|
||||
|
||||
## When to remove a statement
|
||||
|
||||
Delete the statement once the Debian package it covers is fixed in the base
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"@id": "https://github.com/RayLabsHQ/gitea-mirror/blob/main/.vex/util-linux-mount-nsenter.vex.json",
|
||||
"author": "developer@arunavoray.dev",
|
||||
"role": "Gitea Mirror maintainer",
|
||||
"timestamp": "2026-09-04T01:30:00Z",
|
||||
"version": 1,
|
||||
"timestamp": "2026-10-23T03:00:00Z",
|
||||
"version": 2,
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": {
|
||||
@@ -14,7 +14,7 @@
|
||||
"timestamp": "2026-09-04T01:30:00Z",
|
||||
"products": [
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/ghcr.io/raylabshq/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror?repository_url=ghcr.io" },
|
||||
{ "@id": "pkg:docker/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/library/gitea-mirror" }
|
||||
],
|
||||
@@ -30,7 +30,7 @@
|
||||
"timestamp": "2026-09-04T01:30:00Z",
|
||||
"products": [
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/ghcr.io/raylabshq/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror?repository_url=ghcr.io" },
|
||||
{ "@id": "pkg:docker/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/library/gitea-mirror" }
|
||||
],
|
||||
@@ -46,7 +46,7 @@
|
||||
"timestamp": "2026-09-04T01:30:00Z",
|
||||
"products": [
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/ghcr.io/raylabshq/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror?repository_url=ghcr.io" },
|
||||
{ "@id": "pkg:docker/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/library/gitea-mirror" }
|
||||
],
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"@id": "https://github.com/RayLabsHQ/gitea-mirror/blob/main/.vex/zlib-gz-vacate.vex.json",
|
||||
"author": "developer@arunavoray.dev",
|
||||
"role": "Gitea Mirror maintainer",
|
||||
"timestamp": "2026-09-07T01:20:00Z",
|
||||
"version": 1,
|
||||
"timestamp": "2026-10-23T03:00:00Z",
|
||||
"version": 2,
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": {
|
||||
@@ -14,7 +14,7 @@
|
||||
"timestamp": "2026-09-07T01:20:00Z",
|
||||
"products": [
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/ghcr.io/raylabshq/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/raylabshq/gitea-mirror?repository_url=ghcr.io" },
|
||||
{ "@id": "pkg:docker/gitea-mirror" },
|
||||
{ "@id": "pkg:docker/library/gitea-mirror" }
|
||||
],
|
||||
|
||||
@@ -32,6 +32,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Raised the dependency floors for the advisories published on 2026-09-08: Astro 7.2.8 (remote code execution through AVIF image optimization, and an authorization bypass when stripping the configured base), @xmldom/xmldom 0.8.15 (eight parser and serializer issues), sharp 0.35.4 (libheif), svgo 4.1.0 (removeScripts sanitization) and js-yaml 4.3.2 (merge-key CPU use). Applied to both the application and the documentation site.
|
||||
|
||||
### Added
|
||||
- Choose which releases count as candidates: every one, or stable releases only
|
||||
- A **Pre-releases** switch sits under the two release numbers in the mirror settings, and the same choice is in the per organization and per repository **Mirror options** dialog, where **Inherit** means the global value
|
||||
- The release controls in the global mirror settings are listed under each other instead of beside the switch, and in the **Mirror options** dialog the release limit, asset limit and tag rules sit directly under the **Releases** row instead of on a separate tab
|
||||
- **Stable only** skips everything the source marks as a pre-release, and the default **All releases** keeps the previous behaviour
|
||||
- The filter runs before the release limit and looks further back first, so "the newest 10" means the newest 10 matching releases rather than whatever survives of the newest 10
|
||||
- The retention pass also removes destination releases the filter excludes; a release the destination reports no pre-release flag for is kept, so an unknown flag never deletes the wrong thing
|
||||
- `RELEASE_TYPE=all|stable` seeds the global default, and no migration is needed because the value lives inside the existing JSON columns
|
||||
- Filter mirrored releases by tag name with **Include tags** and **Exclude tags**
|
||||
- **Custom tag rules...** opens a dialog holding the two lists, both in the mirror settings and in the per organization and per repository **Mirror options** dialog, where leaving both empty means the level above
|
||||
- A pattern is a glob over the whole tag (`v*`, `v1.*`, `*-rc.*`, `?` for one character) or a regular expression in slashes (`/^v\d+\.\d+\.\d+$/`), matched case-insensitively; exclude wins when both lists name the same tag, and a pattern that cannot compile is ignored instead of failing the run. Commas inside the slashes belong to the expression (`/^v\d{1,3}\.\d+$/`), in the dialog and in the environment variables alike
|
||||
- This is the filter for projects that ship release candidates without marking them as pre-releases, which the type filter cannot separate
|
||||
- A tag filter widens the release scan like the type filter does, so the limit counts the releases that match, and the retention pass removes destination releases whose tag no longer matches, including ones the destination reports no pre-release flag for
|
||||
- When the filters delete every release the destination had, the activity log shows a highlighted **warning** entry "All releases removed" and the **Sync errors** switch sends the same alert, so an over-broad rule cannot empty the destination silently
|
||||
- The dialog lists the releases the destination already holds and crosses out the ones the draft rules would delete on the next sync, so a pattern can be checked before saving. Opened from a repository it previews that one; from an organization or the global settings it adds a picker over the repositories the rules cover
|
||||
- `RELEASE_INCLUDE_TAGS` and `RELEASE_EXCLUDE_TAGS` seed the global lists; the values live in the existing JSON columns, so no migration is needed
|
||||
- Notifications for newly discovered repositories
|
||||
- "New repositories discovered" on the Notifications page, with three sub-switches under it: new repositories in your own account, in one of your organizations, and starred repositories
|
||||
- The scheduled sync, the boot auto-start and Sync Organization send them once per group with the repositories that were imported; all stars share a single "Starred" alert because their owners are arbitrary, and a long list is cut to the first 10 names plus an "and N more" line
|
||||
- The stored configuration gains `notifyOnNewOwnRepo`, `notifyOnNewOrgRepo` and `notifyOnNewStarredRepo` (default on) below the existing `notifyOnNewRepo` master switch; no migration is needed because they live inside the existing JSON column, and a row saved before them behaves as all three on
|
||||
- List view for the Organizations page (#428)
|
||||
- A cards/list switch in the toolbar; the list shows one row per organization with its role, destination, repository counts, last mirrored time, status, the same actions as the cards and the source and destination links, so a few dozen organizations fit on one screen
|
||||
- The choice is remembered per browser, next to the theme and time format preferences
|
||||
|
||||
+5
-4
@@ -32,13 +32,14 @@ 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.27.1
|
||||
ARG GO_VERSION=1.27.3
|
||||
ARG GIT_LFS_VERSION=3.7.1
|
||||
# Pinned rather than @latest: a cached layer kept an old @latest resolution
|
||||
# after golang.org/x/crypto 0.56.0 fixed CVE-2026-56855 and CVE-2026-78662.
|
||||
# x/crypto 0.56.0 needs Go 1.26 or newer, hence the toolchain above.
|
||||
ARG GO_X_CRYPTO_VERSION=v0.56.0
|
||||
ARG GO_X_NET_VERSION=v0.58.0
|
||||
# x/crypto and x/net need Go 1.26 or newer, hence the toolchain above.
|
||||
# x/net 0.61.0 requires x/crypto 0.58.0, so bump the two together.
|
||||
ARG GO_X_CRYPTO_VERSION=v0.58.0
|
||||
ARG GO_X_NET_VERSION=v0.61.0
|
||||
RUN ARCH="$(dpkg --print-architecture)" \
|
||||
&& wget -qO /tmp/go.tar.gz "https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz" \
|
||||
&& tar -C /usr/local -xzf /tmp/go.tar.gz \
|
||||
|
||||
@@ -240,7 +240,9 @@ Transfer complete repository metadata from GitHub to Gitea/Forgejo:
|
||||
- **Wiki** - Mirror wiki content
|
||||
- **Releases** - Transfer GitHub releases with assets
|
||||
|
||||
Releases have two limits. The release limit keeps only the newest N releases in Gitea/Forgejo. The release asset limit uploads assets only for the newest N of those, so a repository with years of releases can keep every release note and tag while only the recent binaries take up space. Leave the asset limit empty to upload assets for every mirrored release, or set it to 0 for release notes only. Both limits can be overridden per organization and per repository, and lowering the asset limit never deletes assets that were already uploaded.
|
||||
Releases have two limits and two filters. The release limit keeps only the newest N releases in Gitea/Forgejo. The release asset limit uploads assets only for the newest N of those, so a repository with years of releases can keep every release note and tag while only the recent binaries take up space. Leave the asset limit empty to upload assets for every mirrored release, or set it to 0 for release notes only. The **Pre-releases** switch skips everything the source marks as a pre-release, and **Custom tag rules** narrows releases by tag name (`v1.*`, `*-rc.*`, or a regular expression in slashes) for projects that ship candidates without marking them.
|
||||
|
||||
All of it can be overridden per organization and per repository in the **Mirror options** dialog, and lowering the asset limit never deletes assets that were already uploaded.
|
||||
|
||||
Enable in Settings → Mirror Options → Mirror metadata
|
||||
|
||||
|
||||
@@ -89,12 +89,14 @@
|
||||
"devalue": "^5.9.2",
|
||||
"fast-uri": "^3.1.5",
|
||||
"fast-xml-builder": "^1.1.7",
|
||||
"http-cache-semantics": "^4.3.0",
|
||||
"js-yaml": "^4.3.2",
|
||||
"kysely": "^0.28.17",
|
||||
"lodash": "^4.18.1",
|
||||
"picomatch": "^4.0.4",
|
||||
"sharp": "^0.35.4",
|
||||
"smol-toml": "^1.7.1",
|
||||
"sharp": "^0.35.5",
|
||||
"smol-toml": "^1.9.0",
|
||||
"source-map-js": "^1.2.2",
|
||||
"svgo": "^4.1.0",
|
||||
},
|
||||
"packages": {
|
||||
@@ -346,57 +348,57 @@
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="],
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="],
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="],
|
||||
|
||||
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="],
|
||||
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="],
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="],
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="],
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="],
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="],
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="],
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="],
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="],
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="],
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="],
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="],
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="],
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="],
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="],
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="],
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="],
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="],
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="],
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="],
|
||||
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="],
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="],
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="],
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
@@ -1056,7 +1058,7 @@
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
||||
"http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
@@ -1466,7 +1468,7 @@
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" } }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="],
|
||||
"sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" } }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="],
|
||||
|
||||
"shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
|
||||
|
||||
@@ -1474,13 +1476,13 @@
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="],
|
||||
"smol-toml": ["smol-toml@1.9.1", "", {}, "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ=="],
|
||||
|
||||
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||
|
||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
@@ -1718,7 +1720,7 @@
|
||||
|
||||
"@esbuild-kit/esm-loader/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
|
||||
"@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
|
||||
|
||||
|
||||
@@ -425,6 +425,10 @@
|
||||
url = "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz";
|
||||
hash = "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==";
|
||||
};
|
||||
"@emnapi/runtime@1.11.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz";
|
||||
hash = "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==";
|
||||
};
|
||||
"@emnapi/runtime@1.9.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz";
|
||||
hash = "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==";
|
||||
@@ -669,109 +673,109 @@
|
||||
url = "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz";
|
||||
hash = "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==";
|
||||
};
|
||||
"@img/sharp-darwin-arm64@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz";
|
||||
hash = "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==";
|
||||
"@img/sharp-darwin-arm64@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz";
|
||||
hash = "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==";
|
||||
};
|
||||
"@img/sharp-darwin-x64@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz";
|
||||
hash = "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==";
|
||||
"@img/sharp-darwin-x64@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz";
|
||||
hash = "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==";
|
||||
};
|
||||
"@img/sharp-freebsd-wasm32@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz";
|
||||
hash = "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==";
|
||||
"@img/sharp-freebsd-wasm32@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz";
|
||||
hash = "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==";
|
||||
};
|
||||
"@img/sharp-libvips-darwin-arm64@1.3.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz";
|
||||
hash = "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==";
|
||||
"@img/sharp-libvips-darwin-arm64@1.3.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz";
|
||||
hash = "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==";
|
||||
};
|
||||
"@img/sharp-libvips-darwin-x64@1.3.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz";
|
||||
hash = "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==";
|
||||
"@img/sharp-libvips-darwin-x64@1.3.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz";
|
||||
hash = "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==";
|
||||
};
|
||||
"@img/sharp-libvips-linux-arm64@1.3.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz";
|
||||
hash = "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==";
|
||||
"@img/sharp-libvips-linux-arm64@1.3.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz";
|
||||
hash = "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==";
|
||||
};
|
||||
"@img/sharp-libvips-linux-arm@1.3.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz";
|
||||
hash = "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==";
|
||||
"@img/sharp-libvips-linux-arm@1.3.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz";
|
||||
hash = "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==";
|
||||
};
|
||||
"@img/sharp-libvips-linux-ppc64@1.3.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz";
|
||||
hash = "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==";
|
||||
"@img/sharp-libvips-linux-ppc64@1.3.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz";
|
||||
hash = "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==";
|
||||
};
|
||||
"@img/sharp-libvips-linux-riscv64@1.3.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz";
|
||||
hash = "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==";
|
||||
"@img/sharp-libvips-linux-riscv64@1.3.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz";
|
||||
hash = "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==";
|
||||
};
|
||||
"@img/sharp-libvips-linux-s390x@1.3.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz";
|
||||
hash = "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==";
|
||||
"@img/sharp-libvips-linux-s390x@1.3.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz";
|
||||
hash = "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==";
|
||||
};
|
||||
"@img/sharp-libvips-linux-x64@1.3.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz";
|
||||
hash = "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==";
|
||||
"@img/sharp-libvips-linux-x64@1.3.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz";
|
||||
hash = "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==";
|
||||
};
|
||||
"@img/sharp-libvips-linuxmusl-arm64@1.3.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz";
|
||||
hash = "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==";
|
||||
"@img/sharp-libvips-linuxmusl-arm64@1.3.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz";
|
||||
hash = "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==";
|
||||
};
|
||||
"@img/sharp-libvips-linuxmusl-x64@1.3.3" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz";
|
||||
hash = "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==";
|
||||
"@img/sharp-libvips-linuxmusl-x64@1.3.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz";
|
||||
hash = "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==";
|
||||
};
|
||||
"@img/sharp-linux-arm64@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz";
|
||||
hash = "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==";
|
||||
"@img/sharp-linux-arm64@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz";
|
||||
hash = "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==";
|
||||
};
|
||||
"@img/sharp-linux-arm@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz";
|
||||
hash = "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==";
|
||||
"@img/sharp-linux-arm@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz";
|
||||
hash = "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==";
|
||||
};
|
||||
"@img/sharp-linux-ppc64@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz";
|
||||
hash = "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==";
|
||||
"@img/sharp-linux-ppc64@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz";
|
||||
hash = "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==";
|
||||
};
|
||||
"@img/sharp-linux-riscv64@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz";
|
||||
hash = "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==";
|
||||
"@img/sharp-linux-riscv64@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz";
|
||||
hash = "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==";
|
||||
};
|
||||
"@img/sharp-linux-s390x@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz";
|
||||
hash = "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==";
|
||||
"@img/sharp-linux-s390x@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz";
|
||||
hash = "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==";
|
||||
};
|
||||
"@img/sharp-linux-x64@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz";
|
||||
hash = "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==";
|
||||
"@img/sharp-linux-x64@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz";
|
||||
hash = "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==";
|
||||
};
|
||||
"@img/sharp-linuxmusl-arm64@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz";
|
||||
hash = "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==";
|
||||
"@img/sharp-linuxmusl-arm64@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz";
|
||||
hash = "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==";
|
||||
};
|
||||
"@img/sharp-linuxmusl-x64@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz";
|
||||
hash = "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==";
|
||||
"@img/sharp-linuxmusl-x64@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz";
|
||||
hash = "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==";
|
||||
};
|
||||
"@img/sharp-wasm32@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz";
|
||||
hash = "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==";
|
||||
"@img/sharp-wasm32@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz";
|
||||
hash = "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==";
|
||||
};
|
||||
"@img/sharp-webcontainers-wasm32@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz";
|
||||
hash = "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==";
|
||||
"@img/sharp-webcontainers-wasm32@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz";
|
||||
hash = "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==";
|
||||
};
|
||||
"@img/sharp-win32-arm64@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz";
|
||||
hash = "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==";
|
||||
"@img/sharp-win32-arm64@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz";
|
||||
hash = "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==";
|
||||
};
|
||||
"@img/sharp-win32-ia32@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz";
|
||||
hash = "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==";
|
||||
"@img/sharp-win32-ia32@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz";
|
||||
hash = "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==";
|
||||
};
|
||||
"@img/sharp-win32-x64@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz";
|
||||
hash = "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==";
|
||||
"@img/sharp-win32-x64@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz";
|
||||
hash = "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==";
|
||||
};
|
||||
"@jridgewell/gen-mapping@0.3.13" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz";
|
||||
@@ -2313,9 +2317,9 @@
|
||||
url = "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz";
|
||||
hash = "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==";
|
||||
};
|
||||
"http-cache-semantics@4.2.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz";
|
||||
hash = "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==";
|
||||
"http-cache-semantics@4.3.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz";
|
||||
hash = "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==";
|
||||
};
|
||||
"http-errors@2.0.1" = fetchurl {
|
||||
url = "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz";
|
||||
@@ -3189,9 +3193,9 @@
|
||||
url = "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz";
|
||||
hash = "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==";
|
||||
};
|
||||
"sharp@0.35.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz";
|
||||
hash = "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==";
|
||||
"sharp@0.35.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz";
|
||||
hash = "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==";
|
||||
};
|
||||
"shiki@4.0.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz";
|
||||
@@ -3205,17 +3209,17 @@
|
||||
url = "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz";
|
||||
hash = "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==";
|
||||
};
|
||||
"smol-toml@1.8.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz";
|
||||
hash = "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==";
|
||||
"smol-toml@1.9.1" = fetchurl {
|
||||
url = "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz";
|
||||
hash = "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==";
|
||||
};
|
||||
"sonner@2.0.7" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz";
|
||||
hash = "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==";
|
||||
};
|
||||
"source-map-js@1.2.1" = fetchurl {
|
||||
url = "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz";
|
||||
hash = "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==";
|
||||
"source-map-js@1.2.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz";
|
||||
hash = "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==";
|
||||
};
|
||||
"source-map@0.7.6" = fetchurl {
|
||||
url = "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz";
|
||||
|
||||
@@ -213,6 +213,9 @@ Control what content gets mirrored from GitHub to Gitea.
|
||||
|----------|-------------|---------|---------|
|
||||
| `MIRROR_RELEASES` | Mirror GitHub releases | `false` | `true`, `false` |
|
||||
| `RELEASE_LIMIT` | Maximum number of releases to mirror per repository | `10` | Number (1-100) |
|
||||
| `RELEASE_TYPE` | Which releases are candidates: `all` mirrors everything, `stable` skips what the source marks as a pre-release. With a filter active the release limit counts the releases that match, and releases of the other kind are removed from Gitea on the next sync | `all` | `all`, `stable` |
|
||||
| `RELEASE_INCLUDE_TAGS` | Comma separated tag patterns; only releases whose tag matches are mirrored. A pattern is a glob over the whole tag (`v1.*`, `*-rc.*`, `?` for one character) or a regular expression in slashes (`/^v\d+\.\d+\.\d+$/`), matched case-insensitively. Commas inside the slashes belong to the expression (`/^v\d{1,3}\.\d+$/`) | empty | any list of globs or `/regex/` |
|
||||
| `RELEASE_EXCLUDE_TAGS` | Comma separated tag patterns in the same syntax; releases whose tag matches are skipped, and an excluded tag wins over `RELEASE_INCLUDE_TAGS` | empty | any list of globs or `/regex/` |
|
||||
| `RELEASE_ASSET_LIMIT` | Upload release assets only for the newest N mirrored releases. Older releases still get their notes and tag. Unset means assets for every mirrored release, `0` means release notes only. Lowering it never deletes assets already uploaded | unset (all) | Number (0 or more) |
|
||||
| `MIRROR_WIKI` | Mirror wiki content | `false` | `true`, `false` |
|
||||
| `MIRROR_METADATA` | Master toggle for metadata mirroring | `false` | `true`, `false` |
|
||||
|
||||
@@ -107,7 +107,12 @@ The secret is encrypted at rest.
|
||||
|-------|---------|-------------|
|
||||
| Sync errors | On | A mirror job failed |
|
||||
| Sync success | Off | A mirror job completed successfully |
|
||||
| New repo discovered | Off | Not yet implemented; the toggle is disabled in the UI |
|
||||
| New repositories discovered | Off | New repositories were discovered and imported; the three switches below select which ones |
|
||||
| In your account | On | Repositories owned by your account |
|
||||
| In an organization | On | Repositories of one of your organizations |
|
||||
| Starred repositories | On | Repositories you starred, all stars in one alert |
|
||||
|
||||
The three group switches are sub-options of "New repositories discovered": they only apply while that switch is on, and a configuration saved before they existed counts as all three on (they are keys inside the stored settings JSON, so no database migration is involved). Each enabled group is sent once per group per discovery pass (scheduled sync, boot auto-start and the manual "Mirror Organization" sync), listing the repositories that were imported: one alert per account, one per organization, and a single "Starred" alert for every star because their owners are arbitrary. A large import is capped to the first 10 names plus an "and N more" line so one pass never floods your provider.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ Picking GitLab or Gitea/Forgejo shows an **Instance URL** field. Leave it empty
|
||||
|
||||
The **Test** button on the card checks the token against the selected host.
|
||||
|
||||
For a GitHub source, **Authentication** can also be set to **GitHub App installation**. Create a GitHub App with repository **Contents** and **Metadata** read permissions; grant **Issues** and **Pull requests** read permissions if mirroring that metadata. Install it on the repositories to back up, enter the installation account or organization under **Username**, then enter its App ID, installation ID and PEM private key in the Sources card. The private key is encrypted at rest and is not returned by the source API. The App sees only repositories selected for its installation; it cannot discover a user's starred repositories or star lists. PAT authentication remains available for those workflows.
|
||||
|
||||
App installation tokens expire after an hour, so Gitea Mirror stores a fresh one on the mirror before each of its own scheduled or manual syncs and turns off Gitea's independent pull schedule for App-backed repositories. Keep the Gitea Mirror scheduler enabled for ongoing App-backed mirrors. Existing PAT mirrors continue using their usual interval.
|
||||
|
||||
Replacing a mirror's credentials needs Gitea 1.27 or later as the destination. Gitea 1.26 and earlier, and Forgejo, ignore those fields, so App-backed repositories are refused there with an error instead of being mirrored and failing once the first token expires. Use a personal access token for those destinations. GitHub and GitLab destinations clone with a fresh token on every push and work with any version.
|
||||
|
||||
## What is mirrored
|
||||
|
||||
The mirror itself is done by Gitea's pull mirror, which treats every source as a plain git remote. The differences are in what the source API offers.
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: gitea-mirror
|
||||
description: Kubernetes helm chart for gitea-mirror
|
||||
type: application
|
||||
version: 3.40.4
|
||||
appVersion: "3.40.4"
|
||||
version: 3.44.0
|
||||
appVersion: "3.44.0"
|
||||
icon: https://github.com/RayLabsHQ/gitea-mirror/blob/main/.github/assets/logo.png
|
||||
keywords:
|
||||
- git
|
||||
|
||||
+6
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.40.4",
|
||||
"version": "3.44.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -51,12 +51,14 @@
|
||||
"fast-uri": "^3.1.5",
|
||||
"fast-xml-builder": "^1.1.7",
|
||||
"js-yaml": "^4.3.2",
|
||||
"smol-toml": "^1.7.1",
|
||||
"smol-toml": "^1.9.0",
|
||||
"kysely": "^0.28.17",
|
||||
"lodash": "^4.18.1",
|
||||
"picomatch": "^4.0.4",
|
||||
"sharp": "^0.35.4",
|
||||
"svgo": "^4.1.0"
|
||||
"sharp": "^0.35.5",
|
||||
"svgo": "^4.1.0",
|
||||
"source-map-js": "^1.2.2",
|
||||
"http-cache-semantics": "^4.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^7.0.3",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
GitFork,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../ui/card';
|
||||
import { cn, formatDate, formatLastSyncTime } from '@/lib/utils';
|
||||
@@ -83,6 +84,13 @@ export const STATUS_PRESENTATION: Record<
|
||||
tone: 'text-blue-600 dark:text-blue-400',
|
||||
wash: 'bg-blue-500/10',
|
||||
},
|
||||
warning: {
|
||||
Icon: TriangleAlert,
|
||||
label: 'Configuration warning',
|
||||
short: 'warning',
|
||||
tone: 'text-amber-600 dark:text-amber-400',
|
||||
wash: 'bg-amber-500/10',
|
||||
},
|
||||
};
|
||||
|
||||
const FALLBACK_PRESENTATION = {
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
import { repoStatusEnum, type RepoStatus } from '@/types/Repository';
|
||||
import { activityStatusEnum, type ActivityStatus } from '@/types/Repository';
|
||||
import ActivityList, { STATUS_PRESENTATION } from './ActivityList';
|
||||
import { ActivityNameCombobox } from './ActivityNameCombobox';
|
||||
import { useSSE } from '@/hooks/useSEE';
|
||||
@@ -413,7 +413,7 @@ export function ActivityLog() {
|
||||
const statusCounts = useMemo(() => countLatestStatuses(activities), [activities]);
|
||||
|
||||
// Most actionable first, and only statuses that actually occurred.
|
||||
const statChips = ['failed', 'syncing', 'mirroring', 'imported', 'synced', 'mirrored']
|
||||
const statChips = ['failed', 'warning', 'syncing', 'mirroring', 'imported', 'synced', 'mirrored']
|
||||
.filter((status) => (statusCounts.get(status) ?? 0) > 0)
|
||||
.slice(0, 4);
|
||||
|
||||
@@ -507,7 +507,7 @@ export function ActivityLog() {
|
||||
onValueChange={(v) =>
|
||||
setFilter((p) => ({
|
||||
...p,
|
||||
status: v === 'all' ? '' : (v as RepoStatus),
|
||||
status: v === 'all' ? '' : (v as ActivityStatus),
|
||||
}))
|
||||
}
|
||||
>
|
||||
@@ -515,13 +515,14 @@ export function ActivityLog() {
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['all', ...repoStatusEnum.options].map((s) => (
|
||||
{['all', ...activityStatusEnum.options].map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
<span className="flex items-center gap-2">
|
||||
{s !== 'all' && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
s === 'synced' ? 'bg-green-500' :
|
||||
s === 'failed' ? 'bg-red-500' :
|
||||
s === 'warning' ? 'bg-amber-500' :
|
||||
s === 'syncing' ? 'bg-blue-500' :
|
||||
'bg-yellow-500'
|
||||
}`} />
|
||||
@@ -735,7 +736,7 @@ export function ActivityLog() {
|
||||
onClick={() =>
|
||||
setFilter((p) => ({
|
||||
...p,
|
||||
status: p.status === status ? '' : (status as RepoStatus),
|
||||
status: p.status === status ? '' : (status as ActivityStatus),
|
||||
}))
|
||||
}
|
||||
className={cn(
|
||||
@@ -766,7 +767,7 @@ export function ActivityLog() {
|
||||
onValueChange={(v) =>
|
||||
setFilter((p) => ({
|
||||
...p,
|
||||
status: v === 'all' ? '' : (v as RepoStatus),
|
||||
status: v === 'all' ? '' : (v as ActivityStatus),
|
||||
}))
|
||||
}
|
||||
>
|
||||
@@ -774,13 +775,14 @@ export function ActivityLog() {
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['all', ...repoStatusEnum.options].map((s) => (
|
||||
{['all', ...activityStatusEnum.options].map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
<span className="flex items-center gap-2">
|
||||
{s !== 'all' && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
s === 'synced' ? 'bg-green-500' :
|
||||
s === 'failed' ? 'bg-red-500' :
|
||||
s === 'warning' ? 'bg-amber-500' :
|
||||
s === 'syncing' ? 'bg-blue-500' :
|
||||
'bg-yellow-500'
|
||||
}`} />
|
||||
|
||||
@@ -108,6 +108,9 @@ export function ConfigTabs() {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: false,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: true,
|
||||
notifyOnNewOrgRepo: true,
|
||||
notifyOnNewStarredRepo: true,
|
||||
},
|
||||
});
|
||||
const { user } = useAuth();
|
||||
@@ -133,7 +136,13 @@ export function ConfigTabs() {
|
||||
// githubConfig check only covers APIs that predate multi-source support.
|
||||
const isSourceConnected = (): boolean => {
|
||||
if (sources.length > 0) {
|
||||
return sources.some((source) => Boolean(source.token && source.token.trim()));
|
||||
return sources.some((source) => {
|
||||
if (!source.enabled) return false;
|
||||
if (source.authType === 'app') {
|
||||
return Boolean(source.appId?.trim() && source.installationId?.trim());
|
||||
}
|
||||
return Boolean(source.token && source.token.trim());
|
||||
});
|
||||
}
|
||||
const { githubConfig } = config;
|
||||
return !!(githubConfig.username.trim() && githubConfig.token.trim());
|
||||
@@ -608,8 +617,10 @@ export function ConfigTabs() {
|
||||
},
|
||||
advancedOptions:
|
||||
response.advancedOptions || prev.advancedOptions,
|
||||
notificationConfig:
|
||||
(response as any).notificationConfig || prev.notificationConfig,
|
||||
notificationConfig: {
|
||||
...prev.notificationConfig,
|
||||
...((response as any).notificationConfig ?? {}), // Merge so a row saved before a switch existed keeps its default
|
||||
},
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ import {
|
||||
Plus,
|
||||
Users,
|
||||
UserX,
|
||||
X
|
||||
X,
|
||||
FlaskConical,
|
||||
} from "lucide-react";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy, DestinationProvider } from "@/types/config";
|
||||
import { DESTINATION_PROVIDER_LABELS, isPushDestinationKind } from "@/lib/destination-kinds";
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { githubApi } from "@/lib/api";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import {
|
||||
sourceKindSupportsReleases,
|
||||
SOURCE_PROVIDER_LABELS,
|
||||
@@ -72,6 +74,7 @@ import {
|
||||
CardDivider,
|
||||
CardSection,
|
||||
} from "./settings-ui";
|
||||
import { ReleaseTagRulesDialog } from "./ReleaseTagRulesDialog";
|
||||
|
||||
interface GitHubMirrorSettingsProps {
|
||||
githubConfig: GitHubConfig;
|
||||
@@ -133,6 +136,40 @@ export function GitHubMirrorSettings({
|
||||
const [loadedStarLists, setLoadedStarLists] = React.useState(false);
|
||||
const [attemptedStarListLoad, setAttemptedStarListLoad] = React.useState(false);
|
||||
|
||||
// The tag patterns are edited in a dialog rather than inline: the wildcard
|
||||
// and regex syntax, and the fact that a now-unmatching release is deleted
|
||||
// from Gitea, both need room to explain themselves.
|
||||
const [tagRulesOpen, setTagRulesOpen] = React.useState(false);
|
||||
const tagPatternCount =
|
||||
(mirrorOptions.releaseIncludeTags?.length ?? 0) +
|
||||
(mirrorOptions.releaseExcludeTags?.length ?? 0);
|
||||
|
||||
// Global rules hit every repository, so the tag dialog also offers the
|
||||
// preview picker here, listing the whole account. The list is fetched once,
|
||||
// lazily, the first time the dialog opens.
|
||||
const [globalPreviewRepos, setGlobalPreviewRepos] = React.useState<
|
||||
{ id: string; label: string }[] | null
|
||||
>(null);
|
||||
React.useEffect(() => {
|
||||
if (!tagRulesOpen || globalPreviewRepos !== null) return;
|
||||
let cancelled = false;
|
||||
fetch(withBase("/api/repositories/preview-repositories"))
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
const data = payload?.data ?? payload;
|
||||
setGlobalPreviewRepos(
|
||||
Array.isArray(data?.repositories) ? data.repositories : []
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setGlobalPreviewRepos([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tagRulesOpen, globalPreviewRepos]);
|
||||
|
||||
const normalizeStarListNames = React.useCallback((lists: string[] | undefined): string[] => {
|
||||
if (!Array.isArray(lists)) return [];
|
||||
|
||||
@@ -164,7 +201,10 @@ export function GitHubMirrorSettings({
|
||||
onGitHubConfigChange({ ...githubConfig, [field]: value });
|
||||
};
|
||||
|
||||
const handleMirrorChange = (field: keyof MirrorOptions, value: boolean | number | null) => {
|
||||
const handleMirrorChange = (
|
||||
field: keyof MirrorOptions,
|
||||
value: boolean | number | string | string[] | null,
|
||||
) => {
|
||||
onMirrorOptionsChange({ ...mirrorOptions, [field]: value });
|
||||
};
|
||||
|
||||
@@ -912,6 +952,7 @@ export function GitHubMirrorSettings({
|
||||
|
||||
<SwitchRow
|
||||
icon={Tag}
|
||||
align="start"
|
||||
label="Releases & tags"
|
||||
description={
|
||||
isPushTarget
|
||||
@@ -927,8 +968,8 @@ export function GitHubMirrorSettings({
|
||||
onCheckedChange={(checked) => handleMirrorChange('mirrorReleases', checked)}
|
||||
extra={
|
||||
mirrorOptions.mirrorReleases ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="space-y-1.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="release-limit">Latest</label>
|
||||
<input
|
||||
id="release-limit"
|
||||
@@ -945,7 +986,7 @@ export function GitHubMirrorSettings({
|
||||
</div>
|
||||
{/* Empty means every mirrored release gets its assets (the
|
||||
behaviour before #311); 0 means notes only. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="release-asset-limit">Assets for latest</label>
|
||||
<input
|
||||
id="release-asset-limit"
|
||||
@@ -974,6 +1015,46 @@ export function GitHubMirrorSettings({
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Which kind of release counts as a candidate. Its own row because it
|
||||
only means anything once releases are mirrored; the tag patterns
|
||||
live in a dialog because their syntax needs room. */}
|
||||
{mirrorOptions.mirrorReleases && (
|
||||
<SwitchRow
|
||||
icon={FlaskConical}
|
||||
className="pl-6"
|
||||
label="Pre-releases"
|
||||
badge="NEW"
|
||||
description={
|
||||
isPushTarget
|
||||
? pushTargetReason
|
||||
: !isReleaseCapableSource
|
||||
? "Release filtering needs a GitHub or Gitea/Forgejo source"
|
||||
: "Turn off to skip releases the source marks as pre-release. Ones already in Gitea are removed on the next sync"
|
||||
}
|
||||
disabled={!isReleaseCapableSource}
|
||||
checked={mirrorOptions.releaseType !== "stable"}
|
||||
onCheckedChange={(checked) => handleMirrorChange('releaseType', checked ? "all" : "stable")}
|
||||
subContent={
|
||||
<div className="flex items-center gap-2 pt-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-[13px] font-normal"
|
||||
onClick={() => setTagRulesOpen(true)}
|
||||
>
|
||||
Custom tag rules...
|
||||
</Button>
|
||||
{tagPatternCount > 0 && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold text-muted-foreground">
|
||||
{tagPatternCount} {tagPatternCount === 1 ? "pattern" : "patterns"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SwitchRow
|
||||
icon={HardDrive}
|
||||
label="Git LFS objects"
|
||||
@@ -1004,6 +1085,24 @@ export function GitHubMirrorSettings({
|
||||
</CardSection>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
<ReleaseTagRulesDialog
|
||||
open={tagRulesOpen}
|
||||
onOpenChange={setTagRulesOpen}
|
||||
includeTags={mirrorOptions.releaseIncludeTags ?? []}
|
||||
excludeTags={mirrorOptions.releaseExcludeTags ?? []}
|
||||
previewRepositories={globalPreviewRepos}
|
||||
releaseType={mirrorOptions.releaseType}
|
||||
onSave={(includeTags, excludeTags) => {
|
||||
// Both fields in one call: handleMirrorChange would spread the same
|
||||
// stale mirrorOptions twice and drop the first list.
|
||||
onMirrorOptionsChange({
|
||||
...mirrorOptions,
|
||||
releaseIncludeTags: includeTags,
|
||||
releaseExcludeTags: excludeTags,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,17 +17,24 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { MirrorOverrides } from "@/lib/db/schema";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import type { MirrorOverrides, ReleaseType } from "@/lib/db/schema";
|
||||
import {
|
||||
compileReleaseTagPattern,
|
||||
getMirrorOverrideGating,
|
||||
MIRROR_OVERRIDE_LABELS,
|
||||
normalizeReleaseAssetLimit,
|
||||
normalizeReleaseLimit,
|
||||
normalizeReleaseTagPatterns,
|
||||
normalizeReleaseType,
|
||||
UI_MIRROR_OVERRIDE_KEYS,
|
||||
type InheritedMirrorOptions,
|
||||
type MirrorOverrideKey,
|
||||
type OrgSelectionOverrideKey,
|
||||
} from "@/lib/utils/mirror-overrides";
|
||||
import { ReleaseTagRulesDialog } from "./ReleaseTagRulesDialog";
|
||||
|
||||
/**
|
||||
* Tri-state value for one flag in the dialog. "inherit" persists as absent,
|
||||
@@ -35,6 +42,25 @@ import {
|
||||
*/
|
||||
type TriState = "inherit" | "on" | "off";
|
||||
|
||||
/**
|
||||
* The release type is a choice rather than a flag, so it gets its own three
|
||||
* values: inherit plus the two kinds. "inherit" persists as absent.
|
||||
*/
|
||||
type ReleaseTypeChoice = "inherit" | ReleaseType;
|
||||
|
||||
/**
|
||||
* What the single Releases row offers. It covers both stored fields at once:
|
||||
* "off" pins the `mirrorReleases` flag false, the two kinds pin it true
|
||||
* together with a `releaseType`, and "inherit" pins neither.
|
||||
*/
|
||||
type ReleaseChoice = "inherit" | ReleaseType | "off";
|
||||
|
||||
/** How the value the row inherits is described in the hint beside its label. */
|
||||
const RELEASE_TYPE_LABELS: Record<ReleaseType, string> = {
|
||||
all: "all releases",
|
||||
stable: "stable releases only",
|
||||
};
|
||||
|
||||
type DialogOverrideKey = MirrorOverrideKey | OrgSelectionOverrideKey;
|
||||
|
||||
function toTriState(value: boolean | null | undefined): TriState {
|
||||
@@ -67,6 +93,45 @@ function parseReleaseAssetLimitDraft(text: string): number | undefined {
|
||||
return normalizeReleaseAssetLimit(Number(trimmed));
|
||||
}
|
||||
|
||||
type TagPatternKey = "releaseIncludeTags" | "releaseExcludeTags";
|
||||
|
||||
/**
|
||||
* The stored list as the comma-separated text the box edits. An absent or
|
||||
* unusable list reads as empty, which is what "inherit" means here: an empty
|
||||
* list is not a pin, so a repository cannot clear a pattern its organization
|
||||
* set.
|
||||
*/
|
||||
function buildReleaseTagDraft(
|
||||
value: MirrorOverrides | null | undefined,
|
||||
key: TagPatternKey
|
||||
): string {
|
||||
return (normalizeReleaseTagPatterns(value?.[key]) ?? []).join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one tag-pattern box.
|
||||
*
|
||||
* `patterns` is what gets stored, or undefined when the box means "inherit".
|
||||
* `unusable` names the entries that would not take effect - too long, or a
|
||||
* regular expression that does not compile - so a typo is visible before saving
|
||||
* rather than silently ignored during a mirror run.
|
||||
*/
|
||||
function readTagPatternDraft(text: string): {
|
||||
patterns: string[] | undefined;
|
||||
unusable: string[];
|
||||
} {
|
||||
const entries = text
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
const patterns = normalizeReleaseTagPatterns(entries) ?? [];
|
||||
const kept = new Set(patterns.map((pattern) => pattern.toLowerCase()));
|
||||
const unusable = entries.filter(
|
||||
(entry) => !kept.has(entry.toLowerCase()) || compileReleaseTagPattern(entry) === null
|
||||
);
|
||||
return { patterns: patterns.length > 0 ? patterns : undefined, unusable };
|
||||
}
|
||||
|
||||
export interface MirrorOverridesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -74,6 +139,12 @@ export interface MirrorOverridesDialogProps {
|
||||
targetName: string;
|
||||
/** "repository" or "organization", used for wording and gating rules. */
|
||||
targetKind: "repository" | "organization";
|
||||
/**
|
||||
* Database id of the repository being edited. Only a single repository has one,
|
||||
* and only with it can the tag-rule dialog show which published releases its
|
||||
* rules keep or delete.
|
||||
*/
|
||||
targetId?: string | null;
|
||||
value?: MirrorOverrides | null;
|
||||
/**
|
||||
* Effective values from the tiers above this one, used to label what
|
||||
@@ -100,6 +171,7 @@ export function MirrorOverridesDialog({
|
||||
onOpenChange,
|
||||
targetName,
|
||||
targetKind,
|
||||
targetId,
|
||||
value,
|
||||
inheritedFrom,
|
||||
inheritedLabel = "global settings",
|
||||
@@ -128,7 +200,22 @@ export function MirrorOverridesDialog({
|
||||
const [releaseAssetLimitDraft, setReleaseAssetLimitDraft] = useState(() =>
|
||||
buildReleaseAssetLimitDraft(value)
|
||||
);
|
||||
const [releaseTypeDraft, setReleaseTypeDraft] = useState<ReleaseTypeChoice>(
|
||||
() => buildReleaseTypeDraft(value)
|
||||
);
|
||||
const [includeTagsDraft, setIncludeTagsDraft] = useState(() =>
|
||||
buildReleaseTagDraft(value, "releaseIncludeTags")
|
||||
);
|
||||
const [excludeTagsDraft, setExcludeTagsDraft] = useState(() =>
|
||||
buildReleaseTagDraft(value, "releaseExcludeTags")
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [tagRulesOpen, setTagRulesOpen] = useState(false);
|
||||
// The repositories an organization override covers. Its tag-rule preview needs
|
||||
// one chosen repository, so the rule dialog offers these in a picker.
|
||||
const [orgPreviewRepos, setOrgPreviewRepos] = useState<
|
||||
{ id: string; label: string }[]
|
||||
>([]);
|
||||
|
||||
// Re-seed whenever the dialog opens or targets a different object, so a
|
||||
// previous edit never leaks into the next one.
|
||||
@@ -137,9 +224,49 @@ export function MirrorOverridesDialog({
|
||||
setDraft(buildDraft(value, dialogKeys));
|
||||
setReleaseLimitDraft(buildReleaseLimitDraft(value));
|
||||
setReleaseAssetLimitDraft(buildReleaseAssetLimitDraft(value));
|
||||
setReleaseTypeDraft(buildReleaseTypeDraft(value));
|
||||
setIncludeTagsDraft(buildReleaseTagDraft(value, "releaseIncludeTags"));
|
||||
setExcludeTagsDraft(buildReleaseTagDraft(value, "releaseExcludeTags"));
|
||||
}
|
||||
}, [open, value, dialogKeys]);
|
||||
|
||||
// An organization override fans out over every repository it mirrors, so its
|
||||
// tag-rule preview cannot name a single destination up front. Fetch the
|
||||
// repositories it covers per open/target; the releases for whichever one the
|
||||
// user then picks come from the repository releases route.
|
||||
useEffect(() => {
|
||||
if (!open || targetKind !== "organization" || !targetId) {
|
||||
setOrgPreviewRepos([]);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
withBase(`/api/organizations/${targetId}/preview-repositories`),
|
||||
{
|
||||
signal: controller.signal,
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
}
|
||||
);
|
||||
const payload = await response.json().catch(() => null);
|
||||
const list = Array.isArray(payload?.repositories) ? payload.repositories : [];
|
||||
setOrgPreviewRepos(
|
||||
list
|
||||
.filter((entry: { id?: unknown }) => typeof entry.id === "string")
|
||||
.map((entry: { id: string; label?: unknown }) => ({
|
||||
id: entry.id,
|
||||
label: typeof entry.label === "string" ? entry.label : entry.id,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setOrgPreviewRepos([]);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [open, targetKind, targetId]);
|
||||
|
||||
// What each flag currently resolves to, including the in-progress edit.
|
||||
// Drives the labels and release-limit gates so they react live as issues
|
||||
// and releases are toggled.
|
||||
@@ -166,6 +293,18 @@ export function MirrorOverridesDialog({
|
||||
[targetKind, isStarred, starredCodeOnly, effective, destinationProvider]
|
||||
);
|
||||
|
||||
// What the tag-rule dialog previews. A repository override has the one row it
|
||||
// edits; an organization override has the list its picker offers, and nothing
|
||||
// until that list arrives. The global settings page never reaches this dialog.
|
||||
const previewRepositories = useMemo<
|
||||
readonly { id: string; label: string }[] | null
|
||||
>(() => {
|
||||
if (targetKind === "repository") {
|
||||
return targetId ? [{ id: targetId, label: targetName }] : null;
|
||||
}
|
||||
return orgPreviewRepos.length > 0 ? orgPreviewRepos : null;
|
||||
}, [targetKind, targetId, targetName, orgPreviewRepos]);
|
||||
|
||||
const pinnedReleaseLimit = parseReleaseLimitDraft(releaseLimitDraft);
|
||||
// Non-empty text that does not parse to a usable limit (0, negative, junk).
|
||||
const releaseLimitInvalid =
|
||||
@@ -175,13 +314,52 @@ export function MirrorOverridesDialog({
|
||||
const releaseAssetLimitInvalid =
|
||||
releaseAssetLimitDraft.trim() !== "" && pinnedReleaseAssetLimit === undefined;
|
||||
|
||||
const overriddenCount = useMemo(
|
||||
() =>
|
||||
Object.values(draft).filter((state) => state !== "inherit").length +
|
||||
(pinnedReleaseLimit !== undefined ? 1 : 0) +
|
||||
(pinnedReleaseAssetLimit !== undefined ? 1 : 0),
|
||||
[draft, pinnedReleaseLimit, pinnedReleaseAssetLimit]
|
||||
);
|
||||
const pinnedReleaseType: ReleaseType | undefined =
|
||||
releaseTypeDraft === "inherit" ? undefined : normalizeReleaseType(releaseTypeDraft);
|
||||
|
||||
// One control drives two stored fields. A stored "releases on" with no kind
|
||||
// pinned still reads as Inherit and keeps its pin on save, so an override set
|
||||
// before the kind filter existed is never dropped by merely opening the dialog.
|
||||
const releaseChoice: ReleaseChoice =
|
||||
draft.mirrorReleases === "off"
|
||||
? "off"
|
||||
: releaseTypeDraft !== "inherit"
|
||||
? releaseTypeDraft
|
||||
: "inherit";
|
||||
|
||||
const applyReleaseChoice = (next: ReleaseChoice) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
mirrorReleases:
|
||||
next === "inherit" ? "inherit" : next === "off" ? "off" : "on",
|
||||
}));
|
||||
// "off" leaves the pinned kind alone, so switching releases back on goes
|
||||
// straight back to the kind that was set before.
|
||||
if (next !== "off") setReleaseTypeDraft(next);
|
||||
};
|
||||
|
||||
const includeTags = readTagPatternDraft(includeTagsDraft);
|
||||
const excludeTags = readTagPatternDraft(excludeTagsDraft);
|
||||
const includeTagsInvalid = includeTags.unusable.length > 0;
|
||||
const excludeTagsInvalid = excludeTags.unusable.length > 0;
|
||||
const tagPatternsInvalid = includeTagsInvalid || excludeTagsInvalid;
|
||||
const unusableTagPatterns = [...includeTags.unusable, ...excludeTags.unusable];
|
||||
const tagPatternCount =
|
||||
(includeTags.patterns?.length ?? 0) + (excludeTags.patterns?.length ?? 0);
|
||||
|
||||
// How many rows pin a value, which is what "Reset all" waits on. The Releases
|
||||
// row counts once even though it writes two fields.
|
||||
const overriddenCount =
|
||||
Object.entries(draft).filter(
|
||||
([key, state]) => key !== "mirrorReleases" && state !== "inherit"
|
||||
).length +
|
||||
(draft.mirrorReleases !== "inherit" || releaseTypeDraft !== "inherit"
|
||||
? 1
|
||||
: 0) +
|
||||
(pinnedReleaseLimit !== undefined ? 1 : 0) +
|
||||
(pinnedReleaseAssetLimit !== undefined ? 1 : 0) +
|
||||
(includeTags.patterns !== undefined ? 1 : 0) +
|
||||
(excludeTags.patterns !== undefined ? 1 : 0);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
@@ -201,6 +379,13 @@ export function MirrorOverridesDialog({
|
||||
if (pinnedReleaseAssetLimit !== undefined) {
|
||||
next.releaseAssetLimit = pinnedReleaseAssetLimit;
|
||||
}
|
||||
if (pinnedReleaseType !== undefined) next.releaseType = pinnedReleaseType;
|
||||
if (includeTags.patterns !== undefined) {
|
||||
next.releaseIncludeTags = includeTags.patterns;
|
||||
}
|
||||
if (excludeTags.patterns !== undefined) {
|
||||
next.releaseExcludeTags = excludeTags.patterns;
|
||||
}
|
||||
await onSave(Object.keys(next).length > 0 ? next : null);
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
@@ -212,6 +397,9 @@ export function MirrorOverridesDialog({
|
||||
setDraft(buildDraft(null, dialogKeys));
|
||||
setReleaseLimitDraft("");
|
||||
setReleaseAssetLimitDraft("");
|
||||
setReleaseTypeDraft("inherit");
|
||||
setIncludeTagsDraft("");
|
||||
setExcludeTagsDraft("");
|
||||
};
|
||||
|
||||
const releaseLimitReason = gating.releaseLimit;
|
||||
@@ -235,6 +423,52 @@ export function MirrorOverridesDialog({
|
||||
releaseAssetLimitDraft.trim() === "" &&
|
||||
inheritedReleaseAssetLimit !== undefined;
|
||||
|
||||
// The Releases row is only locked by a tier-level clamp: the starred setting,
|
||||
// a push destination, or a source with no releases. Choosing "Off" here must
|
||||
// not disable the row, or the choice could never be undone.
|
||||
const releasesRowReason = gating.mirrorReleases;
|
||||
const releasesRowDisabled = !!releasesRowReason;
|
||||
// What the row resolves to while it inherits: the kind the tiers above pin,
|
||||
// which the runtime defaults to "all" when nothing names one. The tag-rule
|
||||
// preview needs it, because the retention pass applies the type filter and
|
||||
// the tag patterns together when it decides what stays in the destination.
|
||||
const effectiveReleaseType: ReleaseType =
|
||||
pinnedReleaseType ?? inheritedFrom?.releaseType ?? "all";
|
||||
const showReleasesHint =
|
||||
!inheritedLoading && !releasesRowDisabled && releaseChoice === "inherit";
|
||||
const releasesHintText = effective.mirrorReleases
|
||||
? RELEASE_TYPE_LABELS[effectiveReleaseType]
|
||||
: "off";
|
||||
|
||||
// Both boxes are gated by the same condition (they narrow the release filter),
|
||||
// so one reason covers the pair.
|
||||
const releaseTagsReason = gating.releaseIncludeTags ?? gating.releaseExcludeTags;
|
||||
const releaseTagsDisabled = !!releaseTagsReason;
|
||||
// An empty inherited list is no filter, so it is not worth a hint; only a list
|
||||
// that is actually filtering gets named beside the empty box.
|
||||
const inheritedIncludeTagText =
|
||||
inheritedFrom?.releaseIncludeTags && inheritedFrom.releaseIncludeTags.length > 0
|
||||
? inheritedFrom.releaseIncludeTags.join(", ")
|
||||
: undefined;
|
||||
const inheritedExcludeTagText =
|
||||
inheritedFrom?.releaseExcludeTags && inheritedFrom.releaseExcludeTags.length > 0
|
||||
? inheritedFrom.releaseExcludeTags.join(", ")
|
||||
: undefined;
|
||||
// The two lists are edited together in one dialog, so the inherited rules are
|
||||
// named as a single hint: "only v*, except *-rc.*". An empty inherited list is
|
||||
// no filter, so it is not worth naming.
|
||||
const inheritedTagText = [
|
||||
inheritedIncludeTagText ? `only ${inheritedIncludeTagText}` : null,
|
||||
inheritedExcludeTagText ? `except ${inheritedExcludeTagText}` : null,
|
||||
]
|
||||
.filter((text): text is string => !!text)
|
||||
.join(", ");
|
||||
const showTagsHint =
|
||||
!inheritedLoading &&
|
||||
!releaseTagsDisabled &&
|
||||
tagPatternCount === 0 &&
|
||||
inheritedTagText !== "";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
@@ -247,21 +481,195 @@ export function MirrorOverridesDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-2">
|
||||
{dialogKeys.map((key) => {
|
||||
const disabledReason = gating[key];
|
||||
const isDisabled = !!disabledReason;
|
||||
const inherited =
|
||||
key === "skipForks" ? inheritedFrom?.skipForks : inheritedFrom?.[key];
|
||||
const showHint =
|
||||
!inheritedLoading &&
|
||||
!isDisabled &&
|
||||
draft[key] === "inherit" &&
|
||||
inherited !== undefined;
|
||||
<div className="max-h-[62vh] overflow-y-auto pr-1">
|
||||
<div className="space-y-3">
|
||||
{dialogKeys.map((key) => {
|
||||
// Everything release related is grouped under the Releases row,
|
||||
// so the limits and the tag rules never drift away from the
|
||||
// filter they narrow.
|
||||
if (key === "mirrorReleases") {
|
||||
return (
|
||||
<div key={key} className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label
|
||||
htmlFor="override-releases"
|
||||
className={
|
||||
releasesRowDisabled
|
||||
? "flex-1 text-muted-foreground"
|
||||
: "flex-1"
|
||||
}
|
||||
>
|
||||
{MIRROR_OVERRIDE_LABELS.mirrorReleases}
|
||||
{showReleasesHint && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
currently {releasesHintText}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Select
|
||||
value={releaseChoice}
|
||||
disabled={releasesRowDisabled}
|
||||
onValueChange={(next) =>
|
||||
applyReleaseChoice(next as ReleaseChoice)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="override-releases" className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">Inherit</SelectItem>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="stable">Stable only</SelectItem>
|
||||
<SelectItem value="off">Off</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{releasesRowReason && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{releasesRowReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label
|
||||
htmlFor="override-releaseLimit"
|
||||
className={
|
||||
releaseLimitDisabled
|
||||
? "flex-1 text-muted-foreground"
|
||||
: "flex-1"
|
||||
}
|
||||
>
|
||||
{MIRROR_OVERRIDE_LABELS.releaseLimit}
|
||||
{showReleaseLimitHint && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
currently {inheritedReleaseLimit}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="override-releaseLimit"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
placeholder="Inherit"
|
||||
className="w-32"
|
||||
value={releaseLimitDraft}
|
||||
disabled={releaseLimitDisabled}
|
||||
aria-invalid={releaseLimitInvalid || undefined}
|
||||
onChange={(event) =>
|
||||
setReleaseLimitDraft(event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!releaseLimitDisabled && releaseLimitInvalid && (
|
||||
<p className="text-xs text-destructive">
|
||||
Enter a whole number of 1 or more, or leave it empty to
|
||||
inherit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label
|
||||
htmlFor="override-releaseAssetLimit"
|
||||
className={
|
||||
releaseAssetLimitDisabled
|
||||
? "flex-1 text-muted-foreground"
|
||||
: "flex-1"
|
||||
}
|
||||
>
|
||||
{MIRROR_OVERRIDE_LABELS.releaseAssetLimit}
|
||||
{showReleaseAssetLimitHint && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
currently{" "}
|
||||
{inheritedReleaseAssetLimit === null
|
||||
? "all"
|
||||
: inheritedReleaseAssetLimit}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="override-releaseAssetLimit"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="Inherit"
|
||||
className="w-32"
|
||||
value={releaseAssetLimitDraft}
|
||||
disabled={releaseAssetLimitDisabled}
|
||||
aria-invalid={releaseAssetLimitInvalid || undefined}
|
||||
onChange={(event) =>
|
||||
setReleaseAssetLimitDraft(event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!releaseAssetLimitDisabled && releaseAssetLimitInvalid && (
|
||||
<p className="text-xs text-destructive">
|
||||
Enter a whole number of 0 or more, or leave it empty to
|
||||
inherit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag patterns narrow the kind filter further, for upstreams
|
||||
that never set the pre-release flag but tag v1.2.3-rc.1. */}
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
id="override-tag-rules"
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md border border-dashed px-3 py-2 text-left transition-colors hover:bg-muted",
|
||||
releaseTagsDisabled && "cursor-not-allowed opacity-60"
|
||||
)}
|
||||
disabled={releaseTagsDisabled}
|
||||
onClick={() => setTagRulesOpen(true)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Custom tag rules</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
optional
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{tagPatternCount > 0
|
||||
? `${tagPatternCount} set`
|
||||
: showTagsHint
|
||||
? `currently ${inheritedTagText}`
|
||||
: "none set"}
|
||||
</span>
|
||||
</button>
|
||||
{releaseTagsReason ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{releaseTagsReason}
|
||||
</p>
|
||||
) : tagPatternsInvalid ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{unusableTagPatterns.join(", ")} cannot be used as a tag
|
||||
pattern.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const disabledReason = gating[key];
|
||||
const isDisabled = !!disabledReason;
|
||||
const inherited =
|
||||
key === "skipForks" ? inheritedFrom?.skipForks : inheritedFrom?.[key];
|
||||
const showHint =
|
||||
!inheritedLoading &&
|
||||
!isDisabled &&
|
||||
draft[key] === "inherit" &&
|
||||
inherited !== undefined;
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label
|
||||
htmlFor={`override-${key}`}
|
||||
@@ -297,125 +705,15 @@ export function MirrorOverridesDialog({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* The limits only mean something while releases are mirrored,
|
||||
so they sit directly under that row. */}
|
||||
{key === "mirrorReleases" && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label
|
||||
htmlFor="override-releaseLimit"
|
||||
className={
|
||||
releaseLimitDisabled
|
||||
? "flex-1 text-muted-foreground"
|
||||
: "flex-1"
|
||||
}
|
||||
>
|
||||
{MIRROR_OVERRIDE_LABELS.releaseLimit}
|
||||
{showReleaseLimitHint && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
currently {inheritedReleaseLimit}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="override-releaseLimit"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
placeholder="Inherit"
|
||||
className="w-32"
|
||||
value={releaseLimitDraft}
|
||||
disabled={releaseLimitDisabled}
|
||||
aria-invalid={releaseLimitInvalid || undefined}
|
||||
onChange={(event) => setReleaseLimitDraft(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{releaseLimitDisabled ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{releaseLimitReason}
|
||||
</p>
|
||||
) : releaseLimitInvalid ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Enter a whole number of 1 or more, or leave it empty to
|
||||
inherit.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Newest releases to keep. Older ones past the limit are
|
||||
removed from Gitea on the next sync.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label
|
||||
htmlFor="override-releaseAssetLimit"
|
||||
className={
|
||||
releaseAssetLimitDisabled
|
||||
? "flex-1 text-muted-foreground"
|
||||
: "flex-1"
|
||||
}
|
||||
>
|
||||
{MIRROR_OVERRIDE_LABELS.releaseAssetLimit}
|
||||
{showReleaseAssetLimitHint && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
currently{" "}
|
||||
{inheritedReleaseAssetLimit === null
|
||||
? "all"
|
||||
: inheritedReleaseAssetLimit}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="override-releaseAssetLimit"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="Inherit"
|
||||
className="w-32"
|
||||
value={releaseAssetLimitDraft}
|
||||
disabled={releaseAssetLimitDisabled}
|
||||
aria-invalid={releaseAssetLimitInvalid || undefined}
|
||||
onChange={(event) =>
|
||||
setReleaseAssetLimitDraft(event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{releaseAssetLimitDisabled ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{releaseAssetLimitReason}
|
||||
</p>
|
||||
) : releaseAssetLimitInvalid ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Enter a whole number of 0 or more, or leave it empty to
|
||||
inherit.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Assets are uploaded only for this many of the newest
|
||||
releases; 0 keeps release notes only. Assets already in
|
||||
Gitea are never removed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Inherit uses the {inheritedLabel}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Inherit uses the {inheritedLabel}. Git LFS is worth turning off for
|
||||
repositories whose LFS fetch fails, since Gitea aborts the whole
|
||||
migration when it cannot pull them.
|
||||
</p>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -433,11 +731,32 @@ export function MirrorOverridesDialog({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || releaseLimitInvalid || releaseAssetLimitInvalid}
|
||||
disabled={
|
||||
isSaving ||
|
||||
releaseLimitInvalid ||
|
||||
releaseAssetLimitInvalid ||
|
||||
includeTagsInvalid ||
|
||||
excludeTagsInvalid
|
||||
}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
{/* Nested on purpose: the pattern syntax needs more room than this
|
||||
compact override dialog has. */}
|
||||
<ReleaseTagRulesDialog
|
||||
open={tagRulesOpen}
|
||||
onOpenChange={setTagRulesOpen}
|
||||
includeTags={includeTags.patterns ?? []}
|
||||
excludeTags={excludeTags.patterns ?? []}
|
||||
previewRepositories={previewRepositories}
|
||||
releaseType={effectiveReleaseType}
|
||||
onSave={(include, exclude) => {
|
||||
setIncludeTagsDraft(include.join(", "));
|
||||
setExcludeTagsDraft(exclude.join(", "));
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -467,3 +786,10 @@ function buildReleaseAssetLimitDraft(
|
||||
const limit = normalizeReleaseAssetLimit(value?.releaseAssetLimit);
|
||||
return limit === undefined ? "" : String(limit);
|
||||
}
|
||||
|
||||
/** An unusable stored value reads as inherit, matching how the resolver treats it. */
|
||||
function buildReleaseTypeDraft(
|
||||
value: MirrorOverrides | null | undefined
|
||||
): ReleaseTypeChoice {
|
||||
return normalizeReleaseType(value?.releaseType) ?? "inherit";
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Bell, Activity, Send, Info } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { NotificationConfig } from "@/types/config";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
SettingsCard,
|
||||
SectionTitle,
|
||||
@@ -502,11 +503,9 @@ export function NotificationSettings({
|
||||
<CardDivider />
|
||||
<CardSection>
|
||||
<SwitchRow
|
||||
label="New repository discovered"
|
||||
description="Notify when a new GitHub repository is auto-imported"
|
||||
badge="SOON"
|
||||
label="New repositories discovered"
|
||||
description="Notify when new repositories are discovered and imported; choose below which ones"
|
||||
checked={notificationConfig.notifyOnNewRepo}
|
||||
disabled
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
@@ -514,6 +513,50 @@ export function NotificationSettings({
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* Sub-options of the switch above: they only apply while it is on. */}
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-4 border-l-2 border-border pl-5",
|
||||
notificationConfig.notifyOnNewRepo ? "" : "opacity-60"
|
||||
)}
|
||||
>
|
||||
<SwitchRow
|
||||
label="In your account"
|
||||
description="Repositories owned by your account"
|
||||
checked={notificationConfig.notifyOnNewOwnRepo}
|
||||
disabled={!notificationConfig.notifyOnNewRepo}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
notifyOnNewOwnRepo: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<SwitchRow
|
||||
label="In an organization"
|
||||
description="Repositories of one of your organizations"
|
||||
checked={notificationConfig.notifyOnNewOrgRepo}
|
||||
disabled={!notificationConfig.notifyOnNewRepo}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
notifyOnNewOrgRepo: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<SwitchRow
|
||||
label="Starred repositories"
|
||||
description="Repositories you starred; all stars share one notification"
|
||||
checked={notificationConfig.notifyOnNewStarredRepo}
|
||||
disabled={!notificationConfig.notifyOnNewRepo}
|
||||
onCheckedChange={(checked) =>
|
||||
onNotificationChange({
|
||||
...notificationConfig,
|
||||
notifyOnNewStarredRepo: checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardSection>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, XCircle } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
compileReleaseTagPattern,
|
||||
compileReleaseTagPatterns,
|
||||
MAX_RELEASE_TAG_PATTERNS,
|
||||
MAX_RELEASE_TAG_PATTERN_LENGTH,
|
||||
normalizeReleaseTagPatterns,
|
||||
releaseMatchesTagFilters,
|
||||
releaseMatchesType,
|
||||
splitReleaseTagEntries,
|
||||
} from "@/lib/utils/mirror-overrides";
|
||||
import type { ReleaseType } from "@/lib/db/schema";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
|
||||
/** A release the destination already holds for the repository being edited. */
|
||||
type PublishedRelease = {
|
||||
tag: string;
|
||||
prerelease: boolean | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* One repository the rule can be previewed against. A repository override offers
|
||||
* exactly one; an organization override offers every repository it covers, and
|
||||
* the picker lets the user choose which one the preview runs against.
|
||||
*/
|
||||
type PreviewRepository = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type PreviewState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
status: "ready";
|
||||
destination: string;
|
||||
releases: PublishedRelease[];
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One tag-rule box read as text.
|
||||
*
|
||||
* `patterns` is what gets stored. `unusable` names the entries that would never
|
||||
* take effect - too long, past the count cap, or a regular expression that does
|
||||
* not compile - so a typo is visible here instead of being silently dropped
|
||||
* during a mirror run.
|
||||
*/
|
||||
function readTagRuleDraft(text: string): { patterns: string[]; unusable: string[] } {
|
||||
// Commas inside /.../ belong to the regex, not to the list.
|
||||
const entries = splitReleaseTagEntries(text);
|
||||
const patterns = normalizeReleaseTagPatterns(entries) ?? [];
|
||||
const kept = new Set(patterns.map((pattern) => pattern.toLowerCase()));
|
||||
const unusable = entries.filter(
|
||||
(entry) => !kept.has(entry.toLowerCase()) || compileReleaseTagPattern(entry) === null
|
||||
);
|
||||
return { patterns, unusable };
|
||||
}
|
||||
|
||||
export interface ReleaseTagRulesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
includeTags?: readonly string[] | null;
|
||||
excludeTags?: readonly string[] | null;
|
||||
/** An empty list means that side does not filter. */
|
||||
onSave: (includeTags: string[], excludeTags: string[]) => void;
|
||||
/**
|
||||
* Repositories whose published releases the draft rules can be checked
|
||||
* against. When set, the dialog lists what the destination already holds and
|
||||
* marks the releases a save keeps and the ones the next sync would delete. A
|
||||
* repository override passes the single repository it edits; an organization
|
||||
* override passes every repository it covers and the dialog adds a picker so
|
||||
* the user previews one of them. It is left out where nothing single applies:
|
||||
* the global settings cover every repository at once.
|
||||
*/
|
||||
previewRepositories?: readonly PreviewRepository[] | null;
|
||||
/**
|
||||
* The release type this repository ends up with. The retention pass applies the
|
||||
* type filter and the tag patterns together, so the preview would be wrong if
|
||||
* it only looked at the patterns. Defaults to "all" (no type filter).
|
||||
*/
|
||||
releaseType?: ReleaseType;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tag patterns that narrow which releases are mirrored. They live in a
|
||||
* dialog rather than inline on the settings page because the syntax needs room
|
||||
* to explain itself; the page only shows how many rules are set.
|
||||
*/
|
||||
export function ReleaseTagRulesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
includeTags,
|
||||
excludeTags,
|
||||
onSave,
|
||||
previewRepositories,
|
||||
releaseType = "all",
|
||||
}: ReleaseTagRulesDialogProps) {
|
||||
const [includeText, setIncludeText] = useState("");
|
||||
const [excludeText, setExcludeText] = useState("");
|
||||
const [preview, setPreview] = useState<PreviewState | null>(null);
|
||||
// Which repository the preview runs against. An organization override offers
|
||||
// several; a repository override has only the one it edits.
|
||||
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(null);
|
||||
|
||||
// Re-seeded on every open so a config change elsewhere cannot be overwritten
|
||||
// by a draft left over from a previous visit.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setIncludeText((normalizeReleaseTagPatterns(includeTags) ?? []).join(", "));
|
||||
setExcludeText((normalizeReleaseTagPatterns(excludeTags) ?? []).join(", "));
|
||||
}, [open, includeTags, excludeTags]);
|
||||
|
||||
// The published releases are read once per open. Judging them against the rules
|
||||
// happens in the browser, so editing a pattern afterwards costs no request.
|
||||
useEffect(() => {
|
||||
if (!open || !selectedRepoId) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setPreview({ status: "loading" });
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
withBase(`/api/repositories/${selectedRepoId}/releases`),
|
||||
{
|
||||
signal: controller.signal,
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
}
|
||||
);
|
||||
const payload = await response.json().catch(() => null);
|
||||
const data = payload?.data ?? payload;
|
||||
if (!response.ok || data?.success === false) {
|
||||
setPreview({
|
||||
status: "error",
|
||||
error:
|
||||
typeof data?.error === "string" ? data.error : `Request failed (${response.status})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPreview({
|
||||
status: "ready",
|
||||
destination: typeof data?.destination === "string" ? data.destination : "",
|
||||
releases: Array.isArray(data?.releases) ? data.releases : [],
|
||||
truncated: data?.truncated === true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
setPreview({
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : "The destination could not be reached",
|
||||
});
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [open, selectedRepoId]);
|
||||
|
||||
// Keep the selection pointed at a repository that is actually offered. Opening
|
||||
// a different organization, or one whose repository list changed, drops a
|
||||
// stale selection back to the first entry.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const offered = previewRepositories ?? [];
|
||||
setSelectedRepoId((previous) =>
|
||||
previous && offered.some((repository) => repository.id === previous)
|
||||
? previous
|
||||
: (offered[0]?.id ?? null)
|
||||
);
|
||||
}, [open, previewRepositories]);
|
||||
|
||||
const include = readTagRuleDraft(includeText);
|
||||
const exclude = readTagRuleDraft(excludeText);
|
||||
const unusable = [
|
||||
...include.unusable.map((pattern) => `Include: ${pattern}`),
|
||||
...exclude.unusable.map((pattern) => `Exclude: ${pattern}`),
|
||||
];
|
||||
|
||||
// Re-run what the retention pass does to the destination: a published release
|
||||
// stays when its tag passes the patterns and - only where the destination
|
||||
// reported a pre-release flag - the type filter as well. An unknown flag is
|
||||
// never guessed at, exactly as in a mirror run.
|
||||
const kept: string[] = [];
|
||||
const removed: string[] = [];
|
||||
if (preview?.status === "ready") {
|
||||
const includePatterns = compileReleaseTagPatterns(include.patterns);
|
||||
const excludePatterns = compileReleaseTagPatterns(exclude.patterns);
|
||||
for (const release of preview.releases) {
|
||||
const survives =
|
||||
releaseMatchesTagFilters(release.tag, includePatterns, excludePatterns) &&
|
||||
(releaseType === "all" ||
|
||||
typeof release.prerelease !== "boolean" ||
|
||||
releaseMatchesType({ prerelease: release.prerelease }, releaseType));
|
||||
(survives ? kept : removed).push(release.tag);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Custom release tag rules</DialogTitle>
|
||||
<DialogDescription>
|
||||
Narrow which releases are mirrored by their tag name. An excluded tag
|
||||
wins when both lists name it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="tag-rules-include">Include tags</Label>
|
||||
<Input
|
||||
id="tag-rules-include"
|
||||
type="text"
|
||||
placeholder="v*, release-*"
|
||||
value={includeText}
|
||||
onChange={(e) => setIncludeText(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When set, only releases whose tag matches one of these are mirrored.
|
||||
Empty means no include filter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="tag-rules-exclude">Exclude tags</Label>
|
||||
<Input
|
||||
id="tag-rules-exclude"
|
||||
type="text"
|
||||
placeholder="*-rc.*, *-alpha.*, *-next.*"
|
||||
value={excludeText}
|
||||
onChange={(e) => setExcludeText(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Releases whose tag matches one of these are skipped, even when an
|
||||
include pattern matches. Empty means no exclude filter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Patterns are matched against the whole tag name, ignoring case:{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">v1.*</code> matches v1.4
|
||||
and v1.4.0, <code className="rounded bg-muted px-1 py-0.5">?</code> is
|
||||
exactly one character, and{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">/^\d+\.\d+$/</code> is a
|
||||
regular expression. Up to {MAX_RELEASE_TAG_PATTERNS} patterns of{" "}
|
||||
{MAX_RELEASE_TAG_PATTERN_LENGTH} characters each. Releases already in
|
||||
Gitea that stop matching are removed on the next sync.
|
||||
</p>
|
||||
|
||||
{previewRepositories && previewRepositories.length > 0 ? (
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
{previewRepositories.length > 1 ? (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="tag-rules-preview-repo">Preview repository</Label>
|
||||
<Select
|
||||
value={selectedRepoId ?? ""}
|
||||
onValueChange={(next) => setSelectedRepoId(next)}
|
||||
>
|
||||
<SelectTrigger id="tag-rules-preview-repo" className="w-full">
|
||||
<SelectValue placeholder="Choose a repository" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{previewRepositories.map((repository) => (
|
||||
<SelectItem key={repository.id} value={repository.id}>
|
||||
{repository.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">Already in Gitea</span>
|
||||
{preview?.status === "ready" && preview.releases.length > 0 ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
removed.length > 0 ? "text-destructive" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{removed.length === 0
|
||||
? `all ${kept.length} kept`
|
||||
: `${removed.length} of ${preview.releases.length} would be removed`}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{preview?.status === "loading" ? (
|
||||
<p className="text-xs text-muted-foreground">Checking the destination…</p>
|
||||
) : null}
|
||||
|
||||
{preview?.status === "error" ? (
|
||||
<p className="text-xs text-destructive">{preview.error}</p>
|
||||
) : null}
|
||||
|
||||
{preview?.status === "ready" && preview.releases.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{preview.destination
|
||||
? `Nothing is published in ${preview.destination} yet.`
|
||||
: "Nothing is published there yet."}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{preview?.status === "ready" && preview.releases.length > 0 ? (
|
||||
<>
|
||||
<div className="grid max-h-40 gap-x-3 gap-y-1 overflow-y-auto pr-1 sm:grid-cols-2">
|
||||
{removed.map((tag) => (
|
||||
<div key={tag} className="flex items-center gap-2 text-xs">
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />
|
||||
<span className="truncate font-mono text-muted-foreground line-through">
|
||||
{tag}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{kept.map((tag) => (
|
||||
<div key={tag} className="flex items-center gap-2 text-xs">
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-green-600 dark:text-green-400" />
|
||||
<span className="truncate font-mono">{tag}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The destination is checked against these rules on every sync, so a
|
||||
crossed-out release is deleted the next time this repository syncs.
|
||||
{preview.truncated
|
||||
? ` Only the newest ${preview.releases.length} published releases are listed.`
|
||||
: ""}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{unusable.length > 0 && (
|
||||
<p className="rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
Cannot be used: {unusable.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onSave(include.patterns, exclude.patterns);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
disabled={unusable.length > 0}
|
||||
>
|
||||
Save rules
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -178,6 +178,10 @@ type EditorState = {
|
||||
url: string;
|
||||
username: string;
|
||||
token: string;
|
||||
authType: "pat" | "app";
|
||||
appId: string;
|
||||
installationId: string;
|
||||
privateKey: string;
|
||||
/** Set once the lock dialog confirmed changing provider or instance URL. */
|
||||
sourceUnlocked: boolean;
|
||||
};
|
||||
@@ -210,8 +214,9 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
// An empty token field while editing keeps the stored one, so testing
|
||||
// and saving both fall back to it.
|
||||
const effectiveToken = editor?.token.trim() || editing?.token || "";
|
||||
const isApp = provider === "github" && editor?.authType === "app";
|
||||
const isPublicOnlyDraft =
|
||||
!!editor && !editor.username.trim() && !effectiveToken;
|
||||
!!editor && !editor.username.trim() && !effectiveToken && !isApp;
|
||||
|
||||
const refreshSources = async () => {
|
||||
setIsRefreshing(true);
|
||||
@@ -243,6 +248,10 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
url: "",
|
||||
username: "",
|
||||
token: "",
|
||||
authType: "pat",
|
||||
appId: "",
|
||||
installationId: "",
|
||||
privateKey: "",
|
||||
sourceUnlocked: false,
|
||||
});
|
||||
};
|
||||
@@ -254,6 +263,10 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
url: source.url ?? "",
|
||||
username: source.username ?? "",
|
||||
token: "",
|
||||
authType: source.authType ?? "pat",
|
||||
appId: source.appId ?? "",
|
||||
installationId: source.installationId ?? "",
|
||||
privateKey: "",
|
||||
sourceUnlocked: false,
|
||||
});
|
||||
};
|
||||
@@ -276,7 +289,7 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!editor) return;
|
||||
if (!effectiveToken) {
|
||||
if (!effectiveToken && !isApp) {
|
||||
toast.error(`${providerLabel} token is required to test the connection`);
|
||||
return;
|
||||
}
|
||||
@@ -284,7 +297,14 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
setIsTesting(true);
|
||||
|
||||
try {
|
||||
const result = await githubApi.testConnection(effectiveToken, {
|
||||
const result = isApp ? await (async () => {
|
||||
const response = await fetch(withBase("/api/github/test-connection"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, url: editor.url, username: editor.username.trim(), authType: "app", appId: editor.appId, installationId: editor.installationId, privateKey: editor.privateKey, sourceId: editing?.id }),
|
||||
});
|
||||
return response.json();
|
||||
})() : await githubApi.testConnection(effectiveToken, {
|
||||
provider,
|
||||
url: editor.url.trim() || undefined,
|
||||
username: editor.username.trim() || undefined,
|
||||
@@ -314,13 +334,25 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
provider,
|
||||
username: editor.username.trim(),
|
||||
};
|
||||
if (provider === "github") {
|
||||
body.authType = editor.authType;
|
||||
if (isApp) {
|
||||
body.appId = editor.appId;
|
||||
body.installationId = editor.installationId;
|
||||
body.privateKey = editor.privateKey;
|
||||
}
|
||||
}
|
||||
if (editor.url.trim()) {
|
||||
body.url = editor.url.trim();
|
||||
}
|
||||
// Empty token on edit keeps the stored one (existing save convention).
|
||||
if (!editing || editor.token.trim()) {
|
||||
if (!isApp && (!editing || editor.token.trim())) {
|
||||
body.token = editor.token.trim();
|
||||
}
|
||||
if (editing?.authType === "app" && !isApp && !editor.token.trim()) {
|
||||
toast.error("Enter a token to stop using the GitHub App credentials");
|
||||
return;
|
||||
}
|
||||
if (editing && (confirmSourceChange || editor.sourceUnlocked)) {
|
||||
body.confirmSourceChange = true;
|
||||
}
|
||||
@@ -505,7 +537,27 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{provider === "github" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="source-auth-type" className="text-xs font-medium text-muted-foreground">Authentication</Label>
|
||||
<Select value={editor.authType} onValueChange={(authType: "pat" | "app") => setEditor((prev) => prev ? { ...prev, authType } : prev)}>
|
||||
<SelectTrigger id="source-auth-type"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pat">Personal access token</SelectItem>
|
||||
<SelectItem value="app">GitHub App installation</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isApp ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5"><Label htmlFor="source-app-id">App ID</Label><Input id="source-app-id" name="appId" inputMode="numeric" value={editor.appId} onChange={handleFieldChange} /></div>
|
||||
<div className="space-y-1.5"><Label htmlFor="source-installation-id">Installation ID</Label><Input id="source-installation-id" name="installationId" inputMode="numeric" value={editor.installationId} onChange={handleFieldChange} /></div>
|
||||
<div className="space-y-1.5"><Label htmlFor="source-private-key">Private key (PEM)</Label><textarea id="source-private-key" name="privateKey" value={editor.privateKey} onChange={(event) => setEditor((prev) => prev ? { ...prev, privateKey: event.target.value } : prev)} placeholder={editing?.authType === "app" ? "Leave empty to keep the current key" : "-----BEGIN PRIVATE KEY-----"} className="min-h-28 w-full rounded-md border border-input bg-background p-2 font-mono text-xs" /></div>
|
||||
<p className="text-[11px] text-muted-foreground">Install the App on repositories to mirror. Grant repository Contents read access, and Issues and Metadata read access for metadata mirroring. GitHub Apps cannot mirror a user's starred repositories.</p>
|
||||
</div>
|
||||
) : <div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="source-token"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
@@ -527,9 +579,9 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
{providerMeta.tokenHint}
|
||||
</p>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="space-y-3 rounded-lg bg-muted/40 p-4">
|
||||
{!isApp && <div className="space-y-3 rounded-lg bg-muted/40 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -563,7 +615,7 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
@@ -571,7 +623,7 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTesting || !effectiveToken}
|
||||
disabled={isTesting || (!effectiveToken && !isApp)}
|
||||
>
|
||||
<PlugZap className="mr-1.5 h-3.5 w-3.5" />
|
||||
{isTesting ? "Testing..." : "Test"}
|
||||
@@ -591,7 +643,7 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
size="sm"
|
||||
onClick={() => handleSave(false)}
|
||||
disabled={
|
||||
isSaving || (!editor.username.trim() && !!editor.token.trim())
|
||||
isSaving || (isApp ? !editor.appId.trim() || !editor.installationId.trim() || (!editor.privateKey.trim() && editing?.authType !== "app") : !editor.username.trim() && !!editor.token.trim())
|
||||
}
|
||||
>
|
||||
{isSaving ? "Saving..." : editing ? "Save changes" : "Add source"}
|
||||
@@ -638,7 +690,8 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
{meta.badge}
|
||||
</span>
|
||||
)}
|
||||
{source.token === "" && (
|
||||
{source.authType === "app" && <span className="text-[11px] text-muted-foreground">GitHub App</span>}
|
||||
{source.token === "" && source.authType !== "app" && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<Globe className="h-3 w-3" />
|
||||
Public only
|
||||
|
||||
@@ -121,6 +121,10 @@ interface OptionRowProps {
|
||||
badge?: string;
|
||||
/** Control rendered on the right (switch, pill, input...). */
|
||||
right?: ReactNode;
|
||||
/** Rendered under the description, e.g. a link that opens a sub-dialog. */
|
||||
subContent?: ReactNode;
|
||||
/** Rows whose control is a stack of fields (two inputs) sit top-aligned. */
|
||||
align?: "center" | "start";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -131,10 +135,18 @@ export function OptionRow({
|
||||
info,
|
||||
badge,
|
||||
right,
|
||||
subContent,
|
||||
align,
|
||||
className,
|
||||
}: OptionRowProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-4", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-4",
|
||||
align === "start" ? "items-start" : "items-center",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
@@ -149,6 +161,7 @@ export function OptionRow({
|
||||
{description && (
|
||||
<p className="text-[13px] text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{subContent}
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
|
||||
@@ -78,11 +78,14 @@ export function Dashboard() {
|
||||
|
||||
// Create a stable callback using useCallback
|
||||
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||
// A "warning" entry is an activity alert, never a repository or organization status.
|
||||
const status = data.status;
|
||||
if (status === "warning") return;
|
||||
if (data.repositoryId) {
|
||||
setRepositories((prevRepos) =>
|
||||
prevRepos.map((repo) =>
|
||||
repo.id === data.repositoryId
|
||||
? { ...repo, status: data.status, details: data.details }
|
||||
? { ...repo, status, details: data.details }
|
||||
: repo
|
||||
)
|
||||
);
|
||||
@@ -90,7 +93,7 @@ export function Dashboard() {
|
||||
setOrganizations((prevOrgs) =>
|
||||
prevOrgs.map((org) =>
|
||||
org.id === data.organizationId
|
||||
? { ...org, status: data.status, details: data.details }
|
||||
? { ...org, status, details: data.details }
|
||||
: org
|
||||
)
|
||||
);
|
||||
|
||||
@@ -29,6 +29,8 @@ function activityIcon(status: MirrorJob["status"]) {
|
||||
return { Icon: Sparkles, color: "text-muted-foreground" };
|
||||
case "failed":
|
||||
return { Icon: TriangleAlert, color: "text-red-500" };
|
||||
case "warning":
|
||||
return { Icon: TriangleAlert, color: "text-amber-500" };
|
||||
default:
|
||||
return { Icon: Activity, color: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
@@ -268,6 +268,7 @@ export function OrganizationList({
|
||||
}}
|
||||
targetKind="organization"
|
||||
targetName={overridesTarget?.name ?? ""}
|
||||
targetId={overridesTarget?.id ?? null}
|
||||
value={overridesTarget?.mirrorOverrides ?? null}
|
||||
destinationProvider={giteaConfig?.provider}
|
||||
inheritedFrom={{
|
||||
|
||||
@@ -101,11 +101,14 @@ export default function Repository() {
|
||||
|
||||
// Create a stable callback using useCallback
|
||||
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||
// A "warning" entry is an activity alert, never a repository status.
|
||||
const status = data.status;
|
||||
if (status === "warning") return;
|
||||
if (data.repositoryId) {
|
||||
setRepositories((prevRepos) =>
|
||||
prevRepos.map((repo) =>
|
||||
repo.id === data.repositoryId
|
||||
? { ...repo, status: data.status, details: data.details }
|
||||
? { ...repo, status, details: data.details }
|
||||
: repo
|
||||
)
|
||||
);
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
mirrorOptionsToFlags,
|
||||
normalizeReleaseAssetLimit,
|
||||
normalizeReleaseLimit,
|
||||
normalizeReleaseTagPatterns,
|
||||
normalizeReleaseType,
|
||||
type MirrorOverrideKey,
|
||||
} from "@/lib/utils/mirror-overrides";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -189,6 +191,12 @@ export default function RepositoryTable({
|
||||
if (orgReleaseLimit !== undefined) globals.releaseLimit = orgReleaseLimit;
|
||||
const orgReleaseAssetLimit = normalizeReleaseAssetLimit(orgOverrides.releaseAssetLimit);
|
||||
if (orgReleaseAssetLimit !== undefined) globals.releaseAssetLimit = orgReleaseAssetLimit;
|
||||
const orgReleaseType = normalizeReleaseType(orgOverrides.releaseType);
|
||||
if (orgReleaseType !== undefined) globals.releaseType = orgReleaseType;
|
||||
const orgIncludeTags = normalizeReleaseTagPatterns(orgOverrides.releaseIncludeTags);
|
||||
if (orgIncludeTags !== undefined) globals.releaseIncludeTags = orgIncludeTags;
|
||||
const orgExcludeTags = normalizeReleaseTagPatterns(orgOverrides.releaseExcludeTags);
|
||||
if (orgExcludeTags !== undefined) globals.releaseExcludeTags = orgExcludeTags;
|
||||
return globals;
|
||||
}, [mirrorOptions, orgOverrides]);
|
||||
|
||||
@@ -1201,6 +1209,7 @@ export default function RepositoryTable({
|
||||
}}
|
||||
targetKind="repository"
|
||||
targetName={overridesTarget?.fullName ?? ""}
|
||||
targetId={overridesTarget?.id ?? null}
|
||||
value={overridesTarget?.mirrorOverrides ?? null}
|
||||
inheritedFrom={inheritedMirrorOptions}
|
||||
inheritedLoading={orgOverridesLoading}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import type { ConfigApiResponse } from '@/types/config';
|
||||
import { hasConfiguredSource } from './useConfigStatus';
|
||||
|
||||
test('App sources keep the dashboard configured when the private key is redacted', () => {
|
||||
const config = {
|
||||
githubConfig: { token: '' },
|
||||
sources: [{ provider: 'github', authType: 'app', token: '' }],
|
||||
} as ConfigApiResponse;
|
||||
expect(hasConfiguredSource(config)).toBe(true);
|
||||
expect(hasConfiguredSource({ ...config, sources: [{ provider: 'github', authType: 'pat', token: '' }] } as ConfigApiResponse)).toBe(false);
|
||||
expect(hasConfiguredSource({ ...config, githubConfig: { token: 'ghp_pat' }, sources: [] } as ConfigApiResponse)).toBe(true);
|
||||
});
|
||||
@@ -42,6 +42,13 @@ const cacheSubscribers = new Set<() => void>();
|
||||
// of them at once, and without this each would fire its own request.
|
||||
let inFlightRequest: { userId: string; promise: Promise<ConfigApiResponse> } | null = null;
|
||||
|
||||
export function hasConfiguredSource(configResponse: ConfigApiResponse | null): boolean {
|
||||
return Boolean(
|
||||
configResponse?.githubConfig?.token ||
|
||||
configResponse?.sources?.some(source => source.provider === 'github' && source.authType === 'app')
|
||||
);
|
||||
}
|
||||
|
||||
function fetchConfigOnce(userId: string): Promise<ConfigApiResponse> {
|
||||
if (inFlightRequest && inFlightRequest.userId === userId) {
|
||||
return inFlightRequest.promise;
|
||||
@@ -107,13 +114,9 @@ export function useConfigStatus(): ConfigStatus {
|
||||
if (isCacheValid && hasCheckedRef.current) {
|
||||
const configResponse = configCache.data!;
|
||||
|
||||
// Only token/url are actually required at runtime: the GitHub token is
|
||||
// self-authenticating for listForAuthenticatedUser, and a Gitea username
|
||||
// isn't needed under single-org / flat mirror strategies. Users who
|
||||
// configure via env vars without GITHUB_USERNAME / GITEA_USERNAME set
|
||||
// (or who otherwise left those blank) were being locked out of the
|
||||
// dashboard even though mirroring worked fine (see issue #271).
|
||||
const isGitHubConfigured = !!configResponse?.githubConfig?.token;
|
||||
// App private keys are redacted from the config response, so connected
|
||||
// App sources count alongside a visible PAT for dashboard access.
|
||||
const isGitHubConfigured = hasConfiguredSource(configResponse);
|
||||
|
||||
const isGiteaConfigured = !!(
|
||||
configResponse?.giteaConfig?.url &&
|
||||
@@ -158,13 +161,8 @@ export function useConfigStatus(): ConfigStatus {
|
||||
userId: user.id
|
||||
};
|
||||
|
||||
// Only token/url are actually required at runtime: the GitHub token is
|
||||
// self-authenticating for listForAuthenticatedUser, and a Gitea username
|
||||
// isn't needed under single-org / flat mirror strategies. Users who
|
||||
// configure via env vars without GITHUB_USERNAME / GITEA_USERNAME set
|
||||
// (or who otherwise left those blank) were being locked out of the
|
||||
// dashboard even though mirroring worked fine (see issue #271).
|
||||
const isGitHubConfigured = !!configResponse?.githubConfig?.token;
|
||||
// Match the cached path: a redacted App source is still configured.
|
||||
const isGitHubConfigured = hasConfiguredSource(configResponse);
|
||||
|
||||
const isGiteaConfigured = !!(
|
||||
configResponse?.giteaConfig?.url &&
|
||||
|
||||
@@ -53,6 +53,11 @@ export const backupStrategyEnum = z.enum([
|
||||
"block-on-force-push",
|
||||
]);
|
||||
|
||||
// Which releases are mirror candidates: every one, or only the ones the source
|
||||
// does not flag as a pre-release.
|
||||
export const releaseTypeEnum = z.enum(["all", "stable"]);
|
||||
export type ReleaseType = z.infer<typeof releaseTypeEnum>;
|
||||
|
||||
/**
|
||||
* Per-object overrides for the global mirror options in `giteaConfigSchema`.
|
||||
*
|
||||
@@ -73,6 +78,22 @@ export const backupStrategyEnum = z.enum([
|
||||
* assets are uploaded only for the newest N mirrored releases. 0 is a real
|
||||
* value (notes only); null/absent means inherit.
|
||||
*
|
||||
* `releaseType` sits alongside those limits and decides which releases are
|
||||
* candidates at all (`all`, or `stable` = everything the source does not flag as
|
||||
* a pre-release). It is a plain optional string rather than a strict enum for
|
||||
* the same reason as the limits: a stored typo must degrade to "inherit" for
|
||||
* that field alone. `normalizeReleaseType` does the sanitizing, and the global
|
||||
* default is `giteaConfigSchema.releaseType`.
|
||||
*
|
||||
* `releaseIncludeTags` and `releaseExcludeTags` filter by tag name on top of
|
||||
* that, for the many projects that never set the pre-release flag but tag
|
||||
* `v1.2.3-rc.1`. They are `z.unknown()` rather than `z.array(z.string())`
|
||||
* because a hand-written `"releaseExcludeTags": "*-rc*"` (a bare string, not a
|
||||
* list) would otherwise fail the parse of the whole object and silently drop
|
||||
* the LFS opt-out stored next to it. `normalizeReleaseTagPatterns` accepts a
|
||||
* list of strings and ignores everything else, and an empty list is not a pin:
|
||||
* clearing the field means inherit.
|
||||
*
|
||||
* `skipForks` is organization-tier only: it opts an organization out of
|
||||
* importing and mirroring forked repositories without touching the global
|
||||
* `githubConfig.skipForks` switch. Null/absent inherits that global value.
|
||||
@@ -91,6 +112,9 @@ export const mirrorOverridesSchema = z.object({
|
||||
mirrorMilestones: z.boolean().nullable().optional(),
|
||||
releaseLimit: z.number().nullable().optional(),
|
||||
releaseAssetLimit: z.number().nullable().optional(),
|
||||
releaseType: z.string().nullable().optional(),
|
||||
releaseIncludeTags: z.unknown().nullable().optional(),
|
||||
releaseExcludeTags: z.unknown().nullable().optional(),
|
||||
skipForks: z.boolean().nullable().optional(),
|
||||
});
|
||||
|
||||
@@ -129,6 +153,15 @@ export const giteaConfigSchema = z.object({
|
||||
// Upload assets only for the newest N mirrored releases. null/absent means
|
||||
// every mirrored release gets its assets; 0 means release notes only.
|
||||
releaseAssetLimit: z.number().nullable().optional(),
|
||||
// Which releases are candidates: every one, or only the ones not flagged as a
|
||||
// pre-release. The limit is applied after filtering.
|
||||
releaseType: releaseTypeEnum.default("all"),
|
||||
// Tag patterns narrowing that further: include keeps only matching tags,
|
||||
// exclude drops matching ones. Applied after `releaseType`, before the limit.
|
||||
// Empty list means no filter, which is what every config stored before this
|
||||
// option existed behaves.
|
||||
releaseIncludeTags: z.array(z.string()).default([]),
|
||||
releaseExcludeTags: z.array(z.string()).default([]),
|
||||
mirrorMetadata: z.boolean().default(false),
|
||||
mirrorIssues: z.boolean().default(false),
|
||||
mirrorPullRequests: z.boolean().default(false),
|
||||
@@ -212,7 +245,11 @@ export const notificationConfigSchema = z.object({
|
||||
provider: z.enum(["ntfy", "apprise", "gotify", "webhook"]).default("ntfy"),
|
||||
notifyOnSyncError: z.boolean().default(true),
|
||||
notifyOnSyncSuccess: z.boolean().default(false),
|
||||
// Master switch; the three group switches below only apply while it is on.
|
||||
notifyOnNewRepo: z.boolean().default(false),
|
||||
notifyOnNewOwnRepo: z.boolean().default(true),
|
||||
notifyOnNewOrgRepo: z.boolean().default(true),
|
||||
notifyOnNewStarredRepo: z.boolean().default(true),
|
||||
ntfy: ntfyConfigSchema.optional(),
|
||||
apprise: appriseConfigSchema.optional(),
|
||||
gotify: gotifyConfigSchema.optional(),
|
||||
@@ -337,6 +374,7 @@ export const mirrorJobSchema = z.object({
|
||||
"synced",
|
||||
"archived",
|
||||
"pending-approval",
|
||||
"warning", // Activity-log alert (e.g. release filters emptied a destination); never a repository status
|
||||
])
|
||||
.default("imported"),
|
||||
message: z.string(),
|
||||
@@ -471,6 +509,9 @@ export const configs = sqliteTable("configs", {
|
||||
.$type<z.infer<typeof cleanupConfigSchema>>()
|
||||
.notNull(),
|
||||
|
||||
// Do not add the group switches to this default: SQLite can only change a column
|
||||
// default by rebuilding the configs table, and the schema defaults already cover a
|
||||
// row written without them.
|
||||
notificationConfig: text("notification_config", { mode: "json" })
|
||||
.$type<z.infer<typeof notificationConfigSchema>>()
|
||||
.notNull()
|
||||
|
||||
@@ -11,7 +11,7 @@ import { isSourceProviderKind, type SourceProviderKind } from '@/lib/source-prov
|
||||
import { DESTINATION_PROVIDER_DEFAULT_URLS, isDestinationProviderKind, type DestinationProviderKind } from '@/lib/destination-kinds';
|
||||
import { hasDestinationChanged, hasSourceChanged, loadConfigLocks } from '@/lib/config-locks';
|
||||
import type { SourceUpdateInput } from '@/lib/sources';
|
||||
import { normalizeReleaseAssetLimit } from '@/lib/utils/mirror-overrides';
|
||||
import { normalizeReleaseAssetLimit, normalizeReleaseTagPatterns, normalizeReleaseType, splitReleaseTagEntries } from '@/lib/utils/mirror-overrides';
|
||||
|
||||
interface EnvConfig {
|
||||
github: {
|
||||
@@ -66,6 +66,9 @@ interface EnvConfig {
|
||||
mirrorMetadata?: boolean;
|
||||
releaseLimit?: number;
|
||||
releaseAssetLimit?: number;
|
||||
releaseType?: string;
|
||||
releaseIncludeTags?: string[];
|
||||
releaseExcludeTags?: string[];
|
||||
issueConcurrency?: number;
|
||||
pullRequestConcurrency?: number;
|
||||
};
|
||||
@@ -118,6 +121,15 @@ function parseEnvConfig(): EnvConfig {
|
||||
const starredLists = process.env.MIRROR_STARRED_LISTS
|
||||
? process.env.MIRROR_STARRED_LISTS.split(',').map((list) => list.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
// Release tag patterns are comma separated, but a comma inside the slash
|
||||
// form belongs to the regular expression (^v\d{1,3}\.\d+$), so the split
|
||||
// skips commas inside /.../ - the same rules the dialog applies.
|
||||
const releaseIncludeTags = process.env.RELEASE_INCLUDE_TAGS
|
||||
? splitReleaseTagEntries(process.env.RELEASE_INCLUDE_TAGS)
|
||||
: undefined;
|
||||
const releaseExcludeTags = process.env.RELEASE_EXCLUDE_TAGS
|
||||
? splitReleaseTagEntries(process.env.RELEASE_EXCLUDE_TAGS)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
github: {
|
||||
@@ -185,6 +197,9 @@ function parseEnvConfig(): EnvConfig {
|
||||
mirrorMetadata: process.env.MIRROR_METADATA === 'true',
|
||||
releaseLimit: process.env.RELEASE_LIMIT ? parseInt(process.env.RELEASE_LIMIT, 10) : undefined,
|
||||
releaseAssetLimit: process.env.RELEASE_ASSET_LIMIT ? parseInt(process.env.RELEASE_ASSET_LIMIT, 10) : undefined,
|
||||
releaseType: process.env.RELEASE_TYPE,
|
||||
releaseIncludeTags,
|
||||
releaseExcludeTags,
|
||||
issueConcurrency: process.env.MIRROR_ISSUE_CONCURRENCY ? parseInt(process.env.MIRROR_ISSUE_CONCURRENCY, 10) : undefined,
|
||||
pullRequestConcurrency: process.env.MIRROR_PULL_REQUEST_CONCURRENCY ? parseInt(process.env.MIRROR_PULL_REQUEST_CONCURRENCY, 10) : undefined,
|
||||
},
|
||||
@@ -382,6 +397,22 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
normalizeReleaseAssetLimit(envConfig.mirror.releaseAssetLimit) ??
|
||||
existingConfig?.[0]?.giteaConfig?.releaseAssetLimit ??
|
||||
null,
|
||||
// all | stable. An unusable value (a typo in the env var)
|
||||
// falls through to the stored value rather than storing a filter.
|
||||
releaseType:
|
||||
normalizeReleaseType(envConfig.mirror.releaseType) ??
|
||||
existingConfig?.[0]?.giteaConfig?.releaseType ??
|
||||
'all',
|
||||
// Comma separated glob patterns (or the /regex/ form). An unset or unusable
|
||||
// variable keeps what the UI stored, matching how RELEASE_TYPE behaves.
|
||||
releaseIncludeTags:
|
||||
normalizeReleaseTagPatterns(envConfig.mirror.releaseIncludeTags) ??
|
||||
existingConfig?.[0]?.giteaConfig?.releaseIncludeTags ??
|
||||
[],
|
||||
releaseExcludeTags:
|
||||
normalizeReleaseTagPatterns(envConfig.mirror.releaseExcludeTags) ??
|
||||
existingConfig?.[0]?.giteaConfig?.releaseExcludeTags ??
|
||||
[],
|
||||
issueConcurrency: envConfig.mirror.issueConcurrency && envConfig.mirror.issueConcurrency > 0
|
||||
? envConfig.mirror.issueConcurrency
|
||||
: existingConfig?.[0]?.giteaConfig?.issueConcurrency ?? 3,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
import { createMockResponse, mockFetch } from "@/tests/mock-fetch";
|
||||
import { getRepositorySource } from "@/lib/source-providers/kinds";
|
||||
import { encodeGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
import { clearDestinationVersionCache } from "@/lib/gitea-mirror-credentials";
|
||||
|
||||
// Mock the helpers module before importing gitea-enhanced
|
||||
const mockCreateMirrorJob = mock(() => Promise.resolve("mock-job-id"));
|
||||
@@ -9,6 +11,7 @@ mock.module("@/lib/helpers", () => ({
|
||||
}));
|
||||
|
||||
const mockMirrorGitHubReleasesToGitea = mock(() => Promise.resolve());
|
||||
const mockMirrorGiteaSourceReleasesToGitea = mock(() => Promise.resolve());
|
||||
const mockMirrorGitRepoIssuesToGitea = mock(() => Promise.resolve(undefined));
|
||||
const mockMirrorGitRepoPullRequestsToGitea = mock(() => Promise.resolve(undefined));
|
||||
const mockMirrorGitRepoLabelsToGitea = mock(() => Promise.resolve());
|
||||
@@ -102,6 +105,9 @@ mock.module("@/lib/sources", () => ({
|
||||
);
|
||||
},
|
||||
decryptSourceToken: (token: string | null | undefined) => token ?? "",
|
||||
sourceAccessToken: async (source: { token?: string | null }) => source.token?.startsWith("github-app:v1:") ? "ghs_fresh" : source.token ?? "",
|
||||
isGitHubAppSource: (source: { provider?: string; token?: string | null }) =>
|
||||
source.provider === "github" && !!source.token?.startsWith("github-app:v1:"),
|
||||
resolveGitHubApiBaseUrl: (url: string | null | undefined) => {
|
||||
const trimmed = url?.trim().replace(/\/+$/, "") ?? "";
|
||||
if (!trimmed || trimmed === "https://github.com") return undefined;
|
||||
@@ -123,8 +129,14 @@ let orgTestContext = "";
|
||||
let getOrgCalled = false;
|
||||
let createOrgCalled = false;
|
||||
|
||||
// The destination version, for the GitHub App credential refresh check.
|
||||
let giteaVersion = "1.27.0";
|
||||
|
||||
const mockHttpGet = mock(async (url: string, headers?: any) => {
|
||||
// Return different responses based on URL patterns
|
||||
if (url.endsWith("/api/v1/version")) {
|
||||
return { data: { version: giteaVersion }, status: 200, statusText: "OK", headers: new Headers() };
|
||||
}
|
||||
|
||||
// Handle user authentication endpoint
|
||||
if (url.includes("/api/v1/user")) {
|
||||
@@ -451,6 +463,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
mockDb.insert.mockClear();
|
||||
mockDb.update.mockClear();
|
||||
mockMirrorGitHubReleasesToGitea.mockClear();
|
||||
mockMirrorGiteaSourceReleasesToGitea.mockClear();
|
||||
mockMirrorGitRepoIssuesToGitea.mockClear();
|
||||
mockMirrorGitRepoPullRequestsToGitea.mockClear();
|
||||
mockMirrorGitRepoLabelsToGitea.mockClear();
|
||||
@@ -842,13 +855,14 @@ describe("Enhanced Gitea Operations", () => {
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockGetGiteaRepoOwnerAsync).toHaveBeenCalled();
|
||||
expect(mockMirrorGitHubReleasesToGitea).toHaveBeenCalledTimes(1);
|
||||
const releaseCall = mockMirrorGitHubReleasesToGitea.mock.calls[0][0];
|
||||
const releaseCall = mockMirrorGitHubReleasesToGitea.mock.calls[0][0] as any;
|
||||
expect(releaseCall.giteaOwner).toBe("starred");
|
||||
expect(releaseCall.giteaRepoName).toBe("mirror-repo");
|
||||
expect(releaseCall.config.githubConfig?.token).toBe("github-token");
|
||||
expect(releaseCall.octokit).toBeDefined();
|
||||
// No override anywhere: the resolved default limit is handed through.
|
||||
expect(releaseCall.releaseLimit).toBe(10);
|
||||
expect(releaseCall.releaseType).toBe("all");
|
||||
|
||||
// Description/topics are reconciled on every sync, against the resolved
|
||||
// target, with the Gitea repo info already fetched during target
|
||||
@@ -862,7 +876,7 @@ describe("Enhanced Gitea Operations", () => {
|
||||
expect(metadataCall.current?.mirror).toBe(true);
|
||||
});
|
||||
|
||||
test("passes the per-repository release limit through to the release mirror", async () => {
|
||||
test("passes the per-repository release limit and release type through to the release mirror", async () => {
|
||||
const config: Partial<Config> = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
@@ -877,6 +891,8 @@ describe("Enhanced Gitea Operations", () => {
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
releaseLimit: 25,
|
||||
releaseType: "all",
|
||||
releaseIncludeTags: ["v*"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -891,7 +907,11 @@ describe("Enhanced Gitea Operations", () => {
|
||||
status: repoStatusEnum.parse("mirrored"),
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
mirrorOverrides: { releaseLimit: 3 },
|
||||
mirrorOverrides: {
|
||||
releaseLimit: 3,
|
||||
releaseType: "stable",
|
||||
releaseExcludeTags: ["*-rc.*"],
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -912,6 +932,12 @@ describe("Enhanced Gitea Operations", () => {
|
||||
expect(mockMirrorGitHubReleasesToGitea).toHaveBeenCalledTimes(1);
|
||||
const releaseCall = mockMirrorGitHubReleasesToGitea.mock.calls[0][0] as any;
|
||||
expect(releaseCall.releaseLimit).toBe(3);
|
||||
// The repository tier wins over the global "all" default.
|
||||
expect(releaseCall.releaseType).toBe("stable");
|
||||
// The repository sets no include list, so the global one still applies,
|
||||
// alongside the repository's own exclude list.
|
||||
expect(releaseCall.releaseIncludeTags).toEqual(["v*"]);
|
||||
expect(releaseCall.releaseExcludeTags).toEqual(["*-rc.*"]);
|
||||
});
|
||||
|
||||
test("prefers recorded mirroredLocation when owner resolution changes", async () => {
|
||||
@@ -972,6 +998,82 @@ describe("Enhanced Gitea Operations", () => {
|
||||
expect(String(mirrorSyncCalls[0][0])).not.toContain("/api/v1/repos/ceph/test-repo/mirror-sync");
|
||||
});
|
||||
|
||||
describe("GitHub App sources", () => {
|
||||
const appConfig = {
|
||||
userId: "user123",
|
||||
githubConfig: { username: "testuser", token: "", privateRepositories: false, mirrorStarred: false },
|
||||
giteaConfig: { url: "https://gitea.example.com", token: "encrypted-token", defaultOwner: "testuser", mirrorReleases: false, mirrorInterval: "8h" },
|
||||
} as unknown as Partial<Config>;
|
||||
const appRepository = () => ({
|
||||
id: "repo-1", sourceId: "source-1", name: "test-repo", fullName: "ceph/test-repo",
|
||||
owner: "ceph", cloneUrl: "https://github.com/ceph/test-repo.git", isPrivate: false,
|
||||
isStarred: false, status: repoStatusEnum.parse("mirrored"), visibility: "public",
|
||||
userId: "user123", mirroredLocation: "starred/test-repo", createdAt: new Date(), updatedAt: new Date(),
|
||||
} as Repository);
|
||||
// Injected so the sync makes no GitHub calls with the fake token.
|
||||
const appDependencies = {
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
};
|
||||
let originalToken: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalToken = sourceRows[0].token;
|
||||
sourceRows[0].token = encodeGitHubAppCredentials({ appId: "123", installationId: "456", privateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----" });
|
||||
clearDestinationVersionCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sourceRows[0].token = originalToken;
|
||||
giteaVersion = "1.27.0";
|
||||
clearDestinationVersionCache();
|
||||
});
|
||||
|
||||
test("refreshes the mirror credentials before triggering a sync", async () => {
|
||||
await syncGiteaRepoEnhanced({ config: appConfig, repository: appRepository() }, appDependencies);
|
||||
const repoPatches = mockHttpPatch.mock.calls.filter(
|
||||
(call) => call[0] === "https://gitea.example.com/api/v1/repos/starred/test-repo"
|
||||
);
|
||||
// Only the credential refresh: the configured 8h interval is not
|
||||
// re-applied, since Gitea would then pull with an expired token.
|
||||
expect(repoPatches).toEqual([
|
||||
[
|
||||
"https://gitea.example.com/api/v1/repos/starred/test-repo",
|
||||
{ mirror_username: "x-access-token", mirror_token: "ghs_fresh", mirror_interval: "0h" },
|
||||
{ Authorization: "token encrypted-token" },
|
||||
],
|
||||
]);
|
||||
expect(mockHttpPost.mock.calls.some((call) => String(call[0]).includes("/mirror-sync"))).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts the Gitea 28+ version line", async () => {
|
||||
giteaVersion = "29.0.0+dev-160-g55186ca39b";
|
||||
await syncGiteaRepoEnhanced({ config: appConfig, repository: appRepository() }, appDependencies);
|
||||
expect(mockHttpPost.mock.calls.some((call) => String(call[0]).includes("/mirror-sync"))).toBe(true);
|
||||
});
|
||||
|
||||
test("refuses to sync on Gitea 1.26, which ignores mirror credentials", async () => {
|
||||
giteaVersion = "1.26.1";
|
||||
await expect(
|
||||
syncGiteaRepoEnhanced({ config: appConfig, repository: appRepository() }, appDependencies)
|
||||
).rejects.toThrow("GitHub App sources need Gitea 1.27 or later");
|
||||
expect(mockHttpPost.mock.calls.some((call) => String(call[0]).includes("/mirror-sync"))).toBe(false);
|
||||
});
|
||||
|
||||
test("refuses to sync on Forgejo", async () => {
|
||||
giteaVersion = "11.0.3+gitea-1.22.0";
|
||||
await expect(
|
||||
syncGiteaRepoEnhanced({ config: appConfig, repository: appRepository() }, appDependencies)
|
||||
).rejects.toThrow("GitHub App sources need Gitea 1.27 or later");
|
||||
expect(mockHttpPost.mock.calls.some((call) => String(call[0]).includes("/mirror-sync"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("finds a mirror transferred to another owner through the search and syncs it there (#400)", async () => {
|
||||
const config = {
|
||||
userId: "user123",
|
||||
@@ -1800,6 +1902,88 @@ describe("Enhanced Gitea Operations", () => {
|
||||
sourceRows.splice(0, sourceRows.length, ...originalRows);
|
||||
}
|
||||
});
|
||||
|
||||
test("forwards the release filters to a Gitea source release mirror", async () => {
|
||||
// The Gitea/Forgejo source path has its own release mirror (#440); it has
|
||||
// to receive the same resolved filters as the GitHub path.
|
||||
const originalRows = sourceRows.splice(
|
||||
0,
|
||||
sourceRows.length,
|
||||
{
|
||||
id: "source-gitea",
|
||||
userId: "user123",
|
||||
name: "Gitea",
|
||||
provider: "gitea",
|
||||
url: "https://git.example.com",
|
||||
username: "",
|
||||
token: "source-token",
|
||||
enabled: true,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
}
|
||||
);
|
||||
try {
|
||||
const config = {
|
||||
userId: "user123",
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "github-token",
|
||||
privateRepositories: false,
|
||||
mirrorStarred: false,
|
||||
},
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "encrypted-token",
|
||||
defaultOwner: "testuser",
|
||||
mirrorReleases: true,
|
||||
releaseIncludeTags: ["v*"],
|
||||
},
|
||||
} as unknown as Partial<Config>;
|
||||
|
||||
const repository = {
|
||||
id: "repo795",
|
||||
name: "mirror-repo",
|
||||
fullName: "group/mirror-repo",
|
||||
owner: "group",
|
||||
cloneUrl: "https://git.example.com/group/mirror-repo.git",
|
||||
isPrivate: false,
|
||||
isStarred: false,
|
||||
status: repoStatusEnum.parse("mirrored"),
|
||||
visibility: "public",
|
||||
userId: "user123",
|
||||
sourceId: "source-gitea",
|
||||
sourceProvider: "gitea",
|
||||
sourceUrl: "https://git.example.com",
|
||||
mirrorOverrides: { releaseExcludeTags: ["*-rc.*"] },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as Repository;
|
||||
|
||||
const result = await syncGiteaRepoEnhanced(
|
||||
{ config, repository },
|
||||
{
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGiteaSourceReleasesToGitea: mockMirrorGiteaSourceReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockMirrorGitHubReleasesToGitea).not.toHaveBeenCalled();
|
||||
expect(mockMirrorGiteaSourceReleasesToGitea).toHaveBeenCalledTimes(1);
|
||||
const releaseCall = mockMirrorGiteaSourceReleasesToGitea.mock.calls[0][0] as any;
|
||||
expect(releaseCall.connection.token).toBe("source-token");
|
||||
expect(releaseCall.releaseIncludeTags).toEqual(["v*"]);
|
||||
expect(releaseCall.releaseExcludeTags).toEqual(["*-rc.*"]);
|
||||
} finally {
|
||||
sourceRows.splice(0, sourceRows.length, ...originalRows);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleExistingNonMirrorRepo", () => {
|
||||
|
||||
+37
-14
@@ -14,10 +14,12 @@ import {
|
||||
SOURCE_PROVIDER_LABELS,
|
||||
} from "@/lib/source-providers/kinds";
|
||||
import { sourceConnectionFromSource } from "@/lib/source-providers";
|
||||
import { refreshMirrorCredentials } from "./gitea-mirror-credentials";
|
||||
import {
|
||||
decryptSourceToken,
|
||||
findSourceForRepository,
|
||||
isGitHubAppSource,
|
||||
listSources,
|
||||
sourceAccessToken,
|
||||
resolveGitHubApiBaseUrl,
|
||||
} from "@/lib/sources";
|
||||
import type { Repository } from "./db/schema";
|
||||
@@ -483,7 +485,8 @@ export async function syncGiteaRepoEnhanced({
|
||||
// every GitHub client below.
|
||||
const sources = config.userId ? await listSources(config.userId) : [];
|
||||
const repoSource = findSourceForRepository(repository, sources);
|
||||
const repoSourceToken = repoSource ? decryptSourceToken(repoSource.token) : "";
|
||||
const repoUsesGitHubApp = repoSource ? isGitHubAppSource(repoSource) : false;
|
||||
const repoSourceToken = repoSource ? await sourceAccessToken(repoSource) : "";
|
||||
const repoSourceKind = getRepositorySource(repository).provider;
|
||||
const repoIsGitHub = repoSourceKind === "github";
|
||||
|
||||
@@ -904,16 +907,18 @@ export async function syncGiteaRepoEnhanced({
|
||||
}
|
||||
|
||||
// Update mirror interval if needed
|
||||
// NOTE: Gitea/Forgejo's PATCH /repos/{owner}/{repo} API does not support
|
||||
// updating mirror credentials (mirror_username/mirror_password). Repos that
|
||||
// were originally migrated without credentials must be deleted and re-mirrored.
|
||||
// NOTE: Gitea before 1.27 and Forgejo ignore mirror credentials
|
||||
// (mirror_username/mirror_password) on PATCH /repos/{owner}/{repo}. Repos
|
||||
// that were migrated without credentials must be deleted and re-mirrored
|
||||
// there; GitHub App repos, which need a new token every sync, are refused
|
||||
// on those destinations (gitea-mirror-credentials.ts).
|
||||
//
|
||||
// Skipped for archived repos: archiveGiteaRepo deliberately disabled
|
||||
// Gitea's own periodic pulling, and the documented contract
|
||||
// (AutomationSettings.tsx: "Archive ... disables automatic syncs—use
|
||||
// Manual Sync when you want to refresh") says a Manual Sync refreshes
|
||||
// once without re-enabling any automatic syncing.
|
||||
if (config.giteaConfig?.mirrorInterval && !isArchivedRepo) {
|
||||
if (config.giteaConfig?.mirrorInterval && !isArchivedRepo && !repoUsesGitHubApp) {
|
||||
try {
|
||||
console.log(`[Sync] Updating mirror interval for ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
|
||||
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}`;
|
||||
@@ -929,6 +934,18 @@ export async function syncGiteaRepoEnhanced({
|
||||
}
|
||||
}
|
||||
|
||||
// A GitHub App installation token expires within an hour: hand Gitea a
|
||||
// fresh one with enough headroom for its queued pull, before the sync.
|
||||
if (repoUsesGitHubApp && repoSource) {
|
||||
await refreshMirrorCredentials({
|
||||
config,
|
||||
giteaToken: decryptedConfig.giteaConfig.token,
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
accessToken: await sourceAccessToken(repoSource, { minValidityMs: 30 * 60_000 }),
|
||||
});
|
||||
}
|
||||
|
||||
// Perform the sync
|
||||
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/mirror-sync`;
|
||||
|
||||
@@ -1037,6 +1054,9 @@ export async function syncGiteaRepoEnhanced({
|
||||
giteaRepoName: repoName,
|
||||
releaseLimit: mirrorOptions.releaseLimit,
|
||||
releaseAssetLimit: mirrorOptions.releaseAssetLimit,
|
||||
releaseType: mirrorOptions.releaseType,
|
||||
releaseIncludeTags: mirrorOptions.releaseIncludeTags,
|
||||
releaseExcludeTags: mirrorOptions.releaseExcludeTags,
|
||||
});
|
||||
mirrored = true;
|
||||
}
|
||||
@@ -1049,14 +1069,17 @@ export async function syncGiteaRepoEnhanced({
|
||||
repository,
|
||||
// Carries that instance's own decrypted token, so the release
|
||||
// listing and the asset downloads authenticate against it.
|
||||
connection: sourceConnectionFromSource(repoSource, {
|
||||
userId: config.userId,
|
||||
}),
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repoName,
|
||||
releaseLimit: mirrorOptions.releaseLimit,
|
||||
releaseAssetLimit: mirrorOptions.releaseAssetLimit,
|
||||
});
|
||||
connection: sourceConnectionFromSource(repoSource, {
|
||||
userId: config.userId,
|
||||
}),
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repoName,
|
||||
releaseLimit: mirrorOptions.releaseLimit,
|
||||
releaseAssetLimit: mirrorOptions.releaseAssetLimit,
|
||||
releaseType: mirrorOptions.releaseType,
|
||||
releaseIncludeTags: mirrorOptions.releaseIncludeTags,
|
||||
releaseExcludeTags: mirrorOptions.releaseExcludeTags,
|
||||
});
|
||||
mirrored = true;
|
||||
} else {
|
||||
console.warn(
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { supportsMirrorCredentialUpdate } from "./gitea-mirror-credentials";
|
||||
|
||||
describe("supportsMirrorCredentialUpdate", () => {
|
||||
test("accepts Gitea 1.27 and the 28+ version line", () => {
|
||||
for (const version of ["1.27.0", "1.27.3", "v1.27.1", "1.28.0", "28.2.0", "29.0.0+dev-160-g55186ca39b"]) {
|
||||
expect(supportsMirrorCredentialUpdate(version)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects Gitea 1.26 and earlier, which drop the mirror credential fields", () => {
|
||||
for (const version of ["1.26.1", "1.24.7", "1.20.0"]) {
|
||||
expect(supportsMirrorCredentialUpdate(version)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects Forgejo whatever its own version number", () => {
|
||||
for (const version of ["11.0.3+gitea-1.22.0", "16.0.0-dev-785-465b60ef+gitea-1.22.0"]) {
|
||||
expect(supportsMirrorCredentialUpdate(version)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a missing or unparsable version", () => {
|
||||
expect(supportsMirrorCredentialUpdate("")).toBe(false);
|
||||
expect(supportsMirrorCredentialUpdate("development")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Replacing the source credentials a Gitea pull mirror stores.
|
||||
*
|
||||
* GitHub App installation tokens expire after an hour, so an App-backed pull
|
||||
* mirror only keeps pulling if its credentials are replaced before each sync.
|
||||
* Gitea accepts mirror_username, mirror_password and mirror_token on
|
||||
* PATCH /repos/{owner}/{repo} from 1.27.0 (and on the 28+ line). Gitea 1.26
|
||||
* and earlier, and Forgejo, drop those fields without an error, so the
|
||||
* destination version is checked before an App-backed repository is
|
||||
* migrated or synced.
|
||||
*/
|
||||
import type { Config } from "@/types/config";
|
||||
import { resolveDestinationKind } from "./destination-connection";
|
||||
import { httpGet, httpPatch } from "./http-client";
|
||||
|
||||
const VERSION_CACHE_MS = 10 * 60 * 1000;
|
||||
const versionCache = new Map<string, { version: string; fetchedAt: number }>();
|
||||
|
||||
/** True when a Gitea version string accepts mirror credential updates. */
|
||||
export function supportsMirrorCredentialUpdate(version: string): boolean {
|
||||
// Forgejo reports e.g. "11.0.3+gitea-1.22.0".
|
||||
if (/\+gitea-/i.test(version) || /forgejo/i.test(version)) return false;
|
||||
const match = /^v?(\d+)\.(\d+)/.exec(version.trim());
|
||||
if (!match) return false;
|
||||
const major = Number(match[1]);
|
||||
const minor = Number(match[2]);
|
||||
return major > 1 || (major === 1 && minor >= 27);
|
||||
}
|
||||
|
||||
async function destinationVersion(baseUrl: string, token: string): Promise<string> {
|
||||
const cached = versionCache.get(baseUrl);
|
||||
if (cached && Date.now() - cached.fetchedAt < VERSION_CACHE_MS) return cached.version;
|
||||
const response = await httpGet<{ version?: string }>(`${baseUrl}/api/v1/version`, {
|
||||
Authorization: `token ${token}`,
|
||||
});
|
||||
const version = String(response.data?.version ?? "");
|
||||
versionCache.set(baseUrl, { version, fetchedAt: Date.now() });
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws when the destination cannot refresh mirror credentials, before any
|
||||
* mirror is created that would stop pulling once its first token expires.
|
||||
*/
|
||||
export async function assertMirrorCredentialUpdateSupported(
|
||||
config: Partial<Config>,
|
||||
giteaToken: string
|
||||
): Promise<void> {
|
||||
const unsupported =
|
||||
"GitHub App sources need Gitea 1.27 or later as the destination, because installation tokens expire " +
|
||||
"after an hour and older Gitea and Forgejo cannot update a mirror's credentials through the API. " +
|
||||
"Use a personal access token for this source instead.";
|
||||
if (resolveDestinationKind(config) === "forgejo") throw new Error(unsupported);
|
||||
const baseUrl = (config.giteaConfig?.url ?? "").replace(/\/+$/, "");
|
||||
const version = await destinationVersion(baseUrl, giteaToken);
|
||||
if (!supportsMirrorCredentialUpdate(version)) {
|
||||
throw new Error(`${unsupported} (destination reports version ${version || "unknown"})`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Store a fresh installation token on the mirror and keep Gitea's own schedule off. */
|
||||
export async function refreshMirrorCredentials({
|
||||
config,
|
||||
giteaToken,
|
||||
owner,
|
||||
repo,
|
||||
accessToken,
|
||||
}: {
|
||||
config: Partial<Config>;
|
||||
giteaToken: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
accessToken: string;
|
||||
}): Promise<void> {
|
||||
await assertMirrorCredentialUpdateSupported(config, giteaToken);
|
||||
const baseUrl = (config.giteaConfig?.url ?? "").replace(/\/+$/, "");
|
||||
await httpPatch(
|
||||
`${baseUrl}/api/v1/repos/${owner}/${repo}`,
|
||||
{
|
||||
mirror_username: "x-access-token",
|
||||
mirror_token: accessToken,
|
||||
// Gitea's own pulls would run with an expired token; this app's
|
||||
// scheduler refreshes the token and triggers each sync instead.
|
||||
mirror_interval: "0h",
|
||||
},
|
||||
{ Authorization: `token ${giteaToken}` }
|
||||
);
|
||||
}
|
||||
|
||||
/** Test hook: forget cached destination versions. */
|
||||
export function clearDestinationVersionCache(): void {
|
||||
versionCache.clear();
|
||||
}
|
||||
@@ -182,6 +182,8 @@ mock.module("@/lib/sources", () => ({
|
||||
findSourceForOrganization: (org: { sourceId?: string | null }, list: any[]) =>
|
||||
org.sourceId ? list.find((source: any) => source.id === org.sourceId) ?? null : null,
|
||||
decryptSourceToken: (token: string | null | undefined) => token ?? "",
|
||||
sourceAccessToken: async (source: { token?: string | null }) => source.token ?? "",
|
||||
isGitHubAppSource: () => false,
|
||||
resolveGitHubApiBaseUrl: (url: string | null | undefined) => {
|
||||
const trimmed = url?.trim().replace(/\/+$/, "") ?? "";
|
||||
if (!trimmed || trimmed === "https://github.com") return undefined;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* gitea-org-mirror-destination.test.ts) where the real modules are loaded.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, test } from "bun:test";
|
||||
import { afterEach, describe, expect, it, mock, test } from "bun:test";
|
||||
import type { Config } from "@/types/config";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import type { SourceConnection } from "@/lib/source-providers/types";
|
||||
@@ -41,6 +41,44 @@ if (!isChild) {
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
// Both gitea.ts and helpers.ts bind to the notification service. Replace it in
|
||||
// the child before importing them so the all-releases-removed warning can be
|
||||
// captured without loading providers or touching the database.
|
||||
interface RemovalNotice {
|
||||
userId: string;
|
||||
repositoryName: string;
|
||||
destination: string;
|
||||
removedCount: number;
|
||||
}
|
||||
let removalNotices: RemovalNotice[] = [];
|
||||
|
||||
// gitea.ts is the only module that pulls ./helpers into this import graph and
|
||||
// only uses createMirrorJob from it. Capture the activity rows it writes so the
|
||||
// all-releases-removed warning can be asserted without touching the database.
|
||||
interface ActivityJob {
|
||||
status: string;
|
||||
message?: string;
|
||||
details?: string;
|
||||
skipNotification?: boolean;
|
||||
skipDuplicateEvent?: boolean;
|
||||
}
|
||||
let activityJobs: ActivityJob[] = [];
|
||||
|
||||
if (isChild) {
|
||||
mock.module("@/lib/notification-service", () => ({
|
||||
triggerAllReleasesRemovedNotification: async (notice: RemovalNotice) => {
|
||||
removalNotices.push(notice);
|
||||
},
|
||||
triggerJobNotification: async () => {},
|
||||
}));
|
||||
mock.module("@/lib/helpers", () => ({
|
||||
createMirrorJob: async (job: ActivityJob) => {
|
||||
activityJobs.push(job);
|
||||
return "activity-job";
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const { mirrorGiteaSourceReleasesToGitea, mirrorGitHubReleasesToGitea } = isChild
|
||||
? await import("@/lib/gitea")
|
||||
: ({} as typeof import("@/lib/gitea"));
|
||||
@@ -54,6 +92,8 @@ const realFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
removalNotices = [];
|
||||
activityJobs = [];
|
||||
});
|
||||
|
||||
const config = {
|
||||
@@ -90,6 +130,8 @@ interface DestinationOptions {
|
||||
existingRelease?: { id: number; name: string; body: string; tag_name: string };
|
||||
/** Tags the destination already has. Defaults to every source tag. */
|
||||
destinationTags?: string[];
|
||||
/** Releases already on the destination, as the retention pass lists them. */
|
||||
destinationReleases?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function fakeWorld(options: DestinationOptions) {
|
||||
@@ -161,9 +203,14 @@ function fakeWorld(options: DestinationOptions) {
|
||||
return json({ id: 77 });
|
||||
}
|
||||
|
||||
// The retention pass removing a release.
|
||||
if (method === "DELETE" && /\/releases\/\d+$/.test(url)) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
// The retention pass lists everything the destination has.
|
||||
if (method === "GET" && url.startsWith(`${DEST_API}/releases?`)) {
|
||||
return json([]);
|
||||
return json(options.destinationReleases ?? []);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected request: ${method} ${url}`);
|
||||
@@ -173,6 +220,12 @@ function fakeWorld(options: DestinationOptions) {
|
||||
requests,
|
||||
creates: () => requests.filter((r) => r.method === "POST" && r.url === `${DEST_API}/releases`),
|
||||
patches: () => requests.filter((r) => r.method === "PATCH"),
|
||||
deletes: () => requests.filter((r) => r.method === "DELETE"),
|
||||
/** Tags of every release the mirror created on the destination. */
|
||||
createdTags: () =>
|
||||
requests
|
||||
.filter((r) => r.method === "POST" && r.url === `${DEST_API}/releases`)
|
||||
.map((r) => String(JSON.parse(r.body ?? "{}").tag_name)),
|
||||
uploads: () => requests.filter((r) => r.method === "POST" && r.url.includes("/assets?name=")),
|
||||
downloads: () =>
|
||||
requests.filter(
|
||||
@@ -196,18 +249,39 @@ function giteaRelease(tag: string, extra: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function mirrorFromGitea(connection: SourceConnection) {
|
||||
function mirrorFromGitea(
|
||||
connection: SourceConnection,
|
||||
overrides: {
|
||||
releaseLimit?: number;
|
||||
releaseType?: "all" | "stable";
|
||||
releaseIncludeTags?: string[];
|
||||
releaseExcludeTags?: string[];
|
||||
} = {}
|
||||
) {
|
||||
return mirrorGiteaSourceReleasesToGitea({
|
||||
config,
|
||||
repository,
|
||||
connection,
|
||||
giteaOwner: DEST_OWNER,
|
||||
giteaRepoName: DEST_REPO,
|
||||
releaseLimit: 10,
|
||||
releaseLimit: overrides.releaseLimit ?? 10,
|
||||
releaseAssetLimit: null,
|
||||
releaseType: overrides.releaseType,
|
||||
releaseIncludeTags: overrides.releaseIncludeTags,
|
||||
releaseExcludeTags: overrides.releaseExcludeTags,
|
||||
});
|
||||
}
|
||||
|
||||
/** A destination release as the retention pass reads it. */
|
||||
function destinationRelease(tag: string, id: number, prerelease: boolean | null) {
|
||||
return {
|
||||
id,
|
||||
tag_name: tag,
|
||||
created_at: `2026-0${Math.max(1, 9 - id % 9)}-01T00:00:00Z`,
|
||||
prerelease,
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!isChild)("mirrorGiteaSourceReleasesToGitea", () => {
|
||||
it("creates a missing release with the Gitea/Forgejo header", async () => {
|
||||
const world = fakeWorld({ sourceReleases: [giteaRelease("v1.0.0")] });
|
||||
@@ -330,6 +404,302 @@ describe.skipIf(!isChild)("mirrorGiteaSourceReleasesToGitea", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isChild)("release type filter", () => {
|
||||
it("mirrors only the releases the source does not flag as pre-release", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [
|
||||
giteaRelease("v2.1.0-beta.1", { prerelease: true }),
|
||||
giteaRelease("v2.0.0"),
|
||||
giteaRelease("v1.0.0"),
|
||||
],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), { releaseType: "stable" });
|
||||
|
||||
expect(world.createdTags()).toEqual(["v2.0.0", "v1.0.0"]);
|
||||
});
|
||||
|
||||
it("mirrors every release when a stored value names a kind that no longer exists", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [
|
||||
giteaRelease("v2.1.0-beta.1", { prerelease: true }),
|
||||
giteaRelease("v2.0.0"),
|
||||
],
|
||||
});
|
||||
|
||||
// A row saved while the filter offered pre-releases on their own must not
|
||||
// turn into a filter nobody asked for; it falls back to no type filter.
|
||||
await mirrorFromGitea(giteaConnection("source-token"), {
|
||||
releaseType: "prerelease" as unknown as "stable",
|
||||
});
|
||||
|
||||
expect(world.createdTags()).toEqual(["v2.1.0-beta.1", "v2.0.0"]);
|
||||
});
|
||||
|
||||
it("looks further back so the limit counts the releases that match", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [
|
||||
giteaRelease("v3.0.0-rc1", { prerelease: true }),
|
||||
giteaRelease("v2.0.0"),
|
||||
giteaRelease("v1.0.0"),
|
||||
],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), {
|
||||
releaseLimit: 1,
|
||||
releaseType: "stable",
|
||||
});
|
||||
|
||||
// Asking the source for a single release would leave the pre-release as the
|
||||
// only candidate; the filter widens the scan instead, so "the newest one"
|
||||
// is the newest stable one.
|
||||
expect(world.createdTags()).toEqual(["v2.0.0"]);
|
||||
const listing = world.requests.find((r) => r.url.startsWith(`${SOURCE_URL}/api/v1/`));
|
||||
expect(listing?.url).toContain("limit=50&page=1");
|
||||
});
|
||||
|
||||
it("removes destination releases the filter excludes", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [giteaRelease("v2.0.0")],
|
||||
destinationReleases: [
|
||||
destinationRelease("v1.9.0-beta", 11, true),
|
||||
destinationRelease("v1.8.0", 12, false),
|
||||
],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), { releaseType: "stable" });
|
||||
|
||||
expect(world.deletes().map((r) => r.url)).toEqual([`${DEST_API}/releases/11`]);
|
||||
});
|
||||
|
||||
it("keeps a destination release whose pre-release flag is unknown", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [giteaRelease("v2.0.0")],
|
||||
destinationReleases: [destinationRelease("v1.8.0", 12, null)],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), { releaseType: "stable" });
|
||||
|
||||
expect(world.deletes()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still runs retention when the filter excludes every source release", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [giteaRelease("v2.1.0-beta.1", { prerelease: true })],
|
||||
destinationReleases: [destinationRelease("v2.1.0-beta.1", 21, true)],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), { releaseType: "stable" });
|
||||
|
||||
expect(world.creates()).toHaveLength(0);
|
||||
expect(world.deletes().map((r) => r.url)).toEqual([`${DEST_API}/releases/21`]);
|
||||
});
|
||||
|
||||
it("leaves the destination alone when every release is a candidate", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [giteaRelease("v2.0.0")],
|
||||
destinationReleases: [destinationRelease("v1.9.0-beta", 11, true)],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"));
|
||||
|
||||
expect(world.deletes()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isChild)("all releases removed warning", () => {
|
||||
it("warns when the filters leave the destination with no releases", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [giteaRelease("v2.1.0-beta.1", { prerelease: true })],
|
||||
destinationReleases: [
|
||||
destinationRelease("v2.0.0-beta", 21, true),
|
||||
destinationRelease("v1.9.0-beta", 22, true),
|
||||
],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), { releaseType: "stable" });
|
||||
|
||||
expect(world.deletes()).toHaveLength(2);
|
||||
expect(removalNotices).toEqual([
|
||||
{
|
||||
userId: "user-1",
|
||||
repositoryName: DEST_REPO,
|
||||
destination: `${DEST_OWNER}/${DEST_REPO}`,
|
||||
removedCount: 2,
|
||||
},
|
||||
]);
|
||||
expect(activityJobs).toHaveLength(1);
|
||||
expect(activityJobs[0]).toMatchObject({
|
||||
status: "warning",
|
||||
message: `All releases removed from ${DEST_OWNER}/${DEST_REPO}`,
|
||||
skipNotification: true,
|
||||
skipDuplicateEvent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("stays quiet while at least one release survives the filters", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [giteaRelease("v2.0.0")],
|
||||
destinationReleases: [
|
||||
destinationRelease("v2.0.0-beta", 21, true),
|
||||
destinationRelease("v1.9.0", 22, false),
|
||||
],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), { releaseType: "stable" });
|
||||
|
||||
expect(world.deletes().map((r) => r.url)).toEqual([`${DEST_API}/releases/21`]);
|
||||
expect(removalNotices).toHaveLength(0);
|
||||
expect(activityJobs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isChild)("release tag pattern filter", () => {
|
||||
it("skips the source releases an exclude pattern names", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [
|
||||
giteaRelease("v2.1.0-rc.1"),
|
||||
giteaRelease("v2.0.0"),
|
||||
giteaRelease("v1.0.0"),
|
||||
],
|
||||
});
|
||||
|
||||
// The upstream never flagged anything as a pre-release, which is exactly the
|
||||
// case the tag filter exists for.
|
||||
await mirrorFromGitea(giteaConnection("source-token"), {
|
||||
releaseExcludeTags: ["*-rc.*"],
|
||||
});
|
||||
|
||||
expect(world.createdTags()).toEqual(["v2.0.0", "v1.0.0"]);
|
||||
});
|
||||
|
||||
it("mirrors only the tags an include pattern names", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [giteaRelease("v2.0.0"), giteaRelease("v1.2.0"), giteaRelease("v1.0.0")],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), {
|
||||
releaseIncludeTags: ["v1.*"],
|
||||
});
|
||||
|
||||
expect(world.createdTags()).toEqual(["v1.2.0", "v1.0.0"]);
|
||||
});
|
||||
|
||||
it("lets exclude win when both lists name a tag", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [giteaRelease("v2.0.0"), giteaRelease("v2.0.0-rc.1")],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), {
|
||||
releaseIncludeTags: ["v*"],
|
||||
releaseExcludeTags: ["*-rc.*"],
|
||||
});
|
||||
|
||||
expect(world.createdTags()).toEqual(["v2.0.0"]);
|
||||
});
|
||||
|
||||
it("matches case-insensitively", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [giteaRelease("V2.0.0"), giteaRelease("v1.0.0")],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), {
|
||||
releaseIncludeTags: ["v2*"],
|
||||
});
|
||||
|
||||
expect(world.createdTags()).toEqual(["V2.0.0"]);
|
||||
});
|
||||
|
||||
it("treats a pattern in slashes as a regular expression", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [
|
||||
giteaRelease("v1.2.3-rc.1"),
|
||||
giteaRelease("v1.2.3"),
|
||||
giteaRelease("nightly-20260101"),
|
||||
],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), {
|
||||
releaseIncludeTags: ["/^v\\d+\\.\\d+\\.\\d+$/"],
|
||||
});
|
||||
|
||||
expect(world.createdTags()).toEqual(["v1.2.3"]);
|
||||
});
|
||||
|
||||
it("widens the scan window for a tag filter alone", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [
|
||||
giteaRelease("nightly-20260201"),
|
||||
giteaRelease("nightly-20260115"),
|
||||
giteaRelease("v2.0.0"),
|
||||
],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), {
|
||||
releaseLimit: 1,
|
||||
releaseIncludeTags: ["v*"],
|
||||
});
|
||||
|
||||
// A one-release scan would have found only nightlies and mirrored nothing;
|
||||
// the filter looks further back so "the newest one" is the newest match.
|
||||
expect(world.createdTags()).toEqual(["v2.0.0"]);
|
||||
const listing = world.requests.find((r) => r.url.startsWith(`${SOURCE_URL}/api/v1/`));
|
||||
expect(listing?.url).toContain("limit=50&page=1");
|
||||
});
|
||||
|
||||
it("removes destination releases whose tag no longer matches", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [giteaRelease("v2.0.0")],
|
||||
destinationReleases: [
|
||||
// No pre-release flag: a tag pattern needs only the tag name, so unlike
|
||||
// the type filter it can still clean these up.
|
||||
destinationRelease("v1.9.0-beta", 11, null),
|
||||
destinationRelease("v1.8.0", 12, null),
|
||||
],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), {
|
||||
releaseExcludeTags: ["*-beta"],
|
||||
});
|
||||
|
||||
expect(world.deletes().map((r) => r.url)).toEqual([`${DEST_API}/releases/11`]);
|
||||
});
|
||||
|
||||
it("combines with the type filter and the count limit", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [
|
||||
giteaRelease("v3.0.0-rc.1", { prerelease: true }),
|
||||
giteaRelease("v2.9.0-beta.2"),
|
||||
giteaRelease("v2.0.0"),
|
||||
giteaRelease("v1.0.0"),
|
||||
],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), {
|
||||
releaseLimit: 1,
|
||||
releaseType: "stable",
|
||||
releaseExcludeTags: ["*-beta*"],
|
||||
});
|
||||
|
||||
expect(world.createdTags()).toEqual(["v2.0.0"]);
|
||||
});
|
||||
|
||||
it("ignores a pattern that cannot compile instead of failing the run", async () => {
|
||||
const world = fakeWorld({
|
||||
sourceReleases: [giteaRelease("v2.0.0"), giteaRelease("v1.0.0")],
|
||||
});
|
||||
|
||||
await mirrorFromGitea(giteaConnection("source-token"), {
|
||||
releaseExcludeTags: ["/v1(/"],
|
||||
});
|
||||
|
||||
// The broken pattern is dropped, which leaves no filter at all rather than a
|
||||
// mirror run that throws.
|
||||
expect(world.createdTags()).toEqual(["v2.0.0", "v1.0.0"]);
|
||||
expect(world.deletes()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isChild)("mirrorGitHubReleasesToGitea keeps its own header and credentials", () => {
|
||||
function githubOctokit(releases: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
|
||||
+252
-33
@@ -7,7 +7,7 @@ import {
|
||||
import { membershipRoleEnum } from "@/types/organizations";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import type { Config } from "@/types/config";
|
||||
import type { Organization, Repository } from "./db/schema";
|
||||
import type { Organization, ReleaseType, Repository } from "./db/schema";
|
||||
import { httpPost, httpGet, httpDelete, httpPut, httpPatch } from "./http-client";
|
||||
import { createMirrorJob } from "./helpers";
|
||||
import { db, organizations, repositories } from "./db";
|
||||
@@ -27,9 +27,13 @@ import {
|
||||
decryptSourceToken,
|
||||
findSourceForOrganization,
|
||||
findSourceForRepository,
|
||||
isGitHubAppSource,
|
||||
listSources,
|
||||
sourceAccessToken,
|
||||
} from "./sources";
|
||||
import { triggerAllReleasesRemovedNotification } from "./notification-service";
|
||||
import { isRateLimitError } from "./rate-limit-gate";
|
||||
import { assertMirrorCredentialUpdateSupported } from "./gitea-mirror-credentials";
|
||||
import { persistMetadataPassProgress } from "./metadata-progress-store";
|
||||
import {
|
||||
advanceMetadataSyncCursor,
|
||||
@@ -42,9 +46,16 @@ import {
|
||||
type MetadataSyncCursor,
|
||||
} from "./metadata-state";
|
||||
import {
|
||||
compileReleaseTagPattern,
|
||||
compileReleaseTagPatterns,
|
||||
normalizeReleaseAssetLimit,
|
||||
normalizeReleaseLimit,
|
||||
normalizeReleaseTagPatterns,
|
||||
normalizeReleaseType,
|
||||
releaseGetsAssets,
|
||||
releaseMatchesTagFilters,
|
||||
releaseMatchesType,
|
||||
releaseScanLimit,
|
||||
resolveMirrorOptionsForRepository,
|
||||
resolveOrganizationSkipForks,
|
||||
type ResolvedMirrorOptions,
|
||||
@@ -76,6 +87,9 @@ export async function mirrorRepositoryReleases({
|
||||
giteaRepoName,
|
||||
releaseLimit,
|
||||
releaseAssetLimit,
|
||||
releaseType,
|
||||
releaseIncludeTags,
|
||||
releaseExcludeTags,
|
||||
}: {
|
||||
config: Partial<Config>;
|
||||
repository: Repository;
|
||||
@@ -88,6 +102,9 @@ export async function mirrorRepositoryReleases({
|
||||
giteaRepoName: string;
|
||||
releaseLimit?: number;
|
||||
releaseAssetLimit?: number | null;
|
||||
releaseType?: ReleaseType;
|
||||
releaseIncludeTags?: string[];
|
||||
releaseExcludeTags?: string[];
|
||||
}): Promise<boolean> {
|
||||
if (sourceConnection.provider === "github") {
|
||||
if (!octokit) {
|
||||
@@ -105,6 +122,9 @@ export async function mirrorRepositoryReleases({
|
||||
giteaRepoName,
|
||||
releaseLimit,
|
||||
releaseAssetLimit,
|
||||
releaseType,
|
||||
releaseIncludeTags,
|
||||
releaseExcludeTags,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -118,6 +138,9 @@ export async function mirrorRepositoryReleases({
|
||||
giteaRepoName,
|
||||
releaseLimit,
|
||||
releaseAssetLimit,
|
||||
releaseType,
|
||||
releaseIncludeTags,
|
||||
releaseExcludeTags,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -774,7 +797,14 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
const sourceConnection = repoSource
|
||||
? sourceConnectionFromSource(repoSource, { userId: config.userId })
|
||||
: resolveSourceConnection(config);
|
||||
const repoSourceToken = repoSource ? decryptSourceToken(repoSource.token) : "";
|
||||
// A GitHub App installation token expires within an hour, so the mirror
|
||||
// can only keep pulling where Gitea lets each sync replace it.
|
||||
const repoUsesGitHubApp = repoSource ? isGitHubAppSource(repoSource) : false;
|
||||
if (repoUsesGitHubApp) {
|
||||
await assertMirrorCredentialUpdateSupported(config, decryptedConfig.giteaConfig.token);
|
||||
}
|
||||
const repoSourceToken = repoSource ? await sourceAccessToken(repoSource) : "";
|
||||
sourceConnection.token = repoSourceToken;
|
||||
|
||||
// Get the correct owner based on the strategy (with organization overrides)
|
||||
let repoOwner = await getGiteaRepoOwnerAsync({
|
||||
@@ -1085,7 +1115,9 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
clone_addr: cloneAddress,
|
||||
repo_name: targetRepoName,
|
||||
mirror: true,
|
||||
mirror_interval: config.giteaConfig?.mirrorInterval || "8h",
|
||||
// App-backed mirrors are pulled only when this app has refreshed the
|
||||
// token; Gitea's own schedule would pull with an expired one.
|
||||
mirror_interval: repoUsesGitHubApp ? "0h" : config.giteaConfig?.mirrorInterval || "8h",
|
||||
wiki: mirrorOptions.wiki,
|
||||
// Gitea performs the LFS fetch inside its own migration, so a failure
|
||||
// here aborts the entire migration with nothing to salvage. That is why
|
||||
@@ -1107,7 +1139,7 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
buildSourceAuthPayload({
|
||||
provider: sourceConnection.provider,
|
||||
token: repoSourceToken,
|
||||
username: sourceConnection.username,
|
||||
username: repoUsesGitHubApp ? "x-access-token" : sourceConnection.username,
|
||||
repositoryOwner: repository.owner,
|
||||
})
|
||||
);
|
||||
@@ -1156,6 +1188,9 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
giteaRepoName: targetRepoName,
|
||||
releaseLimit: mirrorOptions.releaseLimit,
|
||||
releaseAssetLimit: mirrorOptions.releaseAssetLimit,
|
||||
releaseType: mirrorOptions.releaseType,
|
||||
releaseIncludeTags: mirrorOptions.releaseIncludeTags,
|
||||
releaseExcludeTags: mirrorOptions.releaseExcludeTags,
|
||||
});
|
||||
if (mirrored) {
|
||||
metadataState.components.releases = true;
|
||||
@@ -1680,7 +1715,14 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
const sourceConnection = repoSource
|
||||
? sourceConnectionFromSource(repoSource, { userId: config.userId })
|
||||
: resolveSourceConnection(config);
|
||||
const repoSourceToken = repoSource ? decryptSourceToken(repoSource.token) : "";
|
||||
// A GitHub App installation token expires within an hour, so the mirror
|
||||
// can only keep pulling where Gitea lets each sync replace it.
|
||||
const repoUsesGitHubApp = repoSource ? isGitHubAppSource(repoSource) : false;
|
||||
if (repoUsesGitHubApp) {
|
||||
await assertMirrorCredentialUpdateSupported(config, decryptedConfig.giteaConfig.token);
|
||||
}
|
||||
const repoSourceToken = repoSource ? await sourceAccessToken(repoSource) : "";
|
||||
sourceConnection.token = repoSourceToken;
|
||||
|
||||
// Determine the actual repository name to use (handle duplicates for starred repos)
|
||||
let targetRepoName = repository.name;
|
||||
@@ -1900,7 +1942,9 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
uid: giteaOrgId,
|
||||
repo_name: targetRepoName,
|
||||
mirror: true,
|
||||
mirror_interval: config.giteaConfig?.mirrorInterval || "8h",
|
||||
// App-backed mirrors are pulled only when this app has refreshed the
|
||||
// token; Gitea's own schedule would pull with an expired one.
|
||||
mirror_interval: repoUsesGitHubApp ? "0h" : config.giteaConfig?.mirrorInterval || "8h",
|
||||
wiki: mirrorOptions.wiki,
|
||||
// Gitea performs the LFS fetch inside its own migration, so a failure
|
||||
// here aborts the entire migration with nothing to salvage. That is why
|
||||
@@ -1921,7 +1965,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
buildSourceAuthPayload({
|
||||
provider: sourceConnection.provider,
|
||||
token: repoSourceToken,
|
||||
username: sourceConnection.username,
|
||||
username: repoUsesGitHubApp ? "x-access-token" : sourceConnection.username,
|
||||
repositoryOwner: repository.owner,
|
||||
})
|
||||
);
|
||||
@@ -1969,6 +2013,9 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
giteaRepoName: targetRepoName,
|
||||
releaseLimit: mirrorOptions.releaseLimit,
|
||||
releaseAssetLimit: mirrorOptions.releaseAssetLimit,
|
||||
releaseType: mirrorOptions.releaseType,
|
||||
releaseIncludeTags: mirrorOptions.releaseIncludeTags,
|
||||
releaseExcludeTags: mirrorOptions.releaseExcludeTags,
|
||||
});
|
||||
if (mirrored) {
|
||||
metadataState.components.releases = true;
|
||||
@@ -3801,6 +3848,20 @@ type MirrorReleasesParams = {
|
||||
* none, `undefined` means resolve it here like the release limit.
|
||||
*/
|
||||
releaseAssetLimit?: number | null;
|
||||
/**
|
||||
* Already-resolved release type: `all` mirrors every release, `stable` only
|
||||
* the ones the source does not flag as a pre-release. `undefined` means
|
||||
* resolve it here like the other two.
|
||||
*/
|
||||
releaseType?: ReleaseType;
|
||||
/**
|
||||
* Already-resolved tag patterns narrowing the release type filter further:
|
||||
* `releaseIncludeTags` keeps only tags matching one of them,
|
||||
* `releaseExcludeTags` drops the ones that do. An empty list means no filter.
|
||||
* `undefined` means resolve them here like the other three.
|
||||
*/
|
||||
releaseIncludeTags?: string[];
|
||||
releaseExcludeTags?: string[];
|
||||
};
|
||||
|
||||
/** The same parameters with the source adapter in place of the GitHub client. */
|
||||
@@ -3808,6 +3869,14 @@ type MirrorSourceReleasesParams = Omit<MirrorReleasesParams, "octokit"> & {
|
||||
source: ReleaseSourceAdapter;
|
||||
};
|
||||
|
||||
/**
|
||||
* The stored tag patterns that can actually be compiled. Used only for the log
|
||||
* line, so a broken pattern is never named as if it were in effect.
|
||||
*/
|
||||
function usableTagPatterns(patterns: readonly string[]): string[] {
|
||||
return patterns.filter((pattern) => compileReleaseTagPattern(pattern) !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock key for one destination repository's releases: the destination base URL
|
||||
* plus the owner and repository name it is mirrored as, lowercased.
|
||||
@@ -3967,6 +4036,9 @@ async function mirrorSourceReleasesToGiteaLocked({
|
||||
giteaRepoName,
|
||||
releaseLimit: releaseLimitOverride,
|
||||
releaseAssetLimit: releaseAssetLimitOverride,
|
||||
releaseType: releaseTypeOverride,
|
||||
releaseIncludeTags: releaseIncludeTagsOverride,
|
||||
releaseExcludeTags: releaseExcludeTagsOverride,
|
||||
}: MirrorSourceReleasesParams) {
|
||||
if (
|
||||
!config.giteaConfig?.defaultOwner ||
|
||||
@@ -4025,14 +4097,66 @@ async function mirrorSourceReleasesToGiteaLocked({
|
||||
? (normalizeReleaseAssetLimit(releaseAssetLimitOverride) ?? null)
|
||||
: (await resolveOptions()).releaseAssetLimit;
|
||||
|
||||
// Which releases are candidates at all: every one, or only the ones the source
|
||||
// does not flag as a pre-release.
|
||||
const releaseType =
|
||||
normalizeReleaseType(releaseTypeOverride) ??
|
||||
(await resolveOptions()).releaseType;
|
||||
|
||||
// Tag patterns narrow that further, for the projects that never set the
|
||||
// pre-release flag but tag `v1.2.3-rc.1`. Include keeps only matching tags,
|
||||
// exclude drops matching ones, and exclude wins when both name a tag.
|
||||
const releaseIncludeTags =
|
||||
normalizeReleaseTagPatterns(releaseIncludeTagsOverride) ??
|
||||
(await resolveOptions()).releaseIncludeTags;
|
||||
const releaseExcludeTags =
|
||||
normalizeReleaseTagPatterns(releaseExcludeTagsOverride) ??
|
||||
(await resolveOptions()).releaseExcludeTags;
|
||||
|
||||
const includeTagPatterns = compileReleaseTagPatterns(releaseIncludeTags);
|
||||
const excludeTagPatterns = compileReleaseTagPatterns(releaseExcludeTags);
|
||||
const hasTypeFilter = releaseType !== "all";
|
||||
const hasTagFilter =
|
||||
includeTagPatterns.length > 0 || excludeTagPatterns.length > 0;
|
||||
const hasReleaseFilter = hasTypeFilter || hasTagFilter;
|
||||
|
||||
// The source lists its own newest releases; everything below this point is
|
||||
// the same reconciliation whichever host they came from.
|
||||
const limitedReleases = (
|
||||
await source.listReleases(sourceOwner, sourceRepo, releaseLimit)
|
||||
).slice(0, releaseLimit);
|
||||
// the same reconciliation whichever host they came from. With a filter
|
||||
// active the mirror looks further back first, so "the newest 10" means the
|
||||
// newest 10 matching releases rather than whatever survives of the newest 10.
|
||||
const scanLimit = releaseScanLimit(releaseLimit, releaseType, hasTagFilter);
|
||||
const fetchedReleases = await source.listReleases(sourceOwner, sourceRepo, scanLimit);
|
||||
const matchingReleases = fetchedReleases.filter(
|
||||
(release) =>
|
||||
releaseMatchesType(release, releaseType) &&
|
||||
releaseMatchesTagFilters(
|
||||
release.tag_name,
|
||||
includeTagPatterns,
|
||||
excludeTagPatterns
|
||||
)
|
||||
);
|
||||
const limitedReleases = matchingReleases.slice(0, releaseLimit);
|
||||
const filteredOutCount = fetchedReleases.length - matchingReleases.length;
|
||||
|
||||
// Only the patterns that actually compiled are named in the log, so a broken
|
||||
// one cannot look like it is doing something.
|
||||
const filterDescriptions: string[] = [];
|
||||
if (hasTypeFilter) filterDescriptions.push(`only ${releaseType} releases`);
|
||||
const usableIncludeTags = usableTagPatterns(releaseIncludeTags);
|
||||
const usableExcludeTags = usableTagPatterns(releaseExcludeTags);
|
||||
if (usableIncludeTags.length > 0) {
|
||||
filterDescriptions.push(`only tags matching ${usableIncludeTags.join(", ")}`);
|
||||
}
|
||||
if (usableExcludeTags.length > 0) {
|
||||
filterDescriptions.push(`skipping tags matching ${usableExcludeTags.join(", ")}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Releases] Found ${limitedReleases.length} releases (limited to latest ${releaseLimit}; ${
|
||||
filterDescriptions.length > 0
|
||||
? `${filterDescriptions.join(", ")}; ${filteredOutCount} filtered out`
|
||||
: "any release type"
|
||||
}; ${
|
||||
releaseAssetLimit === null
|
||||
? "assets for every release"
|
||||
: `assets for the newest ${releaseAssetLimit}`
|
||||
@@ -4041,7 +4165,10 @@ async function mirrorSourceReleasesToGiteaLocked({
|
||||
|
||||
if (limitedReleases.length === 0) {
|
||||
console.log(`[Releases] No releases to mirror for ${repository.fullName}`);
|
||||
return;
|
||||
// With a filter active the destination may still hold releases the filter
|
||||
// excludes, and the retention pass below is what removes them, so only the
|
||||
// unfiltered case keeps its historical early exit.
|
||||
if (!hasReleaseFilter) return;
|
||||
}
|
||||
|
||||
let mirroredCount = 0;
|
||||
@@ -4244,10 +4371,16 @@ async function mirrorSourceReleasesToGiteaLocked({
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce release retention limit by removing the oldest excess releases from Gitea
|
||||
// Enforce release retention: first drop the releases the type filter excludes,
|
||||
// then the oldest releases past the limit.
|
||||
try {
|
||||
// Paginate to fetch ALL Gitea releases (API max is 100 per page)
|
||||
const allGiteaReleases: Array<{ id: number; tag_name: string; created_at: string }> = [];
|
||||
const allGiteaReleases: Array<{
|
||||
id: number;
|
||||
tag_name: string;
|
||||
created_at: string;
|
||||
prerelease?: boolean | null;
|
||||
}> = [];
|
||||
let cleanupPage = 1;
|
||||
while (true) {
|
||||
const pageResponse = await httpGet(
|
||||
@@ -4269,36 +4402,122 @@ async function mirrorSourceReleasesToGiteaLocked({
|
||||
cleanupPage++;
|
||||
}
|
||||
|
||||
if (allGiteaReleases.length > releaseLimit) {
|
||||
const excessCount = allGiteaReleases.length - releaseLimit;
|
||||
const giteaBaseUrl = config.giteaConfig.url;
|
||||
const giteaToken = decryptedConfig.giteaConfig.token;
|
||||
|
||||
const deleteGiteaRelease = async (
|
||||
release: { id: number; tag_name: string },
|
||||
reason: string
|
||||
) => {
|
||||
try {
|
||||
await httpDelete(
|
||||
`${giteaBaseUrl}/api/v1/repos/${repoOwner}/${repoName}/releases/${release.id}`,
|
||||
{
|
||||
Authorization: `token ${giteaToken}`,
|
||||
}
|
||||
);
|
||||
console.log(`[Releases] Deleted ${reason} release: ${release.tag_name}`);
|
||||
} catch (deleteError) {
|
||||
console.error(
|
||||
`[Releases] Failed to delete ${reason} release ${release.tag_name}: ${
|
||||
deleteError instanceof Error ? deleteError.message : String(deleteError)
|
||||
}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Releases the filters exclude are no longer mirror candidates, so they leave
|
||||
// the destination the same way releases past the limit do. A release the
|
||||
// destination did not report a pre-release flag for is kept: guessing which
|
||||
// kind it was risks deleting the wrong one. Tag patterns need no such caveat -
|
||||
// they are judged on the tag name, which every release has.
|
||||
let retainedReleases = allGiteaReleases;
|
||||
if (hasReleaseFilter) {
|
||||
const excluded = allGiteaReleases.filter(
|
||||
(rel) =>
|
||||
!releaseMatchesTagFilters(
|
||||
rel.tag_name ?? "",
|
||||
includeTagPatterns,
|
||||
excludeTagPatterns
|
||||
) ||
|
||||
(hasTypeFilter &&
|
||||
typeof rel.prerelease === "boolean" &&
|
||||
!releaseMatchesType(rel, releaseType))
|
||||
);
|
||||
|
||||
if (excluded.length > 0) {
|
||||
console.log(
|
||||
`[Releases] Enforcing release filters (${filterDescriptions.join(", ")}): removing ${excluded.length} release(s) in Gitea that do not match`
|
||||
);
|
||||
for (const rel of excluded) {
|
||||
await deleteGiteaRelease(rel, "excluded");
|
||||
}
|
||||
|
||||
const excludedIds = new Set(excluded.map((rel) => rel.id));
|
||||
retainedReleases = allGiteaReleases.filter(
|
||||
(rel) => !excludedIds.has(rel.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The filters emptied a destination that had releases. The sync itself
|
||||
// succeeded, so no job-failure notification would fire; without this the
|
||||
// user would only notice the gone releases on the Gitea site. The activity
|
||||
// log gets its own row (like the force-push alerts) and the push channels
|
||||
// get the same warning through the "Sync errors" switch.
|
||||
if (allGiteaReleases.length > 0 && retainedReleases.length === 0) {
|
||||
const userId = config.userId ?? repository.userId;
|
||||
const removedCount = allGiteaReleases.length;
|
||||
|
||||
void triggerAllReleasesRemovedNotification({
|
||||
userId,
|
||||
repositoryName: repository.name,
|
||||
destination: `${repoOwner}/${repoName}`,
|
||||
removedCount,
|
||||
});
|
||||
|
||||
// skipNotification keeps the row out of the push notification system:
|
||||
// triggerAllReleasesRemovedNotification covers that side with its own
|
||||
// message. skipDuplicateEvent stops the row's `repo-<id>-warning` key
|
||||
// from swallowing the sync's own completion event on the event stream;
|
||||
// the terminal update already refreshes the activity view. The row uses
|
||||
// the activity-only "warning" status so it stands out from plain syncs.
|
||||
await createMirrorJob({
|
||||
userId,
|
||||
repositoryId: repository.id,
|
||||
repositoryName: repository.name,
|
||||
status: "warning",
|
||||
skipNotification: true,
|
||||
skipDuplicateEvent: true,
|
||||
message: `All releases removed from ${repoOwner}/${repoName}`,
|
||||
details:
|
||||
`${removedCount} release${removedCount === 1 ? "" : "s"} deleted by the release filters; the destination now has none.` +
|
||||
" Check the include/exclude tag patterns and the pre-release setting.",
|
||||
}).catch((activityError) => {
|
||||
console.error(
|
||||
`[Releases] Failed to record the all-releases-removed activity for ${repository.fullName}: ${
|
||||
activityError instanceof Error ? activityError.message : String(activityError)
|
||||
}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (retainedReleases.length > releaseLimit) {
|
||||
const excessCount = retainedReleases.length - releaseLimit;
|
||||
|
||||
// Sort by created_at ascending (oldest first) so we delete the oldest excess
|
||||
const sorted = [...allGiteaReleases].sort(
|
||||
const sorted = [...retainedReleases].sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
|
||||
const toDelete = sorted.slice(0, excessCount);
|
||||
|
||||
console.log(
|
||||
`[Releases] Enforcing retention limit (${releaseLimit}): ${allGiteaReleases.length} releases found, removing ${toDelete.length} oldest excess release(s)`
|
||||
`[Releases] Enforcing retention limit (${releaseLimit}): ${retainedReleases.length} releases found, removing ${toDelete.length} oldest excess release(s)`
|
||||
);
|
||||
|
||||
for (const excess of toDelete) {
|
||||
try {
|
||||
await httpDelete(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${excess.id}`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
console.log(`[Releases] Deleted excess release: ${excess.tag_name}`);
|
||||
} catch (deleteError) {
|
||||
console.error(
|
||||
`[Releases] Failed to delete excess release ${excess.tag_name}: ${
|
||||
deleteError instanceof Error ? deleteError.message : String(deleteError)
|
||||
}`
|
||||
);
|
||||
}
|
||||
await deleteGiteaRelease(excess, "excess");
|
||||
}
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Octokit for a GitHub App installation source.
|
||||
*
|
||||
* The client is the throttled public one with an installation token added to
|
||||
* every request. Tokens come from resolveGitHubAccessToken, which caches them
|
||||
* until shortly before they expire, so long runs keep working past the hour.
|
||||
*/
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { createPublicGitHubClient } from "@/lib/github";
|
||||
import { resolveGitHubAccessToken } from "@/lib/github-app-credentials";
|
||||
|
||||
export function createGitHubAppClient(
|
||||
credential: string,
|
||||
{ userId, apiBaseUrl }: { userId?: string; apiBaseUrl?: string } = {}
|
||||
): Octokit {
|
||||
const octokit = createPublicGitHubClient(apiBaseUrl, userId);
|
||||
const tokenApiBaseUrl =
|
||||
apiBaseUrl || process.env.GH_API_URL || process.env.GITHUB_API_URL || "https://api.github.com";
|
||||
octokit.hook.before("request", async (options) => {
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
authorization: `token ${await resolveGitHubAccessToken(credential, tokenApiBaseUrl)}`,
|
||||
};
|
||||
});
|
||||
return octokit;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import { encodeGitHubAppCredentials, parseGitHubAppCredentials, resolveGitHubAccessToken } from "./github-app-credentials";
|
||||
|
||||
test("GitHub App credentials round-trip without confusing PATs", () => {
|
||||
const credentials = { appId: "123", installationId: "456", privateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----" };
|
||||
expect(parseGitHubAppCredentials(encodeGitHubAppCredentials(credentials))).toEqual(credentials);
|
||||
expect(parseGitHubAppCredentials("ghp_pat")).toBeNull();
|
||||
expect(() => parseGitHubAppCredentials("github-app:v1:broken")).toThrow();
|
||||
});
|
||||
|
||||
test("installation tokens are minted, cached and never substituted for a PAT", async () => {
|
||||
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048, privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" } });
|
||||
const credential = encodeGitHubAppCredentials({ appId: "123", installationId: "456", privateKey });
|
||||
const originalFetch = globalThis.fetch;
|
||||
let requests = 0;
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
requests++;
|
||||
expect(String(input)).toBe("https://api.github.com/app/installations/456/access_tokens");
|
||||
expect(init?.headers).toHaveProperty("Authorization");
|
||||
return new Response(JSON.stringify({ token: "ghs_test", expires_at: new Date(Date.now() + 3600_000).toISOString() }), { status: 201 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
expect(await resolveGitHubAccessToken(credential)).toBe("ghs_test");
|
||||
expect(await resolveGitHubAccessToken(credential)).toBe("ghs_test");
|
||||
expect(requests).toBe(1);
|
||||
expect(await resolveGitHubAccessToken("ghp_pat")).toBe("ghp_pat");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { sign } from "jsonwebtoken";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export interface GitHubAppCredentials {
|
||||
appId: string;
|
||||
installationId: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
const PREFIX = "github-app:v1:";
|
||||
|
||||
/** True for a stored App credential, valid or not; never send one as a token. */
|
||||
export function isGitHubAppCredential(value: string | null | undefined): boolean {
|
||||
return typeof value === "string" && value.startsWith(PREFIX);
|
||||
}
|
||||
|
||||
export function encodeGitHubAppCredentials(credentials: GitHubAppCredentials): string {
|
||||
if (!/^\d+$/.test(credentials.appId) || !/^\d+$/.test(credentials.installationId) ||
|
||||
!credentials.privateKey.includes("-----BEGIN") || !credentials.privateKey.includes("PRIVATE KEY-----")) {
|
||||
throw new Error("A GitHub App ID, installation ID, and PEM private key are required.");
|
||||
}
|
||||
return PREFIX + JSON.stringify(credentials);
|
||||
}
|
||||
|
||||
export function parseGitHubAppCredentials(value: string): GitHubAppCredentials | null {
|
||||
if (!value.startsWith(PREFIX)) return null;
|
||||
try {
|
||||
const credentials = JSON.parse(value.slice(PREFIX.length));
|
||||
if (typeof credentials.appId === "string" && typeof credentials.installationId === "string" &&
|
||||
typeof credentials.privateKey === "string") {
|
||||
encodeGitHubAppCredentials(credentials);
|
||||
return credentials;
|
||||
}
|
||||
} catch {
|
||||
// Invalid stored credentials should never be used as an access token.
|
||||
}
|
||||
throw new Error("Invalid stored GitHub App credentials.");
|
||||
}
|
||||
|
||||
const installationTokens = new Map<string, { token: string; expiresAt: number }>();
|
||||
|
||||
/**
|
||||
* The token to authenticate with: a PAT as is, or an installation token for
|
||||
* App credentials. `minValidityMs` is how long a cached installation token
|
||||
* must still be valid; a token handed to Gitea for a queued mirror pull needs
|
||||
* more headroom than one used for an API call made right away.
|
||||
*/
|
||||
export async function resolveGitHubAccessToken(
|
||||
value: string,
|
||||
apiBaseUrl = "https://api.github.com",
|
||||
{ minValidityMs = 60_000 }: { minValidityMs?: number } = {}
|
||||
): Promise<string> {
|
||||
const credentials = parseGitHubAppCredentials(value);
|
||||
if (!credentials) return value;
|
||||
|
||||
const cacheKey = createHash("sha256").update(`${apiBaseUrl}:${credentials.appId}:${credentials.installationId}:${credentials.privateKey}`).digest("hex");
|
||||
const cached = installationTokens.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now() + minValidityMs) return cached.token;
|
||||
|
||||
const jwt = sign({}, credentials.privateKey, {
|
||||
algorithm: "RS256",
|
||||
issuer: credentials.appId,
|
||||
expiresIn: "9m",
|
||||
});
|
||||
const response = await fetch(`${apiBaseUrl.replace(/\/+$/, "")}/app/installations/${credentials.installationId}/access_tokens`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`GitHub App installation token request failed (${response.status}).`);
|
||||
const result = await response.json() as { token?: string; expires_at?: string };
|
||||
if (!result.token || !result.expires_at || !Number.isFinite(Date.parse(result.expires_at))) {
|
||||
throw new Error("GitHub App returned an invalid installation token.");
|
||||
}
|
||||
installationTokens.set(cacheKey, { token: result.token, expiresAt: Date.parse(result.expires_at) });
|
||||
return result.token;
|
||||
}
|
||||
+65
-38
@@ -334,7 +334,7 @@ export function createGitHubClient(
|
||||
* schedule retries. Never pass an empty token to createGitHubClient
|
||||
* instead — Octokit treats `auth: ""` as an invalid credential header.
|
||||
*/
|
||||
export function createPublicGitHubClient(apiBaseUrl?: string): Octokit {
|
||||
export function createPublicGitHubClient(apiBaseUrl?: string, userId?: string): Octokit {
|
||||
// Same base URL resolution as createGitHubClient: an explicit apiBaseUrl
|
||||
// (a GitHub Enterprise source's /api/v3) replaces the env default;
|
||||
// otherwise support GH_API_URL (preferred) or GITHUB_API_URL (may conflict
|
||||
@@ -356,13 +356,13 @@ export function createPublicGitHubClient(apiBaseUrl?: string): Octokit {
|
||||
"x-github-api-version": "2022-11-28", // Use a stable API version
|
||||
},
|
||||
},
|
||||
throttle: githubThrottleOptions(),
|
||||
throttle: githubThrottleOptions(userId),
|
||||
});
|
||||
|
||||
// Anonymous clients share one gate key, so one exhausted 60 req/hr budget
|
||||
// pauses every tokenless source together.
|
||||
installRateLimitGate(octokit, {
|
||||
key: rateLimitGateKey(undefined),
|
||||
key: rateLimitGateKey(userId),
|
||||
maxWaitMs: RATE_LIMIT_FAST_FAIL_THRESHOLD_SECONDS * 1000,
|
||||
});
|
||||
|
||||
@@ -392,6 +392,63 @@ export async function getGithubRepoCloneUrl({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The account filters a repository listing honors: skipForks (with
|
||||
* per-organization pins), skipPersonalRepos and the includeOrganizations
|
||||
* allowlist. Shared by the token listing and the GitHub App installation
|
||||
* listing so both import the same repositories.
|
||||
*/
|
||||
export function filterGithubRepositories<
|
||||
T extends { fork?: boolean; owner: { login: string; type?: string } },
|
||||
>(
|
||||
repos: T[],
|
||||
config: Partial<Config>,
|
||||
{
|
||||
includeAllOrgsOverride,
|
||||
orgForkOverrides,
|
||||
authenticatedUserLogin,
|
||||
}: {
|
||||
includeAllOrgsOverride?: boolean;
|
||||
orgForkOverrides?: Map<string, boolean>;
|
||||
authenticatedUserLogin: string;
|
||||
}
|
||||
): T[] {
|
||||
const forkSkipFor = orgForkSkipDecision(orgForkOverrides, config);
|
||||
const skipPersonalRepos = config.githubConfig?.skipPersonalRepos ?? false;
|
||||
// Opt-in organization allowlist. When non-empty, only repos owned by the
|
||||
// listed organizations are imported; org repos from any other org the user
|
||||
// happens to be a member of are dropped. Empty = import all org repos
|
||||
// (backward-compatible default). Owned/collaborator repos are unaffected.
|
||||
const includeOrgs = includeAllOrgsOverride
|
||||
? []
|
||||
: config.githubConfig?.includeOrganizations ?? [];
|
||||
const allowedOrgs = new Set(
|
||||
includeOrgs.map((org) => org.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
|
||||
return repos.filter((repo) => {
|
||||
const skipForks = forkSkipFor(
|
||||
repo.owner.type === "Organization" ? repo.owner.login : undefined
|
||||
);
|
||||
const isForkAllowed = !skipForks || !repo.fork;
|
||||
// When skipPersonalRepos is true, drop repos owned by the authenticated user
|
||||
// (owner.type === "User" and owner.login matches the configured GitHub username).
|
||||
// Org repos have owner.type === "Organization" so they are always kept.
|
||||
const isPersonalRepo =
|
||||
skipPersonalRepos &&
|
||||
authenticatedUserLogin.length > 0 &&
|
||||
repo.owner.login === authenticatedUserLogin &&
|
||||
repo.owner.type === "User";
|
||||
// When an allowlist is configured, only keep org repos whose owning org
|
||||
// is listed. Non-org repos (owned/collaborator) are never restricted here.
|
||||
const isOrgAllowed =
|
||||
allowedOrgs.size === 0 ||
|
||||
repo.owner.type !== "Organization" ||
|
||||
allowedOrgs.has(repo.owner.login.toLowerCase());
|
||||
return isForkAllowed && !isPersonalRepo && isOrgAllowed;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user repositories from GitHub
|
||||
* todo: need to handle pagination and apply more filters based on user config
|
||||
@@ -435,41 +492,11 @@ export async function getGithubRepositories({
|
||||
{ per_page: 100, affiliation },
|
||||
);
|
||||
|
||||
const forkSkipFor = orgForkSkipDecision(orgForkOverrides, config);
|
||||
const skipPersonalRepos = config.githubConfig?.skipPersonalRepos ?? false;
|
||||
// The authenticated user's login — used to identify personally-owned repos
|
||||
const authenticatedUserLogin = config.githubConfig?.owner ?? "";
|
||||
// Opt-in organization allowlist. When non-empty, only repos owned by the
|
||||
// listed organizations are imported; org repos from any other org the user
|
||||
// happens to be a member of are dropped. Empty = import all org repos
|
||||
// (backward-compatible default). Owned/collaborator repos are unaffected.
|
||||
const includeOrgs = includeAllOrgsOverride
|
||||
? []
|
||||
: config.githubConfig?.includeOrganizations ?? [];
|
||||
const allowedOrgs = new Set(
|
||||
includeOrgs.map((org) => org.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
|
||||
const filteredRepos = repos.filter((repo) => {
|
||||
const skipForks = forkSkipFor(
|
||||
repo.owner.type === "Organization" ? repo.owner.login : undefined
|
||||
);
|
||||
const isForkAllowed = !skipForks || !repo.fork;
|
||||
// When skipPersonalRepos is true, drop repos owned by the authenticated user
|
||||
// (owner.type === "User" and owner.login matches the configured GitHub username).
|
||||
// Org repos have owner.type === "Organization" so they are always kept.
|
||||
const isPersonalRepo =
|
||||
skipPersonalRepos &&
|
||||
authenticatedUserLogin.length > 0 &&
|
||||
repo.owner.login === authenticatedUserLogin &&
|
||||
repo.owner.type === "User";
|
||||
// When an allowlist is configured, only keep org repos whose owning org
|
||||
// is listed. Non-org repos (owned/collaborator) are never restricted here.
|
||||
const isOrgAllowed =
|
||||
allowedOrgs.size === 0 ||
|
||||
repo.owner.type !== "Organization" ||
|
||||
allowedOrgs.has(repo.owner.login.toLowerCase());
|
||||
return isForkAllowed && !isPersonalRepo && isOrgAllowed;
|
||||
const filteredRepos = filterGithubRepositories(repos, config, {
|
||||
includeAllOrgsOverride,
|
||||
orgForkOverrides,
|
||||
// The authenticated user's login — used to identify personally-owned repos
|
||||
authenticatedUserLogin: config.githubConfig?.owner ?? "",
|
||||
});
|
||||
|
||||
return filteredRepos.map((repo) => ({
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import type { RepoStatus } from "@/types/Repository";
|
||||
import type { ActivityStatus, RepoStatus } from "@/types/Repository";
|
||||
import { db, mirrorJobs } from "./db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { buildInterruptedJobsCondition } from "./interrupted-job-detection";
|
||||
@@ -31,7 +31,7 @@ export async function createMirrorJob({
|
||||
repositoryName?: string;
|
||||
details?: string;
|
||||
message: string;
|
||||
status: RepoStatus;
|
||||
status: ActivityStatus;
|
||||
jobType?: "mirror" | "sync" | "retry";
|
||||
batchId?: string;
|
||||
totalItems?: number;
|
||||
|
||||
@@ -18,7 +18,7 @@ mock.module("@/lib/utils/encryption", () => ({
|
||||
}));
|
||||
|
||||
// Import after mocks are set up — db is already mocked via setup.bun.ts
|
||||
import { sendNotification, testNotification } from "./notification-service";
|
||||
import { sendNotification, testNotification, buildNewRepositoriesNotificationEvent, buildAllReleasesRemovedNotificationEvent, triggerAllReleasesRemovedNotification, isNewRepositoryGroupEnabled, MAX_LISTED_NEW_REPOSITORIES, STARRED_GROUP_LABEL } from "./notification-service";
|
||||
import type { NotificationConfig } from "@/types/config";
|
||||
|
||||
describe("sendNotification", () => {
|
||||
@@ -29,6 +29,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "test-topic",
|
||||
@@ -54,6 +57,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
apprise: {
|
||||
url: "http://apprise:8000",
|
||||
token: "my-token",
|
||||
@@ -78,6 +84,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
gotify: {
|
||||
url: "https://gotify.example.com",
|
||||
token: "my-app-token",
|
||||
@@ -104,6 +113,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
webhook: {
|
||||
url: "https://example.com/hooks/gitea-mirror",
|
||||
},
|
||||
@@ -133,6 +145,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "test-topic",
|
||||
@@ -155,6 +170,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "",
|
||||
@@ -178,6 +196,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
apprise: {
|
||||
url: "",
|
||||
token: "my-token",
|
||||
@@ -200,6 +221,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
gotify: {
|
||||
url: "https://gotify.example.com",
|
||||
token: "",
|
||||
@@ -223,6 +247,9 @@ describe("sendNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
webhook: {
|
||||
url: "",
|
||||
},
|
||||
@@ -246,6 +273,9 @@ describe("testNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
webhook: {
|
||||
url: "",
|
||||
},
|
||||
@@ -263,6 +293,9 @@ describe("testNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "test-topic",
|
||||
@@ -282,6 +315,9 @@ describe("testNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "",
|
||||
@@ -306,6 +342,9 @@ describe("testNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
ntfy: {
|
||||
url: "https://ntfy.sh",
|
||||
topic: "test-topic",
|
||||
@@ -325,6 +364,9 @@ describe("testNotification", () => {
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: true,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
};
|
||||
|
||||
const result = await testNotification(config);
|
||||
@@ -332,3 +374,188 @@ describe("testNotification", () => {
|
||||
expect(result.error).toContain("Unknown provider");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNewRepositoriesNotificationEvent", () => {
|
||||
test("reports the discovered repositories of one owner in a single event", () => {
|
||||
const event = buildNewRepositoriesNotificationEvent({
|
||||
kind: "organization",
|
||||
groupLabel: "gitea",
|
||||
repositoryNames: ["gitea/alpha", "gitea/beta"],
|
||||
details: "Discovered during scheduled sync on source github.",
|
||||
});
|
||||
|
||||
expect(event.type).toBe("new_repo");
|
||||
expect(event.title).toBe("New Repositories: gitea");
|
||||
expect(event.message).toContain("2 new repositories discovered for gitea:");
|
||||
expect(event.message).toContain("- gitea/alpha");
|
||||
expect(event.message).toContain("- gitea/beta");
|
||||
expect(event.message).toContain("Details: Discovered during scheduled sync on source github.");
|
||||
});
|
||||
|
||||
test("uses the singular wording for a single repository", () => {
|
||||
const event = buildNewRepositoriesNotificationEvent({
|
||||
kind: "own",
|
||||
groupLabel: "octocat",
|
||||
repositoryNames: ["octocat/alpha"],
|
||||
});
|
||||
|
||||
expect(event.message).toContain("1 new repository discovered for octocat:");
|
||||
expect(event.message).not.toContain("more");
|
||||
});
|
||||
|
||||
test("labels starred imports without naming an owner", () => {
|
||||
// Stars come from any number of owners, so they are reported as one group.
|
||||
const event = buildNewRepositoriesNotificationEvent({
|
||||
kind: "starred",
|
||||
repositoryNames: ["alpha/one", "beta/two"],
|
||||
});
|
||||
|
||||
expect(event.title).toBe(`New Repositories: ${STARRED_GROUP_LABEL}`);
|
||||
expect(event.message).toContain("2 new starred repositories discovered:");
|
||||
expect(event.message).not.toContain("for Starred");
|
||||
});
|
||||
|
||||
test("caps the listed repositories so a large import stays readable", () => {
|
||||
const names = Array.from({ length: MAX_LISTED_NEW_REPOSITORIES + 5 }, (_, i) => `gitea/repo-${i}`);
|
||||
const event = buildNewRepositoriesNotificationEvent({
|
||||
kind: "organization",
|
||||
groupLabel: "gitea",
|
||||
repositoryNames: names,
|
||||
});
|
||||
|
||||
expect(event.message).toContain("15 new repositories discovered for gitea:");
|
||||
expect(event.message).toContain(`- ${names[MAX_LISTED_NEW_REPOSITORIES - 1]}`);
|
||||
expect(event.message).not.toContain(names[MAX_LISTED_NEW_REPOSITORIES]);
|
||||
expect(event.message).toContain("- and 5 more");
|
||||
});
|
||||
|
||||
test("falls back to a placeholder when the group is unknown", () => {
|
||||
const event = buildNewRepositoriesNotificationEvent({
|
||||
kind: "own",
|
||||
repositoryNames: ["someone/repo"],
|
||||
});
|
||||
|
||||
expect(event.title).toBe("New Repositories: Unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNewRepositoryGroupEnabled", () => {
|
||||
const baseConfig: NotificationConfig = {
|
||||
enabled: true,
|
||||
provider: "ntfy",
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: false,
|
||||
notifyOnNewRepo: true,
|
||||
notifyOnNewOwnRepo: false,
|
||||
notifyOnNewOrgRepo: false,
|
||||
notifyOnNewStarredRepo: false,
|
||||
};
|
||||
|
||||
test("each group switch enables only its own kind", () => {
|
||||
expect(isNewRepositoryGroupEnabled(baseConfig, "own")).toBe(false);
|
||||
expect(isNewRepositoryGroupEnabled(baseConfig, "organization")).toBe(false);
|
||||
expect(isNewRepositoryGroupEnabled(baseConfig, "starred")).toBe(false);
|
||||
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewOwnRepo: true }, "own")).toBe(true);
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewOwnRepo: true }, "organization")).toBe(false);
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewOwnRepo: true }, "starred")).toBe(false);
|
||||
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewOrgRepo: true }, "organization")).toBe(true);
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewOrgRepo: true }, "own")).toBe(false);
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewOrgRepo: true }, "starred")).toBe(false);
|
||||
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewStarredRepo: true }, "starred")).toBe(true);
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewStarredRepo: true }, "own")).toBe(false);
|
||||
expect(isNewRepositoryGroupEnabled({ ...baseConfig, notifyOnNewStarredRepo: true }, "organization")).toBe(false);
|
||||
});
|
||||
|
||||
test("the master switch gates every group", () => {
|
||||
const allGroupsOn: NotificationConfig = {
|
||||
...baseConfig,
|
||||
notifyOnNewRepo: false,
|
||||
notifyOnNewOwnRepo: true,
|
||||
notifyOnNewOrgRepo: true,
|
||||
notifyOnNewStarredRepo: true,
|
||||
};
|
||||
|
||||
expect(isNewRepositoryGroupEnabled(allGroupsOn, "own")).toBe(false);
|
||||
expect(isNewRepositoryGroupEnabled(allGroupsOn, "organization")).toBe(false);
|
||||
expect(isNewRepositoryGroupEnabled(allGroupsOn, "starred")).toBe(false);
|
||||
|
||||
const masterOn = { ...allGroupsOn, notifyOnNewRepo: true };
|
||||
expect(isNewRepositoryGroupEnabled(masterOn, "own")).toBe(true);
|
||||
expect(isNewRepositoryGroupEnabled(masterOn, "organization")).toBe(true);
|
||||
expect(isNewRepositoryGroupEnabled(masterOn, "starred")).toBe(true);
|
||||
|
||||
// The provider-wide switch still wins over both discovery switches.
|
||||
expect(isNewRepositoryGroupEnabled({ ...masterOn, enabled: false }, "own")).toBe(false);
|
||||
});
|
||||
|
||||
test("a stored row without the group switches keeps every group on", () => {
|
||||
// Rows saved before the sub-options existed carry the master switch only; a
|
||||
// missing group switch matches the schema default and must not silence a
|
||||
// user who had already turned the discovery alerts on.
|
||||
const legacyRow = {
|
||||
enabled: true,
|
||||
provider: "ntfy",
|
||||
notifyOnSyncError: true,
|
||||
notifyOnSyncSuccess: false,
|
||||
notifyOnNewRepo: true,
|
||||
} as NotificationConfig;
|
||||
|
||||
expect(isNewRepositoryGroupEnabled(legacyRow, "own")).toBe(true);
|
||||
expect(isNewRepositoryGroupEnabled(legacyRow, "organization")).toBe(true);
|
||||
expect(isNewRepositoryGroupEnabled(legacyRow, "starred")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAllReleasesRemovedNotificationEvent", () => {
|
||||
test("names the repository and the destination that lost its releases", () => {
|
||||
const event = buildAllReleasesRemovedNotificationEvent({
|
||||
repositoryName: "demo",
|
||||
destination: "mirrors/demo",
|
||||
removedCount: 3,
|
||||
});
|
||||
|
||||
expect(event.title).toBe("All Releases Removed: demo");
|
||||
expect(event.type).toBe("sync_error");
|
||||
expect(event.message).toContain("3 releases deleted from mirrors/demo");
|
||||
expect(event.message).toContain("include/exclude tag patterns");
|
||||
});
|
||||
|
||||
test("uses the singular when a single release was removed", () => {
|
||||
const event = buildAllReleasesRemovedNotificationEvent({
|
||||
repositoryName: "demo",
|
||||
destination: "mirrors/demo",
|
||||
removedCount: 1,
|
||||
});
|
||||
|
||||
expect(event.message).toContain("1 release deleted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggerAllReleasesRemovedNotification", () => {
|
||||
test("stays silent when nothing was removed", async () => {
|
||||
await triggerAllReleasesRemovedNotification({
|
||||
userId: "user-1",
|
||||
repositoryName: "demo",
|
||||
destination: "mirrors/demo",
|
||||
removedCount: 0,
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("never throws when the user has no notification config", async () => {
|
||||
// The setup db mock resolves to no rows, so the config lookup finds none
|
||||
// and no provider is contacted.
|
||||
await triggerAllReleasesRemovedNotification({
|
||||
userId: "user-1",
|
||||
repositoryName: "demo",
|
||||
destination: "mirrors/demo",
|
||||
removedCount: 2,
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
+244
-40
@@ -116,6 +116,65 @@ export async function testNotification(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's stored notification config, or undefined when they have none.
|
||||
* Prefers the active and most-recently-updated row to avoid picking a stale
|
||||
* inactive stub when multiple rows exist (see issue #271).
|
||||
*/
|
||||
async function loadNotificationConfig(
|
||||
userId: string,
|
||||
): Promise<NotificationConfig | undefined> {
|
||||
const configResults = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
if (configResults.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return configResults[0].notificationConfig as NotificationConfig | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy of a stored config with the ACTIVE provider's credential decrypted.
|
||||
*
|
||||
* Only the active provider is decrypted so stale credentials on the inactive
|
||||
* provider cannot drop the whole notification.
|
||||
*/
|
||||
function decryptNotificationConfig(
|
||||
notificationConfig: NotificationConfig,
|
||||
): NotificationConfig {
|
||||
const decryptedConfig = { ...notificationConfig };
|
||||
if (decryptedConfig.provider === "ntfy" && decryptedConfig.ntfy?.token) {
|
||||
decryptedConfig.ntfy = {
|
||||
...decryptedConfig.ntfy,
|
||||
token: decrypt(decryptedConfig.ntfy.token),
|
||||
};
|
||||
}
|
||||
if (decryptedConfig.provider === "apprise" && decryptedConfig.apprise?.token) {
|
||||
decryptedConfig.apprise = {
|
||||
...decryptedConfig.apprise,
|
||||
token: decrypt(decryptedConfig.apprise.token),
|
||||
};
|
||||
}
|
||||
if (decryptedConfig.provider === "gotify" && decryptedConfig.gotify?.token) {
|
||||
decryptedConfig.gotify = {
|
||||
...decryptedConfig.gotify,
|
||||
token: decrypt(decryptedConfig.gotify.token),
|
||||
};
|
||||
}
|
||||
if (decryptedConfig.provider === "webhook" && decryptedConfig.webhook?.secret) {
|
||||
decryptedConfig.webhook = {
|
||||
...decryptedConfig.webhook,
|
||||
secret: decrypt(decryptedConfig.webhook.secret),
|
||||
};
|
||||
}
|
||||
return decryptedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the user's notification config from the database and triggers
|
||||
* a notification if the event type matches the user's preferences.
|
||||
@@ -144,21 +203,7 @@ export async function triggerJobNotification({
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch user's config from database — prefer active and most-recently-updated
|
||||
// to avoid picking a stale inactive stub when multiple rows exist (see issue #271).
|
||||
const configResults = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(eq(configs.userId, userId))
|
||||
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
|
||||
.limit(1);
|
||||
|
||||
if (configResults.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userConfig = configResults[0];
|
||||
const notificationConfig = userConfig.notificationConfig as NotificationConfig | undefined;
|
||||
const notificationConfig = await loadNotificationConfig(userId);
|
||||
|
||||
if (!notificationConfig?.enabled) {
|
||||
return;
|
||||
@@ -177,31 +222,7 @@ export async function triggerJobNotification({
|
||||
|
||||
// Only decrypt the active provider's token to avoid failures from stale
|
||||
// credentials on the inactive provider dropping the entire notification
|
||||
const decryptedConfig = { ...notificationConfig };
|
||||
if (decryptedConfig.provider === "ntfy" && decryptedConfig.ntfy?.token) {
|
||||
decryptedConfig.ntfy = {
|
||||
...decryptedConfig.ntfy,
|
||||
token: decrypt(decryptedConfig.ntfy.token),
|
||||
};
|
||||
}
|
||||
if (decryptedConfig.provider === "apprise" && decryptedConfig.apprise?.token) {
|
||||
decryptedConfig.apprise = {
|
||||
...decryptedConfig.apprise,
|
||||
token: decrypt(decryptedConfig.apprise.token),
|
||||
};
|
||||
}
|
||||
if (decryptedConfig.provider === "gotify" && decryptedConfig.gotify?.token) {
|
||||
decryptedConfig.gotify = {
|
||||
...decryptedConfig.gotify,
|
||||
token: decrypt(decryptedConfig.gotify.token),
|
||||
};
|
||||
}
|
||||
if (decryptedConfig.provider === "webhook" && decryptedConfig.webhook?.secret) {
|
||||
decryptedConfig.webhook = {
|
||||
...decryptedConfig.webhook,
|
||||
secret: decrypt(decryptedConfig.webhook.secret),
|
||||
};
|
||||
}
|
||||
const decryptedConfig = decryptNotificationConfig(notificationConfig);
|
||||
|
||||
// Build event
|
||||
const repoLabel = repositoryName || organizationName || "Unknown";
|
||||
@@ -225,3 +246,186 @@ export async function triggerJobNotification({
|
||||
console.error("[NotificationService] Background notification failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "New repository discovered" event for one group of imports.
|
||||
*
|
||||
* A source can publish dozens of new repositories between two scheduler
|
||||
* ticks, so they are reported as a single notification instead of one alert
|
||||
* per repository, and the list is capped so a large import cannot bury every
|
||||
* other notification.
|
||||
*/
|
||||
export const MAX_LISTED_NEW_REPOSITORIES = 10;
|
||||
|
||||
/** Label for the single group that collects every starred import. */
|
||||
export const STARRED_GROUP_LABEL = "Starred";
|
||||
|
||||
/**
|
||||
* Which group of discovered repositories a notification belongs to. Stars come
|
||||
* from any number of owners, so they form one group of their own.
|
||||
*/
|
||||
export type NewRepositoryGroupKind = "own" | "organization" | "starred";
|
||||
|
||||
/**
|
||||
* Whether one group reports. The group switches are sub-options of
|
||||
* notifyOnNewRepo, so a group only reports while the master switch is on. A
|
||||
* stored row that predates them has no group keys, which counts as on.
|
||||
*/
|
||||
export function isNewRepositoryGroupEnabled(
|
||||
config: Pick<
|
||||
NotificationConfig,
|
||||
| "enabled"
|
||||
| "notifyOnNewRepo"
|
||||
| "notifyOnNewOwnRepo"
|
||||
| "notifyOnNewOrgRepo"
|
||||
| "notifyOnNewStarredRepo"
|
||||
>,
|
||||
kind: NewRepositoryGroupKind,
|
||||
): boolean {
|
||||
if (!config.enabled || config.notifyOnNewRepo !== true) {
|
||||
return false;
|
||||
}
|
||||
if (kind === "starred") {
|
||||
return config.notifyOnNewStarredRepo !== false;
|
||||
}
|
||||
if (kind === "organization") {
|
||||
return config.notifyOnNewOrgRepo !== false;
|
||||
}
|
||||
return config.notifyOnNewOwnRepo !== false;
|
||||
}
|
||||
|
||||
export function buildNewRepositoriesNotificationEvent({
|
||||
kind,
|
||||
groupLabel,
|
||||
repositoryNames,
|
||||
details,
|
||||
}: {
|
||||
kind: NewRepositoryGroupKind;
|
||||
groupLabel?: string | null;
|
||||
repositoryNames: string[];
|
||||
details?: string;
|
||||
}): NotificationEvent {
|
||||
const label = kind === "starred" ? STARRED_GROUP_LABEL : groupLabel || "Unknown";
|
||||
const count = repositoryNames.length;
|
||||
const noun = count === 1 ? "repository" : "repositories";
|
||||
const listed = repositoryNames.slice(0, MAX_LISTED_NEW_REPOSITORIES);
|
||||
const overflow = repositoryNames.length - listed.length;
|
||||
|
||||
return {
|
||||
title: `New Repositories: ${label}`,
|
||||
message: [
|
||||
kind === "starred"
|
||||
? `${count} new starred ${noun} discovered:`
|
||||
: `${count} new ${noun} discovered for ${label}:`,
|
||||
listed.map((name) => `\n- ${name}`).join(""),
|
||||
overflow > 0 ? `\n- and ${overflow} more` : "",
|
||||
details ? `\nDetails: ${details}` : "",
|
||||
].join(""),
|
||||
type: "new_repo",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the user about repositories that were newly discovered and
|
||||
* imported. The notifyOnNewRepo switch gates the alerts and the three group
|
||||
* switches (own, organization, starred) select which groups report; each group
|
||||
* arrives as a single notification instead of one per repository.
|
||||
*
|
||||
* Called fire-and-forget from the discovery passes, so it NEVER throws.
|
||||
*/
|
||||
export async function triggerNewRepositoriesNotification({
|
||||
userId,
|
||||
kind,
|
||||
groupLabel,
|
||||
repositoryNames,
|
||||
details,
|
||||
}: {
|
||||
userId: string;
|
||||
kind: NewRepositoryGroupKind;
|
||||
groupLabel?: string | null;
|
||||
/** Full names of the repositories that were imported. */
|
||||
repositoryNames: string[];
|
||||
details?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
if (repositoryNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationConfig = await loadNotificationConfig(userId);
|
||||
|
||||
if (!notificationConfig || !isNewRepositoryGroupEnabled(notificationConfig, kind)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = buildNewRepositoriesNotificationEvent({
|
||||
kind,
|
||||
groupLabel,
|
||||
repositoryNames,
|
||||
details,
|
||||
});
|
||||
|
||||
await sendNotification(decryptNotificationConfig(notificationConfig), event);
|
||||
} catch (error) {
|
||||
console.error("[NotificationService] New repository notification failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "every mirrored release was removed" event.
|
||||
*
|
||||
* A tag or type filter change makes the retention pass delete the destination
|
||||
* releases that no longer match. When nothing survives, the sync itself
|
||||
* succeeded but the destination lost every release it had, so nothing else
|
||||
* would alert the user.
|
||||
*/
|
||||
export function buildAllReleasesRemovedNotificationEvent({
|
||||
repositoryName,
|
||||
destination,
|
||||
removedCount,
|
||||
}: {
|
||||
repositoryName: string;
|
||||
destination: string;
|
||||
removedCount: number;
|
||||
}): NotificationEvent {
|
||||
const plural = removedCount === 1 ? "" : "s";
|
||||
return {
|
||||
title: `All Releases Removed: ${repositoryName}`,
|
||||
message: [
|
||||
`${removedCount} release${plural} deleted from ${destination} by the release filters; the destination now has none.`,
|
||||
" Check the include/exclude tag patterns and the pre-release setting before the next sync.",
|
||||
].join(""),
|
||||
type: "sync_error",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Warns that the release filters emptied a destination that previously had
|
||||
* releases. The existing "Sync errors" switch gates the alert.
|
||||
*
|
||||
* Called fire-and-forget from the retention pass, so it NEVER throws.
|
||||
*/
|
||||
export async function triggerAllReleasesRemovedNotification(args: {
|
||||
userId: string;
|
||||
repositoryName: string;
|
||||
destination: string;
|
||||
removedCount: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
if (args.removedCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationConfig = await loadNotificationConfig(args.userId);
|
||||
|
||||
if (!notificationConfig?.enabled || !notificationConfig.notifyOnSyncError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = buildAllReleasesRemovedNotificationEvent(args);
|
||||
|
||||
await sendNotification(decryptNotificationConfig(notificationConfig), event);
|
||||
} catch (error) {
|
||||
console.error("[NotificationService] All-releases-removed notification failed:", error);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
selectNewRepositoriesByIdentity,
|
||||
} from '@/lib/repo-utils';
|
||||
import { resolveOrganizationSkipForks } from '@/lib/utils/mirror-overrides';
|
||||
import { triggerNewRepositoriesNotification } from '@/lib/notification-service';
|
||||
import type { SourceRecord } from '@/lib/sources';
|
||||
|
||||
/** The organization fields re-discovery reads. */
|
||||
@@ -152,6 +153,19 @@ export async function rediscoverRepositoriesForOrganization({
|
||||
.onConflictDoNothing({ target: [repositories.userId, repositories.sourceId, repositories.normalizedFullName] });
|
||||
}
|
||||
console.log(`${logPrefix} Re-discovered ${repoRecords.length} new repositories for organization ${organization.name} on source ${source.name} for user ${userId} during ${phase}`);
|
||||
|
||||
// New repository notification for an organization (settings > notifications).
|
||||
// Fire-and-forget: a failing provider must never break discovery, and
|
||||
// the service itself swallows every error.
|
||||
triggerNewRepositoriesNotification({
|
||||
userId,
|
||||
kind: 'organization',
|
||||
groupLabel: organization.name,
|
||||
repositoryNames: repoRecords.map(repo => repo.fullName ?? '').filter(Boolean),
|
||||
details: `Discovered during ${phase} on source ${source.name}.`,
|
||||
}).catch(error => {
|
||||
console.error(`${logPrefix} Failed to send the new repository notification for organization ${organization.name}:`, error);
|
||||
});
|
||||
}
|
||||
if (alreadyTracked.length > 0) {
|
||||
console.log(`${logPrefix} Organization ${organization.name}: ${alreadyTracked.length} repositories are already tracked for user ${userId} on the same host, not adding them again under source ${source.name}`);
|
||||
|
||||
@@ -17,6 +17,8 @@ import { db, organizations, repositories } from "@/lib/db";
|
||||
import type { Config, Repository } from "@/lib/db/schema";
|
||||
import { usesPushEngine } from "@/lib/destination-connection";
|
||||
import { createGitHubClient, createPublicGitHubClient } from "@/lib/github";
|
||||
import { createGitHubAppClient } from "@/lib/github-app-client";
|
||||
import { isGitHubAppCredential } from "@/lib/github-app-credentials";
|
||||
import { createMirrorJob } from "@/lib/helpers";
|
||||
import {
|
||||
decryptSourceToken,
|
||||
@@ -190,6 +192,9 @@ function createRepositoryOctokitResolver(
|
||||
if (!source || source.provider !== "github") return null;
|
||||
|
||||
const token = decryptSourceToken(source.token);
|
||||
if (isGitHubAppCredential(token)) {
|
||||
return createGitHubAppClient(token, { userId, apiBaseUrl: resolveGitHubApiBaseUrl(source.url) });
|
||||
}
|
||||
// A tokenless GitHub source still gets an anonymous public client
|
||||
// (60 req/hr) so public-repo metadata mirrors; an empty token must never
|
||||
// reach createGitHubClient, which would send `auth: ""`.
|
||||
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
decryptSourceToken,
|
||||
findSourceForRepository,
|
||||
listSources,
|
||||
sourceAccessToken,
|
||||
} from "@/lib/sources";
|
||||
import { buildSourceAuthPayload } from "@/lib/utils/mirror-source-auth";
|
||||
import { parseGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
import {
|
||||
assertRepositoryMatchesConfiguredDestination,
|
||||
createPushTargetFromConfig,
|
||||
@@ -58,18 +60,18 @@ export function parseMirroredLocation(location: string | null | undefined): { ow
|
||||
* own source row. Sources are loaded once per push by the caller and passed
|
||||
* down so batches never query per repository.
|
||||
*/
|
||||
export function resolveSourceCredentials(
|
||||
export async function resolveSourceCredentials(
|
||||
config: Partial<Config>,
|
||||
repository: Repository,
|
||||
sources: SourceRecord[]
|
||||
): GitCredentials | null {
|
||||
): Promise<GitCredentials | null> {
|
||||
const source = findSourceForRepository(repository, sources);
|
||||
if (!source) return null;
|
||||
const connection = sourceConnectionFromSource(source, { userId: config.userId });
|
||||
const payload = buildSourceAuthPayload({
|
||||
provider: connection.provider,
|
||||
token: decryptSourceToken(source.token),
|
||||
username: connection.username,
|
||||
token: await sourceAccessToken(source),
|
||||
username: source.provider === "github" && parseGitHubAppCredentials(connection.token) ? "x-access-token" : connection.username,
|
||||
repositoryOwner: repository.owner,
|
||||
});
|
||||
if (!("auth_username" in payload) || !payload.auth_password) return null;
|
||||
@@ -148,7 +150,7 @@ export async function pushMirrorRepository({
|
||||
const outcome = await runPushMirror({
|
||||
clonePath: clonePathForRepository(repository),
|
||||
sourceCloneUrl: repository.cloneUrl,
|
||||
sourceCredentials: resolveSourceCredentials(config, repository, sources),
|
||||
sourceCredentials: await resolveSourceCredentials(config, repository, sources),
|
||||
target: target ?? createPushTargetFromConfig(config),
|
||||
owner,
|
||||
name,
|
||||
|
||||
+8
-1
@@ -9,6 +9,8 @@ import { db, repositories, organizations, mirrorJobs, configs } from './db';
|
||||
import { eq, and, lt, inArray, sql } from 'drizzle-orm';
|
||||
import { mirrorRepositoryToDestination, syncRepositoryOnDestination } from './mirror-dispatch';
|
||||
import { createGitHubClient, createPublicGitHubClient } from './github';
|
||||
import { createGitHubAppClient } from './github-app-client';
|
||||
import { isGitHubAppCredential } from './github-app-credentials';
|
||||
import type { Octokit } from '@octokit/rest';
|
||||
import {
|
||||
decryptSourceToken,
|
||||
@@ -305,7 +307,12 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
|
||||
// (60 req/hr), the same resolution the job routes use; an empty
|
||||
// token must never reach createGitHubClient, which would send
|
||||
// `auth: ""` and 401 on every call.
|
||||
octokit = decryptedToken
|
||||
octokit = isGitHubAppCredential(decryptedToken)
|
||||
? createGitHubAppClient(decryptedToken, {
|
||||
userId: config.userId || undefined,
|
||||
apiBaseUrl: resolveGitHubApiBaseUrl(repoSource.url),
|
||||
})
|
||||
: decryptedToken
|
||||
? createGitHubClient(
|
||||
decryptedToken,
|
||||
config.userId || undefined,
|
||||
|
||||
@@ -213,10 +213,30 @@ const publicOnlySource = {
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
|
||||
const mockListRepositories = mock(async () => {
|
||||
throw new Error("personal discovery must not run for a public-only source");
|
||||
});
|
||||
const mockListStarredRepositories = mock(async () => []);
|
||||
// A token source: personal and starred discovery run for it. Only the tests
|
||||
// that exercise those paths add it to sourceRows.
|
||||
const personalSource = {
|
||||
id: "source-personal",
|
||||
userId: "user-1",
|
||||
name: "GitHub (token)",
|
||||
provider: "github",
|
||||
url: "https://github.com",
|
||||
username: "majmcccloud",
|
||||
token: "encrypted-token",
|
||||
enabled: true,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
|
||||
// What listSources returns; reset to the public-only source before each test.
|
||||
let sourceRows: any[] = [publicOnlySource];
|
||||
|
||||
// Personal and starred listings, empty by default so the public-only
|
||||
// scenarios never see them (those tests assert listRepositories is not called).
|
||||
let personalRepoListings: any[] = [];
|
||||
let starredRepoListings: any[] = [];
|
||||
const mockListRepositories = mock(async () => personalRepoListings);
|
||||
const mockListStarredRepositories = mock(async () => starredRepoListings);
|
||||
const orgRepoListings = new Map<string, any[]>();
|
||||
const mockListOrganizationRepositories = mock(async (name: string) =>
|
||||
orgRepoListings.get(name) ?? []
|
||||
@@ -228,6 +248,9 @@ const mockCreateSourceProviderFromSource = mock(() => ({
|
||||
listStarredRepositories: mockListStarredRepositories,
|
||||
listOrganizationRepositories: mockListOrganizationRepositories,
|
||||
}));
|
||||
// The "New repository discovered" notification is fire-and-forget; the child
|
||||
// suite only asserts that discovery hands the imported repositories over.
|
||||
const mockTriggerNewRepositoriesNotification = mock(async () => {});
|
||||
|
||||
if (isChild) {
|
||||
mock.module("@/lib/db", () => {
|
||||
@@ -295,7 +318,7 @@ if (isChild) {
|
||||
|
||||
mock.module("@/lib/sources", () => ({
|
||||
listSources: mock(async (userId: string) =>
|
||||
publicOnlySource.userId === userId ? [publicOnlySource] : []
|
||||
sourceRows.filter((source) => source.userId === userId)
|
||||
),
|
||||
ensureSourcesFromConfig: mock(async () => {}),
|
||||
decryptSourceToken: (token: string | null | undefined) => token ?? "",
|
||||
@@ -320,6 +343,13 @@ if (isChild) {
|
||||
mirrorRepositoryToDestination: mockMirrorRepositoryToDestination,
|
||||
syncRepositoryOnDestination: mockSyncRepositoryOnDestination,
|
||||
}));
|
||||
|
||||
mock.module("@/lib/notification-service", () => ({
|
||||
triggerNewRepositoriesNotification: mockTriggerNewRepositoriesNotification,
|
||||
triggerJobNotification: mock(async () => {}),
|
||||
sendNotification: mock(async () => {}),
|
||||
testNotification: mock(async () => ({ success: true })),
|
||||
}));
|
||||
}
|
||||
|
||||
const { schedulerLoop } = isChild
|
||||
@@ -418,10 +448,14 @@ describe.skipIf(!isChild)("Scheduler public-only sources (WP4)", () => {
|
||||
repoInsertAttempts = 0;
|
||||
clearAllRateLimits();
|
||||
orgRepoListings.clear();
|
||||
sourceRows = [publicOnlySource];
|
||||
personalRepoListings = [];
|
||||
starredRepoListings = [];
|
||||
mockListRepositories.mockClear();
|
||||
mockListStarredRepositories.mockClear();
|
||||
mockListOrganizationRepositories.mockClear();
|
||||
mockCreateSourceProviderFromSource.mockClear();
|
||||
mockTriggerNewRepositoriesNotification.mockClear();
|
||||
});
|
||||
|
||||
test("a destination-only config runs the scheduled sync instead of skipping it", async () => {
|
||||
@@ -471,6 +505,16 @@ describe.skipIf(!isChild)("Scheduler public-only sources (WP4)", () => {
|
||||
expect(orgRepoNames).toEqual(["existing", "newly-published"]);
|
||||
expect(orgUpdates.some((data) => data.repositoryCount === 3)).toBe(true);
|
||||
|
||||
// The "New repository discovered" notification covers only the repositories
|
||||
// this pass actually imported, grouped per organization.
|
||||
expect(mockTriggerNewRepositoriesNotification).toHaveBeenCalledTimes(1);
|
||||
expect(mockTriggerNewRepositoriesNotification.mock.calls[0][0]).toMatchObject({
|
||||
userId: "user-1",
|
||||
kind: "organization",
|
||||
groupLabel: "pinned-org",
|
||||
repositoryNames: ["pinned-org/newly-published"],
|
||||
});
|
||||
|
||||
const attemptsAfterFirstRun = repoInsertAttempts;
|
||||
expect(attemptsAfterFirstRun).toBeGreaterThan(0);
|
||||
|
||||
@@ -510,6 +554,60 @@ describe.skipIf(!isChild)("Scheduler public-only sources (WP4)", () => {
|
||||
expect(repoRows.some((row) => row.normalizedFullName === "pinned-org/fresh")).toBe(true);
|
||||
expect(consoleLines.some((line) => line.includes("already tracked") && line.includes("pinned-org"))).toBe(true);
|
||||
});
|
||||
|
||||
test("auto-imported repositories notify once per owner and once for all stars", async () => {
|
||||
// The personal auto-import path groups by owner: the user's own
|
||||
// repositories carry no organization and fall back to the owner name.
|
||||
// Starred repositories are the exception - their owners are arbitrary, so
|
||||
// every star shares a single notification instead of one per owner.
|
||||
sourceRows = [personalSource];
|
||||
orgRows = [];
|
||||
configRows = [
|
||||
{ ...makeSchedulerConfig(), githubConfig: { token: "encrypted-token", includeStarred: true } },
|
||||
];
|
||||
personalRepoListings = [
|
||||
makeGitRepo("own-alpha", { owner: "majmcccloud", organization: null, fullName: "majmcccloud/own-alpha" }),
|
||||
makeGitRepo("own-beta", { owner: "majmcccloud", organization: null, fullName: "majmcccloud/own-beta" }),
|
||||
];
|
||||
starredRepoListings = [
|
||||
makeGitRepo("starred-gamma", {
|
||||
owner: "other-org",
|
||||
organization: "other-org",
|
||||
fullName: "other-org/starred-gamma",
|
||||
isStarred: true,
|
||||
}),
|
||||
makeGitRepo("starred-delta", {
|
||||
owner: "another-user",
|
||||
organization: null,
|
||||
fullName: "another-user/starred-delta",
|
||||
isStarred: true,
|
||||
}),
|
||||
];
|
||||
|
||||
await schedulerLoop();
|
||||
|
||||
expect(mockListRepositories).toHaveBeenCalledTimes(1);
|
||||
expect(mockListStarredRepositories).toHaveBeenCalledTimes(1);
|
||||
expect(repoRows.some((row) => row.normalizedFullName === "majmcccloud/own-beta")).toBe(true);
|
||||
|
||||
const calls = mockTriggerNewRepositoriesNotification.mock.calls.map(([args]) => args);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
kind: "own",
|
||||
groupLabel: "majmcccloud",
|
||||
repositoryNames: ["majmcccloud/own-alpha", "majmcccloud/own-beta"],
|
||||
})
|
||||
);
|
||||
expect(calls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
kind: "starred",
|
||||
repositoryNames: ["other-org/starred-gamma", "another-user/starred-delta"],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate limit gate (issue #437).
|
||||
@@ -568,6 +666,9 @@ describe.skipIf(!isChild)("Scheduler rate limit gate (#437)", () => {
|
||||
configUpdates = [];
|
||||
repoInsertAttempts = 0;
|
||||
orgRepoListings.clear();
|
||||
sourceRows = [publicOnlySource];
|
||||
personalRepoListings = [];
|
||||
starredRepoListings = [];
|
||||
mockListOrganizationRepositories.mockClear();
|
||||
mockMirrorRepositoryToDestination.mockClear();
|
||||
mockSyncRepositoryOnDestination.mockClear();
|
||||
|
||||
@@ -25,6 +25,7 @@ import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility';
|
||||
import { loadOrganizationForkPolicies, orgForkSkipDecision } from '@/lib/utils/mirror-overrides';
|
||||
import { createMirrorJob } from '@/lib/helpers';
|
||||
import { rediscoverRepositoriesForOrganization } from '@/lib/organization-discovery';
|
||||
import { triggerNewRepositoriesNotification } from '@/lib/notification-service';
|
||||
import { getNextScheduledRun, isCronExpression, normalizeTimezone } from '@/lib/utils/schedule-utils';
|
||||
import { resetStuckMirrorStatuses } from '@/lib/stuck-status-recovery';
|
||||
import { isRateLimitError, rateLimitGateKey, rateLimitedUntil } from '@/lib/rate-limit-gate';
|
||||
@@ -271,6 +272,49 @@ async function importRepositoriesFromSources(
|
||||
}
|
||||
console.log(`[Scheduler] Successfully imported ${newRepos.length} new repositories for user ${userId} from source ${source.name}`);
|
||||
|
||||
// New repository notifications (settings > notifications). The
|
||||
// notifyOnNewRepo master switch gates them and the own/organization/
|
||||
// starred sub-switches select the groups; every group produces one alert
|
||||
// instead of one per repository.
|
||||
// Starred repositories always share a single group because their owners
|
||||
// are arbitrary. Fire-and-forget: the service swallows every error, and
|
||||
// a failing provider must not break the run.
|
||||
const newReposByGroup = new Map<string, { kind: 'own' | 'organization'; label: string; names: string[] }>();
|
||||
const starredRepoNames: string[] = [];
|
||||
for (const repo of newRepos) {
|
||||
if (repo.isStarred) {
|
||||
starredRepoNames.push(repo.fullName);
|
||||
continue;
|
||||
}
|
||||
const kind: 'own' | 'organization' = repo.organization ? 'organization' : 'own';
|
||||
const label = repo.organization || repo.owner || 'Unknown';
|
||||
const groupKey = `${kind}:${label}`;
|
||||
const group = newReposByGroup.get(groupKey) ?? { kind, label, names: [] };
|
||||
group.names.push(repo.fullName);
|
||||
newReposByGroup.set(groupKey, group);
|
||||
}
|
||||
for (const group of newReposByGroup.values()) {
|
||||
triggerNewRepositoriesNotification({
|
||||
userId,
|
||||
kind: group.kind,
|
||||
groupLabel: group.label,
|
||||
repositoryNames: group.names,
|
||||
details: `Auto-imported during ${phase} from source ${source.name}.`,
|
||||
}).catch(notificationError => {
|
||||
console.error(`[Scheduler] Failed to send the new repository notification for ${group.label}:`, notificationError);
|
||||
});
|
||||
}
|
||||
if (starredRepoNames.length > 0) {
|
||||
triggerNewRepositoriesNotification({
|
||||
userId,
|
||||
kind: 'starred',
|
||||
repositoryNames: starredRepoNames,
|
||||
details: `Auto-imported during ${phase} from source ${source.name}.`,
|
||||
}).catch(notificationError => {
|
||||
console.error('[Scheduler] Failed to send the starred repository notification:', notificationError);
|
||||
});
|
||||
}
|
||||
|
||||
for (const repo of newRepos) {
|
||||
const sourceLabel = repo.isStarred ? 'starred' : 'owned';
|
||||
await createMirrorJob({
|
||||
@@ -370,6 +414,8 @@ async function createOctokitResolverForSources(
|
||||
): Promise<(repo: { sourceId?: string | null }) => Promise<Octokit | null>> {
|
||||
const { decryptSourceToken, resolveGitHubApiBaseUrl } = await import('@/lib/sources');
|
||||
const { createGitHubClient, createPublicGitHubClient } = await import('@/lib/github');
|
||||
const { createGitHubAppClient } = await import('@/lib/github-app-client');
|
||||
const { isGitHubAppCredential } = await import('@/lib/github-app-credentials');
|
||||
|
||||
const sourcesById = new Map(sources.map(source => [source.id, source]));
|
||||
const clientsBySourceId = new Map<string, Octokit | null>();
|
||||
@@ -387,7 +433,9 @@ async function createOctokitResolverForSources(
|
||||
const sourceToken = decryptSourceToken(source.token);
|
||||
clientsBySourceId.set(
|
||||
source.id,
|
||||
sourceToken
|
||||
isGitHubAppCredential(sourceToken)
|
||||
? createGitHubAppClient(sourceToken, { userId, apiBaseUrl: resolveGitHubApiBaseUrl(source.url) })
|
||||
: sourceToken
|
||||
? createGitHubClient(
|
||||
sourceToken,
|
||||
userId,
|
||||
@@ -406,7 +454,13 @@ async function createOctokitResolverForSources(
|
||||
if (configClient === undefined) {
|
||||
const { resolveSourceProviderKind } = await import('@/lib/source-providers');
|
||||
if (resolveSourceProviderKind(config) === 'github') {
|
||||
configClient = createGitHubClient(getDecryptedGitHubToken(config), userId, config.githubConfig?.owner);
|
||||
const configToken = getDecryptedGitHubToken(config);
|
||||
configClient = isGitHubAppCredential(configToken)
|
||||
? createGitHubAppClient(configToken, {
|
||||
userId,
|
||||
apiBaseUrl: resolveGitHubApiBaseUrl(config.githubConfig?.url),
|
||||
})
|
||||
: createGitHubClient(configToken, userId, config.githubConfig?.owner);
|
||||
} else {
|
||||
configClient = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { shutdownExitCode } from "./shutdown-manager";
|
||||
|
||||
describe("shutdownExitCode (#465)", () => {
|
||||
test("a crash-driven shutdown exits non-zero so Restart=on-failure restarts the service", () => {
|
||||
expect(shutdownExitCode("UNCAUGHT_EXCEPTION")).toBe(1);
|
||||
expect(shutdownExitCode("UNHANDLED_REJECTION")).toBe(1);
|
||||
});
|
||||
|
||||
test("a signal-driven stop is a clean exit", () => {
|
||||
expect(shutdownExitCode("SIGTERM")).toBe(0);
|
||||
expect(shutdownExitCode("SIGINT")).toBe(0);
|
||||
expect(shutdownExitCode("SIGHUP")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -147,6 +147,11 @@ async function executeShutdownCallbacks(): Promise<void> {
|
||||
/**
|
||||
* Perform graceful shutdown of the application
|
||||
*/
|
||||
/** Exit status for a completed shutdown: 1 after a crash, 0 after a signal. */
|
||||
export function shutdownExitCode(signal: string): number {
|
||||
return signal === 'UNCAUGHT_EXCEPTION' || signal === 'UNHANDLED_REJECTION' ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function gracefulShutdown(signal: string = 'UNKNOWN'): Promise<void> {
|
||||
if (shutdownInProgress) {
|
||||
console.log('⚠️ Shutdown already in progress, ignoring additional signal');
|
||||
@@ -187,8 +192,10 @@ export async function gracefulShutdown(signal: string = 'UNKNOWN'): Promise<void
|
||||
shutdownTimeout = null;
|
||||
}
|
||||
|
||||
// Exit with success code
|
||||
process.exit(0);
|
||||
// A shutdown forced by a crash exits non-zero so a supervisor with
|
||||
// Restart=on-failure (the Proxmox script's unit) starts the service
|
||||
// again; a signal-driven stop is a clean exit (#465).
|
||||
process.exit(shutdownExitCode(signal));
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error during graceful shutdown:', error);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { GitHubSourceProvider, type GithubRestRepository } from "./github-source";
|
||||
import { encodeGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
|
||||
type ListForOrgParams = { org: string; type: "public" | "private" | "member"; per_page: number };
|
||||
type OrgReposPage = (GithubRestRepository & { id: number })[];
|
||||
@@ -35,6 +37,70 @@ const connection = {
|
||||
userId: "u1",
|
||||
};
|
||||
|
||||
test("GitHub App lists installed repositories without using user or starred APIs", async () => {
|
||||
const provider = new GitHubSourceProvider({ ...connection, token: encodeGitHubAppCredentials({
|
||||
appId: "123", installationId: "456", privateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
|
||||
}) });
|
||||
const request = mock(async (route: string) => {
|
||||
expect(route).toBe("GET /installation/repositories");
|
||||
return { data: { total_count: 1, repositories: [repo(1, "private-tool")] } };
|
||||
});
|
||||
Object.defineProperty(provider, "octokit", { value: { request } });
|
||||
const repos = await provider.listRepositories({});
|
||||
expect(repos.map((item) => item.fullName)).toEqual(["acme/private-tool"]);
|
||||
expect(await provider.listStarredRepositories({})).toEqual([]);
|
||||
});
|
||||
|
||||
test("GitHub App listing applies the same account filters as the token listing", async () => {
|
||||
const provider = new GitHubSourceProvider({ ...connection, token: encodeGitHubAppCredentials({
|
||||
appId: "123", installationId: "456", privateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
|
||||
}) });
|
||||
const personal = { ...repo(4, "dotfiles", "me"), owner: { login: "me", type: "User" } };
|
||||
const repositories = [
|
||||
repo(1, "tool"),
|
||||
{ ...repo(2, "forked"), fork: true },
|
||||
repo(3, "other-tool", "other-org"),
|
||||
personal,
|
||||
];
|
||||
const request = mock(async () => ({ data: { total_count: repositories.length, repositories } }));
|
||||
Object.defineProperty(provider, "octokit", { value: { request } });
|
||||
const config = {
|
||||
githubConfig: { skipForks: true, skipPersonalRepos: true, includeOrganizations: ["acme"] },
|
||||
} as any;
|
||||
|
||||
expect((await provider.listRepositories(config)).map((item) => item.fullName)).toEqual(["acme/tool"]);
|
||||
// Cleanup lists past the allowlist, like the token path.
|
||||
expect(
|
||||
(await provider.listRepositories(config, { includeAllOrgsOverride: true })).map((item) => item.fullName)
|
||||
).toEqual(["acme/tool", "other-org/other-tool"]);
|
||||
// Every installation org is discovered; the allowlist only narrows imports.
|
||||
const { organizations } = await provider.listOrganizations(config);
|
||||
expect(organizations.map((org) => org.name).sort()).toEqual(["acme", "other-org"]);
|
||||
});
|
||||
|
||||
test("GitHub App Octokit authenticates requests with an installation token", async () => {
|
||||
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048, privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" } });
|
||||
const provider = new GitHubSourceProvider({ ...connection, token: encodeGitHubAppCredentials({ appId: "789", installationId: "987", privateKey }) });
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: string[] = [];
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const url = String(input);
|
||||
requests.push(url);
|
||||
if (url.endsWith("/access_tokens")) {
|
||||
return new Response(JSON.stringify({ token: "ghs_live", expires_at: new Date(Date.now() + 3600_000).toISOString() }), { status: 201, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
expect(new Headers(init?.headers).get("authorization")).toBe("token ghs_live");
|
||||
return new Response(JSON.stringify({ total_count: 1, repositories: [repo(1, "private-tool")] }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
expect(await provider.testConnection()).toEqual({ login: "me" });
|
||||
expect((await provider.listRepositories({})).map((item) => item.fullName)).toEqual(["acme/private-tool"]);
|
||||
expect(requests).toHaveLength(3);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
function installOctokitDouble(
|
||||
provider: GitHubSourceProvider,
|
||||
paginate: (fn: unknown, params: ListForOrgParams) => Promise<OrgReposPage>
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
*/
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import type { Config } from "@/lib/db/schema";
|
||||
import { parseGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
import { createGitHubAppClient } from "@/lib/github-app-client";
|
||||
import {
|
||||
createGitHubClient,
|
||||
createPublicGitHubClient,
|
||||
filterGithubRepositories,
|
||||
getGithubOrganizations,
|
||||
getGithubRepositories,
|
||||
getGithubStarredRepositories,
|
||||
@@ -117,9 +120,12 @@ export class GitHubSourceProvider implements SourceProvider {
|
||||
// A GitHub Enterprise source URL drives the API base; github.com (the
|
||||
// default) keeps the GH_API_URL env default.
|
||||
const apiBaseUrl = resolveGitHubApiBaseUrl(connection.url);
|
||||
const app = parseGitHubAppCredentials(connection.token);
|
||||
// Tokenless: the throttled public client, never an empty token — anonymous
|
||||
// GitHub runs at 60 req/hr, so the onRateLimit backoff must stay attached.
|
||||
this.octokit = connection.token.trim()
|
||||
this.octokit = app
|
||||
? createGitHubAppClient(connection.token, { userId: connection.userId, apiBaseUrl })
|
||||
: connection.token.trim()
|
||||
? createGitHubClient(
|
||||
connection.token,
|
||||
connection.userId,
|
||||
@@ -141,6 +147,24 @@ export class GitHubSourceProvider implements SourceProvider {
|
||||
config: Partial<Config>,
|
||||
options: ListRepositoriesOptions = {}
|
||||
): Promise<GitRepo[]> {
|
||||
if (parseGitHubAppCredentials(this.connection.token)) {
|
||||
const repos: GithubRestRepository[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const { data } = await this.octokit.request("GET /installation/repositories", { per_page: 100, page });
|
||||
repos.push(...data.repositories as GithubRestRepository[]);
|
||||
if (repos.length >= data.total_count || data.repositories.length === 0) break;
|
||||
page++;
|
||||
}
|
||||
// Same account filters as the token listing. The installation account
|
||||
// (the source's username) is the one whose personal repos are skipped.
|
||||
const filtered = filterGithubRepositories(repos, config, {
|
||||
includeAllOrgsOverride: options.includeAllOrgsOverride,
|
||||
orgForkOverrides: options.orgForkOverrides,
|
||||
authenticatedUserLogin: this.connection.username || config.githubConfig?.owner || "",
|
||||
});
|
||||
return this.stamp(filtered.map((repo) => mapGithubRestRepository(repo, this.connection)));
|
||||
}
|
||||
const repos = await getGithubRepositories({
|
||||
octokit: this.octokit,
|
||||
config,
|
||||
@@ -152,6 +176,7 @@ export class GitHubSourceProvider implements SourceProvider {
|
||||
}
|
||||
|
||||
async listStarredRepositories(config: Partial<Config>): Promise<GitRepo[]> {
|
||||
if (parseGitHubAppCredentials(this.connection.token)) return [];
|
||||
const repos = await getGithubStarredRepositories({ octokit: this.octokit, config });
|
||||
return this.stamp(repos);
|
||||
}
|
||||
@@ -160,6 +185,16 @@ export class GitHubSourceProvider implements SourceProvider {
|
||||
config: Partial<Config>,
|
||||
skipOrgNames?: Set<string>
|
||||
): Promise<SourceOrganizationResult> {
|
||||
if (parseGitHubAppCredentials(this.connection.token)) {
|
||||
// Every installation org is listed, like the token path's org listing;
|
||||
// the allowlist only narrows which repositories get imported.
|
||||
return this.listRepositories(config, { includeAllOrgsOverride: true }).then((repos) => ({
|
||||
organizations: [...new Set(repos.map((repo) => repo.organization).filter((name): name is string => !!name))]
|
||||
.filter((name) => !skipOrgNames?.has(name))
|
||||
.map((name) => ({ name, avatarUrl: "", membershipRole: "member" as const, isIncluded: false, status: "imported" as const, repositoryCount: repos.filter((repo) => repo.organization === name).length, createdAt: new Date(), updatedAt: new Date() })),
|
||||
failedOrgs: [],
|
||||
}));
|
||||
}
|
||||
return getGithubOrganizations({ octokit: this.octokit, config, skipOrgNames });
|
||||
}
|
||||
|
||||
@@ -232,6 +267,7 @@ export class GitHubSourceProvider implements SourceProvider {
|
||||
}
|
||||
|
||||
async isRepositoryStarred(owner: string, name: string): Promise<boolean> {
|
||||
if (parseGitHubAppCredentials(this.connection.token)) return false;
|
||||
try {
|
||||
await this.octokit.rest.activity.checkRepoIsStarredByAuthenticatedUser({
|
||||
owner,
|
||||
@@ -245,6 +281,10 @@ export class GitHubSourceProvider implements SourceProvider {
|
||||
}
|
||||
|
||||
async testConnection(): Promise<SourceAccount> {
|
||||
if (parseGitHubAppCredentials(this.connection.token)) {
|
||||
await this.octokit.request("GET /installation/repositories", { per_page: 1 });
|
||||
return { login: this.connection.username };
|
||||
}
|
||||
const { data } = await this.octokit.users.getAuthenticated();
|
||||
return { login: data.login, name: data.name, avatarUrl: data.avatar_url };
|
||||
}
|
||||
|
||||
+20
-1
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import type { SourceProviderKind } from "@/lib/source-providers/kinds";
|
||||
import { isGitHubAppCredential, parseGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
|
||||
/** One source as the API returns it: the stored row plus its lock state. */
|
||||
export interface SourceApiItem {
|
||||
@@ -18,6 +19,9 @@ export interface SourceApiItem {
|
||||
url: string;
|
||||
username: string;
|
||||
token: string;
|
||||
authType: "pat" | "app";
|
||||
appId?: string;
|
||||
installationId?: string;
|
||||
enabled: boolean;
|
||||
repositoryCount: number;
|
||||
locked: boolean;
|
||||
@@ -39,13 +43,28 @@ export async function toSourceApiItem(
|
||||
repositoryCount: number
|
||||
): Promise<SourceApiItem> {
|
||||
const { decryptSourceToken } = await import("@/lib/sources");
|
||||
const token = decryptSourceToken(source.token);
|
||||
// A stored App credential holds the private key: never return it, whatever
|
||||
// the provider, and don't fail the whole listing over a damaged one.
|
||||
const appCredential = isGitHubAppCredential(token);
|
||||
let app: ReturnType<typeof parseGitHubAppCredentials> = null;
|
||||
if (appCredential && source.provider === "github") {
|
||||
try {
|
||||
app = parseGitHubAppCredentials(token);
|
||||
} catch {
|
||||
app = null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
provider: source.provider,
|
||||
url: source.url ?? "",
|
||||
username: source.username,
|
||||
token: decryptSourceToken(source.token),
|
||||
token: appCredential ? "" : token,
|
||||
authType: app ? "app" : "pat",
|
||||
appId: app?.appId,
|
||||
installationId: app?.installationId,
|
||||
enabled: source.enabled,
|
||||
repositoryCount,
|
||||
locked: repositoryCount > 0,
|
||||
|
||||
@@ -17,6 +17,11 @@ import { and, eq, inArray, ne, sql } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { sources } from "@/lib/db/schema";
|
||||
import { decrypt, encrypt } from "@/lib/utils/encryption";
|
||||
import {
|
||||
isGitHubAppCredential,
|
||||
parseGitHubAppCredentials,
|
||||
resolveGitHubAccessToken,
|
||||
} from "@/lib/github-app-credentials";
|
||||
import {
|
||||
SOURCE_PROVIDER_KINDS,
|
||||
SOURCE_PROVIDER_LABELS,
|
||||
@@ -466,6 +471,40 @@ export function decryptSourceToken(token: string | null | undefined): string {
|
||||
return token ? decrypt(token) : "";
|
||||
}
|
||||
|
||||
/** True when a source authenticates as a GitHub App installation. */
|
||||
export function isGitHubAppSource(source: Pick<SourceRecord, "provider" | "token">): boolean {
|
||||
const token = decryptSourceToken(source.token);
|
||||
if (!isGitHubAppCredential(token)) return false;
|
||||
// An App credential on a non-GitHub row (a provider switch that kept the
|
||||
// stored value) must never be sent anywhere as a token.
|
||||
if (source.provider !== "github") {
|
||||
throw new Error("This source holds GitHub App credentials but is not a GitHub source. Enter a token for it.");
|
||||
}
|
||||
return parseGitHubAppCredentials(token) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The token to authenticate to a source with: the stored PAT, or a fresh
|
||||
* installation token for a GitHub App source.
|
||||
*/
|
||||
export async function sourceAccessToken(
|
||||
source: SourceRecord,
|
||||
options: { minValidityMs?: number } = {}
|
||||
): Promise<string> {
|
||||
const token = decryptSourceToken(source.token);
|
||||
if (source.provider !== "github") {
|
||||
if (isGitHubAppCredential(token)) {
|
||||
throw new Error("This source holds GitHub App credentials but is not a GitHub source. Enter a token for it.");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
return resolveGitHubAccessToken(
|
||||
token,
|
||||
resolveGitHubApiBaseUrl(source.url) || process.env.GH_API_URL || process.env.GITHUB_API_URL || "https://api.github.com",
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The REST API base for a GitHub-kind source. github.com (or empty) returns
|
||||
* undefined so the GH_API_URL environment default applies (GHES/GHEC setups
|
||||
|
||||
@@ -17,7 +17,7 @@ import { normalizeDestinationProviderKind } from "@/lib/destination-kinds";
|
||||
import { z } from "zod";
|
||||
import { githubConfigSchema, giteaConfigSchema, scheduleConfigSchema, cleanupConfigSchema } from "@/lib/db/schema";
|
||||
import { parseInterval } from "@/lib/utils/duration-parser";
|
||||
import { normalizeReleaseAssetLimit } from "@/lib/utils/mirror-overrides";
|
||||
import { normalizeReleaseAssetLimit, normalizeReleaseTagPatterns, normalizeReleaseType, DEFAULT_RELEASE_TYPE } from "@/lib/utils/mirror-overrides";
|
||||
import { buildClockCronExpression, normalizeTimezone, parseClockCronExpression } from "@/lib/utils/schedule-utils";
|
||||
|
||||
// Use the actual database schema types
|
||||
@@ -171,6 +171,13 @@ export function mapUiToDbConfig(
|
||||
// null is a real value here ("assets for every mirrored release"), so
|
||||
// it is stored as such rather than dropped.
|
||||
releaseAssetLimit: normalizeReleaseAssetLimit(mirrorOptions.releaseAssetLimit) ?? null,
|
||||
// An unusable value falls back to "all" rather than storing a filter that
|
||||
// would hide releases from the user.
|
||||
releaseType: normalizeReleaseType(mirrorOptions.releaseType) ?? DEFAULT_RELEASE_TYPE,
|
||||
// Junk entries are dropped one by one rather than rejected wholesale, so a
|
||||
// stray comma in the input cannot discard the rest of the list.
|
||||
releaseIncludeTags: normalizeReleaseTagPatterns(mirrorOptions.releaseIncludeTags) ?? [],
|
||||
releaseExcludeTags: normalizeReleaseTagPatterns(mirrorOptions.releaseExcludeTags) ?? [],
|
||||
mirrorMetadata: mirrorOptions.mirrorMetadata,
|
||||
mirrorIssues: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.issues,
|
||||
mirrorPullRequests: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests,
|
||||
@@ -244,6 +251,9 @@ export function mapDbToUiConfig(dbConfig: any): {
|
||||
mirrorReleases: dbConfig.giteaConfig?.mirrorReleases || false,
|
||||
releaseLimit: dbConfig.giteaConfig?.releaseLimit || 10,
|
||||
releaseAssetLimit: normalizeReleaseAssetLimit(dbConfig.giteaConfig?.releaseAssetLimit) ?? null,
|
||||
releaseType: normalizeReleaseType(dbConfig.giteaConfig?.releaseType) ?? DEFAULT_RELEASE_TYPE,
|
||||
releaseIncludeTags: normalizeReleaseTagPatterns(dbConfig.giteaConfig?.releaseIncludeTags) ?? [],
|
||||
releaseExcludeTags: normalizeReleaseTagPatterns(dbConfig.giteaConfig?.releaseExcludeTags) ?? [],
|
||||
mirrorLFS: dbConfig.giteaConfig?.lfs || false,
|
||||
mirrorMetadata: dbConfig.giteaConfig?.mirrorMetadata || false,
|
||||
metadataComponents: {
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
compileReleaseTagPattern,
|
||||
compileReleaseTagPatterns,
|
||||
DEFAULT_RELEASE_LIMIT,
|
||||
DEFAULT_RELEASE_TYPE,
|
||||
getMirrorOverrideGating,
|
||||
hasMirrorOverrides,
|
||||
listOverriddenKeys,
|
||||
MAX_RELEASE_TAG_PATTERNS,
|
||||
MAX_RELEASE_TAG_PATTERN_LENGTH,
|
||||
MIRROR_GATING_REASONS,
|
||||
MIRROR_OVERRIDE_CHOICE_KEYS,
|
||||
MIRROR_OVERRIDE_KEYS,
|
||||
MIRROR_OVERRIDE_LABELS,
|
||||
MIRROR_OVERRIDE_LIMIT_KEYS,
|
||||
MIRROR_OVERRIDE_PATTERN_KEYS,
|
||||
mirrorOptionsToFlags,
|
||||
normalizeMirrorOverrides,
|
||||
normalizeReleaseAssetLimit,
|
||||
normalizeReleaseLimit,
|
||||
normalizeReleaseTagPatterns,
|
||||
normalizeReleaseType,
|
||||
orgForkSkipDecision,
|
||||
parseMirrorOverrides,
|
||||
releaseGetsAssets,
|
||||
releaseMatchesTagFilters,
|
||||
releaseMatchesType,
|
||||
releaseScanLimit,
|
||||
releaseTagMatchesAny,
|
||||
RELEASE_SCAN_WINDOW,
|
||||
resolveMirrorOptions,
|
||||
resolveOrganizationSkipForks,
|
||||
splitReleaseTagEntries,
|
||||
STARRED_CLAMPED_KEYS,
|
||||
UI_MIRROR_OVERRIDE_KEYS,
|
||||
} from "./mirror-overrides";
|
||||
@@ -560,7 +575,7 @@ describe("mirrorOptionsToFlags - client/server shape contract", () => {
|
||||
mapDbToUiConfig(dbConfig).mirrorOptions
|
||||
);
|
||||
|
||||
const stored = dbConfig.giteaConfig as Record<string, boolean>;
|
||||
const stored = dbConfig.giteaConfig as unknown as Record<string, boolean>;
|
||||
expect(flags.lfs).toBe(stored.lfs);
|
||||
expect(flags.wiki).toBe(stored.wiki);
|
||||
expect(flags.mirrorIssues).toBe(stored.mirrorIssues);
|
||||
@@ -1223,3 +1238,535 @@ describe("organization skip forks policy", () => {
|
||||
expect((resolved as any).skipForks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("release type filter", () => {
|
||||
describe("normalizeReleaseType", () => {
|
||||
test("accepts the two known values", () => {
|
||||
expect(normalizeReleaseType("all")).toBe("all");
|
||||
expect(normalizeReleaseType("stable")).toBe("stable");
|
||||
});
|
||||
|
||||
test("folds case and padding so a hand-written env var works", () => {
|
||||
expect(normalizeReleaseType("Stable")).toBe("stable");
|
||||
expect(normalizeReleaseType(" STABLE ")).toBe("stable");
|
||||
});
|
||||
|
||||
test("rejects unknown words and non-strings", () => {
|
||||
expect(normalizeReleaseType("main")).toBeUndefined();
|
||||
expect(normalizeReleaseType("beta")).toBeUndefined();
|
||||
// The kind filter never offered pre-releases on their own, but a value
|
||||
// saved while it did must fall through to the next tier rather than pin
|
||||
// something the mirror no longer does.
|
||||
expect(normalizeReleaseType("prerelease")).toBeUndefined();
|
||||
expect(normalizeReleaseType("")).toBeUndefined();
|
||||
expect(normalizeReleaseType(true)).toBeUndefined();
|
||||
expect(normalizeReleaseType(1)).toBeUndefined();
|
||||
expect(normalizeReleaseType(null)).toBeUndefined();
|
||||
expect(normalizeReleaseType(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseMatchesType", () => {
|
||||
test("all accepts every release", () => {
|
||||
expect(releaseMatchesType({ prerelease: true }, "all")).toBe(true);
|
||||
expect(releaseMatchesType({ prerelease: false }, "all")).toBe(true);
|
||||
expect(releaseMatchesType({}, "all")).toBe(true);
|
||||
});
|
||||
|
||||
test("stable keeps everything the source does not flag", () => {
|
||||
expect(releaseMatchesType({ prerelease: false }, "stable")).toBe(true);
|
||||
expect(releaseMatchesType({ prerelease: true }, "stable")).toBe(false);
|
||||
});
|
||||
|
||||
test("a release with no flag counts as a normal release", () => {
|
||||
// Dropping everything because a source omits the flag would silently
|
||||
// empty a mirror, so unknown is treated as "not a pre-release".
|
||||
expect(releaseMatchesType({}, "stable")).toBe(true);
|
||||
expect(releaseMatchesType({ prerelease: null }, "stable")).toBe(true);
|
||||
});
|
||||
|
||||
test("a filter splits a newest-first list", () => {
|
||||
const releases = [
|
||||
{ prerelease: true },
|
||||
{ prerelease: false },
|
||||
{ prerelease: true },
|
||||
{},
|
||||
];
|
||||
expect(releases.filter((r) => releaseMatchesType(r, "stable"))).toHaveLength(2);
|
||||
expect(releases.filter((r) => releaseMatchesType(r, "all"))).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseScanLimit", () => {
|
||||
test("without a filter the limit is the window", () => {
|
||||
expect(releaseScanLimit(10, "all")).toBe(10);
|
||||
});
|
||||
|
||||
test("a filter looks back at least the scan window", () => {
|
||||
expect(releaseScanLimit(3, "stable")).toBe(RELEASE_SCAN_WINDOW);
|
||||
expect(releaseScanLimit(1, "stable")).toBe(RELEASE_SCAN_WINDOW);
|
||||
});
|
||||
|
||||
test("a tag filter widens the window the same way", () => {
|
||||
expect(releaseScanLimit(1, "all", true)).toBe(RELEASE_SCAN_WINDOW);
|
||||
expect(releaseScanLimit(1, "all", false)).toBe(1);
|
||||
});
|
||||
|
||||
test("a limit bigger than the window still wins", () => {
|
||||
expect(releaseScanLimit(RELEASE_SCAN_WINDOW + 50, "stable")).toBe(
|
||||
RELEASE_SCAN_WINDOW + 50
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("release tag patterns", () => {
|
||||
test("normalizeReleaseTagPatterns trims, dedupes and drops junk entries", () => {
|
||||
expect(
|
||||
normalizeReleaseTagPatterns([" v1.0 ", "", 5, null, "v1.0", "V1.0", "rc"])
|
||||
).toEqual(["v1.0", "rc"]);
|
||||
});
|
||||
|
||||
test("an empty or unusable list is absent, not an empty pin", () => {
|
||||
// Clearing the box means inherit, so an empty list has to fall through to
|
||||
// the next tier rather than pinning "no patterns" here.
|
||||
expect(normalizeReleaseTagPatterns([])).toBeUndefined();
|
||||
expect(normalizeReleaseTagPatterns(["", " "])).toBeUndefined();
|
||||
expect(normalizeReleaseTagPatterns("v*")).toBeUndefined();
|
||||
expect(normalizeReleaseTagPatterns(null)).toBeUndefined();
|
||||
expect(normalizeReleaseTagPatterns(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("over-long patterns are dropped and the list is capped", () => {
|
||||
expect(
|
||||
normalizeReleaseTagPatterns(["x".repeat(MAX_RELEASE_TAG_PATTERN_LENGTH + 1)])
|
||||
).toBeUndefined();
|
||||
const many = Array.from(
|
||||
{ length: MAX_RELEASE_TAG_PATTERNS + 5 },
|
||||
(_, index) => `v${index}`
|
||||
);
|
||||
expect(normalizeReleaseTagPatterns(many)).toHaveLength(MAX_RELEASE_TAG_PATTERNS);
|
||||
});
|
||||
|
||||
test("a plain pattern is a whole-tag wildcard, case-insensitive", () => {
|
||||
expect(compileReleaseTagPattern("*-rc.*")?.test("V1.0-rc.1")).toBe(true);
|
||||
expect(compileReleaseTagPattern("*-rc.*")?.test("v1.0.0")).toBe(false);
|
||||
expect(compileReleaseTagPattern("?")?.test("a")).toBe(true);
|
||||
expect(compileReleaseTagPattern("?")?.test("ab")).toBe(false);
|
||||
expect(compileReleaseTagPattern("RC")?.test("rc")).toBe(true);
|
||||
});
|
||||
|
||||
test("a wildcard cannot start or end in the middle of a tag", () => {
|
||||
expect(compileReleaseTagPattern("rc")?.test("v1.0-rc.1")).toBe(false);
|
||||
expect(compileReleaseTagPattern("*rc*")?.test("v1.0-rc.1")).toBe(true);
|
||||
});
|
||||
|
||||
test("dots and brackets are literal in a glob", () => {
|
||||
expect(compileReleaseTagPattern("v1.0")?.test("v1.0")).toBe(true);
|
||||
expect(compileReleaseTagPattern("v1.0")?.test("v1x0")).toBe(false);
|
||||
expect(compileReleaseTagPattern("[bet]")?.test("[bet]")).toBe(true);
|
||||
expect(compileReleaseTagPattern("[bet]")?.test("b")).toBe(false);
|
||||
});
|
||||
|
||||
test("the slash form is a regular expression", () => {
|
||||
const strict = compileReleaseTagPattern("/^v\\d+\\.\\d+\\.\\d+$/");
|
||||
expect(strict?.test("v1.2.3")).toBe(true);
|
||||
expect(strict?.test("v1.2.3-rc.1")).toBe(false);
|
||||
expect(compileReleaseTagPattern("/rc/")?.test("v2.0-rc2")).toBe(true);
|
||||
});
|
||||
|
||||
test("a regex keeps its own flags but always ignores case", () => {
|
||||
expect(compileReleaseTagPattern("/^V\\d+$/i")?.test("v7")).toBe(true);
|
||||
expect(compileReleaseTagPattern("/^V\\d+$/")?.test("v7")).toBe(true);
|
||||
});
|
||||
|
||||
test("a pattern that cannot compile is dropped rather than thrown", () => {
|
||||
// One typo in a stored pattern must not stop a mirror run.
|
||||
expect(compileReleaseTagPattern("/v1(/")).toBeNull();
|
||||
expect(compileReleaseTagPattern("")).toBeNull();
|
||||
expect(compileReleaseTagPattern(true)).toBeNull();
|
||||
expect(compileReleaseTagPatterns(["v*", "/v1(/", ""])).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("an unterminated slash pattern is rejected, not treated as a glob", () => {
|
||||
// /... with no closing slash is an unfinished regex. Globbing it would
|
||||
// store a pattern that silently matches nothing.
|
||||
expect(compileReleaseTagPattern("/^v\\d{1")).toBeNull();
|
||||
expect(compileReleaseTagPatterns(["/^v\\d{1"])).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("splitReleaseTagEntries", () => {
|
||||
test("splits plain globs on commas and trims away empty entries", () => {
|
||||
expect(splitReleaseTagEntries(" v* , *-rc.* ,, ")).toEqual(["v*", "*-rc.*"]);
|
||||
});
|
||||
|
||||
test("keeps commas inside the regex form intact", () => {
|
||||
// The bug this guards: a regex quantifier like {1,3} split into two
|
||||
// literal globs that match nothing, silently dropping every release.
|
||||
const entries = splitReleaseTagEntries("/^v\\d{1,3}\\.\\d+$/, v2.*");
|
||||
expect(entries).toEqual(["/^v\\d{1,3}\\.\\d+$/", "v2.*"]);
|
||||
expect(compileReleaseTagPattern(entries[0])?.test("v123.4")).toBe(true);
|
||||
expect(compileReleaseTagPattern(entries[0])?.test("v1234.4")).toBe(false);
|
||||
expect(compileReleaseTagPattern(entries[1])?.test("v2.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("a backslash keeps an escaped slash from closing the regex early", () => {
|
||||
expect(splitReleaseTagEntries("/a\\/b,c/, d")).toEqual(["/a\\/b,c/", "d"]);
|
||||
});
|
||||
|
||||
test("an unterminated regex swallows the rest so the compile step flags it", () => {
|
||||
const entries = splitReleaseTagEntries("/foo, /bar");
|
||||
expect(entries).toEqual(["/foo, /bar"]);
|
||||
expect(compileReleaseTagPattern(entries[0])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("include restricts, exclude wins when both name a tag", () => {
|
||||
const include = compileReleaseTagPatterns(["v*"]);
|
||||
const exclude = compileReleaseTagPatterns(["*-rc.*"]);
|
||||
expect(releaseMatchesTagFilters("v1.2.3", include, exclude)).toBe(true);
|
||||
expect(releaseMatchesTagFilters("next-1.2", include, exclude)).toBe(false);
|
||||
expect(releaseMatchesTagFilters("v1.0-rc.1", include, exclude)).toBe(false);
|
||||
});
|
||||
|
||||
test("no patterns means every tag passes", () => {
|
||||
expect(releaseMatchesTagFilters("anything", [], [])).toBe(true);
|
||||
expect(releaseTagMatchesAny("v1", [])).toBe(false);
|
||||
});
|
||||
|
||||
test("a release with no tag name still evaluates", () => {
|
||||
expect(releaseMatchesTagFilters("", compileReleaseTagPatterns(["*"]), [])).toBe(true);
|
||||
expect(releaseMatchesTagFilters("", compileReleaseTagPatterns(["v*"]), [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("release tag pattern tiers and overrides", () => {
|
||||
test("no tier means no filter", () => {
|
||||
const resolved = resolveMirrorOptions({
|
||||
config: makeConfig({ mirrorReleases: true }),
|
||||
repository: makeRepo(),
|
||||
});
|
||||
expect(resolved.releaseIncludeTags).toEqual([]);
|
||||
expect(resolved.releaseExcludeTags).toEqual([]);
|
||||
});
|
||||
|
||||
test("organization beats global, repository beats organization", () => {
|
||||
const config = makeConfig({
|
||||
mirrorReleases: true,
|
||||
releaseExcludeTags: ["*-rc.*"],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMirrorOptions({ config, repository: makeRepo() }).releaseExcludeTags
|
||||
).toEqual(["*-rc.*"]);
|
||||
|
||||
expect(
|
||||
resolveMirrorOptions({
|
||||
config,
|
||||
repository: makeRepo(),
|
||||
orgOverrides: { releaseExcludeTags: ["*-beta.*"] },
|
||||
}).releaseExcludeTags
|
||||
).toEqual(["*-beta.*"]);
|
||||
|
||||
expect(
|
||||
resolveMirrorOptions({
|
||||
config,
|
||||
repository: makeRepo({
|
||||
mirrorOverrides: { releaseExcludeTags: ["*-alpha.*"] },
|
||||
}),
|
||||
orgOverrides: { releaseExcludeTags: ["*-beta.*"] },
|
||||
}).releaseExcludeTags
|
||||
).toEqual(["*-alpha.*"]);
|
||||
});
|
||||
|
||||
test("an empty repository list inherits rather than clearing", () => {
|
||||
// The documented consequence of "an empty list is not a pin": a repository
|
||||
// cannot switch off a pattern its organization set.
|
||||
expect(
|
||||
resolveMirrorOptions({
|
||||
config: makeConfig({ mirrorReleases: true, releaseExcludeTags: ["*-rc.*"] }),
|
||||
repository: makeRepo({ mirrorOverrides: { releaseExcludeTags: [] } }),
|
||||
}).releaseExcludeTags
|
||||
).toEqual(["*-rc.*"]);
|
||||
});
|
||||
|
||||
test("include and exclude resolve independently of each other", () => {
|
||||
const resolved = resolveMirrorOptions({
|
||||
config: makeConfig({ mirrorReleases: true, releaseIncludeTags: ["v*"] }),
|
||||
repository: makeRepo({
|
||||
mirrorOverrides: { releaseExcludeTags: ["*-rc.*"] },
|
||||
}),
|
||||
});
|
||||
expect(resolved.releaseIncludeTags).toEqual(["v*"]);
|
||||
expect(resolved.releaseExcludeTags).toEqual(["*-rc.*"]);
|
||||
});
|
||||
|
||||
test("junk entries are dropped one at a time, not the whole list", () => {
|
||||
expect(
|
||||
resolveMirrorOptions({
|
||||
config: makeConfig({ mirrorReleases: true }),
|
||||
repository: makeRepo({
|
||||
mirrorOverrides: { releaseIncludeTags: ["v*", 7, "", "V*", null] },
|
||||
}),
|
||||
}).releaseIncludeTags
|
||||
).toEqual(["v*"]);
|
||||
});
|
||||
|
||||
test("a pinned pattern list counts as an override", () => {
|
||||
expect(hasMirrorOverrides({ releaseExcludeTags: ["v*"] })).toBe(true);
|
||||
expect(listOverriddenKeys({ releaseIncludeTags: ["v*"] })).toEqual([
|
||||
"releaseIncludeTags",
|
||||
]);
|
||||
});
|
||||
|
||||
test("empty and junk pattern lists do not count", () => {
|
||||
expect(hasMirrorOverrides({ releaseIncludeTags: [] })).toBe(false);
|
||||
expect(hasMirrorOverrides({ releaseExcludeTags: null })).toBe(false);
|
||||
expect(listOverriddenKeys({ releaseExcludeTags: [7, ""] })).toEqual([]);
|
||||
});
|
||||
|
||||
test("normalizeMirrorOverrides keeps the usable patterns", () => {
|
||||
expect(
|
||||
normalizeMirrorOverrides({ releaseIncludeTags: [" v* ", "v*", 5] })
|
||||
).toEqual({ releaseIncludeTags: ["v*"] });
|
||||
expect(normalizeMirrorOverrides({ releaseIncludeTags: [] })).toBeNull();
|
||||
});
|
||||
|
||||
test("a junk pattern entry does not take the rest of the overrides with it", () => {
|
||||
expect(
|
||||
parseMirrorOverrides({ releaseIncludeTags: ["v*", 7], lfs: false })
|
||||
).toEqual({ releaseIncludeTags: ["v*", 7], lfs: false });
|
||||
expect(
|
||||
normalizeMirrorOverrides({ releaseIncludeTags: ["v*", 7], lfs: false })
|
||||
).toEqual({ releaseIncludeTags: ["v*"], lfs: false });
|
||||
});
|
||||
|
||||
test("the pattern keys are their own group, with labels", () => {
|
||||
expect(MIRROR_OVERRIDE_PATTERN_KEYS).toEqual([
|
||||
"releaseIncludeTags",
|
||||
"releaseExcludeTags",
|
||||
]);
|
||||
expect(MIRROR_OVERRIDE_KEYS).not.toContain("releaseIncludeTags");
|
||||
expect(MIRROR_OVERRIDE_LIMIT_KEYS).not.toContain("releaseExcludeTags");
|
||||
expect(MIRROR_OVERRIDE_CHOICE_KEYS).not.toContain("releaseExcludeTags");
|
||||
for (const key of MIRROR_OVERRIDE_PATTERN_KEYS) {
|
||||
expect(MIRROR_OVERRIDE_LABELS[key]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("mirrorOptionsToFlags exposes the lists for the dialog hints", () => {
|
||||
expect(mirrorOptionsToFlags({ mirrorReleases: true }).releaseIncludeTags).toEqual([]);
|
||||
expect(
|
||||
mirrorOptionsToFlags({ releaseExcludeTags: ["*-rc.*"] }).releaseExcludeTags
|
||||
).toEqual(["*-rc.*"]);
|
||||
});
|
||||
|
||||
test("the pattern boxes are gated exactly like the release limit", () => {
|
||||
const releasesOff = getMirrorOverrideGating({
|
||||
targetKind: "repository",
|
||||
effective: { mirrorReleases: false },
|
||||
});
|
||||
expect(releasesOff.releaseIncludeTags).toBe(MIRROR_GATING_REASONS.releasesOff);
|
||||
expect(releasesOff.releaseExcludeTags).toBe(MIRROR_GATING_REASONS.releasesOff);
|
||||
|
||||
const starred = getMirrorOverrideGating({
|
||||
targetKind: "repository",
|
||||
isStarred: true,
|
||||
starredCodeOnly: true,
|
||||
effective: { mirrorReleases: true },
|
||||
});
|
||||
expect(starred.releaseIncludeTags).toBe(MIRROR_GATING_REASONS.starredCodeOnly);
|
||||
|
||||
const pushTarget = getMirrorOverrideGating({
|
||||
targetKind: "repository",
|
||||
effective: { mirrorReleases: true },
|
||||
destinationProvider: "gitlab",
|
||||
});
|
||||
expect(pushTarget.releaseIncludeTags).toBe(MIRROR_GATING_REASONS.pushTarget);
|
||||
expect(pushTarget.releaseExcludeTags).toBe(MIRROR_GATING_REASONS.pushTarget);
|
||||
|
||||
const editable = getMirrorOverrideGating({
|
||||
targetKind: "organization",
|
||||
effective: { mirrorReleases: true },
|
||||
});
|
||||
expect(editable.releaseIncludeTags).toBeUndefined();
|
||||
expect(editable.releaseExcludeTags).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMirrorOptions", () => {
|
||||
test("defaults to every release when no tier pins a type", () => {
|
||||
const resolved = resolveMirrorOptions({
|
||||
config: makeConfig({ mirrorReleases: true }),
|
||||
repository: makeRepo(),
|
||||
});
|
||||
expect(resolved.releaseType).toBe(DEFAULT_RELEASE_TYPE);
|
||||
expect(resolved.releaseType).toBe("all");
|
||||
});
|
||||
|
||||
test("the global value applies when nothing overrides it", () => {
|
||||
const resolved = resolveMirrorOptions({
|
||||
config: makeConfig({ mirrorReleases: true, releaseType: "stable" }),
|
||||
repository: makeRepo(),
|
||||
});
|
||||
expect(resolved.releaseType).toBe("stable");
|
||||
});
|
||||
|
||||
test("organization beats global, repository beats organization", () => {
|
||||
const config = makeConfig({ mirrorReleases: true, releaseType: "all" });
|
||||
|
||||
expect(
|
||||
resolveMirrorOptions({
|
||||
config,
|
||||
repository: makeRepo(),
|
||||
orgOverrides: { releaseType: "stable" },
|
||||
}).releaseType
|
||||
).toBe("stable");
|
||||
|
||||
expect(
|
||||
resolveMirrorOptions({
|
||||
config,
|
||||
repository: makeRepo({ mirrorOverrides: { releaseType: "all" } }),
|
||||
orgOverrides: { releaseType: "stable" },
|
||||
}).releaseType
|
||||
).toBe("all");
|
||||
});
|
||||
|
||||
test("null in an override means inherit", () => {
|
||||
const resolved = resolveMirrorOptions({
|
||||
config: makeConfig({ mirrorReleases: true, releaseType: "stable" }),
|
||||
repository: makeRepo({ mirrorOverrides: { releaseType: null } }),
|
||||
orgOverrides: { releaseType: null },
|
||||
});
|
||||
expect(resolved.releaseType).toBe("stable");
|
||||
});
|
||||
|
||||
test("an unusable value falls through to the next tier", () => {
|
||||
const resolved = resolveMirrorOptions({
|
||||
config: makeConfig({ mirrorReleases: true, releaseType: "all" }),
|
||||
repository: makeRepo({ mirrorOverrides: { releaseType: "beta" } }),
|
||||
orgOverrides: { releaseType: "stable" },
|
||||
});
|
||||
expect(resolved.releaseType).toBe("stable");
|
||||
});
|
||||
|
||||
test("a tier with nothing usable lands on the default rather than a filter", () => {
|
||||
const resolved = resolveMirrorOptions({
|
||||
config: makeConfig({ mirrorReleases: true, releaseType: "weekly" }),
|
||||
repository: makeRepo({ mirrorOverrides: { releaseType: "beta" } }),
|
||||
});
|
||||
expect(resolved.releaseType).toBe(DEFAULT_RELEASE_TYPE);
|
||||
});
|
||||
|
||||
test("the release type is independent of the release limit", () => {
|
||||
const resolved = resolveMirrorOptions({
|
||||
config: makeConfig({ mirrorReleases: true, releaseLimit: 3 }),
|
||||
repository: makeRepo({ mirrorOverrides: { releaseType: "stable" } }),
|
||||
});
|
||||
expect(resolved.releaseLimit).toBe(3);
|
||||
expect(resolved.releaseType).toBe("stable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("override helpers", () => {
|
||||
test("a pinned release type counts as an override", () => {
|
||||
expect(hasMirrorOverrides({ releaseType: "stable" })).toBe(true);
|
||||
expect(listOverriddenKeys({ releaseType: "all" })).toEqual(["releaseType"]);
|
||||
});
|
||||
|
||||
test("null and unusable values do not count", () => {
|
||||
expect(hasMirrorOverrides({ releaseType: null })).toBe(false);
|
||||
expect(hasMirrorOverrides({ releaseType: "beta" })).toBe(false);
|
||||
expect(listOverriddenKeys({ releaseType: "beta" })).toEqual([]);
|
||||
});
|
||||
|
||||
test("normalizeMirrorOverrides folds case and drops junk", () => {
|
||||
expect(normalizeMirrorOverrides({ releaseType: "Stable" })).toEqual({
|
||||
releaseType: "stable",
|
||||
});
|
||||
expect(normalizeMirrorOverrides({ releaseType: "beta" })).toBeNull();
|
||||
expect(
|
||||
normalizeMirrorOverrides({ releaseType: null, lfs: true })
|
||||
).toEqual({ lfs: true });
|
||||
});
|
||||
|
||||
test("a junk release type does not take the rest of the overrides with it", () => {
|
||||
// The stored column is loose on purpose: one bad field must not make the
|
||||
// whole object unparseable and silently drop the LFS opt-out next to it.
|
||||
expect(parseMirrorOverrides({ releaseType: "beta", lfs: false })).toEqual({
|
||||
releaseType: "beta",
|
||||
lfs: false,
|
||||
});
|
||||
expect(normalizeMirrorOverrides({ releaseType: "beta", lfs: false })).toEqual({
|
||||
lfs: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("the choice keys and labels agree", () => {
|
||||
expect(MIRROR_OVERRIDE_CHOICE_KEYS).toEqual(["releaseType"]);
|
||||
for (const key of MIRROR_OVERRIDE_CHOICE_KEYS) {
|
||||
expect(MIRROR_OVERRIDE_LABELS[key]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("the choice keys are not walked as flags or limits", () => {
|
||||
expect(MIRROR_OVERRIDE_KEYS).not.toContain("releaseType");
|
||||
expect(MIRROR_OVERRIDE_LIMIT_KEYS).not.toContain("releaseType");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mirrorOptionsToFlags", () => {
|
||||
test("defaults to every release when the API sends no type", () => {
|
||||
expect(mirrorOptionsToFlags({ mirrorReleases: true }).releaseType).toBe("all");
|
||||
expect(
|
||||
mirrorOptionsToFlags({ mirrorReleases: true, releaseType: null as any }).releaseType
|
||||
).toBe("all");
|
||||
});
|
||||
|
||||
test("passes the stored value through", () => {
|
||||
expect(mirrorOptionsToFlags({ releaseType: "stable" }).releaseType).toBe("stable");
|
||||
// A value from a release that no longer offers this kind lands on the
|
||||
// default instead of turning into a filter nothing asked for.
|
||||
expect(mirrorOptionsToFlags({ releaseType: "prerelease" as any }).releaseType).toBe(
|
||||
"all"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMirrorOverrideGating", () => {
|
||||
test("the release type is disabled with the release limit when releases are off", () => {
|
||||
const gating = getMirrorOverrideGating({
|
||||
targetKind: "repository",
|
||||
effective: { mirrorReleases: false },
|
||||
});
|
||||
expect(gating.releaseType).toBe(MIRROR_GATING_REASONS.releasesOff);
|
||||
expect(gating.releaseLimit).toBe(MIRROR_GATING_REASONS.releasesOff);
|
||||
});
|
||||
|
||||
test("the release type inherits the starred reason", () => {
|
||||
const gating = getMirrorOverrideGating({
|
||||
targetKind: "repository",
|
||||
isStarred: true,
|
||||
starredCodeOnly: true,
|
||||
effective: { mirrorReleases: true },
|
||||
});
|
||||
expect(gating.releaseType).toBe(MIRROR_GATING_REASONS.starredCodeOnly);
|
||||
});
|
||||
|
||||
test("the release type is editable while releases are on", () => {
|
||||
const gating = getMirrorOverrideGating({
|
||||
targetKind: "organization",
|
||||
effective: { mirrorReleases: true },
|
||||
});
|
||||
expect(gating.releaseType).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a push target disables the release type", () => {
|
||||
const gating = getMirrorOverrideGating({
|
||||
targetKind: "repository",
|
||||
effective: { mirrorReleases: true },
|
||||
destinationProvider: "github",
|
||||
});
|
||||
expect(gating.releaseType).toBe(MIRROR_GATING_REASONS.pushTarget);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Config } from "@/types/config";
|
||||
import type { MirrorOverrides, Repository } from "@/lib/db/schema";
|
||||
import type { MirrorOverrides, ReleaseType, Repository } from "@/lib/db/schema";
|
||||
import { mirrorOverridesSchema } from "@/lib/db/schema";
|
||||
import {
|
||||
normalizeSourceProviderKind,
|
||||
@@ -38,6 +38,36 @@ export const MIRROR_OVERRIDE_LIMIT_KEYS = ["releaseLimit", "releaseAssetLimit"]
|
||||
|
||||
export type MirrorOverrideLimitKey = (typeof MIRROR_OVERRIDE_LIMIT_KEYS)[number];
|
||||
|
||||
/**
|
||||
* The choice-based mirror options that can be overridden per organization and
|
||||
* per repository. Kept apart from the flags and the limits because both the
|
||||
* tri-state toggles and the numeric rows in the dialog assume those shapes.
|
||||
*
|
||||
* `releaseType` decides which releases are candidates at all; `releaseLimit`
|
||||
* then decides how many of the matching ones are kept.
|
||||
*/
|
||||
export const MIRROR_OVERRIDE_CHOICE_KEYS = ["releaseType"] as const;
|
||||
|
||||
export type MirrorOverrideChoiceKey =
|
||||
(typeof MIRROR_OVERRIDE_CHOICE_KEYS)[number];
|
||||
|
||||
/**
|
||||
* The tag-pattern mirror options that can be overridden per organization and
|
||||
* per repository. Kept apart from the flags, limits and choices because all of
|
||||
* those assume a scalar value; these hold a list of patterns.
|
||||
*
|
||||
* `releaseIncludeTags` keeps only the releases whose tag matches one of the
|
||||
* patterns, `releaseExcludeTags` drops the ones that do. Both run after
|
||||
* `releaseType` and before `releaseLimit`.
|
||||
*/
|
||||
export const MIRROR_OVERRIDE_PATTERN_KEYS = [
|
||||
"releaseIncludeTags",
|
||||
"releaseExcludeTags",
|
||||
] as const;
|
||||
|
||||
export type MirrorOverridePatternKey =
|
||||
(typeof MIRROR_OVERRIDE_PATTERN_KEYS)[number];
|
||||
|
||||
/**
|
||||
* Organization-tier selection overrides: per-organization switches that steer
|
||||
* which source repositories are imported and mirrored, not what a mirror
|
||||
@@ -53,6 +83,27 @@ export type OrgSelectionOverrideKey = (typeof ORG_SELECTION_OVERRIDE_KEYS)[numbe
|
||||
/** Matches `giteaConfigSchema.releaseLimit`'s default. */
|
||||
export const DEFAULT_RELEASE_LIMIT = 10;
|
||||
|
||||
/** Matches `giteaConfigSchema.releaseType`'s default. */
|
||||
export const DEFAULT_RELEASE_TYPE: ReleaseType = "all";
|
||||
|
||||
/**
|
||||
* How many releases the mirror looks at when any release filter is active.
|
||||
*
|
||||
* Filtering happens before the limit, so without a wider window a repository
|
||||
* whose newest releases are all filtered out would mirror nothing at all when
|
||||
* asked for the other kind. 100 is GitHub's maximum page size, so it costs one
|
||||
* extra page at most.
|
||||
*/
|
||||
export const RELEASE_SCAN_WINDOW = 100;
|
||||
|
||||
/**
|
||||
* Bounds on stored tag patterns. They are compiled into regular expressions on
|
||||
* every mirror run, so a hand-edited row holding thousands of them cannot be
|
||||
* allowed to turn a sync into a regex benchmark.
|
||||
*/
|
||||
export const MAX_RELEASE_TAG_PATTERNS = 20;
|
||||
export const MAX_RELEASE_TAG_PATTERN_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* Fully resolved mirror options: every flag has a definite boolean value, the
|
||||
* release limit a definite positive integer, and the asset limit either a
|
||||
@@ -61,6 +112,9 @@ export const DEFAULT_RELEASE_LIMIT = 10;
|
||||
export type ResolvedMirrorOptions = Record<MirrorOverrideKey, boolean> & {
|
||||
releaseLimit: number;
|
||||
releaseAssetLimit: number | null;
|
||||
releaseType: ReleaseType;
|
||||
releaseIncludeTags: string[];
|
||||
releaseExcludeTags: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -71,6 +125,9 @@ export type ResolvedMirrorOptions = Record<MirrorOverrideKey, boolean> & {
|
||||
export type InheritedMirrorOptions = Partial<Record<MirrorOverrideKey, boolean>> & {
|
||||
releaseLimit?: number;
|
||||
releaseAssetLimit?: number | null;
|
||||
releaseType?: ReleaseType;
|
||||
releaseIncludeTags?: string[];
|
||||
releaseExcludeTags?: string[];
|
||||
skipForks?: boolean;
|
||||
};
|
||||
|
||||
@@ -99,6 +156,245 @@ export function normalizeReleaseAssetLimit(value: unknown): number | undefined {
|
||||
return limit >= 0 ? limit : undefined;
|
||||
}
|
||||
|
||||
const RELEASE_TYPES: readonly ReleaseType[] = ["all", "stable"];
|
||||
|
||||
/**
|
||||
* Sanitize a release type from any tier into one of the known values, or
|
||||
* `undefined` when it is absent or unusable (so the next tier out is
|
||||
* consulted). Case is folded so a hand-written `RELEASE_TYPE=Stable` env var
|
||||
* behaves the same as the value the UI writes.
|
||||
*/
|
||||
export function normalizeReleaseType(value: unknown): ReleaseType | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const type = value.trim().toLowerCase();
|
||||
return RELEASE_TYPES.find((candidate) => candidate === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a release belongs to the configured type.
|
||||
*
|
||||
* The `prerelease` flag is the only signal: GitHub and Gitea both report it.
|
||||
* A source that does not report it (`undefined`/`null`) counts as a normal
|
||||
* release, which keeps `stable` from silently dropping everything.
|
||||
*/
|
||||
export function releaseMatchesType(
|
||||
release: { prerelease?: boolean | null },
|
||||
releaseType: ReleaseType
|
||||
): boolean {
|
||||
if (releaseType === "all") return true;
|
||||
return release.prerelease !== true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a tag-pattern list from any tier into a usable list, or `undefined`
|
||||
* when it is absent or unusable (so the next tier out is consulted). Entries
|
||||
* that are not non-empty strings are ignored one by one, duplicates collapse,
|
||||
* and the list is capped so a hand-edited row cannot grow past what a mirror
|
||||
* run should compile.
|
||||
*
|
||||
* An empty list is deliberately not a pin: clearing the field in the UI means
|
||||
* "inherit", the same way an empty release-limit box does.
|
||||
*/
|
||||
export function normalizeReleaseTagPatterns(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const patterns: string[] = [];
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== "string") continue;
|
||||
const pattern = entry.trim();
|
||||
if (!pattern || pattern.length > MAX_RELEASE_TAG_PATTERN_LENGTH) continue;
|
||||
const key = pattern.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
patterns.push(pattern);
|
||||
if (patterns.length >= MAX_RELEASE_TAG_PATTERNS) break;
|
||||
}
|
||||
|
||||
return patterns.length > 0 ? patterns : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a tag rules box into entries on commas that sit OUTSIDE a /.../ pair.
|
||||
*
|
||||
* Regex bodies legitimately contain commas - `^v\d{1,3}\.\d+$` is full of them -
|
||||
* so a naive split(",") turns one regex into two globs that match nothing. A
|
||||
* backslash escapes the next character, which keeps `\/` from closing the body
|
||||
* early. Slashes that do not open an entry (mid-glob, as in `a/b`) are literal.
|
||||
*
|
||||
* An unterminated `/` swallows the rest of the text into one entry; the compile
|
||||
* step then rejects it, so the dialog can flag the typo instead of storing a
|
||||
* glob that silently matches nothing.
|
||||
*/
|
||||
export function splitReleaseTagEntries(text: string): string[] {
|
||||
const entries: string[] = [];
|
||||
let current = "";
|
||||
let insideRegex = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
if (insideRegex) {
|
||||
if (char === "\\") {
|
||||
current += char;
|
||||
i++;
|
||||
if (i < text.length) current += text[i];
|
||||
continue;
|
||||
}
|
||||
if (char === "/") insideRegex = false;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && current.trim() === "") {
|
||||
insideRegex = true;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === ",") {
|
||||
entries.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
entries.push(current);
|
||||
return entries.map((entry) => entry.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Escape everything that has a meaning inside a regular expression. */
|
||||
function escapeRegexChars(value: string): string {
|
||||
return value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Turn a whole-string glob into a regular expression source. */
|
||||
function globToRegexSource(glob: string): string {
|
||||
let source = "";
|
||||
for (const char of glob) {
|
||||
if (char === "*") source += ".*";
|
||||
else if (char === "?") source += ".";
|
||||
else source += escapeRegexChars(char);
|
||||
}
|
||||
return `^(?:${source})$`;
|
||||
}
|
||||
|
||||
/** Keep any flags the user asked for, but always match case-insensitively. */
|
||||
function releaseTagPatternFlags(flags: string): string {
|
||||
const cleaned = flags
|
||||
.split("")
|
||||
.filter((flag) => "imsuy".includes(flag))
|
||||
.join("");
|
||||
return cleaned.includes("i") ? cleaned : `${cleaned}i`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile one stored tag pattern.
|
||||
*
|
||||
* A pattern wrapped in slashes (`/v1\.\d+$/`) is a JavaScript regular
|
||||
* expression, which is the escape hatch for whoever needs it. Anything else is a
|
||||
* glob matched against the whole tag name: `*` is any run of characters, `?` is
|
||||
* exactly one, everything else is literal. So `*-rc.*` skips `v1.0-rc.1` and
|
||||
* `v2.0-rc2`, while plain `rc` matches nothing but a tag spelled `rc`. Case is
|
||||
* ignored either way, because upstreams spell their tags however they like.
|
||||
*
|
||||
* Returns `null` for a pattern that cannot be used, including a regular
|
||||
* expression that does not compile. Dropping it beats throwing: one typo must
|
||||
* not stop a mirror run.
|
||||
*/
|
||||
export function compileReleaseTagPattern(pattern: unknown): RegExp | null {
|
||||
if (typeof pattern !== "string") return null;
|
||||
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (trimmed.length > 2 && trimmed.startsWith("/")) {
|
||||
const closing = trimmed.lastIndexOf("/");
|
||||
if (closing > 0) {
|
||||
const trailing = trimmed.slice(closing + 1);
|
||||
// Anything after the closing slash has to be regex flags. Junk like the
|
||||
// `bar` in `/foo, /bar` means the "regex" was really an unterminated one
|
||||
// that swallowed a later slash, so reject it rather than silently
|
||||
// dropping the junk and compiling something the user never wrote.
|
||||
if (/^[imsuy]*$/.test(trailing)) {
|
||||
const body = trimmed.slice(1, closing);
|
||||
if (body) {
|
||||
try {
|
||||
return new RegExp(body, releaseTagPatternFlags(trailing));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A leading slash with no closing slash is an unfinished regex, not a tag
|
||||
// spelled with a leading slash (tags cannot start with one). Rejecting it
|
||||
// lets the dialog flag the typo instead of keeping a glob that silently
|
||||
// matches nothing.
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new RegExp(globToRegexSource(trimmed), "i");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compile a whole list, dropping the entries that cannot be used. */
|
||||
export function compileReleaseTagPatterns(
|
||||
patterns: readonly unknown[] | null | undefined
|
||||
): RegExp[] {
|
||||
if (!Array.isArray(patterns)) return [];
|
||||
|
||||
const compiled: RegExp[] = [];
|
||||
for (const pattern of patterns) {
|
||||
const regex = compileReleaseTagPattern(pattern);
|
||||
if (regex) compiled.push(regex);
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
|
||||
/** True when at least one compiled pattern matches the tag. */
|
||||
export function releaseTagMatchesAny(
|
||||
tag: string,
|
||||
patterns: readonly RegExp[]
|
||||
): boolean {
|
||||
return patterns.some((pattern) => pattern.test(tag ?? ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tag survives the include and exclude lists.
|
||||
*
|
||||
* Exclude wins when a tag is named by both, because "only these, except that
|
||||
* one" is the only reading that makes such a configuration mean anything.
|
||||
*/
|
||||
export function releaseMatchesTagFilters(
|
||||
tag: string,
|
||||
includePatterns: readonly RegExp[],
|
||||
excludePatterns: readonly RegExp[]
|
||||
): boolean {
|
||||
if (includePatterns.length > 0 && !releaseTagMatchesAny(tag, includePatterns)) {
|
||||
return false;
|
||||
}
|
||||
if (excludePatterns.length > 0 && releaseTagMatchesAny(tag, excludePatterns)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many releases to fetch before filtering. Without a filter the limit is
|
||||
* enough; with one (a release type or either tag list), the newest releases may
|
||||
* all be filtered out, so the mirror looks further back before applying
|
||||
* `releaseLimit`.
|
||||
*/
|
||||
export function releaseScanLimit(
|
||||
releaseLimit: number,
|
||||
releaseType: ReleaseType,
|
||||
hasTagFilter = false
|
||||
): number {
|
||||
return releaseType === "all" && !hasTagFilter
|
||||
? releaseLimit
|
||||
: Math.max(releaseLimit, RELEASE_SCAN_WINDOW);
|
||||
}
|
||||
|
||||
/** Per-key sanitizers for the numeric overrides. */
|
||||
const LIMIT_NORMALIZERS: Record<
|
||||
MirrorOverrideLimitKey,
|
||||
@@ -108,6 +404,23 @@ const LIMIT_NORMALIZERS: Record<
|
||||
releaseAssetLimit: normalizeReleaseAssetLimit,
|
||||
};
|
||||
|
||||
/** Per-key sanitizers for the choice overrides. */
|
||||
const CHOICE_NORMALIZERS: Record<
|
||||
MirrorOverrideChoiceKey,
|
||||
(value: unknown) => ReleaseType | undefined
|
||||
> = {
|
||||
releaseType: normalizeReleaseType,
|
||||
};
|
||||
|
||||
/** Per-key sanitizers for the tag-pattern overrides. */
|
||||
const PATTERN_NORMALIZERS: Record<
|
||||
MirrorOverridePatternKey,
|
||||
(value: unknown) => string[] | undefined
|
||||
> = {
|
||||
releaseIncludeTags: normalizeReleaseTagPatterns,
|
||||
releaseExcludeTags: normalizeReleaseTagPatterns,
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the release at `index` in the newest-first list gets its assets
|
||||
* uploaded. `null` means no limit. A release past the limit still gets its
|
||||
@@ -234,6 +547,9 @@ export function mirrorOptionsToFlags(
|
||||
mirrorReleases?: boolean;
|
||||
releaseLimit?: number;
|
||||
releaseAssetLimit?: number | null;
|
||||
releaseType?: ReleaseType;
|
||||
releaseIncludeTags?: string[];
|
||||
releaseExcludeTags?: string[];
|
||||
mirrorMetadata?: boolean;
|
||||
metadataComponents?: {
|
||||
issues?: boolean;
|
||||
@@ -261,6 +577,11 @@ export function mirrorOptionsToFlags(
|
||||
normalizeReleaseLimit(mirrorOptions?.releaseLimit) ?? DEFAULT_RELEASE_LIMIT,
|
||||
releaseAssetLimit:
|
||||
normalizeReleaseAssetLimit(mirrorOptions?.releaseAssetLimit) ?? null,
|
||||
releaseType: normalizeReleaseType(mirrorOptions?.releaseType) ?? DEFAULT_RELEASE_TYPE,
|
||||
releaseIncludeTags:
|
||||
normalizeReleaseTagPatterns(mirrorOptions?.releaseIncludeTags) ?? [],
|
||||
releaseExcludeTags:
|
||||
normalizeReleaseTagPatterns(mirrorOptions?.releaseExcludeTags) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -276,7 +597,7 @@ export const MIRROR_GATING_REASONS = {
|
||||
/** key -> reason it cannot take effect. Absent key means editable. */
|
||||
export type MirrorOverrideGating = Partial<
|
||||
Record<
|
||||
MirrorOverrideKey | MirrorOverrideLimitKey | OrgSelectionOverrideKey,
|
||||
MirrorOverrideKey | MirrorOverrideLimitKey | MirrorOverrideChoiceKey | MirrorOverridePatternKey | OrgSelectionOverrideKey,
|
||||
string
|
||||
>
|
||||
>;
|
||||
@@ -323,6 +644,9 @@ export function getMirrorOverrideGating({
|
||||
}
|
||||
gating.releaseLimit = MIRROR_GATING_REASONS.pushTarget;
|
||||
gating.releaseAssetLimit = MIRROR_GATING_REASONS.pushTarget;
|
||||
gating.releaseType = MIRROR_GATING_REASONS.pushTarget;
|
||||
gating.releaseIncludeTags = MIRROR_GATING_REASONS.pushTarget;
|
||||
gating.releaseExcludeTags = MIRROR_GATING_REASONS.pushTarget;
|
||||
return gating;
|
||||
}
|
||||
|
||||
@@ -342,24 +666,31 @@ export function getMirrorOverrideGating({
|
||||
gating.mirrorLabels = MIRROR_GATING_REASONS.labelsFollowIssues;
|
||||
}
|
||||
|
||||
// The release limit rides along with releases: inherit the starred reason
|
||||
// when that is what disabled releases, otherwise report releases being off.
|
||||
// Only an explicit `false` counts; an unknown value leaves the field open.
|
||||
// The release limit and the release filters ride along with releases:
|
||||
// inherit the starred reason when that is what disabled releases, otherwise
|
||||
// report releases being off. Only an explicit `false` counts; an unknown
|
||||
// value leaves the fields open.
|
||||
if (gating.mirrorReleases) {
|
||||
gating.releaseLimit = gating.mirrorReleases;
|
||||
gating.releaseType = gating.mirrorReleases;
|
||||
} else if (effective.mirrorReleases === false) {
|
||||
gating.releaseLimit = MIRROR_GATING_REASONS.releasesOff;
|
||||
gating.releaseType = MIRROR_GATING_REASONS.releasesOff;
|
||||
}
|
||||
// The asset limit is read in the same place, so it shares the reason.
|
||||
// The asset limit and both tag lists are read in the same place, so they
|
||||
// share the reason.
|
||||
if (gating.releaseLimit) {
|
||||
gating.releaseAssetLimit = gating.releaseLimit;
|
||||
for (const patternKey of MIRROR_OVERRIDE_PATTERN_KEYS) {
|
||||
gating[patternKey] = gating.releaseLimit;
|
||||
}
|
||||
}
|
||||
|
||||
return gating;
|
||||
}
|
||||
|
||||
export const MIRROR_OVERRIDE_LABELS: Record<
|
||||
MirrorOverrideKey | MirrorOverrideLimitKey | OrgSelectionOverrideKey,
|
||||
MirrorOverrideKey | MirrorOverrideLimitKey | MirrorOverrideChoiceKey | MirrorOverridePatternKey | OrgSelectionOverrideKey,
|
||||
string
|
||||
> = {
|
||||
lfs: "Git LFS files",
|
||||
@@ -372,6 +703,9 @@ export const MIRROR_OVERRIDE_LABELS: Record<
|
||||
mirrorMilestones: "Milestones",
|
||||
releaseLimit: "Release limit",
|
||||
releaseAssetLimit: "Release asset limit",
|
||||
releaseType: "Release type",
|
||||
releaseIncludeTags: "Include tags",
|
||||
releaseExcludeTags: "Exclude tags",
|
||||
skipForks: "Skip forks",
|
||||
};
|
||||
|
||||
@@ -415,7 +749,7 @@ export function hasMirrorOverrides(value: unknown): boolean {
|
||||
/** The keys an overrides object pins, for badges and summaries. */
|
||||
export function listOverriddenKeys(
|
||||
value: unknown
|
||||
): (MirrorOverrideKey | MirrorOverrideLimitKey | OrgSelectionOverrideKey)[] {
|
||||
): (MirrorOverrideKey | MirrorOverrideLimitKey | MirrorOverrideChoiceKey | MirrorOverridePatternKey | OrgSelectionOverrideKey)[] {
|
||||
const overrides = parseMirrorOverrides(value);
|
||||
if (!overrides) return [];
|
||||
return [
|
||||
@@ -423,6 +757,12 @@ export function listOverriddenKeys(
|
||||
...MIRROR_OVERRIDE_LIMIT_KEYS.filter(
|
||||
(key) => LIMIT_NORMALIZERS[key](overrides[key]) !== undefined
|
||||
),
|
||||
...MIRROR_OVERRIDE_CHOICE_KEYS.filter(
|
||||
(key) => CHOICE_NORMALIZERS[key](overrides[key]) !== undefined
|
||||
),
|
||||
...MIRROR_OVERRIDE_PATTERN_KEYS.filter(
|
||||
(key) => PATTERN_NORMALIZERS[key](overrides[key]) !== undefined
|
||||
),
|
||||
...ORG_SELECTION_OVERRIDE_KEYS.filter((key) => overrides[key] != null),
|
||||
];
|
||||
}
|
||||
@@ -448,6 +788,14 @@ export function normalizeMirrorOverrides(
|
||||
const limit = LIMIT_NORMALIZERS[key](overrides[key]);
|
||||
if (limit !== undefined) cleaned[key] = limit;
|
||||
}
|
||||
for (const key of MIRROR_OVERRIDE_CHOICE_KEYS) {
|
||||
const choice = CHOICE_NORMALIZERS[key](overrides[key]);
|
||||
if (choice !== undefined) cleaned[key] = choice;
|
||||
}
|
||||
for (const key of MIRROR_OVERRIDE_PATTERN_KEYS) {
|
||||
const patterns = PATTERN_NORMALIZERS[key](overrides[key]);
|
||||
if (patterns !== undefined) cleaned[key] = patterns;
|
||||
}
|
||||
for (const key of ORG_SELECTION_OVERRIDE_KEYS) {
|
||||
const flag = overrides[key];
|
||||
if (typeof flag === "boolean") cleaned[key] = flag;
|
||||
@@ -462,7 +810,9 @@ export function normalizeMirrorOverrides(
|
||||
* Precedence is per field, most specific tier wins:
|
||||
* repository override -> organization override -> global config -> default
|
||||
* where the default is `false` for flags, DEFAULT_RELEASE_LIMIT for the
|
||||
* release limit, and `null` (every mirrored release) for the asset limit.
|
||||
* release limit, `null` (every mirrored release) for the asset limit,
|
||||
* DEFAULT_RELEASE_TYPE for which releases are candidates, and an empty list for
|
||||
* the tag patterns (no tag filter).
|
||||
*
|
||||
* `starredCodeOnly` is applied last as a hard clamp. It is a "code only, no
|
||||
* metadata" switch for starred repos, so it forces every metadata flag off
|
||||
@@ -520,6 +870,31 @@ export function resolveMirrorOptions({
|
||||
normalizeReleaseAssetLimit(globalConfig?.releaseAssetLimit) ??
|
||||
null;
|
||||
|
||||
// Same precedence for the release type. A value no tier recognizes (a typo
|
||||
// typed straight into the database, say) falls back to mirroring every
|
||||
// release rather than to a filter that would hide work from the user.
|
||||
resolved.releaseType =
|
||||
normalizeReleaseType(repo?.releaseType) ??
|
||||
normalizeReleaseType(org?.releaseType) ??
|
||||
normalizeReleaseType(globalConfig?.releaseType) ??
|
||||
DEFAULT_RELEASE_TYPE;
|
||||
|
||||
// And the same for the tag lists. No tier pins one means no filter, which is
|
||||
// what every configuration predating the option does. A tier that pins an
|
||||
// empty or unusable list is not a pin either, so the next tier out still
|
||||
// applies: clearing the box in the UI means inherit, not "no patterns here".
|
||||
resolved.releaseIncludeTags =
|
||||
normalizeReleaseTagPatterns(repo?.releaseIncludeTags) ??
|
||||
normalizeReleaseTagPatterns(org?.releaseIncludeTags) ??
|
||||
normalizeReleaseTagPatterns(globalConfig?.releaseIncludeTags) ??
|
||||
[];
|
||||
|
||||
resolved.releaseExcludeTags =
|
||||
normalizeReleaseTagPatterns(repo?.releaseExcludeTags) ??
|
||||
normalizeReleaseTagPatterns(org?.releaseExcludeTags) ??
|
||||
normalizeReleaseTagPatterns(globalConfig?.releaseExcludeTags) ??
|
||||
[];
|
||||
|
||||
// Starred repos with starredCodeOnly mirror code and nothing else. This
|
||||
// clamp intentionally outranks explicit per-repo overrides: the setting
|
||||
// exists to stop starred repos from dragging in metadata wholesale.
|
||||
|
||||
@@ -442,7 +442,8 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
|
||||
// Decrypt tokens
|
||||
if (githubConfig.token) {
|
||||
githubConfig.token = decrypt(githubConfig.token);
|
||||
const token = decrypt(githubConfig.token);
|
||||
githubConfig.token = token.startsWith("github-app:v1:") ? "" : token;
|
||||
}
|
||||
|
||||
if (giteaConfig.token) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* GET /api/dashboard: lastSync counts manual syncs, not only scheduler runs.
|
||||
*
|
||||
* The route imports @/lib/db and @/lib/auth-guards, which are replaced here
|
||||
* through mock.module; bun's mock.module is process-wide and leaks into other
|
||||
* test files, so this file registers nothing in the shared process and
|
||||
* re-runs itself in an isolated child (same harness as
|
||||
* ../job/sync-org.test.ts).
|
||||
*/
|
||||
import { expect, mock, test } from "bun:test";
|
||||
import { repositories, organizations, mirrorJobs, configs } from "@/lib/db/schema";
|
||||
|
||||
const CHILD_FLAG = "GM_DASHBOARD_ROUTE_ISOLATED";
|
||||
const isChild = !!process.env[CHILD_FLAG];
|
||||
|
||||
if (!isChild) {
|
||||
test("dashboard route - isolated child suite", () => {
|
||||
const res = Bun.spawnSync({
|
||||
cmd: [process.execPath, "test", import.meta.path],
|
||||
env: { ...process.env, [CHILD_FLAG]: "1" },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
if (res.exitCode !== 0) {
|
||||
console.error(res.stdout.toString());
|
||||
console.error(res.stderr.toString());
|
||||
}
|
||||
expect(res.exitCode).toBe(0);
|
||||
}, 120_000);
|
||||
}
|
||||
|
||||
if (isChild) {
|
||||
const mirroredAt = new Date("2026-09-28T12:00:00Z");
|
||||
const repo = { id: "repo-1", userId: "user-1", name: "example", status: "synced", lastMirrored: mirroredAt };
|
||||
const activity = { id: "job-1", userId: "user-1", status: "synced" };
|
||||
const results = [
|
||||
[repo], [], [activity], [{ scheduleConfig: { lastRun: null } }],
|
||||
[{ value: 1 }], [{ value: 0 }], [{ value: 1 }], [{ lastMirrored: mirroredAt }],
|
||||
];
|
||||
let queryIndex = 0;
|
||||
|
||||
mock.module("@/lib/db", () => ({
|
||||
db: {
|
||||
select: () => {
|
||||
const rows = results[queryIndex++];
|
||||
const query = {
|
||||
from: () => query,
|
||||
where: () => query,
|
||||
orderBy: () => query,
|
||||
limit: async () => rows,
|
||||
then: (resolve: (value: unknown) => void) => resolve(rows),
|
||||
};
|
||||
return query;
|
||||
},
|
||||
},
|
||||
repositories, organizations, mirrorJobs, configs,
|
||||
}));
|
||||
|
||||
mock.module("@/lib/auth-guards", () => ({
|
||||
requireAuthenticatedUserId: async () => ({ userId: "user-1" }),
|
||||
}));
|
||||
|
||||
const { GET } = await import("./index");
|
||||
|
||||
test("Dashboard includes manual syncs even when the scheduler has never run", async () => {
|
||||
queryIndex = 0;
|
||||
const response = await GET({ request: new Request("http://localhost/api/dashboard"), locals: {} } as any);
|
||||
const body = await response.json();
|
||||
expect(body).toMatchObject({
|
||||
success: true,
|
||||
repoCount: 1,
|
||||
orgCount: 0,
|
||||
mirroredCount: 1,
|
||||
repositories: [{ ...repo, lastMirrored: mirroredAt.toISOString() }],
|
||||
activities: [activity],
|
||||
lastSync: mirroredAt.toISOString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, repositories, organizations, mirrorJobs, configs } from "@/lib/db";
|
||||
import { eq, count, and, sql, or } from "drizzle-orm";
|
||||
import { eq, count, and, sql, or, isNotNull } from "drizzle-orm";
|
||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
import type { DashboardApiResponse } from "@/types/dashboard";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
@@ -19,6 +19,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
[{ value: repoCount }],
|
||||
[{ value: orgCount }],
|
||||
[{ value: mirroredCount }],
|
||||
[latestMirrored],
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
@@ -66,8 +67,19 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
)
|
||||
)
|
||||
),
|
||||
db
|
||||
.select({ lastMirrored: repositories.lastMirrored })
|
||||
.from(repositories)
|
||||
.where(and(eq(repositories.userId, userId), isNotNull(repositories.lastMirrored)))
|
||||
.orderBy(sql`${repositories.lastMirrored} DESC`)
|
||||
.limit(1),
|
||||
]);
|
||||
|
||||
const scheduledRun = userConfig?.scheduleConfig.lastRun;
|
||||
const lastSync = latestMirrored?.lastMirrored && scheduledRun
|
||||
? new Date(Math.max(+latestMirrored.lastMirrored, +new Date(scheduledRun)))
|
||||
: latestMirrored?.lastMirrored ?? scheduledRun ?? null;
|
||||
|
||||
const successResponse: DashboardApiResponse = {
|
||||
success: true,
|
||||
message: "Dashboard data loaded successfully",
|
||||
@@ -77,7 +89,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
repositories: userRepos,
|
||||
organizations: userOrgs,
|
||||
activities: userLogs,
|
||||
lastSync: userConfig?.scheduleConfig.lastRun ?? null,
|
||||
lastSync,
|
||||
};
|
||||
|
||||
return jsonResponse({ data: successResponse });
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Regression test for #465: a client disconnect took the whole server down.
|
||||
*
|
||||
* The node adapter cancels the stream reader when the socket closes and then
|
||||
* fires the request's abort signal. On Bun 1.4 the cancel has already closed
|
||||
* the stream, so an unguarded controller.close() in the abort listener throws
|
||||
* ERR_INVALID_STATE as an uncaught exception, and the uncaught-exception
|
||||
* handler shut the process down. Both SSE routes are covered.
|
||||
*
|
||||
* The routes import @/lib/auth-guards and @/lib/events, replaced here with
|
||||
* mock.module, which is process wide in bun, so the suite runs in an isolated
|
||||
* child process (same harness as the other route tests).
|
||||
*/
|
||||
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
|
||||
const CHILD_FLAG = "GM_SSE_ROUTES_ISOLATED";
|
||||
const isChild = !!process.env[CHILD_FLAG];
|
||||
|
||||
if (!isChild) {
|
||||
test("SSE routes - isolated child suite", () => {
|
||||
const res = Bun.spawnSync({
|
||||
cmd: [process.execPath, "test", import.meta.path],
|
||||
env: { ...process.env, [CHILD_FLAG]: "1" },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
if (res.exitCode !== 0) {
|
||||
console.error(res.stdout.toString());
|
||||
console.error(res.stderr.toString());
|
||||
}
|
||||
expect(res.exitCode).toBe(0);
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
if (isChild) {
|
||||
mock.module("@/lib/auth-guards", () => ({
|
||||
requireAuthenticatedUserId: async () => ({ userId: "user-1" }),
|
||||
}));
|
||||
mock.module("@/lib/events", () => ({
|
||||
getNewEvents: async () => [],
|
||||
}));
|
||||
}
|
||||
|
||||
const routes = isChild
|
||||
? {
|
||||
events: (await import("./index")).GET,
|
||||
sse: (await import("../sse/index")).GET,
|
||||
}
|
||||
: ({} as Record<string, any>);
|
||||
|
||||
/**
|
||||
* Opens the stream, then closes it the way the node adapter does on a
|
||||
* disconnect: the reader is cancelled first, the abort signal fires second.
|
||||
*/
|
||||
async function disconnect(route: any) {
|
||||
const abort = new AbortController();
|
||||
const request = new Request("http://localhost/api/x", { signal: abort.signal });
|
||||
const response = await route({ request, locals: {} });
|
||||
const reader = response.body.getReader();
|
||||
const first = await reader.read();
|
||||
expect(new TextDecoder().decode(first.value)).toContain(": connected");
|
||||
|
||||
const uncaught: unknown[] = [];
|
||||
const record = (error: unknown) => uncaught.push(error);
|
||||
process.on("uncaughtException", record);
|
||||
try {
|
||||
await reader.cancel();
|
||||
abort.abort();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
} finally {
|
||||
process.off("uncaughtException", record);
|
||||
}
|
||||
return uncaught;
|
||||
}
|
||||
|
||||
describe.skipIf(!isChild)("SSE routes survive a client disconnect (#465)", () => {
|
||||
test("/api/events", async () => {
|
||||
expect(await disconnect(routes.events)).toEqual([]);
|
||||
});
|
||||
|
||||
test("/api/sse", async () => {
|
||||
expect(await disconnect(routes.sse)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
const userId = authResult.userId;
|
||||
|
||||
// Create a new ReadableStream for SSE
|
||||
let cancelStream: (() => void) | undefined;
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
@@ -47,12 +48,30 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Cleanup on close
|
||||
request.signal.addEventListener("abort", () => {
|
||||
// Cleanup on close. When the client disconnects, the node adapter
|
||||
// cancels the stream reader and then fires this abort; on Bun 1.4 the
|
||||
// cancel has already closed the stream, so close() throws
|
||||
// ERR_INVALID_STATE inside an event listener, an uncaught exception
|
||||
// that took the whole process down (#465).
|
||||
let closed = false;
|
||||
const stop = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(heartbeatInterval);
|
||||
controller.close();
|
||||
};
|
||||
request.signal.addEventListener("abort", () => {
|
||||
stop();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed by the reader cancel.
|
||||
}
|
||||
});
|
||||
cancelStream = stop;
|
||||
},
|
||||
cancel() {
|
||||
cancelStream?.();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getGithubStarredListNames,
|
||||
} from "@/lib/github";
|
||||
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
||||
import { parseGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
|
||||
@@ -45,6 +46,9 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
}
|
||||
|
||||
const token = getDecryptedGitHubToken(config);
|
||||
if (parseGitHubAppCredentials(token)) {
|
||||
return jsonResponse({ data: { success: true, lists: [] }, status: 200 });
|
||||
}
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(token, userId, githubUsername);
|
||||
const lists = await getGithubStarredListNames({ octokit });
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { APIRoute } from "astro";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
import { assertSafeOutboundUrl, OutboundUrlError } from "@/lib/utils/outbound-url";
|
||||
import { encodeGitHubAppCredentials, parseGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
import { decryptSourceToken, listSources } from "@/lib/sources";
|
||||
import {
|
||||
SOURCE_PROVIDER_LABELS,
|
||||
createSourceProvider,
|
||||
@@ -34,7 +36,24 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const label = SOURCE_PROVIDER_LABELS[provider];
|
||||
|
||||
try {
|
||||
if (!token) {
|
||||
let credential = token;
|
||||
if (body?.authType === "app") {
|
||||
if (provider !== "github") return json({ success: false, message: "GitHub App requires a GitHub source" }, 400);
|
||||
const source = typeof body.sourceId === "string"
|
||||
? (await listSources(authResult.userId)).find((item) => item.id === body.sourceId && item.provider === "github")
|
||||
: null;
|
||||
const previous = source ? parseGitHubAppCredentials(decryptSourceToken(source.token)) : null;
|
||||
try {
|
||||
credential = encodeGitHubAppCredentials({
|
||||
appId: String(body.appId ?? ""),
|
||||
installationId: String(body.installationId ?? ""),
|
||||
privateKey: typeof body.privateKey === "string" && body.privateKey.trim() ? body.privateKey : previous?.privateKey ?? "",
|
||||
});
|
||||
} catch (error) {
|
||||
return json({ success: false, message: (error as Error).message }, 400);
|
||||
}
|
||||
}
|
||||
if (!credential) {
|
||||
return json({ success: false, message: `${label} token is required` }, 400);
|
||||
}
|
||||
|
||||
@@ -53,7 +72,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
provider,
|
||||
url: instanceUrl,
|
||||
username: typeof username === "string" ? username : "",
|
||||
token,
|
||||
token: credential,
|
||||
});
|
||||
|
||||
const account = await sourceProvider.testConnection();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { createGitHubAppClient } from "@/lib/github-app-client";
|
||||
import { isGitHubAppCredential } from "@/lib/github-app-credentials";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import type { SourceRecord } from "@/lib/sources";
|
||||
import {
|
||||
@@ -101,6 +103,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const source = findSourceForRepository(repository, sources);
|
||||
if (!source || source.provider !== "github") return null;
|
||||
const token = decryptSourceToken(source.token);
|
||||
if (isGitHubAppCredential(token)) {
|
||||
return createGitHubAppClient(token, { userId, apiBaseUrl: resolveGitHubApiBaseUrl(source.url) });
|
||||
}
|
||||
// A tokenless GitHub source still gets an anonymous public client
|
||||
// (60 req/hr) so public-repo metadata mirrors; an empty token must
|
||||
// never reach createGitHubClient, which would send `auth: ""`.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { createGitHubAppClient } from "@/lib/github-app-client";
|
||||
import { isGitHubAppCredential } from "@/lib/github-app-credentials";
|
||||
import type { SourceRecord } from "@/lib/sources";
|
||||
import {
|
||||
decryptSourceToken,
|
||||
@@ -93,6 +95,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const octokitForSource = (source: SourceRecord | null): Octokit | null => {
|
||||
if (!source || source.provider !== "github") return null;
|
||||
const token = decryptSourceToken(source.token);
|
||||
if (isGitHubAppCredential(token)) {
|
||||
return createGitHubAppClient(token, { userId, apiBaseUrl: resolveGitHubApiBaseUrl(source.url) });
|
||||
}
|
||||
// A tokenless GitHub source still gets an anonymous public client
|
||||
// (60 req/hr) so public-repo metadata mirrors; an empty token must
|
||||
// never reach createGitHubClient, which would send `auth: ""`.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import type { SourceRecord } from "@/lib/sources";
|
||||
import { createGitHubAppClient } from "@/lib/github-app-client";
|
||||
import { isGitHubAppCredential } from "@/lib/github-app-credentials";
|
||||
import {
|
||||
decryptSourceToken,
|
||||
findSourceForRepository,
|
||||
@@ -94,6 +96,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const octokitForSource = (source: SourceRecord | null) => {
|
||||
if (!source || source.provider !== "github") return null;
|
||||
const token = decryptSourceToken(source.token);
|
||||
if (isGitHubAppCredential(token)) {
|
||||
return createGitHubAppClient(token, { userId, apiBaseUrl: resolveGitHubApiBaseUrl(source.url) });
|
||||
}
|
||||
// A tokenless GitHub source still gets an anonymous public client
|
||||
// (60 req/hr) so public-repo metadata mirrors; an empty token must
|
||||
// never reach createGitHubClient, which would send `auth: ""`.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Route tests for GET /api/organizations/:id/preview-repositories.
|
||||
*
|
||||
* The organization tag-rule preview offers these repositories in a picker, so
|
||||
* what matters is that the list is scoped to the caller, limited to the rows
|
||||
* that belong to the organization, and shaped with a display label.
|
||||
*
|
||||
* NOTE: this file replaces @/lib/db with a mock. bun's mock.module is
|
||||
* process-wide and leaks into other test files, so in the shared test process
|
||||
* this file registers NOTHING: it re-runs itself in an isolated child process
|
||||
* (the same harness as organizations/[id].test.ts).
|
||||
*/
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
const CHILD_FLAG = "GM_ORG_PREVIEW_REPOS_ISOLATED";
|
||||
const isChild = !!process.env[CHILD_FLAG];
|
||||
|
||||
let orgRows: any[] = [];
|
||||
let repoRows: any[] = [];
|
||||
const mockOrganizations = {};
|
||||
const mockRepositories = {};
|
||||
|
||||
if (!isChild) {
|
||||
test("organization preview-repositories route - isolated child suite", () => {
|
||||
const res = Bun.spawnSync({
|
||||
cmd: [process.execPath, "test", import.meta.path],
|
||||
env: { ...process.env, [CHILD_FLAG]: "1" },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
if (res.exitCode !== 0) {
|
||||
console.error(res.stdout.toString());
|
||||
console.error(res.stderr.toString());
|
||||
}
|
||||
expect(res.exitCode).toBe(0);
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
if (isChild) {
|
||||
// A select chain that answers both query shapes the route runs: the
|
||||
// organization lookup ends at limit(), the repository listing passes through
|
||||
// orderBy() first. The table passed to from() is one of the mocked table
|
||||
// sentinels, which is how the right row set is chosen.
|
||||
const mockDb = {
|
||||
select: mock(() => ({
|
||||
from: mock((table: any) => ({
|
||||
where: mock(() => ({
|
||||
limit: mock(() =>
|
||||
Promise.resolve(table === mockOrganizations ? orgRows : repoRows)
|
||||
),
|
||||
orderBy: mock(() => ({
|
||||
limit: mock(() =>
|
||||
Promise.resolve(table === mockRepositories ? repoRows : orgRows)
|
||||
),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
// The route pulls in the auth module, which imports every table, so the mock
|
||||
// has to name them all.
|
||||
mock.module("@/lib/db", () => ({
|
||||
db: mockDb,
|
||||
repositories: mockRepositories,
|
||||
configs: {},
|
||||
organizations: mockOrganizations,
|
||||
users: {},
|
||||
mirrorJobs: {},
|
||||
events: {},
|
||||
accounts: {},
|
||||
sessions: {},
|
||||
verificationTokens: {},
|
||||
verifications: {},
|
||||
oauthClients: {},
|
||||
oauthAccessTokens: {},
|
||||
oauthRefreshTokens: {},
|
||||
oauthConsents: {},
|
||||
jwkss: {},
|
||||
ssoProviders: {},
|
||||
apikeys: {},
|
||||
rateLimits: {},
|
||||
}));
|
||||
}
|
||||
|
||||
const { GET } = isChild
|
||||
? await import("./preview-repositories")
|
||||
: { GET: undefined as any };
|
||||
|
||||
function call(id?: string) {
|
||||
return GET({
|
||||
params: id ? { id } : {},
|
||||
request: new Request("http://localhost/api/organizations/preview-repositories"),
|
||||
locals: { session: { userId: "user-1" } },
|
||||
} as any);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
orgRows = [{ name: "octo-org" }];
|
||||
repoRows = [
|
||||
{ id: "r1", name: "hello", owner: "octo-org" },
|
||||
{ id: "r2", name: "world", owner: "octo-org" },
|
||||
];
|
||||
});
|
||||
|
||||
describe.skipIf(!isChild)("GET /api/organizations/:id/preview-repositories", () => {
|
||||
test("lists the organization's repositories with a display label", async () => {
|
||||
const response = await call("org-1");
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.organization).toBe("octo-org");
|
||||
expect(data.repositories).toEqual([
|
||||
{ id: "r1", label: "octo-org/hello" },
|
||||
{ id: "r2", label: "octo-org/world" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("answers 404 for an organization the account does not own", async () => {
|
||||
orgRows = [];
|
||||
const response = await call("org-1");
|
||||
expect(response.status).toBe(404);
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
|
||||
test("requires an organization id", async () => {
|
||||
const response = await call();
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns an empty list when the organization mirrors nothing yet", async () => {
|
||||
repoRows = [];
|
||||
const response = await call("org-1");
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.repositories).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { db, organizations, repositories } from "@/lib/db";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Cap on how many repositories a single organization override can preview. The
|
||||
* list only feeds a dropdown, so it is trimmed rather than left to grow without
|
||||
* bound; the newest names are the ones a user is most likely to reach for.
|
||||
*/
|
||||
const MAX_PREVIEW_REPOSITORIES = 200;
|
||||
|
||||
/**
|
||||
* List the repositories an organization override covers, so its release-rule
|
||||
* dialog can preview one of them.
|
||||
*
|
||||
* A global rule has no single repository and a repository override names one,
|
||||
* but an organization rule fans out over every repository the organization
|
||||
* mirrors. This route hands the rule editor the repositories it can offer in a
|
||||
* picker; the per-repository releases then come from the existing
|
||||
* /api/repositories/:id/releases route once one is chosen.
|
||||
*
|
||||
* GET /api/organizations/:id/preview-repositories
|
||||
*/
|
||||
export const GET: APIRoute = async ({ params, request, locals }) => {
|
||||
try {
|
||||
const authResult = await requireAuthenticatedUserId({ request, locals });
|
||||
if ("response" in authResult) return authResult.response;
|
||||
const userId = authResult.userId;
|
||||
|
||||
const orgId = params.id;
|
||||
if (!orgId) {
|
||||
return jsonResponse({ data: { success: false, error: "Organization ID is required" }, status: 400 });
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select({ name: organizations.name })
|
||||
.from(organizations)
|
||||
.where(and(eq(organizations.id, orgId), eq(organizations.userId, userId)))
|
||||
.limit(1);
|
||||
if (!org) {
|
||||
return jsonResponse({ data: { success: false, error: "Organization not found" }, status: 404 });
|
||||
}
|
||||
|
||||
// A repository belongs to the organization when its `organization` column
|
||||
// carries the organization name, the same link the mirror paths use.
|
||||
const rows = await db
|
||||
.select({
|
||||
id: repositories.id,
|
||||
name: repositories.name,
|
||||
owner: repositories.owner,
|
||||
})
|
||||
.from(repositories)
|
||||
.where(and(eq(repositories.userId, userId), eq(repositories.organization, org.name)))
|
||||
.orderBy(asc(repositories.name))
|
||||
.limit(MAX_PREVIEW_REPOSITORIES);
|
||||
|
||||
return jsonResponse({
|
||||
data: {
|
||||
success: true,
|
||||
organization: org.name,
|
||||
repositories: rows.map((row) => ({
|
||||
id: row.id,
|
||||
label: `${row.owner}/${row.name}`,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "List organization preview repositories", 500);
|
||||
}
|
||||
};
|
||||
@@ -6,6 +6,9 @@ import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
import { RateLimitManager } from "@/lib/rate-limit-manager";
|
||||
import { createGitHubClient } from "@/lib/github";
|
||||
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
||||
import { createGitHubAppClient } from "@/lib/github-app-client";
|
||||
import { isGitHubAppCredential } from "@/lib/github-app-credentials";
|
||||
import { resolveGitHubApiBaseUrl } from "@/lib/sources";
|
||||
import { configs } from "@/lib/db";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
|
||||
@@ -32,7 +35,12 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
if (config && config.githubConfig?.token && resolveSourceProviderKind(config) === "github") {
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
const octokit = isGitHubAppCredential(decryptedToken)
|
||||
? createGitHubAppClient(decryptedToken, {
|
||||
userId,
|
||||
apiBaseUrl: resolveGitHubApiBaseUrl(config.githubConfig?.url),
|
||||
})
|
||||
: createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
|
||||
// This will update the rate limit in the database
|
||||
await RateLimitManager.checkGitHubRateLimit(octokit, userId);
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Route tests for GET /api/repositories/:id/releases.
|
||||
*
|
||||
* The release-rule editor judges its draft filters against whatever this route
|
||||
* reports, so the two things that matter are the owner the releases are read
|
||||
* from and the list that comes back. The destination itself is driven through a
|
||||
* fake fetch, which is what the shared HTTP client uses.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
import { createMockResponse, mockFetch } from "@/tests/mock-fetch";
|
||||
|
||||
const CHILD_FLAG = "GM_RELEASES_ROUTE_ISOLATED";
|
||||
const isChild = !!process.env[CHILD_FLAG];
|
||||
|
||||
if (!isChild) {
|
||||
test("repositories [id] releases route — isolated child suite", () => {
|
||||
const res = Bun.spawnSync({
|
||||
cmd: [process.execPath, "test", import.meta.path],
|
||||
env: { ...process.env, [CHILD_FLAG]: "1" },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
if (res.exitCode !== 0) {
|
||||
console.error(res.stdout.toString());
|
||||
console.error(res.stderr.toString());
|
||||
}
|
||||
expect(res.exitCode).toBe(0);
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
let repoRows: any[] = [];
|
||||
let configRows: any[] = [];
|
||||
const mockRepositories = {};
|
||||
const mockConfigs = {};
|
||||
|
||||
const mockDb = {
|
||||
select: mock(() => ({
|
||||
from: mock((table: any) => ({
|
||||
where: mock(() => ({
|
||||
limit: mock(() => Promise.resolve(table === mockConfigs ? configRows : repoRows)),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
// bun's mock.module is process-wide and leaks into other test files, so in the
|
||||
// shared test process this file registers nothing: it re-runs itself in an
|
||||
// isolated child process (the same harness as organizations/[id].test.ts).
|
||||
const mockGetOwner = mock(async () => "mirrors");
|
||||
|
||||
if (isChild) {
|
||||
// The route pulls in the auth module, which imports every table, so the
|
||||
// mock has to name them all.
|
||||
mock.module("@/lib/db", () => ({
|
||||
db: mockDb,
|
||||
repositories: mockRepositories,
|
||||
configs: mockConfigs,
|
||||
organizations: {},
|
||||
users: {},
|
||||
mirrorJobs: {},
|
||||
events: {},
|
||||
accounts: {},
|
||||
sessions: {},
|
||||
verificationTokens: {},
|
||||
verifications: {},
|
||||
oauthClients: {},
|
||||
oauthAccessTokens: {},
|
||||
oauthRefreshTokens: {},
|
||||
oauthConsents: {},
|
||||
jwkss: {},
|
||||
ssoProviders: {},
|
||||
apikeys: {},
|
||||
rateLimits: {},
|
||||
}));
|
||||
|
||||
// Only the owner lookup is needed from the destination client; the releases
|
||||
// themselves arrive through the fake fetch below.
|
||||
mock.module("@/lib/gitea", () => ({
|
||||
getGiteaRepoOwnerAsync: mockGetOwner,
|
||||
getGiteaRepoOwner: () => "mirrors",
|
||||
}));
|
||||
}
|
||||
|
||||
const { GET } = isChild
|
||||
? await import("./releases")
|
||||
: { GET: undefined as any };
|
||||
|
||||
const repoRow = {
|
||||
id: "r1",
|
||||
userId: "user-1",
|
||||
name: "hello",
|
||||
fullName: "octocat/hello",
|
||||
owner: "octocat",
|
||||
organization: null,
|
||||
isStarred: false,
|
||||
destinationOrg: null,
|
||||
mirroredLocation: "mirrors/hello",
|
||||
status: "mirrored",
|
||||
destinationProvider: "gitea",
|
||||
destinationUrl: "https://gitea.example.com",
|
||||
};
|
||||
|
||||
const configRow = {
|
||||
id: "c1",
|
||||
userId: "user-1",
|
||||
isActive: true,
|
||||
githubConfig: { token: "gh", owner: "octocat", mirrorStrategy: "preserve" },
|
||||
giteaConfig: {
|
||||
url: "https://gitea.example.com",
|
||||
token: "gitea-token",
|
||||
defaultOwner: "me",
|
||||
provider: "gitea",
|
||||
},
|
||||
};
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
let requestedUrls: string[] = [];
|
||||
|
||||
/** Serve one response per page, in order. */
|
||||
function servePages(pages: unknown[]) {
|
||||
requestedUrls = [];
|
||||
let index = 0;
|
||||
globalThis.fetch = mockFetch(async (url: string) => {
|
||||
requestedUrls.push(url);
|
||||
const batch = pages[index++];
|
||||
return batch instanceof Response ? batch : createMockResponse(batch);
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
function batch(count: number, offset = 0) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
tag_name: `v${offset + i}`,
|
||||
prerelease: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function call(id = "r1") {
|
||||
return GET({
|
||||
params: { id },
|
||||
request: new Request(`http://localhost/api/repositories/${id}/releases`),
|
||||
locals: { session: { userId: "user-1" } },
|
||||
} as any);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
repoRows = [repoRow];
|
||||
configRows = [configRow];
|
||||
mockGetOwner.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
describe.skipIf(!isChild)("GET /api/repositories/:id/releases", () => {
|
||||
test("reads the destination at the place the mirror was put", async () => {
|
||||
servePages([
|
||||
[
|
||||
{ tag_name: "v1.0", prerelease: false },
|
||||
{ tag_name: "v2.0-rc1", prerelease: true },
|
||||
],
|
||||
]);
|
||||
|
||||
const response = await call();
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.destination).toBe("mirrors/hello");
|
||||
expect(data.releases).toEqual([
|
||||
{ tag: "v1.0", prerelease: false },
|
||||
{ tag: "v2.0-rc1", prerelease: true },
|
||||
]);
|
||||
expect(requestedUrls[0]).toContain("/api/v1/repos/mirrors/hello/releases");
|
||||
expect(requestedUrls[0]).toContain("per_page=100");
|
||||
expect(mockGetOwner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("resolves the owner the way a sync would before the mirror is placed", async () => {
|
||||
repoRows = [{ ...repoRow, mirroredLocation: null }];
|
||||
servePages([[]]);
|
||||
|
||||
const response = await call();
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.destination).toBe("mirrors/hello");
|
||||
expect(data.releases).toEqual([]);
|
||||
expect(mockGetOwner).toHaveBeenCalledTimes(1);
|
||||
expect(requestedUrls[0]).toContain("/api/v1/repos/mirrors/hello/releases");
|
||||
});
|
||||
|
||||
test("answers 404 for a repository that is not the account's", async () => {
|
||||
repoRows = [];
|
||||
servePages([[]]);
|
||||
|
||||
const response = await call();
|
||||
expect(response.status).toBe(404);
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(false);
|
||||
expect(requestedUrls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("refuses before the destination is configured", async () => {
|
||||
configRows = [
|
||||
{ ...configRow, giteaConfig: { url: "", token: "", defaultOwner: "" } },
|
||||
];
|
||||
servePages([[]]);
|
||||
|
||||
const response = await call();
|
||||
expect(response.status).toBe(400);
|
||||
expect(requestedUrls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("says which destination could not be read", async () => {
|
||||
servePages([
|
||||
createMockResponse({ message: "not found" }, { ok: false, status: 404, statusText: "Not Found" }),
|
||||
]);
|
||||
|
||||
const response = await call();
|
||||
expect(response.status).toBe(404);
|
||||
const data = await response.json();
|
||||
expect(data.error).toContain("mirrors/hello");
|
||||
});
|
||||
|
||||
test("follows the destination paging until a short page", async () => {
|
||||
servePages([batch(100), batch(2, 100)]);
|
||||
|
||||
const response = await call();
|
||||
const data = await response.json();
|
||||
expect(data.releases).toHaveLength(102);
|
||||
expect(data.truncated).toBe(false);
|
||||
expect(requestedUrls).toHaveLength(2);
|
||||
expect(requestedUrls[1]).toContain("page=2");
|
||||
});
|
||||
|
||||
test("stops at the cap and marks the list as shortened", async () => {
|
||||
servePages([batch(100), batch(100, 100), batch(100, 200), batch(1, 300)]);
|
||||
|
||||
const response = await call();
|
||||
const data = await response.json();
|
||||
expect(data.releases).toHaveLength(300);
|
||||
expect(data.truncated).toBe(true);
|
||||
expect(requestedUrls).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db, configs, repositories } from "@/lib/db";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import type { Config } from "@/types/config";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
import { getGiteaRepoOwnerAsync } from "@/lib/gitea";
|
||||
import { decryptConfigTokens } from "@/lib/utils/config-encryption";
|
||||
import { httpGet } from "@/lib/http-client";
|
||||
|
||||
/** Gitea's per-page maximum; the cap below is how far the preview looks. */
|
||||
const PAGE_SIZE = 100;
|
||||
const MAX_RELEASES = 300;
|
||||
|
||||
type DestinationRelease = {
|
||||
tag: string;
|
||||
prerelease: boolean | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* List the releases the destination already holds for one mirrored repository.
|
||||
*
|
||||
* The release-rule editor re-runs the draft filters against this list so a user
|
||||
* can see which published releases a Save would keep and which the next sync
|
||||
* would delete. The owner is taken from `mirroredLocation` when the mirror has
|
||||
* already been placed; otherwise it is resolved the way a sync would resolve it.
|
||||
*
|
||||
* GET /api/repositories/:id/releases
|
||||
*/
|
||||
export const GET: APIRoute = async ({ params, request, locals }) => {
|
||||
try {
|
||||
const authResult = await requireAuthenticatedUserId({ request, locals });
|
||||
if ("response" in authResult) return authResult.response;
|
||||
const userId = authResult.userId;
|
||||
|
||||
const repoId = params.id;
|
||||
if (!repoId) {
|
||||
return jsonResponse({ data: { success: false, error: "Repository ID is required" }, status: 400 });
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(and(eq(repositories.id, repoId), eq(repositories.userId, userId)))
|
||||
.limit(1);
|
||||
if (!row) {
|
||||
return jsonResponse({ data: { success: false, error: "Repository not found" }, status: 404 });
|
||||
}
|
||||
const repository = row as Repository;
|
||||
|
||||
const [config] = await db
|
||||
.select()
|
||||
.from(configs)
|
||||
.where(and(eq(configs.userId, userId), eq(configs.isActive, true)))
|
||||
.limit(1);
|
||||
if (!config) {
|
||||
return jsonResponse({ data: { success: false, error: "No active configuration found" }, status: 404 });
|
||||
}
|
||||
if (!config.giteaConfig?.url || !config.giteaConfig?.token || !config.giteaConfig?.defaultOwner) {
|
||||
return jsonResponse({
|
||||
data: { success: false, error: "Configure the destination URL, username and token first" },
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const partial = config as unknown as Partial<Config>;
|
||||
const location = (repository.mirroredLocation ?? "").trim();
|
||||
const slash = location.indexOf("/");
|
||||
let owner = slash > 0 ? location.slice(0, slash) : "";
|
||||
const repoName = slash > 0 ? location.slice(slash + 1) : repository.name;
|
||||
if (!owner) {
|
||||
const fallbackOwner = config.giteaConfig.defaultOwner;
|
||||
owner = await getGiteaRepoOwnerAsync({ config: partial, repository }).catch(
|
||||
() => fallbackOwner
|
||||
);
|
||||
}
|
||||
|
||||
const token = decryptConfigTokens(config as Config).giteaConfig.token;
|
||||
const base = `${config.giteaConfig.url.replace(/\/+$/, "")}/api/v1/repos/${encodeURIComponent(
|
||||
owner
|
||||
)}/${encodeURIComponent(repoName)}/releases`;
|
||||
|
||||
const releases: DestinationRelease[] = [];
|
||||
let truncated = false;
|
||||
for (let page = 1; ; page++) {
|
||||
const response = await httpGet<DestinationReleaseGitea[]>(
|
||||
`${base}?per_page=${PAGE_SIZE}&page=${page}`,
|
||||
{ Authorization: `token ${token}` }
|
||||
).catch(() => null);
|
||||
const batch = response?.data;
|
||||
if (!Array.isArray(batch)) {
|
||||
if (page === 1) {
|
||||
return jsonResponse({
|
||||
data: {
|
||||
success: false,
|
||||
error: `No releases could be read for ${owner}/${repoName} in the destination`,
|
||||
},
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
for (const release of batch) {
|
||||
const tag = typeof release.tag_name === "string" ? release.tag_name : "";
|
||||
if (!tag) continue;
|
||||
releases.push({
|
||||
tag,
|
||||
prerelease: typeof release.prerelease === "boolean" ? release.prerelease : null,
|
||||
});
|
||||
}
|
||||
if (batch.length < PAGE_SIZE) break;
|
||||
if (releases.length >= MAX_RELEASES) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
data: {
|
||||
success: true,
|
||||
destination: `${owner}/${repoName}`,
|
||||
releases,
|
||||
truncated,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "List destination releases", 500);
|
||||
}
|
||||
};
|
||||
|
||||
/** Shape Gitea reports for one release; only the fields the preview needs. */
|
||||
type DestinationReleaseGitea = {
|
||||
tag_name?: string;
|
||||
prerelease?: boolean | null;
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Route tests for GET /api/repositories/preview-repositories.
|
||||
*
|
||||
* The global tag-rule preview offers these repositories in a picker, so what
|
||||
* matters is that the list is scoped to the caller and shaped with a display
|
||||
* label.
|
||||
*
|
||||
* NOTE: this file replaces @/lib/db with a mock. bun's mock.module is
|
||||
* process-wide and leaks into other test files, so in the shared test process
|
||||
* this file registers NOTHING: it re-runs itself in an isolated child process
|
||||
* (the same harness as organizations/[id].test.ts).
|
||||
*/
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
const CHILD_FLAG = "GM_GLOBAL_PREVIEW_REPOS_ISOLATED";
|
||||
const isChild = !!process.env[CHILD_FLAG];
|
||||
|
||||
let repoRows: any[] = [];
|
||||
const mockRepositories = {};
|
||||
|
||||
if (!isChild) {
|
||||
test("global preview-repositories route - isolated child suite", () => {
|
||||
const res = Bun.spawnSync({
|
||||
cmd: [process.execPath, "test", import.meta.path],
|
||||
env: { ...process.env, [CHILD_FLAG]: "1" },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
if (res.exitCode !== 0) {
|
||||
console.error(res.stdout.toString());
|
||||
console.error(res.stderr.toString());
|
||||
}
|
||||
expect(res.exitCode).toBe(0);
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
if (isChild) {
|
||||
const mockDb = {
|
||||
select: mock(() => ({
|
||||
from: mock(() => ({
|
||||
where: mock(() => ({
|
||||
orderBy: mock(() => ({
|
||||
limit: mock(() => Promise.resolve(repoRows)),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
// The route pulls in the auth module, which imports every table, so the mock
|
||||
// has to name them all.
|
||||
mock.module("@/lib/db", () => ({
|
||||
db: mockDb,
|
||||
repositories: mockRepositories,
|
||||
configs: {},
|
||||
organizations: {},
|
||||
users: {},
|
||||
mirrorJobs: {},
|
||||
events: {},
|
||||
accounts: {},
|
||||
sessions: {},
|
||||
verificationTokens: {},
|
||||
verifications: {},
|
||||
oauthClients: {},
|
||||
oauthAccessTokens: {},
|
||||
oauthRefreshTokens: {},
|
||||
oauthConsents: {},
|
||||
jwkss: {},
|
||||
ssoProviders: {},
|
||||
apikeys: {},
|
||||
rateLimits: {},
|
||||
}));
|
||||
}
|
||||
|
||||
const { GET } = isChild
|
||||
? await import("./preview-repositories")
|
||||
: { GET: undefined as any };
|
||||
|
||||
function call() {
|
||||
return GET({
|
||||
params: {},
|
||||
request: new Request("http://localhost/api/repositories/preview-repositories"),
|
||||
locals: { session: { userId: "user-1" } },
|
||||
} as any);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
repoRows = [
|
||||
{ id: "r1", name: "hello", owner: "octocat" },
|
||||
{ id: "r2", name: "world", owner: "octo-org" },
|
||||
];
|
||||
});
|
||||
|
||||
describe.skipIf(!isChild)("GET /api/repositories/preview-repositories", () => {
|
||||
test("lists the account's repositories with a display label", async () => {
|
||||
const response = await call();
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.repositories).toEqual([
|
||||
{ id: "r1", label: "octocat/hello" },
|
||||
{ id: "r2", label: "octo-org/world" },
|
||||
]);
|
||||
expect(data.truncated).toBe(false);
|
||||
});
|
||||
|
||||
test("returns an empty list when nothing is mirrored yet", async () => {
|
||||
repoRows = [];
|
||||
const response = await call();
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data.repositories).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db, repositories } from "@/lib/db";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Cap on how many repositories the global preview picker offers. The list only
|
||||
* feeds a dropdown, so it is trimmed rather than left to grow without bound.
|
||||
*/
|
||||
const MAX_PREVIEW_REPOSITORIES = 200;
|
||||
|
||||
/**
|
||||
* List the account's repositories so the global release-rule dialog can
|
||||
* preview its draft patterns against one of them.
|
||||
*
|
||||
* Global rules hit every repository, so a typo there costs the most; the same
|
||||
* picker the organization dialog uses applies here, just over the whole
|
||||
* account. The per-repository releases then come from the existing
|
||||
* /api/repositories/:id/releases route once one is chosen.
|
||||
*
|
||||
* GET /api/repositories/preview-repositories
|
||||
*/
|
||||
export const GET: APIRoute = async ({ request, locals }) => {
|
||||
try {
|
||||
const authResult = await requireAuthenticatedUserId({ request, locals });
|
||||
if ("response" in authResult) return authResult.response;
|
||||
const userId = authResult.userId;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: repositories.id,
|
||||
name: repositories.name,
|
||||
owner: repositories.owner,
|
||||
})
|
||||
.from(repositories)
|
||||
.where(eq(repositories.userId, userId))
|
||||
.orderBy(asc(repositories.name))
|
||||
.limit(MAX_PREVIEW_REPOSITORIES);
|
||||
|
||||
return jsonResponse({
|
||||
data: {
|
||||
success: true,
|
||||
truncated: rows.length >= MAX_PREVIEW_REPOSITORIES,
|
||||
repositories: rows.map((row) => ({
|
||||
id: row.id,
|
||||
label: `${row.owner}/${row.name}`,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "List preview repositories", 500);
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { encodeGitHubAppCredentials, parseGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
|
||||
let sourceRows: any[] = [];
|
||||
let repositoryCount = 0;
|
||||
@@ -138,6 +139,66 @@ beforeEach(() => {
|
||||
repositoryCount = 2;
|
||||
});
|
||||
|
||||
test("editing App IDs preserves the private key without returning it", async () => {
|
||||
const privateKey = "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----";
|
||||
sourceRows[0].token = encrypted(encodeGitHubAppCredentials({ appId: "123", installationId: "456", privateKey }));
|
||||
const response = await put("source-1", { authType: "app", appId: "123", installationId: "789", privateKey: "" });
|
||||
expect(response.status).toBe(200);
|
||||
expect(parseGitHubAppCredentials(sourceRows[0].token.slice(4))).toEqual({ appId: "123", installationId: "789", privateKey });
|
||||
const body = await response.json();
|
||||
expect(body.source).toMatchObject({ authType: "app", installationId: "789", token: "" });
|
||||
expect(JSON.stringify(body)).not.toContain("PRIVATE KEY");
|
||||
});
|
||||
|
||||
describe("PUT /api/sources/:id with stored GitHub App credentials", () => {
|
||||
const privateKey = "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----";
|
||||
let appToken: string;
|
||||
|
||||
beforeEach(() => {
|
||||
appToken = encrypted(encodeGitHubAppCredentials({ appId: "123", installationId: "456", privateKey }));
|
||||
sourceRows[0].token = appToken;
|
||||
});
|
||||
|
||||
test("refuses a provider switch that would keep the App credentials", async () => {
|
||||
const response = await put("source-1", {
|
||||
provider: "gitlab",
|
||||
url: "https://gitlab.example.com",
|
||||
confirmSourceChange: true,
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
const text = await response.text();
|
||||
expect(text).toContain("GitLab token");
|
||||
expect(text).not.toContain("PRIVATE KEY");
|
||||
expect(sourceRows[0]).toMatchObject({ provider: "github", token: appToken });
|
||||
});
|
||||
|
||||
test("refuses switching to token auth without a token", async () => {
|
||||
const response = await put("source-1", { authType: "pat" });
|
||||
expect(response.status).toBe(400);
|
||||
expect(sourceRows[0].token).toBe(appToken);
|
||||
});
|
||||
|
||||
test("a provider switch with a new token replaces the App credentials", async () => {
|
||||
const response = await put("source-1", {
|
||||
provider: "gitlab",
|
||||
url: "https://gitlab.example.com",
|
||||
token: "glpat-new",
|
||||
confirmSourceChange: true,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(sourceRows[0]).toMatchObject({ provider: "gitlab", token: encrypted("glpat-new") });
|
||||
});
|
||||
|
||||
test("refuses App credentials sent through the token field", async () => {
|
||||
sourceRows[0].token = encrypted("ghp_old");
|
||||
const response = await put("source-1", {
|
||||
token: encodeGitHubAppCredentials({ appId: "1", installationId: "2", privateKey }),
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(sourceRows[0].token).toBe(encrypted("ghp_old"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/sources/:id", () => {
|
||||
test("answers 404 for a source that is not the account's", async () => {
|
||||
const response = await put("source-404", { name: "Renamed" });
|
||||
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
updateSource,
|
||||
} from "@/lib/sources";
|
||||
import { toSourceApiItem } from "@/lib/sources-api";
|
||||
import {
|
||||
encodeGitHubAppCredentials,
|
||||
isGitHubAppCredential,
|
||||
parseGitHubAppCredentials,
|
||||
} from "@/lib/github-app-credentials";
|
||||
import { decryptSourceToken } from "@/lib/sources";
|
||||
|
||||
/** Repositories imported from this source; they are what locks it. */
|
||||
async function countRepositoriesForSource(
|
||||
@@ -59,6 +65,10 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
||||
url?: unknown;
|
||||
username?: unknown;
|
||||
token?: unknown;
|
||||
authType?: unknown;
|
||||
appId?: unknown;
|
||||
installationId?: unknown;
|
||||
privateKey?: unknown;
|
||||
enabled?: unknown;
|
||||
confirmSourceChange?: unknown;
|
||||
};
|
||||
@@ -75,10 +85,40 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
||||
const provider: SourceProviderKind = isSourceProviderKind(body.provider)
|
||||
? body.provider
|
||||
: existing.provider;
|
||||
|
||||
// GitLab and Gitea sources point at an instance; GitHub needs no URL.
|
||||
if (body.authType !== undefined && body.authType !== "app" && body.authType !== "pat") return jsonError("Unknown authentication method.", 400);
|
||||
if (body.authType === "app" && provider !== "github") return jsonError("GitHub App authentication requires a GitHub source.", 400);
|
||||
let token = typeof body.token === "string" ? body.token : undefined;
|
||||
if (body.authType === "app") {
|
||||
const prior = existing.provider === "github" ? parseGitHubAppCredentials(decryptSourceToken(existing.token)) : null;
|
||||
try {
|
||||
token = encodeGitHubAppCredentials({
|
||||
appId: String(body.appId ?? ""),
|
||||
installationId: String(body.installationId ?? ""),
|
||||
privateKey: typeof body.privateKey === "string" && body.privateKey.trim() ? body.privateKey : prior?.privateKey ?? "",
|
||||
});
|
||||
} catch (error) {
|
||||
return jsonError((error as Error).message, 400);
|
||||
}
|
||||
} else if (isGitHubAppCredential(token)) {
|
||||
return jsonError("Use the GitHub App fields to enter App credentials.", 400);
|
||||
}
|
||||
// The stored App credential holds the private key. Moving the source to
|
||||
// another provider or to token auth without a new token would keep it,
|
||||
// return it from the API and send it to the new host as the token.
|
||||
if (
|
||||
body.authType !== "app" &&
|
||||
(provider !== "github" || body.authType === "pat") &&
|
||||
!token?.trim() &&
|
||||
isGitHubAppCredential(decryptSourceToken(existing.token))
|
||||
) {
|
||||
return jsonError(
|
||||
`Enter a ${SOURCE_PROVIDER_LABELS[provider]} token to stop using the GitHub App credentials on this source.`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// All sources may point at their own instance; validate explicit URLs.
|
||||
if (
|
||||
provider !== "github" &&
|
||||
typeof body.url === "string" &&
|
||||
body.url.trim() &&
|
||||
!isValidSourceUrl(body.url)
|
||||
@@ -88,7 +128,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
||||
400
|
||||
);
|
||||
}
|
||||
if (provider !== "github" && typeof body.url === "string" && body.url.trim()) {
|
||||
if (typeof body.url === "string" && body.url.trim()) {
|
||||
const blocked = await blockedOutboundUrlReason(body.url);
|
||||
if (blocked) return jsonError(blocked, 400);
|
||||
}
|
||||
@@ -118,7 +158,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
||||
url: typeof body.url === "string" ? body.url : undefined,
|
||||
username: typeof body.username === "string" ? body.username : undefined,
|
||||
// An empty token keeps the stored one (the service preserves it).
|
||||
token: typeof body.token === "string" ? body.token : undefined,
|
||||
token,
|
||||
enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { encodeGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
|
||||
let sourceRows: any[] = [];
|
||||
let countRows: Array<{ sourceId: string | null; count: number }> = [];
|
||||
@@ -135,6 +136,22 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("GET /api/sources", () => {
|
||||
test("does not return a GitHub App private key", async () => {
|
||||
sourceRows = [{ id: "app-1", userId: "user-1", name: "App", provider: "github", url: "https://github.com", username: "acme", token: encrypted(encodeGitHubAppCredentials({ appId: "123", installationId: "456", privateKey: "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----" })), enabled: true }];
|
||||
const response = await get();
|
||||
const body = await response.json();
|
||||
expect(body.sources[0]).toMatchObject({ authType: "app", appId: "123", installationId: "456", token: "" });
|
||||
expect(JSON.stringify(body)).not.toContain("PRIVATE KEY");
|
||||
});
|
||||
|
||||
test("never returns App credentials stored on a non-GitHub source", async () => {
|
||||
sourceRows = [{ id: "gl-1", userId: "user-1", name: "GitLab", provider: "gitlab", url: "https://gitlab.example.com", username: "acme", token: encrypted(encodeGitHubAppCredentials({ appId: "123", installationId: "456", privateKey: "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----" })), enabled: true }];
|
||||
const response = await get();
|
||||
const body = await response.json();
|
||||
expect(body.sources[0]).toMatchObject({ provider: "gitlab", authType: "pat", token: "" });
|
||||
expect(JSON.stringify(body)).not.toContain("PRIVATE KEY");
|
||||
});
|
||||
|
||||
test("lists sources with repository counts, lock state and decrypted tokens", async () => {
|
||||
sourceRows = [
|
||||
{
|
||||
@@ -190,6 +207,15 @@ describe("GET /api/sources", () => {
|
||||
});
|
||||
|
||||
describe("POST /api/sources", () => {
|
||||
test("creates an App source with encrypted credential data and no private key in the response", async () => {
|
||||
const response = await post({ provider: "github", username: "acme", authType: "app", appId: "123", installationId: "456", privateKey: "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----" });
|
||||
expect(response.status).toBe(201);
|
||||
expect(sourceRows[0].token).toContain("enc:github-app:v1:");
|
||||
const body = await response.json();
|
||||
expect(body.source).toMatchObject({ authType: "app", appId: "123", installationId: "456", token: "" });
|
||||
expect(JSON.stringify(body)).not.toContain("PRIVATE KEY");
|
||||
});
|
||||
|
||||
test("rejects an unknown source provider", async () => {
|
||||
const response = await post({ provider: "bitbucket", username: "me", token: "t" });
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
import { blockedOutboundUrlReason } from "@/lib/utils/outbound-url";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { createSource } from "@/lib/sources";
|
||||
import { encodeGitHubAppCredentials, isGitHubAppCredential } from "@/lib/github-app-credentials";
|
||||
import {
|
||||
isDuplicateSourceError,
|
||||
loadSourceApiItems,
|
||||
@@ -43,6 +44,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
url?: unknown;
|
||||
username?: unknown;
|
||||
token?: unknown;
|
||||
authType?: unknown;
|
||||
appId?: unknown;
|
||||
installationId?: unknown;
|
||||
privateKey?: unknown;
|
||||
enabled?: unknown;
|
||||
};
|
||||
|
||||
@@ -56,9 +61,26 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
);
|
||||
}
|
||||
const provider = body.provider;
|
||||
if (body.authType !== undefined && body.authType !== "app" && body.authType !== "pat") {
|
||||
return new Response(JSON.stringify({ success: false, message: "Unknown authentication method." }), { status: 400 });
|
||||
}
|
||||
if (body.authType === "app" && provider !== "github") {
|
||||
return new Response(JSON.stringify({ success: false, message: "GitHub App authentication requires a GitHub source." }), { status: 400 });
|
||||
}
|
||||
let token: string | undefined;
|
||||
try {
|
||||
token = body.authType === "app"
|
||||
? encodeGitHubAppCredentials({ appId: String(body.appId ?? ""), installationId: String(body.installationId ?? ""), privateKey: String(body.privateKey ?? "") })
|
||||
: typeof body.token === "string" ? body.token : undefined;
|
||||
if (body.authType !== "app" && isGitHubAppCredential(token)) {
|
||||
throw new Error("Use the GitHub App fields to enter App credentials.");
|
||||
}
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ success: false, message: (error as Error).message }), { status: 400 });
|
||||
}
|
||||
|
||||
// GitLab and Gitea sources point at an instance; GitHub needs no URL.
|
||||
if (provider !== "github") {
|
||||
// All sources may point at their own instance; validate explicit URLs.
|
||||
{
|
||||
const rawSourceUrl = typeof body.url === "string" ? body.url.trim() : "";
|
||||
if (rawSourceUrl && !isValidSourceUrl(rawSourceUrl)) {
|
||||
return new Response(
|
||||
@@ -88,7 +110,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
provider,
|
||||
url: typeof body.url === "string" ? body.url : undefined,
|
||||
username: typeof body.username === "string" ? body.username : undefined,
|
||||
token: typeof body.token === "string" ? body.token : undefined,
|
||||
token,
|
||||
enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
let isClosed = false;
|
||||
const POLL_INTERVAL = 5000; // Poll every 5 seconds (reduced from 2 seconds for low-traffic usage)
|
||||
|
||||
let cancelStream: (() => void) | undefined;
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
@@ -73,20 +74,30 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Handle client disconnection
|
||||
// Handle client disconnection. The reader cancel runs first on Bun 1.4
|
||||
// and closes the stream, so close() can throw ERR_INVALID_STATE here;
|
||||
// inside an event listener that is an uncaught exception (#465).
|
||||
request.signal?.addEventListener("abort", () => {
|
||||
if (!isClosed) {
|
||||
isClosed = true;
|
||||
if (pollIntervalId) {
|
||||
clearInterval(pollIntervalId);
|
||||
}
|
||||
controller.close();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed by the reader cancel.
|
||||
}
|
||||
}
|
||||
});
|
||||
cancelStream = () => {
|
||||
isClosed = true;
|
||||
if (pollIntervalId) clearInterval(pollIntervalId);
|
||||
clearInterval(heartbeatInterval);
|
||||
};
|
||||
},
|
||||
cancel() {
|
||||
// Extra safety in case cancel is triggered
|
||||
isClosed = true;
|
||||
cancelStream?.();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss" source("../");
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -19,6 +19,12 @@ export const repoStatusEnum = z.enum([
|
||||
|
||||
export type RepoStatus = z.infer<typeof repoStatusEnum>;
|
||||
|
||||
// Activity entries carry every repository status plus "warning", an alert
|
||||
// (e.g. release filters emptied a destination) that a repository never has.
|
||||
export const activityStatusEnum = z.enum([...repoStatusEnum.options, "warning"]);
|
||||
|
||||
export type ActivityStatus = z.infer<typeof activityStatusEnum>;
|
||||
|
||||
export const repositoryVisibilityEnum = z.enum([
|
||||
"public",
|
||||
"private",
|
||||
|
||||
+11
-1
@@ -1,4 +1,4 @@
|
||||
import { type Config as ConfigType } from "@/lib/db/schema";
|
||||
import { type Config as ConfigType, type ReleaseType } from "@/lib/db/schema";
|
||||
|
||||
export type GiteaOrgVisibility = "public" | "private" | "limited";
|
||||
export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed";
|
||||
@@ -86,6 +86,9 @@ export interface MirrorOptions {
|
||||
mirrorReleases: boolean;
|
||||
releaseLimit?: number; // Limit number of releases to mirror (default: 10)
|
||||
releaseAssetLimit?: number | null; // Upload assets only for the newest N releases; null or absent means all, 0 means none
|
||||
releaseType?: ReleaseType; // "all" | "stable": which releases are mirror candidates before the limit applies
|
||||
releaseIncludeTags?: string[]; // Glob or /regex/ tag patterns; when set, only matching tags are mirror candidates
|
||||
releaseExcludeTags?: string[]; // Glob or /regex/ tag patterns; matching tags are skipped, and exclude wins over include
|
||||
mirrorLFS: boolean; // Mirror Git LFS objects
|
||||
mirrorMetadata: boolean;
|
||||
metadataComponents: {
|
||||
@@ -130,6 +133,9 @@ export interface SourceApiRecord {
|
||||
url: string;
|
||||
username: string;
|
||||
token: string;
|
||||
authType?: "pat" | "app";
|
||||
appId?: string;
|
||||
installationId?: string;
|
||||
enabled: boolean;
|
||||
repositoryCount: number;
|
||||
locked: boolean;
|
||||
@@ -184,7 +190,11 @@ export interface NotificationConfig {
|
||||
provider: "ntfy" | "apprise" | "gotify" | "webhook";
|
||||
notifyOnSyncError: boolean;
|
||||
notifyOnSyncSuccess: boolean;
|
||||
/** Master switch for the discovery alerts below. */
|
||||
notifyOnNewRepo: boolean;
|
||||
notifyOnNewOwnRepo: boolean;
|
||||
notifyOnNewOrgRepo: boolean;
|
||||
notifyOnNewStarredRepo: boolean;
|
||||
ntfy?: NtfyConfig;
|
||||
apprise?: AppriseConfig;
|
||||
gotify?: GotifyConfig;
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import type { MembershipRole } from "./organizations";
|
||||
import type { RepoStatus } from "./Repository";
|
||||
import type { ActivityStatus } from "./Repository";
|
||||
|
||||
export interface FilterParams {
|
||||
searchTerm?: string;
|
||||
status?: RepoStatus | ""; // repos, activity and orgs status
|
||||
status?: ActivityStatus | ""; // repos, activity and orgs status
|
||||
membershipRole?: MembershipRole | ""; //membership role in orgs
|
||||
owner?: string; // owner of the repos
|
||||
organization?: string; // organization of the repos
|
||||
|
||||
+6
-3
@@ -36,11 +36,14 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1",
|
||||
"sharp": "^0.35.4",
|
||||
"sharp": "^0.35.5",
|
||||
"svgo": "^4.1.0",
|
||||
"js-yaml": "^4.3.2",
|
||||
"smol-toml": "^1.7.1",
|
||||
"devalue": "^5.9.2"
|
||||
"smol-toml": "^1.9.0",
|
||||
"devalue": "^5.9.2",
|
||||
"source-map-js": "^1.2.2",
|
||||
"http-cache-semantics": "^4.3.0",
|
||||
"vite@>=8.0.0 <8.1.6": "~8.1.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+160
-170
@@ -6,11 +6,14 @@ settings:
|
||||
|
||||
overrides:
|
||||
esbuild@>=0.27.3 <0.28.1: '>=0.28.1'
|
||||
sharp: ^0.35.4
|
||||
sharp: ^0.35.5
|
||||
svgo: ^4.1.0
|
||||
js-yaml: ^4.3.2
|
||||
smol-toml: ^1.7.1
|
||||
smol-toml: ^1.9.0
|
||||
devalue: ^5.9.2
|
||||
source-map-js: ^1.2.2
|
||||
http-cache-semantics: ^4.3.0
|
||||
vite@>=8.0.0 <8.1.6: ~8.1.6
|
||||
|
||||
importers:
|
||||
|
||||
@@ -36,7 +39,7 @@ importers:
|
||||
version: 1.12.98
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))
|
||||
version: 4.3.3(vite@8.1.6(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))
|
||||
'@types/canvas-confetti':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
@@ -555,160 +558,160 @@ packages:
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
|
||||
'@img/sharp-darwin-arm64@0.35.5':
|
||||
resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.4':
|
||||
resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
|
||||
'@img/sharp-darwin-x64@0.35.5':
|
||||
resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.4':
|
||||
resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
|
||||
'@img/sharp-freebsd-wasm32@0.35.5':
|
||||
resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
os: [freebsd]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.3':
|
||||
resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.4':
|
||||
resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.3':
|
||||
resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
|
||||
'@img/sharp-libvips-darwin-x64@1.3.4':
|
||||
resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.3':
|
||||
resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
|
||||
'@img/sharp-libvips-linux-arm64@1.3.4':
|
||||
resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.3':
|
||||
resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
|
||||
'@img/sharp-libvips-linux-arm@1.3.4':
|
||||
resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.3':
|
||||
resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.4':
|
||||
resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.3':
|
||||
resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.4':
|
||||
resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.3':
|
||||
resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
|
||||
'@img/sharp-libvips-linux-s390x@1.3.4':
|
||||
resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.3':
|
||||
resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
|
||||
'@img/sharp-libvips-linux-x64@1.3.4':
|
||||
resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
|
||||
resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.4':
|
||||
resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
|
||||
resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.4':
|
||||
resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
|
||||
'@img/sharp-linux-arm64@0.35.5':
|
||||
resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.35.4':
|
||||
resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
|
||||
'@img/sharp-linux-arm@0.35.5':
|
||||
resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.4':
|
||||
resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
|
||||
'@img/sharp-linux-ppc64@0.35.5':
|
||||
resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.4':
|
||||
resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
|
||||
'@img/sharp-linux-riscv64@0.35.5':
|
||||
resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.4':
|
||||
resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
|
||||
'@img/sharp-linux-s390x@0.35.5':
|
||||
resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.35.4':
|
||||
resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
|
||||
'@img/sharp-linux-x64@0.35.5':
|
||||
resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
|
||||
'@img/sharp-linuxmusl-arm64@0.35.5':
|
||||
resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.4':
|
||||
resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
|
||||
'@img/sharp-linuxmusl-x64@0.35.5':
|
||||
resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.35.4':
|
||||
resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
|
||||
'@img/sharp-wasm32@0.35.5':
|
||||
resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.4':
|
||||
resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
|
||||
'@img/sharp-webcontainers-wasm32@0.35.5':
|
||||
resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
|
||||
'@img/sharp-win32-arm64@0.35.5':
|
||||
resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.4':
|
||||
resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
|
||||
'@img/sharp-win32-ia32@0.35.5':
|
||||
resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==}
|
||||
engines: {node: ^20.9.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.35.4':
|
||||
resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
|
||||
'@img/sharp-win32-x64@0.35.5':
|
||||
resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -732,12 +735,6 @@ packages:
|
||||
'@mdx-js/mdx@3.1.1':
|
||||
resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6':
|
||||
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||
peerDependencies:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
|
||||
'@napi-rs/wasm-runtime@1.2.3':
|
||||
resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
|
||||
@@ -1007,7 +1004,7 @@ packages:
|
||||
'@tailwindcss/vite@4.3.3':
|
||||
resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==}
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||
vite: ~8.1.6
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
|
||||
@@ -1078,7 +1075,7 @@ packages:
|
||||
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
vite: ~8.1.6
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
@@ -1447,8 +1444,8 @@ packages:
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
http-cache-semantics@4.2.0:
|
||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||
http-cache-semantics@4.3.0:
|
||||
resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==}
|
||||
|
||||
inline-style-parser@0.2.7:
|
||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||
@@ -2003,8 +2000,8 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
sharp@0.35.4:
|
||||
resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
|
||||
sharp@0.35.5:
|
||||
resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '*'
|
||||
@@ -2024,12 +2021,12 @@ packages:
|
||||
engines: {node: '>=20.19.5', npm: '>=10.8.2'}
|
||||
hasBin: true
|
||||
|
||||
smol-toml@1.8.0:
|
||||
resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==}
|
||||
smol-toml@1.9.1:
|
||||
resolution: {integrity: sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
source-map-js@1.2.2:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
source-map@0.7.6:
|
||||
@@ -2225,8 +2222,8 @@ packages:
|
||||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
vite@8.1.5:
|
||||
resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==}
|
||||
vite@8.1.6:
|
||||
resolution: {integrity: sha512-aRAHIxx5SW5zc1sMqFwJU/t5qaFfXb5Hj0J7W0VXihu/X99+346tz6080tO1T1xVIJORX/b3zVTXGFqUXh35uw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -2271,7 +2268,7 @@ packages:
|
||||
vitefu@1.1.3:
|
||||
resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==}
|
||||
peerDependencies:
|
||||
vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
vite: ~8.1.6
|
||||
peerDependenciesMeta:
|
||||
vite:
|
||||
optional: true
|
||||
@@ -2366,7 +2363,7 @@ snapshots:
|
||||
picomatch: 4.0.5
|
||||
retext-smartypants: 6.2.0
|
||||
shiki: 4.3.1
|
||||
smol-toml: 1.8.0
|
||||
smol-toml: 1.9.1
|
||||
unified: 11.0.5
|
||||
|
||||
'@astrojs/internal-helpers@0.11.0':
|
||||
@@ -2377,7 +2374,7 @@ snapshots:
|
||||
picomatch: 4.0.5
|
||||
retext-smartypants: 6.2.0
|
||||
shiki: 4.3.1
|
||||
smol-toml: 1.8.0
|
||||
smol-toml: 1.9.1
|
||||
unified: 11.0.5
|
||||
|
||||
'@astrojs/markdown-remark@7.2.1':
|
||||
@@ -2449,12 +2446,12 @@ snapshots:
|
||||
'@astrojs/internal-helpers': 0.10.1
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@vitejs/plugin-react': 5.2.0(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))
|
||||
'@vitejs/plugin-react': 5.2.0(vite@8.1.6(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))
|
||||
devalue: 5.9.4
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
ultrahtml: 1.7.0
|
||||
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
|
||||
vite: 8.1.6(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- '@vitejs/devtools'
|
||||
@@ -2775,108 +2772,108 @@ snapshots:
|
||||
'@img/colour@1.1.0':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.4':
|
||||
'@img/sharp-darwin-arm64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.3
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.4':
|
||||
'@img/sharp-darwin-x64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.3
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.4':
|
||||
'@img/sharp-freebsd-wasm32@0.35.5':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.4
|
||||
'@img/sharp-wasm32': 0.35.5
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.3':
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.3':
|
||||
'@img/sharp-libvips-darwin-x64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.3':
|
||||
'@img/sharp-libvips-linux-arm64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.3':
|
||||
'@img/sharp-libvips-linux-arm@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.3':
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.3':
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.3':
|
||||
'@img/sharp-libvips-linux-s390x@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.3':
|
||||
'@img/sharp-libvips-linux-x64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.4':
|
||||
'@img/sharp-linux-arm64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.3
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.35.4':
|
||||
'@img/sharp-linux-arm@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.3.3
|
||||
'@img/sharp-libvips-linux-arm': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.4':
|
||||
'@img/sharp-linux-ppc64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.3
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.4':
|
||||
'@img/sharp-linux-riscv64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.3
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.4':
|
||||
'@img/sharp-linux-s390x@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.3
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.35.4':
|
||||
'@img/sharp-linux-x64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.3.3
|
||||
'@img/sharp-libvips-linux-x64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.4':
|
||||
'@img/sharp-linuxmusl-arm64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.4':
|
||||
'@img/sharp-linuxmusl-x64@0.35.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.35.4':
|
||||
'@img/sharp-wasm32@0.35.5':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.11.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.4':
|
||||
'@img/sharp-webcontainers-wasm32@0.35.5':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.4
|
||||
'@img/sharp-wasm32': 0.35.5
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.4':
|
||||
'@img/sharp-win32-arm64@0.35.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.4':
|
||||
'@img/sharp-win32-ia32@0.35.5':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.35.4':
|
||||
'@img/sharp-win32-x64@0.35.5':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
@@ -2928,13 +2925,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.1
|
||||
'@emnapi/runtime': 1.11.1
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.1
|
||||
@@ -3006,7 +2996,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.1
|
||||
'@emnapi/runtime': 1.11.1
|
||||
'@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
|
||||
'@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.1.5':
|
||||
@@ -3081,7 +3071,7 @@ snapshots:
|
||||
jiti: 2.7.0
|
||||
lightningcss: 1.32.0
|
||||
magic-string: 0.30.21
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
tailwindcss: 4.3.3
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.3.3':
|
||||
@@ -3135,12 +3125,12 @@ snapshots:
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
|
||||
|
||||
'@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))':
|
||||
'@tailwindcss/vite@4.3.3(vite@8.1.6(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.3.3
|
||||
'@tailwindcss/oxide': 4.3.3
|
||||
tailwindcss: 4.3.3
|
||||
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
|
||||
vite: 8.1.6(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
dependencies:
|
||||
@@ -3218,7 +3208,7 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.3': {}
|
||||
|
||||
'@vitejs/plugin-react@5.2.0(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))':
|
||||
'@vitejs/plugin-react@5.2.0(vite@8.1.6(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
|
||||
@@ -3226,7 +3216,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.3
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.18.0
|
||||
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
|
||||
vite: 8.1.6(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -3282,7 +3272,7 @@ snapshots:
|
||||
get-tsconfig: 5.0.0-beta.4
|
||||
github-slugger: 2.0.0
|
||||
html-escaper: 3.0.3
|
||||
http-cache-semantics: 4.2.0
|
||||
http-cache-semantics: 4.3.0
|
||||
js-yaml: 4.3.2
|
||||
jsonc-parser: 3.3.1
|
||||
magic-string: 1.2.3
|
||||
@@ -3297,7 +3287,7 @@ snapshots:
|
||||
picomatch: 4.0.5
|
||||
semver: 7.8.5
|
||||
shiki: 4.3.1
|
||||
smol-toml: 1.8.0
|
||||
smol-toml: 1.9.1
|
||||
svgo: 4.1.0
|
||||
tinyclip: 0.1.15
|
||||
tinyexec: 1.2.4
|
||||
@@ -3305,13 +3295,13 @@ snapshots:
|
||||
ultrahtml: 1.7.0
|
||||
unifont: 0.7.5
|
||||
unstorage: 1.17.5
|
||||
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
|
||||
vitefu: 1.1.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))
|
||||
vite: 8.1.6(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
|
||||
vitefu: 1.1.3(vite@8.1.6(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))
|
||||
xxhash-wasm: 1.1.0
|
||||
yargs-parser: 22.0.0
|
||||
zod: 4.5.4
|
||||
optionalDependencies:
|
||||
sharp: 0.35.4(@types/node@24.13.3)
|
||||
sharp: 0.35.5(@types/node@24.13.3)
|
||||
transitivePeerDependencies:
|
||||
- '@azure/app-configuration'
|
||||
- '@azure/cosmos'
|
||||
@@ -3419,12 +3409,12 @@ snapshots:
|
||||
css-tree@2.2.1:
|
||||
dependencies:
|
||||
mdn-data: 2.0.28
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
css-tree@3.2.1:
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
css-what@7.0.0: {}
|
||||
|
||||
@@ -3758,7 +3748,7 @@ snapshots:
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
http-cache-semantics@4.2.0: {}
|
||||
http-cache-semantics@4.3.0: {}
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
@@ -3868,7 +3858,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
markdown-extensions@2.0.0: {}
|
||||
|
||||
@@ -4403,7 +4393,7 @@ snapshots:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
prismjs@1.30.0: {}
|
||||
|
||||
@@ -4628,37 +4618,37 @@ snapshots:
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
sharp@0.35.4(@types/node@24.13.3):
|
||||
sharp@0.35.5(@types/node@24.13.3):
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.8.5
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.35.4
|
||||
'@img/sharp-darwin-x64': 0.35.4
|
||||
'@img/sharp-freebsd-wasm32': 0.35.4
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.3
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.3
|
||||
'@img/sharp-libvips-linux-arm': 1.3.3
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.3
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.3
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.3
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.3
|
||||
'@img/sharp-libvips-linux-x64': 1.3.3
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
|
||||
'@img/sharp-linux-arm': 0.35.4
|
||||
'@img/sharp-linux-arm64': 0.35.4
|
||||
'@img/sharp-linux-ppc64': 0.35.4
|
||||
'@img/sharp-linux-riscv64': 0.35.4
|
||||
'@img/sharp-linux-s390x': 0.35.4
|
||||
'@img/sharp-linux-x64': 0.35.4
|
||||
'@img/sharp-linuxmusl-arm64': 0.35.4
|
||||
'@img/sharp-linuxmusl-x64': 0.35.4
|
||||
'@img/sharp-webcontainers-wasm32': 0.35.4
|
||||
'@img/sharp-win32-arm64': 0.35.4
|
||||
'@img/sharp-win32-ia32': 0.35.4
|
||||
'@img/sharp-win32-x64': 0.35.4
|
||||
'@img/sharp-darwin-arm64': 0.35.5
|
||||
'@img/sharp-darwin-x64': 0.35.5
|
||||
'@img/sharp-freebsd-wasm32': 0.35.5
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.4
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.4
|
||||
'@img/sharp-libvips-linux-arm': 1.3.4
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.4
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.4
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.4
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.4
|
||||
'@img/sharp-libvips-linux-x64': 1.3.4
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
|
||||
'@img/sharp-linux-arm': 0.35.5
|
||||
'@img/sharp-linux-arm64': 0.35.5
|
||||
'@img/sharp-linux-ppc64': 0.35.5
|
||||
'@img/sharp-linux-riscv64': 0.35.5
|
||||
'@img/sharp-linux-s390x': 0.35.5
|
||||
'@img/sharp-linux-x64': 0.35.5
|
||||
'@img/sharp-linuxmusl-arm64': 0.35.5
|
||||
'@img/sharp-linuxmusl-x64': 0.35.5
|
||||
'@img/sharp-webcontainers-wasm32': 0.35.5
|
||||
'@img/sharp-win32-arm64': 0.35.5
|
||||
'@img/sharp-win32-ia32': 0.35.5
|
||||
'@img/sharp-win32-x64': 0.35.5
|
||||
'@types/node': 24.13.3
|
||||
optional: true
|
||||
|
||||
@@ -4682,9 +4672,9 @@ snapshots:
|
||||
arg: 5.0.2
|
||||
sax: 1.6.0
|
||||
|
||||
smol-toml@1.8.0: {}
|
||||
smol-toml@1.9.1: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
source-map-js@1.2.2: {}
|
||||
|
||||
source-map@0.7.6: {}
|
||||
|
||||
@@ -4847,7 +4837,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0):
|
||||
vite@8.1.6(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.5
|
||||
@@ -4860,9 +4850,9 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
|
||||
vitefu@1.1.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)):
|
||||
vitefu@1.1.3(vite@8.1.6(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)):
|
||||
optionalDependencies:
|
||||
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
|
||||
vite: 8.1.6(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)
|
||||
|
||||
web-namespaces@2.0.1: {}
|
||||
|
||||
|
||||
@@ -36,7 +36,13 @@ If your Gitea instance is reachable at one URL from the Gitea Mirror process and
|
||||
|
||||
Below the connection cards, the mirror settings decide what travels with the code: issues, pull requests, labels, milestones, releases, wiki and LFS objects, plus the force push protection strategy. Switches that the current source or destination cannot serve are disabled and say why.
|
||||
|
||||
Releases have two numbers next to the switch. **Latest** is how many releases are mirrored per repository, newest first, ten by default. **Assets for latest** is how many of those get their assets uploaded: leave it empty to upload assets for every mirrored release, set it to `0` for release notes and tags only, or set it to a number to keep assets for the newest N releases while older ones keep their notes and tag. The limit never deletes assets that were already uploaded. Both values can be overridden per organization and per repository through the mirror overrides dialog, and the asset limit is `RELEASE_ASSET_LIMIT` in the environment.
|
||||
Releases have two numbers under the switch. **Latest** is how many releases are mirrored per repository, newest first, ten by default. **Assets for latest** is how many of those get their assets uploaded: leave it empty to upload assets for every mirrored release, set it to `0` for release notes and tags only, or set it to a number to keep assets for the newest N releases while older ones keep their notes and tag. The limit never deletes assets that were already uploaded.
|
||||
|
||||
The **Pre-releases** switch under them decides which releases are candidates at all: left on it mirrors every release, turned off it skips everything the source marks as a pre-release. With a filter active the **Latest** count applies to the releases that match, so ten stable releases are kept even when a project ships betas in between, and releases of the other kind are removed from Gitea on the next sync. A release the destination has no pre-release flag for is left alone. The per organization and per repository **Mirror options** dialog offers the same choice under its **Releases** row - **All**, **Stable only**, or **Inherit**, where **Inherit** means the level above - and the release type is `RELEASE_TYPE` in the environment.
|
||||
|
||||
**Custom tag rules...** beside that switch opens a dialog with **Include tags** and **Exclude tags**, two comma separated pattern lists that catch the projects shipping release candidates without marking them as pre-releases. The same lists sit under the **Releases** row in the per organization and per repository dialog, where leaving both lists empty means the level above, and in the environment they are `RELEASE_INCLUDE_TAGS` and `RELEASE_EXCLUDE_TAGS`.
|
||||
|
||||
Opened from a repository, that dialog also lists the releases Gitea already holds for it and crosses out the ones the draft rules would drop on the next sync, so a pattern can be checked before saving. The organization and global dialogs leave the list out because their rules apply to many repositories at once.
|
||||
|
||||
### Automation
|
||||
|
||||
|
||||
@@ -161,6 +161,9 @@ Only used when `DESTINATION_PROVIDER` is `github` or `gitlab`.
|
||||
| `MIRROR_MILESTONES` | `false` | Mirror milestones. |
|
||||
| `MIRROR_RELEASES` | `false` | Mirror GitHub releases along with their assets. |
|
||||
| `RELEASE_LIMIT` | `10` | Maximum releases mirrored per repository, newest first. |
|
||||
| `RELEASE_TYPE` | `all` | Which releases are candidates: `all` or `stable` (skip what the source marks as a pre-release). With a filter active the limit counts the releases that match, and releases of the other kind are removed from Gitea on the next sync. |
|
||||
| `RELEASE_INCLUDE_TAGS` | empty | Comma separated tag patterns; only releases whose tag matches are mirrored. A pattern is a glob over the whole tag (`v1.*`, `*-rc.*`) or a regular expression in slashes (`/^v\d+\.\d+\.\d+$/`), matched case-insensitively. |
|
||||
| `RELEASE_EXCLUDE_TAGS` | empty | Comma separated tag patterns in the same syntax; matching releases are skipped, and an excluded tag wins over `RELEASE_INCLUDE_TAGS`. |
|
||||
| `RELEASE_ASSET_LIMIT` | unset | Upload release assets only for the newest N mirrored releases; older releases still get their notes and tag. Unset means assets for every mirrored release, `0` means notes only. Never deletes assets already uploaded. |
|
||||
| `MIRROR_WIKI` | `false` | Clone wiki content. |
|
||||
| `MIRROR_ISSUE_CONCURRENCY` | `3` | Issues processed in parallel. |
|
||||
|
||||
@@ -11,7 +11,7 @@ canonical: "https://gitea-mirror.raylabs.io/use-cases/control-release-asset-stor
|
||||
|
||||
A GitHub release is a tag, some notes and, usually, a set of attached files: installers, tarballs, container bundles, model weights. The notes and the tag are a few kilobytes. The assets of a project that ships builds for five platforms every two weeks can run to gigabytes per year, and a mirror that copies all of them copies every historical build you will never install.
|
||||
|
||||
Gitea Mirror has had a release limit for a while: mirror the newest N releases and skip the rest. Since version 3.30 there is a second, independent limit for assets, so you can keep the notes of every release you mirror and the binaries of only the recent ones.
|
||||
Gitea Mirror has had a release limit for a while: mirror the newest N releases and skip the rest. Since version 3.30 there is a second, independent limit for assets, so you can keep the notes of every release you mirror and the binaries of only the recent ones. A third control decides which releases count at all, so a project that ships a beta for every feature does not push the stable builds out of the limit.
|
||||
|
||||
## The two limits
|
||||
|
||||
@@ -24,6 +24,27 @@ On the **Configuration** page, next to the **Releases** switch in the mirror set
|
||||
|
||||
Releases are ordered newest first. A release past the asset limit is still created on Gitea with its notes and tag; it just does not get the files. The two limits are independent: "Latest 50, Assets for latest 3" keeps three years of changelog browsable with the last three builds downloadable.
|
||||
|
||||
## Which releases count
|
||||
|
||||
The **Pre-releases** switch under the two inputs decides which releases are candidates before either limit is applied: leave it on to mirror every release, or turn it off to skip everything the source marks as a pre-release, which is what most people mean by "back up the releases" when a project ships a beta for every feature. The per organization and per repository dialog carries the same choice under its **Releases** row: **All**, **Stable only**, or **Inherit**.
|
||||
|
||||
The filter runs first, so **Latest** counts the releases that match. With **Stable only** and **Latest 10** the mirror looks further back than ten releases when it has to, and keeps the ten newest stable ones. Switching the type also prunes: on the next sync the releases of the other kind that are already on Gitea are removed. A release the destination has no pre-release flag for is left alone. Like the two limits, the type can be set per organization and per repository, and **Inherit** in the dialog means the global value.
|
||||
|
||||
## Filter by tag name
|
||||
|
||||
Plenty of projects ship a release candidate for every feature and never tick the pre-release box, so the type filter cannot tell them apart. **Custom tag rules...** opens a dialog with two lists that can: **Include tags** mirrors only releases whose tag matches, **Exclude tags** skips the ones that do, and an excluded tag wins when both lists name it.
|
||||
|
||||
Patterns are comma separated, and each one is either a glob over the whole tag or a regular expression in slashes:
|
||||
|
||||
| Pattern | Matches |
|
||||
|---|---|
|
||||
| `v1.*` | `v1.0.0`, `v1.4.2`, but not `v2.0.0` |
|
||||
| `*-rc.*` | `v2.1.0-rc.1`, `2.0-rc2` |
|
||||
| `v?` | `v1`, `v2`, but not `v10` |
|
||||
| `/^v\d+\.\d+\.\d+$/` | `v1.2.3`, not `v1.2.3-rc.1` or `nightly-20260101` |
|
||||
|
||||
Matching ignores case, and a pattern that cannot compile is ignored rather than failing the run. A tag filter widens the search exactly like the type filter does, so **Latest 1** with `v*` still finds the newest real release behind a wall of nightlies. Releases whose tag no longer matches are removed from Gitea on the next sync, and unlike the type filter a tag pattern can clean up releases the destination has no pre-release flag for, because it only needs the tag name.
|
||||
|
||||
## Nothing already uploaded is deleted
|
||||
|
||||
The asset limit only decides whether a sync uploads. Lowering it later never removes assets that already reached Gitea. If you mirrored everything for a year and then set the limit to 2, the old files stay until you delete them in Gitea yourself. That is the safe direction for a backup tool, and it means the setting is cheap to experiment with.
|
||||
@@ -36,11 +57,11 @@ Open **Configuration → Connections**, scroll to the mirror settings and make s
|
||||
|
||||
### 2. Override per organization
|
||||
|
||||
Some organizations publish big artifacts, others publish none. Open the three-dot menu on an organization row and choose **Mirror options**. Both limits are there next to the switches, with **Inherit** meaning the global value. An organization that ships firmware images can get `0` assets while the rest of your account keeps the default.
|
||||
Some organizations publish big artifacts, others publish none. Open the three-dot menu on an organization row and choose **Mirror options**. The dialog has two tabs; the release limits and filters are on **Releases**, each with **Inherit** meaning the global value. An organization that ships firmware images can get `0` assets while the rest of your account keeps the default.
|
||||
|
||||
### 3. Override per repository
|
||||
|
||||
The same dialog exists on repository rows, and a repository value wins over its organization, which wins over the global default. The dialog labels each row with what inherit resolves to right now, so you can see the effective value before you change it.
|
||||
The same dialog exists on repository rows, and a repository value wins over its organization, which wins over the global default. Each row there is labelled with what inherit resolves to right now, so you can see the effective value before you change it.
|
||||
|
||||
### 4. Environment
|
||||
|
||||
@@ -50,9 +71,12 @@ For deployments configured from the environment:
|
||||
MIRROR_RELEASES=true
|
||||
RELEASE_LIMIT=25
|
||||
RELEASE_ASSET_LIMIT=3 # unset for all, 0 for notes only
|
||||
RELEASE_TYPE=stable # all or stable
|
||||
RELEASE_INCLUDE_TAGS=v* # comma separated globs or /regex/
|
||||
RELEASE_EXCLUDE_TAGS=*-rc.*
|
||||
```
|
||||
|
||||
`RELEASE_ASSET_LIMIT` seeds the global setting on first boot, the same way the other mirror options do. The [environment variables page](/docs/environment-variables/) lists the rest.
|
||||
`RELEASE_ASSET_LIMIT`, `RELEASE_TYPE` and the two tag lists seed the global settings on first boot, the same way the other mirror options do. The [environment variables page](/docs/environment-variables/) lists the rest.
|
||||
|
||||
## Doing the disk math
|
||||
|
||||
@@ -68,7 +92,11 @@ Releases are read from the GitHub API. For GitLab and Gitea or Forgejo sources t
|
||||
|
||||
### Does lowering the release limit delete releases on Gitea?
|
||||
|
||||
No. Neither limit deletes anything. Releases and assets that already exist stay until you remove them in Gitea.
|
||||
Yes for the release limit, no for the asset limit. The release limit is a retention setting: on the next sync the mirror removes the oldest releases past the newest N. The asset limit only decides whether a sync uploads, and lowering it never removes assets that already reached Gitea.
|
||||
|
||||
### Can I skip beta and release candidate builds?
|
||||
|
||||
Turn the **Pre-releases** switch off, or pick **Stable only** in the repository's **Mirror options**. Everything the source marks as a pre-release is then skipped, and the pre-releases already on Gitea are removed on the next sync.
|
||||
|
||||
### Do assets sync again if I raise the limit?
|
||||
|
||||
|
||||
Reference in new issue
Block a user