실제 터미널 캡처: GitHub API에서 한 저장소의 open_issues_count가 188이고 검색 API로 센 열린 이슈 172개와 열린 PR 16개를 비교한 화면과 글 제목

GitHub API open_issues_count는 PR까지 셉니다

트렌딩에 오른 도구를 의존성으로 검토하다 보면 별이 많다는 이유만으로 유지보수 상태까지 좋다고 여기기 쉽습니다. 의존성을 고를 때는 누적 관심을 보여 주는 별, 최근 푸시, 열린 풀 리퀘스트의 적체, 실제 머지 시각을 하나씩 떼어 읽어야 합니다. 이 지표들을 운영에서 자동으로 모을 때 요청이 성공했는지와 값이 무엇을 뜻하는지를 함께 검증하려고, Debian 13 서버에서 인증 헤더 없이 GitHub 공개 저장소를 조회하는 재현·해결·점검 스크립트를 돌려 요청 오류와 집계 기준을 기록했습니다.

막히기 쉬운 곳은 세 군데입니다. 요청에 User-Agent가 없으면 GitHub가 호출을 거절합니다. 저장소 객체의 open_issues_count를 열린 풀 리퀘스트 수로 읽으면 다른 열과 맞지 않습니다. 닫힌 풀 리퀘스트를 updated로 정렬하고 첫 항목의 merged_at을 그 페이지의 최신 머지로 쓰면, 갱신 시각과 머지 시각이 다를 때 엉뚱한 시각을 집습니다.

Debian 13에서 그 거절과 KeyError를 재현한 다음, 인증 헤더 없이 저장소 객체와 풀 리퀘스트 목록 두 가지로 건강 상태를 읽었습니다. 이어서 이슈 목록의 Link 헤더, 마지막 페이지의 다음 페이지, 한도 헤더를 어떻게 해석할지 정리했습니다. 대상으로 삼은 공개 저장소는 boykopovar/AnyPS5, tester-army/e2e, mattpocock/skills, thedotmack/claude-mem, heygen-com/hyperframes 다섯 곳입니다.

실행 환경은 아래 표와 같습니다. 프로세서 수 8과 메모리 총량 15Gi는 수집기를 돌린 환경의 기록일 뿐입니다. 이 값으로 필요한 서버 사양을 정할 것이 아니라, 뒤의 실행에서 요청이 어떻게 성공하고 한도를 얼마나 쓰는지를 함께 봐야 운영 판단에 쓸 수 있습니다.

항목값나온 곳
OSDebian GNU/Linux 13 (trixie)os-release의 PRETTY_NAME
커널Linux 6.12.94+ x86_64uname -srm
PythonPython 3.13.5python3 –version
curlcurl 8.14.1curl –version 첫 줄에서 이름과 버전
프로세서 수8nproc
메모리 총량15Gifree -h의 Mem total
토큰 환경 변수 이름github_token_vars noneGITHUB_TOKEN, GH_TOKEN, GITHUB_PAT의 이름만 보고 값은 출력하지 않습니다
실행일2026-10-14세 번의 실행 기록의 date 줄
API 버전 헤더2026-03-10세 스크립트의 api_version 행

User-Agent 누락과 없는 저장소의 KeyError

환경 차이와 요청 실패를 섞지 않으려고 재현 스크립트는 실행 환경부터 기록합니다. 아래 명령은 운영체제와 도구 버전, 프로세서 수와 메모리, 토큰 환경 변수가 있는지를 모읍니다. 토큰은 변수 이름만 검사하니 값이 로그에 남지 않습니다. 이어지는 출력에서 이 환경 정보와, 문서·저장소 주소에 대한 HEAD 결과 200을 같이 볼 수 있습니다.

echo "$ TZ=Asia/Seoul date '+%Y-%m-%d %H:%M:%S %Z'"
TZ=Asia/Seoul date '+%Y-%m-%d %H:%M:%S %Z'

echo "$ uname -srm"
uname -srm

echo "$ . /etc/os-release && printf '%s\n' \"\$PRETTY_NAME\""
. /etc/os-release && printf '%s\n' "$PRETTY_NAME"

echo "$ python3 --version"
python3 --version

echo "$ curl --version | awk 'NR==1{print \$1,\$2}'"
curl --version | awk 'NR==1{print $1,$2}'

echo "$ nproc"
nproc

echo "$ free -h | awk 'NR==1 || NR==2'"
free -h | awk 'NR==1 || NR==2'

echo "$ if command -v nvidia-smi >/dev/null 2>&1; then nvidia-smi -L; else echo 'nvidia-smi: command not found'; fi"
if command -v nvidia-smi >/dev/null 2>&1; then nvidia-smi -L; else echo 'nvidia-smi: command not found'; fi

echo "$ python3 -c 'import os; names=[k for k in os.environ if k in (\"GITHUB_TOKEN\",\"GH_TOKEN\",\"GITHUB_PAT\")]; print(\"github_token_vars\", \",\".join(names) if names else \"none\")'"
python3 -c 'import os; names=[k for k in os.environ if k in ("GITHUB_TOKEN","GH_TOKEN","GITHUB_PAT")]; print("github_token_vars", ",".join(names) if names else "none")'

REST 시작 문서는 모든 API 요청에 유효한 User-Agent가 필요하고, 헤더가 없거나 잘못되면 403 Forbidden으로 거절된다고 적습니다. curl은 기본으로 User-Agent를 보내므로, 거절을 재현하려면 빈 값을 명시해야 합니다. 같은 문서와 문제 해결 문서는 애플리케이션 이름을 User-Agent로 쓰라고 합니다. 이후 호출의 값은 repo-health-guide입니다. Accept는 application/vnd.github+json이고, X-GitHub-Api-Version은 2026-03-10입니다. API 버전 문서와 문제 해결 문서의 예시가 이 날짜입니다.

아래 재현 코드는 빈 User-Agent 요청과 없는 저장소 요청을 따로 보냅니다. 빈 헤더 요청에서는 HTTP 403과 본문의 User-Agent 안내가 함께 나와야 하고, 없는 저장소에서는 HTTP 404와 Not Found를 본 다음 stargazers_count를 읽어 KeyError를 일으킵니다. 요청이 거절되는 실패와 오류 응답을 잘못 해석하는 실패가 서로 다르다는 것을 로그에 남기려는 순서입니다.

UA = "repo-health-guide"
VER = "2026-03-10"
print("api_version=%s user_agent=%s" % (VER, UA))

cmd = [
    "curl", "-sS", "-m", "15", "-A", "",
    "-H", "Accept: application/vnd.github+json",
    "-H", "X-GitHub-Api-Version: " + VER,
    "-w", "\nempty_ua HTTP %{http_code}\n",
    "https://api.github.com/rate_limit",
]
print("$ " + " ".join(cmd))
proc = subprocess.run(cmd, capture_output=True, text=True)
sys.stdout.write(proc.stdout)
if proc.stderr:
    sys.stdout.write(proc.stderr)
if proc.returncode != 0:
    raise SystemExit("curl empty User-Agent failed")
if "empty_ua HTTP 403" not in proc.stdout or "User-Agent" not in proc.stdout:
    raise SystemExit("expected User-Agent rejection")
print("REPRO_EMPTY_UA ok")

url = "https://api.github.com/repos/github/no-such-repo-r41014"
req = urllib.request.Request(url, headers={
    "User-Agent": UA,
    "Accept": "application/vnd.github+json",
    "X-GitHub-Api-Version": VER,
})
print("$ GET", url)
try:
    with urllib.request.urlopen(req, timeout=15) as resp:
        raw = resp.read()
        status = resp.status
except urllib.error.HTTPError as err:
    raw = err.read()
    status = err.code
data = json.loads(raw.decode())
print("missing_repo HTTP", status)
print(json.dumps({
    "message": data.get("message"),
    "status": data.get("status"),
    "documentation_url": data.get("documentation_url"),
}, ensure_ascii=True))
if status != 404 or data.get("message") != "Not Found":
    raise SystemExit("expected Not Found")
try:
    print(data["stargazers_count"])
except KeyError:
    traceback.print_exc(file=sys.stdout)
    print("REPRO_KEYERROR stargazers_count")
else:
    raise SystemExit("expected KeyError")

재현 스크립트의 환경 확인과 오류 재현을 함께 담은 실제 출력입니다. 빈 User-Agent 요청은 HTTP 403으로 거절되고, 없는 저장소 요청은 HTTP 404를 돌려줍니다. 뒤이은 KeyError는 통신이 실패해서가 아니라 오류 응답에 저장소 필드를 요구해서 난 것이니, 수집기에서는 상태 코드 검사를 필드 접근보다 앞에 둬야 합니다.

$ TZ=Asia/Seoul date '+%Y-%m-%d %H:%M:%S %Z'
2026-10-14 15:46:02 KST
$ uname -srm
Linux 6.12.94+ x86_64
$ . /etc/os-release && printf '%s\n' "$PRETTY_NAME"
Debian GNU/Linux 13 (trixie)
$ python3 --version
Python 3.13.5
$ curl --version | awk 'NR==1{print $1,$2}'
curl 8.14.1
$ nproc
8
$ free -h | awk 'NR==1 || NR==2'
               total        used        free      shared  buff/cache   available
Mem:            15Gi        11Gi       489Mi       168Mi       4.3Gi       4.3Gi
$ if command -v nvidia-smi >/dev/null 2>&1; then nvidia-smi -L; else echo 'nvidia-smi: command not found'; fi
nvidia-smi: command not found
$ python3 -c 'import os; names=[k for k in os.environ if k in ("GITHUB_TOKEN","GH_TOKEN","GITHUB_PAT")]; print("github_token_vars", ",".join(names) if names else "none")'
github_token_vars none
$ curl -sI -L -o /dev/null -w '%{http_code} %{url_effective}' --max-time 8 -A repo-health-guide https://docs.github.com/en/rest/using-the-rest-api/getting-started-with-the-rest-api
200 https://docs.github.com/en/rest/using-the-rest-api/getting-started-with-the-rest-api
$ curl -sI -L -o /dev/null -w '%{http_code} %{url_effective}' --max-time 8 -A repo-health-guide https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api
200 https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api
$ curl -sI -L -o /dev/null -w '%{http_code} %{url_effective}' --max-time 8 -A repo-health-guide https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
200 https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
$ curl -sI -L -o /dev/null -w '%{http_code} %{url_effective}' --max-time 8 -A repo-health-guide https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
200 https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
$ curl -sI -L -o /dev/null -w '%{http_code} %{url_effective}' --max-time 8 -A repo-health-guide https://docs.github.com/en/rest/about-the-rest-api/api-versions
200 https://docs.github.com/en/rest/about-the-rest-api/api-versions
$ curl -sI -L -o /dev/null -w '%{http_code} %{url_effective}' --max-time 8 -A repo-health-guide https://docs.github.com/en/rest/repos/repos
200 https://docs.github.com/en/rest/repos/repos
$ curl -sI -L -o /dev/null -w '%{http_code} %{url_effective}' --max-time 8 -A repo-health-guide https://docs.github.com/en/rest/pulls/pulls
200 https://docs.github.com/en/rest/pulls/pulls
$ curl -sI -L -o /dev/null -w '%{http_code} %{url_effective}' --max-time 8 -A repo-health-guide https://docs.github.com/en/rest/issues/issues
200 https://docs.github.com/en/rest/issues/issues
$ curl -sI -L -o /dev/null -w '%{http_code} %{url_effective}' --max-time 8 -A repo-health-guide https://github.com/boykopovar/AnyPS5
200 https://github.com/boykopovar/AnyPS5
$ curl -sI -L -o /dev/null -w '%{http_code} %{url_effective}' --max-time 8 -A repo-health-guide https://github.com/tester-army/e2e
200 https://github.com/tester-army/e2e
$ curl -sI -L -o /dev/null -w '%{http_code} %{url_effective}' --max-time 8 -A repo-health-guide https://github.com/mattpocock/skills
200 https://github.com/mattpocock/skills
$ curl -sI -L -o /dev/null -w '%{http_code} %{url_effective}' --max-time 8 -A repo-health-guide https://github.com/thedotmack/claude-mem
200 https://github.com/thedotmack/claude-mem
$ curl -sI -L -o /dev/null -w '%{http_code} %{url_effective}' --max-time 8 -A repo-health-guide https://github.com/heygen-com/hyperframes
200 https://github.com/heygen-com/hyperframes
$ python3 - <<'PY'
import json, subprocess, sys, traceback, urllib.error, urllib.request

UA = "repo-health-guide"
VER = "2026-03-10"
print("api_version=%s user_agent=%s" % (VER, UA))

cmd = [
    "curl", "-sS", "-m", "15", "-A", "",
    "-H", "Accept: application/vnd.github+json",
    "-H", "X-GitHub-Api-Version: " + VER,
    "-w", "\nempty_ua HTTP %{http_code}\n",
    "https://api.github.com/rate_limit",
]
print("$ " + " ".join(cmd))
proc = subprocess.run(cmd, capture_output=True, text=True)
sys.stdout.write(proc.stdout)
if proc.stderr:
    sys.stdout.write(proc.stderr)
if proc.returncode != 0:
    raise SystemExit("curl empty User-Agent failed")
if "empty_ua HTTP 403" not in proc.stdout or "User-Agent" not in proc.stdout:
    raise SystemExit("expected User-Agent rejection")
print("REPRO_EMPTY_UA ok")

url = "https://api.github.com/repos/github/no-such-repo-r41014"
req = urllib.request.Request(url, headers={
    "User-Agent": UA,
    "Accept": "application/vnd.github+json",
    "X-GitHub-Api-Version": VER,
})
print("$ GET", url)
try:
    with urllib.request.urlopen(req, timeout=15) as resp:
        raw = resp.read()
        status = resp.status
except urllib.error.HTTPError as err:
    raw = err.read()
    status = err.code
data = json.loads(raw.decode())
print("missing_repo HTTP", status)
print(json.dumps({
    "message": data.get("message"),
    "status": data.get("status"),
    "documentation_url": data.get("documentation_url"),
}, ensure_ascii=True))
if status != 404 or data.get("message") != "Not Found":
    raise SystemExit("expected Not Found")
try:
    print(data["stargazers_count"])
except KeyError:
    traceback.print_exc(file=sys.stdout)
    print("REPRO_KEYERROR stargazers_count")
else:
    raise SystemExit("expected KeyError")
PY
api_version=2026-03-10 user_agent=repo-health-guide
$ curl -sS -m 15 -A  -H Accept: application/vnd.github+json -H X-GitHub-Api-Version: 2026-03-10 -w 
empty_ua HTTP %{http_code}
 https://api.github.com/rate_limit

Request forbidden by administrative rules. Please make sure your request has a User-Agent header (https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api#user-agent-required). Check https://developer.github.com for other possible causes.

empty_ua HTTP 403
REPRO_EMPTY_UA ok
$ GET https://api.github.com/repos/github/no-such-repo-r41014
missing_repo HTTP 404
{"message": "Not Found", "status": "404", "documentation_url": "https://docs.github.com/rest/repos/repos#get-a-repository"}
Traceback (most recent call last):
  File "<stdin>", line 49, in <module>
KeyError: 'stargazers_count'
REPRO_KEYERROR stargazers_count
$ TZ=Asia/Seoul date '+%Y-%m-%d %H:%M:%S %Z'
2026-10-14 15:46:25 KST
GitHub API 실행 화면: 없는 저장소의 HTTP 404 뒤 stargazers_count KeyError를 확인합니다
2026-10-14 15:46:02 KST, Debian 13 — 없는 저장소의 HTTP 404 뒤 stargazers_count KeyError를 확인합니다.

캡처에서 빈 User-Agent 호출은 empty_ua HTTP 403으로 끝나고, 거절 본문에 User-Agent 안내가 함께 나옵니다. 재현 스크립트는 상태 코드와 안내 문구가 둘 다 맞을 때 REPRO_EMPTY_UA ok를 찍습니다. 이어지는 없는 저장소 요청은 HTTP 404이고, 그 응답을 저장소 객체처럼 읽은 자리에서 KeyError가 납니다. 실제 데이터를 읽는 호출에는 repo-health-guide를 넣고, 오류 응답이 지표 처리 단계로 넘어가기 전에 상태 코드를 검사합니다.

없는 저장소의 출력은 missing_repo HTTP 404이고, message는 Not Found입니다. 이 JSON에는 stargazers_count가 없으므로 인덱스가 KeyError를 일으킵니다. 트레이스백은 출력에 있고, 스크립트는 그 예외를 잡았을 때 REPRO_KEYERROR stargazers_count를 찍습니다. 원인은 오류 문서를 저장소 객체로 가정한 것입니다. 상태 코드가 200이고 full_name이 요청한 이름과 같을 때만 별과 푸시 시각을 읽습니다.

저장소 객체 한 번이면 되는 질문, 목록까지 봐야 하는 질문

저장소 REST 문서와 풀 리퀘스트 문서는 공개 리소스만 요청하면 해당 엔드포인트를 인증 없이 쓸 수 있다고 합니다. 해결 스크립트는 Authorization 헤더를 넣지 않으며, 출력의 request_has_authorization는 no입니다. 호출 전에 GET /rate_limit으로 core 한도를 보고, 스크립트에 적힌 하한보다 남음이 작으면 목록 호출을 시작하지 않습니다. 한도 문서에서 인증을 붙이지 않은 요청의 primary 한도는 한 시간에 60요청이고 출발지 IP에 묶입니다. 같은 문서는 GET /rate_limit이 primary 한도에는 들어가지 않고 secondary 한도에는 들어갈 수 있다고 합니다. 이번 출력의 core.limit와 각 응답의 x-ratelimit-limit는 60입니다.

별과 푸시만 필요하면 저장소 객체 한 번

푸시가 있었는지와 별만 알면 된다면 저장소 객체 한 번으로 시작합니다. 해결 스크립트가 읽는 필드는 full_name, stargazers_count, open_issues_count, pushed_at, updated_at, archived입니다. 아래 설정은 공통 요청 헤더와 조회 대상을 한데 모아, 재현 때 비웠던 User-Agent가 실제 수집 요청에는 꼭 들어가게 합니다. 대상 이름과 응답의 full_name을 맞춰 본 다음 지표를 읽으면 오류 응답이 정상 데이터로 둔갑하지 않습니다.

UA = "repo-health-guide"
VER = "2026-03-10"
HEADERS = {
    "User-Agent": UA,
    "Accept": "application/vnd.github+json",
    "X-GitHub-Api-Version": VER,
}
REPOS = [
    "boykopovar/AnyPS5",
    "tester-army/e2e",
    "mattpocock/skills",
    "thedotmack/claude-mem",
    "heygen-com/hyperframes",
]
print("api_version=%s user_agent=%s request_has_authorization=no" % (VER, UA))
print("calls_per_repo=3 repos=%s" % len(REPOS))

저장소마다 요청이 한 번이라 시간당 60요청 안에서 더 많은 이름을 훑을 수 있다는 것이 이 방식의 장점입니다. 대신 열린 풀 리퀘스트의 페이지 수와 머지 시각은 이 응답에 없고, open_issues_count를 열린 풀 리퀘스트 수로 쓰면 아래 세 갈래 결과와 어긋납니다.

적체와 머지까지 보려면 열린 목록의 rel=last와 닫힌 30건의 merged_at

열린 풀 리퀘스트는 state=open, per_page=1로 한 페이지만 받고, Link 헤더의 rel=last에서 page 값을 읽습니다. 페이지네이션 문서에 따르면 마지막 페이지를 계산할 수 없을 때는 rel=last가 빠질 수 있습니다. 그래서 link_last가 없으면 개수를 지어내지 않고 link_last_missing만 올립니다. 파서는 다음과 같습니다.

def parse_link(value):
    rels = []
    last = None
    if not value:
        return rels, last
    for part in value.split(","):
        mrel = re.search(r'rel="([^"]+)"', part)
        if not mrel:
            continue
        rel = mrel.group(1)
        rels.append(rel)
        if rel == "last":
            mpage = re.search(r"[?&]page=(\d+)", part)
            if mpage:
                last = int(mpage.group(1))
    return rels, last

닫힌 목록의 sort로 풀 리퀘스트 문서가 허용하는 값은 created, updated, popularity, long-running입니다. merged는 그 목록에 없습니다. 문서는 sort가 created이거나 sort를 생략하면 direction 기본값이 desc이고, 그 밖의 sort에서는 asc라고 합니다. updated를 쓸 때는 direction=desc를 쿼리에 넣습니다. 해결 스크립트는 그 30건 안에서 merged_at이 있는 값의 최댓값을 newest_merged_at으로 적고, 첫 항목의 merged_at은 first_merged_at으로 따로 적습니다. 이 newest_merged_at은 그 페이지 안의 최댓값이지, 저장소의 모든 머지 가운데 마지막은 아닙니다.

    open_url = base + "/pulls?" + urlencode({"state": "open", "per_page": "1"})
    status, hdrs, raw = fetch(open_url)
    if hdrs.get("x-ratelimit-remaining") not in (None, ""):
        remainings.append(int(hdrs.get("x-ratelimit-remaining")))
    if status != 200:
        raise SystemExit("open pulls HTTP %s %s" % (status, repo))
    http_200 += 1
    rels, last = parse_link(hdrs.get("Link"))
    if last is None:
        link_missing += 1
    print("OPEN_PR", repo, "link_rels=%s" % (",".join(rels) if rels else "none"), "link_last=%s" % last)
    time.sleep(0.15)
    closed_url = base + "/pulls?" + urlencode({
        "state": "closed",
        "sort": "updated",
        "direction": "desc",
        "per_page": "30",
    })
    status, hdrs, raw = fetch(closed_url)
    if hdrs.get("x-ratelimit-remaining") not in (None, ""):
        remainings.append(int(hdrs.get("x-ratelimit-remaining")))
    if status != 200:
        raise SystemExit("closed pulls HTTP %s %s" % (status, repo))
    http_200 += 1
    pulls = json.loads(raw.decode())
    if not isinstance(pulls, list):
        raise SystemExit("closed pulls not a list %s" % repo)
    merged = [item.get("merged_at") for item in pulls if item.get("merged_at")]
    first = pulls[0] if pulls else {}
    newest = max(merged) if merged else None
    print(
        "CLOSED_PR", repo,
        "scanned=%s" % len(pulls),
        "merged_in_page=%s" % len(merged),
        "first_merged_at=%s" % first.get("merged_at"),
        "first_updated_at=%s" % first.get("updated_at"),
        "newest_merged_at=%s" % newest,

해결 스크립트 출력을 보면 저장소 다섯 곳에 대한 조회가 HTTP 200으로 15건 성공했습니다. 열린 풀 리퀘스트 수는 436, 78, 16, 58, 188로 찍혀 각 저장소의 open_issues_count와 다릅니다. mattpocock/skills의 first_merged_at은 2026-07-13T13:36:01Z인데 newest_merged_at은 2026-10-09T10:56:50Z이니, 첫 항목만 읽었다면 페이지 안의 최근 머지를 놓쳤을 것입니다.

$ TZ=Asia/Seoul date '+%Y-%m-%d %H:%M:%S %Z'
2026-10-14 15:46:30 KST
$ python3 - <<'PY'
import json, re, sys, time, urllib.error, urllib.request
from urllib.parse import urlencode

UA = "repo-health-guide"
VER = "2026-03-10"
HEADERS = {
    "User-Agent": UA,
    "Accept": "application/vnd.github+json",
    "X-GitHub-Api-Version": VER,
}
REPOS = [
    "boykopovar/AnyPS5",
    "tester-army/e2e",
    "mattpocock/skills",
    "thedotmack/claude-mem",
    "heygen-com/hyperframes",
]
print("api_version=%s user_agent=%s request_has_authorization=no" % (VER, UA))
print("calls_per_repo=3 repos=%s" % len(REPOS))

def fetch(url):
    req = urllib.request.Request(url, headers=HEADERS)
    if req.has_header("Authorization"):
        raise SystemExit("refusing to send Authorization")
    print("$ GET", url)
    try:
        with urllib.request.urlopen(req, timeout=12) as resp:
            raw = resp.read()
            status = resp.status
            hdrs = resp.headers
    except urllib.error.HTTPError as err:
        raw = err.read()
        status = err.code
        hdrs = err.headers
    print(
        "HTTP", status,
        "x-ratelimit-limit=%s" % hdrs.get("x-ratelimit-limit"),
        "x-ratelimit-remaining=%s" % hdrs.get("x-ratelimit-remaining"),
    )
    return status, hdrs, raw

def parse_link(value):
    rels = []
    last = None
    if not value:
        return rels, last
    for part in value.split(","):
        mrel = re.search(r'rel="([^"]+)"', part)
        if not mrel:
            continue
        rel = mrel.group(1)
        rels.append(rel)
        if rel == "last":
            mpage = re.search(r"[?&]page=(\d+)", part)
            if mpage:
                last = int(mpage.group(1))
    return rels, last

status, hdrs, raw = fetch("https://api.github.com/rate_limit")
if status != 200:
    raise SystemExit("rate_limit HTTP %s" % status)
core = json.loads(raw.decode())["resources"]["core"]
print("core.limit=%s core.remaining=%s core.used=%s" % (core["limit"], core["remaining"], core["used"]))
if core["remaining"] < 22:
    raise SystemExit("abort: core.remaining below 22")
print("RATE_LIMIT_OK")

rows = []
http_200 = 0
link_missing = 0
remainings = []
for repo in REPOS:
    base = "https://api.github.com/repos/" + repo
    status, hdrs, raw = fetch(base)
    if hdrs.get("x-ratelimit-remaining") not in (None, ""):
        remainings.append(int(hdrs.get("x-ratelimit-remaining")))
    if status != 200:
        raise SystemExit("repo HTTP %s %s" % (status, repo))
    http_200 += 1
    data = json.loads(raw.decode())
    if data.get("full_name") != repo:
        raise SystemExit("full_name mismatch %s" % repo)
    print(
        "REPO", repo,
        "full_name=%s" % data.get("full_name"),
        "stars=%s" % data.get("stargazers_count"),
        "open_issues_count=%s" % data.get("open_issues_count"),
        "pushed_at=%s" % data.get("pushed_at"),
        "updated_at=%s" % data.get("updated_at"),
        "archived=%s" % data.get("archived"),
    )
    time.sleep(0.15)
    open_url = base + "/pulls?" + urlencode({"state": "open", "per_page": "1"})
    status, hdrs, raw = fetch(open_url)
    if hdrs.get("x-ratelimit-remaining") not in (None, ""):
        remainings.append(int(hdrs.get("x-ratelimit-remaining")))
    if status != 200:
        raise SystemExit("open pulls HTTP %s %s" % (status, repo))
    http_200 += 1
    rels, last = parse_link(hdrs.get("Link"))
    if last is None:
        link_missing += 1
    print("OPEN_PR", repo, "link_rels=%s" % (",".join(rels) if rels else "none"), "link_last=%s" % last)
    time.sleep(0.15)
    closed_url = base + "/pulls?" + urlencode({
        "state": "closed",
        "sort": "updated",
        "direction": "desc",
        "per_page": "30",
    })
    status, hdrs, raw = fetch(closed_url)
    if hdrs.get("x-ratelimit-remaining") not in (None, ""):
        remainings.append(int(hdrs.get("x-ratelimit-remaining")))
    if status != 200:
        raise SystemExit("closed pulls HTTP %s %s" % (status, repo))
    http_200 += 1
    pulls = json.loads(raw.decode())
    if not isinstance(pulls, list):
        raise SystemExit("closed pulls not a list %s" % repo)
    merged = [item.get("merged_at") for item in pulls if item.get("merged_at")]
    first = pulls[0] if pulls else {}
    newest = max(merged) if merged else None
    print(
        "CLOSED_PR", repo,
        "scanned=%s" % len(pulls),
        "merged_in_page=%s" % len(merged),
        "first_merged_at=%s" % first.get("merged_at"),
        "first_updated_at=%s" % first.get("updated_at"),
        "newest_merged_at=%s" % newest,
    )
    rows.append((
        repo,
        data.get("stargazers_count"),
        data.get("open_issues_count"),
        data.get("pushed_at"),
        data.get("updated_at"),
        last,
        newest,
        first.get("merged_at"),
        len(pulls),
        len(merged),
    ))
    time.sleep(0.15)

increased = any(remainings[i] > remainings[i - 1] for i in range(1, len(remainings)))
print("remaining_increased", "yes" if increased else "no")
print("SUMMARY repo stars open_issues_count pushed_at updated_at open_pr_pages newest_merged_at first_merged_at scanned merged_in_page")
for row in rows:
    print("ROW " + " ".join("" if v is None else str(v) for v in row))
print("SUCCESS health_read repos=%s http_200=%s link_last_missing=%s" % (len(REPOS), http_200, link_missing))
PY
api_version=2026-03-10 user_agent=repo-health-guide request_has_authorization=no
calls_per_repo=3 repos=5
$ GET https://api.github.com/rate_limit
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=52
core.limit=60 core.remaining=52 core.used=8
RATE_LIMIT_OK
$ GET https://api.github.com/repos/boykopovar/AnyPS5
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=57
REPO boykopovar/AnyPS5 full_name=boykopovar/AnyPS5 stars=35785 open_issues_count=572 pushed_at=2026-10-14T06:38:44Z updated_at=2026-10-14T06:45:56Z archived=False
$ GET https://api.github.com/repos/boykopovar/AnyPS5/pulls?state=open&per_page=1
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=48
OPEN_PR boykopovar/AnyPS5 link_rels=next,last link_last=436
$ GET https://api.github.com/repos/boykopovar/AnyPS5/pulls?state=closed&sort=updated&direction=desc&per_page=30
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=56
CLOSED_PR boykopovar/AnyPS5 scanned=30 merged_in_page=24 first_merged_at=2026-10-14T06:38:44Z first_updated_at=2026-10-14T06:39:18Z newest_merged_at=2026-10-14T06:38:44Z
$ GET https://api.github.com/repos/tester-army/e2e
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=48
REPO tester-army/e2e full_name=tester-army/e2e stars=10228 open_issues_count=114 pushed_at=2026-10-14T05:07:51Z updated_at=2026-10-14T06:45:54Z archived=False
$ GET https://api.github.com/repos/tester-army/e2e/pulls?state=open&per_page=1
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=43
OPEN_PR tester-army/e2e link_rels=next,last link_last=78
$ GET https://api.github.com/repos/tester-army/e2e/pulls?state=closed&sort=updated&direction=desc&per_page=30
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=49
CLOSED_PR tester-army/e2e scanned=30 merged_in_page=27 first_merged_at=2026-10-13T16:42:07Z first_updated_at=2026-10-13T16:42:07Z newest_merged_at=2026-10-13T16:42:07Z
$ GET https://api.github.com/repos/mattpocock/skills
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=39
REPO mattpocock/skills full_name=mattpocock/skills stars=290092 open_issues_count=172 pushed_at=2026-10-13T08:46:40Z updated_at=2026-10-14T06:45:23Z archived=False
$ GET https://api.github.com/repos/mattpocock/skills/pulls?state=open&per_page=1
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=42
OPEN_PR mattpocock/skills link_rels=next,last link_last=16
$ GET https://api.github.com/repos/mattpocock/skills/pulls?state=closed&sort=updated&direction=desc&per_page=30
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=38
CLOSED_PR mattpocock/skills scanned=30 merged_in_page=27 first_merged_at=2026-07-13T13:36:01Z first_updated_at=2026-10-13T01:20:12Z newest_merged_at=2026-10-09T10:56:50Z
$ GET https://api.github.com/repos/thedotmack/claude-mem
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=48
REPO thedotmack/claude-mem full_name=thedotmack/claude-mem stars=100529 open_issues_count=93 pushed_at=2026-10-13T21:35:02Z updated_at=2026-10-14T06:45:16Z archived=False
$ GET https://api.github.com/repos/thedotmack/claude-mem/pulls?state=open&per_page=1
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=47
OPEN_PR thedotmack/claude-mem link_rels=next,last link_last=58
$ GET https://api.github.com/repos/thedotmack/claude-mem/pulls?state=closed&sort=updated&direction=desc&per_page=30
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=55
CLOSED_PR thedotmack/claude-mem scanned=30 merged_in_page=26 first_merged_at=2026-10-13T21:28:55Z first_updated_at=2026-10-13T21:28:55Z newest_merged_at=2026-10-13T21:28:55Z
$ GET https://api.github.com/repos/heygen-com/hyperframes
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=46
REPO heygen-com/hyperframes full_name=heygen-com/hyperframes stars=63048 open_issues_count=278 pushed_at=2026-10-14T06:46:31Z updated_at=2026-10-14T06:46:43Z archived=False
$ GET https://api.github.com/repos/heygen-com/hyperframes/pulls?state=open&per_page=1
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=52
OPEN_PR heygen-com/hyperframes link_rels=next,last link_last=188
$ GET https://api.github.com/repos/heygen-com/hyperframes/pulls?state=closed&sort=updated&direction=desc&per_page=30
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=49
CLOSED_PR heygen-com/hyperframes scanned=30 merged_in_page=29 first_merged_at=2026-10-14T06:41:02Z first_updated_at=2026-10-14T06:41:04Z newest_merged_at=2026-10-14T06:41:02Z
remaining_increased yes
SUMMARY repo stars open_issues_count pushed_at updated_at open_pr_pages newest_merged_at first_merged_at scanned merged_in_page
ROW boykopovar/AnyPS5 35785 572 2026-10-14T06:38:44Z 2026-10-14T06:45:56Z 436 2026-10-14T06:38:44Z 2026-10-14T06:38:44Z 30 24
ROW tester-army/e2e 10228 114 2026-10-14T05:07:51Z 2026-10-14T06:45:54Z 78 2026-10-13T16:42:07Z 2026-10-13T16:42:07Z 30 27
ROW mattpocock/skills 290092 172 2026-10-13T08:46:40Z 2026-10-14T06:45:23Z 16 2026-10-09T10:56:50Z 2026-07-13T13:36:01Z 30 27
ROW thedotmack/claude-mem 100529 93 2026-10-13T21:35:02Z 2026-10-14T06:45:16Z 58 2026-10-13T21:28:55Z 2026-10-13T21:28:55Z 30 26
ROW heygen-com/hyperframes 63048 278 2026-10-14T06:46:31Z 2026-10-14T06:46:43Z 188 2026-10-14T06:41:02Z 2026-10-14T06:41:02Z 30 29
SUCCESS health_read repos=5 http_200=15 link_last_missing=0
$ TZ=Asia/Seoul date '+%Y-%m-%d %H:%M:%S %Z'
2026-10-14 15:46:58 KST

해결 스크립트가 저장소 객체, 열린 목록, 닫힌 목록을 조회한 터미널입니다. SUCCESS health_read의 repos=5와 http_200=15가 수집이 끝났음을 보여 주고, link_last_missing=0은 이번 열린 목록에서 마지막 페이지 값을 하나도 빠짐없이 얻었다는 뜻입니다. 운영 로그에도 성공 건수와 누락 건수를 함께 남겨야, 일부 지표가 빠진 수집을 정상 완료로 착각하지 않습니다.

GitHub API 실행 화면: 별·푸시·PR 조회를 정리해 repos=5, http_200=15를 보고합니다
2026-10-14 15:46:30 KST, Debian 13 — 별·푸시·PR 조회를 정리해 repos=5, http_200=15를 보고합니다.

세 갈래 호출은 별, pushed_at, 열린 풀 리퀘스트의 마지막 페이지, 닫힌 30건 안의 머지 시각을 한 세트로 돌려주고, per_page=1의 rel=last 덕분에 목록 전체를 내려받지 않고도 페이지 번호를 얻습니다. 그 대가로 저장소마다 요청이 세 번 나가며, 출력의 calls_per_repo가 그 횟수입니다. 이 한 페이지 밖에 있는 머지는 응답에 들어 있지 않다는 한계도 있습니다.

이번 해결 스크립트는 SUCCESS health_read까지 출력했습니다. 저장소 수, HTTP 200 건수, link_last_missing은 그 줄에 있습니다. full_name이 요청 이름과 다르면 스크립트가 SUCCESS 전에 멈추므로, SUCCESS가 있다는 것은 다섯 곳의 full_name이 요청과 같았다는 뜻입니다. REPO 줄의 archived도 함께 봅니다. 이번 다섯 줄은 모두 False였습니다.

별, 시각, 한도의 남은 횟수는 재실행마다 달라지므로 여기에 다시 적지 않습니다. SUMMARY와 REPO, OPEN_PR, CLOSED_PR 줄이 그 값의 자리입니다. 이번 다섯 ROW에서는 open_issues_count와 open_pr_pages가 모두 달랐습니다. open_issues_count를 열린 풀 리퀘스트 페이지 수로 두지 않습니다. 이번 다섯 REPO 줄은 모두 updated_at이 pushed_at보다 늦었습니다. 푸시 시각으로 읽는 열은 pushed_at이고, updated_at은 그 열에 섞지 않습니다.

CLOSED_PR 줄은 first_merged_at과 newest_merged_at을 둘 다 찍습니다. mattpocock/skills는 두 시각이 서로 다릅니다. 같은 값을 가진 저장소도 있으므로, 어느 쪽인지는 CLOSED_PR 줄을 비교합니다. scanned와 merged_in_page도 그 줄에 있습니다. 이번 다섯 곳은 모두 merged_in_page가 scanned보다 작아서, 닫힌 30건 안에 머지 시각이 빈 항목이 있었습니다.

점검 스크립트가 boykopovar/AnyPS5를 다시 읽은 별과 해결 스크립트의 별은 같지 않습니다. 한 표에 두 실행의 별을 섞지 않고, 건강 판단의 별은 해결 스크립트의 REPO 줄만 사용합니다.

이슈 목록, 마지막 페이지, 한도 헤더를 다룰 때

이슈 문서는 이슈 엔드포인트가 이슈와 풀 리퀘스트를 섞어 돌려줄 수 있고, 둘은 pull_request 키로 가린다고 설명합니다. 아래 코드는 boykopovar/AnyPS5의 이슈 목록과 풀 리퀘스트 목록을 같은 per_page=1로 조회한 뒤, 열린 풀 리퀘스트의 마지막 페이지와 그 다음 페이지를 요청해 경계를 확인합니다. 같은 저장소라도 목록 종류에 따라 Link 헤더와 개수 해석이 달라지는지를 보려는 순서입니다.

issues_url = base + "/issues?" + urlencode({"state": "open", "per_page": "1"})
status, hdrs, raw, rem = fetch(issues_url)
if rem not in (None, ""):
    remainings.append(int(rem))
if status != 200:
    raise SystemExit("issues HTTP %s" % status)
irels, ilast = parse_link(hdrs.get("Link"))
items = json.loads(raw.decode())
first = items[0] if isinstance(items, list) and items else {}
print(
    "ISSUES_OPEN",
    "items=%s" % (len(items) if isinstance(items, list) else "na"),
    "link_rels=%s" % (",".join(irels) if irels else "none"),
    "link_last=%s" % ilast,
    "first_has_pull_request_key=%s" % ("pull_request" in first),
)
time.sleep(0.15)

open_url = base + "/pulls?" + urlencode({"state": "open", "per_page": "1"})
status, hdrs, raw, rem = fetch(open_url)
if rem not in (None, ""):
    remainings.append(int(rem))
if status != 200:
    raise SystemExit("pulls HTTP %s" % status)
prels, last = parse_link(hdrs.get("Link"))
pitems = json.loads(raw.decode())
print(
    "PULLS_OPEN",
    "items=%s" % (len(pitems) if isinstance(pitems, list) else "na"),
    "link_rels=%s" % (",".join(prels) if prels else "none"),
    "link_last=%s" % last,
)
print("COMPARE open_issues_count=%s open_pr_pages=%s issues_link_last=%s" % (
    repo.get("open_issues_count"), last, ilast,
))

probe = "no"
if last is None:
    print("SKIP_PAGE_PROBE no_link_last")
else:
    time.sleep(0.15)
    last_url = base + "/pulls?" + urlencode({"state": "open", "per_page": "1", "page": str(last)})
    status, hdrs, raw, rem = fetch(last_url)
    if rem not in (None, ""):
        remainings.append(int(rem))
    if status != 200:
        raise SystemExit("last page HTTP %s" % status)
    last_items = json.loads(raw.decode())
    n_last = len(last_items) if isinstance(last_items, list) else -1
    print("PULLS_LAST_PAGE page=%s items=%s" % (last, n_last))
    time.sleep(0.15)
    after_url = base + "/pulls?" + urlencode({"state": "open", "per_page": "1", "page": str(last + 1)})
    status, hdrs, raw, rem = fetch(after_url)
    if rem not in (None, ""):
        remainings.append(int(rem))
    if status != 200:
        raise SystemExit("after last HTTP %s" % status)
    after_items = json.loads(raw.decode())
    n_after = len(after_items) if isinstance(after_items, list) else -1
    print("PULLS_AFTER_LAST page=%s items=%s" % (last + 1, n_after))

같은 저장소에서 이슈 값과 열린 풀 리퀘스트 페이지를 맞대어 본 점검 스크립트 출력입니다. open_issues_count는 572인데 열린 풀 리퀘스트의 마지막 페이지는 436이고, 이슈 목록에는 rel=last가 없습니다. 마지막 페이지와 그 다음 페이지까지 요청했으니, 응답이 성공한 것과 집계 경계가 맞은 것을 따로 확인할 수 있습니다.

$ TZ=Asia/Seoul date '+%Y-%m-%d %H:%M:%S %Z'
2026-10-14 15:47:03 KST
$ python3 - <<'PY'
import json, re, sys, time, urllib.error, urllib.request
from urllib.parse import urlencode

UA = "repo-health-guide"
VER = "2026-03-10"
HEADERS = {
    "User-Agent": UA,
    "Accept": "application/vnd.github+json",
    "X-GitHub-Api-Version": VER,
}
REPO = "boykopovar/AnyPS5"
print("api_version=%s user_agent=%s request_has_authorization=no sample_repo=%s" % (VER, UA, REPO))

def fetch(url):
    req = urllib.request.Request(url, headers=HEADERS)
    if req.has_header("Authorization"):
        raise SystemExit("refusing to send Authorization")
    print("$ GET", url)
    try:
        with urllib.request.urlopen(req, timeout=12) as resp:
            raw = resp.read()
            status = resp.status
            hdrs = resp.headers
    except urllib.error.HTTPError as err:
        raw = err.read()
        status = err.code
        hdrs = err.headers
    rem = hdrs.get("x-ratelimit-remaining")
    print("HTTP", status, "x-ratelimit-limit=%s" % hdrs.get("x-ratelimit-limit"), "x-ratelimit-remaining=%s" % rem)
    return status, hdrs, raw, rem

def parse_link(value):
    rels = []
    last = None
    if not value:
        return rels, last
    for part in value.split(","):
        mrel = re.search(r'rel="([^"]+)"', part)
        if not mrel:
            continue
        rel = mrel.group(1)
        rels.append(rel)
        if rel == "last":
            mpage = re.search(r"[?&]page=(\d+)", part)
            if mpage:
                last = int(mpage.group(1))
    return rels, last

base = "https://api.github.com/repos/" + REPO
remainings = []

status, hdrs, raw, rem = fetch(base)
if rem not in (None, ""):
    remainings.append(int(rem))
if status != 200:
    raise SystemExit("repo HTTP %s" % status)
repo = json.loads(raw.decode())
print("open_issues_count=%s stars=%s" % (repo.get("open_issues_count"), repo.get("stargazers_count")))
time.sleep(0.15)

issues_url = base + "/issues?" + urlencode({"state": "open", "per_page": "1"})
status, hdrs, raw, rem = fetch(issues_url)
if rem not in (None, ""):
    remainings.append(int(rem))
if status != 200:
    raise SystemExit("issues HTTP %s" % status)
irels, ilast = parse_link(hdrs.get("Link"))
items = json.loads(raw.decode())
first = items[0] if isinstance(items, list) and items else {}
print(
    "ISSUES_OPEN",
    "items=%s" % (len(items) if isinstance(items, list) else "na"),
    "link_rels=%s" % (",".join(irels) if irels else "none"),
    "link_last=%s" % ilast,
    "first_has_pull_request_key=%s" % ("pull_request" in first),
)
time.sleep(0.15)

open_url = base + "/pulls?" + urlencode({"state": "open", "per_page": "1"})
status, hdrs, raw, rem = fetch(open_url)
if rem not in (None, ""):
    remainings.append(int(rem))
if status != 200:
    raise SystemExit("pulls HTTP %s" % status)
prels, last = parse_link(hdrs.get("Link"))
pitems = json.loads(raw.decode())
print(
    "PULLS_OPEN",
    "items=%s" % (len(pitems) if isinstance(pitems, list) else "na"),
    "link_rels=%s" % (",".join(prels) if prels else "none"),
    "link_last=%s" % last,
)
print("COMPARE open_issues_count=%s open_pr_pages=%s issues_link_last=%s" % (
    repo.get("open_issues_count"), last, ilast,
))

probe = "no"
if last is None:
    print("SKIP_PAGE_PROBE no_link_last")
else:
    time.sleep(0.15)
    last_url = base + "/pulls?" + urlencode({"state": "open", "per_page": "1", "page": str(last)})
    status, hdrs, raw, rem = fetch(last_url)
    if rem not in (None, ""):
        remainings.append(int(rem))
    if status != 200:
        raise SystemExit("last page HTTP %s" % status)
    last_items = json.loads(raw.decode())
    n_last = len(last_items) if isinstance(last_items, list) else -1
    print("PULLS_LAST_PAGE page=%s items=%s" % (last, n_last))
    time.sleep(0.15)
    after_url = base + "/pulls?" + urlencode({"state": "open", "per_page": "1", "page": str(last + 1)})
    status, hdrs, raw, rem = fetch(after_url)
    if rem not in (None, ""):
        remainings.append(int(rem))
    if status != 200:
        raise SystemExit("after last HTTP %s" % status)
    after_items = json.loads(raw.decode())
    n_after = len(after_items) if isinstance(after_items, list) else -1
    print("PULLS_AFTER_LAST page=%s items=%s" % (last + 1, n_after))
    if n_last >= 1 and n_after == 0:
        probe = "yes"

increased = any(remainings[i] > remainings[i - 1] for i in range(1, len(remainings)))
print("remaining_increased", "yes" if increased else "no")
print("SUCCESS pulls_page_probe=%s issues_recorded=yes" % probe)
PY
api_version=2026-03-10 user_agent=repo-health-guide request_has_authorization=no sample_repo=boykopovar/AnyPS5
$ GET https://api.github.com/repos/boykopovar/AnyPS5
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=53
open_issues_count=572 stars=35786
$ GET https://api.github.com/repos/boykopovar/AnyPS5/issues?state=open&per_page=1
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=47
ISSUES_OPEN items=1 link_rels=next link_last=None first_has_pull_request_key=True
$ GET https://api.github.com/repos/boykopovar/AnyPS5/pulls?state=open&per_page=1
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=45
PULLS_OPEN items=1 link_rels=next,last link_last=436
COMPARE open_issues_count=572 open_pr_pages=436 issues_link_last=None
$ GET https://api.github.com/repos/boykopovar/AnyPS5/pulls?state=open&per_page=1&page=436
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=47
PULLS_LAST_PAGE page=436 items=1
$ GET https://api.github.com/repos/boykopovar/AnyPS5/pulls?state=open&per_page=1&page=437
HTTP 200 x-ratelimit-limit=60 x-ratelimit-remaining=48
PULLS_AFTER_LAST page=437 items=0
remaining_increased yes
SUCCESS pulls_page_probe=yes issues_recorded=yes
$ TZ=Asia/Seoul date '+%Y-%m-%d %H:%M:%S %Z'
2026-10-14 15:47:10 KST
GitHub API 실행 화면: 이슈 값 572와 PR 마지막 페이지 436·다음 페이지의 빈 결과를 대조합니다
2026-10-14 15:47:03 KST, Debian 13 — 이슈 값 572와 PR 마지막 페이지 436·다음 페이지의 빈 결과를 대조합니다.

위 캡처에서 ISSUES_OPEN의 link_rels는 next뿐이고 link_last는 None이며, 첫 항목의 pull_request 키는 True입니다. 같은 실행의 PULLS_OPEN에는 last가 있으므로, 이슈 목록의 페이지 정보와 풀 리퀘스트의 페이지 정보를 같은 개수로 취급할 수 없습니다. COMPARE에는 open_issues_count=572와 open_pr_pages=436이 나란히 기록되어 차이를 보여 줍니다. 이슈 목록에 rel=last가 없을 때는 값을 임의로 채우지 않고, 열린 풀 리퀘스트 집계는 해당 목록의 페이지 경계로 판단합니다.

이번 PULLS_LAST_PAGE의 items는 1이고, PULLS_AFTER_LAST의 items는 0입니다. SUCCESS 줄의 pulls_page_probe가 yes인 것은, 마지막 페이지에 항목이 있고 그 다음 페이지는 비어 있다는 조건이 맞았을 때입니다. per_page=1에서 그 조건이 맞으면, rel=last의 page 값을 그 저장소의 열린 풀 리퀘스트 수로 읽을 수 있습니다. 페이지 번호 자체는 재실행에서 달라지므로 출력의 page 칸을 봅니다. 이 확인은 표본 한 곳에 한정합니다.

한도 문서는 요청을 여러 리전에서 처리하면 이후 응답의 x-ratelimit-remaining이 이전보다 클 수 있고, 그때의 기준은 응답 헤더이며, GET /rate_limit의 숫자와 어긋나면 헤더를 따르라고 합니다. 정확한 남은 횟수에 동작을 묶지 말라는 뜻입니다. 같은 문서는 primary 한도를 넘기면 x-ratelimit-reset에 적힌 시각 전에는 다시 보내지 말라고 합니다. 해결 스크립트와 점검 스크립트는 호출마다 남은 횟수를 모은 뒤 remaining_increased를 yes 또는 no로 찍습니다. 이번 값이 어느 쪽인지는 그 줄을 봅니다. yes이면 같은 실행 안에서도 뒤 호출의 남은 횟수가 앞 호출보다 컸다는 뜻입니다.

updated 정렬은 머지 시각 정렬이 아닙니다. 첫 줄의 merged_at이 페이지 안의 최댓값보다 오래된 경우가 이번 CLOSED_PR 줄에 있습니다. 머지 후보로 쓸 열은 newest_merged_at이고, 그 값조차 이번 요청의 30건 안으로 한정합니다. 빈 merged_at은 머지 시각이 없는 닫힌 항목이므로 시각 비교에 넣지 않습니다.

GitHub 수집 헤더와 집계 필드는 설정 한곳에서

이번 출력에는 api_version=2026-03-10이 찍혀 있고, 그 헤더를 쓴 정상 조회가 HTTP 200으로 끝났습니다. 수집 설정을 바꿀 때는 User-Agent, Accept, X-GitHub-Api-Version을 공통 헤더 설정에서 함께 관리하고, 실행 로그에도 실제 적용된 값을 남기기를 권합니다. 헬스체크에서는 상태 코드, full_name, 필요한 필드의 형태부터 검증해서, 응답을 받았다는 사실만으로 집계까지 정상이라고 넘기지 않게 합니다. 폐기된 API나 버전 사이의 동작 차이는 변경 안내와 응답을 맞대어 판단해야 하고, 이 실행의 헤더 날짜만으로 호환성을 짐작하면 안 됩니다.

k8s probe가 외부 GitHub API를 매번 부르도록 묶어 두면 수집 한도와 서비스 상태 판단이 얽힙니다. 운영에 적용한다면 수집 작업과 화면 응답을 떼어 놓고, probe는 내부 프로세스 상태만 보게 합니다. API 경로나 헤더를 갱신할 때는 수집 설정과 외부 연동 점검 경로를 같이 검토하고, 내부 점검에는 마지막 수집 성공 여부와 결과가 갱신됐는지를 연결합니다. 이번 점검 스크립트처럼 남은 횟수가 뒤 응답에서 늘어날 수도 있으니, 호출 예산은 단순히 빼 나가는 추정이 아니라 각 응답 헤더를 기준으로 조정해야 합니다.

푸시 확인은 저장소 객체 한 번, 적체와 머지는 필요한 저장소에만

세 스크립트의 출력을 놓고 보면, 저장소 상태를 읽을 때는 호출을 많이 하는 것보다 무엇을 알고 싶은지를 먼저 정하는 쪽이 결과를 좌우했습니다. 아래 표는 기본 정보 조회, 풀 리퀘스트 조회, 이슈 목록 확인이 각각 어떤 판단에 쓰이는지 정리한 것입니다. 운영에서는 푸시 확인부터 시작하고, 기여 후보를 좁힌 다음 적체와 머지를 더 조회하는 구성이 요청 예산을 설명하기에도 깔끔합니다.

구분저장소 객체 한 번세 갈래 호출이슈 목록 per_page=1
실행해결 스크립트의 REPO 줄해결 스크립트의 SUCCESS health_read점검 스크립트의 ISSUES_OPEN
읽는 열별, open_issues_count, pushed_at, updated_at, archived왼쪽 열에 link_last, newest_merged_at, first_merged_at을 더함Link의 rel과 첫 항목의 pull_request 키
이번 실행에서 본 점다섯 곳 모두 updated_at이 pushed_at보다 늦고 archived는 Falseopen_pr_pages는 open_issues_count와 다르고, first_merged_at은 페이지 안 최신이 아닐 수 있음link_last가 None이고 첫 항목에 pull_request 키가 있었음
요청저장소당 한 번저장소당 세 번. 닫힌 목록은 30건표본 한 곳. 개수 계산에는 쓰지 않음

푸시가 있었는지만 볼 경우에는 저장소 객체만 권장합니다. 별과 pushed_at을 보고, updated_at은 마지막 푸시 자리에 두지 않습니다. core.limit가 60인 시간대에는 이 한 호출부터 시작합니다.

기여하기 전에 쌓인 풀 리퀘스트를 볼 경우에는 열린 풀 리퀘스트의 rel=last를 더하는 쪽을 권장합니다. open_issues_count는 그 수와 다른 열입니다. 이슈 목록에 rel=last가 없으면 이슈 쪽으로 개수를 만들지 않습니다. 표본에서 마지막 페이지의 items가 1이고 다음 페이지의 items가 0이었으므로, per_page=1의 page 값을 그 저장소의 열린 수로 읽는 해석은 그 확인이 맞은 저장소에 한정합니다.

머지 시각이 필요할 경우에는 닫힌 30건의 newest_merged_at을 같이 보는 쪽을 권장합니다. 첫 줄의 merged_at은 그 값으로 쓰지 않습니다. 별이 크다는 사실과, 이 30건 안에서 본 최신 머지 시각은 따로 둡니다. 세 갈래를 한 저장소에 모두 쓰면 요청이 세 번이므로, 적체나 머지가 궁금한 이름만 이어서 호출합니다.

운영 수집기라면 저장소 객체를 기본으로 읽고, 검토 대상에만 풀 리퀘스트 조회를 더하겠습니다. 실시간 화면이 필요하면 사용자 요청마다 API를 부르지 말고 수집 결과를 저장해 보여 주면서 마지막 수집 시각을 함께 표시하겠습니다. 이번 실행 환경의 프로세서 8개와 메모리 15Gi를 권장 사양으로 삼기보다, 인증 없는 한도 60과 저장소당 요청 세 번이라는 호출 비용에 맞춰 갱신 주기를 먼저 정하겠습니다. 응답 헤더를 보고 수집을 늦추거나 멈추게 하고, 수집 오류가 화면 서비스의 장애로 곧바로 번지지 않도록 떼어 두는 구성을 권합니다. 이번 출력에서 드러난 것은 별 수를 모으는 일보다 서로 다른 시각과 목록을 섞지 않는 설계가 저장소 검토의 신뢰도를 더 크게 좌우한다는 점이었습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다