diff --git a/backend/src/modules/gitea/gitea.service.spec.ts b/backend/src/modules/gitea/gitea.service.spec.ts index 2da0404..c417a8f 100644 --- a/backend/src/modules/gitea/gitea.service.spec.ts +++ b/backend/src/modules/gitea/gitea.service.spec.ts @@ -48,7 +48,10 @@ describe('GiteaService', () => { it('조직 경로로 POST 하고 응답을 매핑한다(베이스 URL 끝 슬래시 제거)', async () => { const res = mockResponse(201, { id: 42, + name: 'q3-launch', full_name: 'marketing-team/q3-launch', + description: 'q3 — 런칭', + private: true, clone_url: 'https://gitea.example.com/marketing-team/q3-launch.git', html_url: 'https://gitea.example.com/marketing-team/q3-launch', default_branch: 'main', @@ -77,7 +80,10 @@ describe('GiteaService', () => { }); expect(result).toEqual({ id: 42, + name: 'q3-launch', fullName: 'marketing-team/q3-launch', + description: 'q3 — 런칭', + private: true, cloneUrl: 'https://gitea.example.com/marketing-team/q3-launch.git', htmlUrl: 'https://gitea.example.com/marketing-team/q3-launch', defaultBranch: 'main', @@ -103,6 +109,57 @@ describe('GiteaService', () => { }); }); + describe('listOrgRepos', () => { + it('조직 저장소를 페이지네이션 순회하며 매핑한다', async () => { + const page1 = Array.from({ length: 50 }, (_, i) => ({ + id: i + 1, + name: `repo-${i + 1}`, + full_name: `marketing-team/repo-${i + 1}`, + description: '', + private: false, + clone_url: `https://gitea.example.com/marketing-team/repo-${i + 1}.git`, + html_url: `https://gitea.example.com/marketing-team/repo-${i + 1}`, + default_branch: 'main', + })); + const page2 = [ + { + id: 51, + name: 'repo-51', + full_name: 'marketing-team/repo-51', + description: '표시명 — 설명', + private: true, + clone_url: 'https://gitea.example.com/marketing-team/repo-51.git', + html_url: 'https://gitea.example.com/marketing-team/repo-51', + default_branch: 'develop', + }, + ]; + const fetchMock = jest + .spyOn(global, 'fetch') + .mockReturnValueOnce(mockResponse(200, page1)) + .mockReturnValueOnce(mockResponse(200, page2)); + + const svc = new GiteaService(configStub(fullConfig)); + const result = await svc.listOrgRepos(); + + // 첫 페이지가 한도(50)면 다음 페이지까지 요청 + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://gitea.example.com/api/v1/orgs/marketing-team/repos?page=1&limit=50', + ); + expect(result).toHaveLength(51); + expect(result[50]).toEqual({ + id: 51, + name: 'repo-51', + fullName: 'marketing-team/repo-51', + description: '표시명 — 설명', + private: true, + cloneUrl: 'https://gitea.example.com/marketing-team/repo-51.git', + htmlUrl: 'https://gitea.example.com/marketing-team/repo-51', + defaultBranch: 'develop', + }); + }); + }); + describe('deleteRepo', () => { it('repo 경로로 DELETE 한다', async () => { const res = mockResponse(204, ''); diff --git a/backend/src/modules/gitea/gitea.service.ts b/backend/src/modules/gitea/gitea.service.ts index 62944cb..42674a4 100644 --- a/backend/src/modules/gitea/gitea.service.ts +++ b/backend/src/modules/gitea/gitea.service.ts @@ -4,7 +4,10 @@ import { ConfigService } from '@nestjs/config'; // Gitea 저장소 생성/조회 결과(우리가 보관하는 핵심 필드만 추림) export interface GiteaRepoResult { id: number; + name: string; // 저장소 이름(= Relay slugName) fullName: string; // 'org/slug' + description: string; // 설명(없으면 빈 문자열) + private: boolean; // 비공개 여부 cloneUrl: string; // HTTPS clone URL htmlUrl: string; // 웹 UI URL defaultBranch: string; @@ -24,7 +27,10 @@ export class GiteaApiError extends Error { // Gitea REST API(/api/v1) 응답 중 사용하는 필드만 정의 interface GiteaRepoApi { id: number; + name: string; full_name: string; + description: string; + private: boolean; clone_url: string; html_url: string; default_branch: string; @@ -146,6 +152,23 @@ export class GiteaService { ); } + // 조직 아래 모든 저장소 조회(페이지네이션 순회) — Gitea → Relay 동기화용 + async listOrgRepos(): Promise { + const limit = 50; // Gitea 기본 최대 페이지 크기 + const all: GiteaRepoResult[] = []; + for (let page = 1; ; page++) { + const data = await this.request( + 'GET', + `/orgs/${encodeURIComponent(this.org)}/repos?page=${page}&limit=${limit}`, + ); + if (!data || data.length === 0) break; + all.push(...data.map((d) => this.toResult(d))); + // 마지막 페이지(요청 한도 미만)면 종료 + if (data.length < limit) break; + } + return all; + } + // --- 내부 헬퍼 --- // Gitea API 공통 요청 — 토큰 헤더 부착, 실패 시 GiteaApiError @@ -183,7 +206,10 @@ export class GiteaService { private toResult(data: GiteaRepoApi): GiteaRepoResult { return { id: data.id, + name: data.name, fullName: data.full_name, + description: data.description ?? '', + private: data.private, cloneUrl: data.clone_url, htmlUrl: data.html_url, defaultBranch: data.default_branch, diff --git a/backend/src/modules/repo/entities/repo.entity.ts b/backend/src/modules/repo/entities/repo.entity.ts index 2203a6f..b4726cb 100644 --- a/backend/src/modules/repo/entities/repo.entity.ts +++ b/backend/src/modules/repo/entities/repo.entity.ts @@ -61,6 +61,11 @@ export class Repo { @Column({ name: 'html_url', type: 'varchar', nullable: true }) htmlUrl!: string | null; + // Gitea 조직에서 더 이상 발견되지 않는 저장소 표시(동기화로 갱신). + // 연동 origin(giteaRepoId 보유)이 아닌 저장소는 항상 false 유지. + @Column({ name: 'gitea_missing', default: false }) + giteaMissing!: boolean; + // 멤버 목록 @OneToMany(() => RepoMember, (member) => member.repo) members!: Relation[]; diff --git a/backend/src/modules/repo/repo-sync.service.ts b/backend/src/modules/repo/repo-sync.service.ts new file mode 100644 index 0000000..007804e --- /dev/null +++ b/backend/src/modules/repo/repo-sync.service.ts @@ -0,0 +1,180 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { GiteaService } from '../gitea/gitea.service'; +import { Repo } from './entities/repo.entity'; + +// Gitea description 에서 분리한 표시명/설명 +interface ParsedDescription { + name: string; + desc: string | null; +} + +// Gitea 조직 저장소를 Relay 로 가져오는 동기화 서비스. +// - 서버 기동 직후 1회 + 매시간 정각에 조직의 저장소 목록을 받아 Relay 와 맞춘다. +// - 추가형(additive): Gitea 에만 있는 저장소는 Relay 로 import, 양쪽에 있으면 연동정보 backfill. +// - Gitea 에서 사라진(연동 origin) 저장소는 삭제하지 않고 giteaMissing 플래그로 표기만 한다. +// - 멤버십은 Relay 자체 관리(Gitea collaborator 미동기화) — import 저장소는 멤버 0명으로 들어온다. +@Injectable() +export class RepoSyncService implements OnApplicationBootstrap { + private readonly logger = new Logger(RepoSyncService.name); + // 동기화 중복 실행 방지(부트스트랩과 스케줄이 겹치는 경우) + private running = false; + + constructor( + @InjectRepository(Repo) + private readonly repoRepo: Repository, + private readonly gitea: GiteaService, + ) {} + + // 서버 기동 직후 1회 동기화 — 부팅을 막지 않도록 비차단(백그라운드)으로 실행 + onApplicationBootstrap(): void { + void this.syncFromGitea('부트스트랩'); + } + + // 매시간 정각 동기화 + @Cron(CronExpression.EVERY_HOUR) + async handleHourlySync(): Promise { + await this.syncFromGitea('스케줄(매시간)'); + } + + // 조직 저장소 동기화 본체 — 실패해도 예외를 전파하지 않고 로그만 남긴다. + async syncFromGitea(trigger: string): Promise { + if (!this.gitea.enabled) { + this.logger.debug(`Gitea 미연동 — 동기화 건너뜀 (${trigger})`); + return; + } + if (this.running) { + this.logger.warn(`이전 동기화가 진행 중 — 건너뜀 (${trigger})`); + return; + } + this.running = true; + try { + const remote = await this.gitea.listOrgRepos(); + const existing = await this.repoRepo.find(); + + // 매칭용 인덱스: Gitea ID 우선, 없으면 slugName 으로 매칭 + const byGiteaId = new Map(); + const bySlug = new Map(); + for (const r of existing) { + if (r.giteaRepoId !== null) byGiteaId.set(r.giteaRepoId, r); + bySlug.set(r.slugName, r); + } + + const seenGiteaIds = new Set(); + let imported = 0; + let relinked = 0; + + for (const gr of remote) { + seenGiteaIds.add(gr.id); + const match = byGiteaId.get(gr.id) ?? bySlug.get(gr.name); + if (match) { + // 기존 Relay 저장소 — 연동정보 backfill + 누락표시 해제. + // 표시명/설명/공개범위는 Relay 가 SSOT 이므로 덮어쓰지 않는다. + if (this.backfillLinkage(match, gr)) { + await this.repoRepo.save(match); + relinked++; + } + } else { + // Gitea 에만 있는 저장소 — Relay 로 가져오기(멤버 없음) + const { name, desc } = this.parseDescription(gr.description, gr.name); + const repo = this.repoRepo.create({ + slugName: gr.name, + owner: this.gitea.org, + name, + description: desc, + visibility: gr.private ? 'private' : 'public', + branch: gr.defaultBranch || 'main', + giteaRepoId: gr.id, + giteaFullName: gr.fullName, + cloneUrl: gr.cloneUrl, + htmlUrl: gr.htmlUrl, + giteaMissing: false, + }); + await this.repoRepo.save(repo); + imported++; + } + } + + // Gitea 에서 사라진(연동 origin) 저장소는 삭제하지 않고 표기만 한다. + let missing = 0; + for (const r of existing) { + if ( + r.giteaRepoId !== null && + !seenGiteaIds.has(r.giteaRepoId) && + !r.giteaMissing + ) { + r.giteaMissing = true; + await this.repoRepo.save(r); + missing++; + } + } + + this.logger.log( + `Gitea 동기화 완료 (${trigger}) — 원격 ${remote.length}개 / 신규 ${imported} · 갱신 ${relinked} · 누락표시 ${missing}`, + ); + } catch (e) { + this.logger.error( + `Gitea 동기화 실패 (${trigger})`, + e instanceof Error ? e.stack : String(e), + ); + } finally { + this.running = false; + } + } + + // 기존 저장소에 Gitea 연동정보를 채우고 누락표시를 해제 — 변경이 있었으면 true + private backfillLinkage( + repo: Repo, + gr: { + id: number; + fullName: string; + cloneUrl: string; + htmlUrl: string; + }, + ): boolean { + let changed = false; + if (repo.giteaRepoId !== gr.id) { + repo.giteaRepoId = gr.id; + changed = true; + } + if (repo.giteaFullName !== gr.fullName) { + repo.giteaFullName = gr.fullName; + changed = true; + } + if (repo.cloneUrl !== gr.cloneUrl) { + repo.cloneUrl = gr.cloneUrl; + changed = true; + } + if (repo.htmlUrl !== gr.htmlUrl) { + repo.htmlUrl = gr.htmlUrl; + changed = true; + } + if (repo.giteaMissing) { + repo.giteaMissing = false; + changed = true; + } + return changed; + } + + // Gitea description("표시제목 — 설명") 에서 표시명/설명 분리. + // Relay 생성 저장소는 buildGiteaDescription 규칙을 따르고, + // 구분자가 없는 Gitea-native 저장소는 slug 를 표시명으로 사용한다. + private parseDescription( + description: string, + slug: string, + ): ParsedDescription { + const sep = ' — '; + const d = (description ?? '').trim(); + if (!d) return { name: slug, desc: null }; + const idx = d.indexOf(sep); + if (idx > 0) { + return { + name: d.slice(0, idx).trim(), + desc: d.slice(idx + sep.length).trim() || null, + }; + } + return { name: slug, desc: d }; + } +} diff --git a/backend/src/modules/repo/repo.controller.ts b/backend/src/modules/repo/repo.controller.ts index 4e27929..b4f8042 100644 --- a/backend/src/modules/repo/repo.controller.ts +++ b/backend/src/modules/repo/repo.controller.ts @@ -26,10 +26,10 @@ export class RepoController { constructor(private readonly repoService: RepoService) {} @Get() - @ApiOperation({ summary: '내가 멤버인 저장소 목록' }) + @ApiOperation({ summary: '조직 저장소 전체 목록' }) @ApiResponse({ status: 200, description: '목록 조회 성공' }) - findAll(@CurrentUser() user: PublicUser): Promise { - return this.repoService.findAllForUser(user.id); + findAll(): Promise { + return this.repoService.findAll(); } @Post() @@ -61,13 +61,9 @@ export class RepoController { @Get(':repoId') @ApiOperation({ summary: '저장소 단건 조회' }) @ApiResponse({ status: 200, description: '조회 성공' }) - @ApiResponse({ status: 403, description: '접근 권한 없음 (AUTH_003)' }) @ApiResponse({ status: 404, description: '저장소를 찾을 수 없음 (RES_001)' }) - findOne( - @Param('repoId') repoId: string, - @CurrentUser() user: PublicUser, - ): Promise { - return this.repoService.findOneForUser(repoId, user.id); + findOne(@Param('repoId') repoId: string): Promise { + return this.repoService.findOne(repoId); } @Patch(':repoId') diff --git a/backend/src/modules/repo/repo.module.ts b/backend/src/modules/repo/repo.module.ts index 812a2ce..83f09aa 100644 --- a/backend/src/modules/repo/repo.module.ts +++ b/backend/src/modules/repo/repo.module.ts @@ -8,8 +8,9 @@ import { RepoService } from './repo.service'; import { RepoController } from './repo.controller'; import { MemberService } from './member.service'; import { MemberController } from './member.controller'; +import { RepoSyncService } from './repo-sync.service'; -// 저장소 모듈 (저장소 + 멤버 관리) +// 저장소 모듈 (저장소 + 멤버 관리 + Gitea 동기화) @Module({ imports: [ TypeOrmModule.forFeature([Repo, RepoMember]), @@ -17,7 +18,7 @@ import { MemberController } from './member.controller'; GiteaModule, ], controllers: [RepoController, MemberController], - providers: [RepoService, MemberService], + providers: [RepoService, MemberService, RepoSyncService], exports: [RepoService, MemberService], }) export class RepoModule {} diff --git a/backend/src/modules/repo/repo.service.ts b/backend/src/modules/repo/repo.service.ts index 27c7091..0c3d6ce 100644 --- a/backend/src/modules/repo/repo.service.ts +++ b/backend/src/modules/repo/repo.service.ts @@ -6,7 +6,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; import { UserService, type PublicUser } from '../user/user.service'; import { BusinessException } from '../../common/exceptions/business.exception'; import { @@ -40,6 +40,7 @@ export interface RepoResponse { totalCount: number; cloneUrl: string | null; // Gitea HTTPS clone URL (미연동 시 null) htmlUrl: string | null; // Gitea 웹 UI URL (미연동 시 null) + giteaMissing: boolean; // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신) updatedAt: string; } @@ -73,33 +74,19 @@ export class RepoService { }; } - // 내가 멤버인 저장소 목록 - async findAllForUser(userId: string): Promise { - const memberships = await this.memberRepo.find({ - where: { user: { id: userId } }, - relations: ['repo'], - }); - const repoIds = memberships.map((m) => m.repo.id); - if (repoIds.length === 0) return []; - + // 조직 저장소 전체 목록(생성 출처 무관) — Relay 생성분 + Gitea 동기화분. + // 멤버십은 저장소 내 역할/권한에만 쓰이고 목록 노출에는 영향을 주지 않는다(단일 조직 모델). + async findAll(): Promise { const repos = await this.repoRepo.find({ - where: { id: In(repoIds) }, relations: ['members', 'members.user'], order: { updatedAt: 'DESC' }, }); return repos.map((repo) => this.toResponse(repo)); } - // 저장소 단건 — 접근 권한 확인(멤버이거나 공개) - async findOneForUser( - slugName: string, - userId: string, - ): Promise { + // 저장소 단건 — 조직 모델상 인증 사용자면 열람 가능(쓰기 권한은 별도 가드). + async findOne(slugName: string): Promise { const repo = await this.getEntityOrThrow(slugName); - const isMember = repo.members.some((m) => m.user.id === userId); - if (!isMember && repo.visibility !== 'public') { - throw new ForbiddenException(); - } return this.toResponse(repo); } @@ -350,6 +337,7 @@ export class RepoService { totalCount: 0, cloneUrl: repo.cloneUrl, htmlUrl: repo.htmlUrl, + giteaMissing: repo.giteaMissing, updatedAt: repo.updatedAt.toISOString(), }; } diff --git a/docs/api-contract.md b/docs/api-contract.md index bd445f9..ce70393 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -79,7 +79,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프 | 메서드 | 경로 | 요청 | 응답 | |---|---|---|---| -| GET | `/repos` | `?page&size` | `Repo[]`(요약) — 내가 멤버인 저장소 | +| GET | `/repos` | `?page&size` | `Repo[]`(요약) — **조직 저장소 전체**(생성 출처 무관) | | GET | `/repos/meta` | — | `{ owner, template }` — 생성 폼 메타(`:repoId` 보다 먼저 매칭) | | POST | `/repos` | `CreateRepoDto` | `Repo` | | GET | `/repos/:repoId` | — | `Repo`(상세) | @@ -108,10 +108,12 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프 "totalCount": 12, "cloneUrl": "https://gitea.example.com/marketing-team/q3-launch-campaign.git", // 미연동 시 null "htmlUrl": "https://gitea.example.com/marketing-team/q3-launch-campaign", // 미연동 시 null + "giteaMissing": false, // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신) "updatedAt": "2026-06-16T11:00:00Z" } ``` > `moreCount`, `progressPct/Level/Label`, `updatedAgo` 는 프론트 계산. +> `GET /repos` 는 단일 조직 모델상 **조직의 모든 저장소**를 반환한다(멤버십은 역할/권한에만 사용). Gitea-동기화로 import 된 저장소는 멤버 0명으로 노출되며, `giteaMissing:true` 면 프론트에서 '원격 없음' 배지로 표기한다. > 매핑 페이지: RepoListPage, RepoCreatePage, RepoDetailPage(헤더), RepoSettingsPage. #### Gitea 연동 (외부 인스턴스) @@ -132,6 +134,15 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프 - Gitea 이름 충돌(409) → `BIZ_001` "이미 사용 중인 저장소 이름입니다.", 그 외 실패 → `BIZ_001`(502). - 수정/삭제의 Gitea 동기화는 **베스트 에포트**(실패해도 Relay 상태는 반영, 에러 로그만 남김). +##### Gitea → Relay 역방향 동기화 (주기적 import) + +조직에 직접 만든 저장소도 Relay 에 보이도록, **서버 기동 직후 1회 + 매시간 정각**에 조직 저장소 목록(`GET /api/v1/orgs/{org}/repos`)을 받아 Relay 와 맞춘다(`RepoSyncService`). + +- **추가형(additive)**: Gitea ID → slug 순으로 매칭. Relay 에 없으면 import(멤버 0명), 양쪽에 있으면 연동정보(`giteaRepoId`/`cloneUrl`/`htmlUrl`)만 backfill. 표시명/설명/공개범위는 import 이후 **Relay 가 SSOT**(재동기화로 덮어쓰지 않음). +- **import 표시명**: Gitea description 의 `"표시제목 — 설명"` 규칙을 파싱(구분자 없으면 slug 를 표시명, 전체를 설명으로). +- **누락 표기**: Gitea 에서 사라진(연동 origin) 저장소는 **삭제하지 않고** `giteaMissing:true` 로만 표기. 다시 나타나면 false 로 복구. +- Gitea 미연동(`GITEA_*` 미설정) 시 동기화는 건너뛴다. 중복 실행 방지 가드 포함. + --- ## 3. 멤버 (`/api/repos/:repoId/members`) — **3단계** diff --git a/docs/integration-progress.md b/docs/integration-progress.md index 6841a06..54930ca 100644 --- a/docs/integration-progress.md +++ b/docs/integration-progress.md @@ -2,8 +2,8 @@ > 프론트 UI(목업)를 NestJS 백엔드에 단계적으로 연동하는 작업의 진행 기록. > 계약/스키마 상세는 [`api-contract.md`](./api-contract.md) 참조. -> 최종 갱신: 2026-06-17 · 현재 위치: **0~3단계 완료(+ Gitea 연동·템플릿 복제·생성폼 보강), 4단계(업무) 대기** -> 남은 프론트 연동: RepoMembersPage(3단계), cloneUrl/htmlUrl 표시(2.5단계). 그 외 4~8단계 미착수. +> 최종 갱신: 2026-06-17 · 현재 위치: **0~3단계 완료(+ Gitea 양방향 연동·템플릿 복제·생성폼 보강), 4단계(업무) 대기** +> 남은 프론트 연동: RepoMembersPage(3단계), cloneUrl/htmlUrl 표시·giteaMissing 배지(2.5단계). 그 외 4~8단계 미착수. --- @@ -78,6 +78,19 @@ > **TODO(프론트)**: RepoSettingsPage/RepoDetailPage 에 `cloneUrl`/`htmlUrl` 표시(클론 주소 복사, Gitea 열기 버튼). 백엔드 응답에는 이미 포함. +### 2.6단계 — Gitea → Relay 역방향 동기화 ✅ (백엔드) + +**백엔드** `backend/src/modules/gitea/`, `modules/repo/repo-sync.service.ts` +- 문제: 동기화가 단방향(Relay→Gitea)이라 **Gitea 조직에 직접 만든 저장소가 Relay 목록에 안 떴음**. +- `GiteaService.listOrgRepos()`: 조직 저장소 페이지네이션 순회(`GET /orgs/{org}/repos`). 결과에 `name`/`description`/`private` 추가. +- `RepoSyncService`: **서버 기동 직후 1회(`OnApplicationBootstrap`, 비차단) + 매시간(`@Cron(EVERY_HOUR)`)** 동기화. `ScheduleModule.forRoot()` 는 `app.module` 에 이미 등록됨. + - **추가형**: Gitea ID→slug 매칭. 없으면 import(멤버 0명), 있으면 연동정보만 backfill. 표시명/설명/공개범위는 import 후 Relay 가 SSOT. + - **누락 표기**: Gitea 에서 사라진 연동 origin 저장소는 삭제하지 않고 `giteaMissing:true` 로만 표기(재출현 시 복구). `Repo.giteaMissing` 컬럼 추가, `RepoResponse`/프론트 `ApiRepo` 에 노출. + - 미연동 시 건너뜀 + 중복 실행 가드. +- **목록 모델 변경**: `GET /repos` 가 '내 멤버 저장소' → **'조직 저장소 전체'**(`findAll`). 단건(`findOne`)도 조직 모델상 인증 사용자 열람 허용(쓰기 권한은 기존 admin/owner 유지). 멤버십은 역할/권한 전용. + +> **TODO(프론트)**: RepoListPage 에 `giteaMissing` '원격 없음' 배지 표시. import 된 멤버 0명 저장소의 빈 아바타/0% 진행률은 정상. + ### 3단계 — 멤버 관리 ✅ (백엔드) **백엔드** `backend/src/modules/repo/` diff --git a/frontend/src/types/repo.ts b/frontend/src/types/repo.ts index 2f978e2..757b2b6 100644 --- a/frontend/src/types/repo.ts +++ b/frontend/src/types/repo.ts @@ -21,6 +21,9 @@ export interface ApiRepo { memberCount: number doneCount: number totalCount: number + cloneUrl: string | null // Gitea HTTPS clone URL (미연동 시 null) + htmlUrl: string | null // Gitea 웹 UI URL (미연동 시 null) + giteaMissing: boolean // Gitea 조직에서 사라진 저장소 표시(동기화로 갱신) updatedAt: string }