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:
parent
05a64c51e7
commit
e26bb28b61
@ -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)
|
||||||
|
|||||||
232
internal/adapter/woodpecker/pipelines.go
Normal file
232
internal/adapter/woodpecker/pipelines.go
Normal 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")
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user