You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
614 lines
17 KiB
614 lines
17 KiB
/** |
|
* 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 };
|
|
|