diff --git a/internal/adapter/woodpecker/client.go b/internal/adapter/woodpecker/client.go index e4bc059..8f90b74 100644 --- a/internal/adapter/woodpecker/client.go +++ b/internal/adapter/woodpecker/client.go @@ -331,225 +331,6 @@ func (c *Client) DeleteSecret(ctx context.Context, owner, repo, secretName strin return nil } -// 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", fullName) - } - - 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", fullName) - } - - 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 -} - -// 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, - } -} - -// 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", fullName) - } - - 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 -} - -// 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") -} - -// 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", fullName) - } - - // Create a new pipeline for the branch - pipeline, err := c.client.PipelineCreate(r.ID, &woodpecker.PipelineOptions{ - Branch: branch, - }) - 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 -} - // repoFromWoodpecker converts a woodpecker.Repo to domain.CIRepo. func repoFromWoodpecker(r *woodpecker.Repo) *domain.CIRepo { // Parse forge remote ID (string in SDK, int64 in our domain) diff --git a/internal/adapter/woodpecker/pipelines.go b/internal/adapter/woodpecker/pipelines.go new file mode 100644 index 0000000..e1a186e --- /dev/null +++ b/internal/adapter/woodpecker/pipelines.go @@ -0,0 +1,232 @@ +// 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", fullName) + } + + 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", fullName) + } + + 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", fullName) + } + + 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", fullName) + } + + // Create a new pipeline for the branch + pipeline, err := c.client.PipelineCreate(r.ID, &woodpecker.PipelineOptions{ + Branch: branch, + }) + 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 +} + +// 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") +}