firstsun-dev_git-files-sync/src/services/gitlab-service.ts
Claude bcc5cda69c
fix(services): clear error when Git API returns HTML instead of JSON
Refresh failed with the cryptic "Unexpected token '<', "<!DOCTYPE ..."
is not valid JSON" whenever the Git server returned an HTML page (login,
SSO redirect, or proxy/error page) on a 2xx/3xx response. safeRequest
only threw on status >= 400, so such bodies slipped through and crashed
at response.json.

Add BaseGitService.parseJson() which detects non-JSON/HTML bodies and
throws an actionable message, and use it for all response.json reads in
the GitHub and GitLab services. Also harden parseErrorResponse so HTML
error pages produce a clear message instead of dumping the raw document.

Add tests covering HTML 2xx responses, HTML detection by leading '<',
malformed JSON, and HTML error pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe
2026-06-26 06:02:40 +00:00

113 lines
4.1 KiB
TypeScript

import { GitServiceInterface } from './git-service-interface';
import { BaseGitService, GitFile, GitLabFileResponse, GitLabTreeItem } from './git-service-base';
export class GitLabService extends BaseGitService implements GitServiceInterface {
private baseUrl: string = 'https://gitlab.com';
private projectId: string = '';
updateConfig(baseUrl: string, token: string, projectId: string, rootPath: string = '') {
this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
this.token = token;
this.projectId = projectId;
this.rootPath = rootPath;
}
protected addAuthHeader(headers: Record<string, string>): void {
headers['PRIVATE-TOKEN'] = this.token;
}
private getApiUrl(path: string): string {
const fullPath = this.getFullPath(path);
const encodedPath = encodeURIComponent(fullPath);
const encodedProjectId = encodeURIComponent(this.projectId);
return `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/files/${encodedPath}`;
}
async getFile(path: string, branch: string): Promise<GitFile> {
try {
const url = `${this.getApiUrl(path)}?ref=${branch}`;
const response = await this.safeRequest(url, 'GET');
const data = this.parseJson<GitLabFileResponse>(response);
return {
content: this.decodeContent(data.content, path),
sha: data.last_commit_id
};
} catch (e) {
return this.handleFileNotFound(e);
}
}
async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> {
const url = this.getApiUrl(path);
const body = {
branch,
content: this.encodeContent(content),
encoding: 'base64',
commit_message: message,
last_commit_id: sha
};
const method = sha ? 'PUT' : 'POST';
const response = await this.safeRequest(url, method, body);
const data = this.parseJson<GitLabFileResponse>(response);
return { path: data.file_path };
}
async listFiles(branch: string, useFilter = true): Promise<string[]> {
const encodedProjectId = encodeURIComponent(this.projectId);
let allPaths: string[] = [];
let page = 1;
const perPage = 100;
while (true) {
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=${perPage}&page=${page}`;
const response = await this.safeRequest(url, 'GET');
const data = this.parseJson<GitLabTreeItem[]>(response);
if (!data || data.length === 0) break;
const paths = data
.filter(item => item.type === 'blob')
.map(item => item.path);
if (useFilter) {
const filtered = paths.filter(p => {
if (!this.rootPath) return true;
const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`;
return p === this.rootPath || p.startsWith(cleanRoot);
});
allPaths = allPaths.concat(filtered);
} else {
allPaths = allPaths.concat(paths);
}
if (data.length < perPage) break;
page++;
}
return allPaths;
}
async deleteFile(path: string, branch: string, message: string): Promise<void> {
const url = this.getApiUrl(path);
const body = {
branch,
commit_message: message
};
await this.safeRequest(url, 'DELETE', body);
}
async testConnection(): Promise<boolean> {
try {
const encodedProjectId = encodeURIComponent(this.projectId);
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}`;
await this.safeRequest(url, 'GET');
return true;
} catch {
return false;
}
}
}