fix(run): show logs past jobs that have not started

run logs stopped at the first job without logs with a misleading not-found
error. Report such a job as "build (job 35): not started yet" (or that it
never started when it was canceled or skipped first) and continue with the
next job. Jobs report started_at and completed_at, null until they happen.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:46:26 +02:00
1 parent 68da491b9e
commit b09c1aab4a
3 files changed
+94 -16

No files matched your search

+36 -9
View File
@@ -11,19 +11,23 @@ import (
"fmt"
"io"
"net/http"
"strings"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport"
)
// Job identifies one workflow job without exposing server-specific metadata.
// Job identifies one workflow job without exposing server-specific metadata. StartedAt and
// CompletedAt are nil until the job starts and completes.
type Job struct {
RunAttempt int64 `json:"run_attempt"`
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
RunAttempt int64 `json:"run_attempt"`
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
StartedAt *string `json:"started_at"`
CompletedAt *string `json:"completed_at"`
}
// Jobs lists every page of jobs in the selected run.
@@ -66,13 +70,35 @@ func (s Actions) jobsPage(ctx context.Context, run int64, limit, page int) ([]Jo
}
func jobFrom(job *gitea.ActionsWorkflowJob) Job {
return Job{RunAttempt: job.RunAttempt, ID: job.ID, Name: job.Name, Status: job.Status, Conclusion: job.Conclusion}
return Job{
RunAttempt: job.RunAttempt, ID: job.ID, Name: job.Name, Status: job.Status, Conclusion: job.Conclusion,
StartedAt: actionTime(job.StartedAt), CompletedAt: actionTime(job.CompletedAt),
}
}
// JobLogs streams a job's plain-text logs within the selected instance.
// actionTime is runTime for SDK times.
func actionTime(t time.Time) *string {
if t.Unix() <= 0 {
return nil
}
value := timestamp(t)
return &value
}
// Started reports whether the job has started, so that it has logs.
func (j Job) Started() bool { return j.StartedAt != nil }
// ErrJobNotStarted reports a job without logs because no runner has started it.
var ErrJobNotStarted = errors.New("job not started")
// JobLogs streams a job's plain-text logs within the selected instance. A job that has not
// started fails with ErrJobNotStarted.
func (s Actions) JobLogs(ctx context.Context, id int64) (io.ReadCloser, error) {
// TODO(sdk): GetRepoRunJobLogs buffers the whole log; a reader variant would allow streaming.
response, err := s.Client.Do(ctx, http.MethodGet, fmt.Sprintf("%sjobs/%d/logs", s.endpoint(), id), nil, nil)
if api, ok := errors.AsType[*transport.APIError](err); ok && api.Status == http.StatusNotFound && strings.Contains(api.Message, "not started") {
return nil, fmt.Errorf("job %d: %w", id, ErrJobNotStarted)
}
if err != nil {
return nil, err
}
@@ -109,7 +135,8 @@ func (s Actions) FollowJobLogs(ctx context.Context, run, id int64, interval time
attempt = current.RunAttempt
logs, err := s.JobLogs(ctx, id)
if err != nil {
if api, ok := errors.AsType[*transport.APIError](err); ok && api.Status == http.StatusNotFound && offset == 0 && (current.Status == "queued" || current.Status == "waiting" || current.Status == "pending") {
api, ok := errors.AsType[*transport.APIError](err)
if offset == 0 && !completedActionStatus(current.Status) && (errors.Is(err, ErrJobNotStarted) || ok && api.Status == http.StatusNotFound) {
if err := sleepCtx(ctx, interval); err != nil {
return err
}