mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-30 22:21:58 +01:00
feat(issues): issue state, comment and media filters (#491)
Adds three issue options under the metadata settings, each with organization and repository overrides: a state filter (all, open only or closed only), copying comments, and copying images and attachments. The state filter only decides which issues get created. Mirrored issues keep following GitHub, and changing the filter runs a full issue pass. Media removal covers GitHub uploads only.
This commit is contained in:
1 parent
35b275035d
commit
f6342de307
19 files changed
+785
-110
No files matched your search
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Issue filters** narrow issue mirroring (#491). A state filter creates only open or only closed issues; an issue that is already mirrored keeps following GitHub, so closing it there still closes the copy. Comments can be skipped, which also skips the comment listing for each issue, and images, videos and attached files can be left out of issue bodies and new comments, replaced by `[media omitted]`. All three sit under the metadata options and can be overridden per organization and repository. With media off, mirrored issue bodies lose their media as they are next synced; comments already mirrored stay as they are. Changing the state filter runs a full issue pass on the next sync
|
||||
- **Refresh sizes** on the Storage page measures every mirror now instead of waiting for its next sync, so the page fills in on a fresh install. It only reads the destination: one repository listing per destination owner, the single repository GET for a mirror the listing does not have, and the release listing of repositories that mirror releases. It runs in the background with its progress on the button (`POST /api/storage/refresh`)
|
||||
- **Remove old release assets** brings the attachments of older releases under a release asset limit. Turning the limit on, or lowering it, used to leave every file uploaded earlier in place, so the limit only held for the future. With the switch on, a kept release past the newest N releases that get assets loses its attachments on the next sync; the release, its notes and its tag stay, and a release whose attachments cannot be listed is left alone. It is a global setting (`PRUNE_OLD_RELEASE_ASSETS`, off by default) because it deletes files from the destination, while the limit it works with stays configurable per organization and repository
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ export function ConfigTabs() {
|
||||
milestones: false,
|
||||
wiki: false,
|
||||
},
|
||||
issueState: "all",
|
||||
issueComments: true,
|
||||
issueMedia: true,
|
||||
},
|
||||
advancedOptions: {
|
||||
skipForks: false,
|
||||
|
||||
@@ -480,7 +480,7 @@ export function GitHubMirrorSettings({
|
||||
<ChevronDown className="ml-2 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64">
|
||||
<PopoverContent align="end" className="w-72">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Metadata to mirror</div>
|
||||
@@ -529,6 +529,63 @@ export function GitHubMirrorSettings({
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{isGithubSource && mirrorOptions.metadataComponents.issues && (
|
||||
<div className="ml-8 space-y-2 border-l pl-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor="issue-state-popup" className="text-xs font-normal">
|
||||
State
|
||||
</Label>
|
||||
<Select
|
||||
value={mirrorOptions.issueState ?? "all"}
|
||||
onValueChange={(value) =>
|
||||
handleMirrorChange("issueState", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="issue-state-popup" className="h-8 w-28 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="all" className="text-xs">All issues</SelectItem>
|
||||
<SelectItem value="open" className="text-xs">Open only</SelectItem>
|
||||
<SelectItem value="closed" className="text-xs">Closed only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="issue-comments-popup"
|
||||
checked={mirrorOptions.issueComments ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
handleMirrorChange("issueComments", !!checked)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="issue-comments-popup"
|
||||
className="text-xs font-normal cursor-pointer"
|
||||
>
|
||||
Copy comments
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="issue-media-popup"
|
||||
checked={mirrorOptions.issueMedia ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
handleMirrorChange("issueMedia", !!checked)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="issue-media-popup"
|
||||
className="text-xs font-normal cursor-pointer"
|
||||
>
|
||||
Copy images and attachments
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-prs-popup"
|
||||
|
||||
@@ -20,11 +20,13 @@ import {
|
||||
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 type { IssueStateFilter, MirrorOverrides, ReleaseType } from "@/lib/db/schema";
|
||||
import {
|
||||
compileReleaseTagPattern,
|
||||
DEFAULT_ISSUE_STATE,
|
||||
getMirrorOverrideGating,
|
||||
MIRROR_OVERRIDE_LABELS,
|
||||
normalizeIssueState,
|
||||
normalizeReleaseAssetLimit,
|
||||
normalizeReleaseLimit,
|
||||
normalizeReleaseTagPatterns,
|
||||
@@ -48,6 +50,16 @@ type TriState = "inherit" | "on" | "off";
|
||||
*/
|
||||
type ReleaseTypeChoice = "inherit" | ReleaseType;
|
||||
|
||||
/** Issue state is a choice too, so "inherit" persists as absent. */
|
||||
type IssueStateChoice = "inherit" | IssueStateFilter;
|
||||
|
||||
/** How the issue-state row describes the value it inherits. */
|
||||
const ISSUE_STATE_LABELS: Record<IssueStateFilter, string> = {
|
||||
all: "all issues",
|
||||
open: "open only",
|
||||
closed: "closed only",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -203,6 +215,9 @@ export function MirrorOverridesDialog({
|
||||
const [releaseTypeDraft, setReleaseTypeDraft] = useState<ReleaseTypeChoice>(
|
||||
() => buildReleaseTypeDraft(value)
|
||||
);
|
||||
const [issueStateDraft, setIssueStateDraft] = useState<IssueStateChoice>(
|
||||
() => buildIssueStateDraft(value)
|
||||
);
|
||||
const [includeTagsDraft, setIncludeTagsDraft] = useState(() =>
|
||||
buildReleaseTagDraft(value, "releaseIncludeTags")
|
||||
);
|
||||
@@ -225,6 +240,7 @@ export function MirrorOverridesDialog({
|
||||
setReleaseLimitDraft(buildReleaseLimitDraft(value));
|
||||
setReleaseAssetLimitDraft(buildReleaseAssetLimitDraft(value));
|
||||
setReleaseTypeDraft(buildReleaseTypeDraft(value));
|
||||
setIssueStateDraft(buildIssueStateDraft(value));
|
||||
setIncludeTagsDraft(buildReleaseTagDraft(value, "releaseIncludeTags"));
|
||||
setExcludeTagsDraft(buildReleaseTagDraft(value, "releaseExcludeTags"));
|
||||
}
|
||||
@@ -317,6 +333,9 @@ export function MirrorOverridesDialog({
|
||||
const pinnedReleaseType: ReleaseType | undefined =
|
||||
releaseTypeDraft === "inherit" ? undefined : normalizeReleaseType(releaseTypeDraft);
|
||||
|
||||
const pinnedIssueState: IssueStateFilter | undefined =
|
||||
issueStateDraft === "inherit" ? undefined : normalizeIssueState(issueStateDraft);
|
||||
|
||||
// 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.
|
||||
@@ -356,6 +375,7 @@ export function MirrorOverridesDialog({
|
||||
(draft.mirrorReleases !== "inherit" || releaseTypeDraft !== "inherit"
|
||||
? 1
|
||||
: 0) +
|
||||
(pinnedIssueState !== undefined ? 1 : 0) +
|
||||
(pinnedReleaseLimit !== undefined ? 1 : 0) +
|
||||
(pinnedReleaseAssetLimit !== undefined ? 1 : 0) +
|
||||
(includeTags.patterns !== undefined ? 1 : 0) +
|
||||
@@ -380,6 +400,9 @@ export function MirrorOverridesDialog({
|
||||
next.releaseAssetLimit = pinnedReleaseAssetLimit;
|
||||
}
|
||||
if (pinnedReleaseType !== undefined) next.releaseType = pinnedReleaseType;
|
||||
if (pinnedIssueState !== undefined) {
|
||||
next.mirrorIssueState = pinnedIssueState;
|
||||
}
|
||||
if (includeTags.patterns !== undefined) {
|
||||
next.releaseIncludeTags = includeTags.patterns;
|
||||
}
|
||||
@@ -398,6 +421,7 @@ export function MirrorOverridesDialog({
|
||||
setReleaseLimitDraft("");
|
||||
setReleaseAssetLimitDraft("");
|
||||
setReleaseTypeDraft("inherit");
|
||||
setIssueStateDraft("inherit");
|
||||
setIncludeTagsDraft("");
|
||||
setExcludeTagsDraft("");
|
||||
};
|
||||
@@ -469,6 +493,59 @@ export function MirrorOverridesDialog({
|
||||
tagPatternCount === 0 &&
|
||||
inheritedTagText !== "";
|
||||
|
||||
const issueStateReason = gating.mirrorIssueState;
|
||||
const issueStateDisabled = !!issueStateReason;
|
||||
const effectiveIssueState =
|
||||
pinnedIssueState ?? inheritedFrom?.mirrorIssueState ?? DEFAULT_ISSUE_STATE;
|
||||
const showIssueStateHint =
|
||||
!inheritedLoading && !issueStateDisabled && issueStateDraft === "inherit";
|
||||
|
||||
// Rendered under the Issues row, with the other issue options.
|
||||
const issueStateRow = (
|
||||
<div key="mirrorIssueState" className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label
|
||||
htmlFor="override-mirrorIssueState"
|
||||
className={
|
||||
issueStateDisabled ? "flex-1 text-muted-foreground" : "flex-1"
|
||||
}
|
||||
>
|
||||
{MIRROR_OVERRIDE_LABELS.mirrorIssueState}
|
||||
{showIssueStateHint && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
currently {ISSUE_STATE_LABELS[effectiveIssueState]}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Select
|
||||
value={issueStateDraft}
|
||||
disabled={issueStateDisabled}
|
||||
onValueChange={(next) =>
|
||||
setIssueStateDraft(next as IssueStateChoice)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="override-mirrorIssueState"
|
||||
className="w-32"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">Inherit</SelectItem>
|
||||
<SelectItem value="all">All issues</SelectItem>
|
||||
<SelectItem value="open">Open only</SelectItem>
|
||||
<SelectItem value="closed">Closed only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{issueStateReason && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{issueStateReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
@@ -668,7 +745,7 @@ export function MirrorOverridesDialog({
|
||||
draft[key] === "inherit" &&
|
||||
inherited !== undefined;
|
||||
|
||||
return (
|
||||
const row = (
|
||||
<div key={key} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label
|
||||
@@ -706,6 +783,7 @@ export function MirrorOverridesDialog({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return key === "mirrorIssues" ? [row, issueStateRow] : row;
|
||||
})}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -793,3 +871,9 @@ function buildReleaseTypeDraft(
|
||||
): ReleaseTypeChoice {
|
||||
return normalizeReleaseType(value?.releaseType) ?? "inherit";
|
||||
}
|
||||
|
||||
function buildIssueStateDraft(
|
||||
value: MirrorOverrides | null | undefined
|
||||
): IssueStateChoice {
|
||||
return normalizeIssueState(value?.mirrorIssueState) ?? "inherit";
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import { MirrorOverridesDialog } from "@/components/config/MirrorOverridesDialog
|
||||
import {
|
||||
hasMirrorOverrides,
|
||||
mirrorOptionsToFlags,
|
||||
normalizeIssueState,
|
||||
normalizeReleaseAssetLimit,
|
||||
normalizeReleaseLimit,
|
||||
normalizeReleaseTagPatterns,
|
||||
@@ -198,6 +199,8 @@ export default function RepositoryTable({
|
||||
if (orgIncludeTags !== undefined) globals.releaseIncludeTags = orgIncludeTags;
|
||||
const orgExcludeTags = normalizeReleaseTagPatterns(orgOverrides.releaseExcludeTags);
|
||||
if (orgExcludeTags !== undefined) globals.releaseExcludeTags = orgExcludeTags;
|
||||
const orgIssueState = normalizeIssueState(orgOverrides.mirrorIssueState);
|
||||
if (orgIssueState !== undefined) globals.mirrorIssueState = orgIssueState;
|
||||
return globals;
|
||||
}, [mirrorOptions, orgOverrides]);
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ export const backupStrategyEnum = z.enum([
|
||||
export const releaseTypeEnum = z.enum(["all", "stable"]);
|
||||
export type ReleaseType = z.infer<typeof releaseTypeEnum>;
|
||||
|
||||
// Which source issues are candidates for mirroring. "all" keeps the historical
|
||||
// behavior; the other values narrow the GitHub listing before anything is
|
||||
// created in the destination.
|
||||
export const issueStateFilterEnum = z.enum(["all", "open", "closed"]);
|
||||
export type IssueStateFilter = z.infer<typeof issueStateFilterEnum>;
|
||||
|
||||
/**
|
||||
* Per-object overrides for the global mirror options in `giteaConfigSchema`.
|
||||
*
|
||||
@@ -94,6 +100,11 @@ export type ReleaseType = z.infer<typeof releaseTypeEnum>;
|
||||
* list of strings and ignores everything else, and an empty list is not a pin:
|
||||
* clearing the field means inherit.
|
||||
*
|
||||
* `mirrorIssueState` follows the same lenient-string rule as `releaseType`
|
||||
* (`normalizeIssueState` sanitizes it). `mirrorIssueComments` and
|
||||
* `mirrorIssueMedia` narrow what an issue pass copies; both default to on when
|
||||
* no tier pins them.
|
||||
*
|
||||
* `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.
|
||||
@@ -107,6 +118,9 @@ export const mirrorOverridesSchema = z.object({
|
||||
mirrorReleases: z.boolean().nullable().optional(),
|
||||
mirrorMetadata: z.boolean().nullable().optional(),
|
||||
mirrorIssues: z.boolean().nullable().optional(),
|
||||
mirrorIssueComments: z.boolean().nullable().optional(),
|
||||
mirrorIssueMedia: z.boolean().nullable().optional(),
|
||||
mirrorIssueState: z.string().nullable().optional(),
|
||||
mirrorPullRequests: z.boolean().nullable().optional(),
|
||||
mirrorLabels: z.boolean().nullable().optional(),
|
||||
mirrorMilestones: z.boolean().nullable().optional(),
|
||||
@@ -171,6 +185,11 @@ export const giteaConfigSchema = z.object({
|
||||
releaseExcludeTags: z.array(z.string()).default([]),
|
||||
mirrorMetadata: z.boolean().default(false),
|
||||
mirrorIssues: z.boolean().default(false),
|
||||
// Which issues the pass lists, and how much of each one it copies. Defaults
|
||||
// preserve the behavior from before these options existed.
|
||||
mirrorIssueState: issueStateFilterEnum.default("all"),
|
||||
mirrorIssueComments: z.boolean().default(true),
|
||||
mirrorIssueMedia: z.boolean().default(true),
|
||||
mirrorPullRequests: z.boolean().default(false),
|
||||
mirrorLabels: z.boolean().default(false),
|
||||
mirrorMilestones: z.boolean().default(false),
|
||||
|
||||
@@ -432,6 +432,10 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
: existingConfig?.[0]?.giteaConfig?.pullRequestConcurrency ?? 5,
|
||||
mirrorMetadata: envConfig.mirror.mirrorMetadata ?? (envConfig.mirror.mirrorIssues || envConfig.mirror.mirrorPullRequests || envConfig.mirror.mirrorLabels || envConfig.mirror.mirrorMilestones) ?? existingConfig?.[0]?.giteaConfig?.mirrorMetadata ?? false,
|
||||
mirrorIssues: envConfig.mirror.mirrorIssues ?? existingConfig?.[0]?.giteaConfig?.mirrorIssues ?? false,
|
||||
// No env vars for these; keep what the UI stored across restarts.
|
||||
mirrorIssueState: existingConfig?.[0]?.giteaConfig?.mirrorIssueState ?? 'all',
|
||||
mirrorIssueComments: existingConfig?.[0]?.giteaConfig?.mirrorIssueComments ?? true,
|
||||
mirrorIssueMedia: existingConfig?.[0]?.giteaConfig?.mirrorIssueMedia ?? true,
|
||||
mirrorPullRequests: envConfig.mirror.mirrorPullRequests ?? existingConfig?.[0]?.giteaConfig?.mirrorPullRequests ?? false,
|
||||
mirrorLabels: envConfig.mirror.mirrorLabels ?? existingConfig?.[0]?.giteaConfig?.mirrorLabels ?? false,
|
||||
mirrorMilestones: envConfig.mirror.mirrorMilestones ?? existingConfig?.[0]?.giteaConfig?.mirrorMilestones ?? false,
|
||||
|
||||
@@ -1166,6 +1166,9 @@ export async function syncGiteaRepoEnhanced({
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: repoName,
|
||||
issueState: mirrorOptions.mirrorIssueState,
|
||||
includeComments: mirrorOptions.mirrorIssueComments,
|
||||
includeMedia: mirrorOptions.mirrorIssueMedia,
|
||||
syncCursor: metadataState.syncCursors.issues,
|
||||
passProgress: metadataState.passProgress.issues,
|
||||
onPassProgress: async (progress) => {
|
||||
|
||||
@@ -195,7 +195,11 @@ function fakeOctokit(data: GitHubData) {
|
||||
const issues = {
|
||||
listForRepo: async (params: any) => {
|
||||
calls.push({ endpoint: "issues.listForRepo", params });
|
||||
return { data: data.issueListing };
|
||||
// GitHub answers a state filter with only the issues in that state.
|
||||
const state = params.state ?? "open";
|
||||
return {
|
||||
data: data.issueListing.filter((issue) => state === "all" || issue.state === state),
|
||||
};
|
||||
},
|
||||
listComments: async (params: any) => {
|
||||
calls.push({ endpoint: "issues.listComments", params });
|
||||
@@ -983,3 +987,92 @@ describe.skipIf(!isChild)("milestone assignment (#460)", () => {
|
||||
expect(cursor).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isChild)("mirrorGitRepoIssuesToGitea content options", () => {
|
||||
const baseArgs = (octokit: any, extra: Record<string, unknown> = {}) => ({
|
||||
config,
|
||||
octokit,
|
||||
repository,
|
||||
giteaOwner: DEST_OWNER,
|
||||
giteaRepoName: DEST_REPO,
|
||||
...extra,
|
||||
});
|
||||
|
||||
it("lists every state and creates only the issues the filter keeps", async () => {
|
||||
const gitea = fakeGitea([]);
|
||||
const github = fakeOctokit({
|
||||
issueListing: [ghIssue(1), ghIssue(2, { state: "closed" })],
|
||||
});
|
||||
|
||||
const cursor = await mirrorGitRepoIssuesToGitea(
|
||||
baseArgs(github.octokit, { issueState: "open" })
|
||||
);
|
||||
|
||||
expect(github.callsTo("issues.listForRepo")[0]?.params.state).toBe("all");
|
||||
expect(gitea.createdTitles()).toEqual(["[GH-ISSUE #1] Issue 1"]);
|
||||
expect(github.callsTo("issues.listComments").map((c) => c.params.issue_number)).toEqual([1]);
|
||||
expect(cursor?.issueState).toBe("open");
|
||||
});
|
||||
|
||||
it("closes the copy of a mirrored issue that was closed on GitHub", async () => {
|
||||
const gitea = fakeGitea([{ number: 21, title: "[GH-ISSUE #1] Issue 1" }]);
|
||||
const github = fakeOctokit({ issueListing: [ghIssue(1, { state: "closed" })] });
|
||||
|
||||
await mirrorGitRepoIssuesToGitea(baseArgs(github.octokit, { issueState: "open" }));
|
||||
|
||||
const edit = gitea.requests.find(
|
||||
(r) => r.method === "PATCH" && r.url === `${DEST_API}/issues/21`
|
||||
);
|
||||
expect(JSON.parse(edit!.body!).state).toBe("closed");
|
||||
});
|
||||
|
||||
it("runs a full pass when the filter differs from the last pass", async () => {
|
||||
fakeGitea([{ number: 21, title: "[GH-ISSUE #1] Issue 1" }]);
|
||||
const changed = fakeOctokit({ issueListing: [] });
|
||||
await mirrorGitRepoIssuesToGitea(
|
||||
baseArgs(changed.octokit, { issueState: "all", syncCursor: { ...recentCursor(), issueState: "open" } })
|
||||
);
|
||||
expect(changed.callsTo("issues.listForRepo")[0]?.params.since).toBeUndefined();
|
||||
|
||||
fakeGitea([{ number: 21, title: "[GH-ISSUE #1] Issue 1" }]);
|
||||
const same = fakeOctokit({ issueListing: [] });
|
||||
const cursor = recentCursor();
|
||||
await mirrorGitRepoIssuesToGitea(
|
||||
baseArgs(same.octokit, { issueState: "open", syncCursor: { ...cursor, issueState: "open" } })
|
||||
);
|
||||
expect(same.callsTo("issues.listForRepo")[0]?.params.since).toBe(expectedSince(cursor));
|
||||
});
|
||||
|
||||
it("skips comments when the comments option is off", async () => {
|
||||
fakeGitea([]);
|
||||
const github = fakeOctokit({ issueListing: [ghIssue(1)] });
|
||||
|
||||
await mirrorGitRepoIssuesToGitea(baseArgs(github.octokit, { includeComments: false }));
|
||||
|
||||
expect(github.callsTo("issues.listComments")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps media by default and strips it when the media option is off", async () => {
|
||||
const issue = ghIssue(1, {
|
||||
body: "Before  after",
|
||||
});
|
||||
|
||||
const giteaWithMedia = fakeGitea([]);
|
||||
const withMedia = fakeOctokit({ issueListing: [issue] });
|
||||
await mirrorGitRepoIssuesToGitea(baseArgs(withMedia.octokit));
|
||||
|
||||
const giteaWithoutMedia = fakeGitea([]);
|
||||
const withoutMedia = fakeOctokit({ issueListing: [issue] });
|
||||
await mirrorGitRepoIssuesToGitea(
|
||||
baseArgs(withoutMedia.octokit, { includeMedia: false })
|
||||
);
|
||||
|
||||
expect(giteaWithMedia.createdBodies()[0]).toContain(
|
||||
"user-images.githubusercontent.com"
|
||||
);
|
||||
expect(giteaWithoutMedia.createdBodies()[0]).toContain("[media omitted]");
|
||||
expect(giteaWithoutMedia.createdBodies()[0]).not.toContain(
|
||||
"user-images.githubusercontent.com"
|
||||
);
|
||||
});
|
||||
});
|
||||
+153
-94
@@ -7,9 +7,10 @@ import {
|
||||
import { membershipRoleEnum } from "@/types/organizations";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import type { Config } from "@/types/config";
|
||||
import type { Organization, ReleaseType, Repository } from "./db/schema";
|
||||
import type { IssueStateFilter, Organization, ReleaseType, Repository } from "./db/schema";
|
||||
import { httpPost, httpGet, httpDelete, httpPut, httpPatch } from "./http-client";
|
||||
import { createMirrorJob } from "./helpers";
|
||||
import { applyIssueMediaOption, stripIssueMedia } from "./utils/issue-media";
|
||||
import { db, organizations, repositories } from "./db";
|
||||
import { eq, and, ne } from "drizzle-orm";
|
||||
import { decryptConfigTokens } from "./utils/config-encryption";
|
||||
@@ -1272,6 +1273,9 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
repository,
|
||||
giteaOwner: repoOwner,
|
||||
giteaRepoName: targetRepoName,
|
||||
issueState: mirrorOptions.mirrorIssueState,
|
||||
includeComments: mirrorOptions.mirrorIssueComments,
|
||||
includeMedia: mirrorOptions.mirrorIssueMedia,
|
||||
syncCursor: metadataState.syncCursors.issues,
|
||||
passProgress: metadataState.passProgress.issues,
|
||||
onPassProgress: async (progress) => {
|
||||
@@ -2095,6 +2099,9 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
repository,
|
||||
giteaOwner: orgName,
|
||||
giteaRepoName: targetRepoName,
|
||||
issueState: mirrorOptions.mirrorIssueState,
|
||||
includeComments: mirrorOptions.mirrorIssueComments,
|
||||
includeMedia: mirrorOptions.mirrorIssueMedia,
|
||||
syncCursor: metadataState.syncCursors.issues,
|
||||
passProgress: metadataState.passProgress.issues,
|
||||
onPassProgress: async (progress) => {
|
||||
@@ -2809,12 +2816,26 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
passProgress,
|
||||
onPassProgress,
|
||||
milestoneIds,
|
||||
issueState = "all",
|
||||
includeComments = true,
|
||||
includeMedia = true,
|
||||
}: {
|
||||
config: Partial<Config>;
|
||||
octokit: Octokit;
|
||||
repository: Repository;
|
||||
giteaOwner: string;
|
||||
giteaRepoName?: string;
|
||||
/**
|
||||
* Which source issues get created. "all" keeps the historical behavior.
|
||||
* The listing still asks for every state, so an issue that is already
|
||||
* mirrored keeps following GitHub when it leaves the filter: closing it
|
||||
* there closes the copy. Nothing is deleted.
|
||||
*/
|
||||
issueState?: IssueStateFilter;
|
||||
/** Copy issue comments. Off skips the per-issue comment listing entirely. */
|
||||
includeComments?: boolean;
|
||||
/** Keep images/videos/attachment links in issue bodies and comments. */
|
||||
includeMedia?: boolean;
|
||||
/**
|
||||
* Gitea milestone ids by title from the milestones pass that ran just
|
||||
* before (#460). Without it the issues' milestones are left untouched.
|
||||
@@ -2888,9 +2909,19 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
}
|
||||
// GitHub numbers this pass has finished, kept if it does not complete.
|
||||
const completedNumbers = new Set<number>();
|
||||
// Issues an earlier state filter left out have not changed since, so an
|
||||
// incremental listing would never return them. A pass under a different
|
||||
// filter lists everything once.
|
||||
if (plan.mode === "incremental" && (syncCursor?.issueState ?? "all") !== issueState) {
|
||||
console.log(
|
||||
`[Issues] The issue state filter for ${repository.fullName} changed; running a full pass`
|
||||
);
|
||||
plan = { mode: "full", reason: "the issue state filter changed" };
|
||||
}
|
||||
const finishPass = async () => {
|
||||
if (resume) await onPassProgress?.(undefined);
|
||||
return advanceMetadataSyncCursor(syncCursor, plan.mode, passStartedAt);
|
||||
const cursor = advanceMetadataSyncCursor(syncCursor, plan.mode, passStartedAt);
|
||||
return issueState === "all" ? cursor : { ...cursor, issueState };
|
||||
};
|
||||
const keepPassProgress = async () => {
|
||||
await onPassProgress?.(buildPassProgress(plan, passStartedAt, completedNumbers, resume));
|
||||
@@ -3068,6 +3099,7 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
}
|
||||
|
||||
let unchangedIssueCount = 0;
|
||||
let filteredOutIssueCount = 0;
|
||||
|
||||
// Process issues in parallel with concurrency control
|
||||
let mirroredIssues: unknown[];
|
||||
@@ -3088,6 +3120,17 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
return issue;
|
||||
}
|
||||
|
||||
// The state filter only decides what gets created. Not counted as done,
|
||||
// so a resumed pass under another filter looks at the issue again.
|
||||
if (
|
||||
issueState !== "all" &&
|
||||
issue.state !== issueState &&
|
||||
!giteaIssueByGitHubNumber.has(issue.number)
|
||||
) {
|
||||
filteredOutIssueCount++;
|
||||
return issue;
|
||||
}
|
||||
|
||||
const githubLabelNames =
|
||||
issue.labels
|
||||
?.map((l) => (typeof l === "string" ? l : l.name))
|
||||
@@ -3133,7 +3176,7 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
}.`;
|
||||
const issueMarker = `[GH-ISSUE #${issue.number}]`;
|
||||
const mirroredTitle = `${issueMarker} ${issue.title}`;
|
||||
const issueBody = `${issueOriginHeader}\nOriginal GitHub issue: ${issue.html_url}${originalAssignees}\n\n${issue.body ?? ""}`;
|
||||
const issueBody = `${issueOriginHeader}\nOriginal GitHub issue: ${issue.html_url}${originalAssignees}\n\n${applyIssueMediaOption(issue.body, includeMedia)}`;
|
||||
|
||||
const wantedMilestoneId = resolveGiteaMilestoneId(milestoneIds, issue.milestone);
|
||||
const issuePayload: any = {
|
||||
@@ -3331,106 +3374,119 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// Clone comments
|
||||
const comments = await octokit.paginate(
|
||||
octokit.rest.issues.listComments,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 100,
|
||||
},
|
||||
(res) => res.data
|
||||
);
|
||||
|
||||
// Ensure comments are applied in chronological order to preserve discussion flow
|
||||
const sortedComments = comments
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at || 0).getTime() -
|
||||
new Date(b.created_at || 0).getTime()
|
||||
if (includeComments) {
|
||||
// Clone comments
|
||||
const comments = await octokit.paginate(
|
||||
octokit.rest.issues.listComments,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 100,
|
||||
},
|
||||
(res) => res.data
|
||||
);
|
||||
|
||||
// Process comments sequentially to preserve historical ordering
|
||||
if (sortedComments.length > 0) {
|
||||
const existingComments: any[] = [];
|
||||
let commentsPage = 1;
|
||||
const commentsPerPage = 100;
|
||||
while (true) {
|
||||
const existingCommentsRes = await httpGet(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}/comments?page=${commentsPage}&limit=${commentsPerPage}`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
// Ensure comments are applied in chronological order to preserve discussion flow
|
||||
const sortedComments = comments
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at || 0).getTime() -
|
||||
new Date(b.created_at || 0).getTime()
|
||||
);
|
||||
const pageComments = Array.isArray(existingCommentsRes.data)
|
||||
? existingCommentsRes.data
|
||||
: [];
|
||||
if (!pageComments.length) break;
|
||||
existingComments.push(...pageComments);
|
||||
// Use the Link header to decide whether more pages exist.
|
||||
// See note on the existing-issues pagination above; the
|
||||
// same Gitea behaviors (MAX_RESPONSE_ITEMS cap and
|
||||
// repeated-data on out-of-bound pages) apply here.
|
||||
const commentsLinkHeader =
|
||||
existingCommentsRes.headers.get("link") || "";
|
||||
if (!/\brel="next"/.test(commentsLinkHeader)) break;
|
||||
commentsPage += 1;
|
||||
}
|
||||
const mirroredCommentIds = new Set<number>();
|
||||
const existingCommentBodies = new Set<string>();
|
||||
for (const existingComment of existingComments) {
|
||||
const body = String(existingComment.body || "");
|
||||
if (body) existingCommentBodies.add(body);
|
||||
const marker = String(existingComment.body || "").match(
|
||||
/<!--\s*gh-comment-id:(\d+)\s*-->/i
|
||||
);
|
||||
if (marker?.[1]) {
|
||||
const parsed = Number.parseInt(marker[1], 10);
|
||||
if (Number.isFinite(parsed)) mirroredCommentIds.add(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
await processWithRetry(
|
||||
sortedComments,
|
||||
async (comment) => {
|
||||
if (mirroredCommentIds.has(comment.id)) {
|
||||
return comment;
|
||||
}
|
||||
const commenter = comment.user?.login ?? "unknown";
|
||||
const commentDate = formatDateShort(comment.created_at);
|
||||
const commentHeader = `@${commenter} commented on GitHub${
|
||||
commentDate ? ` (${commentDate})` : ""
|
||||
}:`;
|
||||
const legacyBody = `${commentHeader}\n\n${comment.body ?? ""}`;
|
||||
const markedBody = `<!-- gh-comment-id:${comment.id} -->\n${legacyBody}`;
|
||||
if (existingCommentBodies.has(legacyBody) || existingCommentBodies.has(markedBody)) {
|
||||
return comment;
|
||||
}
|
||||
|
||||
await httpPost(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}/comments`,
|
||||
{
|
||||
body: markedBody,
|
||||
},
|
||||
// Process comments sequentially to preserve historical ordering
|
||||
if (sortedComments.length > 0) {
|
||||
const existingComments: any[] = [];
|
||||
let commentsPage = 1;
|
||||
const commentsPerPage = 100;
|
||||
while (true) {
|
||||
const existingCommentsRes = await httpGet(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}/comments?page=${commentsPage}&limit=${commentsPerPage}`,
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
return comment;
|
||||
},
|
||||
{
|
||||
concurrencyLimit: 1,
|
||||
maxRetries: 2,
|
||||
retryDelay: 1000,
|
||||
onRetry: (_comment, error, attempt) => {
|
||||
console.log(
|
||||
`Retrying comment (attempt ${attempt}): ${error.message}`
|
||||
);
|
||||
},
|
||||
const pageComments = Array.isArray(existingCommentsRes.data)
|
||||
? existingCommentsRes.data
|
||||
: [];
|
||||
if (!pageComments.length) break;
|
||||
existingComments.push(...pageComments);
|
||||
// Use the Link header to decide whether more pages exist.
|
||||
// See note on the existing-issues pagination above; the
|
||||
// same Gitea behaviors (MAX_RESPONSE_ITEMS cap and
|
||||
// repeated-data on out-of-bound pages) apply here.
|
||||
const commentsLinkHeader =
|
||||
existingCommentsRes.headers.get("link") || "";
|
||||
if (!/\brel="next"/.test(commentsLinkHeader)) break;
|
||||
commentsPage += 1;
|
||||
}
|
||||
);
|
||||
const mirroredCommentIds = new Set<number>();
|
||||
const existingCommentBodies = new Set<string>();
|
||||
for (const existingComment of existingComments) {
|
||||
const body = String(existingComment.body || "");
|
||||
if (body) {
|
||||
existingCommentBodies.add(body);
|
||||
// When media is off, compare against the stripped form too so a
|
||||
// comment mirrored before the option existed is not re-created
|
||||
// as a duplicate with the same text and no media.
|
||||
if (!includeMedia) {
|
||||
existingCommentBodies.add(stripIssueMedia(body));
|
||||
}
|
||||
}
|
||||
const marker = String(existingComment.body || "").match(
|
||||
/<!--\s*gh-comment-id:(\d+)\s*-->/i
|
||||
);
|
||||
if (marker?.[1]) {
|
||||
const parsed = Number.parseInt(marker[1], 10);
|
||||
if (Number.isFinite(parsed)) mirroredCommentIds.add(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
await processWithRetry(
|
||||
sortedComments,
|
||||
async (comment) => {
|
||||
if (mirroredCommentIds.has(comment.id)) {
|
||||
return comment;
|
||||
}
|
||||
const commenter = comment.user?.login ?? "unknown";
|
||||
const commentDate = formatDateShort(comment.created_at);
|
||||
const commentHeader = `@${commenter} commented on GitHub${
|
||||
commentDate ? ` (${commentDate})` : ""
|
||||
}:`;
|
||||
const legacyBody = `${commentHeader}\n\n${applyIssueMediaOption(comment.body, includeMedia)}`;
|
||||
const markedBody = `<!-- gh-comment-id:${comment.id} -->\n${legacyBody}`;
|
||||
if (
|
||||
existingCommentBodies.has(legacyBody) ||
|
||||
existingCommentBodies.has(markedBody)
|
||||
) {
|
||||
return comment;
|
||||
}
|
||||
|
||||
await httpPost(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}/comments`,
|
||||
{
|
||||
body: markedBody,
|
||||
},
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
);
|
||||
return comment;
|
||||
},
|
||||
{
|
||||
concurrencyLimit: 1,
|
||||
maxRetries: 2,
|
||||
retryDelay: 1000,
|
||||
onRetry: (_comment, error, attempt) => {
|
||||
console.log(
|
||||
`Retrying comment (attempt ${attempt}): ${error.message}`
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
completedNumbers.add(issue.number);
|
||||
@@ -3471,6 +3527,9 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
`Completed mirroring ${filteredIssues.length} issues for ${repository.fullName}` +
|
||||
(unchangedIssueCount > 0
|
||||
? ` (${unchangedIssueCount} unchanged since the last pass, no GitHub calls)`
|
||||
: "") +
|
||||
(filteredOutIssueCount > 0
|
||||
? ` (${filteredOutIssueCount} not ${issueState}, skipped)`
|
||||
: "")
|
||||
);
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ describe("sync cursor parsing", () => {
|
||||
state.syncCursors.issues = {
|
||||
lastPassStartedAt: "2026-09-20T10:00:00.000Z",
|
||||
lastFullPassStartedAt: "2026-09-18T10:00:00.000Z",
|
||||
issueState: "open",
|
||||
};
|
||||
state.syncCursors.pullRequests = {
|
||||
lastPassStartedAt: "2026-09-20T11:00:00.000Z",
|
||||
|
||||
@@ -29,6 +29,11 @@ export interface MetadataSyncCursor {
|
||||
lastPassStartedAt: string;
|
||||
/** Start of the last full pass that completed cleanly. */
|
||||
lastFullPassStartedAt: string;
|
||||
/**
|
||||
* Issue state filter the last completed issues pass ran with, when it was
|
||||
* not "all". Issues a filter left out are only found again by a full pass.
|
||||
*/
|
||||
issueState?: string;
|
||||
}
|
||||
|
||||
export interface MetadataSyncCursors {
|
||||
@@ -115,14 +120,17 @@ function isValidTimestamp(value: unknown): value is string {
|
||||
|
||||
function parseSyncCursor(raw: unknown): MetadataSyncCursor | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const { lastPassStartedAt, lastFullPassStartedAt } = raw as {
|
||||
const { lastPassStartedAt, lastFullPassStartedAt, issueState } = raw as {
|
||||
lastPassStartedAt?: unknown;
|
||||
lastFullPassStartedAt?: unknown;
|
||||
issueState?: unknown;
|
||||
};
|
||||
if (!isValidTimestamp(lastPassStartedAt) || !isValidTimestamp(lastFullPassStartedAt)) {
|
||||
return undefined;
|
||||
}
|
||||
return { lastPassStartedAt, lastFullPassStartedAt };
|
||||
return typeof issueState === "string"
|
||||
? { lastPassStartedAt, lastFullPassStartedAt, issueState }
|
||||
: { lastPassStartedAt, lastFullPassStartedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,6 +41,9 @@ function buildMinimalUiConfigs(overrides: { includeCollaboratorRepos?: boolean }
|
||||
milestones: false,
|
||||
wiki: false,
|
||||
},
|
||||
issueState: "all",
|
||||
issueComments: true,
|
||||
issueMedia: true,
|
||||
};
|
||||
const advancedOptions: AdvancedOptions = {
|
||||
skipForks: false,
|
||||
|
||||
@@ -17,7 +17,14 @@ 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, normalizeReleaseTagPatterns, normalizeReleaseType, DEFAULT_RELEASE_TYPE } from "@/lib/utils/mirror-overrides";
|
||||
import {
|
||||
DEFAULT_ISSUE_STATE,
|
||||
normalizeIssueState,
|
||||
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
|
||||
@@ -183,6 +190,9 @@ export function mapUiToDbConfig(
|
||||
releaseExcludeTags: normalizeReleaseTagPatterns(mirrorOptions.releaseExcludeTags) ?? [],
|
||||
mirrorMetadata: mirrorOptions.mirrorMetadata,
|
||||
mirrorIssues: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.issues,
|
||||
mirrorIssueState: normalizeIssueState(mirrorOptions.issueState) ?? DEFAULT_ISSUE_STATE,
|
||||
mirrorIssueComments: mirrorOptions.issueComments,
|
||||
mirrorIssueMedia: mirrorOptions.issueMedia,
|
||||
mirrorPullRequests: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests,
|
||||
mirrorLabels: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.labels,
|
||||
mirrorMilestones: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.milestones,
|
||||
@@ -267,6 +277,10 @@ export function mapDbToUiConfig(dbConfig: any): {
|
||||
milestones: dbConfig.giteaConfig?.mirrorMilestones || false,
|
||||
wiki: dbConfig.giteaConfig?.wiki || false,
|
||||
},
|
||||
issueState:
|
||||
normalizeIssueState(dbConfig.giteaConfig?.mirrorIssueState) ?? DEFAULT_ISSUE_STATE,
|
||||
issueComments: dbConfig.giteaConfig?.mirrorIssueComments ?? true,
|
||||
issueMedia: dbConfig.giteaConfig?.mirrorIssueMedia ?? true,
|
||||
};
|
||||
|
||||
// Map advanced options
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { applyIssueMediaOption, stripIssueMedia } from "./issue-media";
|
||||
|
||||
describe("stripIssueMedia", () => {
|
||||
test("removes Markdown images but keeps the surrounding text", () => {
|
||||
const body = "Before\n\n\n\nAfter";
|
||||
|
||||
expect(stripIssueMedia(body)).toBe("Before\n\n[media omitted]\n\nAfter");
|
||||
});
|
||||
|
||||
test("removes images nested inside links", () => {
|
||||
const body = "[](https://example.com)";
|
||||
|
||||
expect(stripIssueMedia(body)).toBe("[media omitted]");
|
||||
});
|
||||
|
||||
test("removes HTML media tags", () => {
|
||||
const body = 'Text\n<img src="https://example.com/a.png" />\n<video controls><source src="a.mp4"></video>\nEnd';
|
||||
|
||||
const result = stripIssueMedia(body);
|
||||
expect(result).toContain("Text");
|
||||
expect(result).toContain("End");
|
||||
expect(result).not.toContain("<img");
|
||||
expect(result).not.toContain("<video");
|
||||
});
|
||||
|
||||
test("removes GitHub attachment links", () => {
|
||||
const body = "See https://github.com/user-attachments/assets/abc123 and https://user-images.githubusercontent.com/2/b.png";
|
||||
|
||||
expect(stripIssueMedia(body)).toBe("See [media omitted] and [media omitted]");
|
||||
});
|
||||
|
||||
test("removes attached files, on the current and the older upload paths", () => {
|
||||
const body =
|
||||
"Logs: [debug.log](https://github.com/user-attachments/files/123/debug.log) and [dump.zip](https://github.com/acme/demo/files/456/dump.zip)\nVideo: https://github.com/acme/demo/assets/789/0d1e2f3a-aaaa-bbbb-cccc-1234567890ab";
|
||||
|
||||
expect(stripIssueMedia(body)).toBe(
|
||||
"Logs: [media omitted] and [media omitted]\nVideo: [media omitted]"
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps links to repository content on other GitHub hosts", () => {
|
||||
const body =
|
||||
"Run https://raw.githubusercontent.com/acme/demo/main/install.sh, see [the config](https://raw.githubusercontent.com/acme/demo/main/config.yml) and https://gist.githubusercontent.com/u/abc/raw/log.txt";
|
||||
|
||||
expect(stripIssueMedia(body)).toBe(body);
|
||||
});
|
||||
|
||||
test("keeps tilde fenced code blocks", () => {
|
||||
const body = "~~~md\n\n~~~";
|
||||
|
||||
expect(stripIssueMedia(body)).toBe(body);
|
||||
});
|
||||
|
||||
test("keeps normal links and code blocks", () => {
|
||||
const body = "See [the docs](https://example.com/docs).\n\n```\n\n```";
|
||||
|
||||
expect(stripIssueMedia(body)).toBe(body);
|
||||
});
|
||||
|
||||
test("returns an empty string for missing bodies", () => {
|
||||
expect(stripIssueMedia(null)).toBe("");
|
||||
expect(stripIssueMedia(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyIssueMediaOption", () => {
|
||||
test("leaves the body untouched when media is enabled", () => {
|
||||
const body = "";
|
||||
expect(applyIssueMediaOption(body, true)).toBe(body);
|
||||
});
|
||||
|
||||
test("strips media when the option is disabled", () => {
|
||||
const body = "";
|
||||
expect(applyIssueMediaOption(body, false)).toBe("[media omitted]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
const MEDIA_PLACEHOLDER = "[media omitted]";
|
||||
|
||||
/**
|
||||
* Where GitHub keeps what people upload to issues and comments: images,
|
||||
* videos and attached files, on the current hosts and the older per-repository
|
||||
* paths. Other GitHub hosts such as raw.githubusercontent.com serve repository
|
||||
* content, not uploads, and are left alone.
|
||||
*/
|
||||
const GITHUB_UPLOAD_URL =
|
||||
String.raw`https?:\/\/(?:(?:private-)?user-images\.githubusercontent\.com\/|github\.com\/user-attachments\/(?:assets|files)\/|github\.com\/[\w.-]+\/[\w.-]+\/(?:assets|files)\/\d+\/)`;
|
||||
|
||||
const GITHUB_MEDIA_URL = new RegExp(String.raw`${GITHUB_UPLOAD_URL}[^\s)>\]]+`, "gi");
|
||||
|
||||
/**
|
||||
* Markdown links to an upload. This runs before the bare URL pass so
|
||||
* `[screenshot](https://user-images...)` becomes one placeholder instead of a
|
||||
* broken empty link.
|
||||
*/
|
||||
const MARKDOWN_MEDIA_LINK = new RegExp(
|
||||
String.raw`\[([^\]]*)\]\(${GITHUB_UPLOAD_URL}[^\s)]+(?:\s+"[^"]*")?\)`,
|
||||
"gi"
|
||||
);
|
||||
|
||||
/** Inline Markdown images, with optional title. */
|
||||
const MARKDOWN_IMAGE = /!\[[^\]]*\]\([^)]*\)/g;
|
||||
|
||||
/** HTML media blocks/tags that can be embedded in Markdown. */
|
||||
const HTML_MEDIA = [
|
||||
/<picture\b[^>]*>[\s\S]*?<\/picture>/gi,
|
||||
/<video\b[^>]*>[\s\S]*?<\/video>/gi,
|
||||
/<audio\b[^>]*>[\s\S]*?<\/audio>/gi,
|
||||
/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi,
|
||||
/<(?:img|source)\b[^>]*\/?>/gi,
|
||||
];
|
||||
|
||||
/**
|
||||
* Remove images, videos and attachment links from an issue body or comment.
|
||||
*
|
||||
* With the "issue media" option off we still want the text of the issue, so
|
||||
* this drops only the media references and leaves everything else intact. It
|
||||
* does not download anything; it just prevents the mirrored copy from pointing
|
||||
* at GitHub-hosted media that would be loaded every time the issue is opened.
|
||||
*/
|
||||
export function stripIssueMedia(value: string | null | undefined): string {
|
||||
if (!value) return "";
|
||||
|
||||
// Code blocks may document Markdown/HTML media syntax. Protect them so the
|
||||
// cleanup only touches content the author actually rendered as media.
|
||||
const codeBlocks: string[] = [];
|
||||
let result = value.replace(/(```|~~~)[\s\S]*?\1|`[^`\n]*`/g, (block) => {
|
||||
const index = codeBlocks.push(block) - 1;
|
||||
return `\u0000${index}\u0000`;
|
||||
});
|
||||
|
||||
// Images first: their target URL is often a media host, and running the
|
||||
// link pass first would leave the leading `![...]` behind.
|
||||
result = result.replace(MARKDOWN_IMAGE, MEDIA_PLACEHOLDER);
|
||||
result = result.replace(MARKDOWN_MEDIA_LINK, MEDIA_PLACEHOLDER);
|
||||
for (const pattern of HTML_MEDIA) {
|
||||
result = result.replace(pattern, MEDIA_PLACEHOLDER);
|
||||
}
|
||||
result = result.replace(GITHUB_MEDIA_URL, MEDIA_PLACEHOLDER);
|
||||
|
||||
// An image wrapped in a link leaves either `[](url)` or a link whose text is
|
||||
// just the placeholder behind; collapse both so the body does not keep a
|
||||
// meaningless link.
|
||||
result = result.replace(
|
||||
/\[\[media omitted\]\]\([^)]*\)/g,
|
||||
MEDIA_PLACEHOLDER
|
||||
);
|
||||
result = result.replace(/\[\]\([^)]*\)/g, MEDIA_PLACEHOLDER);
|
||||
|
||||
// Removing block-level media leaves runs of blank lines behind. Keep the
|
||||
// author's paragraph spacing but avoid growing the body with empty space.
|
||||
result = result.replace(/\n{3,}/g, "\n\n");
|
||||
|
||||
result = result.replace(/\u0000(\d+)\u0000/g, (_match, index: string) => {
|
||||
return codeBlocks[Number.parseInt(index, 10)] ?? "";
|
||||
});
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
/** Apply the media option without changing bodies when media is enabled. */
|
||||
export function applyIssueMediaOption(
|
||||
value: string | null | undefined,
|
||||
includeMedia: boolean
|
||||
): string {
|
||||
return includeMedia ? value ?? "" : stripIssueMedia(value);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
MIRROR_OVERRIDE_LIMIT_KEYS,
|
||||
MIRROR_OVERRIDE_PATTERN_KEYS,
|
||||
mirrorOptionsToFlags,
|
||||
normalizeIssueState,
|
||||
normalizeMirrorOverrides,
|
||||
normalizeReleaseAssetLimit,
|
||||
normalizeReleaseLimit,
|
||||
@@ -446,6 +447,8 @@ describe("UI_MIRROR_OVERRIDE_KEYS", () => {
|
||||
"lfs",
|
||||
"wiki",
|
||||
"mirrorIssues",
|
||||
"mirrorIssueComments",
|
||||
"mirrorIssueMedia",
|
||||
"mirrorPullRequests",
|
||||
"mirrorReleases",
|
||||
"mirrorLabels",
|
||||
@@ -1702,15 +1705,20 @@ describe("release type filter", () => {
|
||||
});
|
||||
|
||||
test("the choice keys and labels agree", () => {
|
||||
expect(MIRROR_OVERRIDE_CHOICE_KEYS).toEqual(["releaseType"]);
|
||||
expect(MIRROR_OVERRIDE_CHOICE_KEYS).toEqual([
|
||||
"releaseType",
|
||||
"mirrorIssueState",
|
||||
]);
|
||||
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");
|
||||
for (const key of MIRROR_OVERRIDE_CHOICE_KEYS) {
|
||||
expect(MIRROR_OVERRIDE_KEYS).not.toContain(key);
|
||||
expect(MIRROR_OVERRIDE_LIMIT_KEYS).not.toContain(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1770,3 +1778,57 @@ describe("release type filter", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue content options", () => {
|
||||
test("normalizes the issue state filter", () => {
|
||||
expect(normalizeIssueState(" OPEN ")).toBe("open");
|
||||
expect(normalizeIssueState("closed")).toBe("closed");
|
||||
expect(normalizeIssueState("all")).toBe("all");
|
||||
expect(normalizeIssueState("merged")).toBeUndefined();
|
||||
expect(normalizeIssueState(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("resolves issue state per tier, most specific first", () => {
|
||||
const resolved = resolveMirrorOptions({
|
||||
config: makeConfig({ mirrorIssueState: "closed" }),
|
||||
repository: makeRepo({
|
||||
mirrorOverrides: { mirrorIssueState: "all" },
|
||||
}),
|
||||
orgOverrides: { mirrorIssueState: "open" },
|
||||
});
|
||||
|
||||
expect(resolved.mirrorIssueState).toBe("all");
|
||||
});
|
||||
|
||||
test("inherits issue state from the organization when the repository has none", () => {
|
||||
const resolved = resolveMirrorOptions({
|
||||
config: makeConfig({ mirrorIssueState: "closed" }),
|
||||
repository: makeRepo(),
|
||||
orgOverrides: { mirrorIssueState: "open" },
|
||||
});
|
||||
|
||||
expect(resolved.mirrorIssueState).toBe("open");
|
||||
});
|
||||
|
||||
test("keeps comments and media on when no tier pins them", () => {
|
||||
const resolved = resolveMirrorOptions({
|
||||
config: makeConfig({}),
|
||||
repository: makeRepo(),
|
||||
});
|
||||
|
||||
expect(resolved.mirrorIssueComments).toBe(true);
|
||||
expect(resolved.mirrorIssueMedia).toBe(true);
|
||||
expect(resolved.mirrorIssueState).toBe("all");
|
||||
});
|
||||
|
||||
test("the content options are disabled while issues are off", () => {
|
||||
const gating = getMirrorOverrideGating({
|
||||
targetKind: "repository",
|
||||
effective: { mirrorIssues: false },
|
||||
});
|
||||
|
||||
expect(gating.mirrorIssueComments).toBe(MIRROR_GATING_REASONS.issuesOff);
|
||||
expect(gating.mirrorIssueMedia).toBe(MIRROR_GATING_REASONS.issuesOff);
|
||||
expect(gating.mirrorIssueState).toBe(MIRROR_GATING_REASONS.issuesOff);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { Config } from "@/types/config";
|
||||
import type { MirrorOverrides, ReleaseType, Repository } from "@/lib/db/schema";
|
||||
import type {
|
||||
IssueStateFilter,
|
||||
MirrorOverrides,
|
||||
ReleaseType,
|
||||
Repository,
|
||||
} from "@/lib/db/schema";
|
||||
import { mirrorOverridesSchema } from "@/lib/db/schema";
|
||||
import {
|
||||
normalizeSourceProviderKind,
|
||||
@@ -18,6 +23,8 @@ export const MIRROR_OVERRIDE_KEYS = [
|
||||
"mirrorReleases",
|
||||
"mirrorMetadata",
|
||||
"mirrorIssues",
|
||||
"mirrorIssueComments",
|
||||
"mirrorIssueMedia",
|
||||
"mirrorPullRequests",
|
||||
"mirrorLabels",
|
||||
"mirrorMilestones",
|
||||
@@ -44,9 +51,13 @@ export type MirrorOverrideLimitKey = (typeof MIRROR_OVERRIDE_LIMIT_KEYS)[number]
|
||||
* 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.
|
||||
* then decides how many of the matching ones are kept. `mirrorIssueState`
|
||||
* selects which source issues the issue pass lists.
|
||||
*/
|
||||
export const MIRROR_OVERRIDE_CHOICE_KEYS = ["releaseType"] as const;
|
||||
export const MIRROR_OVERRIDE_CHOICE_KEYS = [
|
||||
"releaseType",
|
||||
"mirrorIssueState",
|
||||
] as const;
|
||||
|
||||
export type MirrorOverrideChoiceKey =
|
||||
(typeof MIRROR_OVERRIDE_CHOICE_KEYS)[number];
|
||||
@@ -86,6 +97,19 @@ export const DEFAULT_RELEASE_LIMIT = 10;
|
||||
/** Matches `giteaConfigSchema.releaseType`'s default. */
|
||||
export const DEFAULT_RELEASE_TYPE: ReleaseType = "all";
|
||||
|
||||
/** Matches `giteaConfigSchema.mirrorIssueState`'s default. */
|
||||
export const DEFAULT_ISSUE_STATE: IssueStateFilter = "all";
|
||||
|
||||
/**
|
||||
* Flags whose absent global value means "on". The schema defaults cover saved
|
||||
* configs, but the resolver also runs against partial configs (tests, older
|
||||
* callers), where an absent key must not silently turn issue content off.
|
||||
*/
|
||||
const FLAG_DEFAULTS: Partial<Record<MirrorOverrideKey, boolean>> = {
|
||||
mirrorIssueComments: true,
|
||||
mirrorIssueMedia: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* How many releases the mirror looks at when any release filter is active.
|
||||
*
|
||||
@@ -119,6 +143,7 @@ export type ResolvedMirrorOptions = Record<MirrorOverrideKey, boolean> & {
|
||||
releaseType: ReleaseType;
|
||||
releaseIncludeTags: string[];
|
||||
releaseExcludeTags: string[];
|
||||
mirrorIssueState: IssueStateFilter;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -132,6 +157,7 @@ export type InheritedMirrorOptions = Partial<Record<MirrorOverrideKey, boolean>>
|
||||
releaseType?: ReleaseType;
|
||||
releaseIncludeTags?: string[];
|
||||
releaseExcludeTags?: string[];
|
||||
mirrorIssueState?: IssueStateFilter;
|
||||
skipForks?: boolean;
|
||||
};
|
||||
|
||||
@@ -160,6 +186,20 @@ export function normalizeReleaseAssetLimit(value: unknown): number | undefined {
|
||||
return limit >= 0 ? limit : undefined;
|
||||
}
|
||||
|
||||
const ISSUE_STATES: readonly IssueStateFilter[] = ["all", "open", "closed"];
|
||||
|
||||
/**
|
||||
* Sanitize an issue-state filter 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 an env var or a hand-edited row behaves like
|
||||
* the value the UI writes.
|
||||
*/
|
||||
export function normalizeIssueState(value: unknown): IssueStateFilter | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const state = value.trim().toLowerCase();
|
||||
return ISSUE_STATES.find((candidate) => candidate === state);
|
||||
}
|
||||
|
||||
const RELEASE_TYPES: readonly ReleaseType[] = ["all", "stable"];
|
||||
|
||||
/**
|
||||
@@ -411,9 +451,10 @@ const LIMIT_NORMALIZERS: Record<
|
||||
/** Per-key sanitizers for the choice overrides. */
|
||||
const CHOICE_NORMALIZERS: Record<
|
||||
MirrorOverrideChoiceKey,
|
||||
(value: unknown) => ReleaseType | undefined
|
||||
(value: unknown) => ReleaseType | IssueStateFilter | undefined
|
||||
> = {
|
||||
releaseType: normalizeReleaseType,
|
||||
mirrorIssueState: normalizeIssueState,
|
||||
};
|
||||
|
||||
/** Per-key sanitizers for the tag-pattern overrides. */
|
||||
@@ -453,6 +494,8 @@ export const STARRED_CLAMPED_KEYS = [
|
||||
"mirrorReleases",
|
||||
"mirrorMetadata",
|
||||
"mirrorIssues",
|
||||
"mirrorIssueComments",
|
||||
"mirrorIssueMedia",
|
||||
"mirrorPullRequests",
|
||||
"mirrorLabels",
|
||||
"mirrorMilestones",
|
||||
@@ -473,6 +516,8 @@ export const STARRED_CLAMPED_KEYS = [
|
||||
export const GITHUB_ONLY_METADATA_KEYS = [
|
||||
"mirrorMetadata",
|
||||
"mirrorIssues",
|
||||
"mirrorIssueComments",
|
||||
"mirrorIssueMedia",
|
||||
"mirrorPullRequests",
|
||||
"mirrorLabels",
|
||||
"mirrorMilestones",
|
||||
@@ -491,6 +536,8 @@ export const PUSH_TARGET_CLAMPED_KEYS = [
|
||||
"mirrorReleases",
|
||||
"mirrorMetadata",
|
||||
"mirrorIssues",
|
||||
"mirrorIssueComments",
|
||||
"mirrorIssueMedia",
|
||||
"mirrorPullRequests",
|
||||
"mirrorLabels",
|
||||
"mirrorMilestones",
|
||||
@@ -515,6 +562,8 @@ export const UI_MIRROR_OVERRIDE_KEYS: MirrorOverrideKey[] = [
|
||||
"lfs",
|
||||
"wiki",
|
||||
"mirrorIssues",
|
||||
"mirrorIssueComments",
|
||||
"mirrorIssueMedia",
|
||||
"mirrorPullRequests",
|
||||
"mirrorReleases",
|
||||
"mirrorLabels",
|
||||
@@ -557,6 +606,9 @@ export function mirrorOptionsToFlags(
|
||||
releaseIncludeTags?: string[];
|
||||
releaseExcludeTags?: string[];
|
||||
mirrorMetadata?: boolean;
|
||||
issueState?: IssueStateFilter;
|
||||
issueComments?: boolean;
|
||||
issueMedia?: boolean;
|
||||
metadataComponents?: {
|
||||
issues?: boolean;
|
||||
pullRequests?: boolean;
|
||||
@@ -576,6 +628,10 @@ export function mirrorOptionsToFlags(
|
||||
mirrorReleases: !!mirrorOptions?.mirrorReleases,
|
||||
wiki: !!components?.wiki,
|
||||
mirrorIssues: !!components?.issues,
|
||||
mirrorIssueComments: mirrorOptions?.issueComments ?? true,
|
||||
mirrorIssueMedia: mirrorOptions?.issueMedia ?? true,
|
||||
mirrorIssueState:
|
||||
normalizeIssueState(mirrorOptions?.issueState) ?? DEFAULT_ISSUE_STATE,
|
||||
mirrorPullRequests: !!components?.pullRequests,
|
||||
mirrorLabels: !!components?.labels,
|
||||
mirrorMilestones: !!components?.milestones,
|
||||
@@ -596,6 +652,7 @@ export const MIRROR_GATING_REASONS = {
|
||||
starredCodeOnly:
|
||||
"Starred repos mirror code only (Advanced Options > starred code only)",
|
||||
labelsFollowIssues: "Issues mirroring already syncs labels",
|
||||
issuesOff: "Issues are not being mirrored",
|
||||
releasesOff: "Releases are not being mirrored",
|
||||
pushTarget: "Not available for GitHub and GitLab destinations: only branches and tags are pushed",
|
||||
} as const;
|
||||
@@ -651,6 +708,7 @@ export function getMirrorOverrideGating({
|
||||
gating.releaseLimit = MIRROR_GATING_REASONS.pushTarget;
|
||||
gating.releaseAssetLimit = MIRROR_GATING_REASONS.pushTarget;
|
||||
gating.releaseType = MIRROR_GATING_REASONS.pushTarget;
|
||||
gating.mirrorIssueState = MIRROR_GATING_REASONS.pushTarget;
|
||||
gating.releaseIncludeTags = MIRROR_GATING_REASONS.pushTarget;
|
||||
gating.releaseExcludeTags = MIRROR_GATING_REASONS.pushTarget;
|
||||
return gating;
|
||||
@@ -672,6 +730,18 @@ export function getMirrorOverrideGating({
|
||||
gating.mirrorLabels = MIRROR_GATING_REASONS.labelsFollowIssues;
|
||||
}
|
||||
|
||||
// Issue content filters only mean something while issues are mirrored.
|
||||
// Inherit the starred/push reason when that is what disabled issues.
|
||||
if (gating.mirrorIssues) {
|
||||
gating.mirrorIssueComments = gating.mirrorIssues;
|
||||
gating.mirrorIssueMedia = gating.mirrorIssues;
|
||||
gating.mirrorIssueState = gating.mirrorIssues;
|
||||
} else if (effective.mirrorIssues === false) {
|
||||
gating.mirrorIssueComments = MIRROR_GATING_REASONS.issuesOff;
|
||||
gating.mirrorIssueMedia = MIRROR_GATING_REASONS.issuesOff;
|
||||
gating.mirrorIssueState = MIRROR_GATING_REASONS.issuesOff;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -704,6 +774,9 @@ export const MIRROR_OVERRIDE_LABELS: Record<
|
||||
mirrorReleases: "Releases",
|
||||
mirrorMetadata: "Metadata",
|
||||
mirrorIssues: "Issues",
|
||||
mirrorIssueComments: "Issue comments",
|
||||
mirrorIssueMedia: "Issue images and attachments",
|
||||
mirrorIssueState: "Issue state",
|
||||
mirrorPullRequests: "Pull requests",
|
||||
mirrorLabels: "Labels",
|
||||
mirrorMilestones: "Milestones",
|
||||
@@ -854,7 +927,9 @@ export function resolveMirrorOptions({
|
||||
} else if (typeof orgValue === "boolean") {
|
||||
resolved[key] = orgValue;
|
||||
} else {
|
||||
resolved[key] = !!globalConfig?.[key];
|
||||
const globalValue = globalConfig?.[key];
|
||||
resolved[key] =
|
||||
typeof globalValue === "boolean" ? globalValue : FLAG_DEFAULTS[key] ?? !!globalValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -885,6 +960,15 @@ export function resolveMirrorOptions({
|
||||
normalizeReleaseType(globalConfig?.releaseType) ??
|
||||
DEFAULT_RELEASE_TYPE;
|
||||
|
||||
// Issue state follows the same precedence and the same "an unusable value
|
||||
// falls through to the next tier" rule. Nothing pinned means mirror every
|
||||
// issue, like every configuration from before this option existed.
|
||||
resolved.mirrorIssueState =
|
||||
normalizeIssueState(repo?.mirrorIssueState) ??
|
||||
normalizeIssueState(org?.mirrorIssueState) ??
|
||||
normalizeIssueState(globalConfig?.mirrorIssueState) ??
|
||||
DEFAULT_ISSUE_STATE;
|
||||
|
||||
// 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
|
||||
|
||||
+11
-1
@@ -1,4 +1,8 @@
|
||||
import { type Config as ConfigType, type ReleaseType } from "@/lib/db/schema";
|
||||
import {
|
||||
type Config as ConfigType,
|
||||
type IssueStateFilter,
|
||||
type ReleaseType,
|
||||
} from "@/lib/db/schema";
|
||||
|
||||
export type GiteaOrgVisibility = "public" | "private" | "limited";
|
||||
export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed";
|
||||
@@ -99,6 +103,12 @@ export interface MirrorOptions {
|
||||
milestones: boolean;
|
||||
wiki: boolean;
|
||||
};
|
||||
/** Which source issues are listed: all, open only, or closed only. */
|
||||
issueState: IssueStateFilter;
|
||||
/** Copy comments along with each issue. */
|
||||
issueComments: boolean;
|
||||
/** Keep images, videos and attachment links in issue bodies and comments. */
|
||||
issueMedia: boolean;
|
||||
}
|
||||
|
||||
export interface AdvancedOptions {
|
||||
|
||||
Reference in new issue
Block a user