back interdiff of round #1 and #0

spindle: move the clone step out of nixery into a shared package for all spindle engines #827

merged
opened by evan.jarrett.net targeting master from evan.jarrett.net/core: spindle-clone
files
appview
db
pages
templates
repo
compare
settings
user
fragments
pipelines
pulls
repo
nix
spindle
ERROR
spindle/engines/nixery/setup_steps.go

Failed to calculate interdiff for this file.

REVERTED
spindle/workflow/clone.go
··· 1 - package workflow 2 - 3 - import ( 4 - "fmt" 5 - "strings" 6 - 7 - "tangled.org/core/api/tangled" 8 - "tangled.org/core/workflow" 9 - ) 10 - 11 - type CloneOptions struct { 12 - Workflow tangled.Pipeline_Workflow 13 - TriggerMetadata tangled.Pipeline_TriggerMetadata 14 - DevMode bool 15 - WorkspaceDir string 16 - } 17 - 18 - type CloneInfo struct { 19 - Commands []string 20 - RepoURL string 21 - CommitSHA string 22 - Skip bool 23 - } 24 - 25 - // GetCloneInfo generates git clone commands and metadata from pipeline trigger metadata 26 - func GetCloneInfo(opts CloneOptions) (*CloneInfo, error) { 27 - if opts.Workflow.Clone != nil && opts.Workflow.Clone.Skip { 28 - return &CloneInfo{Skip: true}, nil 29 - } 30 - 31 - commitSHA, err := extractCommitSHA(opts.TriggerMetadata) 32 - if err != nil { 33 - return nil, fmt.Errorf("failed to extract commit SHA: %w", err) 34 - } 35 - 36 - repoURL := buildRepoURL(opts.TriggerMetadata, opts.DevMode) 37 - 38 - workspaceDir := opts.WorkspaceDir 39 - if workspaceDir == "" { 40 - workspaceDir = "/tangled/workspace" 41 - } 42 - 43 - initCmd := fmt.Sprintf("git init %s", workspaceDir) 44 - remoteCmd := fmt.Sprintf("git remote add origin %s", repoURL) 45 - 46 - var cloneOpts tangled.Pipeline_CloneOpts 47 - if opts.Workflow.Clone != nil { 48 - cloneOpts = *opts.Workflow.Clone 49 - } 50 - fetchArgs := buildFetchArgs(cloneOpts, commitSHA) 51 - fetchCmd := fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")) 52 - 53 - checkoutCmd := "git checkout FETCH_HEAD" 54 - 55 - commands := []string{ 56 - initCmd, 57 - fmt.Sprintf("cd %s", workspaceDir), 58 - remoteCmd, 59 - fetchCmd, 60 - checkoutCmd, 61 - } 62 - 63 - return &CloneInfo{ 64 - Commands: commands, 65 - RepoURL: repoURL, 66 - CommitSHA: commitSHA, 67 - Skip: false, 68 - }, nil 69 - } 70 - 71 - // extractCommitSHA extracts the commit SHA from trigger metadata based on trigger type 72 - func extractCommitSHA(tr tangled.Pipeline_TriggerMetadata) (string, error) { 73 - switch workflow.TriggerKind(tr.Kind) { 74 - case workflow.TriggerKindPush: 75 - if tr.Push == nil { 76 - return "", fmt.Errorf("push trigger metadata is nil") 77 - } 78 - return tr.Push.NewSha, nil 79 - 80 - case workflow.TriggerKindPullRequest: 81 - if tr.PullRequest == nil { 82 - return "", fmt.Errorf("pull request trigger metadata is nil") 83 - } 84 - return tr.PullRequest.SourceSha, nil 85 - 86 - case workflow.TriggerKindManual: 87 - // Manual triggers don't have an explicit SHA in the metadata 88 - // For now, return empty string - could be enhanced to fetch from default branch 89 - // TODO: Implement manual trigger SHA resolution (fetch default branch HEAD) 90 - return "", nil 91 - 92 - default: 93 - return "", fmt.Errorf("unknown trigger kind: %s", tr.Kind) 94 - } 95 - } 96 - 97 - // buildRepoURL constructs the repository URL from trigger metadata 98 - func buildRepoURL(tr tangled.Pipeline_TriggerMetadata, devMode bool) string { 99 - if tr.Repo == nil { 100 - return "" 101 - } 102 - 103 - // Determine protocol 104 - scheme := "https://" 105 - if devMode { 106 - scheme = "http://" 107 - } 108 - 109 - // Get host from knot 110 - host := tr.Repo.Knot 111 - 112 - // In dev mode, replace localhost with host.docker.internal for Docker networking 113 - if devMode && strings.Contains(host, "localhost") { 114 - host = strings.ReplaceAll(host, "localhost", "host.docker.internal") 115 - } 116 - 117 - // Build URL: {scheme}{knot}/{did}/{repo} 118 - return fmt.Sprintf("%s%s/%s/%s", scheme, host, tr.Repo.Did, tr.Repo.Repo) 119 - } 120 - 121 - // buildFetchArgs constructs the arguments for git fetch based on clone options 122 - func buildFetchArgs(clone tangled.Pipeline_CloneOpts, sha string) []string { 123 - args := []string{} 124 - 125 - // Set fetch depth (default to 1 for shallow clone) 126 - depth := clone.Depth 127 - if depth == 0 { 128 - depth = 1 129 - } 130 - args = append(args, fmt.Sprintf("--depth=%d", depth)) 131 - 132 - // Add submodules if requested 133 - if clone.Submodules { 134 - args = append(args, "--recurse-submodules=yes") 135 - } 136 - 137 - // Add remote and SHA 138 - args = append(args, "origin") 139 - if sha != "" { 140 - args = append(args, sha) 141 - } 142 - 143 - return args 144 - }
REVERTED
spindle/workflow/clone_test.go
··· 1 - package workflow 2 - 3 - import ( 4 - "strings" 5 - "testing" 6 - 7 - "tangled.org/core/api/tangled" 8 - "tangled.org/core/workflow" 9 - ) 10 - 11 - func TestGetCloneInfo_PushTrigger(t *testing.T) { 12 - cfg := CloneOptions{ 13 - Workflow: tangled.Pipeline_Workflow{ 14 - Clone: &tangled.Pipeline_CloneOpts{ 15 - Depth: 1, 16 - Submodules: false, 17 - Skip: false, 18 - }, 19 - }, 20 - TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 21 - Kind: string(workflow.TriggerKindPush), 22 - Push: &tangled.Pipeline_PushTriggerData{ 23 - NewSha: "abc123", 24 - OldSha: "def456", 25 - Ref: "refs/heads/main", 26 - }, 27 - Repo: &tangled.Pipeline_TriggerRepo{ 28 - Knot: "example.com", 29 - Did: "did:plc:user123", 30 - Repo: "my-repo", 31 - }, 32 - }, 33 - DevMode: false, 34 - WorkspaceDir: "/tangled/workspace", 35 - } 36 - 37 - info, err := GetCloneInfo(cfg) 38 - if err != nil { 39 - t.Fatalf("GetCloneInfo failed: %v", err) 40 - } 41 - 42 - if info.Skip { 43 - t.Error("Expected Skip to be false") 44 - } 45 - 46 - if info.CommitSHA != "abc123" { 47 - t.Errorf("Expected CommitSHA 'abc123', got '%s'", info.CommitSHA) 48 - } 49 - 50 - expectedURL := "https://example.com/did:plc:user123/my-repo" 51 - if info.RepoURL != expectedURL { 52 - t.Errorf("Expected RepoURL '%s', got '%s'", expectedURL, info.RepoURL) 53 - } 54 - 55 - if len(info.Commands) != 5 { 56 - t.Errorf("Expected 5 commands, got %d", len(info.Commands)) 57 - } 58 - 59 - // Verify commands contain expected git operations 60 - allCmds := strings.Join(info.Commands, " ") 61 - if !strings.Contains(allCmds, "git init") { 62 - t.Error("Commands should contain 'git init'") 63 - } 64 - if !strings.Contains(allCmds, "git remote add origin") { 65 - t.Error("Commands should contain 'git remote add origin'") 66 - } 67 - if !strings.Contains(allCmds, "git fetch") { 68 - t.Error("Commands should contain 'git fetch'") 69 - } 70 - if !strings.Contains(allCmds, "abc123") { 71 - t.Error("Commands should contain commit SHA") 72 - } 73 - if !strings.Contains(allCmds, "git checkout FETCH_HEAD") { 74 - t.Error("Commands should contain 'git checkout FETCH_HEAD'") 75 - } 76 - } 77 - 78 - func TestGetCloneInfo_PullRequestTrigger(t *testing.T) { 79 - cfg := CloneOptions{ 80 - Workflow: tangled.Pipeline_Workflow{ 81 - Clone: &tangled.Pipeline_CloneOpts{ 82 - Depth: 1, 83 - Skip: false, 84 - }, 85 - }, 86 - TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 87 - Kind: string(workflow.TriggerKindPullRequest), 88 - PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 89 - SourceSha: "pr-sha-789", 90 - SourceBranch: "feature-branch", 91 - TargetBranch: "main", 92 - Action: "opened", 93 - }, 94 - Repo: &tangled.Pipeline_TriggerRepo{ 95 - Knot: "example.com", 96 - Did: "did:plc:user123", 97 - Repo: "my-repo", 98 - }, 99 - }, 100 - DevMode: false, 101 - WorkspaceDir: "/tangled/workspace", 102 - } 103 - 104 - info, err := GetCloneInfo(cfg) 105 - if err != nil { 106 - t.Fatalf("GetCloneInfo failed: %v", err) 107 - } 108 - 109 - if info.CommitSHA != "pr-sha-789" { 110 - t.Errorf("Expected CommitSHA 'pr-sha-789', got '%s'", info.CommitSHA) 111 - } 112 - 113 - allCmds := strings.Join(info.Commands, " ") 114 - if !strings.Contains(allCmds, "pr-sha-789") { 115 - t.Error("Commands should contain PR commit SHA") 116 - } 117 - } 118 - 119 - func TestGetCloneInfo_ManualTrigger(t *testing.T) { 120 - cfg := CloneOptions{ 121 - Workflow: tangled.Pipeline_Workflow{ 122 - Clone: &tangled.Pipeline_CloneOpts{ 123 - Depth: 1, 124 - Skip: false, 125 - }, 126 - }, 127 - TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 128 - Kind: string(workflow.TriggerKindManual), 129 - Manual: &tangled.Pipeline_ManualTriggerData{ 130 - Inputs: nil, 131 - }, 132 - Repo: &tangled.Pipeline_TriggerRepo{ 133 - Knot: "example.com", 134 - Did: "did:plc:user123", 135 - Repo: "my-repo", 136 - }, 137 - }, 138 - DevMode: false, 139 - WorkspaceDir: "/tangled/workspace", 140 - } 141 - 142 - info, err := GetCloneInfo(cfg) 143 - if err != nil { 144 - t.Fatalf("GetCloneInfo failed: %v", err) 145 - } 146 - 147 - // Manual triggers don't have a SHA yet (TODO) 148 - if info.CommitSHA != "" { 149 - t.Errorf("Expected empty CommitSHA for manual trigger, got '%s'", info.CommitSHA) 150 - } 151 - } 152 - 153 - func TestGetCloneInfo_SkipFlag(t *testing.T) { 154 - cfg := CloneOptions{ 155 - Workflow: tangled.Pipeline_Workflow{ 156 - Clone: &tangled.Pipeline_CloneOpts{ 157 - Skip: true, 158 - }, 159 - }, 160 - TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 161 - Kind: string(workflow.TriggerKindPush), 162 - Push: &tangled.Pipeline_PushTriggerData{ 163 - NewSha: "abc123", 164 - }, 165 - Repo: &tangled.Pipeline_TriggerRepo{ 166 - Knot: "example.com", 167 - Did: "did:plc:user123", 168 - Repo: "my-repo", 169 - }, 170 - }, 171 - } 172 - 173 - info, err := GetCloneInfo(cfg) 174 - if err != nil { 175 - t.Fatalf("GetCloneInfo failed: %v", err) 176 - } 177 - 178 - if !info.Skip { 179 - t.Error("Expected Skip to be true") 180 - } 181 - 182 - if len(info.Commands) != 0 { 183 - t.Errorf("Expected no commands when Skip is true, got %d commands", len(info.Commands)) 184 - } 185 - } 186 - 187 - func TestGetCloneInfo_DevMode(t *testing.T) { 188 - cfg := CloneOptions{ 189 - Workflow: tangled.Pipeline_Workflow{ 190 - Clone: &tangled.Pipeline_CloneOpts{ 191 - Depth: 1, 192 - Skip: false, 193 - }, 194 - }, 195 - TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 196 - Kind: string(workflow.TriggerKindPush), 197 - Push: &tangled.Pipeline_PushTriggerData{ 198 - NewSha: "abc123", 199 - }, 200 - Repo: &tangled.Pipeline_TriggerRepo{ 201 - Knot: "localhost:3000", 202 - Did: "did:plc:user123", 203 - Repo: "my-repo", 204 - }, 205 - }, 206 - DevMode: true, 207 - WorkspaceDir: "/tangled/workspace", 208 - } 209 - 210 - info, err := GetCloneInfo(cfg) 211 - if err != nil { 212 - t.Fatalf("GetCloneInfo failed: %v", err) 213 - } 214 - 215 - // In dev mode, should use http:// and replace localhost with host.docker.internal 216 - expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo" 217 - if info.RepoURL != expectedURL { 218 - t.Errorf("Expected dev mode URL '%s', got '%s'", expectedURL, info.RepoURL) 219 - } 220 - } 221 - 222 - func TestGetCloneInfo_DepthAndSubmodules(t *testing.T) { 223 - cfg := CloneOptions{ 224 - Workflow: tangled.Pipeline_Workflow{ 225 - Clone: &tangled.Pipeline_CloneOpts{ 226 - Depth: 10, 227 - Submodules: true, 228 - Skip: false, 229 - }, 230 - }, 231 - TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 232 - Kind: string(workflow.TriggerKindPush), 233 - Push: &tangled.Pipeline_PushTriggerData{ 234 - NewSha: "abc123", 235 - }, 236 - Repo: &tangled.Pipeline_TriggerRepo{ 237 - Knot: "example.com", 238 - Did: "did:plc:user123", 239 - Repo: "my-repo", 240 - }, 241 - }, 242 - DevMode: false, 243 - WorkspaceDir: "/tangled/workspace", 244 - } 245 - 246 - info, err := GetCloneInfo(cfg) 247 - if err != nil { 248 - t.Fatalf("GetCloneInfo failed: %v", err) 249 - } 250 - 251 - allCmds := strings.Join(info.Commands, " ") 252 - if !strings.Contains(allCmds, "--depth=10") { 253 - t.Error("Commands should contain '--depth=10'") 254 - } 255 - 256 - if !strings.Contains(allCmds, "--recurse-submodules=yes") { 257 - t.Error("Commands should contain '--recurse-submodules=yes'") 258 - } 259 - } 260 - 261 - func TestGetCloneInfo_DefaultDepth(t *testing.T) { 262 - cfg := CloneOptions{ 263 - Workflow: tangled.Pipeline_Workflow{ 264 - Clone: &tangled.Pipeline_CloneOpts{ 265 - Depth: 0, // Default should be 1 266 - Skip: false, 267 - }, 268 - }, 269 - TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 270 - Kind: string(workflow.TriggerKindPush), 271 - Push: &tangled.Pipeline_PushTriggerData{ 272 - NewSha: "abc123", 273 - }, 274 - Repo: &tangled.Pipeline_TriggerRepo{ 275 - Knot: "example.com", 276 - Did: "did:plc:user123", 277 - Repo: "my-repo", 278 - }, 279 - }, 280 - WorkspaceDir: "/tangled/workspace", 281 - } 282 - 283 - info, err := GetCloneInfo(cfg) 284 - if err != nil { 285 - t.Fatalf("GetCloneInfo failed: %v", err) 286 - } 287 - 288 - allCmds := strings.Join(info.Commands, " ") 289 - if !strings.Contains(allCmds, "--depth=1") { 290 - t.Error("Commands should default to '--depth=1'") 291 - } 292 - } 293 - 294 - func TestGetCloneInfo_NilPushData(t *testing.T) { 295 - cfg := CloneOptions{ 296 - Workflow: tangled.Pipeline_Workflow{ 297 - Clone: &tangled.Pipeline_CloneOpts{ 298 - Depth: 1, 299 - Skip: false, 300 - }, 301 - }, 302 - TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 303 - Kind: string(workflow.TriggerKindPush), 304 - Push: nil, // Nil push data should return error 305 - Repo: &tangled.Pipeline_TriggerRepo{ 306 - Knot: "example.com", 307 - Did: "did:plc:user123", 308 - Repo: "my-repo", 309 - }, 310 - }, 311 - WorkspaceDir: "/tangled/workspace", 312 - } 313 - 314 - _, err := GetCloneInfo(cfg) 315 - if err == nil { 316 - t.Error("Expected error when push data is nil") 317 - } 318 - 319 - if !strings.Contains(err.Error(), "push trigger metadata is nil") { 320 - t.Errorf("Expected error about nil push metadata, got: %v", err) 321 - } 322 - } 323 - 324 - func TestGetCloneInfo_NilPRData(t *testing.T) { 325 - cfg := CloneOptions{ 326 - Workflow: tangled.Pipeline_Workflow{ 327 - Clone: &tangled.Pipeline_CloneOpts{ 328 - Depth: 1, 329 - Skip: false, 330 - }, 331 - }, 332 - TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 333 - Kind: string(workflow.TriggerKindPullRequest), 334 - PullRequest: nil, // Nil PR data should return error 335 - Repo: &tangled.Pipeline_TriggerRepo{ 336 - Knot: "example.com", 337 - Did: "did:plc:user123", 338 - Repo: "my-repo", 339 - }, 340 - }, 341 - WorkspaceDir: "/tangled/workspace", 342 - } 343 - 344 - _, err := GetCloneInfo(cfg) 345 - if err == nil { 346 - t.Error("Expected error when pull request data is nil") 347 - } 348 - 349 - if !strings.Contains(err.Error(), "pull request trigger metadata is nil") { 350 - t.Errorf("Expected error about nil PR metadata, got: %v", err) 351 - } 352 - } 353 - 354 - func TestGetCloneInfo_CustomWorkspace(t *testing.T) { 355 - cfg := CloneOptions{ 356 - Workflow: tangled.Pipeline_Workflow{ 357 - Clone: &tangled.Pipeline_CloneOpts{ 358 - Depth: 1, 359 - Skip: false, 360 - }, 361 - }, 362 - TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 363 - Kind: string(workflow.TriggerKindPush), 364 - Push: &tangled.Pipeline_PushTriggerData{ 365 - NewSha: "abc123", 366 - }, 367 - Repo: &tangled.Pipeline_TriggerRepo{ 368 - Knot: "example.com", 369 - Did: "did:plc:user123", 370 - Repo: "my-repo", 371 - }, 372 - }, 373 - DevMode: false, 374 - WorkspaceDir: "/custom/path", 375 - } 376 - 377 - info, err := GetCloneInfo(cfg) 378 - if err != nil { 379 - t.Fatalf("GetCloneInfo failed: %v", err) 380 - } 381 - 382 - allCmds := strings.Join(info.Commands, " ") 383 - if !strings.Contains(allCmds, "/custom/path") { 384 - t.Error("Commands should use custom workspace directory") 385 - } 386 - } 387 - 388 - func TestGetCloneInfo_DefaultWorkspace(t *testing.T) { 389 - cfg := CloneOptions{ 390 - Workflow: tangled.Pipeline_Workflow{ 391 - Clone: &tangled.Pipeline_CloneOpts{ 392 - Depth: 1, 393 - Skip: false, 394 - }, 395 - }, 396 - TriggerMetadata: tangled.Pipeline_TriggerMetadata{ 397 - Kind: string(workflow.TriggerKindPush), 398 - Push: &tangled.Pipeline_PushTriggerData{ 399 - NewSha: "abc123", 400 - }, 401 - Repo: &tangled.Pipeline_TriggerRepo{ 402 - Knot: "example.com", 403 - Did: "did:plc:user123", 404 - Repo: "my-repo", 405 - }, 406 - }, 407 - DevMode: false, 408 - WorkspaceDir: "", // Empty should default to /tangled/workspace 409 - } 410 - 411 - info, err := GetCloneInfo(cfg) 412 - if err != nil { 413 - t.Fatalf("GetCloneInfo failed: %v", err) 414 - } 415 - 416 - allCmds := strings.Join(info.Commands, " ") 417 - if !strings.Contains(allCmds, "/tangled/workspace") { 418 - t.Error("Commands should default to /tangled/workspace") 419 - } 420 - }
NEW
appview/db/pipeline.go
··· 168 168 169 169 // this is a mega query, but the most useful one: 170 170 // get N pipelines, for each one get the latest status of its N workflows 171 - func GetPipelineStatuses(e Execer, limit int, filters ...filter) ([]models.Pipeline, error) { 171 + func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) { 172 172 var conditions []string 173 173 var args []any 174 174 for _, filter := range filters { ··· 205 205 join 206 206 triggers t ON p.trigger_id = t.id 207 207 %s 208 - order by p.created desc 209 - limit %d 210 - `, whereClause, limit) 208 + `, whereClause) 211 209 212 210 rows, err := e.Query(query, args...) 213 211 if err != nil {
NEW
appview/pages/templates/repo/compare/compare.html
··· 17 17 {{ end }} 18 18 19 19 {{ define "mainLayout" }} 20 - <div class="px-1 flex-grow col-span-full flex flex-col gap-4"> 20 + <div class="px-1 col-span-full flex flex-col gap-4"> 21 21 {{ block "contentLayout" . }} 22 22 {{ block "content" . }}{{ end }} 23 23 {{ end }}
NEW
appview/pages/templates/repo/settings/general.html
··· 58 58 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 59 </button> 60 60 </div> 61 - </fieldset> 61 + <fieldset> 62 62 </form> 63 63 {{ end }} 64 64
NEW
appview/pages/templates/user/fragments/editBio.html
··· 31 31 class="py-1 px-1 w-full" 32 32 name="pronouns" 33 33 placeholder="they/them" 34 + pattern="[a-zA-Z]{1,6}[\/\s\-][a-zA-Z]{1,6}" 34 35 value="{{ $pronouns }}" 35 36 > 36 37 </div>
NEW
appview/pipelines/pipelines.go
··· 82 82 83 83 ps, err := db.GetPipelineStatuses( 84 84 p.db, 85 - 30, 86 85 db.FilterEq("repo_owner", repoInfo.OwnerDid), 87 86 db.FilterEq("repo_name", repoInfo.Name), 88 87 db.FilterEq("knot", repoInfo.Knot), ··· 125 124 126 125 ps, err := db.GetPipelineStatuses( 127 126 p.db, 128 - 1, 129 127 db.FilterEq("repo_owner", repoInfo.OwnerDid), 130 128 db.FilterEq("repo_name", repoInfo.Name), 131 129 db.FilterEq("knot", repoInfo.Knot), ··· 195 193 196 194 ps, err := db.GetPipelineStatuses( 197 195 p.db, 198 - 1, 199 196 db.FilterEq("repo_owner", repoInfo.OwnerDid), 200 197 db.FilterEq("repo_name", repoInfo.Name), 201 198 db.FilterEq("knot", repoInfo.Knot),
NEW
appview/pulls/pulls.go
··· 178 178 179 179 ps, err := db.GetPipelineStatuses( 180 180 s.db, 181 - len(shas), 182 181 db.FilterEq("repo_owner", repoInfo.OwnerDid), 183 182 db.FilterEq("repo_name", repoInfo.Name), 184 183 db.FilterEq("knot", repoInfo.Knot), ··· 649 648 repoInfo := f.RepoInfo(user) 650 649 ps, err := db.GetPipelineStatuses( 651 650 s.db, 652 - len(shas), 653 651 db.FilterEq("repo_owner", repoInfo.OwnerDid), 654 652 db.FilterEq("repo_name", repoInfo.Name), 655 653 db.FilterEq("knot", repoInfo.Knot),
NEW
appview/repo/compare.go
··· 116 116 } 117 117 118 118 // if user is navigating to one of 119 - // /compare/{base}...{head} 120 119 // /compare/{base}/{head} 121 - var base, head string 122 - rest := chi.URLParam(r, "*") 123 - 124 - var parts []string 125 - if strings.Contains(rest, "...") { 126 - parts = strings.SplitN(rest, "...", 2) 127 - } else if strings.Contains(rest, "/") { 128 - parts = strings.SplitN(rest, "/", 2) 129 - } 130 - 131 - if len(parts) == 2 { 132 - base = parts[0] 133 - head = parts[1] 120 + // /compare/{base}...{head} 121 + base := chi.URLParam(r, "base") 122 + head := chi.URLParam(r, "head") 123 + if base == "" && head == "" { 124 + rest := chi.URLParam(r, "*") // master...feature/xyz 125 + parts := strings.SplitN(rest, "...", 2) 126 + if len(parts) == 2 { 127 + base = parts[0] 128 + head = parts[1] 129 + } 134 130 } 135 131 136 132 base, _ = url.PathUnescape(base)
NEW
appview/repo/repo_util.go
··· 1 1 package repo 2 2 3 3 import ( 4 + "crypto/rand" 5 + "math/big" 4 6 "slices" 5 7 "sort" 6 8 "strings" ··· 88 90 return 89 91 } 90 92 93 + func randomString(n int) string { 94 + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 95 + result := make([]byte, n) 96 + 97 + for i := 0; i < n; i++ { 98 + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) 99 + result[i] = letters[n.Int64()] 100 + } 101 + 102 + return string(result) 103 + } 104 + 91 105 // grab pipelines from DB and munge that into a hashmap with commit sha as key 92 106 // 93 107 // golang is so blessed that it requires 35 lines of imperative code for this ··· 104 118 105 119 ps, err := db.GetPipelineStatuses( 106 120 d, 107 - len(shas), 108 121 db.FilterEq("repo_owner", repoInfo.OwnerDid), 109 122 db.FilterEq("repo_name", repoInfo.Name), 110 123 db.FilterEq("knot", repoInfo.Knot),
NEW
appview/repo/router.go
··· 61 61 // for example: 62 62 // /compare/master...some/feature 63 63 // /compare/master...example.com:another/feature <- this is a fork 64 + r.Get("/{base}/{head}", rp.Compare) 64 65 r.Get("/*", rp.Compare) 65 66 }) 66 67
NEW
nix/pkgs/knot-unwrapped.nix
··· 4 4 sqlite-lib, 5 5 src, 6 6 }: let 7 - version = "1.11.0-alpha"; 7 + version = "1.9.1-alpha"; 8 8 in 9 9 buildGoApplication { 10 10 pname = "knot";
NEW
spindle/engines/nixery/engine.go
··· 109 109 setup := &setupSteps{} 110 110 111 111 setup.addStep(nixConfStep()) 112 - setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 112 + setup.addStep(models.BuildCloneStep(twf, *tpl.TriggerMetadata, workspaceDir, e.cfg.Server.Dev)) 113 113 // this step could be empty 114 114 if s := dependencyStep(dwf.Dependencies); s != nil { 115 115 setup.addStep(*s)
NEW
spindle/models/clone.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/workflow" 9 + ) 10 + 11 + type CloneStep struct { 12 + name string 13 + kind StepKind 14 + commands []string 15 + } 16 + 17 + func (s CloneStep) Name() string { 18 + return s.name 19 + } 20 + 21 + func (s CloneStep) Commands() []string { 22 + return s.commands 23 + } 24 + 25 + func (s CloneStep) Command() string { 26 + return strings.Join(s.commands, "\n") 27 + } 28 + 29 + func (s CloneStep) Kind() StepKind { 30 + return s.kind 31 + } 32 + 33 + // BuildCloneStep generates git clone commands. 34 + // The shared builder handles: 35 + // - git init 36 + // - git remote add origin <url> 37 + // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 38 + // - git checkout FETCH_HEAD 39 + // And supports all trigger types (push, PR, manual) and clone options. 40 + func BuildCloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, workspaceDir string, dev bool) CloneStep { 41 + if twf.Clone != nil && twf.Clone.Skip { 42 + return CloneStep{} 43 + } 44 + 45 + commitSHA, err := extractCommitSHA(tr) 46 + if err != nil { 47 + return CloneStep{ 48 + kind: StepKindSystem, 49 + name: "Clone repository into workspace (error)", 50 + commands: []string{fmt.Sprintf("echo 'Failed to get clone info: %s' && exit 1", err.Error())}, 51 + } 52 + } 53 + 54 + repoURL := buildRepoURL(tr, dev) 55 + 56 + if workspaceDir == "" { 57 + workspaceDir = "/tangled/workspace" 58 + } 59 + 60 + initCmd := fmt.Sprintf("git init %s", workspaceDir) 61 + remoteCmd := fmt.Sprintf("git remote add origin %s", repoURL) 62 + 63 + var cloneOpts tangled.Pipeline_CloneOpts 64 + if twf.Clone != nil { 65 + cloneOpts = *twf.Clone 66 + } 67 + fetchArgs := buildFetchArgs(cloneOpts, commitSHA) 68 + fetchCmd := fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " ")) 69 + checkoutCmd := "git checkout FETCH_HEAD" 70 + 71 + return CloneStep{ 72 + kind: StepKindSystem, 73 + name: "Clone repository into workspace", 74 + commands: []string{ 75 + initCmd, 76 + fmt.Sprintf("cd %s", workspaceDir), 77 + remoteCmd, 78 + fetchCmd, 79 + checkoutCmd, 80 + }, 81 + } 82 + } 83 + 84 + // extractCommitSHA extracts the commit SHA from trigger metadata based on trigger type 85 + func extractCommitSHA(tr tangled.Pipeline_TriggerMetadata) (string, error) { 86 + switch workflow.TriggerKind(tr.Kind) { 87 + case workflow.TriggerKindPush: 88 + if tr.Push == nil { 89 + return "", fmt.Errorf("push trigger metadata is nil") 90 + } 91 + return tr.Push.NewSha, nil 92 + 93 + case workflow.TriggerKindPullRequest: 94 + if tr.PullRequest == nil { 95 + return "", fmt.Errorf("pull request trigger metadata is nil") 96 + } 97 + return tr.PullRequest.SourceSha, nil 98 + 99 + case workflow.TriggerKindManual: 100 + // Manual triggers don't have an explicit SHA in the metadata 101 + // For now, return empty string - could be enhanced to fetch from default branch 102 + // TODO: Implement manual trigger SHA resolution (fetch default branch HEAD) 103 + return "", nil 104 + 105 + default: 106 + return "", fmt.Errorf("unknown trigger kind: %s", tr.Kind) 107 + } 108 + } 109 + 110 + // buildRepoURL constructs the repository URL from trigger metadata 111 + func buildRepoURL(tr tangled.Pipeline_TriggerMetadata, devMode bool) string { 112 + if tr.Repo == nil { 113 + return "" 114 + } 115 + 116 + // Determine protocol 117 + scheme := "https://" 118 + if devMode { 119 + scheme = "http://" 120 + } 121 + 122 + // Get host from knot 123 + host := tr.Repo.Knot 124 + 125 + // In dev mode, replace localhost with host.docker.internal for Docker networking 126 + if devMode && strings.Contains(host, "localhost") { 127 + host = strings.ReplaceAll(host, "localhost", "host.docker.internal") 128 + } 129 + 130 + // Build URL: {scheme}{knot}/{did}/{repo} 131 + return fmt.Sprintf("%s%s/%s/%s", scheme, host, tr.Repo.Did, tr.Repo.Repo) 132 + } 133 + 134 + // buildFetchArgs constructs the arguments for git fetch based on clone options 135 + func buildFetchArgs(clone tangled.Pipeline_CloneOpts, sha string) []string { 136 + args := []string{} 137 + 138 + // Set fetch depth (default to 1 for shallow clone) 139 + depth := clone.Depth 140 + if depth == 0 { 141 + depth = 1 142 + } 143 + args = append(args, fmt.Sprintf("--depth=%d", depth)) 144 + 145 + // Add submodules if requested 146 + if clone.Submodules { 147 + args = append(args, "--recurse-submodules=yes") 148 + } 149 + 150 + // Add remote and SHA 151 + args = append(args, "origin") 152 + if sha != "" { 153 + args = append(args, sha) 154 + } 155 + 156 + return args 157 + }
NEW
spindle/models/clone_test.go
··· 1 + package models 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/workflow" 9 + ) 10 + 11 + func TestBuildCloneStep_PushTrigger(t *testing.T) { 12 + twf := tangled.Pipeline_Workflow{ 13 + Clone: &tangled.Pipeline_CloneOpts{ 14 + Depth: 1, 15 + Submodules: false, 16 + Skip: false, 17 + }, 18 + } 19 + tr := tangled.Pipeline_TriggerMetadata{ 20 + Kind: string(workflow.TriggerKindPush), 21 + Push: &tangled.Pipeline_PushTriggerData{ 22 + NewSha: "abc123", 23 + OldSha: "def456", 24 + Ref: "refs/heads/main", 25 + }, 26 + Repo: &tangled.Pipeline_TriggerRepo{ 27 + Knot: "example.com", 28 + Did: "did:plc:user123", 29 + Repo: "my-repo", 30 + }, 31 + } 32 + 33 + step := BuildCloneStep(twf, tr, "/tangled/workspace", false) 34 + 35 + if step.Kind() != StepKindSystem { 36 + t.Errorf("Expected StepKindSystem, got %v", step.Kind()) 37 + } 38 + 39 + if step.Name() != "Clone repository into workspace" { 40 + t.Errorf("Expected 'Clone repository into workspace', got '%s'", step.Name()) 41 + } 42 + 43 + commands := step.Commands() 44 + if len(commands) != 5 { 45 + t.Errorf("Expected 5 commands, got %d", len(commands)) 46 + } 47 + 48 + // Verify commands contain expected git operations 49 + allCmds := strings.Join(commands, " ") 50 + if !strings.Contains(allCmds, "git init") { 51 + t.Error("Commands should contain 'git init'") 52 + } 53 + if !strings.Contains(allCmds, "git remote add origin") { 54 + t.Error("Commands should contain 'git remote add origin'") 55 + } 56 + if !strings.Contains(allCmds, "git fetch") { 57 + t.Error("Commands should contain 'git fetch'") 58 + } 59 + if !strings.Contains(allCmds, "abc123") { 60 + t.Error("Commands should contain commit SHA") 61 + } 62 + if !strings.Contains(allCmds, "git checkout FETCH_HEAD") { 63 + t.Error("Commands should contain 'git checkout FETCH_HEAD'") 64 + } 65 + if !strings.Contains(allCmds, "https://example.com/did:plc:user123/my-repo") { 66 + t.Error("Commands should contain expected repo URL") 67 + } 68 + } 69 + 70 + func TestBuildCloneStep_PullRequestTrigger(t *testing.T) { 71 + twf := tangled.Pipeline_Workflow{ 72 + Clone: &tangled.Pipeline_CloneOpts{ 73 + Depth: 1, 74 + Skip: false, 75 + }, 76 + } 77 + tr := tangled.Pipeline_TriggerMetadata{ 78 + Kind: string(workflow.TriggerKindPullRequest), 79 + PullRequest: &tangled.Pipeline_PullRequestTriggerData{ 80 + SourceSha: "pr-sha-789", 81 + SourceBranch: "feature-branch", 82 + TargetBranch: "main", 83 + Action: "opened", 84 + }, 85 + Repo: &tangled.Pipeline_TriggerRepo{ 86 + Knot: "example.com", 87 + Did: "did:plc:user123", 88 + Repo: "my-repo", 89 + }, 90 + } 91 + 92 + step := BuildCloneStep(twf, tr, "/tangled/workspace", false) 93 + 94 + allCmds := strings.Join(step.Commands(), " ") 95 + if !strings.Contains(allCmds, "pr-sha-789") { 96 + t.Error("Commands should contain PR commit SHA") 97 + } 98 + } 99 + 100 + func TestBuildCloneStep_ManualTrigger(t *testing.T) { 101 + twf := tangled.Pipeline_Workflow{ 102 + Clone: &tangled.Pipeline_CloneOpts{ 103 + Depth: 1, 104 + Skip: false, 105 + }, 106 + } 107 + tr := tangled.Pipeline_TriggerMetadata{ 108 + Kind: string(workflow.TriggerKindManual), 109 + Manual: &tangled.Pipeline_ManualTriggerData{ 110 + Inputs: nil, 111 + }, 112 + Repo: &tangled.Pipeline_TriggerRepo{ 113 + Knot: "example.com", 114 + Did: "did:plc:user123", 115 + Repo: "my-repo", 116 + }, 117 + } 118 + 119 + step := BuildCloneStep(twf, tr, "/tangled/workspace", false) 120 + 121 + // Manual triggers don't have a SHA yet (TODO), so git fetch won't include a SHA 122 + allCmds := strings.Join(step.Commands(), " ") 123 + // Should still have basic git commands 124 + if !strings.Contains(allCmds, "git init") { 125 + t.Error("Commands should contain 'git init'") 126 + } 127 + if !strings.Contains(allCmds, "git fetch") { 128 + t.Error("Commands should contain 'git fetch'") 129 + } 130 + } 131 + 132 + func TestBuildCloneStep_SkipFlag(t *testing.T) { 133 + twf := tangled.Pipeline_Workflow{ 134 + Clone: &tangled.Pipeline_CloneOpts{ 135 + Skip: true, 136 + }, 137 + } 138 + tr := tangled.Pipeline_TriggerMetadata{ 139 + Kind: string(workflow.TriggerKindPush), 140 + Push: &tangled.Pipeline_PushTriggerData{ 141 + NewSha: "abc123", 142 + }, 143 + Repo: &tangled.Pipeline_TriggerRepo{ 144 + Knot: "example.com", 145 + Did: "did:plc:user123", 146 + Repo: "my-repo", 147 + }, 148 + } 149 + 150 + step := BuildCloneStep(twf, tr, "/tangled/workspace", false) 151 + 152 + // Empty step when skip is true 153 + if step.Name() != "" { 154 + t.Error("Expected empty step name when Skip is true") 155 + } 156 + if len(step.Commands()) != 0 { 157 + t.Errorf("Expected no commands when Skip is true, got %d commands", len(step.Commands())) 158 + } 159 + } 160 + 161 + func TestBuildCloneStep_DevMode(t *testing.T) { 162 + twf := tangled.Pipeline_Workflow{ 163 + Clone: &tangled.Pipeline_CloneOpts{ 164 + Depth: 1, 165 + Skip: false, 166 + }, 167 + } 168 + tr := tangled.Pipeline_TriggerMetadata{ 169 + Kind: string(workflow.TriggerKindPush), 170 + Push: &tangled.Pipeline_PushTriggerData{ 171 + NewSha: "abc123", 172 + }, 173 + Repo: &tangled.Pipeline_TriggerRepo{ 174 + Knot: "localhost:3000", 175 + Did: "did:plc:user123", 176 + Repo: "my-repo", 177 + }, 178 + } 179 + 180 + step := BuildCloneStep(twf, tr, "/tangled/workspace", true) 181 + 182 + // In dev mode, should use http:// and replace localhost with host.docker.internal 183 + allCmds := strings.Join(step.Commands(), " ") 184 + expectedURL := "http://host.docker.internal:3000/did:plc:user123/my-repo" 185 + if !strings.Contains(allCmds, expectedURL) { 186 + t.Errorf("Expected dev mode URL '%s' in commands", expectedURL) 187 + } 188 + } 189 + 190 + func TestBuildCloneStep_DepthAndSubmodules(t *testing.T) { 191 + twf := tangled.Pipeline_Workflow{ 192 + Clone: &tangled.Pipeline_CloneOpts{ 193 + Depth: 10, 194 + Submodules: true, 195 + Skip: false, 196 + }, 197 + } 198 + tr := tangled.Pipeline_TriggerMetadata{ 199 + Kind: string(workflow.TriggerKindPush), 200 + Push: &tangled.Pipeline_PushTriggerData{ 201 + NewSha: "abc123", 202 + }, 203 + Repo: &tangled.Pipeline_TriggerRepo{ 204 + Knot: "example.com", 205 + Did: "did:plc:user123", 206 + Repo: "my-repo", 207 + }, 208 + } 209 + 210 + step := BuildCloneStep(twf, tr, "/tangled/workspace", false) 211 + 212 + allCmds := strings.Join(step.Commands(), " ") 213 + if !strings.Contains(allCmds, "--depth=10") { 214 + t.Error("Commands should contain '--depth=10'") 215 + } 216 + 217 + if !strings.Contains(allCmds, "--recurse-submodules=yes") { 218 + t.Error("Commands should contain '--recurse-submodules=yes'") 219 + } 220 + } 221 + 222 + func TestBuildCloneStep_DefaultDepth(t *testing.T) { 223 + twf := tangled.Pipeline_Workflow{ 224 + Clone: &tangled.Pipeline_CloneOpts{ 225 + Depth: 0, // Default should be 1 226 + Skip: false, 227 + }, 228 + } 229 + tr := tangled.Pipeline_TriggerMetadata{ 230 + Kind: string(workflow.TriggerKindPush), 231 + Push: &tangled.Pipeline_PushTriggerData{ 232 + NewSha: "abc123", 233 + }, 234 + Repo: &tangled.Pipeline_TriggerRepo{ 235 + Knot: "example.com", 236 + Did: "did:plc:user123", 237 + Repo: "my-repo", 238 + }, 239 + } 240 + 241 + step := BuildCloneStep(twf, tr, "/tangled/workspace", false) 242 + 243 + allCmds := strings.Join(step.Commands(), " ") 244 + if !strings.Contains(allCmds, "--depth=1") { 245 + t.Error("Commands should default to '--depth=1'") 246 + } 247 + } 248 + 249 + func TestBuildCloneStep_NilPushData(t *testing.T) { 250 + twf := tangled.Pipeline_Workflow{ 251 + Clone: &tangled.Pipeline_CloneOpts{ 252 + Depth: 1, 253 + Skip: false, 254 + }, 255 + } 256 + tr := tangled.Pipeline_TriggerMetadata{ 257 + Kind: string(workflow.TriggerKindPush), 258 + Push: nil, // Nil push data should create error step 259 + Repo: &tangled.Pipeline_TriggerRepo{ 260 + Knot: "example.com", 261 + Did: "did:plc:user123", 262 + Repo: "my-repo", 263 + }, 264 + } 265 + 266 + step := BuildCloneStep(twf, tr, "/tangled/workspace", false) 267 + 268 + // Should return an error step 269 + if !strings.Contains(step.Name(), "error") { 270 + t.Error("Expected error in step name when push data is nil") 271 + } 272 + 273 + allCmds := strings.Join(step.Commands(), " ") 274 + if !strings.Contains(allCmds, "Failed to get clone info") { 275 + t.Error("Commands should contain error message") 276 + } 277 + if !strings.Contains(allCmds, "exit 1") { 278 + t.Error("Commands should exit with error") 279 + } 280 + } 281 + 282 + func TestBuildCloneStep_NilPRData(t *testing.T) { 283 + twf := tangled.Pipeline_Workflow{ 284 + Clone: &tangled.Pipeline_CloneOpts{ 285 + Depth: 1, 286 + Skip: false, 287 + }, 288 + } 289 + tr := tangled.Pipeline_TriggerMetadata{ 290 + Kind: string(workflow.TriggerKindPullRequest), 291 + PullRequest: nil, // Nil PR data should create error step 292 + Repo: &tangled.Pipeline_TriggerRepo{ 293 + Knot: "example.com", 294 + Did: "did:plc:user123", 295 + Repo: "my-repo", 296 + }, 297 + } 298 + 299 + step := BuildCloneStep(twf, tr, "/tangled/workspace", false) 300 + 301 + // Should return an error step 302 + if !strings.Contains(step.Name(), "error") { 303 + t.Error("Expected error in step name when pull request data is nil") 304 + } 305 + 306 + allCmds := strings.Join(step.Commands(), " ") 307 + if !strings.Contains(allCmds, "Failed to get clone info") { 308 + t.Error("Commands should contain error message") 309 + } 310 + } 311 + 312 + func TestBuildCloneStep_CustomWorkspace(t *testing.T) { 313 + twf := tangled.Pipeline_Workflow{ 314 + Clone: &tangled.Pipeline_CloneOpts{ 315 + Depth: 1, 316 + Skip: false, 317 + }, 318 + } 319 + tr := tangled.Pipeline_TriggerMetadata{ 320 + Kind: string(workflow.TriggerKindPush), 321 + Push: &tangled.Pipeline_PushTriggerData{ 322 + NewSha: "abc123", 323 + }, 324 + Repo: &tangled.Pipeline_TriggerRepo{ 325 + Knot: "example.com", 326 + Did: "did:plc:user123", 327 + Repo: "my-repo", 328 + }, 329 + } 330 + 331 + step := BuildCloneStep(twf, tr, "/custom/path", false) 332 + 333 + allCmds := strings.Join(step.Commands(), " ") 334 + if !strings.Contains(allCmds, "/custom/path") { 335 + t.Error("Commands should use custom workspace directory") 336 + } 337 + } 338 + 339 + func TestBuildCloneStep_DefaultWorkspace(t *testing.T) { 340 + twf := tangled.Pipeline_Workflow{ 341 + Clone: &tangled.Pipeline_CloneOpts{ 342 + Depth: 1, 343 + Skip: false, 344 + }, 345 + } 346 + tr := tangled.Pipeline_TriggerMetadata{ 347 + Kind: string(workflow.TriggerKindPush), 348 + Push: &tangled.Pipeline_PushTriggerData{ 349 + NewSha: "abc123", 350 + }, 351 + Repo: &tangled.Pipeline_TriggerRepo{ 352 + Knot: "example.com", 353 + Did: "did:plc:user123", 354 + Repo: "my-repo", 355 + }, 356 + } 357 + 358 + step := BuildCloneStep(twf, tr, "", false) // Empty should default to /tangled/workspace 359 + 360 + allCmds := strings.Join(step.Commands(), " ") 361 + if !strings.Contains(allCmds, "/tangled/workspace") { 362 + t.Error("Commands should default to /tangled/workspace") 363 + } 364 + }