GitHub
GitHub 저장소에서 세션을 돌리고, 승인한 결과를 브랜치로 push하고, 그 브랜치로 PR을 연다
GitHub와 하는 일은 셋이다. 시작 저장소가 있는 세션은 시작할 때, 없는 세션은 에이전트가 attach할 때 저장소를 clone한다. 사람이 승인한 commit을 agent/<session-id> 브랜치로 push한다. 그 브랜치로 PR을 열고 코멘트를 단다. clone은 worker가 API의 프록시를 거쳐 하고, push와 PR은 플랫폼(API)이 직접 한다. 어느 쪽이든 저장소 자격 증명은 worker에 가지 않는다.
운영자가 할 일
자격 증명은 GitHub App이다. owner나 admin이 콘솔의 설정 > 워크스페이스 > 연동 > GitHub에서 다룬다.
- App 추가: GitHub에서 새 App을 만들거나(콘솔이 App ID와 키를 받아 저장한다), 이미 있는 App을 App ID와 private key(PEM 파일)로 연결한다. 작성용 App과 리뷰 승인용 App을 여럿 둘 수 있고, 목록에서 역할 배지로 구분한다. 키는 암호화해 저장하고 화면과 API 응답에 다시 나오지 않는다. 키를 바꿀 때는 App 행의 ⋯ 메뉴에서 새 키 등록을 쓴다.
- 조직 추가: App을 조직이나 개인 계정에 설치한다. 설치 표에 계정마다 유형·저장소 범위·App·연결한 사람·상태가 나오고, 행의 ⋯ 메뉴 GitHub에서 관리가 그 설치의 설정 화면으로 간다.
- 저장소 관리: 에이전트가 고른 저장소를 설치별로 보고, 기본 브랜치와 그 저장소를 맡을 App을 바꾼다. 저장소는 에이전트의 GitHub 탭에서 고르면 생기고, 따로 등록하지 않는다.
- 에이전트가 쓸 수 있는 기능: 워크스페이스 전체의 상한이다. 스위치를 바꾸면 바로 저장된다. 켠 기능에 App 권한이 모자라면 그 App 행에 필요한 권한과 GitHub에서 권한 추가가 나온다. 권한을 더하면 GitHub가 설치한 계정마다 owner에게 승인을 요청하고, 다시 설치하지 않아도 된다.
- 자기 PR 머지: 기본이 꺼짐이다. 켜면 사용자가 머지하라고 지시했을 때 에이전트가 자기 세션이 연 PR을 squash로 머지한다. 필수 check와 리뷰는 GitHub가 판단하고, 머지 요청은 ruleset 우회를 끈 채 보낸다. owner와 admin만 워크스페이스 GitHub 설정 API(
PUT /v1/workspaces/{id}/github-settings의ceiling.merge)로 바꾼다. 켜면 새 릴리스 없이 다음 호출부터 적용된다.
콘솔에서 새 App을 만들려면 API에 CONSOLE_ORIGIN(브라우저가 콘솔을 여는 origin. 예: https://werft.internal.example)이 있어야 한다. GitHub가 App을 만든 뒤 이 주소로 브라우저를 돌려보내기 때문이다. 없으면 GitHub App 만들기를 눌러 나오는 새 App 만들기 화면에서 GitHub에서 만들기 버튼이 꺼지고 "CONSOLE_ORIGIN이 설정되지 않아 GitHub가 콘솔로 돌아올 주소가 없습니다"라는 안내가 보인다. 이때는 운영자에게 값을 설정해 달라고 하거나 이미 있는 App 연결을 쓴다. 단일 서버는 env 파일에 넣고 upgrade로 다시 적용한다(단일 서버). 새 App은 GitHub가 비밀번호 대신 passkey 확인(sudo mode)을 요구하면 그 화면에서 App이 만들어지지 않고 빈 App 폼으로 넘어갈 수 있다. 이때는 확인을 마친 뒤 콘솔로 돌아가 GitHub에서 만들기를 다시 누른다.
| 방식 | 필요한 권한 |
|---|---|
| GitHub App | Repository permissions: Contents Read and write, Pull requests Read and write |
Workflows 권한은 "워크플로 파일 수정" 기능을 켤 때만 App에 더한다. push되는 commit은 에이전트가 쓴 것이라, 이 권한이 있으면 에이전트가 고친 .github/workflows/ 파일도 push되고 CI에서 실행된다. 기본 브랜치는 저장소 ruleset으로도 막아 둔다. 설정 방법은 운영 문서에 있다.
App의 Setup URL
콘솔에서 만든 App은 Setup URL이 <CONSOLE_ORIGIN>/settings/workspace/integrations/github로 채워진다. 이미 있던 App을 연결했다면 GitHub의 App 설정(Developer settings > GitHub Apps > 그 App > General)에서 Setup URL을 이 주소에 맞춘다. 조직을 추가하고 나면 GitHub가 브라우저를 이 주소로 돌려보내고, 콘솔은 돌아온 값으로 설치를 기록한다. Setup URL이 옛 /settings/workspace/github를 가리키는 App은 새 주소로 바꾼다. 그 경로는 없어졌다. 이미 설치한 계정에서 설치 설정을 저장한 뒤 콘솔로 돌아오는 것은 App의 Redirect on update가 켜져 있을 때만이다.
사용자가 할 일
- 그 저장소의
repository_id로 세션을 만든다(시작). - 결과를 요청하고 승인한다. 요청이
pushed가 되면 저장소에agent/<session-id>브랜치가 생긴다. - PR을 연다.
sessions:approve가 필요하다. 아래 예제는 요청과 승인의sessionId와push(HTTP API는SID,PUSH_ID)를 쓴다.
const opened = await werft.sessions.pushRequests.openPullRequest(sessionId, push.id, {
title: "Add release notes",
body: "Written by the agent, approved by me.",
});
console.log(opened.pull_request.number, opened.pull_request.url, opened.created);이 호출에는 Idempotency-Key가 없다. 그래서 SDK는 응답을 받지 못한 경우 다시 보내지 않는다(재시도와 Idempotency-Key). head는 agent/<session-id>, base는 카탈로그에 적은 브랜치다. 요청 하나에 PR은 하나다. 그 브랜치의 PR이 이미 열려 있으면 새로 열지 않고 그 PR을 돌려준다(created: false). 이 호출은 PR을 승인·머지하지 않고, 리뷰어나 라벨도 정하지 않는다.
- 코멘트를 단다. 같은
marker(기본status)의 코멘트가 이미 있으면 새로 달지 않고 그 코멘트를 고친다.
await werft.sessions.pushRequests.commentPullRequest(sessionId, push.id, {
body: "Tests pass on the agent branch.",
});제목·본문·코멘트는 GitHub에 보내기 전에 정리된다. 비밀처럼 보이는 줄은 가리고, @멘션과 Closes #N 같은 닫기 키워드는 알림이나 자동 닫기가 일어나지 않게 바꾼다.
PR 본문 끝에는 플랫폼이 쓰는 꼬리가 붙는다. 세션 id와 commit, 세션 주소, 에이전트와 release, 누가 어떤 경로로 시작했는지, 그 push를 요청한 사람(Requested by, 알림이 가지 않도록 @ 없이 login만 적는다)이 들어간다. 그 브랜치에 다시 push하고 PR을 다시 요청하면 꼬리만 새 commit으로 바뀌고, 그 위에 사람이 쓴 본문은 그대로 남는다.
세션 안의 에이전트는 내장 MCP 도구로 같은 일을 한다. open_pull_request로 열고, update_pull_request로 제목·본문을 고치고, comment_pull_request로 코멘트를 단다. close_pull_request와 reopen_pull_request로 이 세션의 push 요청이 연 PR을 머지하지 않고 닫거나 다시 연다. 사람 승인은 받지 않는다. 다른 세션의 PR이나 허용되지 않은 저장소의 PR은 닫지 못한다. 이미 닫힌 PR을 닫거나 열린 PR을 다시 열면 아무것도 바꾸지 않고 changed: false로 답한다. 머지된 PR은 PULL_REQUEST_MERGED로 거절한다. 닫아도 agent/<session-id> 브랜치는 지우지 않는다. 그래서 다시 열 수 있다.
워크스페이스가 허용하면 merge_pull_request로 그 PR을 squash로 머지한다. 허용하지 않았으면 GitHub를 부르기 전에 GITHUB_REFUSED(merge_off)로 거절한다. 머지는 push 요청이 푸시한 commit에 고정한다. PR의 head가 그 commit이 아니면 head_moved로 거절하고, 필수 check 대기·리뷰 필요·대화 미해결·충돌처럼 GitHub가 지금은 머지하지 않는 PR은 merge_blocked로 거절한다. 이 거절의 details에는 GitHub가 알려 준 상태(mergeable_state, mergeable, draft)와 어떤 조건이 맞지 않았는지 적은 reason 문장이 실린다. reason은 상태를 말할 뿐 어느 필수 check나 리뷰인지는 말하지 않는다. 열 때와 다른 base로 옮겨졌거나 base가 다른 열린 PR의 head인 PR(쌓인 PR)도 merge_blocked다. GitHub의 비동기 머지는 그 PR이 쌓인 아래 PR까지 함께 머지하기 때문이다. 머지 요청을 GitHub가 받았지만 기다리는 동안 끝나지 않으면 forge_failed로 답한다. 머지가 뒤늦게 끝날 수 있으니 PR을 확인하고 다시 부르면 된다. 이미 머지된 같은 commit은 changed: false로 답한다. 이미 머지된 PR은 같은 commit이면 changed: false로 답한다. 관리자 우회, 자동 머지, 다른 머지 방식은 쓰지 않는다. 권한 확인이 없는 모드(bypassPermissions)의 세션은 켜져 있으면 지시 없이도 머지할 수 있다. 머지 요청은 규칙 우회를 끄고(bypass_rules: false) GitHub의 비동기 머지 API로 보내므로, 작성용 App이 저장소·조직 ruleset의 우회 대상이어도 그 권한으로 필수 check와 리뷰를 건너뛰지 못한다. 그래도 머지용 App은 우회 대상에 넣지 않는 편이 안전하다. 에이전트의 Actions 허용 목록에 PR을 머지하는 워크플로를 넣으면 이 스위치가 그 머지를 막지 못한다.
push가 끝난 뒤 PR이 열리는 방식
push 요청에 PR 요청이 붙어 있으면, push가 pushed로 끝난 뒤에 control-host 안의 action-broker가 PR을 연다. 에이전트가 이어 턴에서 open_pull_request를 다시 부르지 않아도 된다. 이 일은 control-host가 뜰 때 같이 시작하고, 다시 띄워도 이미 시작된 PR 단계는 끝까지 처리된다. REST로는 push가 끝나기 전에 POST /v1/sessions/{id}/push-requests/{push_request_id}/pull-request?schedule=true를 부르면 409 PUSH_NOT_PUSHED 대신 202와 status: "scheduled"를 받고, push가 끝나는 대로 PR이 열린다. schedule query가 없거나, 예전 흐름(flow = legacy)으로 만든 push 요청이면 전과 같이 409다.
PR 단계는 push 요청마다 하나이고, 상태가 아래처럼 바뀐다.
| 상태 | 뜻 |
|---|---|
due | push가 끝나 PR을 열 차례다. 열기에 실패하면 간격을 두고 다시 시도한다(최대 3번) |
opening | action-broker 한 곳이 PR을 열고 있다. 그 처리가 멈추면 다른 replica가 이어받는다 |
opened | PR이 열렸고 기록됐다. 번호와 링크는 PR 행에 있다 |
closed_unrecorded | 이 push 요청의 PR이 기록되기 전에 닫히거나 머지됐다. 새 PR을 만들지 않는다 |
uncertain | GitHub에 PR이 있는지 확인하지 못했다(조회가 세 번 실패했거나, 첫 조회가 한도인 10건을 가득 채워 다음 쪽에 더 있을 수 있다). 또는 맞는 PR이 둘 이상이다. 플랫폼이 고르지 않는다 |
failed | PR을 열지 못했다. 사유가 pr_error에 남는다 |
cancelled | push가 실패해서 열 PR이 없다. 사유가 pr_error에 남는다 |
열기 전에 플랫폼은 브랜치를 모든 상태(열림·닫힘·머지됨)로 먼저 찾는다. 응답을 잃은 뒤 다시 시도해도 PR이 하나 더 생기지 않는 이유다. 이미 열린 PR이 있으면 그 PR을 쓰고 꼬리만 새 commit으로 바꾼다. 찾은 PR이나 새로 만든 PR의 head가 push한 commit과 다르면 기록하지 않고 head_moved로 끝낸다. 다만 GitHub가 push 직후 잠깐 이전 head를 보여 주는 경우가 있어, 그 head가 push한 commit의 조상이면 끝내지 않고 다음 시도까지 기다린다(마지막 시도 전까지, 비교 요청 1회). 조상이 아니거나 비교가 실패하면 바로 끝낸다. 만들어진 PR은 닫지 않으니 사람이 확인한다. PR 본문의 표시는 누구나 쓸 수 있으므로, 같은 저장소·head·base인 PR만 후보로 본다.
결과는 세션 스레드에 알림으로 온다(PR 열림, 열지 못함, 이미 닫힘, 확인하지 못함). 알림 문구는 세션을 시작한 사람의 언어를 따르고(요청한 사람이 아니다), 결과마다 한 번만 만들어진다. 알림은 세션에 Slack 스레드가 이어져 있고 음소거·권한 제한이 없을 때만 만들어진다. 그렇지 않으면 상태만 바뀌고 알림 행은 생기지 않는다. 알림이 만들어지는 경우에는 상태를 바꾸는 트랜잭션에 함께 기록되므로 상태만 바뀌고 알림이 빠지는 일은 없다.
알려진 한계가 있다. 프로세스가 호출 직전에 오래 멈췄다가 깨어나 이미 보낸 요청이 늦게 도착하면, 같은 브랜치에 PR이 하나 더 생길 수 있고 플랫폼은 PR을 연 뒤에 이를 다시 찾지 않는다. GitHub에는 조건부 생성이 없어서, 플랫폼은 호출 직전에 다시 확인하고 열기 전에 브랜치를 찾아보는 것까지만 한다. 열 때 맞는 PR이 둘 이상이면 새로 만들지 않고 uncertain으로 두며 알린다. 자동으로 닫지 않으니 GitHub에서 사람이 고른다.
세션 도중 저장소 붙이기
GitHub 설정이 있는 에이전트의 세션은 저장소 없이 시작한다(설정이 없는 옛 에이전트는 release의 저장소로 시작한다). 에이전트는 작업이 읽거나 바꾸는 저장소만, 일하는 도중에 내장 MCP 도구 attach_repository({ repository_id })로 붙인다. 붙일 수 있는 것은 에이전트 release의 GitHub 설정(github.repositories)에 있고 프로필이 허용하는 저장소뿐이다. 사람 승인은 받지 않는다. 그 저장소에 쓰는 일은 지금처럼 push 요청 승인을 거치고, 부착 저장소의 push는 push 승인을 끄고 첨부 저장소 push 정책도 allow로 둔 에이전트만 사람 승인 없이 간다(모든 저장소에 같은 규칙). 붙일 수 있는 저장소는 에이전트가 list_repositories로 본다(붙인 것 attached, 받아 오는 중이거나 실패한 것 pending·failed, 아직 붙이지 않은 것 not_attached. 세션을 만들 때 repository_id로 지정한 저장소는 attached로 나온다). 세션은 시작할 때의 release를 계속 쓴다. 그래서 그 뒤 활성 release에만 더한 저장소는 allowed_in_newer_release로 따로 보이고, attach_repository도 repository_not_allowed 대신 allowed_in_newer_release로 거절한다. 다시 허용할 필요는 없고, 새 세션(Slack에서는 새 스레드)에서 붙일 수 있다. 에이전트 설정으로 도는 세션(붙인 저장소 목록을 가진 세션)은 다르다. 시작한 뒤에 에이전트가 목록에 켠 저장소를 바로 붙일 수 있고, 목록에서 빼거나 GitHub를 끄면 붙였던 저장소(세션을 만들 때 지정한 저장소 포함)가 revoked로 보이며, 플랫폼이 에이전트의 그 저장소 GitHub 작업과 다른 저장소의 새 attach를 거절한다. 세션을 만들 때 지정한 저장소에 대한 attach는 아무것도 하지 않고 이미 붙은 것으로 답하고, 플랫폼이 기록된 PR을 읽는 일은 계속된다. 작업 공간의 사본은 지우지 않고, 다시 켜면 그대로 쓴다.
세션을 만들 때 attached_repository_ids를 보내지 않으면 붙인 저장소 없이 시작한다(repository_id를 보낸 세션은 그 저장소만 있다). 처음부터 필요한 저장소는 그 목록에 넣고, 나머지는 에이전트가 필요할 때 붙인다. push 요청은 저장소 하나를 내보내므로, 저장소가 여럿이면 저장소마다 한 번씩 낸다.
attach가 연결 오류(upstream connection failed 답이나 HTTP 502·503)로 실패하면 일시적인 실패이고 저장소에 대해 알려 주는 것이 없다. 같은 인자로 다시 시도해도 안전하다. repository_not_allowed는 연결 오류가 아니라 에이전트 설정의 거절이니 다시 시도해도 같다.
- 붙인 저장소는 작업 공간에 이름이
repository_id인 git 원격이 되고, 기본 브랜치를<repository_id>/<branch>로 받아 온다. 에이전트는 토큰을 보지 않는다. - 같은 저장소를 다시 붙이면 아무것도 바꾸지 않고 성공한다. 받아 오는 중이면
pending과final: false를 바로 돌려준다.wait_seconds(0~7초, 기본 0)를 주면 받아 오기가 끝나거나 그 시간이 지날 때까지 기다렸다 답하고, 끝났으면final: true다. - 받아 오기에는 시간 상한 300초와 크기 상한 2 GiB가 있다. 넘으면 부착이 실패로 끝나고
failure에 이유(fetch_timeout,fetch_too_large)가 남는다. - 부착마다
attach_repositoryreceipt가 남는다. 요청한 사람·attempt, 저장소, release, 받아 온 commit, 거절 사유(repository_not_allowed·allowed_in_newer_release)가 들어간다. - 세션이 다른 worker에서 다시 시작하면(restore) 붙인 저장소의 원격을 다시 단다. 붙인 저장소의 CLAUDE.md는 지시문으로 읽지 않는다.
내 GitHub 계정 연결
commit 작성자는 늘 GitHub App의 bot 계정(<App slug>[bot])이다. 내 GitHub 계정을 연결해 두면 내가 요청한 턴의 commit에 Co-authored-by: 트레일러로 내 이름이 함께 들어가, GitHub에서 그 commit이 내 기여로도 보인다.
- owner나 admin이 설정 > 워크스페이스 > 연동 > GitHub의 "구성원 GitHub 계정 연결" 줄에서 연결에 쓸 작성용 App을 고르고(App이 여럿일 때), ⋯ 메뉴로 callback URL을 App의 Callback URL에 넣고 그 App에서 만든 client secret을 입력한다. 이 준비가 없으면 연결 버튼이 꺼져 있다.
- 설정 > 연동 계정의 GitHub 줄에서 "연결"을 누르고 GitHub의 동의 화면에서 승인한다. 플랫폼은 GitHub 계정의 id와 login만 읽고 GitHub token은 보관하지 않는다.
- 연결된 줄에 commit에 들어갈 줄(
Co-authored-by: <login> <<id>+<login>@users.noreply.github.com>)이 보인다. 해제하면 그 뒤에 요청하는 턴부터 들어가지 않는다.
웹 콘솔, 개인 API key, Slack으로 요청한 턴은 그 턴을 요청한 사람의 연결을 따른다. 한 세션에서 여러 사람이 이어 요청하면 commit마다 그 턴의 요청자가 들어간다. 에이전트 key나 운영자 key로 요청한 턴은 요청한 사람이 없어 공동 작성자를 넣지 않는다. 어느 경우든 commit에는 Agent-Session: 트레일러가 붙는다. 운영자가 콘솔 주소를 설정한 설치라면 세션 주소이고, 아니면 세션 id다. 세션 상세의 성과물 탭 맨 아래 줄에서 그 세션이 어느 쪽인지 볼 수 있다.
GitHub 계정 하나는 한 사람에게만 연결된다. 다른 사람이 이미 연결한 계정이면 연결이 거절된다.
로컬 스택에서
로컬 sample-app은 스택 안의 Git 서버에 GitHub App으로 등록돼 있어, 승인한 push는 pushed가 되고 PR도 열린다. push되지 않은 요청(예: 거부한 요청)에 PR을 요청하면 409 PUSH_NOT_PUSHED다. 승인에서 rejected로 끝난 요청으로 확인한다.
import { isApiError } from "@werft-ai/sdk";
const requests = await werft.sessions.pushRequests.list(sessionId);
const rejected = requests.find((request) => request.state === "rejected");
if (rejected) {
try {
await werft.sessions.pushRequests.openPullRequest(sessionId, rejected.id, {
title: "Add release notes",
});
} catch (error) {
if (!isApiError(error)) throw error;
console.log(error.status, error.code);
}
}409 PUSH_NOT_PUSHED| 응답 | 뜻 |
|---|---|
409 PUSH_NOT_PUSHED | 요청이 아직 pushed가 아니다 |
409 PULL_REQUEST_MISSING | 코멘트 전에 PR을 먼저 열어야 한다 |
409 CATALOG_MISMATCH | 요청 뒤에 운영자가 저장소 설정을 바꿨다 |
403 FORBIDDEN | sessions:approve가 없거나, 세션을 보기만 하고 승인할 사람이 아니거나, GitHub가 아닌 저장소다(no_forge) |
503 BACKEND_UNAVAILABLE | GitHub 호출이 재시도 뒤에도 실패했다 |