GitHub 데이터 수집과 API 작업
GitHub 커넥터는 저장소, 이슈, 풀 리퀘스트, 커밋과 관련 엔터티를 관계형 테이블로 제공하여 대량으로 읽는 데 적합합니다. 원천 탐색에서 테이블을 확인하고 배치 동기화로 Foundry 데이터셋에 저장하면 저장소나 조직의 데이터를 분석에 사용할 수 있습니다.
원천 탐색, 배치 동기화, 증분 수집, OAuth 2.0 인증과 테이블 내보내기는 일반 제공(GA)합니다. CData 테이블 내보내기는 기존 테이블을 비우지 않는 추가 모드만 지원하며 키 기준 갱신, 삭제와 upsert는 지원하지 않습니다. GitHub 레코드 생성이나 갱신은 같은 소스를 코드에 가져와 GitHub REST API로 수행합니다. 두 번째 소스는 필요하지 않습니다.
GitHub 애플리케이션 선택
GitHub App은 리소스별 세부 권한과 수명이 짧은 토큰을 지원하므로 권장됩니다. 예를 들어 특정 저장소의 이슈에만 읽기 권한을 줄 수 있습니다. OAuth app은 repo 같은 계정 수준의 넓은 범위를 사용합니다. 두 방식 모두 OAuth 2.0을 사용하지만 권한 부여와 설치 절차는 다릅니다.
GitHub App 등록
- GitHub의 Settings, Developer settings, GitHub Apps에서 New GitHub App을 선택합니다.
- Callback URL에 Foundry 소스 설정 화면의 콜백 URL을 입력합니다.
- Permissions & events에서 수집에 필요한 권한만 선택합니다. 읽기 작업이라면 Contents와 Issues의 읽기 권한처럼 범위를 제한합니다.
- 앱을 만들고 Install App에서 대상 저장소나 조직에 설치합니다.
- General에서 Client ID를 복사하고 Client secret을 생성합니다.
OAuth app을 사용할 때에는 Settings의 Developer settings에서 OAuth app을 선택해 해당 권한 부여 절차를 따릅니다.
Foundry 소스 구성
Data Connection에서 GitHub 소스를 만들고 새 연결에 권장되는 Foundry 워커를 선택합니다. OAuthClientId와 OAuthClientSecret에 GitHub에서 발급한 값을 넣고 InitiateOAuth=REFRESH를 유지합니다. 최초 승인에는 GitHub의 OAuth 웹 애플리케이션 흐름을 사용하고 이후 토큰을 갱신합니다.
| 속성 | 요구 수준 | 의미 | 기본값 |
|---|---|---|---|
InitiateOAuth | 권장 | OAuth 액세스 토큰 발급 및 갱신 절차입니다. | REFRESH |
OAuthClientId | 권장 | 사용자 정의 OAuth 앱의 client ID, 즉 consumer key입니다. 인증 서버에 앱을 식별합니다. | 미지정 |
OAuthClientSecret | 권장 | 앱을 인증할 기밀 값입니다. | 미지정 |
github.com과 api.github.com은 항상 외부 통신이 가능해야 합니다. 각 대상에 외부 통신 정책을 적용하며, 직접 접근할 수 없는 경로는 에이전트 프록시로 구성하고 에이전트 호스트의 접근도 확보합니다.
OAuth 인증 코드 흐름은 Foundry 워커에서만 지원합니다. 소스의 콜백 URL을 앱에 등록하고 저장한 뒤 Authorize로 승인합니다. 승인 시작, 갱신과 철회에는 기본적으로 소스의 Owner 역할이 필요합니다. Viewer나 Editor만으로는 수행할 수 없으며 ConnectOnOpen=true도 이 OAuth 흐름과 함께 사용할 수 없습니다.
코드에서 GitHub 소스 사용
외부 변환 또는 외부 함수의 저장소에 기존 GitHub 소스를 가져옵니다. 코드에서 GitHub App 설치 토큰을 발급해 API를 호출하며 토큰의 수명은 대략 1시간입니다.
이 소스는 get_https_connection().get_client() 형태의 내장 HTTPS 클라이언트를 제공하지 않습니다. 소스에 설정한 api.github.com 외부 통신 권한을 사용하고, Python에서는 requests, TypeScript에서는 소스의 fetch 또는 getFetch로 호출합니다.
비밀 키와 식별자 준비
Data Connection의 Connection details에서 Add property, New encrypted property로 GitHub App의 private key를 추가하고 이름을 Other로 지정합니다. Python은 get_secret("Other")로 읽습니다.
Python의 APP_ID에는 GitHub App ID 또는 client ID를 지정합니다. 공개 식별자이므로 비밀 저장 대상은 아닙니다. TypeScript는 소스의 OAuthClientId에서 값을 읽습니다. 코드의 소스 RID, 소스 별칭, 출력 데이터셋 RID와 저장소 식별자는 실제로 가져온 리소스에 맞게 바꿉니다. TypeScript v1의 GitHubSource도 소스 API 이름으로 생성된 식별자에 맞춥니다.
이 GitHub App 설치 토큰 흐름은 OAuth app에는 적용되지 않습니다. OAuth app으로 같은 코드 작업을 수행하려면 개인 액세스 토큰을 사용하는 별도 인증 구성이 필요합니다.
함수 Live preview에서는 이 소스를 함수에 바인딩할 수 없어 예제가 오류를 냅니다. 함수를 배포한 뒤 Workshop 또는 serverless 실행 경로에서 사용합니다.
이슈 읽기
각 구현은 앱의 private key로 RS256 JWT를 만들고, 저장소에 설치한 앱의 installation ID를 조회한 다음 설치 토큰을 발급합니다. JWT의 iat는 현재보다 60초 전, exp는 현재보다 540초 후이며 iss에 앱 식별자를 넣습니다. 이후 요청의 Authorization 헤더는 발급한 설치 토큰으로 바꿉니다.
읽기 요청은 GET /repos/{owner}/{repository}/issues에 state=all, per_page=100을 전달합니다. 예제는 첫 페이지를 읽으며 전체 저장소의 모든 페이지를 순회하지 않습니다. 반환하는 필드는 number, title, body, state입니다. GitHub의 Issues 응답에는 풀 리퀘스트도 포함될 수 있으므로 업무에서 이슈만 필요하면 pull_request 필드로 구분해야 합니다.
Python 파이프라인은 결과를 pandas 테이블로 출력하고, Python 함수는 GitHubIssue 목록을 반환합니다. TypeScript 함수는 본문이 null이면 선택 속성의 undefined로 변환합니다.
Python 파이프라인
import time
import jwt
import pandas as pd
import requests
from transforms.api import LightweightOutput, Output, transform
from transforms.external.systems import ResolvedSource, Source, external_systems
API = "https://api.github.com"
APP_ID = "<github_app_id_or_client_id>"
OWNER = "<owner>"
REPOSITORY = "<repository>"
def github_headers(github: ResolvedSource) -> dict[str, str]:
now = int(time.time())
app_jwt = jwt.encode(
{"iat": now - 60, "exp": now + 540, "iss": APP_ID},
github.get_secret("Other"),
algorithm="RS256",
)
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Authorization": f"Bearer {app_jwt}",
}
installation_id = requests.get(
f"{API}/repos/{OWNER}/{REPOSITORY}/installation", headers=headers
).json()["id"]
token = requests.post(
f"{API}/app/installations/{installation_id}/access_tokens",
headers=headers,
).json()["token"]
return {**headers, "Authorization": f"Bearer {token}"}
@external_systems(github=Source("<source_rid>"))
@transform.using(output=Output("<issues_output_dataset_rid>"))
def get_issues(github: ResolvedSource, output: LightweightOutput) -> None:
response = requests.get(
f"{API}/repos/{OWNER}/{REPOSITORY}/issues",
headers=github_headers(github),
params={"state": "all", "per_page": 100},
)
response.raise_for_status()
output.write_table(pd.DataFrame([{
"number": issue["number"],
"title": issue["title"],
"body": issue["body"],
"state": issue["state"],
} for issue in response.json()]))
Python 함수
import time
from dataclasses import dataclass
import jwt
import requests
from functions.api import function
from functions.sources import get_source
API = "https://api.github.com"
APP_ID = "<github_app_id_or_client_id>"
SOURCE_ALIAS = "<source_alias>"
@dataclass
class GitHubIssue:
number: int
title: str
body: str | None
state: str
def github_headers(github, owner: str, repository: str) -> dict[str, str]:
now = int(time.time())
app_jwt = jwt.encode(
{"iat": now - 60, "exp": now + 540, "iss": APP_ID},
github.get_secret("Other"),
algorithm="RS256",
)
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Authorization": f"Bearer {app_jwt}",
}
installation_id = requests.get(
f"{API}/repos/{owner}/{repository}/installation", headers=headers
).json()["id"]
token = requests.post(
f"{API}/app/installations/{installation_id}/access_tokens",
headers=headers,
).json()["token"]
return {**headers, "Authorization": f"Bearer {token}"}
@function(sources=["<source_alias>"])
def get_issues(owner: str, repository: str) -> list[GitHubIssue]:
response = requests.get(
f"{API}/repos/{owner}/{repository}/issues",
headers=github_headers(get_source(SOURCE_ALIAS), owner, repository),
params={"state": "all", "per_page": 100},
)
response.raise_for_status()
return [GitHubIssue(issue["number"], issue["title"], issue["body"], issue["state"])
for issue in response.json()]
TypeScript v1 함수
import { ExternalSystems, Function, Integer } from "@foundry/functions-api";
import { GitHubSource } from "@foundry/external-systems/sources";
import * as jwt from "jsonwebtoken";
const API = "https://api.github.com";
export interface GitHubIssue {
number: Integer;
title: string;
body?: string;
state: string;
}
async function githubHeaders(owner: string, repository: string): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000);
const appJwt = jwt.sign(
{ iat: now - 60, exp: now + 540, iss: GitHubSource.getSecret("OAuthClientId") },
GitHubSource.getSecret("Other"),
{ algorithm: "RS256" },
);
const headers = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
Authorization: `Bearer ${appJwt}`,
};
const installation = await GitHubSource.fetch(
`${API}/repos/${owner}/${repository}/installation`, { method: "GET", headers }
);
const { id } = await installation.json() as { id: number };
const tokenResponse = await GitHubSource.fetch(
`${API}/app/installations/${id}/access_tokens`, { method: "POST", headers }
);
const { token } = await tokenResponse.json() as { token: string };
return { ...headers, Authorization: `Bearer ${token}` };
}
export class GitHubFunctions {
@ExternalSystems({ sources: [GitHubSource] })
@Function()
public async getIssues(owner: string, repository: string): Promise<GitHubIssue[]> {
const response = await GitHubSource.fetch(
`${API}/repos/${owner}/${repository}/issues?state=all&per_page=100`,
{ method: "GET", headers: await githubHeaders(owner, repository) },
);
if (!response.ok) throw new Error(`GitHub returned ${response.status}`);
const issues = await response.json() as Array<{
number: number; title: string; body: string | null; state: string;
}>;
return issues.map(({ number, title, body, state }) => ({
number, title, body: body ?? undefined, state,
}));
}
}
TypeScript v2 함수
import { Integer } from "@osdk/functions";
import { getFetch, getSource } from "@palantir/functions-sources";
import jwt from "jsonwebtoken";
const API = "https://api.github.com";
const SOURCE_RID = "<source_rid>";
export const config = { sources: [SOURCE_RID] };
export interface GitHubIssue {
number: Integer;
title: string;
body?: string;
state: string;
}
async function githubClient(owner: string, repository: string) {
const source = await getSource({ rid: SOURCE_RID });
const request = await getFetch(source);
const now = Math.floor(Date.now() / 1000);
const appJwt = jwt.sign(
{ iat: now - 60, exp: now + 540, iss: source.secrets.OAuthClientId! },
source.secrets.Other!,
{ algorithm: "RS256" },
);
const headers = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
Authorization: `Bearer ${appJwt}`,
};
const installation = await request(
`${API}/repos/${owner}/${repository}/installation`, { method: "GET", headers }
);
const { id } = await installation.json() as { id: number };
const tokenResponse = await request(
`${API}/app/installations/${id}/access_tokens`, { method: "POST", headers }
);
const { token } = await tokenResponse.json() as { token: string };
return { request, headers: { ...headers, Authorization: `Bearer ${token}` } };
}
export default async function getIssues(owner: string, repository: string): Promise<GitHubIssue[]> {
const { request, headers } = await githubClient(owner, repository);
const response = await request(
`${API}/repos/${owner}/${repository}/issues?state=all&per_page=100`,
{ method: "GET", headers },
);
if (!response.ok) throw new Error(`GitHub returned ${response.status}`);
const issues = await response.json() as Array<{
number: number; title: string; body: string | null; state: string;
}>;
return issues.map(({ number, title, body, state }) => ({
number, title, body: body ?? undefined, state,
}));
}
이슈 본문 갱신
갱신은 읽기와 같은 설치 토큰 발급 절차를 사용하고 PATCH /repos/{owner}/{repository}/issues/{issue_number}에 {"body": ...}를 보냅니다. 호출하면 선택한 이슈의 전체 본문을 즉시 교체합니다. 추가 문자열을 덧붙이는 동작이 아니므로 소유자, 저장소, 이슈 번호와 교체할 본문을 호출 전에 확인합니다.
Python 파이프라인은 갱신 후 이슈 번호와 요청한 본문을 출력 데이터셋에 기록합니다. Python과 TypeScript 함수는 요청한 본문 문자열을 반환합니다. 이 반환값은 별도의 재조회 결과가 아닙니다. 최종 API 요청의 실패는 Python의 raise_for_status() 또는 TypeScript의 응답 상태 검사에서 오류로 전달됩니다.
Python 파이프라인
import time
import jwt
import pandas as pd
import requests
from transforms.api import LightweightOutput, Output, transform
from transforms.external.systems import ResolvedSource, Source, external_systems
API = "https://api.github.com"
APP_ID = "<github_app_id_or_client_id>"
OWNER = "<owner>"
REPOSITORY = "<repository>"
ISSUE_NUMBER = "<issue_number>"
UPDATED_BODY = "<updated_issue_body>"
def github_headers(github: ResolvedSource) -> dict[str, str]:
now = int(time.time())
app_jwt = jwt.encode(
{"iat": now - 60, "exp": now + 540, "iss": APP_ID},
github.get_secret("Other"),
algorithm="RS256",
)
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Authorization": f"Bearer {app_jwt}",
}
installation_id = requests.get(
f"{API}/repos/{OWNER}/{REPOSITORY}/installation", headers=headers
).json()["id"]
token = requests.post(
f"{API}/app/installations/{installation_id}/access_tokens",
headers=headers,
).json()["token"]
return {**headers, "Authorization": f"Bearer {token}"}
@external_systems(github=Source("<source_rid>"))
@transform.using(output=Output("<update_output_dataset_rid>"))
def patch_issue(github: ResolvedSource, output: LightweightOutput) -> None:
response = requests.patch(
f"{API}/repos/{OWNER}/{REPOSITORY}/issues/{ISSUE_NUMBER}",
headers=github_headers(github),
json={"body": UPDATED_BODY},
)
response.raise_for_status()
output.write_table(pd.DataFrame([{
"issue_number": ISSUE_NUMBER,
"body": UPDATED_BODY,
}]))
Python 함수
import time
import jwt
import requests
from functions.api import function
from functions.sources import get_source
API = "https://api.github.com"
APP_ID = "<github_app_id_or_client_id>"
SOURCE_ALIAS = "<source_alias>"
def github_headers(github, owner: str, repository: str) -> dict[str, str]:
now = int(time.time())
app_jwt = jwt.encode(
{"iat": now - 60, "exp": now + 540, "iss": APP_ID},
github.get_secret("Other"),
algorithm="RS256",
)
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Authorization": f"Bearer {app_jwt}",
}
installation_id = requests.get(
f"{API}/repos/{owner}/{repository}/installation", headers=headers
).json()["id"]
token = requests.post(
f"{API}/app/installations/{installation_id}/access_tokens",
headers=headers,
).json()["token"]
return {**headers, "Authorization": f"Bearer {token}"}
@function(sources=["<source_alias>"])
def patch_issue(
owner: str, repository: str, issue_number: int, body: str
) -> str:
response = requests.patch(
f"{API}/repos/{owner}/{repository}/issues/{issue_number}",
headers=github_headers(get_source(SOURCE_ALIAS), owner, repository),
json={"body": body},
)
response.raise_for_status()
return body
TypeScript v1 함수
import { ExternalSystems, Function, Integer } from "@foundry/functions-api";
import { GitHubSource } from "@foundry/external-systems/sources";
import * as jwt from "jsonwebtoken";
const API = "https://api.github.com";
async function githubHeaders(owner: string, repository: string): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000);
const appJwt = jwt.sign(
{ iat: now - 60, exp: now + 540, iss: GitHubSource.getSecret("OAuthClientId") },
GitHubSource.getSecret("Other"),
{ algorithm: "RS256" },
);
const headers = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
Authorization: `Bearer ${appJwt}`,
};
const installation = await GitHubSource.fetch(
`${API}/repos/${owner}/${repository}/installation`, { method: "GET", headers }
);
const { id } = await installation.json() as { id: number };
const tokenResponse = await GitHubSource.fetch(
`${API}/app/installations/${id}/access_tokens`, { method: "POST", headers }
);
const { token } = await tokenResponse.json() as { token: string };
return { ...headers, Authorization: `Bearer ${token}` };
}
export class GitHubFunctions {
@ExternalSystems({ sources: [GitHubSource] })
@Function()
public async patchIssue(
owner: string, repository: string, issueNumber: Integer, body: string
): Promise<string> {
const response = await GitHubSource.fetch(
`${API}/repos/${owner}/${repository}/issues/${issueNumber}`,
{
method: "PATCH",
headers: { ...await githubHeaders(owner, repository), "Content-Type": "application/json" },
body: JSON.stringify({ body }),
},
);
if (!response.ok) throw new Error(`GitHub returned ${response.status}`);
return body;
}
}
TypeScript v2 함수
import { Integer } from "@osdk/functions";
import { getFetch, getSource } from "@palantir/functions-sources";
import jwt from "jsonwebtoken";
const API = "https://api.github.com";
const SOURCE_RID = "<source_rid>";
export const config = { sources: [SOURCE_RID] };
async function githubClient(owner: string, repository: string) {
const source = await getSource({ rid: SOURCE_RID });
const request = await getFetch(source);
const now = Math.floor(Date.now() / 1000);
const appJwt = jwt.sign(
{ iat: now - 60, exp: now + 540, iss: source.secrets.OAuthClientId! },
source.secrets.Other!,
{ algorithm: "RS256" },
);
const headers = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
Authorization: `Bearer ${appJwt}`,
};
const installation = await request(
`${API}/repos/${owner}/${repository}/installation`, { method: "GET", headers }
);
const { id } = await installation.json() as { id: number };
const tokenResponse = await request(
`${API}/app/installations/${id}/access_tokens`, { method: "POST", headers }
);
const { token } = await tokenResponse.json() as { token: string };
return { request, headers: { ...headers, Authorization: `Bearer ${token}` } };
}
export default async function patchIssue(
owner: string, repository: string, issueNumber: Integer, body: string
): Promise<string> {
const { request, headers } = await githubClient(owner, repository);
const response = await request(
`${API}/repos/${owner}/${repository}/issues/${issueNumber}`,
{
method: "PATCH",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ body }),
},
);
if (!response.ok) throw new Error(`GitHub returned ${response.status}`);
return body;
}