feat(projects): 메인 언어 Gitea 자동 판별 + 수동 선택 폴백
- src/gitea.js: repo_url 파싱 + 언어 통계 API(fetchMainLang) 신규 - 등록 폼에 '메인 언어' 드롭다운 추가(기본 '자동 감지') - 결정 순서: 드롭다운 → Gitea 자동감지 → 태그 추론 → 미표시 - langFromTags 기본값 TypeScript 제거(안 맞으면 미표시) - GITEA_TOKEN(선택): 비공개 레포 자동감지용 스위치 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,11 @@ OIDC_CLIENT_SECRET=REPLACE_WITH_PORTAL_CLIENT_SECRET
|
|||||||
# 이 Keycloak 역할(realm role)을 가진 사용자에게만 인프라 도구(OpenEverest/MinIO/Keycloak/LiteLLM) 노출.
|
# 이 Keycloak 역할(realm role)을 가진 사용자에게만 인프라 도구(OpenEverest/MinIO/Keycloak/LiteLLM) 노출.
|
||||||
ADMIN_ROLE=admin
|
ADMIN_ROLE=admin
|
||||||
|
|
||||||
|
# --- Gitea 언어 자동 판별 (선택) ---
|
||||||
|
# 프로젝트 등록 시 repo_url 이 Gitea 레포면 언어 통계 API 로 메인 언어를 자동으로 채운다.
|
||||||
|
# 공개 레포는 토큰 없이 동작. 비공개 레포까지 판별하려면 read:repository 권한 토큰 필요.
|
||||||
|
# GITEA_TOKEN=
|
||||||
|
|
||||||
# --- 도구 URL (기본값 있음 · 도메인/폐쇄망 대응으로 덮어쓰기) ---
|
# --- 도구 URL (기본값 있음 · 도메인/폐쇄망 대응으로 덮어쓰기) ---
|
||||||
# CODER_URL=https://coder.bokdev.in/api/v2/users/oidc/callback
|
# CODER_URL=https://coder.bokdev.in/api/v2/users/oidc/callback
|
||||||
# GITEA_URL=https://gitea.bokdev.in/user/oauth2/keycloak
|
# GITEA_URL=https://gitea.bokdev.in/user/oauth2/keycloak
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export const config = {
|
|||||||
// 관리자 판별용 Keycloak 역할 이름. 이 role 을 가진 사용자에게만 인프라 도구 노출.
|
// 관리자 판별용 Keycloak 역할 이름. 이 role 을 가진 사용자에게만 인프라 도구 노출.
|
||||||
adminRole: process.env.ADMIN_ROLE || "admin",
|
adminRole: process.env.ADMIN_ROLE || "admin",
|
||||||
|
|
||||||
|
// 프로젝트 등록 시 Gitea 언어 통계 API 로 메인 언어 자동 판별용 토큰(선택).
|
||||||
|
// 공개 레포는 없어도 되고, 비공개 레포 조회에만 필요.
|
||||||
|
giteaToken: process.env.GITEA_TOKEN || "",
|
||||||
|
|
||||||
// 도구(사이트) URL — 폐쇄망/도메인 변경 대비 환경변수로 덮어쓰기 가능.
|
// 도구(사이트) URL — 폐쇄망/도메인 변경 대비 환경변수로 덮어쓰기 가능.
|
||||||
tools: {
|
tools: {
|
||||||
coder: process.env.CODER_URL || "https://coder.bokdev.in/api/v2/users/oidc/callback",
|
coder: process.env.CODER_URL || "https://coder.bokdev.in/api/v2/users/oidc/callback",
|
||||||
|
|||||||
50
src/gitea.js
Normal file
50
src/gitea.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Gitea 언어 통계 조회 — 프로젝트의 repo_url 로 메인 언어를 자동 판별.
|
||||||
|
// 공개 레포는 토큰 없이도 조회되고, 비공개 레포는 GITEA_TOKEN 이 있어야 한다.
|
||||||
|
// 네트워크 실패/타임아웃/비-Gitea URL 이면 빈 문자열을 반환해 호출부가 폴백하도록 한다.
|
||||||
|
import { config } from "./config.js";
|
||||||
|
|
||||||
|
// repo_url → { base, owner, repo } 파싱. URL 형태가 아니거나 owner/repo 가 없으면 null.
|
||||||
|
// 예) https://gitea.bokdev.in/hong/my-app(.git) → { base, owner: hong, repo: my-app }
|
||||||
|
export function parseRepo(repoUrl) {
|
||||||
|
if (!repoUrl) return null;
|
||||||
|
let u;
|
||||||
|
try {
|
||||||
|
u = new URL(repoUrl);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parts = u.pathname.replace(/\.git$/, "").split("/").filter(Boolean);
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
return { base: `${u.protocol}//${u.host}`, owner: parts[0], repo: parts[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레포의 메인 언어(바이트 최다) 1개를 반환. 실패/없음이면 "".
|
||||||
|
export async function fetchMainLang(repoUrl) {
|
||||||
|
const r = parseRepo(repoUrl);
|
||||||
|
if (!r) return "";
|
||||||
|
const url = `${r.base}/api/v1/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.repo)}/languages`;
|
||||||
|
const headers = { Accept: "application/json" };
|
||||||
|
if (config.giteaToken) headers.Authorization = `token ${config.giteaToken}`;
|
||||||
|
|
||||||
|
// 등록 응답이 Gitea 응답을 기다리다 멈추지 않도록 3초 타임아웃.
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), 3000);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers, signal: ctrl.signal });
|
||||||
|
if (!res.ok) return "";
|
||||||
|
const data = await res.json(); // { "JavaScript": 12345, "CSS": 678, ... }
|
||||||
|
let top = "";
|
||||||
|
let max = -1;
|
||||||
|
for (const [lang, bytes] of Object.entries(data || {})) {
|
||||||
|
if (typeof bytes === "number" && bytes > max) {
|
||||||
|
max = bytes;
|
||||||
|
top = lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return top;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ export function langFromTags(tags) {
|
|||||||
if (t.includes("go") || t.includes("golang")) return "Go";
|
if (t.includes("go") || t.includes("golang")) return "Go";
|
||||||
if (t.includes("rust")) return "Rust";
|
if (t.includes("rust")) return "Rust";
|
||||||
if (t.includes("node") || t.includes("js")) return "JavaScript";
|
if (t.includes("node") || t.includes("js")) return "JavaScript";
|
||||||
return "TypeScript";
|
return ""; // 매칭되는 언어 태그가 없으면 언어 미표시
|
||||||
}
|
}
|
||||||
|
|
||||||
// 문자열 → 결정적 해시(아바타/색 시드).
|
// 문자열 → 결정적 해시(아바타/색 시드).
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { config } from "./config.js";
|
|||||||
import { toolsForUser } from "./sites.js";
|
import { toolsForUser } from "./sites.js";
|
||||||
import { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady, logoutUrl } from "./auth.js";
|
import { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady, logoutUrl } from "./auth.js";
|
||||||
import { icon, identicon, langColor, langFromTags } from "./helpers.js";
|
import { icon, identicon, langColor, langFromTags } from "./helpers.js";
|
||||||
|
import { fetchMainLang } from "./gitea.js";
|
||||||
import * as db from "./db.js";
|
import * as db from "./db.js";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -110,15 +111,18 @@ app.post("/projects", requireAuth, async (req, res) => {
|
|||||||
.map((t) => t.trim().replace(/^#/, ""))
|
.map((t) => t.trim().replace(/^#/, ""))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
|
const repoUrl = (repo_url || "").trim();
|
||||||
|
// 메인 언어: 사용자가 직접 고르면 그 값, '자동 감지'면 Gitea 언어 통계 → 태그 추론 순으로 폴백.
|
||||||
|
const lang = (req.body.lang || "").trim() || (await fetchMainLang(repoUrl)) || langFromTags(tagArr);
|
||||||
await db.addProject({
|
await db.addProject({
|
||||||
owner: req.session.user.sabun,
|
owner: req.session.user.sabun,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: (description || "").trim(),
|
description: (description || "").trim(),
|
||||||
repoUrl: (repo_url || "").trim(),
|
repoUrl,
|
||||||
appUrl: (app_url || "").trim(),
|
appUrl: (app_url || "").trim(),
|
||||||
tags: tagArr.join(", "),
|
tags: tagArr.join(", "),
|
||||||
isPublic: req.body.is_public !== "0",
|
isPublic: req.body.is_public !== "0",
|
||||||
lang: langFromTags(tagArr),
|
lang,
|
||||||
});
|
});
|
||||||
const msg = req.body.is_public === "0" ? "비공개로 등록됐습니다" : "등록 완료 · 피드에 공유됐습니다";
|
const msg = req.body.is_public === "0" ? "비공개로 등록됐습니다" : "등록 완료 · 피드에 공유됐습니다";
|
||||||
res.redirect("/?toast=" + encodeURIComponent(msg));
|
res.redirect("/?toast=" + encodeURIComponent(msg));
|
||||||
|
|||||||
@@ -219,6 +219,19 @@
|
|||||||
<span class="share-label">저장소 URL <span class="hint">선택</span></span>
|
<span class="share-label">저장소 URL <span class="hint">선택</span></span>
|
||||||
<input class="share-input mono" name="repo_url" placeholder="https://gitea.bokdev.in/사번/repo" />
|
<input class="share-input mono" name="repo_url" placeholder="https://gitea.bokdev.in/사번/repo" />
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
<span class="share-label">메인 언어 <span class="hint">선택 · 기본은 저장소에서 자동 감지</span></span>
|
||||||
|
<select class="share-input" name="lang">
|
||||||
|
<option value="">자동 감지</option>
|
||||||
|
<option value="Python">Python</option>
|
||||||
|
<option value="JavaScript">JavaScript</option>
|
||||||
|
<option value="TypeScript">TypeScript</option>
|
||||||
|
<option value="Go">Go</option>
|
||||||
|
<option value="Java">Java</option>
|
||||||
|
<option value="Rust">Rust</option>
|
||||||
|
<option value="Shell">Shell</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span class="share-label">배포 URL <span class="hint">선택 · 없으면 바로가기 숨김</span></span>
|
<span class="share-label">배포 URL <span class="hint">선택 · 없으면 바로가기 숨김</span></span>
|
||||||
<input class="share-input mono" name="app_url" placeholder="https://myapp.apps.bokdev.in" />
|
<input class="share-input mono" name="app_url" placeholder="https://myapp.apps.bokdev.in" />
|
||||||
|
|||||||
Reference in New Issue
Block a user