From 22947bad962876a11fbd153bd4b151ef5129cda2 Mon Sep 17 00:00:00 2001
From: William Le Roux
Date: Thu, 9 Oct 2025 19:35:17 +0000
Subject: [PATCH 1/3] feat: agnostic git support
---
.../page/dashboard/CreateProjectModal.tsx | 8 +-
.../components/page/dashboard/ProjectCard.tsx | 12 +-
.../components/page/project/CommitHistory.jsx | 12 +-
.../page/project/ContributionMetrics.tsx | 19 +-
.../components/page/project/LatestCommit.tsx | 23 +-
.../components/page/project/ProjectInfo.astro | 21 +-
dapp/src/pages/api/git.ts | 304 ++++++++++++++++++
dapp/src/pages/project/index.astro | 8 +-
dapp/src/schemas/validation.ts | 22 +-
.../src/service/ContributionMetricsService.ts | 10 +-
dapp/src/service/GithubService.ts | 229 +++++++------
dapp/src/service/StateService.ts | 34 +-
dapp/src/service/walletService.ts | 1 +
dapp/src/utils/editLinkFunctions.ts | 58 ++--
dapp/tests/anonymous-execute-flow.spec.ts | 20 +-
dapp/tests/helpers/mock.ts | 54 ++--
website/docs/developers/user_flows.mdx | 9 +-
website/docs/using_the_dapp.mdx | 2 +-
18 files changed, 549 insertions(+), 297 deletions(-)
create mode 100644 dapp/src/pages/api/git.ts
diff --git a/dapp/src/components/page/dashboard/CreateProjectModal.tsx b/dapp/src/components/page/dashboard/CreateProjectModal.tsx
index aed0f04a..d6accb42 100644
--- a/dapp/src/components/page/dashboard/CreateProjectModal.tsx
+++ b/dapp/src/components/page/dashboard/CreateProjectModal.tsx
@@ -3,7 +3,7 @@ import { loadedPublicKey } from "@service/walletService";
import {
setConfigData,
setProject,
- setProjectRepoInfo,
+ setProjectRepoUrl,
} from "@service/StateService";
import { navigate } from "astro:transitions/client";
import Button from "components/utils/Button.tsx";
@@ -13,7 +13,6 @@ import FlowProgressModal from "components/utils/FlowProgressModal.tsx";
import Step from "components/utils/Step.tsx";
import Title from "components/utils/Title.tsx";
import { useState, type FC, useCallback, useEffect } from "react";
-import { getAuthorRepo } from "utils/editLinkFunctions";
import { extractConfigData, toast } from "utils/utils";
import {
validateProjectName as validateProjectNameUtil,
@@ -345,9 +344,8 @@ ${maintainerGithubs.map((gh) => `[[PRINCIPALS]]\ngithub="${gh}"`).join("\n\n")}
if (project && project.name && project.config && project.maintainers) {
setProject(project);
- const { username, repoName } = getAuthorRepo(project.config.url);
- if (username && repoName) {
- setProjectRepoInfo(username, repoName);
+ if (project.config.url) {
+ setProjectRepoUrl(project.config.url);
}
const tomlData = await fetchTomlFromCid(project.config.ipfs);
diff --git a/dapp/src/components/page/dashboard/ProjectCard.tsx b/dapp/src/components/page/dashboard/ProjectCard.tsx
index d4364a8f..d3ee9eb4 100644
--- a/dapp/src/components/page/dashboard/ProjectCard.tsx
+++ b/dapp/src/components/page/dashboard/ProjectCard.tsx
@@ -9,12 +9,9 @@ import {
setProject,
setProjectId,
setProjectLatestSha,
- setProjectRepoInfo,
+ setProjectRepoUrl,
} from "../../../service/StateService";
-import {
- convertGitHubLink,
- getAuthorRepo,
-} from "../../../utils/editLinkFunctions";
+import { convertGitHubLink } from "../../../utils/editLinkFunctions";
import { projectCardModalOpen } from "../../../utils/store";
import { extractConfigData, toast } from "../../../utils/utils";
@@ -38,9 +35,8 @@ const ProjectCard = ({ config }: { config: ProjectConfig }) => {
const project = await getProjectFromName(config.projectName);
if (project && project.name && project.config && project.maintainers) {
setProject(project);
- const { username, repoName } = getAuthorRepo(project.config.url);
- if (username && repoName) {
- setProjectRepoInfo(username, repoName);
+ if (project.config.url) {
+ setProjectRepoUrl(project.config.url);
}
const tomlData = await fetchTomlFromCid(project.config.ipfs);
if (tomlData) {
diff --git a/dapp/src/components/page/project/CommitHistory.jsx b/dapp/src/components/page/project/CommitHistory.jsx
index 220c4799..d8d167e5 100644
--- a/dapp/src/components/page/project/CommitHistory.jsx
+++ b/dapp/src/components/page/project/CommitHistory.jsx
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
import { getCommitHistory } from "../../../service/GithubService.ts";
import {
loadConfigData,
- loadProjectRepoInfo,
+ loadProjectRepoUrl,
} from "../../../service/StateService.ts";
import { formatDate } from "../../../utils/formatTimeFunctions.ts";
import { latestCommit, projectInfoLoaded } from "../../../utils/store.ts";
@@ -17,13 +17,9 @@ const CommitHistory = () => {
const [currentPage, setCurrentPage] = useState(1);
const fetchCommitHistory = async (page = 1) => {
- const projectRepoInfo = loadProjectRepoInfo();
- if (projectRepoInfo?.author && projectRepoInfo?.repository) {
- const history = await getCommitHistory(
- projectRepoInfo.author,
- projectRepoInfo.repository,
- page,
- );
+ const repoUrl = loadProjectRepoUrl();
+ if (repoUrl) {
+ const history = await getCommitHistory(repoUrl, page);
if (history) {
setCommitHistory(history);
diff --git a/dapp/src/components/page/project/ContributionMetrics.tsx b/dapp/src/components/page/project/ContributionMetrics.tsx
index d878d1ca..6382f1ba 100644
--- a/dapp/src/components/page/project/ContributionMetrics.tsx
+++ b/dapp/src/components/page/project/ContributionMetrics.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
-import type { ContributionMetrics as ContributionMetricsType } from "../../../types/contributionMetrics";
+import type { ContributionMetrics as ContributionMetricsData } from "../../../types/contributionMetrics";
import { ContributionMetricsService } from "../../../service/ContributionMetricsService";
import { loadConfigData } from "../../../service/StateService";
import PonyFactorCard from "./PonyFactorCard";
@@ -8,16 +8,14 @@ import MonthlyActivityChart from "./MonthlyActivityChart";
interface ContributionMetricsProps {
projectName: string;
- owner: string;
- repo: string;
+ repoUrl: string;
}
const ContributionMetrics = ({
projectName,
- owner,
- repo,
+ repoUrl,
}: ContributionMetricsProps) => {
- const [metrics, setMetrics] = useState(null);
+ const [metrics, setMetrics] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [maintainers, setMaintainers] = useState([]);
@@ -28,10 +26,7 @@ const ContributionMetrics = ({
setLoading(true);
setError(null);
- const metrics = await ContributionMetricsService.fetchMetrics(
- owner,
- repo,
- );
+ const metrics = await ContributionMetricsService.fetchMetrics(repoUrl);
setMetrics(metrics);
const configData = loadConfigData();
@@ -54,10 +49,10 @@ const ContributionMetrics = ({
}
};
- if (projectName && owner && repo) {
+ if (projectName && repoUrl) {
fetchMetrics();
}
- }, [projectName, owner, repo]);
+ }, [projectName, repoUrl]);
if (loading) {
return (
diff --git a/dapp/src/components/page/project/LatestCommit.tsx b/dapp/src/components/page/project/LatestCommit.tsx
index 9044b30b..8d17fd4a 100644
--- a/dapp/src/components/page/project/LatestCommit.tsx
+++ b/dapp/src/components/page/project/LatestCommit.tsx
@@ -72,15 +72,20 @@ const LatestCommit = () => {
{commitData?.sha.slice(0, 9)}
-
-
-
-
+
+ {commitData?.html_url ? (
+
+
+
+ ) : null}
)}
diff --git a/dapp/src/components/page/project/ProjectInfo.astro b/dapp/src/components/page/project/ProjectInfo.astro
index 9073d4e8..902f64da 100644
--- a/dapp/src/components/page/project/ProjectInfo.astro
+++ b/dapp/src/components/page/project/ProjectInfo.astro
@@ -119,10 +119,7 @@ import Button from "components/utils/Button";
loadProjectLatestSha,
loadProjectName,
} from "../../../service/StateService";
- import {
- convertGitHubLink,
- getAuthorRepo,
- } from "../../../utils/editLinkFunctions";
+ import { convertGitHubLink } from "../../../utils/editLinkFunctions";
import { projectInfoLoaded } from "../../../utils/store";
import { getBadges, getMember } from "@service/ReadContractService";
@@ -579,16 +576,12 @@ import Button from "components/utils/Button";
}
if (projectInfo?.name && projectInfo?.config?.url) {
- const { username, repoName } = getAuthorRepo(projectInfo.config.url);
- if (username && repoName) {
- contributionMetricsRoot.render(
- React.createElement(ContributionMetrics, {
- projectName: projectInfo.name,
- owner: username,
- repo: repoName,
- }),
- );
- }
+ contributionMetricsRoot.render(
+ React.createElement(ContributionMetrics, {
+ projectName: projectInfo.name,
+ repoUrl: projectInfo.config.url,
+ }),
+ );
}
// Re-render modals with the updated data
diff --git a/dapp/src/pages/api/git.ts b/dapp/src/pages/api/git.ts
new file mode 100644
index 00000000..47126736
--- /dev/null
+++ b/dapp/src/pages/api/git.ts
@@ -0,0 +1,304 @@
+import type { APIRoute } from "astro";
+import { execFile } from "node:child_process";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { promisify } from "node:util";
+
+const execFileAsync = promisify(execFile);
+
+interface GitCloneOptions {
+ depth?: number;
+}
+
+async function cloneRepository(
+ repoUrl: string,
+ options: GitCloneOptions = {},
+): Promise<{ baseDir: string; repoDir: string }> {
+ const baseDir = await mkdtemp(join(tmpdir(), "tansu-git-"));
+ const repoDir = join(baseDir, "repo.git");
+
+ const args = ["clone", "--bare", "--no-tags"];
+ if (options.depth && options.depth > 0) {
+ args.push("--depth", String(options.depth));
+ }
+ args.push(repoUrl, repoDir);
+
+ try {
+ await execFileAsync("git", args, {
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
+ });
+ } catch (error) {
+ await rm(baseDir, { recursive: true, force: true });
+ throw error;
+ }
+
+ return { baseDir, repoDir };
+}
+
+async function runGitCommand(repoDir: string, args: string[]): Promise {
+ const { stdout } = await execFileAsync(
+ "git",
+ ["--git-dir", repoDir, ...args],
+ {
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
+ maxBuffer: 10 * 1024 * 1024,
+ },
+ );
+ return stdout;
+}
+
+function normalizeRepoUrl(repoUrl: string): string {
+ let normalized = repoUrl.trim();
+ if (normalized.endsWith(".git")) {
+ normalized = normalized.slice(0, -4);
+ }
+ if (normalized.endsWith("/")) {
+ normalized = normalized.slice(0, -1);
+ }
+ return normalized;
+}
+
+function buildCommitUrl(repoUrl: string, sha: string): string | undefined {
+ const normalized = normalizeRepoUrl(repoUrl);
+
+ try {
+ const parsed = new URL(normalized);
+ const host = parsed.hostname.toLowerCase();
+
+ if (host.includes("github.")) {
+ return `${normalized}/commit/${sha}`;
+ }
+ if (host.includes("gitlab.")) {
+ return `${normalized}/-/commit/${sha}`;
+ }
+ if (host.includes("bitbucket.")) {
+ return `${normalized}/commits/${sha}`;
+ }
+ } catch {
+ // Non-HTTP(S) URL (e.g., SSH) – we cannot build a web URL
+ return undefined;
+ }
+
+ return undefined;
+}
+
+async function getCommitHistory(
+ repoUrl: string,
+ page: number,
+ perPage: number,
+) {
+ const depth = Math.max(page * perPage, perPage);
+ const { baseDir, repoDir } = await cloneRepository(repoUrl, { depth });
+
+ try {
+ const skip = Math.max(0, (page - 1) * perPage);
+ const format = "%H%x1f%an%x1f%aI%x1f%ae%x1f%cn%x1f%cI%x1f%ce%x1f%B%x1e";
+ const rawLog = await runGitCommand(repoDir, [
+ "log",
+ "--date=iso-strict",
+ `--skip=${skip}`,
+ "-n",
+ String(perPage),
+ `--pretty=format:${format}`,
+ ]);
+
+ const commits = rawLog
+ .split("\x1e")
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+ .map((entry) => {
+ const [
+ sha,
+ authorName,
+ authorDate,
+ authorEmail,
+ committerName,
+ committerDate,
+ committerEmail,
+ message,
+ ] = entry.split("\x1f");
+
+ return {
+ sha,
+ authorName,
+ authorDate,
+ authorEmail,
+ committerName,
+ committerDate,
+ committerEmail,
+ message: message?.trimEnd() ?? "",
+ commitUrl: buildCommitUrl(repoUrl, sha || "") ?? "",
+ };
+ });
+
+ return commits;
+ } finally {
+ await rm(baseDir, { recursive: true, force: true });
+ }
+}
+
+async function getCommitDetails(repoUrl: string, sha: string) {
+ const { baseDir, repoDir } = await cloneRepository(repoUrl);
+
+ try {
+ const format = "%H%x1f%an%x1f%aI%x1f%ae%x1f%cn%x1f%cI%x1f%ce%x1f%B";
+ const raw = await runGitCommand(repoDir, [
+ "show",
+ sha,
+ "--quiet",
+ "--date=iso-strict",
+ `--pretty=format:${format}`,
+ ]);
+
+ const [
+ commitSha,
+ authorName,
+ authorDate,
+ authorEmail,
+ committerName,
+ committerDate,
+ committerEmail,
+ message,
+ ] = raw.trim().split("\x1f");
+
+ if (!commitSha) {
+ return undefined;
+ }
+
+ return {
+ sha: commitSha,
+ html_url: buildCommitUrl(repoUrl, commitSha) ?? "",
+ commit: {
+ message: message?.trimEnd() ?? "",
+ author: {
+ name: authorName || "",
+ email: authorEmail || "",
+ date: authorDate || "",
+ },
+ committer: {
+ name: committerName || "",
+ email: committerEmail || "",
+ date: committerDate || "",
+ },
+ },
+ };
+ } finally {
+ await rm(baseDir, { recursive: true, force: true });
+ }
+}
+
+async function getLatestCommitHash(repoUrl: string) {
+ const { baseDir, repoDir } = await cloneRepository(repoUrl, { depth: 1 });
+
+ try {
+ const sha = await runGitCommand(repoDir, ["rev-parse", "HEAD"]);
+ return sha.trim();
+ } finally {
+ await rm(baseDir, { recursive: true, force: true });
+ }
+}
+
+async function getReadmeContent(repoUrl: string) {
+ const { baseDir, repoDir } = await cloneRepository(repoUrl, { depth: 1 });
+ const candidates = [
+ "README.md",
+ "README.MD",
+ "README",
+ "Readme.md",
+ "readme.md",
+ ];
+
+ try {
+ for (const candidate of candidates) {
+ try {
+ const content = await runGitCommand(repoDir, [
+ "show",
+ `HEAD:${candidate}`,
+ ]);
+ if (content) {
+ return content;
+ }
+ } catch (_error) {
+ // Try next candidate
+ continue;
+ }
+ }
+ return undefined;
+ } finally {
+ await rm(baseDir, { recursive: true, force: true });
+ }
+}
+
+export const POST: APIRoute = async ({ request }) => {
+ try {
+ // Safely parse JSON body; handle empty or invalid JSON without throwing
+ let body: any = {};
+ try {
+ const text = await request.text();
+ body = text ? JSON.parse(text) : {};
+ } catch {
+ body = {};
+ }
+
+ const { action, repoUrl, page = 1, perPage = 30, sha } = body ?? {};
+
+ if (!repoUrl || typeof repoUrl !== "string") {
+ return new Response(
+ JSON.stringify({ error: "Repository URL is required" }),
+ { status: 400 },
+ );
+ }
+
+ switch (action) {
+ case "history": {
+ const commits = await getCommitHistory(repoUrl, page, perPage);
+ return new Response(JSON.stringify({ commits }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ case "commit": {
+ if (!sha || typeof sha !== "string") {
+ return new Response(
+ JSON.stringify({ error: "Commit SHA is required" }),
+ { status: 400 },
+ );
+ }
+ const commit = await getCommitDetails(repoUrl, sha);
+ if (!commit) {
+ return new Response(null, { status: 404 });
+ }
+ return new Response(JSON.stringify(commit), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ case "latest-hash": {
+ const latestSha = await getLatestCommitHash(repoUrl);
+ return new Response(JSON.stringify({ sha: latestSha }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ case "readme": {
+ const content = await getReadmeContent(repoUrl);
+ return new Response(JSON.stringify({ content }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ default:
+ return new Response(JSON.stringify({ error: "Unknown action" }), {
+ status: 400,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ } catch (error: any) {
+ console.error("Git API error", error);
+ return new Response(
+ JSON.stringify({ error: "Failed to process git request" }),
+ { status: 500 },
+ );
+ }
+};
diff --git a/dapp/src/pages/project/index.astro b/dapp/src/pages/project/index.astro
index 7ba8aee3..11969c50 100644
--- a/dapp/src/pages/project/index.astro
+++ b/dapp/src/pages/project/index.astro
@@ -28,9 +28,8 @@ import ProjectInfoTitle from "../../components/page/project/ProjectInfoTitle.ast
setProject,
setProjectId,
setProjectLatestSha,
- setProjectRepoInfo,
+ setProjectRepoUrl,
} from "../../service/StateService";
- import { getAuthorRepo } from "../../utils/editLinkFunctions";
import { projectInfoLoaded } from "../../utils/store";
import { extractConfigData, toast } from "../../utils/utils";
@@ -43,10 +42,7 @@ import ProjectInfoTitle from "../../components/page/project/ProjectInfoTitle.ast
const project = await getProjectFromName(projectName);
if (project && project.name && project.config && project.maintainers) {
setProject(project);
- const { username, repoName } = getAuthorRepo(project.config.url);
- if (username && repoName) {
- setProjectRepoInfo(username, repoName);
- }
+ setProjectRepoUrl(project.config.url);
const tomlData = await fetchTomlFromCid(project.config.ipfs);
if (tomlData) {
const configData = extractConfigData(tomlData, project);
diff --git a/dapp/src/schemas/validation.ts b/dapp/src/schemas/validation.ts
index a9fd291a..9811e6a7 100644
--- a/dapp/src/schemas/validation.ts
+++ b/dapp/src/schemas/validation.ts
@@ -24,19 +24,29 @@ export const projectNameSchema = z
export const githubUrlSchema = z
.string()
- .min(1, "GitHub URL is required")
- .refine((url) => {
+ .min(1, "Repository URL is required")
+ .refine((value) => {
try {
- const parsedUrl = new URL(url);
- if (parsedUrl.hostname !== "github.com") {
+ if (value.startsWith("git@")) {
+ const [, path] = value.split(":");
+ if (!path) return false;
+ const segments = path
+ .replace(/\.git$/, "")
+ .split("/")
+ .filter(Boolean);
+ return segments.length >= 2;
+ }
+
+ const parsedUrl = new URL(value);
+ if (parsedUrl.protocol !== "https:") {
return false;
}
const pathParts = parsedUrl.pathname.split("/").filter(Boolean);
- return pathParts.length === 2;
+ return pathParts.length >= 2;
} catch {
return false;
}
- }, "URL must be in format: https://github.com/username/repository");
+ }, "URL must be a valid git repository (e.g., https://host/owner/repo)");
export const githubHandleSchema = z
.string()
diff --git a/dapp/src/service/ContributionMetricsService.ts b/dapp/src/service/ContributionMetricsService.ts
index 779a1109..573d95a4 100644
--- a/dapp/src/service/ContributionMetricsService.ts
+++ b/dapp/src/service/ContributionMetricsService.ts
@@ -12,16 +12,16 @@ export class ContributionMetricsService {
* Fetch contribution metrics by analyzing GitHub API commit data
* Uses the same getCommitHistory() function as CommitHistory component
*/
- static async fetchMetrics(
- owner: string,
- repo: string,
- ): Promise {
+ static async fetchMetrics(repoUrl: string): Promise {
try {
+ if (!repoUrl) {
+ throw new Error("Repository URL is required");
+ }
const allCommits: FormattedCommit[] = [];
const maxPages = 34;
for (let page = 1; page <= maxPages; page++) {
- const history = await getCommitHistory(owner, repo, page, 30);
+ const history = await getCommitHistory(repoUrl, page, 30);
if (!history || history.length === 0) break;
for (const dayGroup of history) {
diff --git a/dapp/src/service/GithubService.ts b/dapp/src/service/GithubService.ts
index 66b42963..fd31b29b 100644
--- a/dapp/src/service/GithubService.ts
+++ b/dapp/src/service/GithubService.ts
@@ -1,156 +1,149 @@
-import axios from "axios";
-
import type { FormattedCommit } from "../types/github";
-import {
- getAuthorRepo,
- getGithubContentUrlFromReadmeUrl,
-} from "../utils/editLinkFunctions";
+
+interface GitHistoryCommit {
+ sha: string;
+ authorName: string;
+ authorDate: string;
+ message: string;
+ commitUrl?: string;
+}
+
+interface GitCommitDetails {
+ sha: string;
+ html_url?: string;
+ commit: {
+ message: string;
+ author: { name: string; email?: string; date?: string };
+ committer: { name: string; email?: string; date?: string };
+ };
+}
+
+async function callGitApi(body: Record): Promise {
+ const response = await fetch("/api/git", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Git API request failed with status ${response.status}`);
+ }
+
+ return (await response.json()) as T;
+}
+
+function groupCommitsByDate(commits: FormattedCommit[]) {
+ const grouped = commits.reduce(
+ (acc: Record, commit) => {
+ const date = new Date(commit.commit_date).toISOString().split("T")[0];
+ if (!date) {
+ return acc;
+ }
+ if (!acc[date]) {
+ acc[date] = [];
+ }
+ acc[date].push(commit);
+ return acc;
+ },
+ {},
+ );
+
+ return Object.entries(grouped).map(([date, groupedCommits]) => ({
+ date,
+ commits: groupedCommits as FormattedCommit[],
+ }));
+}
async function getCommitHistory(
- username: string,
- repo: string,
+ repoUrl: string,
page: number = 1,
perPage: number = 30,
): Promise<{ date: string; commits: FormattedCommit[] }[] | null> {
- try {
- const response = await axios.get(
- `https://api.github.com/repos/${username}/${repo}/commits`,
- {
- params: { per_page: perPage, page: page },
- headers: { Accept: "application/vnd.github.v3+json" },
- },
- );
+ if (!repoUrl) {
+ return null;
+ }
- const formattedCommits = response.data.map((commit: any) => ({
- message: commit.commit.message,
+ try {
+ const data = await callGitApi<{ commits: GitHistoryCommit[] }>({
+ action: "history",
+ repoUrl,
+ page,
+ perPage,
+ });
+
+ const formatted = data.commits.map((commit) => ({
+ message: commit.message,
author: {
- name: commit.commit.author.name,
- html_url: commit.author ? commit.author.html_url : "",
+ name: commit.authorName,
+ html_url: "",
},
- commit_date: commit.commit.author.date,
- html_url: commit.html_url,
+ commit_date: commit.authorDate,
+ html_url: commit.commitUrl || "",
sha: commit.sha,
}));
- // Group commits by date
- const groupedCommits = formattedCommits.reduce(
- (acc: Record, commit: FormattedCommit) => {
- const date = new Date(commit.commit_date).toISOString().split("T")[0];
- if (!date) {
- return acc;
- }
- if (!acc[date]) {
- acc[date] = [];
- }
- acc[date].push(commit);
- return acc;
- },
- {},
- );
-
- // Convert grouped commits to array format
- return Object.entries(groupedCommits).map(([date, commits]) => ({
- date,
- commits: commits as FormattedCommit[],
- }));
- } catch {
- // Don't show error toast as this may be an expected condition
- // (e.g., repository is private or doesn't exist)
+ return groupCommitsByDate(formatted);
+ } catch (error) {
+ console.error("Failed to load commit history", error);
return null;
}
}
-async function getCommitDataFromSha(
- owner: string,
- repo: string,
+async function getLatestCommitData(
+ repoUrl: string,
sha: string,
-): Promise {
- try {
- const url = `https://api.github.com/repos/${owner}/${repo}/commits/${sha}`;
- const response = await fetch(url);
-
- if (!response.ok) {
- // Expected condition (commit may not exist)
- return undefined;
- }
-
- return await response.json();
- } catch {
- // Don't show error toast as this may be an expected condition
+): Promise {
+ if (!repoUrl || !sha) {
return undefined;
}
-}
-async function getLatestCommitData(
- configUrl: string,
- sha: string,
-): Promise {
- const { username, repoName } = getAuthorRepo(configUrl);
- if (!username || !repoName) {
- // Expected condition (URL may be malformed)
+ try {
+ const commit = await callGitApi({
+ action: "commit",
+ repoUrl,
+ sha,
+ });
+ return commit;
+ } catch (error) {
+ console.error("Failed to load commit data", error);
return undefined;
}
-
- return await getCommitDataFromSha(username, repoName, sha);
}
async function getLatestCommitHash(
- configUrl: string,
+ repoUrl: string,
): Promise {
- const { username, repoName } = getAuthorRepo(configUrl);
- if (!username || !repoName) {
- // Expected condition (URL may be malformed)
+ if (!repoUrl) {
return undefined;
}
try {
- const repoRes = await fetch(
- `https://api.github.com/repos/${username}/${repoName}`,
- );
- if (!repoRes.ok) {
- // Expected condition (repo may not exist or be private)
- return undefined;
- }
-
- const repoData = await repoRes.json();
- const defaultBranch = repoData.default_branch;
-
- const commitRes = await fetch(
- `https://api.github.com/repos/${username}/${repoName}/commits/${defaultBranch}`,
- );
- if (!commitRes.ok) {
- // Expected condition (branch may not exist)
- return undefined;
- }
-
- const latestCommit = await commitRes.json();
- const latestSha = latestCommit.sha;
- return latestSha;
- } catch {
- // Don't show error toast as these are often expected conditions
+ const { sha } = await callGitApi<{ sha: string }>({
+ action: "latest-hash",
+ repoUrl,
+ });
+ return sha;
+ } catch (error) {
+ console.error("Failed to load latest commit hash", error);
return undefined;
}
}
-async function fetchReadmeContentFromConfigUrl(configUrl: string) {
- try {
- const url = getGithubContentUrlFromReadmeUrl(configUrl);
-
- if (url) {
- const response = await fetch(url);
-
- if (!response.ok) {
- // Expected condition (readme may not exist)
- return undefined;
- }
-
- const tomlText = await response.text();
- return tomlText;
- }
-
+async function fetchReadmeContentFromConfigUrl(
+ repoUrl: string,
+): Promise {
+ if (!repoUrl) {
return undefined;
- } catch {
- // Don't show error toast as this may be an expected condition
+ }
+
+ try {
+ const { content } = await callGitApi<{ content?: string | null }>({
+ action: "readme",
+ repoUrl,
+ });
+ return content ?? undefined;
+ } catch (error) {
+ console.error("Failed to load repository README", error);
return undefined;
}
}
diff --git a/dapp/src/service/StateService.ts b/dapp/src/service/StateService.ts
index e9ab1380..80882a85 100644
--- a/dapp/src/service/StateService.ts
+++ b/dapp/src/service/StateService.ts
@@ -28,12 +28,10 @@ const projectInfo: {
project_config_ipfs: undefined,
};
-const projectRepoInfo: {
- project_author: string | undefined;
- project_repository: string | undefined;
+const projectRepo: {
+ project_url: string | undefined;
} = {
- project_author: undefined,
- project_repository: undefined,
+ project_url: undefined,
};
const projectLatestSha: {
@@ -54,8 +52,7 @@ function refreshLocalStorage(): void {
projectInfo.project_maintainers = undefined;
projectInfo.project_config_url = undefined;
projectInfo.project_config_ipfs = undefined;
- projectRepoInfo.project_author = undefined;
- projectRepoInfo.project_repository = undefined;
+ projectRepo.project_url = undefined;
projectLatestSha.sha = undefined;
configData = undefined;
}
@@ -87,11 +84,10 @@ function setProject(project: Project): void {
}
}
-function setProjectRepoInfo(author: string, repository: string): void {
- projectRepoInfo.project_author = author;
- projectRepoInfo.project_repository = repository;
+function setProjectRepoUrl(url: string): void {
+ projectRepo.project_url = url;
if (typeof window !== "undefined") {
- projectRepoInfoStore.set(projectRepoInfo);
+ projectRepoInfoStore.set(projectRepo);
}
}
@@ -143,16 +139,8 @@ function loadProjectInfo(): Project | undefined {
};
}
-function loadProjectRepoInfo():
- | { author: string; repository: string }
- | undefined {
- if (!projectRepoInfo.project_author || !projectRepoInfo.project_repository) {
- return undefined;
- }
- return {
- author: projectRepoInfo.project_author,
- repository: projectRepoInfo.project_repository,
- };
+function loadProjectRepoUrl(): string | undefined {
+ return projectRepo.project_url;
}
function loadProjectLatestSha(): string | undefined {
@@ -166,10 +154,10 @@ function loadConfigData(): ConfigData | undefined {
export {
setProjectId,
setProject,
- setProjectRepoInfo,
+ setProjectRepoUrl,
loadedProjectId,
loadProjectInfo,
- loadProjectRepoInfo,
+ loadProjectRepoUrl,
setProjectLatestSha,
loadProjectLatestSha,
loadProjectName,
diff --git a/dapp/src/service/walletService.ts b/dapp/src/service/walletService.ts
index f361e779..a59f5513 100644
--- a/dapp/src/service/walletService.ts
+++ b/dapp/src/service/walletService.ts
@@ -127,6 +127,7 @@ export {
loadedPublicKey,
loadedProvider,
setConnection,
+ setConnection as setPublicKey,
disconnect,
initializeConnection,
getWalletHealth,
diff --git a/dapp/src/utils/editLinkFunctions.ts b/dapp/src/utils/editLinkFunctions.ts
index cc4d1271..47f71fbd 100644
--- a/dapp/src/utils/editLinkFunctions.ts
+++ b/dapp/src/utils/editLinkFunctions.ts
@@ -12,42 +12,38 @@ export function convertGitHubLink(link: string): string {
}
}
-export function getGithubContentUrl(
- username: string,
- repoName: string,
- filePath: string,
-): string {
- return `https://raw.githubusercontent.com/${username}/${repoName}/${filePath}`;
-}
-
export function getAuthorRepo(repoUrl: string): {
username: string | undefined;
repoName: string | undefined;
} {
- const match = repoUrl.match(/https\:\/\/github\.com\/([^\/]+)\/([^\/]+)/);
- if (!match || !match[1] || !match[2])
- return { username: undefined, repoName: undefined };
- return { username: match[1], repoName: match[2] };
-}
+ try {
+ if (repoUrl.startsWith("git@")) {
+ const [, path] = repoUrl.split(":");
+ if (!path) {
+ return { username: undefined, repoName: undefined };
+ }
+ const segments = path
+ .replace(/\.git$/, "")
+ .split("/")
+ .filter(Boolean);
+ if (segments.length < 2) {
+ return { username: undefined, repoName: undefined };
+ }
+ const repoName = segments.pop();
+ const username = segments.pop();
+ return { username, repoName };
+ }
-export function getGithubContentUrlFromConfigUrl(
- configUrl: string,
-): string | undefined {
- const { username, repoName } = getAuthorRepo(configUrl);
- if (username && repoName) {
- // use master as GitHub will do an automatic redirection to main
- return getGithubContentUrl(username, repoName, "master/tansu.toml");
- }
- return undefined;
-}
+ const url = new URL(repoUrl);
+ const segments = url.pathname.split("/").filter(Boolean);
+ if (segments.length < 2) {
+ return { username: undefined, repoName: undefined };
+ }
-export function getGithubContentUrlFromReadmeUrl(
- configUrl: string,
-): string | undefined {
- const { username, repoName } = getAuthorRepo(configUrl);
- if (username && repoName) {
- // use master as GitHub will do an automatic redirection to main
- return getGithubContentUrl(username, repoName, "master/README.md");
+ const repoName = segments.pop()?.replace(/\.git$/, "");
+ const username = segments.pop();
+ return { username, repoName };
+ } catch {
+ return { username: undefined, repoName: undefined };
}
- return undefined;
}
diff --git a/dapp/tests/anonymous-execute-flow.spec.ts b/dapp/tests/anonymous-execute-flow.spec.ts
index a0c96de2..2044f9c0 100644
--- a/dapp/tests/anonymous-execute-flow.spec.ts
+++ b/dapp/tests/anonymous-execute-flow.spec.ts
@@ -92,19 +92,13 @@ test("execute() receives weighted tallies/seeds for anonymous proposal", async (
// Stub walletService to provide a connected Mock wallet
await page.route("**/src/service/walletService.ts", (route) => {
const body = `
- export function loadedPublicKey() {
- return 'G'.padEnd(56,'A');
- }
-
- export function loadedProvider() {
- // Simulate a Mock wallet object
- return { id: 'mockWallet', name: 'Mock Wallet', connected: true };
- }
-
- export function setConnection() {}
- export function disconnect() {}
- export function initializeConnection() {}
- `;
+ export function loadedPublicKey(){ return 'G'.padEnd(56,'A'); }
+ export function loadedProvider(){ return 'freighter'; }
+ export function setConnection(){}
+ export function setPublicKey(){}
+ export function disconnect(){}
+ export function initializeConnection(){}
+ `;
route.fulfill({
status: 200,
headers: { "content-type": "application/javascript" },
diff --git a/dapp/tests/helpers/mock.ts b/dapp/tests/helpers/mock.ts
index 87d97584..7c0cc35f 100644
--- a/dapp/tests/helpers/mock.ts
+++ b/dapp/tests/helpers/mock.ts
@@ -390,10 +390,7 @@ export async function applyAllMocks(page) {
signAuthEntry: async () => ({ signedAuthEntry: 'mock', signerAddress: '${WALLET_PK}' }),
signMessage: async () => ({ signature: 'mock', signerAddress: '${WALLET_PK}' }),
getNetwork: async () => ({ network: 'testnet' }),
- setWallet: async (walletId) => {
- console.log('Mock setWallet called with:', walletId);
- return true;
- }
+ setWallet: (id) => { /* no-op for tests */ }
};`;
route.fulfill({
status: 200,
@@ -485,10 +482,7 @@ export async function applyAllMocks(page) {
signAuthEntry: async () => ({ signedAuthEntry: 'mock', signerAddress: '${WALLET_PK}' }),
signMessage: async () => ({ signature: 'mock', signerAddress: '${WALLET_PK}' }),
getNetwork: async () => ({ network: 'testnet' }),
- setWallet: async (walletId) => {
- console.log('Mock setWallet called with:', walletId);
- return true;
- }
+ setWallet: (id) => { /* no-op for tests */ }
};`;
route.fulfill({
status: 200,
@@ -528,19 +522,15 @@ export async function applyAllMocks(page) {
// Apply walletService mock before navigating to provide authenticated user for tests
await page.route("**/src/service/walletService.ts", (route) => {
const body = `
- export function loadedPublicKey() { return '${WALLET_PK}'; }
- export function loadedProvider() { return { id: 'mockWallet', name: 'Mock Wallet', connected: true }; }
- export function setPublicKey() {}
- export function setConnection() {}
- export function disconnect() {}
- export function initializeConnection() { return { success: true }; }
- export async function checkAndNotifyFunding() {
- console.log('🧪 Mocked checkAndNotifyFunding called');
- }
- export async function getWalletHealth() {
- return { exists: true, balance: 100 };
- }
- `;
+ export function loadedPublicKey(){ return '${WALLET_PK}'; }
+ export function loadedProvider(){ return 'freighter'; }
+ export function setConnection(){ }
+ export function setPublicKey(){}
+ export function disconnect(){}
+ export function initializeConnection(){}
+ export async function checkAndNotifyFunding() {}
+ export async function getWalletHealth() { return { exists: true, balance: 100 }; }
+ `;
route.fulfill({
status: 200,
headers: { "content-type": "application/javascript" },
@@ -551,19 +541,15 @@ export async function applyAllMocks(page) {
// Also mock the wallet service with the @service alias pattern
await page.route("**/@service/walletService*", (route) => {
const body = `
- export function loadedPublicKey() { return '${WALLET_PK}'; }
- export function loadedProvider() { return { id: 'mockWallet', name: 'Mock Wallet', connected: true }; }
- export function setPublicKey() {}
- export function setConnection() {}
- export function disconnect() {}
- export function initializeConnection() { return { success: true }; }
- export async function checkAndNotifyFunding() {
- console.log('🧪 Mocked checkAndNotifyFunding called');
- }
- export async function getWalletHealth() {
- return { exists: true, balance: 100 };
- }
- `;
+ export function loadedPublicKey(){ return '${WALLET_PK}'; }
+ export function loadedProvider(){ return 'freighter'; }
+ export function setConnection(){ }
+ export function setPublicKey(){}
+ export function disconnect(){}
+ export function initializeConnection(){}
+ export async function checkAndNotifyFunding() {}
+ export async function getWalletHealth() { return { exists: true, balance: 100 }; }
+ `;
route.fulfill({
status: 200,
headers: { "content-type": "application/javascript" },
diff --git a/website/docs/developers/user_flows.mdx b/website/docs/developers/user_flows.mdx
index 49ff81ec..95dafc3d 100644
--- a/website/docs/developers/user_flows.mdx
+++ b/website/docs/developers/user_flows.mdx
@@ -279,13 +279,13 @@ Maintainers can update project commit hashes to track code versions on-chain.
sequenceDiagram
participant M as Maintainer
participant D as dApp
- participant GH as GitHub
+ participant LG as Local Git Mirror
participant SC as SorobanContract
participant DB as Database
M->>D: Update Commit Hash
- D->>GH: Verify Commit Exists
- GH-->>D: Commit Verification
+ D->>LG: Clone & Verify Commit
+ LG-->>D: Commit Verification
M->>SC: Submit commit()
SC-->>D: Commit Updated
SC->>DB: Emit Commit Event
@@ -294,7 +294,8 @@ sequenceDiagram
### Commit Verification
-- **GitHub API Integration**: Verify commit hashes exist in specified repository
+- **Local Git Clone**: The dApp clones the repository server-side to verify the commit exists, supporting any git hosting provider
+- **Generic Metadata Fetching**: Commit history, README content, and latest hashes are resolved through the local clone instead of external platform APIs
- **Hash Format Validation**: Ensure 40-character hexadecimal format
- **Authorization Check**: Only project maintainers can update commits
diff --git a/website/docs/using_the_dapp.mdx b/website/docs/using_the_dapp.mdx
index 2878370f..48ecdd37 100644
--- a/website/docs/using_the_dapp.mdx
+++ b/website/docs/using_the_dapp.mdx
@@ -30,7 +30,7 @@ The dApp shows a **pending** status until the transaction is confirmed. Once don
## 3. Update your commit hash
-Inside a project page click **Set commit hash**, paste the 40-character Git commit SHA and sign. The commit is stored on-chain and displayed alongside a link to the corresponding GitHub commit.
+Inside a project page click **Set commit hash**, paste the 40-character Git commit SHA and sign. The commit is stored on-chain and displayed alongside a link to the corresponding commit in your repository host when the URL can be inferred.
For an automated workflow, install the pre-push hook described in the [Git Hooks](developers/on_chain.mdx#git-hooks) section.
From c4ac9cb7ba163506aba7d8fe52e645a7585d6feb Mon Sep 17 00:00:00 2001
From: William Le Roux
Date: Thu, 9 Oct 2025 21:34:46 +0000
Subject: [PATCH 2/3] perf: make initial load faster and cache repo 5min
---
dapp/src/pages/api/git.ts | 364 +++++++++++++++++++++++++-------------
1 file changed, 239 insertions(+), 125 deletions(-)
diff --git a/dapp/src/pages/api/git.ts b/dapp/src/pages/api/git.ts
index 47126736..19a0120e 100644
--- a/dapp/src/pages/api/git.ts
+++ b/dapp/src/pages/api/git.ts
@@ -1,4 +1,6 @@
import type { APIRoute } from "astro";
+// This endpoint must run on the server to handle POST requests
+export const prerender = false;
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -7,33 +9,162 @@ import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
-interface GitCloneOptions {
- depth?: number;
+interface RepoCacheEntry {
+ baseDir: string;
+ repoDir: string;
+ depth: number;
+ lastAccess: number;
}
-async function cloneRepository(
- repoUrl: string,
- options: GitCloneOptions = {},
-): Promise<{ baseDir: string; repoDir: string }> {
+const REPO_CACHE = new Map();
+const REPO_PENDING = new Map>();
+const REPO_TTL_MS = 5 * 60 * 1000;
+
+function now(): number {
+ return Date.now();
+}
+
+function cacheKeyFor(repoUrl: string): string {
+ return normalizeRepoUrl(repoUrl);
+}
+
+async function cloneBareRepo(repoUrl: string, depth: number): Promise {
const baseDir = await mkdtemp(join(tmpdir(), "tansu-git-"));
const repoDir = join(baseDir, "repo.git");
- const args = ["clone", "--bare", "--no-tags"];
- if (options.depth && options.depth > 0) {
- args.push("--depth", String(options.depth));
+ const args = [
+ "-c",
+ "protocol.version=2",
+ "clone",
+ "--bare",
+ "--no-tags",
+ "--filter=blob:none",
+ ];
+ if (depth > 0) {
+ args.push("--depth", String(depth));
}
args.push(repoUrl, repoDir);
try {
await execFileAsync("git", args, {
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_LFS_SKIP_SMUDGE: "1" },
});
+ return { baseDir, repoDir, depth, lastAccess: now() };
} catch (error) {
await rm(baseDir, { recursive: true, force: true });
throw error;
}
+}
+
+async function deepenRepo(repoDir: string, depth: number): Promise {
+ await execFileAsync(
+ "git",
+ [
+ "--git-dir",
+ repoDir,
+ "-c",
+ "protocol.version=2",
+ "fetch",
+ "--no-tags",
+ "--filter=blob:none",
+ "--depth",
+ String(depth),
+ "origin",
+ ],
+ { env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_LFS_SKIP_SMUDGE: "1" } },
+ );
+}
+
+async function getRepo(repoUrl: string, depth: number): Promise {
+ const key = cacheKeyFor(repoUrl);
+ const requestedDepth = Math.max(depth, 1);
+ let entry = REPO_CACHE.get(key);
+
+ if (!entry) {
+ // Deduplicate concurrent clones
+ let pending = REPO_PENDING.get(key);
+ if (!pending) {
+ pending = cloneBareRepo(repoUrl, requestedDepth).then((e) => {
+ REPO_CACHE.set(key, e);
+ REPO_PENDING.delete(key);
+ return e;
+ }).catch((err) => {
+ REPO_PENDING.delete(key);
+ throw err;
+ });
+ REPO_PENDING.set(key, pending);
+ }
+ entry = await pending;
+ return entry;
+ }
+
+ if (requestedDepth > entry.depth) {
+ const deepenKey = `${key}@${requestedDepth}`;
+ let pending = REPO_PENDING.get(deepenKey);
+ if (!pending) {
+ pending = deepenRepo(entry.repoDir, requestedDepth).then(() => {
+ entry!.depth = requestedDepth;
+ entry!.lastAccess = now();
+ REPO_PENDING.delete(deepenKey);
+ return entry!;
+ }).catch((err) => {
+ REPO_PENDING.delete(deepenKey);
+ throw err;
+ });
+ REPO_PENDING.set(deepenKey, pending);
+ }
+ entry = await pending;
+ }
- return { baseDir, repoDir };
+ entry.lastAccess = now();
+ return entry;
+}
+
+async function ensureCommitAvailable(repoDir: string, sha: string): Promise {
+ try {
+ await runGitCommand(repoDir, ["cat-file", "-e", `${sha}^{commit}`]);
+ } catch {
+ await execFileAsync(
+ "git",
+ [
+ "--git-dir",
+ repoDir,
+ "-c",
+ "protocol.version=2",
+ "fetch",
+ "--no-tags",
+ "--filter=blob:none",
+ "--depth",
+ "1",
+ "origin",
+ sha,
+ ],
+ { env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_LFS_SKIP_SMUDGE: "1" } },
+ );
+ }
+}
+
+setInterval(async () => {
+ const cutoff = now() - REPO_TTL_MS;
+ for (const [key, entry] of REPO_CACHE.entries()) {
+ if (entry.lastAccess < cutoff) {
+ REPO_CACHE.delete(key);
+ await rm(entry.baseDir, { recursive: true, force: true }).catch(() => undefined);
+ }
+ }
+}, 30_000).unref();
+
+interface GitCloneOptions {
+ depth?: number;
+}
+
+async function cloneRepository(
+ repoUrl: string,
+ options: GitCloneOptions = {},
+): Promise<{ baseDir: string; repoDir: string }> {
+ const depth = options.depth && options.depth > 0 ? options.depth : 1;
+ const entry = await getRepo(repoUrl, depth);
+ return { baseDir: entry.baseDir, repoDir: entry.repoDir };
}
async function runGitCommand(repoDir: string, args: string[]): Promise {
@@ -89,118 +220,105 @@ async function getCommitHistory(
perPage: number,
) {
const depth = Math.max(page * perPage, perPage);
- const { baseDir, repoDir } = await cloneRepository(repoUrl, { depth });
+ const { repoDir } = await cloneRepository(repoUrl, { depth });
+ const skip = Math.max(0, (page - 1) * perPage);
+ const format = "%H%x1f%an%x1f%aI%x1f%ae%x1f%cn%x1f%cI%x1f%ce%x1f%B%x1e";
+ const rawLog = await runGitCommand(repoDir, [
+ "log",
+ "--date=iso-strict",
+ `--skip=${skip}`,
+ "-n",
+ String(perPage),
+ `--pretty=format:${format}`,
+ ]);
- try {
- const skip = Math.max(0, (page - 1) * perPage);
- const format = "%H%x1f%an%x1f%aI%x1f%ae%x1f%cn%x1f%cI%x1f%ce%x1f%B%x1e";
- const rawLog = await runGitCommand(repoDir, [
- "log",
- "--date=iso-strict",
- `--skip=${skip}`,
- "-n",
- String(perPage),
- `--pretty=format:${format}`,
- ]);
-
- const commits = rawLog
- .split("\x1e")
- .map((entry) => entry.trim())
- .filter(Boolean)
- .map((entry) => {
- const [
- sha,
- authorName,
- authorDate,
- authorEmail,
- committerName,
- committerDate,
- committerEmail,
- message,
- ] = entry.split("\x1f");
-
- return {
- sha,
- authorName,
- authorDate,
- authorEmail,
- committerName,
- committerDate,
- committerEmail,
- message: message?.trimEnd() ?? "",
- commitUrl: buildCommitUrl(repoUrl, sha || "") ?? "",
- };
- });
+ const commits = rawLog
+ .split("\x1e")
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+ .map((entry) => {
+ const [
+ sha,
+ authorName,
+ authorDate,
+ authorEmail,
+ committerName,
+ committerDate,
+ committerEmail,
+ message,
+ ] = entry.split("\x1f");
- return commits;
- } finally {
- await rm(baseDir, { recursive: true, force: true });
- }
+ return {
+ sha,
+ authorName,
+ authorDate,
+ authorEmail,
+ committerName,
+ committerDate,
+ committerEmail,
+ message: message?.trimEnd() ?? "",
+ commitUrl: buildCommitUrl(repoUrl, sha || "") ?? "",
+ };
+ });
+
+ return commits;
}
async function getCommitDetails(repoUrl: string, sha: string) {
- const { baseDir, repoDir } = await cloneRepository(repoUrl);
+ const { repoDir } = await cloneRepository(repoUrl, { depth: 1 });
+ await ensureCommitAvailable(repoDir, sha);
- try {
- const format = "%H%x1f%an%x1f%aI%x1f%ae%x1f%cn%x1f%cI%x1f%ce%x1f%B";
- const raw = await runGitCommand(repoDir, [
- "show",
- sha,
- "--quiet",
- "--date=iso-strict",
- `--pretty=format:${format}`,
- ]);
-
- const [
- commitSha,
- authorName,
- authorDate,
- authorEmail,
- committerName,
- committerDate,
- committerEmail,
- message,
- ] = raw.trim().split("\x1f");
-
- if (!commitSha) {
- return undefined;
- }
+ const format = "%H%x1f%an%x1f%aI%x1f%ae%x1f%cn%x1f%cI%x1f%ce%x1f%B";
+ const raw = await runGitCommand(repoDir, [
+ "show",
+ sha,
+ "--quiet",
+ "--date=iso-strict",
+ `--pretty=format:${format}`,
+ ]);
- return {
- sha: commitSha,
- html_url: buildCommitUrl(repoUrl, commitSha) ?? "",
- commit: {
- message: message?.trimEnd() ?? "",
- author: {
- name: authorName || "",
- email: authorEmail || "",
- date: authorDate || "",
- },
- committer: {
- name: committerName || "",
- email: committerEmail || "",
- date: committerDate || "",
- },
- },
- };
- } finally {
- await rm(baseDir, { recursive: true, force: true });
+ const [
+ commitSha,
+ authorName,
+ authorDate,
+ authorEmail,
+ committerName,
+ committerDate,
+ committerEmail,
+ message,
+ ] = raw.trim().split("\x1f");
+
+ if (!commitSha) {
+ return undefined;
}
+
+ return {
+ sha: commitSha,
+ html_url: buildCommitUrl(repoUrl, commitSha) ?? "",
+ commit: {
+ message: message?.trimEnd() ?? "",
+ author: {
+ name: authorName || "",
+ email: authorEmail || "",
+ date: authorDate || "",
+ },
+ committer: {
+ name: committerName || "",
+ email: committerEmail || "",
+ date: committerDate || "",
+ },
+ },
+ };
}
async function getLatestCommitHash(repoUrl: string) {
- const { baseDir, repoDir } = await cloneRepository(repoUrl, { depth: 1 });
-
- try {
- const sha = await runGitCommand(repoDir, ["rev-parse", "HEAD"]);
- return sha.trim();
- } finally {
- await rm(baseDir, { recursive: true, force: true });
- }
+ const { repoDir } = await cloneRepository(repoUrl, { depth: 1 });
+ const sha = await runGitCommand(repoDir, ["rev-parse", "HEAD"]);
+ return sha.trim();
}
async function getReadmeContent(repoUrl: string) {
- const { baseDir, repoDir } = await cloneRepository(repoUrl, { depth: 1 });
+ const { repoDir } = await cloneRepository(repoUrl, { depth: 1 });
const candidates = [
"README.md",
"README.MD",
@@ -209,25 +327,21 @@ async function getReadmeContent(repoUrl: string) {
"readme.md",
];
- try {
- for (const candidate of candidates) {
- try {
- const content = await runGitCommand(repoDir, [
- "show",
- `HEAD:${candidate}`,
- ]);
- if (content) {
- return content;
- }
- } catch (_error) {
- // Try next candidate
- continue;
+ for (const candidate of candidates) {
+ try {
+ const content = await runGitCommand(repoDir, [
+ "show",
+ `HEAD:${candidate}`,
+ ]);
+ if (content) {
+ return content;
}
+ } catch (_error) {
+ continue;
}
- return undefined;
- } finally {
- await rm(baseDir, { recursive: true, force: true });
}
+
+ return undefined;
}
export const POST: APIRoute = async ({ request }) => {
From a0e1879a96c18f89911e72245cf56b102eba056e Mon Sep 17 00:00:00 2001
From: William Le Roux
Date: Tue, 13 Jan 2026 15:46:53 +0000
Subject: [PATCH 3/3] feat: add agnostic git support with allow list and
Cloudflare proxy
- Add ALLOWED_GIT_HOSTS allow list to local git API (github, gitlab, bitbucket, codeberg, etc.)
- Support additional hosts via GIT_ALLOWED_HOSTS environment variable
- Create git-proxy Cloudflare Worker as alternative to local API
- Add PUBLIC_GIT_PROXY_URL environment variable to switch between local API and Cloudflare proxy
- Worker supports GitHub, GitLab, Bitbucket, Codeberg APIs with unified interface
- Add CORS and security controls to both local and proxy implementations
---
dapp/src/env.d.ts | 14 +
dapp/src/pages/api/git.ts | 78 +++
dapp/src/service/GithubService.ts | 22 +-
dapp/workers/git-proxy/.gitignore | 3 +
dapp/workers/git-proxy/README.md | 104 ++++
dapp/workers/git-proxy/package.json | 16 +
dapp/workers/git-proxy/src/index.ts | 731 +++++++++++++++++++++++++++
dapp/workers/git-proxy/tsconfig.json | 17 +
dapp/workers/git-proxy/wrangler.toml | 17 +
9 files changed, 1001 insertions(+), 1 deletion(-)
create mode 100644 dapp/workers/git-proxy/.gitignore
create mode 100644 dapp/workers/git-proxy/README.md
create mode 100644 dapp/workers/git-proxy/package.json
create mode 100644 dapp/workers/git-proxy/src/index.ts
create mode 100644 dapp/workers/git-proxy/tsconfig.json
create mode 100644 dapp/workers/git-proxy/wrangler.toml
diff --git a/dapp/src/env.d.ts b/dapp/src/env.d.ts
index 88bf36ed..f7748211 100644
--- a/dapp/src/env.d.ts
+++ b/dapp/src/env.d.ts
@@ -11,6 +11,20 @@ interface ImportMetaEnv {
readonly PUBLIC_DEFAULT_FEE: string;
readonly PUBLIC_DEFAULT_TIMEOUT: number;
readonly PUBLIC_DELEGATION_API_URL: string;
+ /**
+ * Optional URL for the Git proxy Cloudflare Worker.
+ * When set, git operations will use this proxy instead of the local /api/git endpoint.
+ * Examples:
+ * - "https://git.tansu.dev" (production)
+ * - "https://git-testnet.tansu.dev" (testnet)
+ */
+ readonly PUBLIC_GIT_PROXY_URL?: string;
+ /**
+ * Additional allowed Git hosts (comma-separated).
+ * Used by the local /api/git endpoint to allow custom self-hosted Git servers.
+ * Example: "gitlab.mycompany.com,git.example.org"
+ */
+ readonly GIT_ALLOWED_HOSTS?: string;
}
interface ImportMeta {
diff --git a/dapp/src/pages/api/git.ts b/dapp/src/pages/api/git.ts
index 19a0120e..9adbe0ef 100644
--- a/dapp/src/pages/api/git.ts
+++ b/dapp/src/pages/api/git.ts
@@ -9,6 +9,74 @@ import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
+/**
+ * Allow list of trusted git hosting domains.
+ * Only repositories from these domains will be cloned.
+ * Add new domains here to support additional git hosts.
+ */
+const ALLOWED_GIT_HOSTS = new Set([
+ "github.com",
+ "gitlab.com",
+ "bitbucket.org",
+ "codeberg.org",
+ "sr.ht",
+ "gitea.com",
+ "git.sr.ht",
+ // Self-hosted GitLab instances can be added via environment variable
+]);
+
+/**
+ * Additional allowed hosts from environment variable.
+ * Format: comma-separated list of domains (e.g., "gitlab.mycompany.com,git.example.org")
+ */
+const EXTRA_ALLOWED_HOSTS = (
+ import.meta.env.GIT_ALLOWED_HOSTS || ""
+)
+ .split(",")
+ .map((h: string) => h.trim().toLowerCase())
+ .filter(Boolean);
+
+EXTRA_ALLOWED_HOSTS.forEach((host: string) => ALLOWED_GIT_HOSTS.add(host));
+
+/**
+ * Validate that a repository URL is from an allowed host.
+ * Returns the normalized hostname if allowed, throws an error otherwise.
+ */
+function validateRepoUrl(repoUrl: string): string {
+ const trimmed = repoUrl.trim();
+
+ // Handle SSH URLs (git@host:path)
+ if (trimmed.startsWith("git@")) {
+ const match = trimmed.match(/^git@([^:]+):/);
+ if (match) {
+ const host = match[1].toLowerCase();
+ if (ALLOWED_GIT_HOSTS.has(host)) {
+ return host;
+ }
+ }
+ throw new Error(
+ `Repository host not allowed. Allowed hosts: ${Array.from(ALLOWED_GIT_HOSTS).join(", ")}`,
+ );
+ }
+
+ // Handle HTTPS URLs
+ try {
+ const url = new URL(trimmed);
+ const host = url.hostname.toLowerCase();
+ if (ALLOWED_GIT_HOSTS.has(host)) {
+ return host;
+ }
+ throw new Error(
+ `Repository host "${host}" not allowed. Allowed hosts: ${Array.from(ALLOWED_GIT_HOSTS).join(", ")}`,
+ );
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("not allowed")) {
+ throw e;
+ }
+ throw new Error("Invalid repository URL format");
+ }
+}
+
interface RepoCacheEntry {
baseDir: string;
repoDir: string;
@@ -364,6 +432,16 @@ export const POST: APIRoute = async ({ request }) => {
);
}
+ // Validate that the repository is from an allowed host
+ try {
+ validateRepoUrl(repoUrl);
+ } catch (validationError: any) {
+ return new Response(
+ JSON.stringify({ error: validationError.message }),
+ { status: 403 },
+ );
+ }
+
switch (action) {
case "history": {
const commits = await getCommitHistory(repoUrl, page, perPage);
diff --git a/dapp/src/service/GithubService.ts b/dapp/src/service/GithubService.ts
index fd31b29b..8113bf1c 100644
--- a/dapp/src/service/GithubService.ts
+++ b/dapp/src/service/GithubService.ts
@@ -18,8 +18,28 @@ interface GitCommitDetails {
};
}
+/**
+ * Get the Git API endpoint URL.
+ * Uses Cloudflare proxy if configured, otherwise uses local API.
+ *
+ * Environment variable GIT_PROXY_URL can be set to:
+ * - "https://git.tansu.dev" (production Cloudflare Worker)
+ * - "https://git-testnet.tansu.dev" (testnet Cloudflare Worker)
+ * - "" or undefined (use local /api/git endpoint)
+ */
+function getGitApiUrl(): string {
+ // Check for Cloudflare proxy URL from environment
+ const proxyUrl = import.meta.env.PUBLIC_GIT_PROXY_URL;
+ if (proxyUrl && typeof proxyUrl === "string" && proxyUrl.trim()) {
+ return proxyUrl.trim();
+ }
+ // Default to local API endpoint
+ return "/api/git";
+}
+
async function callGitApi(body: Record): Promise {
- const response = await fetch("/api/git", {
+ const apiUrl = getGitApiUrl();
+ const response = await fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
diff --git a/dapp/workers/git-proxy/.gitignore b/dapp/workers/git-proxy/.gitignore
new file mode 100644
index 00000000..d391312c
--- /dev/null
+++ b/dapp/workers/git-proxy/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+.wrangler/
diff --git a/dapp/workers/git-proxy/README.md b/dapp/workers/git-proxy/README.md
new file mode 100644
index 00000000..2c2797b4
--- /dev/null
+++ b/dapp/workers/git-proxy/README.md
@@ -0,0 +1,104 @@
+# Git Proxy Cloudflare Worker
+
+A Cloudflare Worker that provides a unified API for fetching git commit data from multiple git hosting providers. This worker acts as a proxy to avoid CORS issues and rate limiting when fetching git data from the browser.
+
+## Supported Git Hosts
+
+- **GitHub** (github.com)
+- **GitLab** (gitlab.com)
+- **Bitbucket** (bitbucket.org)
+- **Codeberg** (codeberg.org)
+- **Gitea** (gitea.com)
+- **SourceHut** (sr.ht, git.sr.ht)
+
+Additional hosts can be added via the `EXTRA_GIT_HOSTS` environment variable.
+
+## Security
+
+### Allow List
+
+The worker only proxies requests to repositories hosted on allowed domains. This prevents abuse and ensures the proxy is only used for legitimate git hosting services.
+
+### CORS
+
+Only requests from allowed origins are accepted:
+- `http://localhost:4321` (development)
+- `https://testnet.tansu.dev`
+- `https://app.tansu.dev`
+- `https://tansu.xlm.sh`
+- Netlify deploy previews
+
+## API
+
+All requests are POST requests with a JSON body.
+
+### Get Commit History
+
+```json
+{
+ "action": "history",
+ "repoUrl": "https://github.com/owner/repo",
+ "page": 1,
+ "perPage": 30
+}
+```
+
+### Get Commit Details
+
+```json
+{
+ "action": "commit",
+ "repoUrl": "https://github.com/owner/repo",
+ "sha": "abc123..."
+}
+```
+
+### Get Latest Commit Hash
+
+```json
+{
+ "action": "latest-hash",
+ "repoUrl": "https://github.com/owner/repo"
+}
+```
+
+### Get README Content
+
+```json
+{
+ "action": "readme",
+ "repoUrl": "https://github.com/owner/repo"
+}
+```
+
+## Configuration
+
+### Environment Variables
+
+- `GITHUB_TOKEN` (optional): GitHub personal access token for higher rate limits
+- `GITLAB_TOKEN` (optional): GitLab token for private repositories
+- `EXTRA_GIT_HOSTS` (optional): Comma-separated list of additional allowed hosts
+
+### Deployment
+
+```bash
+# Development
+npm run dev
+
+# Deploy to testnet
+npm run deploy:testnet
+
+# Deploy to production
+npm run deploy:production
+```
+
+## Using with the dApp
+
+Set the `PUBLIC_GIT_PROXY_URL` environment variable in your dApp:
+
+```env
+# Use Cloudflare proxy instead of local /api/git
+PUBLIC_GIT_PROXY_URL=https://git.tansu.dev
+```
+
+When this variable is set, the GithubService will use the Cloudflare Worker instead of the local API endpoint.
diff --git a/dapp/workers/git-proxy/package.json b/dapp/workers/git-proxy/package.json
new file mode 100644
index 00000000..8bced331
--- /dev/null
+++ b/dapp/workers/git-proxy/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "git-proxy",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "deploy:testnet": "wrangler deploy --env testnet",
+ "deploy:production": "wrangler deploy --env production",
+ "dev": "wrangler dev",
+ "tail": "wrangler tail"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4.20241127.0",
+ "typescript": "^5.7.0",
+ "wrangler": "^3.91.0"
+ }
+}
diff --git a/dapp/workers/git-proxy/src/index.ts b/dapp/workers/git-proxy/src/index.ts
new file mode 100644
index 00000000..d863c2d0
--- /dev/null
+++ b/dapp/workers/git-proxy/src/index.ts
@@ -0,0 +1,731 @@
+/**
+ * Cloudflare Worker for Git Proxy
+ *
+ * This worker proxies requests to various Git hosting providers' APIs.
+ * It provides a unified interface for fetching commit data from
+ * GitHub, GitLab, Bitbucket, Codeberg, and other Git hosts.
+ *
+ * SECURITY: Uses an allow list for Git hosts and CORS for origins.
+ */
+
+export interface Env {
+ // Optional: GitHub token for higher rate limits
+ GITHUB_TOKEN?: string;
+ // Optional: GitLab token for private repos
+ GITLAB_TOKEN?: string;
+ // Optional: Additional allowed hosts (comma-separated)
+ EXTRA_GIT_HOSTS?: string;
+}
+
+/**
+ * Allow list of trusted git hosting domains.
+ * Only repositories from these domains will be proxied.
+ */
+const ALLOWED_GIT_HOSTS = new Set([
+ "github.com",
+ "gitlab.com",
+ "bitbucket.org",
+ "codeberg.org",
+ "sr.ht",
+ "gitea.com",
+ "git.sr.ht",
+]);
+
+const ALLOWED_ORIGINS = [
+ "http://localhost:4321",
+ "https://testnet.tansu.dev",
+ "https://app.tansu.dev",
+ "https://tansu.xlm.sh",
+ "https://deploy-preview-*--staging-tansu.netlify.app",
+];
+
+function getCorsHeaders(origin: string | null): Record {
+ if (!origin) return {};
+
+ const isAllowed = ALLOWED_ORIGINS.some(
+ (allowed) =>
+ allowed === origin ||
+ (allowed.includes("*") &&
+ new RegExp(`^${allowed.replace(/\*/g, ".*")}$`).test(origin)),
+ );
+
+ if (!isAllowed) return {};
+
+ return {
+ "Access-Control-Allow-Origin": origin,
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ "Access-Control-Max-Age": "86400",
+ };
+}
+
+function getAllowedHosts(env: Env): Set {
+ const hosts = new Set(ALLOWED_GIT_HOSTS);
+ if (env.EXTRA_GIT_HOSTS) {
+ env.EXTRA_GIT_HOSTS.split(",")
+ .map((h) => h.trim().toLowerCase())
+ .filter(Boolean)
+ .forEach((h) => hosts.add(h));
+ }
+ return hosts;
+}
+
+function validateRepoUrl(repoUrl: string, allowedHosts: Set): URL {
+ const trimmed = repoUrl.trim();
+
+ try {
+ const url = new URL(trimmed);
+ const host = url.hostname.toLowerCase();
+ if (allowedHosts.has(host)) {
+ return url;
+ }
+ throw new Error(
+ `Repository host "${host}" not allowed. Allowed hosts: ${Array.from(allowedHosts).join(", ")}`,
+ );
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("not allowed")) {
+ throw e;
+ }
+ throw new Error("Invalid repository URL format");
+ }
+}
+
+function normalizeRepoUrl(repoUrl: string): string {
+ let normalized = repoUrl.trim();
+ if (normalized.endsWith(".git")) {
+ normalized = normalized.slice(0, -4);
+ }
+ if (normalized.endsWith("/")) {
+ normalized = normalized.slice(0, -1);
+ }
+ return normalized;
+}
+
+function parseRepoPath(url: URL): { owner: string; repo: string } {
+ const parts = url.pathname.split("/").filter(Boolean);
+ if (parts.length < 2) {
+ throw new Error("Invalid repository path");
+ }
+ return { owner: parts[0], repo: parts[1].replace(".git", "") };
+}
+
+interface GitCommit {
+ sha: string;
+ authorName: string;
+ authorDate: string;
+ authorEmail: string;
+ committerName: string;
+ committerDate: string;
+ committerEmail: string;
+ message: string;
+ commitUrl: string;
+}
+
+interface GitHostHandler {
+ getCommitHistory(
+ owner: string,
+ repo: string,
+ page: number,
+ perPage: number,
+ env: Env,
+ ): Promise;
+ getCommitDetails(
+ owner: string,
+ repo: string,
+ sha: string,
+ env: Env,
+ ): Promise;
+ getLatestCommitHash(owner: string, repo: string, env: Env): Promise;
+ getReadmeContent(
+ owner: string,
+ repo: string,
+ env: Env,
+ ): Promise;
+}
+
+// GitHub API handler
+const githubHandler: GitHostHandler = {
+ async getCommitHistory(owner, repo, page, perPage, env) {
+ const headers: Record = {
+ Accept: "application/vnd.github.v3+json",
+ "User-Agent": "Tansu-Git-Proxy/1.0",
+ };
+ if (env.GITHUB_TOKEN) {
+ headers.Authorization = `Bearer ${env.GITHUB_TOKEN}`;
+ }
+
+ const response = await fetch(
+ `https://api.github.com/repos/${owner}/${repo}/commits?page=${page}&per_page=${perPage}`,
+ { headers },
+ );
+
+ if (!response.ok) {
+ throw new Error(`GitHub API error: ${response.status}`);
+ }
+
+ const data = (await response.json()) as any[];
+ return data.map((commit: any) => ({
+ sha: commit.sha,
+ authorName: commit.commit.author.name,
+ authorDate: commit.commit.author.date,
+ authorEmail: commit.commit.author.email,
+ committerName: commit.commit.committer.name,
+ committerDate: commit.commit.committer.date,
+ committerEmail: commit.commit.committer.email,
+ message: commit.commit.message,
+ commitUrl: commit.html_url,
+ }));
+ },
+
+ async getCommitDetails(owner, repo, sha, env) {
+ const headers: Record = {
+ Accept: "application/vnd.github.v3+json",
+ "User-Agent": "Tansu-Git-Proxy/1.0",
+ };
+ if (env.GITHUB_TOKEN) {
+ headers.Authorization = `Bearer ${env.GITHUB_TOKEN}`;
+ }
+
+ const response = await fetch(
+ `https://api.github.com/repos/${owner}/${repo}/commits/${sha}`,
+ { headers },
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) return null;
+ throw new Error(`GitHub API error: ${response.status}`);
+ }
+
+ const commit = (await response.json()) as any;
+ return {
+ sha: commit.sha,
+ authorName: commit.commit.author.name,
+ authorDate: commit.commit.author.date,
+ authorEmail: commit.commit.author.email,
+ committerName: commit.commit.committer.name,
+ committerDate: commit.commit.committer.date,
+ committerEmail: commit.commit.committer.email,
+ message: commit.commit.message,
+ commitUrl: commit.html_url,
+ };
+ },
+
+ async getLatestCommitHash(owner, repo, env) {
+ const headers: Record = {
+ Accept: "application/vnd.github.v3+json",
+ "User-Agent": "Tansu-Git-Proxy/1.0",
+ };
+ if (env.GITHUB_TOKEN) {
+ headers.Authorization = `Bearer ${env.GITHUB_TOKEN}`;
+ }
+
+ const response = await fetch(
+ `https://api.github.com/repos/${owner}/${repo}/commits?per_page=1`,
+ { headers },
+ );
+
+ if (!response.ok) {
+ throw new Error(`GitHub API error: ${response.status}`);
+ }
+
+ const data = (await response.json()) as any[];
+ if (data.length === 0) {
+ throw new Error("No commits found");
+ }
+ return data[0].sha;
+ },
+
+ async getReadmeContent(owner, repo, env) {
+ const headers: Record = {
+ Accept: "application/vnd.github.v3.raw",
+ "User-Agent": "Tansu-Git-Proxy/1.0",
+ };
+ if (env.GITHUB_TOKEN) {
+ headers.Authorization = `Bearer ${env.GITHUB_TOKEN}`;
+ }
+
+ const response = await fetch(
+ `https://api.github.com/repos/${owner}/${repo}/readme`,
+ { headers },
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) return null;
+ throw new Error(`GitHub API error: ${response.status}`);
+ }
+
+ return response.text();
+ },
+};
+
+// GitLab API handler
+const gitlabHandler: GitHostHandler = {
+ async getCommitHistory(owner, repo, page, perPage, env) {
+ const headers: Record = {
+ "User-Agent": "Tansu-Git-Proxy/1.0",
+ };
+ if (env.GITLAB_TOKEN) {
+ headers["PRIVATE-TOKEN"] = env.GITLAB_TOKEN;
+ }
+
+ const projectPath = encodeURIComponent(`${owner}/${repo}`);
+ const response = await fetch(
+ `https://gitlab.com/api/v4/projects/${projectPath}/repository/commits?page=${page}&per_page=${perPage}`,
+ { headers },
+ );
+
+ if (!response.ok) {
+ throw new Error(`GitLab API error: ${response.status}`);
+ }
+
+ const data = (await response.json()) as any[];
+ return data.map((commit: any) => ({
+ sha: commit.id,
+ authorName: commit.author_name,
+ authorDate: commit.authored_date,
+ authorEmail: commit.author_email,
+ committerName: commit.committer_name,
+ committerDate: commit.committed_date,
+ committerEmail: commit.committer_email,
+ message: commit.message,
+ commitUrl: commit.web_url,
+ }));
+ },
+
+ async getCommitDetails(owner, repo, sha, env) {
+ const headers: Record = {
+ "User-Agent": "Tansu-Git-Proxy/1.0",
+ };
+ if (env.GITLAB_TOKEN) {
+ headers["PRIVATE-TOKEN"] = env.GITLAB_TOKEN;
+ }
+
+ const projectPath = encodeURIComponent(`${owner}/${repo}`);
+ const response = await fetch(
+ `https://gitlab.com/api/v4/projects/${projectPath}/repository/commits/${sha}`,
+ { headers },
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) return null;
+ throw new Error(`GitLab API error: ${response.status}`);
+ }
+
+ const commit = (await response.json()) as any;
+ return {
+ sha: commit.id,
+ authorName: commit.author_name,
+ authorDate: commit.authored_date,
+ authorEmail: commit.author_email,
+ committerName: commit.committer_name,
+ committerDate: commit.committed_date,
+ committerEmail: commit.committer_email,
+ message: commit.message,
+ commitUrl: commit.web_url,
+ };
+ },
+
+ async getLatestCommitHash(owner, repo, env) {
+ const commits = await gitlabHandler.getCommitHistory(
+ owner,
+ repo,
+ 1,
+ 1,
+ env,
+ );
+ if (commits.length === 0) {
+ throw new Error("No commits found");
+ }
+ return commits[0].sha;
+ },
+
+ async getReadmeContent(owner, repo, env) {
+ const headers: Record = {
+ "User-Agent": "Tansu-Git-Proxy/1.0",
+ };
+ if (env.GITLAB_TOKEN) {
+ headers["PRIVATE-TOKEN"] = env.GITLAB_TOKEN;
+ }
+
+ const projectPath = encodeURIComponent(`${owner}/${repo}`);
+
+ // Try common README filenames
+ const candidates = ["README.md", "README", "readme.md"];
+ for (const candidate of candidates) {
+ const response = await fetch(
+ `https://gitlab.com/api/v4/projects/${projectPath}/repository/files/${encodeURIComponent(candidate)}/raw?ref=HEAD`,
+ { headers },
+ );
+
+ if (response.ok) {
+ return response.text();
+ }
+ }
+
+ return null;
+ },
+};
+
+// Codeberg/Gitea API handler (Forgejo-compatible)
+const codebergHandler: GitHostHandler = {
+ async getCommitHistory(owner, repo, page, perPage, _env) {
+ const response = await fetch(
+ `https://codeberg.org/api/v1/repos/${owner}/${repo}/commits?page=${page}&limit=${perPage}`,
+ {
+ headers: { "User-Agent": "Tansu-Git-Proxy/1.0" },
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`Codeberg API error: ${response.status}`);
+ }
+
+ const data = (await response.json()) as any[];
+ return data.map((commit: any) => ({
+ sha: commit.sha,
+ authorName: commit.commit.author.name,
+ authorDate: commit.commit.author.date,
+ authorEmail: commit.commit.author.email,
+ committerName: commit.commit.committer.name,
+ committerDate: commit.commit.committer.date,
+ committerEmail: commit.commit.committer.email,
+ message: commit.commit.message,
+ commitUrl: commit.html_url,
+ }));
+ },
+
+ async getCommitDetails(owner, repo, sha, _env) {
+ const response = await fetch(
+ `https://codeberg.org/api/v1/repos/${owner}/${repo}/git/commits/${sha}`,
+ {
+ headers: { "User-Agent": "Tansu-Git-Proxy/1.0" },
+ },
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) return null;
+ throw new Error(`Codeberg API error: ${response.status}`);
+ }
+
+ const commit = (await response.json()) as any;
+ return {
+ sha: commit.sha,
+ authorName: commit.author?.name || "",
+ authorDate: commit.author?.date || "",
+ authorEmail: commit.author?.email || "",
+ committerName: commit.committer?.name || "",
+ committerDate: commit.committer?.date || "",
+ committerEmail: commit.committer?.email || "",
+ message: commit.message,
+ commitUrl: `https://codeberg.org/${owner}/${repo}/commit/${sha}`,
+ };
+ },
+
+ async getLatestCommitHash(owner, repo, env) {
+ const commits = await codebergHandler.getCommitHistory(
+ owner,
+ repo,
+ 1,
+ 1,
+ env,
+ );
+ if (commits.length === 0) {
+ throw new Error("No commits found");
+ }
+ return commits[0].sha;
+ },
+
+ async getReadmeContent(owner, repo, _env) {
+ const response = await fetch(
+ `https://codeberg.org/api/v1/repos/${owner}/${repo}/raw/README.md`,
+ {
+ headers: { "User-Agent": "Tansu-Git-Proxy/1.0" },
+ },
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) return null;
+ throw new Error(`Codeberg API error: ${response.status}`);
+ }
+
+ return response.text();
+ },
+};
+
+// Bitbucket API handler
+const bitbucketHandler: GitHostHandler = {
+ async getCommitHistory(owner, repo, page, perPage, _env) {
+ const response = await fetch(
+ `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/commits?page=${page}&pagelen=${perPage}`,
+ {
+ headers: { "User-Agent": "Tansu-Git-Proxy/1.0" },
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`Bitbucket API error: ${response.status}`);
+ }
+
+ const data = (await response.json()) as any;
+ return (data.values || []).map((commit: any) => ({
+ sha: commit.hash,
+ authorName: commit.author?.user?.display_name || commit.author?.raw || "",
+ authorDate: commit.date,
+ authorEmail: "",
+ committerName: commit.author?.user?.display_name || "",
+ committerDate: commit.date,
+ committerEmail: "",
+ message: commit.message,
+ commitUrl: commit.links?.html?.href || "",
+ }));
+ },
+
+ async getCommitDetails(owner, repo, sha, _env) {
+ const response = await fetch(
+ `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/commit/${sha}`,
+ {
+ headers: { "User-Agent": "Tansu-Git-Proxy/1.0" },
+ },
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) return null;
+ throw new Error(`Bitbucket API error: ${response.status}`);
+ }
+
+ const commit = (await response.json()) as any;
+ return {
+ sha: commit.hash,
+ authorName: commit.author?.user?.display_name || commit.author?.raw || "",
+ authorDate: commit.date,
+ authorEmail: "",
+ committerName: commit.author?.user?.display_name || "",
+ committerDate: commit.date,
+ committerEmail: "",
+ message: commit.message,
+ commitUrl: commit.links?.html?.href || "",
+ };
+ },
+
+ async getLatestCommitHash(owner, repo, env) {
+ const commits = await bitbucketHandler.getCommitHistory(
+ owner,
+ repo,
+ 1,
+ 1,
+ env,
+ );
+ if (commits.length === 0) {
+ throw new Error("No commits found");
+ }
+ return commits[0].sha;
+ },
+
+ async getReadmeContent(owner, repo, _env) {
+ const response = await fetch(
+ `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/HEAD/README.md`,
+ {
+ headers: { "User-Agent": "Tansu-Git-Proxy/1.0" },
+ },
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) return null;
+ throw new Error(`Bitbucket API error: ${response.status}`);
+ }
+
+ return response.text();
+ },
+};
+
+function getHandlerForHost(host: string): GitHostHandler {
+ const normalizedHost = host.toLowerCase();
+
+ if (normalizedHost.includes("github")) {
+ return githubHandler;
+ }
+ if (normalizedHost.includes("gitlab")) {
+ return gitlabHandler;
+ }
+ if (normalizedHost.includes("codeberg") || normalizedHost.includes("gitea")) {
+ return codebergHandler;
+ }
+ if (normalizedHost.includes("bitbucket")) {
+ return bitbucketHandler;
+ }
+
+ // Default to Gitea/Forgejo API for unknown hosts (common for self-hosted)
+ return codebergHandler;
+}
+
+interface RequestBody {
+ action: "history" | "commit" | "latest-hash" | "readme";
+ repoUrl: string;
+ page?: number;
+ perPage?: number;
+ sha?: string;
+}
+
+export default {
+ async fetch(request: Request, env: Env): Promise {
+ const origin = request.headers.get("Origin");
+ const corsHeaders = getCorsHeaders(origin);
+
+ // Handle OPTIONS preflight request
+ if (request.method === "OPTIONS") {
+ return new Response(null, {
+ status: 204,
+ headers: corsHeaders,
+ });
+ }
+
+ if (request.method !== "POST") {
+ return new Response(JSON.stringify({ error: "Method not allowed" }), {
+ status: 405,
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
+ }
+
+ try {
+ const body = (await request.json()) as RequestBody;
+ const { action, repoUrl, page = 1, perPage = 30, sha } = body;
+
+ if (!repoUrl || typeof repoUrl !== "string") {
+ return new Response(
+ JSON.stringify({ error: "Repository URL is required" }),
+ {
+ status: 400,
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
+ },
+ );
+ }
+
+ // Validate and parse the repository URL
+ const allowedHosts = getAllowedHosts(env);
+ let url: URL;
+ try {
+ url = validateRepoUrl(repoUrl, allowedHosts);
+ } catch (validationError: any) {
+ return new Response(
+ JSON.stringify({ error: validationError.message }),
+ {
+ status: 403,
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
+ },
+ );
+ }
+
+ const { owner, repo } = parseRepoPath(url);
+ const handler = getHandlerForHost(url.hostname);
+
+ switch (action) {
+ case "history": {
+ const commits = await handler.getCommitHistory(
+ owner,
+ repo,
+ page,
+ perPage,
+ env,
+ );
+ return new Response(JSON.stringify({ commits }), {
+ status: 200,
+ headers: {
+ ...corsHeaders,
+ "Content-Type": "application/json",
+ "Cache-Control": "public, max-age=60",
+ },
+ });
+ }
+
+ case "commit": {
+ if (!sha || typeof sha !== "string") {
+ return new Response(
+ JSON.stringify({ error: "Commit SHA is required" }),
+ {
+ status: 400,
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
+ },
+ );
+ }
+ const commit = await handler.getCommitDetails(owner, repo, sha, env);
+ if (!commit) {
+ return new Response(JSON.stringify({ error: "Commit not found" }), {
+ status: 404,
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
+ }
+ return new Response(
+ JSON.stringify({
+ sha: commit.sha,
+ html_url: commit.commitUrl,
+ commit: {
+ message: commit.message,
+ author: {
+ name: commit.authorName,
+ email: commit.authorEmail,
+ date: commit.authorDate,
+ },
+ committer: {
+ name: commit.committerName,
+ email: commit.committerEmail,
+ date: commit.committerDate,
+ },
+ },
+ }),
+ {
+ status: 200,
+ headers: {
+ ...corsHeaders,
+ "Content-Type": "application/json",
+ "Cache-Control": "public, max-age=3600",
+ },
+ },
+ );
+ }
+
+ case "latest-hash": {
+ const latestSha = await handler.getLatestCommitHash(
+ owner,
+ repo,
+ env,
+ );
+ return new Response(JSON.stringify({ sha: latestSha }), {
+ status: 200,
+ headers: {
+ ...corsHeaders,
+ "Content-Type": "application/json",
+ "Cache-Control": "public, max-age=60",
+ },
+ });
+ }
+
+ case "readme": {
+ const content = await handler.getReadmeContent(owner, repo, env);
+ return new Response(JSON.stringify({ content }), {
+ status: 200,
+ headers: {
+ ...corsHeaders,
+ "Content-Type": "application/json",
+ "Cache-Control": "public, max-age=300",
+ },
+ });
+ }
+
+ default:
+ return new Response(JSON.stringify({ error: "Unknown action" }), {
+ status: 400,
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
+ }
+ } catch (error: any) {
+ console.error("Git proxy error:", error);
+ return new Response(
+ JSON.stringify({ error: "Failed to process git request" }),
+ {
+ status: 500,
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
+ },
+ );
+ }
+ },
+};
diff --git a/dapp/workers/git-proxy/tsconfig.json b/dapp/workers/git-proxy/tsconfig.json
new file mode 100644
index 00000000..b25d9af5
--- /dev/null
+++ b/dapp/workers/git-proxy/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "WebWorker"],
+ "types": ["@cloudflare/workers-types"],
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules"]
+}
diff --git a/dapp/workers/git-proxy/wrangler.toml b/dapp/workers/git-proxy/wrangler.toml
new file mode 100644
index 00000000..0f3a25a6
--- /dev/null
+++ b/dapp/workers/git-proxy/wrangler.toml
@@ -0,0 +1,17 @@
+name = "git-proxy"
+main = "src/index.ts"
+compatibility_date = "2024-01-01"
+compatibility_flags = ["nodejs_compat"]
+
+[env.testnet]
+[[env.testnet.routes]]
+pattern = "git-testnet.tansu.dev"
+custom_domain = true
+
+[env.production]
+[[env.production.routes]]
+pattern = "git.tansu.dev"
+custom_domain = true
+
+[observability]
+enabled = true