# GitHub Generator — 구현 계획

> 착수 결정의 선택지와 그때의 근거 숫자는 [`github-kickoff.html`](../../../docs/features/social-generator/github-kickoff.html) 에 있다.
> 이 문서는 **확정된 답**만 담는다. 규칙은 [`guides/social-generator-rubric.md`](../../social-generator-rubric.md),
> 횡단 결정은 [`decisions.md`](../decisions.md).
> 최종 갱신: 2026-08-11 — 루브릭 전수 점검 완료(§10)

---

## 0. 범위

`github.com` · `gist.github.com` · `*.github.io` 로 들어오는 URL 을 **Account(owner)** 와
**Content(repo)** 로 만든다. 관측 합계 147건.

**Venue 가 없다.** GitHub 에는 X 커뮤니티·서브레딧 같은 "장소" 개념이 없다.

---

## 1. 이 소셜의 성격

**무료다.** REST API 이고 PAT 있으면 5,000 req/h 다. **비용이 결정을 가르지 않는다** —
X 의 커뮤니티(20 credits)나 Reddit($0.022/run · 2026-08-11 정정)과 완전히 다른 축이다.

**모멘텀 채널이 아니라 러그 검증 채널이다.** `ContentMetrics` 주석이 그렇게 적었고
audit 등급도 그 방향이다 — 지표 넷(`followers`·`stars`·`forks`·`openIssues`)이 전부
`noise`/`context` 인데, **시각과 불리언이 actionable** 이다:

| 값 | 무엇을 말하나 |
|---|---|
| `createdAt`(owner) | *"런칭 며칠 전 급조 계정 판별의 **핵심**"* |
| `pushedAt`(repo) | *"생성 후 push 정지 = 방치된 죽은 프로젝트"* |
| `fork` | *"남의 repo 를 포크해 자기 프로젝트로 위장"* |
| `archived` | *"팀이 스스로 프로젝트 종료를 선언"* |
| `twitterUsername` | *"검증된 신호 **sameHandle 사칭** 판별"* |
| `blog` · `homepage` | *"사칭 도메인·드레이너 링크 대조"* |

**조인키가 숫자 id 다.** qna Q3 가 audit 의 *"profile 제거대상: id"* 를 뒤집었다 —
`login` 도 repo name 도 **rename 가능**하고(qna 가 문서의 `immutable` 오표기 2건을 정정),
`GET /repos/{owner}/{repo}` 응답이 `owner.id` 를 추가 쿼리 없이 준다.

---

## 2. 구조

```
Router          정규화 · host 조회 (github.com · gist.github.com) + 꼬리표 .github.io
   ↓
GithubFetcher   classify(ParsedUrl) → SocialRoute<GithubSourceType>   순수·무료
                fetchRepo(owner, repo) → GithubRepo | null
                fetchOwner(login)      → GithubOwner | null
   ↓
GithubGenerator  묶음 항상 1개 — Content(repo) + Account(owner) 를 같은 묶음에
```

**묶음이 항상 1개다.** fork 관계를 사슬로 만들지 않는다 — `parentRelation` 에 `fork` 가
있지만 **원본 repo 를 별도 호출해야** 하고 관측 근거가 없다. `data.fork` 불리언으로만 남긴다.

---

## 3. 결정 로그

| ID | 결정 | 근거 |
|---|---|---|
| **G-1** | repo 경유 계정은 **`fetchOwner` 를 1콜 더 불러 12필드를 채운다** | R-18. `repo.owner` 는 `id`·`login`·`type` **3필드**뿐이고 직접 조회는 **12필드**다. 빠지는 9개에 `createdAt`(러그리스크 핵심)과 `twitterUsername`(사칭 판별)이 들어 있다. **무료이고 5,000 req/h 의 1.5%**(repo 74건)라 비용이 이유가 안 된다. ②(양쪽 3필드)를 고르면 **직접 조회 68건이 손해**를 본다 |
| **G-2** | user/org 는 **`data.accountType`** 으로만 구분. `accounts` 에 `subtype` 을 만들지 않는다 | 자리가 이미 있다. 개인/조직은 **해석 보정**이지 조회축이 아니다(audit 등급 context). `accounts` 에 `subtype` 을 신설하면 **다른 7소셜이 비우는 컬럼**이 생기고, Venue 가 겪은 *"상태냐 정체성이냐"* 문제를 계정에 들인다 |
| **G-3** | `{login}.github.io` 는 **현행 유지 — Account 로 접는다** | audit 확정 설계이고 *"path 무시는 의도적"* 으로 문서화돼 있다. **Pages 는 계정당 하나**라 키가 안정적이고 5건이라 규모도 작다. website 생성기로 넘기면 그때까지 UNSUPPORTED 로 남는데 착수 시점이 미정이다 |
| **G-4** | gist · README **둘 다 이번엔 하지 않는다** | 생성기 범위를 지킨다. gist 는 관측이 분리조차 안 돼 있고(`unknown.json` 그룹 안), **README 는 저장 정책(전문/앞부분 N자/해시)이 스키마 결정**이라 여기서 정하면 `text.body` 의 뜻이 소셜마다 갈린다. 둘 다 design §9 에 `keep` 미수록으로 남긴다 |
| **G-5** | `name` 을 되살려 **`displayName`** 에. **`text.primary` 는 `fullName`** | Y-7 과 같은 논리다 — audit 의 skip 사유가 *"표시 메타"* 인데 **`displayName` 이 바로 그 자리**다. 판정은 옳았고 적용된 자리가 틀렸다. repo 에는 "제목" 이 없어 `fullName`(`owner/repo`)이 사람이 읽는 이름 노릇을 한다 |
| **G-6** | 자리 없는 **8필드를 전부 담는다** | G-7(소셜 원래 필드명)을 따르면 이름 논쟁이 없다. `blog`·`homepage` → `outboundUrls[]`, `topics[]` → `tags[]`, 나머지는 `data`. **`fork`·`archived` 는 audit 이 actionable 로 판정한 러그 tell 둘**이고, `company`·`email` 은 *"러그 팀은 흔히 비공개"* 라 **결측 자체가 신호**다 |
| **G-7** | 지표 3종(`stars`·`forks`·`openIssues`)은 **`ContentMetrics`**. `followers` 는 **`metrics.followers` 로 통합** | 앞의 셋은 **스키마에 이미 선언돼 있었다** — 주석이 이 소셜을 지목한다(*"역할축에 억지로 매핑하지 않는다. 저장만 한다"*). Content 의 `metrics` 는 **`metric_series` 로 시계열이 쌓이는 자리**라 mutable 수치는 여기여야 한다(G-17 정정).<br>`followers` 는 Account 라 시계열이 없어 **순수 쿼리 모양 문제**다. audit 등급이 `noise` 라 `data` 도 후보였으나, **실무상 소셜별로 조회하므로** 공통 컬럼 혼입 우려가 실현되지 않는다고 보고 G-17 의 통합 규칙을 따른다 |
| **G-8** | `avatarUrl` 을 **담는다** | G-16 을 그대로 적용한다. audit 은 skip 이지만 fetcher 가 이미 담고 있고 `AccountInput.avatarUrl` 자리도 있어 **선행 작업이 0건**이다.<br>⚠️ **YouTube Y-5 와 근거가 다르다** — GitHub 아바타는 `avatars.githubusercontent.com/u/{id}` 라 **`id` 로 재구성된다**. Y-5 의 *"안 담으면 영영 잃는다"* 가 여기선 성립하지 않는다. 그래도 담는 이유는 **URL 형식이 바뀌면 못 만들고** 비용이 0 이기 때문이다 |

### 질문 없이 둔 가정

- `RESERVED_ROOTS` 20종 · `hostSuffixes` · `mapError`(403 이 rate limit 인지 차단인지) 는 **손대지 않는다**. 이미 fetcher 에 있다.
- 유료 게이트가 없다 — 무료 소스라 `PAID_SOURCE_TYPES` 에 넣지 않는다.
- fork 를 사슬로 만들지 않는다 — 원본 repo 를 별도 호출해야 하고 관측 근거가 없다.

---

## 4. URL 종류별 산출

| URL 형태 | 관측 | sourceType | sourceKey | 호출 | 묶음 | 산출 | status |
|---|---|---|---|---|---|---|---|
| `github.com/{owner}/{repo}` | 74 | `github_repo` | `owner/repo` 소문자 | **2회** | 1 | Content 1 + Account 1 | `OK` / `NOT_FOUND` |
| `github.com/{login}` | 68 | `github_owner` | login 소문자 | 1회 | 1 | Account 1 | 〃 |
| `{login}.github.io` | 5 | `github_owner` | 호스트에서 `.github.io` 제거 | 1회 | 1 | Account 1 | 〃 |
| `gist.github.com/{login}/{gistId}` | 미분리 | `github_gist` | gistId | 0회 | 0 | 없음 | `UNSUPPORTED` |
| 예약 경로 20종 · 빈 경로 | 미분리 | `unknown` | `null` | 0회 | 0 | 없음 | `UNSUPPORTED` |

**repo 가 2회인 것이 G-1 이다.** `fetchRepo` 로 repo 를 얻고, 그 응답의 `ownerLogin` 으로
`fetchOwner` 를 한 번 더 부른다.

> ⚠️ **`fetchOwner` 실패를 흡수하지 않는다.** R-15 의 보조 호출 예외는
> *"그 호출이 실패해도 남는 값이 있는가"* 로 판정하는데, 여기서 실패하면
> **R-18 이 깨진 계정**(3필드)이 저장된다. 그건 "남는 값" 이 아니라
> **잘못된 값이 영구화되는 것**이라 예외에 해당하지 않는다.

**`NOT_FOUND` 로 가는 경우** — `httpGet` 이 404 를 `null` 로 준다(삭제·비공개 repo).

---

## 5. 필드 매핑

### Account ← `GithubOwner`

```ts
platform          github
platformKey       id                    // 숫자. login 은 rename 가능(qna Q3)
handle            login
displayName       name                  // G-5 — audit skip 을 되살린다
accountCreatedAt  createdAt             // 러그리스크 핵심
bio               bio
outboundUrls      [blog]                // G-6 · normalizeUrl 필수(R-9)
avatarUrl         avatarUrl             // G-8
metrics           { followers }         // G-7 ②
data
  accountType     type                  // User | Organization (G-2)
  contentCount    publicRepos
  declaredHandles [{ platform:'x', value: twitterUsername }]
  company         company               // G-6
  email           email                 // G-6
```

### Content ← `GithubRepo`

```ts
platform          github
subtype           REPO                  // enum 에 이미 있다
platformKey       id                    // 숫자. repo name 도 rename 가능
publishedAt       createdAt
text.primary      fullName              // G-5 — repo 에 "제목" 이 없다
text.body         description
outboundUrls      [homepage]            // G-6 · normalizeUrl 필수
tags              topics                // G-6
mentions          []
creatorId         (Writer 가 채운다)      // R-12 — 같은 묶음의 Account 에서
metrics           { stars, forks, openIssues }   // G-7 — 시계열이 쌓인다
data
  fork            fork                  // G-6 — "포크해 위장"
  archived        archived              // G-6 — "팀이 종료 선언"
  language        language
  ownerLogin      ownerLogin
  pushedAt        pushedAt              // ⚠️ 아래 참조
```

> ⚠️ **`pushedAt` 은 `data` 다.** audit 이 actionable(*"방치된 죽은 프로젝트"*)로 판정했지만
> `ContentInput` 에 그 자리가 없다 — `publishedAt` 은 `createdAt` 이 쓴다.
> 시각이라 `metrics` 도 아니다(수치가 아니고 `metric_series` 포인트가 될 수 없다).

### 버리는 것

| 원본 | 사유 |
|---|---|
| `license` | audit remove — noise |
| `README` | G-4 — 별도 호출 + 저장 정책 미정. design §9 에 `keep` 미수록으로 |
| `ownerType`(repo 경유) | G-1 로 owner 를 직접 부르므로 그쪽 `type` 을 쓴다 |
| owner 의 `name`/`location`/`public_gists`/`following`/`hireable` 등 | audit skip — 표시 메타·중복 |
| repo 의 `watchers`/`subscribers`/`network`/`size`/`has_wiki` 등 | audit skip — 개발 메타 |

---

## 6. 실패 표현

| 상황 | 표현 |
|---|---|
| 404 (삭제·비공개) | `null` → `NOT_FOUND` |
| 403 rate limit / 차단 | **throw** — fetcher `mapError` 가 `RATE_LIMITED`/`TARGET_BLOCKED` 로 가른다 |
| `fetchOwner` 실패(repo 경유) | **throw** — 흡수하지 않는다(§4 참조) |
| `github_gist` · `unknown` | 메서드 도달 안 함 → `UNSUPPORTED` |
| 조인키(`id`) 부재 | fetcher 가 `UPSTREAM_CONTRACT_BROKEN` 으로 던진다 |

**새 `FetchStatus` 가 필요 없다.**

---

## 7. 선행 작업 (생성기 코드보다 먼저)

프로세스 §5 체크포인트 — **한 곳만 고치면 조용히 틀린다.**

- [x] **`GithubOwner.name` 추가** (G-5) — **완료**(정정, 2026-08-11). `github.types.ts` 의
      `name: string | null` 로 존재
- [x] **`AccountDataInput` +2** — **완료.** `company?: string` · `email?: string` 이
      `social-graph.types.ts` 에 존재(G-6)
- [x] **`ContentDataInput` +5** — **완료.** `fork`·`archived`·`language`·`ownerLogin`·
      `pushedAt` 다섯 다 `social-graph.types.ts` 에 존재(G-6)
- [x] **`SocialGeneratorRouter` 등록** — **완료.** `GithubGenerator` 가 `social-recording.module.ts`
      의 배열·`inject` 양쪽에, `GithubFetcher` 가 `social-fetcher.module.ts` 의 `exports` 에 등록됨

**선행 작업이 없는 것** — `ContentMetrics` 3종 · `metrics.followers` · `AccountInput.avatarUrl` ·
`data.accountType`/`contentCount`/`declaredHandles` · `ContentSubtype.REPO` · `tags` 는 자리가 이미 있다.

---

## 8. 알려진 한계

**🟡 `README` 미수록** — audit 이 `keep` 으로 승격했는데(qna Q4: *"조달 가능"*) 담지 않는다.
별도 호출 1회 + 저장 정책 미정이 이유다(G-4). design §9 에 올린다.

**🟡 gist 미지원** — `classify` 는 `github_gist` 로 가르지만 fetch 가 없다.
키는 gistId 기준이어야 한다(username 변경 시 gist URL 은 **리다이렉트 없이 404**).

**🟡 org/user 구분이 API 한계다** — `type` 은 주지만 조직의 멤버·소유 구조는 못 본다.

**🟡 fork 원본을 안 따라간다** — `data.fork` 불리언만 남고 *무엇을* 포크했는지는 모른다.
`parentRelation.fork` 어휘는 있지만 원본 repo 호출이 필요하다.

**🟡 `pushedAt` 이 `data` 에 있다** — actionable 인데 top-level 자리가 없다(§5).
"최근 활동" 으로 정렬하려면 `data.pushedAt` 을 봐야 한다.

---

## 9. 구현 순서

1. §7 선행 작업 4건
2. `github/github.generator.ts` — repo 경유는 **2콜**(repo → owner), owner 경유는 1콜
3. 단위 테스트 — repo(2콜) / owner / Pages / gist·예약경로 UNSUPPORTED / 404 NOT_FOUND
4. **R-18 대조 테스트** — 같은 owner 를 두 경로로 넣고 산출 객체를 **통째로 비교**한다
   (필드를 하나씩 보면 나중에 추가되는 필드에서 다시 갈린다 — 루브릭 R-18 의 확인 방법)
5. 루브릭 R-1 ~ R-18 전수 + 적대적 입력(fork 자기참조 · 빈 topics · `blog` 가 스킴 없는 문자열)
6. 실물 `SocialGraphWriter` 통과 확인

## 10. 루브릭 전수 점검 (2026-08-11)

R-1 ~ R-18 을 코드(`github.generator.ts` · `github.fetcher.ts`)와 대조했다. **위반 1건**을
새로 찾아 닫았고, **avatarUrl 의 R-9 위반은 이미 다른 세션(Reddit 점검, 같은 날)이 찾아
고쳐 둔 상태**였다 — 그 기록도 여기서 함께 남긴다. X·Telegram 과 같은 날 같은 방식으로
점검했다.

### 🔴 발견 1 — `https://.github.io` 가 R-16 을 어겼다 (`classify`)

GitHub Pages 분기가 `host.endsWith('.github.io')` 만 보고 `host.replace(/\.github\.io$/,
'')` 를 그대로 키로 썼다. **서브도메인 없이 `.github.io` 그 자체가 host 인 URL**
(`https://.github.io`)이 들어오면 owner 가 빈 문자열이 되는데 `sourceType` 은
`github_owner` 그대로였다 — X 의 `t.me/+`, Telegram 발견 1과 같은 유형이다.

**자동 검사가 구조적으로 이 모양을 못 만들고 있었다.** `classify-contract.spec.ts` 의
`degenerateUrls()` 는 각 fetcher 의 `hosts` 배열에만 경로를 붙여 URL 을 만드는데,
GitHub Pages 는 `hosts` 가 아니라 **`hostSuffixes`**(호스트가 계정 수만큼 무한해서
꼬리표로 선언)로 등록돼 있다. `hosts` 만 보는 한 이 blind spot 은 **GitHub 만의 문제가
아니라 hostSuffixes 를 쓰는 모든 미래 소셜의 문제**였다.

**고쳤다** — `classify()` 에 빈 owner 가드를 추가했고, `degenerateUrls()` 자체를
`hostSuffixes` 도 훑도록 넓혔다(`https://${suffix}` 형태를 전 소셜에 자동 적용).
고의로 되돌려서 `classify-contract.spec.ts` 가 실제로 빨개지는 것도 확인했다.

### ✅ 이미 닫힌 것 — `avatarUrl` R-9 (2026-08-11, Reddit 점검에서 발견·수정)

같은 날 Reddit Generator 를 점검하던 세션이 *"X·TikTok·Instagram 은 `avatarUrl` 을
정규화하는데 GitHub 만 raw 였다"* 는 것을 찾아 `normalizedOne()` 적용과 회귀 테스트를
같이 커밋했다(`629686c`). 두 점검이 독립적으로 같은 항목을 겨눈 셈이라, 그 자체가
**"이 규칙은 자기 코드에서 못 본다"** 는 루브릭의 전제를 다시 한번 실증한다.

### 통과 확인한 항목

| | 근거 |
|---|---|
| **R-1~R-6 (그래프 형태)** | 묶음이 항상 1개다(사슬·순환·`parentRef` 개념 자체가 없다 — Content 는 인용하지 않는다) |
| **R-7** | `platformKey = String(id)`(계정·콘텐츠 둘 다 숫자 id, rename 에도 불변). 계정을 못 만들면(`fromRepo` 에서 owner 조회 실패) **콘텐츠도 만들지 않는다** — R-8 이 명시한 판단이다 |
| **R-8** | `toAccount`·`toContent` 가 `GithubOwner`(13필드)·`GithubRepo`(16필드 중 저장 대상)를 전부 매핑한다. `GithubRepo.ownerId`·`ownerType` 은 fetcher 응답엔 있지만 Generator 가 안 읽는다 — `ownerLogin` 으로 별도 조회해 항상 최신 12필드를 채우므로(G-1) 무해하게 버려진다 |
| **R-9** | `outboundUrls`(`blog`·`homepage`)·`avatarUrl` 전부 정규화 경유. 발견 1과 별개로 이미 확인 완료 |
| **R-10** | 스프레드 없음 — 계정 8필드·콘텐츠 8필드 전부 명시 매핑 |
| **R-11** | `ContentSubtype.REPO` 하나뿐이라 애초에 판별 여지가 없다 |
| **R-12** | `creatorId`·`venueId`·`parentContentId` 를 어디서도 채우지 않는다 |
| **R-13·R-14** | `OK`+`graph:null` 조합 경로 없음. `fromRepo`/`fromOwner` 는 `ok`/`attempted(NOT_FOUND)` 둘뿐, `NOT_HANDLED` 는 `attempted:false`. `expectGeneratorContract` 가 자동 검증 |
| **R-15** | 두 번째 호출(`fetchOwner`, G-1)을 **의도적으로 보조 호출 취급하지 않는다** — 실패를 흡수하면 R-18 이 깨진 3필드 계정이 굳는다는 것이 코드 주석의 명시적 판단이고, 실제로 try/catch 가 없어 그대로 던진다 |
| **R-16** | 발견 1로 닫힘. gist·예약 루트·빈 경로는 이미 `unknown` 가드가 있었다 |
| **R-17** | `normalizeUrl()` 이 `null` 이면 `generate()` 첫 줄에서 던진다 |
| **R-18** | **구조적으로 위반이 불가능하다** — `repo.owner`(3필드 임베드)를 저장에 아예 안 쓰고, `fromRepo`·`fromOwner` 둘 다 `fetchOwner()` 를 거쳐 나온 같은 `GithubOwner` 를 같은 `toAccount()` 로 옮긴다. "두 경로가 같은 산출을 내야 한다" 가 아니라 **애초에 경로가 하나다**(G-1) |
