Go · 12249 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package api
4
5 import (
6 "crypto/rand"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "net/http"
12 "sort"
13 "strings"
14
15 "github.com/go-chi/chi/v5"
16
17 "github.com/tenseleyFlow/shithub/internal/actions/dispatch"
18 actionsevent "github.com/tenseleyFlow/shithub/internal/actions/event"
19 "github.com/tenseleyFlow/shithub/internal/actions/trigger"
20 "github.com/tenseleyFlow/shithub/internal/actions/workflow"
21 "github.com/tenseleyFlow/shithub/internal/auth/pat"
22 "github.com/tenseleyFlow/shithub/internal/auth/policy"
23 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
24 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
25 "github.com/tenseleyFlow/shithub/internal/web/middleware"
26 )
27
28 // mountActionsWorkflows registers the S50 §13 workflow REST surface.
29 //
30 // GET /api/v1/repos/{owner}/{repo}/actions/workflows list workflows at default-branch HEAD
31 // GET /api/v1/repos/{owner}/{repo}/actions/workflows/{id_or_file} single workflow
32 // POST /api/v1/repos/{owner}/{repo}/actions/workflows/{id_or_file}/dispatches workflow_dispatch
33 //
34 // Scopes: `repo:read` on the GETs, `repo:write` on dispatch. Mirrors
35 // gh's contract; the dispatch path reuses the shared
36 // `internal/actions/dispatch` validation so the HTML and REST
37 // surfaces share semantics verbatim.
38 //
39 // Workflows have no `workflows` table in shithub — they're discovered
40 // at request time by walking `.shithub/workflows/` at the repo's
41 // default-branch HEAD (or the `?ref=` override). `{id_or_file}` is
42 // matched against the basename of the discovered file path.
43 //
44 // Enable/disable knobs are deferred to a follow-up PR (needs a new
45 // `workflow_disabled` table). Every listed workflow is reported as
46 // `state: "active"` for now.
47 func (h *Handlers) mountActionsWorkflows(r chi.Router) {
48 r.Group(func(r chi.Router) {
49 r.Use(middleware.RequireScope(pat.ScopeRepoRead))
50 r.Get("/api/v1/repos/{owner}/{repo}/actions/workflows", h.actionsWorkflowsList)
51 r.Get("/api/v1/repos/{owner}/{repo}/actions/workflows/{workflow}", h.actionsWorkflowsGet)
52 })
53 r.Group(func(r chi.Router) {
54 r.Use(middleware.RequireScope(pat.ScopeRepoWrite))
55 r.Post("/api/v1/repos/{owner}/{repo}/actions/workflows/{workflow}/dispatches", h.actionsWorkflowsDispatch)
56 })
57 }
58
59 type workflowResponse struct {
60 // ID is a stable identifier within a repo derived from the file
61 // path. Mirrors gh's numeric `id` shape using a 64-bit FNV hash of
62 // the path so clients can round-trip it back as `{id_or_file}`;
63 // the path itself is the canonical identifier.
64 ID int64 `json:"id"`
65 Name string `json:"name"`
66 Path string `json:"path"`
67 File string `json:"file"`
68 State string `json:"state"`
69 }
70
71 // workflowIDFromPath is a stable, deterministic 64-bit id derived from
72 // the workflow's repo-relative path. We don't persist this — it's
73 // just an alternate addressing form for the `{id_or_file}` path
74 // parameter so gh-shaped clients work without modification.
75 func workflowIDFromPath(path string) int64 {
76 const (
77 offset = 1469598103934665603
78 prime = 1099511628211
79 )
80 h := uint64(offset)
81 for i := 0; i < len(path); i++ {
82 h ^= uint64(path[i])
83 h *= prime
84 }
85 // Shift to a positive int64 so the JSON doesn't carry a negative.
86 return int64(h >> 1)
87 }
88
89 func (h *Handlers) actionsWorkflowsList(w http.ResponseWriter, r *http.Request) {
90 repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
91 if !ok {
92 return
93 }
94 ref := strings.TrimSpace(r.URL.Query().Get("ref"))
95 if ref == "" {
96 ref = repo.DefaultBranch
97 }
98 files, _, err := h.discoverWorkflows(r, repo, ref)
99 if err != nil {
100 if errors.Is(err, repogit.ErrRefNotFound) {
101 writeAPIError(w, http.StatusNotFound, "ref not found")
102 return
103 }
104 h.d.Logger.ErrorContext(r.Context(), "api: discover workflows", "error", err)
105 writeAPIError(w, http.StatusInternalServerError, "list failed")
106 return
107 }
108 out := make([]workflowResponse, 0, len(files))
109 for _, f := range files {
110 out = append(out, presentWorkflow(f))
111 }
112 sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
113 writeJSON(w, http.StatusOK, out)
114 }
115
116 func (h *Handlers) actionsWorkflowsGet(w http.ResponseWriter, r *http.Request) {
117 repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
118 if !ok {
119 return
120 }
121 param := chi.URLParam(r, "workflow")
122 ref := strings.TrimSpace(r.URL.Query().Get("ref"))
123 if ref == "" {
124 ref = repo.DefaultBranch
125 }
126 files, _, err := h.discoverWorkflows(r, repo, ref)
127 if err != nil {
128 if errors.Is(err, repogit.ErrRefNotFound) {
129 writeAPIError(w, http.StatusNotFound, "ref not found")
130 return
131 }
132 h.d.Logger.ErrorContext(r.Context(), "api: discover workflows", "error", err)
133 writeAPIError(w, http.StatusInternalServerError, "lookup failed")
134 return
135 }
136 match, found := matchWorkflow(files, param)
137 if !found {
138 writeAPIError(w, http.StatusNotFound, "workflow not found")
139 return
140 }
141 writeJSON(w, http.StatusOK, presentWorkflow(match))
142 }
143
144 func (h *Handlers) actionsWorkflowsDispatch(w http.ResponseWriter, r *http.Request) {
145 repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoWrite)
146 if !ok {
147 return
148 }
149 param := chi.URLParam(r, "workflow")
150 file, err := resolveWorkflowFile(param)
151 if err != nil {
152 writeAPIError(w, http.StatusBadRequest, "invalid workflow file path")
153 return
154 }
155
156 var body dispatchAPIRequest
157 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&body); err != nil {
158 writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
159 return
160 }
161
162 ref := strings.TrimSpace(body.Ref)
163 if ref == "" {
164 ref = repo.DefaultBranch
165 }
166 branch := strings.TrimPrefix(ref, "refs/heads/")
167
168 gitDir, err := h.repoGitDir(r.Context(), repo)
169 if err != nil {
170 h.d.Logger.ErrorContext(r.Context(), "api: workflow dispatch repo path", "error", err)
171 writeAPIError(w, http.StatusInternalServerError, "internal error")
172 return
173 }
174 headSHA, err := repogit.ResolveRefOID(r.Context(), gitDir, branch)
175 if err != nil {
176 if errors.Is(err, repogit.ErrRefNotFound) {
177 writeAPIError(w, http.StatusNotFound, "ref "+branch+" not found")
178 return
179 }
180 writeAPIError(w, http.StatusBadRequest, "could not resolve ref")
181 return
182 }
183 bytes, err := repogit.ReadBlobBytes(r.Context(), gitDir, headSHA, file, int64(workflow.MaxWorkflowFileBytes))
184 if err != nil || len(bytes) == 0 {
185 // ReadBlobBytes silently returns (nil, nil) when the path is
186 // absent at the ref (git cat-file's stderr is discarded). An
187 // empty body is therefore indistinguishable from a missing
188 // file, which is the correct caller-facing answer either way.
189 writeAPIError(w, http.StatusNotFound, fmt.Sprintf("workflow file %q not found at ref %s", file, branch))
190 return
191 }
192 wf, diags, err := workflow.Parse(bytes)
193 if err != nil {
194 writeAPIError(w, http.StatusBadRequest, "workflow parse: "+err.Error())
195 return
196 }
197 for _, d := range diags {
198 if d.Severity == workflow.Error {
199 writeAPIError(w, http.StatusBadRequest, "workflow has Error diagnostics: "+d.String())
200 return
201 }
202 }
203 if wf.On.WorkflowDispatch == nil {
204 writeAPIError(w, http.StatusBadRequest, "workflow does not declare on.workflow_dispatch")
205 return
206 }
207 inputs, err := dispatch.NormalizeInputs(body.Inputs, wf.On.WorkflowDispatch.Inputs)
208 if err != nil {
209 writeAPIError(w, http.StatusBadRequest, err.Error())
210 return
211 }
212
213 requestID, err := randHex(8)
214 if err != nil {
215 h.d.Logger.ErrorContext(r.Context(), "api: workflow dispatch rand", "error", err)
216 writeAPIError(w, http.StatusInternalServerError, "internal error")
217 return
218 }
219 triggerID := fmt.Sprintf("dispatch:%s:%s:%s", file, headSHA, requestID)
220 auth := middleware.PATAuthFromContext(r.Context())
221
222 if _, err := trigger.Enqueue(r.Context(), trigger.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, trigger.EnqueueParams{
223 RepoID: repo.ID,
224 WorkflowFile: file,
225 HeadSHA: headSHA,
226 HeadRef: "refs/heads/" + branch,
227 EventKind: trigger.EventWorkflowDispatch,
228 EventPayload: actionsevent.WorkflowDispatch(inputs),
229 ActorUserID: auth.UserID,
230 TriggerEventID: triggerID,
231 Workflow: wf,
232 }); err != nil {
233 h.d.Logger.ErrorContext(r.Context(), "api: workflow dispatch enqueue", "error", err)
234 writeAPIError(w, http.StatusInternalServerError, "enqueue failed")
235 return
236 }
237 w.WriteHeader(http.StatusNoContent)
238 }
239
240 type dispatchAPIRequest struct {
241 Ref string `json:"ref,omitempty"`
242 Inputs map[string]string `json:"inputs,omitempty"`
243 }
244
245 // matchWorkflow finds a discovered file whose basename (or full path)
246 // matches `param`. `param` may be the basename (`ci.yml`), the full
247 // path (`.shithub/workflows/ci.yml`), or the FNV-derived numeric id
248 // from the list response.
249 func matchWorkflow(files []discoveredWorkflow, param string) (discoveredWorkflow, bool) {
250 param = strings.TrimSpace(param)
251 if param == "" {
252 return discoveredWorkflow{}, false
253 }
254 // Numeric id path.
255 if id, err := parseInt64(param); err == nil {
256 for _, f := range files {
257 if workflowIDFromPath(f.Path) == id {
258 return f, true
259 }
260 }
261 }
262 // Path / basename path.
263 want := param
264 if !strings.HasPrefix(want, dispatch.WorkflowFilesDir) {
265 want = dispatch.WorkflowFilesDir + want
266 }
267 for _, f := range files {
268 if f.Path == want {
269 return f, true
270 }
271 }
272 return discoveredWorkflow{}, false
273 }
274
275 // resolveWorkflowFile maps a {workflow} URL param (basename, full
276 // path, or numeric id) to the canonical full path. Numeric ids
277 // require a discovery walk; this function is the basename/full-path
278 // fast path used by the dispatch handler (which would otherwise need
279 // a Discover call before parsing the file we're about to dispatch).
280 func resolveWorkflowFile(param string) (string, error) {
281 // Numeric ids aren't accepted on the dispatch path — clients
282 // who want to dispatch by id must hit the list endpoint first to
283 // resolve the path. This keeps dispatch a single round-trip when
284 // the caller already knows the file name (the common case).
285 if _, err := parseInt64(param); err == nil {
286 return "", dispatch.ErrInvalidWorkflowName
287 }
288 return dispatch.NormalizeFilePath(param)
289 }
290
291 func parseInt64(s string) (int64, error) {
292 var out int64
293 for i := 0; i < len(s); i++ {
294 c := s[i]
295 if c < '0' || c > '9' {
296 return 0, fmt.Errorf("not a number: %q", s)
297 }
298 out = out*10 + int64(c-'0')
299 }
300 if len(s) == 0 {
301 return 0, fmt.Errorf("empty")
302 }
303 return out, nil
304 }
305
306 func presentWorkflow(f discoveredWorkflow) workflowResponse {
307 return workflowResponse{
308 ID: workflowIDFromPath(f.Path),
309 Name: f.Name,
310 Path: f.Path,
311 File: strings.TrimPrefix(f.Path, dispatch.WorkflowFilesDir),
312 State: "active",
313 }
314 }
315
316 type discoveredWorkflow struct {
317 Path string
318 Name string // workflow.name, or basename without extension if unset
319 }
320
321 // discoverWorkflows walks `.shithub/workflows/` at the given ref's
322 // HEAD, parsing each found file to extract its `name:` for the
323 // response. Files that fail to parse are still returned (with their
324 // basename as the name) so the listing reflects actual ground truth
325 // rather than silently dropping broken workflows.
326 func (h *Handlers) discoverWorkflows(r *http.Request, repo *reposdb.Repo, ref string) ([]discoveredWorkflow, []string, error) {
327 branch := strings.TrimPrefix(ref, "refs/heads/")
328 gitDir, err := h.repoGitDir(r.Context(), repo)
329 if err != nil {
330 return nil, nil, fmt.Errorf("repo path: %w", err)
331 }
332 headSHA, err := repogit.ResolveRefOID(r.Context(), gitDir, branch)
333 if err != nil {
334 return nil, nil, err
335 }
336 files, skips, err := trigger.Discover(r.Context(), gitDir, headSHA)
337 if err != nil {
338 return nil, nil, err
339 }
340 out := make([]discoveredWorkflow, 0, len(files))
341 for _, f := range files {
342 name := strings.TrimSuffix(strings.TrimSuffix(strings.TrimPrefix(f.Path, dispatch.WorkflowFilesDir), ".yml"), ".yaml")
343 if wf, _, err := workflow.Parse(f.Bytes); err == nil && wf.Name != "" {
344 name = wf.Name
345 }
346 out = append(out, discoveredWorkflow{Path: f.Path, Name: name})
347 }
348 skipPaths := make([]string, 0, len(skips))
349 for _, s := range skips {
350 skipPaths = append(skipPaths, s.Path)
351 }
352 return out, skipPaths, nil
353 }
354
355 func randHex(n int) (string, error) {
356 b := make([]byte, n)
357 if _, err := rand.Read(b); err != nil {
358 return "", err
359 }
360 return hex.EncodeToString(b), nil
361 }
362