Stitch any CI into Tangled
2

Configure Feed

Select the types of activity you want to include in your feed.

at main 9.4 kB View raw
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}