Stitch any CI into Tangled
1// Package sourcehut is a minimal builds.sr.ht client used by the
2// sourcehut Provider implementation in tack. Job submission and
3// state queries go to GraphQL at /query; plain-text task logs are
4// fetched from /query/log/<id>[/<task>]/log.
5package sourcehut
6
7import (
8 "bytes"
9 "context"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "net/http"
15 "net/url"
16 "strings"
17 "time"
18)
19
20// DefaultBaseURL is the public builds.sr.ht endpoint. Operators
21// running their own sourcehut instance can override it on the Client.
22const DefaultBaseURL = "https://builds.sr.ht"
23
24// ErrNotFound is returned by Get* methods when the upstream returns
25// 404 (or, for GraphQL, when the requested job's `job` field comes
26// back null). The Provider maps this onto its own ErrLogsNotFound
27// for the /logs handler.
28var ErrNotFound = errors.New("sourcehut: not found")
29
30// ErrUnauthorized is returned by Get* methods when the upstream rejects
31// the request with 401/403 — most commonly because the configured
32// personal access token is missing the requisite scope (e.g. the log
33// endpoints require `builds.sr.ht/LOGS:RO`). It's distinct from
34// ErrNotFound so callers can surface a systemic auth misconfiguration
35// instead of silently treating it as "no logs yet".
36var ErrUnauthorized = errors.New("sourcehut: unauthorized")
37
38// Client is a thin wrapper around net/http carrying API credentials
39// and the target instance base URL. Safe for concurrent use; the
40// embedded http.Client is goroutine-safe.
41type Client struct {
42 http *http.Client
43 baseURL string
44 token string
45}
46
47// NewClient builds a Client targeting baseURL using the given OAuth2
48// personal access token. baseURL should be the scheme+host of the
49// builds.sr.ht instance (e.g. "https://builds.sr.ht"); pass empty to
50// fall back to DefaultBaseURL.
51func NewClient(baseURL, token string) *Client {
52 if baseURL == "" {
53 baseURL = DefaultBaseURL
54 }
55 return &Client{
56 http: &http.Client{Timeout: 30 * time.Second},
57 baseURL: strings.TrimRight(baseURL, "/"),
58 token: token,
59 }
60}
61
62// Owner is the small slice of the upstream owner object we care
63// about. CanonicalName carries the leading "~" (e.g. "~someone").
64type Owner struct {
65 CanonicalName string `json:"canonicalName"`
66}
67
68// Task is one step inside a sourcehut job.
69type Task struct {
70 Name string `json:"name"`
71 Status string `json:"status"`
72}
73
74// Job is the subset of the upstream job object we care about.
75type Job struct {
76 ID int64 `json:"id"`
77 Status string `json:"status"`
78 Note string `json:"note"`
79 Owner Owner `json:"owner"`
80 Tasks []Task `json:"tasks"`
81}
82
83// SubmitRequest mirrors the arguments of the GraphQL `submit`
84// mutation. Manifest is the YAML build manifest exactly as the user
85// would submit it via the web UI. Execute=true tells builds.sr.ht to
86// start the job immediately; Secrets controls whether the runner
87// mounts the user's configured secrets.
88type SubmitRequest struct {
89 Manifest string
90 Tags []string
91 Note string
92 Secrets bool
93 Execute bool
94}
95
96// gqlRequest / gqlResponse are the on-the-wire shapes for a single
97// GraphQL POST. We hand-roll them because the entire query surface we
98// touch is two operations — pulling in a real GraphQL library would
99// dwarf the call sites.
100type gqlRequest struct {
101 Query string `json:"query"`
102 Variables map[string]any `json:"variables,omitempty"`
103}
104
105type gqlError struct {
106 Message string `json:"message"`
107}
108
109type gqlResponse struct {
110 Data json.RawMessage `json:"data"`
111 Errors []gqlError `json:"errors"`
112}
113
114// SubmitJob fires a job via the GraphQL `submit` mutation and returns
115// the freshly created Job.
116//
117// The submit mutation's response is shallow — it doesn't include the
118// per-task list — so callers wanting a populated Tasks slice should
119// follow up with GetJob once the runner has scheduled the job. That
120// matches how the Provider already drives its watch loop.
121func (c *Client) SubmitJob(ctx context.Context, req SubmitRequest) (*Job, error) {
122 const query = `mutation($manifest:String!, $tags:[String!], $note:String, $execute:Boolean, $secrets:Boolean){
123 submit(manifest:$manifest, tags:$tags, note:$note, execute:$execute, secrets:$secrets){
124 id status note owner{ canonicalName }
125 }
126 }`
127 vars := map[string]any{
128 "manifest": req.Manifest,
129 "execute": req.Execute,
130 "secrets": req.Secrets,
131 }
132 if len(req.Tags) > 0 {
133 vars["tags"] = req.Tags
134 }
135 if req.Note != "" {
136 vars["note"] = req.Note
137 }
138
139 raw, err := c.gql(ctx, query, vars)
140 if err != nil {
141 return nil, err
142 }
143 var wrap struct {
144 Submit *Job `json:"submit"`
145 }
146 if err := json.Unmarshal(raw, &wrap); err != nil {
147 return nil, fmt.Errorf("decode submit response: %w", err)
148 }
149 if wrap.Submit == nil {
150 // Sourcehut returns `submit: null` with no errors when the
151 // manifest fails server-side validation in some edge cases;
152 // guard so the caller doesn't dereference nil.
153 return nil, errors.New("sourcehut: submit returned null job")
154 }
155 return wrap.Submit, nil
156}
157
158// GetJob fetches the current state of a job via GraphQL. Returns
159// ErrNotFound when the upstream `job(id:)` field resolves to null —
160// the usual signal for an unknown or deleted job.
161func (c *Client) GetJob(ctx context.Context, id int64) (*Job, error) {
162 const query = `query($id:Int!){
163 job(id:$id){
164 id status note owner{ canonicalName } tasks{ name status }
165 }
166 }`
167 raw, err := c.gql(ctx, query, map[string]any{"id": id})
168 if err != nil {
169 return nil, err
170 }
171 var wrap struct {
172 Job *Job `json:"job"`
173 }
174 if err := json.Unmarshal(raw, &wrap); err != nil {
175 return nil, fmt.Errorf("decode job response: %w", err)
176 }
177 if wrap.Job == nil {
178 return nil, ErrNotFound
179 }
180 return wrap.Job, nil
181}
182
183// GetTaskLog fetches the plain-text log for a single task inside a
184// job. taskName must match the task's `name` exactly; pass an empty
185// string to fetch the master log (the wrapper output that contains
186// setup steps before any task runs). Returns ErrNotFound on 404 —
187// common for a task that hasn't started yet, or a master log fetched
188// before the runner has produced output.
189func (c *Client) GetTaskLog(ctx context.Context, jobID int64, taskName string) (string, error) {
190 path := fmt.Sprintf("%s/query/log/%d/log", c.baseURL, jobID)
191 if taskName != "" {
192 path = fmt.Sprintf("%s/query/log/%d/%s/log",
193 c.baseURL, jobID, url.PathEscape(taskName),
194 )
195 }
196 httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
197 if err != nil {
198 return "", fmt.Errorf("build request: %w", err)
199 }
200 c.setAuth(httpReq)
201
202 resp, err := c.http.Do(httpReq)
203 if err != nil {
204 return "", fmt.Errorf("get task log: %w", err)
205 }
206 defer resp.Body.Close()
207 if resp.StatusCode == http.StatusNotFound {
208 return "", ErrNotFound
209 }
210 if resp.StatusCode == http.StatusUnauthorized ||
211 resp.StatusCode == http.StatusForbidden {
212 // Surface auth failures distinctly so callers can stop the
213 // log stream loudly rather than silently emitting empty
214 // steps for every task. The most common cause is a token
215 // missing `builds.sr.ht/LOGS:RO`.
216 raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
217 return "", fmt.Errorf("get task log: status %d: %s: %w",
218 resp.StatusCode, strings.TrimSpace(string(raw)),
219 ErrUnauthorized,
220 )
221 }
222 if resp.StatusCode != http.StatusOK {
223 raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
224 return "", fmt.Errorf("get task log: status %d: %s",
225 resp.StatusCode, strings.TrimSpace(string(raw)),
226 )
227 }
228 body, err := io.ReadAll(resp.Body)
229 if err != nil {
230 return "", fmt.Errorf("read task log: %w", err)
231 }
232 return string(body), nil
233}
234
235// JobWebURL renders the human-visible URL for a job.
236func (c *Client) JobWebURL(owner string, jobID int64) string {
237 return fmt.Sprintf("%s/%s/job/%d", c.baseURL, owner, jobID)
238}
239
240// gql posts a single GraphQL request and returns the raw `data`
241// payload. Errors from the upstream "errors" array are surfaced as a
242// single Go error so callers don't have to hand-decode them.
243func (c *Client) gql(ctx context.Context, query string, vars map[string]any) (json.RawMessage, error) {
244 body, err := json.Marshal(gqlRequest{Query: query, Variables: vars})
245 if err != nil {
246 return nil, fmt.Errorf("marshal gql request: %w", err)
247 }
248 httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
249 c.baseURL+"/query", bytes.NewReader(body),
250 )
251 if err != nil {
252 return nil, fmt.Errorf("build request: %w", err)
253 }
254 c.setAuth(httpReq)
255 httpReq.Header.Set("Content-Type", "application/json")
256
257 resp, err := c.http.Do(httpReq)
258 if err != nil {
259 return nil, fmt.Errorf("post gql: %w", err)
260 }
261 defer resp.Body.Close()
262 if resp.StatusCode != http.StatusOK {
263 raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
264 return nil, fmt.Errorf("gql: status %d: %s",
265 resp.StatusCode, strings.TrimSpace(string(raw)),
266 )
267 }
268 var out gqlResponse
269 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
270 return nil, fmt.Errorf("decode gql response: %w", err)
271 }
272 if len(out.Errors) > 0 {
273 msgs := make([]string, 0, len(out.Errors))
274 for _, e := range out.Errors {
275 msgs = append(msgs, e.Message)
276 }
277 return nil, fmt.Errorf("gql errors: %s", strings.Join(msgs, "; "))
278 }
279 return out.Data, nil
280}
281
282// setAuth applies the OAuth2 Bearer header builds.sr.ht expects.
283func (c *Client) setAuth(req *http.Request) {
284 if c.token != "" {
285 req.Header.Set("Authorization", "Bearer "+c.token)
286 }
287}