- projectsync: checkout, config 모듈 개선 및 테스트 추가 - workitempipeline: service 개선 및 테스트 추가 - http handlers 테스트 업데이트 - client: proto_socket_branch_event_service.dart 추가 및 테스트 - contracts notes 업데이트 - agent-task: milestone 하위 subtask PLAN/CODE_REVIEW 문서 추가
315 lines
8.2 KiB
Go
315 lines
8.2 KiB
Go
package projectsync
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/nomadcode/nomadcode-core/internal/db"
|
|
"github.com/nomadcode/nomadcode-core/internal/workitem"
|
|
)
|
|
|
|
var ErrInvalidConfig = errors.New("invalid project sync config")
|
|
|
|
const (
|
|
DefaultSlotIndex SlotIndex = 0
|
|
MaxSlotIndex SlotIndex = 999
|
|
)
|
|
|
|
type BranchName string
|
|
|
|
const (
|
|
BranchNameMain BranchName = "main"
|
|
BranchNameDevelop BranchName = "develop"
|
|
)
|
|
|
|
// reservedBranchNames is the closed set of branch workspace names. Only these
|
|
// branches get a fixed directory under branches/; agent slots use slots/NNN.
|
|
var reservedBranchNames = map[BranchName]struct{}{
|
|
BranchNameMain: {},
|
|
BranchNameDevelop: {},
|
|
}
|
|
|
|
func (b BranchName) Valid() bool {
|
|
_, ok := reservedBranchNames[b]
|
|
return ok
|
|
}
|
|
|
|
type BranchSyncState string
|
|
|
|
const (
|
|
BranchSyncStateSynced BranchSyncState = "synced"
|
|
BranchSyncStateStale BranchSyncState = "stale"
|
|
BranchSyncStateError BranchSyncState = "error"
|
|
)
|
|
|
|
func (s BranchSyncState) Valid() bool {
|
|
switch s {
|
|
case BranchSyncStateSynced, BranchSyncStateStale, BranchSyncStateError:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// RequiredBranchWorkspaces returns the fixed branch names every project
|
|
// workspace must provision under branches/.
|
|
func RequiredBranchWorkspaces() []BranchName {
|
|
return []BranchName{BranchNameMain, BranchNameDevelop}
|
|
}
|
|
|
|
// BranchWorkspacePath returns the filesystem path for a named branch workspace
|
|
// under the project workspace root. Only reserved branch names are accepted;
|
|
// agent slots must use SlotWorkspacePath instead.
|
|
func BranchWorkspacePath(workspaceBasePath, repoDirName string, branch BranchName) (string, error) {
|
|
if !branch.Valid() {
|
|
return "", ErrInvalidConfig
|
|
}
|
|
root, err := ProjectWorkspaceRoot(workspaceBasePath, repoDirName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(root, "branches", string(branch)), nil
|
|
}
|
|
|
|
type ProviderProjectTarget struct {
|
|
Provider workitem.ProviderID
|
|
Tenant string
|
|
Project string
|
|
}
|
|
|
|
type Config struct {
|
|
Target ProviderProjectTarget
|
|
GitRemoteURL string
|
|
SourceBranch string
|
|
WorkspaceID string
|
|
WorkspaceBasePath string
|
|
RepoDirName string
|
|
}
|
|
|
|
type SlotIndex int
|
|
|
|
type SlotState string
|
|
|
|
const (
|
|
SlotStateAvailable SlotState = "available"
|
|
SlotStateInUse SlotState = "in_use"
|
|
SlotStateDirty SlotState = "dirty"
|
|
SlotStateError SlotState = "error"
|
|
)
|
|
|
|
func (t ProviderProjectTarget) Normalize() (ProviderProjectTarget, error) {
|
|
target := ProviderProjectTarget{
|
|
Provider: workitem.ProviderID(strings.TrimSpace(string(t.Provider))),
|
|
Tenant: strings.TrimSpace(t.Tenant),
|
|
Project: strings.TrimSpace(t.Project),
|
|
}
|
|
if target.Provider == "" || target.Tenant == "" || target.Project == "" {
|
|
return ProviderProjectTarget{}, ErrInvalidConfig
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
func (c Config) Normalize() (Config, error) {
|
|
target, err := c.Target.Normalize()
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
basePath, err := normalizePath(c.WorkspaceBasePath)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
repoDir, err := normalizeRepoDirName(c.RepoDirName)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
config := Config{
|
|
Target: target,
|
|
GitRemoteURL: strings.TrimSpace(c.GitRemoteURL),
|
|
SourceBranch: strings.TrimSpace(c.SourceBranch),
|
|
WorkspaceID: strings.TrimSpace(c.WorkspaceID),
|
|
WorkspaceBasePath: basePath,
|
|
RepoDirName: repoDir,
|
|
}
|
|
if config.GitRemoteURL == "" || config.SourceBranch == "" || config.WorkspaceID == "" {
|
|
return Config{}, ErrInvalidConfig
|
|
}
|
|
return config, nil
|
|
}
|
|
|
|
func (c Config) ProjectWorkspaceRoot() (string, error) {
|
|
config, err := c.Normalize()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return ProjectWorkspaceRoot(config.WorkspaceBasePath, config.RepoDirName)
|
|
}
|
|
|
|
func (c Config) SlotWorkspacePath(slot SlotIndex) (string, error) {
|
|
config, err := c.Normalize()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return SlotWorkspacePath(config.WorkspaceBasePath, config.RepoDirName, slot)
|
|
}
|
|
|
|
func NewSlotIndex(index int) (SlotIndex, error) {
|
|
slot := SlotIndex(index)
|
|
if !slot.Valid() {
|
|
return 0, ErrInvalidConfig
|
|
}
|
|
return slot, nil
|
|
}
|
|
|
|
func (s SlotIndex) Valid() bool {
|
|
return s >= DefaultSlotIndex && s <= MaxSlotIndex
|
|
}
|
|
|
|
func (s SlotIndex) String() string {
|
|
return fmt.Sprintf("%03d", int(s))
|
|
}
|
|
|
|
func (s SlotIndex) PathName() (string, error) {
|
|
if !s.Valid() {
|
|
return "", ErrInvalidConfig
|
|
}
|
|
return s.String(), nil
|
|
}
|
|
|
|
func (s SlotState) Valid() bool {
|
|
switch s {
|
|
case SlotStateAvailable, SlotStateInUse, SlotStateDirty, SlotStateError:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s SlotState) Allocatable() bool {
|
|
return s == SlotStateAvailable
|
|
}
|
|
|
|
func ProjectWorkspaceRoot(workspaceBasePath, repoDirName string) (string, error) {
|
|
basePath, err := normalizePath(workspaceBasePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
repoDir, err := normalizeRepoDirName(repoDirName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(basePath, repoDir), nil
|
|
}
|
|
|
|
func SlotWorkspacePath(workspaceBasePath, repoDirName string, slot SlotIndex) (string, error) {
|
|
slotName, err := slot.PathName()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
root, err := ProjectWorkspaceRoot(workspaceBasePath, repoDirName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(root, "slots", slotName), nil
|
|
}
|
|
|
|
func normalizePath(value string) (string, error) {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return "", ErrInvalidConfig
|
|
}
|
|
cleaned := filepath.Clean(trimmed)
|
|
if cleaned == "." {
|
|
return "", ErrInvalidConfig
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
func normalizeRepoDirName(value string) (string, error) {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return "", ErrInvalidConfig
|
|
}
|
|
cleaned := filepath.Clean(trimmed)
|
|
if cleaned == "." || cleaned == ".." || filepath.IsAbs(cleaned) || strings.ContainsAny(cleaned, `/\`) {
|
|
return "", ErrInvalidConfig
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
func (c Config) ToCreateProjectSyncParams() db.CreateProjectSyncSettingParams {
|
|
return db.CreateProjectSyncSettingParams{
|
|
Provider: string(c.Target.Provider),
|
|
Tenant: c.Target.Tenant,
|
|
Project: c.Target.Project,
|
|
GitRemoteUrl: c.GitRemoteURL,
|
|
SourceBranch: c.SourceBranch,
|
|
WorkspaceID: c.WorkspaceID,
|
|
WorkspaceBasePath: c.WorkspaceBasePath,
|
|
RepoDirName: c.RepoDirName,
|
|
}
|
|
}
|
|
|
|
// WorkspaceSlot is the typed view of a persisted workspace slot row, keeping
|
|
// the slot index and state as projectsync domain types.
|
|
type WorkspaceSlot struct {
|
|
ID int64
|
|
ProjectSyncSettingID int64
|
|
Index SlotIndex
|
|
State SlotState
|
|
Path string
|
|
}
|
|
|
|
// SlotFromDBRecord converts a generated db row into the typed WorkspaceSlot.
|
|
func SlotFromDBRecord(r db.WorkspaceSlot) WorkspaceSlot {
|
|
return WorkspaceSlot{
|
|
ID: r.ID,
|
|
ProjectSyncSettingID: r.ProjectSyncSettingID,
|
|
Index: SlotIndex(r.SlotIndex),
|
|
State: SlotState(r.State),
|
|
Path: r.Path,
|
|
}
|
|
}
|
|
|
|
// ToUpsertWorkspaceSlotParams builds DB upsert params for a slot, rejecting an
|
|
// out-of-range index before it reaches the database.
|
|
func ToUpsertWorkspaceSlotParams(projectSyncSettingID int64, index SlotIndex, path string) (db.UpsertWorkspaceSlotParams, error) {
|
|
if !index.Valid() {
|
|
return db.UpsertWorkspaceSlotParams{}, ErrInvalidConfig
|
|
}
|
|
return db.UpsertWorkspaceSlotParams{
|
|
ProjectSyncSettingID: projectSyncSettingID,
|
|
SlotIndex: int32(index),
|
|
Path: path,
|
|
}, nil
|
|
}
|
|
|
|
// ToUpdateWorkspaceSlotStateParams builds DB params for a slot state
|
|
// transition, rejecting unknown states to mirror the DB check constraint.
|
|
func ToUpdateWorkspaceSlotStateParams(id int64, state SlotState) (db.UpdateWorkspaceSlotStateParams, error) {
|
|
if !state.Valid() {
|
|
return db.UpdateWorkspaceSlotStateParams{}, ErrInvalidConfig
|
|
}
|
|
return db.UpdateWorkspaceSlotStateParams{
|
|
ID: id,
|
|
State: string(state),
|
|
}, nil
|
|
}
|
|
|
|
func ConfigFromDBRecord(r db.ProjectSyncSetting) Config {
|
|
return Config{
|
|
Target: ProviderProjectTarget{
|
|
Provider: workitem.ProviderID(r.Provider),
|
|
Tenant: r.Tenant,
|
|
Project: r.Project,
|
|
},
|
|
GitRemoteURL: r.GitRemoteUrl,
|
|
SourceBranch: r.SourceBranch,
|
|
WorkspaceID: r.WorkspaceID,
|
|
WorkspaceBasePath: r.WorkspaceBasePath,
|
|
RepoDirName: r.RepoDirName,
|
|
}
|
|
}
|