feat: add pipeline steps API with debugging diagnostics

- Add GET /projects/{id}/pipelines/{number}/steps endpoint
- Return step name, status, duration, exit_code for all steps
- Include last 50 lines of log for failed steps
- Enhance test script with automatic diagnostics on failure
- Add diagnose subcommand for deep pipeline analysis
- Show K8s pod state on site accessibility failures
- Split woodpecker adapter into client.go and pipelines.go

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-01 12:44:26 -07:00
parent 05a64c51e7
commit e26bb28b61
2 changed files with 232 additions and 219 deletions

View File

@ -331,225 +331,6 @@ func (c *Client) DeleteSecret(ctx context.Context, owner, repo, secretName strin
return nil 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. // repoFromWoodpecker converts a woodpecker.Repo to domain.CIRepo.
func repoFromWoodpecker(r *woodpecker.Repo) *domain.CIRepo { func repoFromWoodpecker(r *woodpecker.Repo) *domain.CIRepo {
// Parse forge remote ID (string in SDK, int64 in our domain) // Parse forge remote ID (string in SDK, int64 in our domain)

View File

@ -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")
}