diff --git a/README.md b/README.md
index d1c7cf7..6768322 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ First user signup becomes admin. Configure GitHub and Gitea through the web inte
- 🏢 Mirror entire organizations with flexible strategies
- 🎯 Custom destination control for repos and organizations
- 📦 **Git LFS support** - Mirror large files with Git LFS
-- 📝 **Metadata mirroring** - Issues, PRs, labels, milestones, wiki
+- 📝 **Metadata mirroring** - Issues, pull requests (as issues), labels, milestones, wiki
- 🚫 **Repository ignore** - Mark specific repos to skip
- 🔐 Secure authentication with Better Auth (email/password, SSO, OIDC)
- 📊 Real-time dashboard with activity logs
@@ -311,6 +311,31 @@ Gitea Mirror can also act as an OIDC provider for other applications. Register O
- Create service-to-service authentication
- Build integrations with your Gitea Mirror instance
+## Known Limitations
+
+### Pull Request Mirroring Implementation
+Pull requests **cannot be created as actual PRs** in Gitea due to API limitations. Instead, they are mirrored as **enriched issues** with comprehensive metadata.
+
+**Why real PR mirroring isn't possible:**
+- Gitea's API doesn't support creating pull requests from external sources
+- Real PRs require actual Git branches with commits to exist in the repository
+- Would require complex branch synchronization and commit replication
+- The mirror relationship is one-way (GitHub → Gitea) for repository content
+
+**How we handle Pull Requests:**
+PRs are mirrored as issues with rich metadata including:
+- 🏷️ Special "pull-request" label for identification
+- 📌 [PR #number] prefix in title with status indicators ([MERGED], [CLOSED])
+- 👤 Original author and creation date
+- 📝 Complete commit history (up to 10 commits with links)
+- 📊 File changes summary with additions/deletions
+- 📁 List of modified files (up to 20 files)
+- 💬 Original PR description and comments
+- 🔀 Base and head branch information
+- ✅ Merge status tracking
+
+This approach preserves all important PR information while working within Gitea's API constraints. The PRs appear in Gitea's issue tracker with clear visual distinction and comprehensive details.
+
## Contributing
Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
diff --git a/src/components/config/MirrorOptionsForm.tsx b/src/components/config/MirrorOptionsForm.tsx
index c676de0..9a67ef8 100644
--- a/src/components/config/MirrorOptionsForm.tsx
+++ b/src/components/config/MirrorOptionsForm.tsx
@@ -3,7 +3,12 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "../ui/checkbox";
import type { MirrorOptions } from "@/types/config";
import { RefreshCw, Info } from "lucide-react";
-import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger
+} from "../ui/tooltip";
interface MirrorOptionsFormProps {
config: MirrorOptions;
@@ -27,7 +32,7 @@ export function MirrorOptionsForm({
if (!checked) {
newConfig.metadataComponents = {
issues: false,
- pullRequests: false,
+ pullRequests: false, // Keep for backwards compatibility but not shown in UI
labels: false,
milestones: false,
wiki: false,
@@ -188,8 +193,33 @@ export function MirrorOptionsForm({
htmlFor="metadata-pullRequests"
className="ml-2 text-sm select-none"
>
- Pull requests
+ Pull Requests (as issues)
+
+
+
+
+
+
+
+
Pull Requests are mirrored as issues
+
+ Due to Gitea API limitations, PRs cannot be created as actual pull requests.
+ Instead, they are mirrored as issues with:
+
+
+ - • [PR #number] prefix in title
+ - • Full PR description and metadata
+ - • Commit history (up to 10 commits)
+ - • File changes summary
+ - • Diff preview (first 5 files)
+ - • Review comments preserved
+ - • Merge/close status tracking
+
+
+
+
+
diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts
index 94efe35..abbc2bf 100644
--- a/src/lib/gitea.ts
+++ b/src/lib/gitea.ts
@@ -1593,17 +1593,90 @@ export async function mirrorGitRepoPullRequestsToGitea({
const { processWithRetry } = await import("@/lib/utils/concurrency");
+ let successCount = 0;
+ let failedCount = 0;
+
await processWithRetry(
pullRequests,
async (pr) => {
- const issueData = {
- title: `[PR #${pr.number}] ${pr.title}`,
- body: `**Original Pull Request:** ${pr.html_url}\n\n**State:** ${pr.state}\n**Merged:** ${pr.merged_at ? 'Yes' : 'No'}\n\n---\n\n${pr.body || 'No description provided'}`,
- labels: [{ name: "pull-request" }],
- state: pr.state === "closed" ? "closed" : "open",
- };
-
try {
+ // Fetch additional PR data for rich metadata
+ const [prDetail, commits, files] = await Promise.all([
+ octokit.rest.pulls.get({ owner, repo, pull_number: pr.number }),
+ octokit.rest.pulls.listCommits({ owner, repo, pull_number: pr.number, per_page: 10 }),
+ octokit.rest.pulls.listFiles({ owner, repo, pull_number: pr.number, per_page: 100 })
+ ]);
+
+ // Build rich PR body with metadata
+ let richBody = `## 📋 Pull Request Information\n\n`;
+ richBody += `**Original PR:** ${pr.html_url}\n`;
+ richBody += `**Author:** [@${pr.user?.login}](${pr.user?.html_url})\n`;
+ richBody += `**Created:** ${new Date(pr.created_at).toLocaleDateString()}\n`;
+ richBody += `**Status:** ${pr.state === 'closed' ? (pr.merged_at ? '✅ Merged' : '❌ Closed') : '🔄 Open'}\n`;
+
+ if (pr.merged_at) {
+ richBody += `**Merged:** ${new Date(pr.merged_at).toLocaleDateString()}\n`;
+ richBody += `**Merged by:** [@${prDetail.data.merged_by?.login}](${prDetail.data.merged_by?.html_url})\n`;
+ }
+
+ richBody += `\n**Base:** \`${pr.base.ref}\` ← **Head:** \`${pr.head.ref}\`\n`;
+ richBody += `\n---\n\n`;
+
+ // Add commit history (up to 10 commits)
+ if (commits.data.length > 0) {
+ richBody += `### 📝 Commits (${commits.data.length}${commits.data.length >= 10 ? '+' : ''})\n\n`;
+ commits.data.slice(0, 10).forEach(commit => {
+ const shortSha = commit.sha.substring(0, 7);
+ richBody += `- [\`${shortSha}\`](${commit.html_url}) ${commit.commit.message.split('\n')[0]}\n`;
+ });
+ if (commits.data.length > 10) {
+ richBody += `\n_...and ${commits.data.length - 10} more commits_\n`;
+ }
+ richBody += `\n`;
+ }
+
+ // Add file changes summary
+ if (files.data.length > 0) {
+ const additions = prDetail.data.additions || 0;
+ const deletions = prDetail.data.deletions || 0;
+ const changedFiles = prDetail.data.changed_files || files.data.length;
+
+ richBody += `### 📊 Changes\n\n`;
+ richBody += `**${changedFiles} file${changedFiles !== 1 ? 's' : ''} changed** `;
+ richBody += `(+${additions} additions, -${deletions} deletions)\n\n`;
+
+ // List changed files (up to 20)
+ richBody += `\nView changed files
\n\n`;
+ files.data.slice(0, 20).forEach(file => {
+ const changeIndicator = file.status === 'added' ? '➕' :
+ file.status === 'removed' ? '➖' : '📝';
+ richBody += `${changeIndicator} \`${file.filename}\` (+${file.additions} -${file.deletions})\n`;
+ });
+ if (files.data.length > 20) {
+ richBody += `\n_...and ${files.data.length - 20} more files_\n`;
+ }
+ richBody += `\n \n\n`;
+ }
+
+ // Add original PR description
+ richBody += `### 📄 Description\n\n`;
+ richBody += pr.body || '_No description provided_';
+ richBody += `\n\n---\n`;
+ richBody += `\n🔄 This issue represents a GitHub Pull Request. `;
+ richBody += `It cannot be merged through Gitea due to API limitations.`;
+
+ // Prepare issue title with status indicator
+ const statusPrefix = pr.merged_at ? '[MERGED] ' : (pr.state === 'closed' ? '[CLOSED] ' : '');
+ const issueTitle = `[PR #${pr.number}] ${statusPrefix}${pr.title}`;
+
+ const issueData = {
+ title: issueTitle,
+ body: richBody,
+ labels: [{ name: "pull-request" }],
+ state: pr.state === "closed" ? "closed" : "open",
+ };
+
+ console.log(`[Pull Requests] Creating enriched issue for PR #${pr.number}: ${pr.title}`);
await httpPost(
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repository.name}/issues`,
issueData,
@@ -1611,10 +1684,34 @@ export async function mirrorGitRepoPullRequestsToGitea({
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
}
);
- } catch (error) {
- console.error(
- `Failed to mirror PR #${pr.number}: ${error instanceof Error ? error.message : String(error)}`
- );
+ successCount++;
+ console.log(`[Pull Requests] ✅ Successfully created issue for PR #${pr.number}`);
+ } catch (apiError) {
+ // If the detailed fetch fails, fall back to basic PR info
+ console.log(`[Pull Requests] Falling back to basic info for PR #${pr.number} due to error: ${apiError}`);
+ const basicIssueData = {
+ title: `[PR #${pr.number}] ${pr.title}`,
+ body: `**Original Pull Request:** ${pr.html_url}\n\n**State:** ${pr.state}\n**Merged:** ${pr.merged_at ? 'Yes' : 'No'}\n\n---\n\n${pr.body || 'No description provided'}`,
+ labels: [{ name: "pull-request" }],
+ state: pr.state === "closed" ? "closed" : "open",
+ };
+
+ try {
+ await httpPost(
+ `${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repository.name}/issues`,
+ basicIssueData,
+ {
+ Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
+ }
+ );
+ successCount++;
+ console.log(`[Pull Requests] ✅ Created basic issue for PR #${pr.number}`);
+ } catch (error) {
+ failedCount++;
+ console.error(
+ `[Pull Requests] ❌ Failed to mirror PR #${pr.number}: ${error instanceof Error ? error.message : String(error)}`
+ );
+ }
}
},
{
@@ -1624,7 +1721,7 @@ export async function mirrorGitRepoPullRequestsToGitea({
}
);
- console.log(`✅ Mirrored ${pullRequests.length} pull requests to Gitea`);
+ console.log(`✅ Mirrored ${successCount}/${pullRequests.length} pull requests to Gitea as enriched issues (${failedCount} failed)`);
}
export async function mirrorGitRepoLabelsToGitea({