Browse Source

git dashboard

main
Silberengel 4 weeks ago
parent
commit
124de5f6e0
  1. 1
      README.md
  2. 28
      docs/MESSAGING_FORWARDING.md
  3. 614
      src/lib/services/git-platforms/git-platform-fetcher.ts
  4. 61
      src/lib/services/messaging/event-forwarder.ts
  5. 55
      src/routes/api/user/git-dashboard/+server.ts
  6. 585
      src/routes/dashboard/+page.svelte
  7. 23
      src/routes/users/[npub]/+page.svelte

1
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 - **Raw File View**: Direct access to raw file content
- **Download Repository**: Download repositories as ZIP archives - **Download Repository**: Download repositories as ZIP archives
- **OpenGraph Metadata**: Rich social media previews with repository images and banners - **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 ### Security & Validation
- **Path Traversal Protection**: Validates and sanitizes file paths - **Path Traversal Protection**: Validates and sanitizes file paths

28
docs/MESSAGING_FORWARDING.md

@ -324,12 +324,12 @@ EMAIL_ENABLED=true
### Supported Platforms ### Supported Platforms
- **GitHub** (`github`) - github.com - **GitHub** (`github`) - github.com
- **GitLab** (`gitlab`) - gitlab.com (also supports self-hosted) - **GitLab** (`gitlab`) - gitlab.com (also supports self-hosted with apiUrl)
- **Gitea** (`gitea`) - Self-hosted instances - **Gitea** (`gitea`) - Self-hosted instances (defaults to codeberg.org if apiUrl not provided)
- **Codeberg** (`codeberg`) - codeberg.org - **Codeberg** (`codeberg`) - codeberg.org (uses Gitea API)
- **Forgejo** (`forgejo`) - Self-hosted instances - **Forgejo** (`forgejo`) - Self-hosted instances (defaults to forgejo.org if apiUrl not provided)
- **OneDev** (`onedev`) - Self-hosted instances (requires apiUrl) - **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 ### Creating Personal Access Tokens
@ -363,7 +363,11 @@ Users provide:
- **Owner**: Username or organization name (project path for OneDev) - **Owner**: Username or organization name (project path for OneDev)
- **Repo**: Repository name (project name for OneDev) - **Repo**: Repository name (project name for OneDev)
- **Token**: Personal access token (stored encrypted) - **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 ### Event Mapping
@ -373,11 +377,13 @@ Users provide:
### Platform-Specific Notes ### Platform-Specific Notes
- **GitLab**: Uses `description` field instead of `body`, and `source_branch`/`target_branch` for PRs - **GitHub**: Uses `body` field and `head`/`base` for PRs. Always uses `https://api.github.com`
- **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` - **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`
- **GitHub**: Uses `body` field and `head`/`base` for PRs - **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`)
- **Gitea/Codeberg/Forgejo**: Compatible with GitHub API format - **Codeberg**: Uses Gitea API format. Always uses `https://codeberg.org/api/v1`
- **Custom**: Must provide `apiUrl` pointing to Gitea-compatible API - **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 ### Security Note

614
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<string, string>;
}
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<string, Omit<GitPlatformConfig, 'baseUrl'>> = {
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<string, string> = {
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<string, string> = {
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, string | number>
): 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<T>(
url: string,
headers: Record<string, string>,
platform: string
): Promise<T[]> {
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<ExternalIssue[]> {
try {
const config = getGitPlatformConfig(platform, apiUrl);
const url = buildUrl(config, config.issuesPath, owner, repo, platform);
const headers: Record<string, string> = {
'Authorization': buildAuthHeader(config, token),
'Content-Type': 'application/json',
'User-Agent': 'GitRepublic',
...(config.customHeaders || {})
};
const rawIssues = await fetchFromPlatform<any>(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<ExternalPullRequest[]> {
try {
const config = getGitPlatformConfig(platform, apiUrl);
const url = buildUrl(config, config.pullsPath, owner, repo, platform);
const headers: Record<string, string> = {
'Authorization': buildAuthHeader(config, token),
'Content-Type': 'application/json',
'User-Agent': 'GitRepublic',
...(config.customHeaders || {})
};
const rawPRs = await fetchFromPlatform<any>(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<ExternalComment[]> {
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<string, string> = {
'Authorization': buildAuthHeader(config, token),
'Content-Type': 'application/json',
'User-Agent': 'GitRepublic',
...(config.customHeaders || {})
};
const rawComments = await fetchFromPlatform<any>(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<void>[] = [];
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 };

61
src/lib/services/messaging/event-forwarder.ts

@ -107,8 +107,8 @@ const GIT_PLATFORM_CONFIGS: Record<string, Omit<GitPlatformConfig, 'baseUrl'>> =
usesSourceTargetBranch: false usesSourceTargetBranch: false
}, },
onedev: { onedev: {
issuesPath: '/api/projects/{owner}/{repo}/issues', issuesPath: '/{owner}/{repo}/issues', // Path relative to /api/projects/ (added in buildGitPlatformUrl)
pullsPath: '/api/projects/{owner}/{repo}/pull-requests', pullsPath: '/{owner}/{repo}/pull-requests', // Path relative to /api/projects/ (added in buildGitPlatformUrl)
authHeader: 'Bearer', authHeader: 'Bearer',
usesDescription: true, usesDescription: true,
usesSourceTargetBranch: true usesSourceTargetBranch: true
@ -200,8 +200,30 @@ function getGitPlatformConfig(
}; };
} }
if (customApiUrl) { // 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<string, string> = {
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 // Custom platform - assume Gitea-compatible format
if (platform === 'custom') {
if (!customApiUrl) {
throw new Error('Custom platform requires apiUrl to be provided');
}
return { return {
baseUrl: customApiUrl, baseUrl: customApiUrl,
issuesPath: '/repos/{owner}/{repo}/issues', 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]; const config = GIT_PLATFORM_CONFIGS[platform];
if (!config) { if (!config) {
throw new Error(`Unsupported Git platform: ${platform}`); throw new Error(`Unsupported Git platform: ${platform}`);
@ -221,9 +255,7 @@ function getGitPlatformConfig(
const baseUrls: Record<string, string> = { const baseUrls: Record<string, string> = {
github: 'https://api.github.com', github: 'https://api.github.com',
gitlab: 'https://gitlab.com/api/v4', gitlab: 'https://gitlab.com/api/v4',
gitea: 'https://codeberg.org/api/v1', codeberg: 'https://codeberg.org/api/v1'
codeberg: 'https://codeberg.org/api/v1',
forgejo: 'https://forgejo.org/api/v1'
}; };
return { return {
@ -239,14 +271,18 @@ function buildGitPlatformUrl(
pathType: 'issues' | 'pulls', pathType: 'issues' | 'pulls',
platform: GitPlatform platform: GitPlatform
): string { ): string {
const path = pathType === 'issues' ? config.issuesPath : config.pullsPath;
if (platform === 'onedev') { if (platform === 'onedev') {
// OneDev uses project-path format: /api/projects/{project-path}/issues
const projectPath = repo ? `${owner}/${repo}` : owner; const projectPath = repo ? `${owner}/${repo}` : owner;
const endpoint = pathType === 'issues' ? 'issues' : 'pull-requests'; const path = pathType === 'issues' ? config.issuesPath : config.pullsPath;
return `${config.baseUrl}/api/projects/${encodeURIComponent(projectPath)}/${endpoint}`; // 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 const urlPath = path
.replace('{owner}', encodeURIComponent(owner)) .replace('{owner}', encodeURIComponent(owner))
.replace('{repo}', encodeURIComponent(repo)); .replace('{repo}', encodeURIComponent(repo));
@ -596,10 +632,15 @@ export async function forwardEventIfEnabled(
continue; continue;
} }
// Validate self-hosted platforms that require apiUrl
if (gitPlatform.platform === 'onedev' && !gitPlatform.apiUrl) { if (gitPlatform.platform === 'onedev' && !gitPlatform.apiUrl) {
logger.warn({ platform: 'onedev' }, 'OneDev requires apiUrl to be provided'); logger.warn({ platform: 'onedev' }, 'OneDev requires apiUrl to be provided');
continue; continue;
} }
if (gitPlatform.platform === 'custom' && !gitPlatform.apiUrl) {
logger.warn({ platform: 'custom' }, 'Custom platform requires apiUrl to be provided');
continue;
}
promises.push( promises.push(
forwardToGitPlatform( forwardToGitPlatform(

55
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');
}
};

585
src/routes/dashboard/+page.svelte

@ -0,0 +1,585 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
import { nip19 } from 'nostr-tools';
import type { ExternalIssue, ExternalPullRequest } from '$lib/services/git-platforms/git-platform-fetcher.js';
let loading = $state(true);
let error = $state<string | null>(null);
let userPubkeyHex = $state<string | null>(null);
let issues = $state<ExternalIssue[]>([]);
let pullRequests = $state<ExternalPullRequest[]>([]);
let activeTab = $state<'issues' | 'prs' | 'all'>('all');
const PLATFORM_NAMES: Record<string, string> = {
github: 'GitHub',
gitlab: 'GitLab',
gitea: 'Gitea',
codeberg: 'Codeberg',
forgejo: 'Forgejo',
onedev: 'OneDev',
custom: 'Custom'
};
onMount(async () => {
await loadUserPubkey();
if (userPubkeyHex) {
await loadDashboard();
} else {
loading = false;
error = 'Please connect your NIP-07 extension to view the dashboard';
}
});
async function loadUserPubkey() {
if (!isNIP07Available()) {
return;
}
try {
const pubkey = await getPublicKeyWithNIP07();
try {
const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') {
userPubkeyHex = decoded.data as string;
} else {
userPubkeyHex = pubkey;
}
} catch {
userPubkeyHex = pubkey;
}
} catch (err) {
console.warn('Failed to load user pubkey:', err);
}
}
async function loadDashboard() {
if (!userPubkeyHex) return;
loading = true;
error = null;
try {
const response = await fetch('/api/user/git-dashboard', {
headers: {
'X-User-Pubkey': userPubkeyHex
}
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || `Failed to load dashboard: ${response.statusText}`);
}
const data = await response.json();
issues = data.issues || [];
pullRequests = data.pullRequests || [];
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load dashboard';
console.error('Error loading dashboard:', err);
} finally {
loading = false;
}
}
function getPlatformName(platform: string): string {
return PLATFORM_NAMES[platform] || platform;
}
function getPlatformIcon(platform: string): string {
const icons: Record<string, string> = {
github: '🐙',
gitlab: '🦊',
gitea: '🐈',
codeberg: '🦫',
forgejo: '🔨',
onedev: '📦',
custom: '⚙'
};
return icons[platform] || '📦';
}
function formatDate(dateString: string): string {
if (!dateString) return 'Unknown';
try {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
} catch {
return dateString;
}
}
function getRepoDisplay(issue: ExternalIssue | ExternalPullRequest): string {
return `${issue.owner}/${issue.repo}`;
}
const filteredItems = $derived(() => {
if (activeTab === 'issues') {
return issues;
} else if (activeTab === 'prs') {
return pullRequests;
} else {
// Combine and sort by updated_at
const all = [
...issues.map(i => ({ ...i, type: 'issue' as const })),
...pullRequests.map(pr => ({ ...pr, type: 'pr' as const }))
];
return all.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
}
});
</script>
<div class="dashboard-container">
<header class="dashboard-header">
<h1>Universal Git Dashboard</h1>
<p class="dashboard-subtitle">Aggregated issues and pull requests from all your configured git platforms</p>
{#if userPubkeyHex}
<button onclick={loadDashboard} class="refresh-button" disabled={loading}>
{loading ? 'Refreshing...' : '🔄 Refresh'}
</button>
{/if}
</header>
{#if loading}
<div class="loading">Loading dashboard...</div>
{:else if error}
<div class="error">
<p>{error}</p>
{#if !userPubkeyHex}
<p>Please connect your NIP-07 extension to view the dashboard.</p>
{/if}
</div>
{:else if issues.length === 0 && pullRequests.length === 0}
<div class="empty-state">
<h2>No items found</h2>
<p>Configure git platform forwarding in your messaging preferences to see issues and pull requests here.</p>
{#if userPubkeyHex}
<p>Go to your <a href="/users/{nip19.npubEncode(userPubkeyHex)}">profile</a> to configure platforms.</p>
{/if}
</div>
{:else}
<!-- Tabs -->
<div class="tabs">
<button
class="tab-button"
class:active={activeTab === 'all'}
onclick={() => activeTab = 'all'}
>
All ({issues.length + pullRequests.length})
</button>
<button
class="tab-button"
class:active={activeTab === 'issues'}
onclick={() => activeTab = 'issues'}
>
Issues ({issues.length})
</button>
<button
class="tab-button"
class:active={activeTab === 'prs'}
onclick={() => activeTab = 'prs'}
>
Pull Requests ({pullRequests.length})
</button>
</div>
<!-- Items List -->
<div class="items-list">
{#each filteredItems() as item}
{@const isPR = 'head' in item}
<div class="item-card" class:pr={isPR} class:issue={!isPR}>
<div class="item-header">
<div class="item-title-row">
<span class="item-type-badge" class:pr={isPR} class:issue={!isPR}>
{isPR ? '🔀 PR' : '📋 Issue'}
</span>
<a
href={item.html_url}
target="_blank"
rel="noopener noreferrer"
class="item-title"
>
{item.title || 'Untitled'}
</a>
</div>
<div class="item-meta">
<span class="platform-badge" title={item.apiUrl || ''}>
{getPlatformIcon(item.platform)} {getPlatformName(item.platform)}
</span>
<span class="repo-name">{getRepoDisplay(item)}</span>
{#if item.number}
<span class="item-number">#{item.number}</span>
{/if}
</div>
</div>
<div class="item-body">
<p class="item-description">
{item.body ? (item.body.length > 200 ? item.body.slice(0, 200) + '...' : item.body) : 'No description'}
</p>
</div>
<div class="item-footer">
<div class="item-status">
<span class="status-badge" class:open={item.state === 'open'} class:closed={item.state === 'closed'} class:merged={item.state === 'merged'}>
{item.state}
</span>
{#if isPR && item.merged_at}
<span class="merged-indicator">✓ Merged</span>
{/if}
</div>
<div class="item-info">
{#if item.user.login || item.user.username}
<span class="item-author">@{item.user.login || item.user.username}</span>
{/if}
<span class="item-date">Updated {formatDate(item.updated_at)}</span>
{#if item.comments_count !== undefined && item.comments_count > 0}
<span class="comments-count">💬 {item.comments_count}</span>
{/if}
</div>
{#if item.labels && item.labels.length > 0}
<div class="item-labels">
{#each item.labels.slice(0, 5) as label}
<span
class="label-badge"
style={label.color ? `background-color: #${label.color}20; color: #${label.color}; border-color: #${label.color}` : ''}
>
{label.name}
</span>
{/each}
{#if item.labels.length > 5}
<span class="more-labels">+{item.labels.length - 5} more</span>
{/if}
</div>
{/if}
</div>
<div class="item-actions">
<a
href={item.html_url}
target="_blank"
rel="noopener noreferrer"
class="external-link"
>
View on {getPlatformName(item.platform)}
</a>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.dashboard-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.dashboard-header h1 {
margin: 0 0 0.5rem 0;
color: var(--text-primary);
}
.dashboard-subtitle {
color: var(--text-secondary);
margin: 0 0 1rem 0;
}
.refresh-button {
padding: 0.5rem 1rem;
background: var(--button-primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.refresh-button:hover:not(:disabled) {
background: var(--button-primary-hover);
}
.refresh-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: 2rem;
}
.tab-button {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 1rem;
color: var(--text-secondary);
transition: all 0.2s;
}
.tab-button:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.tab-button.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 500;
}
.items-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.item-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
transition: box-shadow 0.2s, border-color 0.2s;
}
.item-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-color: var(--accent);
}
.item-card.pr {
border-left: 4px solid var(--accent);
}
.item-card.issue {
border-left: 4px solid var(--success-color, #10b981);
}
.item-header {
margin-bottom: 1rem;
}
.item-title-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.item-type-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
flex-shrink: 0;
}
.item-type-badge.pr {
background: var(--accent-light);
color: var(--accent);
}
.item-type-badge.issue {
background: var(--success-bg, #d1fae5);
color: var(--success-text, #065f46);
}
.item-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
text-decoration: none;
flex: 1;
}
.item-title:hover {
color: var(--accent);
text-decoration: underline;
}
.item-meta {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
font-size: 0.85rem;
color: var(--text-muted);
}
.platform-badge {
padding: 0.25rem 0.5rem;
background: var(--bg-secondary);
border-radius: 4px;
font-weight: 500;
}
.repo-name {
font-family: 'IBM Plex Mono', monospace;
color: var(--text-secondary);
}
.item-number {
color: var(--text-muted);
}
.item-body {
margin-bottom: 1rem;
}
.item-description {
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
.item-footer {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.item-status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 600;
text-transform: capitalize;
}
.status-badge.open {
background: var(--success-bg);
color: var(--success-text);
}
.status-badge.closed {
background: var(--error-bg);
color: var(--error-text);
}
.status-badge.merged {
background: var(--accent-light);
color: var(--accent);
}
.merged-indicator {
color: var(--accent);
font-size: 0.85rem;
}
.item-info {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.85rem;
color: var(--text-muted);
}
.item-author {
font-weight: 500;
}
.comments-count {
display: flex;
align-items: center;
gap: 0.25rem;
}
.item-labels {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.label-badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
}
.more-labels {
font-size: 0.75rem;
color: var(--text-muted);
font-style: italic;
}
.item-actions {
margin-top: 0.5rem;
}
.external-link {
color: var(--accent);
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.external-link:hover {
text-decoration: underline;
}
.loading, .error, .empty-state {
text-align: center;
padding: 3rem 2rem;
color: var(--text-muted);
}
.error {
color: var(--error-text);
background: var(--error-bg);
border: 1px solid var(--error-text);
border-radius: 8px;
padding: 1.5rem;
}
.empty-state h2 {
margin: 0 0 1rem 0;
color: var(--text-primary);
}
.empty-state a {
color: var(--accent);
text-decoration: none;
}
.empty-state a:hover {
text-decoration: underline;
}
</style>

23
src/routes/users/[npub]/+page.svelte

@ -272,6 +272,9 @@
</div> </div>
{#if getForwardingPubkey()} {#if getForwardingPubkey()}
<ForwardingConfig userPubkeyHex={getForwardingPubkey()!} /> <ForwardingConfig userPubkeyHex={getForwardingPubkey()!} />
<div class="dashboard-link">
<a href="/dashboard" class="dashboard-button">📊 View Universal Git Dashboard</a>
</div>
{/if} {/if}
</header> </header>
@ -685,4 +688,24 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; 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);
}
</style> </style>

Loading…
Cancel
Save