nomadcode/services/core/internal/gitosync/runner.go
toki 2afa534338 feat: gito events client 및 gito sync bridge 구현, config 확장
- gitoevents: Gito 이벤트 클라이언트 및 이벤트 모델 구현
- gitosync: 브리지, 러너, 스캐너 구현으로 브랜치 이벤트 및 작업 항목 동기화
- config: 새 설정 항목 추가 및 테스트
- roadmapsync: plane projection 수정 및 테스트
- agent-task: G07 관련 계획 및 코드 리뷰 로그 추가
- roadmap: PHASE 및 마일스톤 업데이트
- contracts: Flutter core API 후보 노트 업데이트
2026-06-14 15:31:25 +09:00

110 lines
3.1 KiB
Go

package gitosync
import (
"context"
"encoding/json"
"errors"
"log/slog"
"os/exec"
"strings"
"nhooyr.io/websocket"
"github.com/nomadcode/nomadcode-core/internal/gitoevents"
"github.com/nomadcode/nomadcode-core/internal/protosocket"
)
// ExecCommandRunner is the production CommandRunner backed by os/exec. It runs
// git in the given directory and returns stdout with the trailing newline left
// intact for the caller to trim.
type ExecCommandRunner struct{}
func (ExecCommandRunner) Run(ctx context.Context, dir, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", err
}
return string(out), nil
}
// websocketTransport is the production gitoevents.Transport that dials the Gito
// proto-socket endpoint over a websocket and exchanges JSON envelopes. It is
// kept unexported; callers wire it through NewRunner.
type websocketTransport struct {
url string
conn *websocket.Conn
}
func (t *websocketTransport) Connect(ctx context.Context) error {
conn, _, err := websocket.Dial(ctx, t.url, nil)
if err != nil {
return err
}
t.conn = conn
return nil
}
func (t *websocketTransport) Write(ctx context.Context, env protosocket.Envelope) error {
if t.conn == nil {
return errors.New("gitosync: transport not connected")
}
data, err := json.Marshal(env)
if err != nil {
return err
}
return t.conn.Write(ctx, websocket.MessageText, data)
}
func (t *websocketTransport) Read(ctx context.Context) (protosocket.Envelope, error) {
if t.conn == nil {
return protosocket.Envelope{}, errors.New("gitosync: transport not connected")
}
_, data, err := t.conn.Read(ctx)
if err != nil {
return protosocket.Envelope{}, err
}
var env protosocket.Envelope
if err := json.Unmarshal(data, &env); err != nil {
return protosocket.Envelope{}, err
}
return env, nil
}
func (t *websocketTransport) Close() error {
if t.conn == nil {
return nil
}
return t.conn.Close(websocket.StatusNormalClosure, "")
}
// NewRunner builds the gitoevents client wired to forward on-target branch
// events into the bridge. It returns a *gitoevents.Client whose Run blocks
// until the context is cancelled or the stream ends; the caller owns starting
// it in a goroutine and cancelling the context on shutdown. The bridge's Handle
// error is logged (not propagated) so a single failed event does not tear down
// the long-lived subscription.
func NewRunner(url, repoID, branch string, bridge *Bridge, logger *slog.Logger) (*gitoevents.Client, error) {
if strings.TrimSpace(url) == "" {
return nil, errors.New("gitosync: proto-socket url is required")
}
if bridge == nil {
return nil, errors.New("gitosync: bridge is nil")
}
transport := &websocketTransport{url: url}
handler := func(ctx context.Context, ev gitoevents.BranchUpdatedEvent) {
if err := bridge.Handle(ctx, ev); err != nil {
if logger != nil {
logger.Error("gito creation sync handle failed",
"error", err, "repo_id", ev.RepoID, "after", ev.After)
}
}
}
return gitoevents.NewClient(transport, gitoevents.Options{
RepoID: repoID,
Branch: branch,
Handler: handler,
Logger: logger,
})
}