// Pipeline-related methods for the Woodpecker CI adapter. package woodpecker import ( "context" "fmt" "strings" "time" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" "github.com/orchard9/rdev/internal/domain" ) // ListPipelines returns recent CI pipeline executions for a repository. func (c *Client) ListPipelines(ctx context.Context, owner, repo string) ([]*domain.CIPipeline, error) { select { case <-ctx.Done(): return nil, ctx.Err() default: } fullName := owner + "/" + repo r, err := c.client.RepoLookup(fullName) if err != nil { return nil, fmt.Errorf("repo not found %s: %w", fullName, err) } pipelines, err := c.client.PipelineList(r.ID, woodpecker.PipelineListOptions{}) if err != nil { return nil, fmt.Errorf("failed to list pipelines: %w", err) } result := make([]*domain.CIPipeline, len(pipelines)) for i, p := range pipelines { result[i] = pipelineFromWoodpecker(p) } return result, nil } // GetPipeline returns a specific pipeline execution by number. func (c *Client) GetPipeline(ctx context.Context, owner, repo string, number int64) (*domain.CIPipeline, error) { select { case <-ctx.Done(): return nil, ctx.Err() default: } fullName := owner + "/" + repo r, err := c.client.RepoLookup(fullName) if err != nil { return nil, fmt.Errorf("repo not found %s: %w", fullName, err) } p, err := c.client.Pipeline(r.ID, number) if err != nil { return nil, fmt.Errorf("pipeline %d not found: %w", number, err) } return pipelineFromWoodpecker(p), nil } // GetPipelineSteps returns detailed step information for a pipeline. // For failed steps, includes the last 50 lines of log output. func (c *Client) GetPipelineSteps(ctx context.Context, owner, repo string, number int64) (*domain.CIPipelineSteps, error) { select { case <-ctx.Done(): return nil, ctx.Err() default: } fullName := owner + "/" + repo r, err := c.client.RepoLookup(fullName) if err != nil { return nil, fmt.Errorf("repo not found %s: %w", fullName, err) } p, err := c.client.Pipeline(r.ID, number) if err != nil { return nil, fmt.Errorf("pipeline %d not found: %w", number, err) } result := &domain.CIPipelineSteps{ PipelineNumber: number, URL: fmt.Sprintf("%s/%s/%d", c.url, fullName, number), Steps: make([]domain.CIPipelineStep, 0), } // Flatten workflows -> steps for _, wf := range p.Workflows { if wf == nil { continue } for _, step := range wf.Children { if step == nil { continue } // Calculate duration var duration int if step.Stopped > 0 && step.Started > 0 { duration = int(step.Stopped - step.Started) } // Convert exit code var exitCode *int if step.State == "success" || step.State == "failure" || step.State == "killed" { code := step.ExitCode exitCode = &code } domainStep := domain.CIPipelineStep{ ID: step.ID, Name: step.Name, Status: step.State, ExitCode: exitCode, Duration: duration, Error: step.Error, } // Fetch logs for failed steps if step.State == "failure" || step.State == "error" || step.State == "killed" { logs, err := c.client.StepLogEntries(r.ID, number, step.ID) if err != nil { c.logger.Warn("failed to fetch step logs", "step", step.Name, "error", err) } else { domainStep.Log = formatLogEntries(logs, 50) } } result.Steps = append(result.Steps, domainStep) } } return result, nil } // TriggerBuild manually starts a new pipeline build on the specified branch. func (c *Client) TriggerBuild(ctx context.Context, owner, repo, branch string) (int64, error) { select { case <-ctx.Done(): return 0, ctx.Err() default: } fullName := owner + "/" + repo r, err := c.client.RepoLookup(fullName) if err != nil { return 0, fmt.Errorf("repo not found %s: %w", fullName, err) } // Create a new pipeline for the branch (with circuit breaker protection) var pipeline *woodpecker.Pipeline err = c.executeWithCircuitBreaker(func() error { var createErr error pipeline, createErr = c.client.PipelineCreate(r.ID, &woodpecker.PipelineOptions{ Branch: branch, }) return createErr }) if err != nil { return 0, fmt.Errorf("failed to trigger build: %w", err) } c.logger.Info("build triggered", "repo", fullName, "branch", branch, "pipeline", pipeline.Number) return pipeline.Number, nil } // RetryPipeline restarts a failed or stopped pipeline. func (c *Client) RetryPipeline(ctx context.Context, owner, repo string, number int64) (*domain.CIPipeline, error) { select { case <-ctx.Done(): return nil, ctx.Err() default: } fullName := owner + "/" + repo r, err := c.client.RepoLookup(fullName) if err != nil { return nil, fmt.Errorf("repo not found %s: %w", fullName, err) } // Restart the pipeline using PipelineStart (with circuit breaker protection) var pipeline *woodpecker.Pipeline err = c.executeWithCircuitBreaker(func() error { var startErr error pipeline, startErr = c.client.PipelineStart(r.ID, number, woodpecker.PipelineStartOptions{}) return startErr }) if err != nil { return nil, fmt.Errorf("failed to retry pipeline %d: %w", number, err) } c.logger.Info("pipeline retried", "repo", fullName, "pipeline", number, "new_status", pipeline.Status) return pipelineFromWoodpecker(pipeline), nil } // pipelineFromWoodpecker converts a woodpecker.Pipeline to domain.CIPipeline. func pipelineFromWoodpecker(p *woodpecker.Pipeline) *domain.CIPipeline { var started, finished time.Time if p.Started > 0 { started = time.Unix(p.Started, 0) } if p.Finished > 0 { finished = time.Unix(p.Finished, 0) } // Map pipeline errors var errors []domain.CIPipelineError for _, e := range p.Errors { if e != nil { errors = append(errors, domain.CIPipelineError{ Type: e.Type, Message: e.Message, IsWarning: e.IsWarning, }) } } return &domain.CIPipeline{ ID: p.ID, Number: p.Number, Status: p.Status, Event: p.Event, Branch: p.Branch, Commit: p.Commit, Message: p.Message, Author: p.Author, Started: started, Finished: finished, Errors: errors, } } // formatLogEntries combines log entries and returns the last N lines. func formatLogEntries(entries []*woodpecker.LogEntry, maxLines int) string { if len(entries) == 0 { return "" } // Collect all lines var lines []string for _, entry := range entries { if entry != nil && len(entry.Data) > 0 { // Data is []byte, convert to string and split data := string(entry.Data) entryLines := strings.Split(data, "\n") for _, line := range entryLines { if line != "" { lines = append(lines, line) } } } } // Return last maxLines if len(lines) > maxLines { lines = lines[len(lines)-maxLines:] } return strings.Join(lines, "\n") }