From 124de5f6e0670b29f286845bb1377f52d18e5738 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 18 Feb 2026 11:50:13 +0100 Subject: [PATCH] git dashboard --- README.md | 1 + docs/MESSAGING_FORWARDING.md | 28 +- .../git-platforms/git-platform-fetcher.ts | 614 ++++++++++++++++++ src/lib/services/messaging/event-forwarder.ts | 63 +- src/routes/api/user/git-dashboard/+server.ts | 55 ++ src/routes/dashboard/+page.svelte | 585 +++++++++++++++++ src/routes/users/[npub]/+page.svelte | 23 + 7 files changed, 1347 insertions(+), 22 deletions(-) create mode 100644 src/lib/services/git-platforms/git-platform-fetcher.ts create mode 100644 src/routes/api/user/git-dashboard/+server.ts create mode 100644 src/routes/dashboard/+page.svelte diff --git a/README.md b/README.md index c5a67f6..3306559 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ See [ARCHITECTURE_FAQ.md](./docs/ARCHITECTURE_FAQ.md) for answers to common arch - **Raw File View**: Direct access to raw file content - **Download Repository**: Download repositories as ZIP archives - **OpenGraph Metadata**: Rich social media previews with repository images and banners +- **Universal Git Dashboard**: Aggregate and view issues and pull requests from all configured git platforms (GitHub, GitLab, Gitea, etc.) in one place ### Security & Validation - **Path Traversal Protection**: Validates and sanitizes file paths diff --git a/docs/MESSAGING_FORWARDING.md b/docs/MESSAGING_FORWARDING.md index 84d5f43..3c1b6b1 100644 --- a/docs/MESSAGING_FORWARDING.md +++ b/docs/MESSAGING_FORWARDING.md @@ -324,12 +324,12 @@ EMAIL_ENABLED=true ### Supported Platforms - **GitHub** (`github`) - github.com -- **GitLab** (`gitlab`) - gitlab.com (also supports self-hosted) -- **Gitea** (`gitea`) - Self-hosted instances -- **Codeberg** (`codeberg`) - codeberg.org -- **Forgejo** (`forgejo`) - Self-hosted instances +- **GitLab** (`gitlab`) - gitlab.com (also supports self-hosted with apiUrl) +- **Gitea** (`gitea`) - Self-hosted instances (defaults to codeberg.org if apiUrl not provided) +- **Codeberg** (`codeberg`) - codeberg.org (uses Gitea API) +- **Forgejo** (`forgejo`) - Self-hosted instances (defaults to forgejo.org if apiUrl not provided) - **OneDev** (`onedev`) - Self-hosted instances (requires apiUrl) -- **Custom** (`custom`) - Any Gitea-compatible API with custom URL +- **Custom** (`custom`) - Any Gitea-compatible API with custom URL (requires apiUrl) ### Creating Personal Access Tokens @@ -363,7 +363,11 @@ Users provide: - **Owner**: Username or organization name (project path for OneDev) - **Repo**: Repository name (project name for OneDev) - **Token**: Personal access token (stored encrypted) -- **API URL** (required for OneDev, optional for custom/self-hosted): Base URL of the instance (e.g., `https://your-onedev-instance.com`) +- **API URL**: + - **Required** for: `onedev`, `custom` + - **Optional** for: `gitea`, `forgejo`, `gitlab` (use for self-hosted instances) + - **Not used** for: `github`, `codeberg` (always use hosted instances) + - Format: Base URL of the instance (e.g., `https://your-gitea-instance.com/api/v1` or `https://your-onedev-instance.com`) ### Event Mapping @@ -373,11 +377,13 @@ Users provide: ### Platform-Specific Notes -- **GitLab**: Uses `description` field instead of `body`, and `source_branch`/`target_branch` for PRs -- **OneDev**: Uses `description` field and `source_branch`/`target_branch` for PRs. Requires `apiUrl` (self-hosted). API endpoints: `/api/projects/{owner}/{repo}/issues` and `/api/projects/{owner}/{repo}/pull-requests` -- **GitHub**: Uses `body` field and `head`/`base` for PRs -- **Gitea/Codeberg/Forgejo**: Compatible with GitHub API format -- **Custom**: Must provide `apiUrl` pointing to Gitea-compatible API +- **GitHub**: Uses `body` field and `head`/`base` for PRs. Always uses `https://api.github.com` +- **GitLab**: Uses `description` field instead of `body`, and `source_branch`/`target_branch` for PRs. Defaults to `https://gitlab.com/api/v4`, but supports self-hosted with `apiUrl` +- **Gitea**: Compatible with GitHub API format. Defaults to `https://codeberg.org/api/v1` (Codeberg), but supports self-hosted instances with `apiUrl` (e.g., `https://your-gitea-instance.com/api/v1`) +- **Codeberg**: Uses Gitea API format. Always uses `https://codeberg.org/api/v1` +- **Forgejo**: Compatible with GitHub API format. Defaults to `https://forgejo.org/api/v1`, but supports self-hosted instances with `apiUrl` (e.g., `https://your-forgejo-instance.com/api/v1`) +- **OneDev**: Uses `description` field and `source_branch`/`target_branch` for PRs. **Requires** `apiUrl` (self-hosted only). API endpoints: `/api/projects/{owner}/{repo}/issues` and `/api/projects/{owner}/{repo}/pull-requests` +- **Custom**: Must provide `apiUrl` pointing to Gitea-compatible API (assumes GitHub/Gitea API format) ### Security Note diff --git a/src/lib/services/git-platforms/git-platform-fetcher.ts b/src/lib/services/git-platforms/git-platform-fetcher.ts new file mode 100644 index 0000000..24dfdce --- /dev/null +++ b/src/lib/services/git-platforms/git-platform-fetcher.ts @@ -0,0 +1,614 @@ +/** + * Git Platform Fetcher Service + * + * Fetches issues, pull requests, and comments from external git platforms + * (GitHub, GitLab, Gitea, Codeberg, Forgejo, OneDev, custom) + * for display in the universal dashboard. + */ + +import logger from '../logger.js'; +import type { MessagingPreferences } from '../messaging/preferences-storage.js'; +import { getPreferences } from '../messaging/preferences-storage.js'; + +type GitPlatform = 'github' | 'gitlab' | 'gitea' | 'codeberg' | 'forgejo' | 'onedev' | 'custom'; + +interface GitPlatformConfig { + baseUrl: string; + issuesPath: string; + pullsPath: string; + commentsPath: string; + authHeader: 'Bearer' | 'token'; + customHeaders?: Record; +} + +interface ExternalIssue { + id: string | number; + number?: number; + title: string; + body: string; + state: 'open' | 'closed' | 'merged'; + created_at: string; + updated_at: string; + user: { + login?: string; + username?: string; + avatar_url?: string; + }; + html_url: string; + comments_url?: string; + comments_count?: number; + labels?: Array<{ name: string; color?: string }>; + platform: GitPlatform; + owner: string; + repo: string; + apiUrl?: string; +} + +interface ExternalPullRequest extends ExternalIssue { + head?: { + ref: string; + sha: string; + }; + base?: { + ref: string; + sha: string; + }; + merged_at?: string | null; + mergeable?: boolean; +} + +interface ExternalComment { + id: string | number; + body: string; + created_at: string; + updated_at: string; + user: { + login?: string; + username?: string; + avatar_url?: string; + }; + html_url: string; + issue_url?: string; + pull_request_url?: string; +} + +// Platform configurations +const GIT_PLATFORM_CONFIGS: Record> = { + github: { + issuesPath: '/repos/{owner}/{repo}/issues', + pullsPath: '/repos/{owner}/{repo}/pulls', + commentsPath: '/repos/{owner}/{repo}/issues/{issue_number}/comments', + authHeader: 'Bearer', + customHeaders: { 'Accept': 'application/vnd.github.v3+json' } + }, + gitlab: { + issuesPath: '/projects/{owner}%2F{repo}/issues', + pullsPath: '/projects/{owner}%2F{repo}/merge_requests', + commentsPath: '/projects/{owner}%2F{repo}/issues/{issue_id}/notes', + authHeader: 'Bearer' + }, + gitea: { + issuesPath: '/repos/{owner}/{repo}/issues', + pullsPath: '/repos/{owner}/{repo}/pulls', + commentsPath: '/repos/{owner}/{repo}/issues/{issue_index}/comments', + authHeader: 'token' + }, + codeberg: { + issuesPath: '/repos/{owner}/{repo}/issues', + pullsPath: '/repos/{owner}/{repo}/pulls', + commentsPath: '/repos/{owner}/{repo}/issues/{issue_index}/comments', + authHeader: 'token' + }, + forgejo: { + issuesPath: '/repos/{owner}/{repo}/issues', + pullsPath: '/repos/{owner}/{repo}/pulls', + commentsPath: '/repos/{owner}/{repo}/issues/{issue_index}/comments', + authHeader: 'token' + }, + onedev: { + issuesPath: '/{owner}/{repo}/issues', + pullsPath: '/{owner}/{repo}/pull-requests', + commentsPath: '/{owner}/{repo}/issues/{issue_id}/comments', + authHeader: 'Bearer' + }, + custom: { + issuesPath: '/repos/{owner}/{repo}/issues', + pullsPath: '/repos/{owner}/{repo}/pulls', + commentsPath: '/repos/{owner}/{repo}/issues/{issue_number}/comments', + authHeader: 'Bearer' + } +}; + +function getGitPlatformConfig( + platform: GitPlatform, + customApiUrl?: string +): GitPlatformConfig { + if (platform === 'onedev') { + if (!customApiUrl) { + throw new Error('OneDev requires apiUrl to be provided'); + } + return { + ...GIT_PLATFORM_CONFIGS.onedev, + baseUrl: customApiUrl + }; + } + + if (platform === 'gitea' || platform === 'forgejo') { + const config = GIT_PLATFORM_CONFIGS[platform]; + const baseUrls: Record = { + gitea: customApiUrl || 'https://codeberg.org/api/v1', + forgejo: customApiUrl || 'https://forgejo.org/api/v1' + }; + return { + ...config, + baseUrl: baseUrls[platform] + }; + } + + if (platform === 'custom') { + if (!customApiUrl) { + throw new Error('Custom platform requires apiUrl'); + } + return { + ...GIT_PLATFORM_CONFIGS.custom, + baseUrl: customApiUrl + }; + } + + if (customApiUrl) { + const config = GIT_PLATFORM_CONFIGS[platform]; + return { + ...config, + baseUrl: customApiUrl + }; + } + + const config = GIT_PLATFORM_CONFIGS[platform]; + const baseUrls: Record = { + github: 'https://api.github.com', + gitlab: 'https://gitlab.com/api/v4', + codeberg: 'https://codeberg.org/api/v1' + }; + + return { + ...config, + baseUrl: baseUrls[platform] || '' + }; +} + +function buildUrl( + config: GitPlatformConfig, + path: string, + owner: string, + repo: string, + platform: GitPlatform, + params?: Record +): string { + let urlPath = path + .replace('{owner}', encodeURIComponent(owner)) + .replace('{repo}', encodeURIComponent(repo)); + + if (platform === 'gitlab') { + // GitLab uses URL-encoded owner/repo + urlPath = path.replace('{owner}%2F{repo}', encodeURIComponent(`${owner}/${repo}`)); + } + + if (platform === 'onedev') { + // OneDev uses project-path format: /api/projects/{project-path}/issues + const projectPath = repo ? `${owner}/${repo}` : owner; + urlPath = path.replace('{owner}/{repo}', encodeURIComponent(projectPath)); + // OneDev paths are relative to /api/projects/ (baseUrl already includes /api/projects) + // But we need to add it here since path is relative + if (!urlPath.startsWith('/api/projects')) { + urlPath = `/api/projects${urlPath}`; + } + } + + // Replace path parameters + if (params) { + for (const [key, value] of Object.entries(params)) { + urlPath = urlPath.replace(`{${key}}`, String(value)); + } + } + + const url = `${config.baseUrl}${urlPath}`; + const searchParams = new URLSearchParams(); + searchParams.set('state', 'all'); // Get both open and closed + searchParams.set('per_page', '50'); // Limit results + searchParams.set('sort', 'updated'); + searchParams.set('direction', 'desc'); + + return `${url}?${searchParams.toString()}`; +} + +function buildAuthHeader(config: GitPlatformConfig, token: string): string { + return config.authHeader === 'Bearer' ? `Bearer ${token}` : `token ${token}`; +} + +async function fetchFromPlatform( + url: string, + headers: Record, + platform: string +): Promise { + try { + const response = await fetch(url, { headers }); + + if (!response.ok) { + if (response.status === 404) { + return []; // Repository or resource not found + } + const error = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(`${platform} API error: ${error.message || response.statusText}`); + } + + const data = await response.json(); + return Array.isArray(data) ? data : [data]; + } catch (error) { + logger.error({ error, url: url.slice(0, 100) + '...', platform }, 'Failed to fetch from git platform'); + return []; + } +} + +function normalizeIssue( + raw: any, + platform: GitPlatform, + owner: string, + repo: string, + apiUrl?: string +): ExternalIssue { + // GitHub format + if (raw.number !== undefined) { + return { + id: raw.id || raw.number, + number: raw.number, + title: raw.title || '', + body: raw.body || '', + state: raw.state === 'closed' ? 'closed' : 'open', + created_at: raw.created_at || '', + updated_at: raw.updated_at || '', + user: { + login: raw.user?.login, + avatar_url: raw.user?.avatar_url + }, + html_url: raw.html_url || '', + comments_url: raw.comments_url, + comments_count: raw.comments, + labels: raw.labels?.map((l: any) => ({ name: l.name, color: l.color })), + platform, + owner, + repo, + apiUrl + }; + } + + // GitLab format + if (raw.iid !== undefined) { + const baseUrl = apiUrl || (platform === 'gitlab' ? 'https://gitlab.com' : ''); + return { + id: raw.id || raw.iid, + number: raw.iid, + title: raw.title || '', + body: raw.description || '', + state: raw.state === 'closed' ? 'closed' : 'open', + created_at: raw.created_at || '', + updated_at: raw.updated_at || '', + user: { + username: raw.author?.username, + avatar_url: raw.author?.avatar_url + }, + html_url: raw.web_url || `${baseUrl}/${owner}/${repo}/-/issues/${raw.iid}`, + comments_count: raw.user_notes_count, + labels: raw.labels?.map((l: string) => ({ name: l })), + platform, + owner, + repo, + apiUrl + }; + } + + // Gitea/Codeberg/Forgejo format + if (raw.index !== undefined) { + const baseUrl = apiUrl || (platform === 'codeberg' ? 'https://codeberg.org' : ''); + return { + id: raw.id || raw.index, + number: raw.index, + title: raw.title || '', + body: raw.body || '', + state: raw.state === 'closed' ? 'closed' : 'open', + created_at: raw.created_at || '', + updated_at: raw.updated_at || '', + user: { + username: raw.user?.username || raw.poster?.username, + avatar_url: raw.user?.avatar_url || raw.poster?.avatar_url + }, + html_url: raw.html_url || `${baseUrl}/${owner}/${repo}/issues/${raw.index}`, + comments_count: raw.comments, + labels: raw.labels?.map((l: any) => ({ name: typeof l === 'string' ? l : l.name })), + platform, + owner, + repo, + apiUrl + }; + } + + // OneDev format + if (platform === 'onedev') { + const baseUrl = apiUrl || ''; + return { + id: raw.id || raw.number, + number: raw.number, + title: raw.title || '', + body: raw.description || '', + state: raw.state === 'closed' ? 'closed' : 'open', + created_at: raw.submitDate || '', + updated_at: raw.updateDate || '', + user: { + username: raw.submitter?.name, + avatar_url: raw.submitter?.avatarUrl + }, + html_url: raw.url || `${baseUrl}/${owner}/${repo}/issues/${raw.number}`, + platform, + owner, + repo, + apiUrl + }; + } + + // Fallback + return { + id: raw.id || raw.number || 0, + number: raw.number, + title: raw.title || '', + body: raw.body || raw.description || '', + state: 'open', + created_at: raw.created_at || '', + updated_at: raw.updated_at || '', + user: {}, + html_url: raw.html_url || raw.web_url || '', + platform, + owner, + repo, + apiUrl + }; +} + +function normalizePullRequest( + raw: any, + platform: GitPlatform, + owner: string, + repo: string, + apiUrl?: string +): ExternalPullRequest { + const issue = normalizeIssue(raw, platform, owner, repo, apiUrl); + + // GitHub format + if (raw.head && raw.base) { + return { + ...issue, + state: raw.merged ? 'merged' : (raw.state === 'closed' ? 'closed' : 'open'), + head: { + ref: raw.head.ref, + sha: raw.head.sha + }, + base: { + ref: raw.base.ref, + sha: raw.base.sha + }, + merged_at: raw.merged_at, + mergeable: raw.mergeable + }; + } + + // GitLab format + if (raw.source_branch && raw.target_branch) { + return { + ...issue, + state: raw.state === 'merged' ? 'merged' : (raw.state === 'closed' ? 'closed' : 'open'), + head: { + ref: raw.source_branch, + sha: raw.sha || '' + }, + base: { + ref: raw.target_branch, + sha: '' + }, + merged_at: raw.merged_at + }; + } + + // Gitea/Codeberg/Forgejo format + if (raw.head && typeof raw.head === 'object') { + return { + ...issue, + state: raw.state === 'closed' ? 'closed' : 'open', + head: { + ref: raw.head.ref || raw.head.name || '', + sha: raw.head.sha || '' + }, + base: { + ref: raw.base?.ref || raw.base?.name || '', + sha: raw.base?.sha || '' + } + }; + } + + return issue as ExternalPullRequest; +} + +/** + * Fetch issues from a git platform + */ +async function fetchIssues( + platform: GitPlatform, + owner: string, + repo: string, + token: string, + apiUrl?: string +): Promise { + try { + const config = getGitPlatformConfig(platform, apiUrl); + const url = buildUrl(config, config.issuesPath, owner, repo, platform); + const headers: Record = { + 'Authorization': buildAuthHeader(config, token), + 'Content-Type': 'application/json', + 'User-Agent': 'GitRepublic', + ...(config.customHeaders || {}) + }; + + const rawIssues = await fetchFromPlatform(url, headers, platform); + return rawIssues + .filter((issue: any) => !issue.pull_request) // Exclude PRs (GitHub returns PRs in issues endpoint) + .map((issue: any) => normalizeIssue(issue, platform, owner, repo, apiUrl)); + } catch (error) { + logger.error({ error, platform, owner, repo }, 'Failed to fetch issues'); + return []; + } +} + +/** + * Fetch pull requests from a git platform + */ +async function fetchPullRequests( + platform: GitPlatform, + owner: string, + repo: string, + token: string, + apiUrl?: string +): Promise { + try { + const config = getGitPlatformConfig(platform, apiUrl); + const url = buildUrl(config, config.pullsPath, owner, repo, platform); + const headers: Record = { + 'Authorization': buildAuthHeader(config, token), + 'Content-Type': 'application/json', + 'User-Agent': 'GitRepublic', + ...(config.customHeaders || {}) + }; + + const rawPRs = await fetchFromPlatform(url, headers, platform); + return rawPRs.map((pr: any) => normalizePullRequest(pr, platform, owner, repo, apiUrl)); + } catch (error) { + logger.error({ error, platform, owner, repo }, 'Failed to fetch pull requests'); + return []; + } +} + +/** + * Fetch comments for an issue or PR + */ +async function fetchComments( + platform: GitPlatform, + owner: string, + repo: string, + issueNumber: number, + token: string, + apiUrl?: string +): Promise { + try { + const config = getGitPlatformConfig(platform, apiUrl); + const commentsPath = config.commentsPath.replace('{issue_number}', String(issueNumber)) + .replace('{issue_id}', String(issueNumber)) + .replace('{issue_index}', String(issueNumber)); + const url = buildUrl(config, commentsPath, owner, repo, platform); + const headers: Record = { + 'Authorization': buildAuthHeader(config, token), + 'Content-Type': 'application/json', + 'User-Agent': 'GitRepublic', + ...(config.customHeaders || {}) + }; + + const rawComments = await fetchFromPlatform(url, headers, platform); + return rawComments.map((comment: any) => ({ + id: comment.id, + body: comment.body || comment.note || '', + created_at: comment.created_at || '', + updated_at: comment.updated_at || '', + user: { + login: comment.user?.login || comment.author?.username, + username: comment.user?.username || comment.author?.username, + avatar_url: comment.user?.avatar_url || comment.author?.avatar_url + }, + html_url: comment.html_url || comment.url || '', + issue_url: comment.issue_url, + pull_request_url: comment.pull_request_url + })); + } catch (error) { + logger.error({ error, platform, owner, repo, issueNumber }, 'Failed to fetch comments'); + return []; + } +} + +/** + * Get all issues and PRs from user's configured git platforms + */ +export async function getAllExternalItems( + userPubkeyHex: string +): Promise<{ + issues: ExternalIssue[]; + pullRequests: ExternalPullRequest[]; +}> { + const preferences = await getPreferences(userPubkeyHex); + if (!preferences || !preferences.gitPlatforms || preferences.gitPlatforms.length === 0) { + return { issues: [], pullRequests: [] }; + } + + const allIssues: ExternalIssue[] = []; + const allPRs: ExternalPullRequest[] = []; + + // Fetch from all configured platforms in parallel + const promises: Promise[] = []; + + for (const gitPlatform of preferences.gitPlatforms) { + if (!gitPlatform.owner || !gitPlatform.repo || !gitPlatform.token) { + continue; + } + + if (gitPlatform.platform === 'onedev' && !gitPlatform.apiUrl) { + logger.warn({ platform: 'onedev' }, 'OneDev requires apiUrl'); + continue; + } + + if (gitPlatform.platform === 'custom' && !gitPlatform.apiUrl) { + logger.warn({ platform: 'custom' }, 'Custom platform requires apiUrl'); + continue; + } + + promises.push( + fetchIssues( + gitPlatform.platform, + gitPlatform.owner, + gitPlatform.repo, + gitPlatform.token, + gitPlatform.apiUrl + ).then(issues => { + allIssues.push(...issues); + }).catch(err => { + logger.warn({ error: err, platform: gitPlatform.platform }, 'Failed to fetch issues'); + }) + ); + + promises.push( + fetchPullRequests( + gitPlatform.platform, + gitPlatform.owner, + gitPlatform.repo, + gitPlatform.token, + gitPlatform.apiUrl + ).then(prs => { + allPRs.push(...prs); + }).catch(err => { + logger.warn({ error: err, platform: gitPlatform.platform }, 'Failed to fetch PRs'); + }) + ); + } + + await Promise.allSettled(promises); + + // Sort by updated_at descending + allIssues.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); + allPRs.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); + + return { issues: allIssues, pullRequests: allPRs }; +} + +export type { ExternalIssue, ExternalPullRequest, ExternalComment, GitPlatform }; diff --git a/src/lib/services/messaging/event-forwarder.ts b/src/lib/services/messaging/event-forwarder.ts index 771bbed..06a4ee2 100644 --- a/src/lib/services/messaging/event-forwarder.ts +++ b/src/lib/services/messaging/event-forwarder.ts @@ -107,8 +107,8 @@ const GIT_PLATFORM_CONFIGS: Record> = usesSourceTargetBranch: false }, onedev: { - issuesPath: '/api/projects/{owner}/{repo}/issues', - pullsPath: '/api/projects/{owner}/{repo}/pull-requests', + issuesPath: '/{owner}/{repo}/issues', // Path relative to /api/projects/ (added in buildGitPlatformUrl) + pullsPath: '/{owner}/{repo}/pull-requests', // Path relative to /api/projects/ (added in buildGitPlatformUrl) authHeader: 'Bearer', usesDescription: true, usesSourceTargetBranch: true @@ -200,8 +200,30 @@ function getGitPlatformConfig( }; } - if (customApiUrl) { - // Custom platform - assume Gitea-compatible format + // Gitea and Forgejo are self-hosted - require apiUrl if not using Codeberg/Forgejo.org defaults + if (platform === 'gitea' || platform === 'forgejo') { + const config = GIT_PLATFORM_CONFIGS[platform]; + if (!config) { + throw new Error(`Unsupported Git platform: ${platform}`); + } + + // Use custom API URL if provided, otherwise use default hosted instance + const baseUrls: Record = { + gitea: customApiUrl || 'https://codeberg.org/api/v1', // Codeberg uses Gitea + forgejo: customApiUrl || 'https://forgejo.org/api/v1' // Forgejo.org hosted instance + }; + + return { + ...config, + baseUrl: baseUrls[platform] + }; + } + + // Custom platform - assume Gitea-compatible format + if (platform === 'custom') { + if (!customApiUrl) { + throw new Error('Custom platform requires apiUrl to be provided'); + } return { baseUrl: customApiUrl, issuesPath: '/repos/{owner}/{repo}/issues', @@ -212,6 +234,18 @@ function getGitPlatformConfig( }; } + // If customApiUrl is provided for other platforms, use it but keep platform config + if (customApiUrl) { + const config = GIT_PLATFORM_CONFIGS[platform]; + if (!config) { + throw new Error(`Unsupported Git platform: ${platform}`); + } + return { + ...config, + baseUrl: customApiUrl + }; + } + const config = GIT_PLATFORM_CONFIGS[platform]; if (!config) { throw new Error(`Unsupported Git platform: ${platform}`); @@ -221,9 +255,7 @@ function getGitPlatformConfig( const baseUrls: Record = { github: 'https://api.github.com', gitlab: 'https://gitlab.com/api/v4', - gitea: 'https://codeberg.org/api/v1', - codeberg: 'https://codeberg.org/api/v1', - forgejo: 'https://forgejo.org/api/v1' + codeberg: 'https://codeberg.org/api/v1' }; return { @@ -239,14 +271,18 @@ function buildGitPlatformUrl( pathType: 'issues' | 'pulls', platform: GitPlatform ): string { - const path = pathType === 'issues' ? config.issuesPath : config.pullsPath; - if (platform === 'onedev') { + // OneDev uses project-path format: /api/projects/{project-path}/issues const projectPath = repo ? `${owner}/${repo}` : owner; - const endpoint = pathType === 'issues' ? 'issues' : 'pull-requests'; - return `${config.baseUrl}/api/projects/${encodeURIComponent(projectPath)}/${endpoint}`; + const path = pathType === 'issues' ? config.issuesPath : config.pullsPath; + // Path already contains {owner}/{repo}, just replace them + const urlPath = path + .replace('{owner}', encodeURIComponent(owner)) + .replace('{repo}', encodeURIComponent(repo)); + return `${config.baseUrl}/api/projects${urlPath}`; } + const path = pathType === 'issues' ? config.issuesPath : config.pullsPath; const urlPath = path .replace('{owner}', encodeURIComponent(owner)) .replace('{repo}', encodeURIComponent(repo)); @@ -596,10 +632,15 @@ export async function forwardEventIfEnabled( continue; } + // Validate self-hosted platforms that require apiUrl if (gitPlatform.platform === 'onedev' && !gitPlatform.apiUrl) { logger.warn({ platform: 'onedev' }, 'OneDev requires apiUrl to be provided'); continue; } + if (gitPlatform.platform === 'custom' && !gitPlatform.apiUrl) { + logger.warn({ platform: 'custom' }, 'Custom platform requires apiUrl to be provided'); + continue; + } promises.push( forwardToGitPlatform( diff --git a/src/routes/api/user/git-dashboard/+server.ts b/src/routes/api/user/git-dashboard/+server.ts new file mode 100644 index 0000000..f6d3241 --- /dev/null +++ b/src/routes/api/user/git-dashboard/+server.ts @@ -0,0 +1,55 @@ +/** + * Git Dashboard API + * + * Aggregates issues and pull requests from external git platforms + * for display in the universal dashboard. + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { extractRequestContext } from '$lib/utils/api-context.js'; +import { getAllExternalItems } from '$lib/services/git-platforms/git-platform-fetcher.js'; +import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js'; +import logger from '$lib/services/logger.js'; + +/** + * GET /api/user/git-dashboard + * Get aggregated issues and PRs from all configured git platforms + */ +export const GET: RequestHandler = async (event) => { + const requestContext = extractRequestContext(event); + const clientIp = requestContext.clientIp || 'unknown'; + + try { + if (!requestContext.userPubkeyHex) { + return error(401, 'Authentication required'); + } + + // Check user has unlimited access (same requirement as messaging forwarding) + const userLevel = getCachedUserLevel(requestContext.userPubkeyHex); + if (!userLevel || userLevel.level !== 'unlimited') { + return json({ + issues: [], + pullRequests: [], + message: 'Git dashboard requires unlimited access. Please verify you can write to at least one default Nostr relay.' + }); + } + + const { issues, pullRequests } = await getAllExternalItems(requestContext.userPubkeyHex); + + logger.debug({ + userPubkey: requestContext.userPubkeyHex.slice(0, 16) + '...', + issuesCount: issues.length, + prsCount: pullRequests.length, + clientIp + }, 'Fetched git dashboard data'); + + return json({ + issues, + pullRequests + }); + } catch (e) { + logger.error({ error: e, clientIp }, 'Failed to fetch git dashboard data'); + return error(500, 'Failed to fetch git dashboard data'); + } +}; diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte new file mode 100644 index 0000000..f09d1ba --- /dev/null +++ b/src/routes/dashboard/+page.svelte @@ -0,0 +1,585 @@ + + +
+
+

Universal Git Dashboard

+

Aggregated issues and pull requests from all your configured git platforms

+ {#if userPubkeyHex} + + {/if} +
+ + {#if loading} +
Loading dashboard...
+ {:else if error} +
+

{error}

+ {#if !userPubkeyHex} +

Please connect your NIP-07 extension to view the dashboard.

+ {/if} +
+ {:else if issues.length === 0 && pullRequests.length === 0} +
+

No items found

+

Configure git platform forwarding in your messaging preferences to see issues and pull requests here.

+ {#if userPubkeyHex} +

Go to your profile to configure platforms.

+ {/if} +
+ {:else} + +
+ + + +
+ + +
+ {#each filteredItems() as item} + {@const isPR = 'head' in item} +
+
+
+ + {isPR ? '🔀 PR' : '📋 Issue'} + + + {item.title || 'Untitled'} + +
+
+ + {getPlatformIcon(item.platform)} {getPlatformName(item.platform)} + + {getRepoDisplay(item)} + {#if item.number} + #{item.number} + {/if} +
+
+ +
+

+ {item.body ? (item.body.length > 200 ? item.body.slice(0, 200) + '...' : item.body) : 'No description'} +

+
+ + + + +
+ {/each} +
+ {/if} +
+ + diff --git a/src/routes/users/[npub]/+page.svelte b/src/routes/users/[npub]/+page.svelte index 98c0957..b78f9a6 100644 --- a/src/routes/users/[npub]/+page.svelte +++ b/src/routes/users/[npub]/+page.svelte @@ -272,6 +272,9 @@ {#if getForwardingPubkey()} + {/if} @@ -685,4 +688,24 @@ opacity: 0.5; cursor: not-allowed; } + + .dashboard-link { + margin-top: 1rem; + } + + .dashboard-button { + display: inline-block; + padding: 0.75rem 1.5rem; + background: var(--accent); + color: white; + text-decoration: none; + border-radius: 6px; + font-size: 0.95rem; + font-weight: 500; + transition: background 0.2s; + } + + .dashboard-button:hover { + background: var(--accent-dark); + }