tenseleyflow/shithub / 3c4221b

Browse files

api/actions_workflows: list, get, and workflow_dispatch endpoints

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
3c4221b3f66f8bbb56fc4cc45569c4aaa3f85956
Parents
c2ec557
Tree
a02dc04

1 changed file

StatusFile+-
A internal/web/handlers/api/actions_workflows.go 357 0
internal/web/handlers/api/actions_workflows.goadded
@@ -0,0 +1,357 @@
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 {
185
+		writeAPIError(w, http.StatusNotFound, fmt.Sprintf("workflow file %q not found at ref %s", file, branch))
186
+		return
187
+	}
188
+	wf, diags, err := workflow.Parse(bytes)
189
+	if err != nil {
190
+		writeAPIError(w, http.StatusBadRequest, "workflow parse: "+err.Error())
191
+		return
192
+	}
193
+	for _, d := range diags {
194
+		if d.Severity == workflow.Error {
195
+			writeAPIError(w, http.StatusBadRequest, "workflow has Error diagnostics: "+d.String())
196
+			return
197
+		}
198
+	}
199
+	if wf.On.WorkflowDispatch == nil {
200
+		writeAPIError(w, http.StatusBadRequest, "workflow does not declare on.workflow_dispatch")
201
+		return
202
+	}
203
+	inputs, err := dispatch.NormalizeInputs(body.Inputs, wf.On.WorkflowDispatch.Inputs)
204
+	if err != nil {
205
+		writeAPIError(w, http.StatusBadRequest, err.Error())
206
+		return
207
+	}
208
+
209
+	requestID, err := randHex(8)
210
+	if err != nil {
211
+		h.d.Logger.ErrorContext(r.Context(), "api: workflow dispatch rand", "error", err)
212
+		writeAPIError(w, http.StatusInternalServerError, "internal error")
213
+		return
214
+	}
215
+	triggerID := fmt.Sprintf("dispatch:%s:%s:%s", file, headSHA, requestID)
216
+	auth := middleware.PATAuthFromContext(r.Context())
217
+
218
+	if _, err := trigger.Enqueue(r.Context(), trigger.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, trigger.EnqueueParams{
219
+		RepoID:         repo.ID,
220
+		WorkflowFile:   file,
221
+		HeadSHA:        headSHA,
222
+		HeadRef:        "refs/heads/" + branch,
223
+		EventKind:      trigger.EventWorkflowDispatch,
224
+		EventPayload:   actionsevent.WorkflowDispatch(inputs),
225
+		ActorUserID:    auth.UserID,
226
+		TriggerEventID: triggerID,
227
+		Workflow:       wf,
228
+	}); err != nil {
229
+		h.d.Logger.ErrorContext(r.Context(), "api: workflow dispatch enqueue", "error", err)
230
+		writeAPIError(w, http.StatusInternalServerError, "enqueue failed")
231
+		return
232
+	}
233
+	w.WriteHeader(http.StatusNoContent)
234
+}
235
+
236
+type dispatchAPIRequest struct {
237
+	Ref    string            `json:"ref,omitempty"`
238
+	Inputs map[string]string `json:"inputs,omitempty"`
239
+}
240
+
241
+// matchWorkflow finds a discovered file whose basename (or full path)
242
+// matches `param`. `param` may be the basename (`ci.yml`), the full
243
+// path (`.shithub/workflows/ci.yml`), or the FNV-derived numeric id
244
+// from the list response.
245
+func matchWorkflow(files []discoveredWorkflow, param string) (discoveredWorkflow, bool) {
246
+	param = strings.TrimSpace(param)
247
+	if param == "" {
248
+		return discoveredWorkflow{}, false
249
+	}
250
+	// Numeric id path.
251
+	if id, err := parseInt64(param); err == nil {
252
+		for _, f := range files {
253
+			if workflowIDFromPath(f.Path) == id {
254
+				return f, true
255
+			}
256
+		}
257
+	}
258
+	// Path / basename path.
259
+	want := param
260
+	if !strings.HasPrefix(want, dispatch.WorkflowFilesDir) {
261
+		want = dispatch.WorkflowFilesDir + want
262
+	}
263
+	for _, f := range files {
264
+		if f.Path == want {
265
+			return f, true
266
+		}
267
+	}
268
+	return discoveredWorkflow{}, false
269
+}
270
+
271
+// resolveWorkflowFile maps a {workflow} URL param (basename, full
272
+// path, or numeric id) to the canonical full path. Numeric ids
273
+// require a discovery walk; this function is the basename/full-path
274
+// fast path used by the dispatch handler (which would otherwise need
275
+// a Discover call before parsing the file we're about to dispatch).
276
+func resolveWorkflowFile(param string) (string, error) {
277
+	// Numeric ids aren't accepted on the dispatch path — clients
278
+	// who want to dispatch by id must hit the list endpoint first to
279
+	// resolve the path. This keeps dispatch a single round-trip when
280
+	// the caller already knows the file name (the common case).
281
+	if _, err := parseInt64(param); err == nil {
282
+		return "", dispatch.ErrInvalidWorkflowName
283
+	}
284
+	return dispatch.NormalizeFilePath(param)
285
+}
286
+
287
+func parseInt64(s string) (int64, error) {
288
+	var out int64
289
+	for i := 0; i < len(s); i++ {
290
+		c := s[i]
291
+		if c < '0' || c > '9' {
292
+			return 0, fmt.Errorf("not a number: %q", s)
293
+		}
294
+		out = out*10 + int64(c-'0')
295
+	}
296
+	if len(s) == 0 {
297
+		return 0, fmt.Errorf("empty")
298
+	}
299
+	return out, nil
300
+}
301
+
302
+func presentWorkflow(f discoveredWorkflow) workflowResponse {
303
+	return workflowResponse{
304
+		ID:    workflowIDFromPath(f.Path),
305
+		Name:  f.Name,
306
+		Path:  f.Path,
307
+		File:  strings.TrimPrefix(f.Path, dispatch.WorkflowFilesDir),
308
+		State: "active",
309
+	}
310
+}
311
+
312
+type discoveredWorkflow struct {
313
+	Path string
314
+	Name string // workflow.name, or basename without extension if unset
315
+}
316
+
317
+// discoverWorkflows walks `.shithub/workflows/` at the given ref's
318
+// HEAD, parsing each found file to extract its `name:` for the
319
+// response. Files that fail to parse are still returned (with their
320
+// basename as the name) so the listing reflects actual ground truth
321
+// rather than silently dropping broken workflows.
322
+func (h *Handlers) discoverWorkflows(r *http.Request, repo *reposdb.Repo, ref string) ([]discoveredWorkflow, []string, error) {
323
+	branch := strings.TrimPrefix(ref, "refs/heads/")
324
+	gitDir, err := h.repoGitDir(r.Context(), repo)
325
+	if err != nil {
326
+		return nil, nil, fmt.Errorf("repo path: %w", err)
327
+	}
328
+	headSHA, err := repogit.ResolveRefOID(r.Context(), gitDir, branch)
329
+	if err != nil {
330
+		return nil, nil, err
331
+	}
332
+	files, skips, err := trigger.Discover(r.Context(), gitDir, headSHA)
333
+	if err != nil {
334
+		return nil, nil, err
335
+	}
336
+	out := make([]discoveredWorkflow, 0, len(files))
337
+	for _, f := range files {
338
+		name := strings.TrimSuffix(strings.TrimSuffix(strings.TrimPrefix(f.Path, dispatch.WorkflowFilesDir), ".yml"), ".yaml")
339
+		if wf, _, err := workflow.Parse(f.Bytes); err == nil && wf.Name != "" {
340
+			name = wf.Name
341
+		}
342
+		out = append(out, discoveredWorkflow{Path: f.Path, Name: name})
343
+	}
344
+	skipPaths := make([]string, 0, len(skips))
345
+	for _, s := range skips {
346
+		skipPaths = append(skipPaths, s.Path)
347
+	}
348
+	return out, skipPaths, nil
349
+}
350
+
351
+func randHex(n int) (string, error) {
352
+	b := make([]byte, n)
353
+	if _, err := rand.Read(b); err != nil {
354
+		return "", err
355
+	}
356
+	return hex.EncodeToString(b), nil
357
+}