tenseleyflow/shithub / 06ca804

Browse files

web/repo: expose actions runs atom feed (S41h)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
06ca804467ba1f50198d566d413433ca2d90cf00
Parents
29771c7
Tree
11ab9ab

6 changed files

StatusFile+-
M docs/internal/actions-schema.md 23 2
M docs/public/SUMMARY.md 2 0
M docs/public/api/actions.md 13 0
A internal/web/handlers/repo/actions_atom.go 123 0
A internal/web/handlers/repo/actions_atom_test.go 79 0
M internal/web/handlers/repo/repo.go 1 0
docs/internal/actions-schema.mdmodified
@@ -562,6 +562,29 @@ defer to S41g where the lifecycle work touches that surface anyway.
562562
   ref defaults to the repo's default branch). Returns 204 No Content
563563
   on success. Synchronous trigger.Enqueue (no discovery — file is
564564
   named in the URL). Auth: requires repo write.
565
+- `GET /{owner}/{repo}/actions.atom`
566
+  Returns the last 50 workflow runs as an Atom feed. Auth and visibility
567
+  match the Actions tab (`repo:read`). Entries link to
568
+  `/{owner}/{repo}/actions/runs/{run_index}` and include the workflow
569
+  name/path, event, branch, short SHA, status, and conclusion.
570
+
571
+### Webhook events (S41h)
572
+
573
+Actions emits webhook-facing domain events through `notif.EmitTx` on
574
+state transitions:
575
+
576
+- `workflow_run`, with `payload.action` set to `queued`, `running`, or
577
+  `completed` (`completed` may carry `conclusion:"cancelled"`).
578
+- `workflow_job`, with `payload.action` set to `queued`, `running`,
579
+  `completed`, or `cancelled`.
580
+
581
+Payloads are structural snapshots only. They include ids, run index,
582
+workflow path/name, head SHA/ref, event kind, status, conclusion,
583
+timestamps, job key/name/runner id, needs, timeout, and cancellation
584
+state. They deliberately exclude `workflow_runs.event_payload`, env,
585
+permissions, logs, runner JWTs, and secret values. This keeps the
586
+webhook surface stable without turning arbitrary workflow input into
587
+subscriber-facing data.
565588
 
566589
 ### What S41b deliberately doesn't do
567590
 
@@ -572,8 +595,6 @@ defer to S41g where the lifecycle work touches that surface anyway.
572595
   but no caller produces them yet. S41b-2 adds the sweep + the
573596
   `robfig/cron/v3` dep + `shithubd-cron.service` wiring.
574597
 - External-PR triggers. Conservative collaborator gate above.
575
-- `workflow_run` webhook events. S41h adds the webhook event family
576
-  + atom feed.
577598
 
578599
 ## Secrets + variables settings surface (S41c)
579600
 
docs/public/SUMMARY.mdmodified
@@ -28,6 +28,8 @@
2828
 - [Issues](./api/issues.md)
2929
 - [Pull requests](./api/pulls.md)
3030
 - [Status checks](./api/checks.md)
31
+- [Actions workflow API](./api/actions.md)
32
+- [Actions runner API](./api/actions-runner.md)
3133
 - [Webhooks](./api/webhooks.md)
3234
 - [Search](./api/search.md)
3335
 - [Admin (site-admin only)](./api/admin.md)
docs/public/api/actions.mdmodified
@@ -4,6 +4,19 @@ Actions workflow lifecycle endpoints are PAT-authenticated and require
44
 `repo:write`. The token's user must also have write permission on the
55
 repository that owns the target run or job.
66
 
7
+## Runs Atom feed
8
+
9
+```text
10
+GET /{owner}/{repo}/actions.atom
11
+```
12
+
13
+Returns the last 50 workflow runs as `application/atom+xml`. Visibility
14
+matches the Actions tab: public repositories are public; private
15
+repositories require repository read access.
16
+
17
+Each entry links to the run page and summarizes workflow name, event,
18
+branch, commit, status, and conclusion.
19
+
720
 ## Cancel job
821
 
922
 ```text
internal/web/handlers/repo/actions_atom.goadded
@@ -0,0 +1,123 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"encoding/xml"
7
+	"fmt"
8
+	"io"
9
+	"net/http"
10
+	"strconv"
11
+	"strings"
12
+	"time"
13
+
14
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
15
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
16
+)
17
+
18
+const actionsAtomRunLimit = int32(50)
19
+
20
+func (h *Handlers) repoActionsAtom(w http.ResponseWriter, r *http.Request) {
21
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
22
+	if !ok {
23
+		return
24
+	}
25
+	runs, err := actionsdb.New().ListWorkflowRunsForRepo(r.Context(), h.d.Pool, actionsdb.ListWorkflowRunsForRepoParams{
26
+		RepoID:     row.ID,
27
+		PageLimit:  actionsAtomRunLimit,
28
+		PageOffset: 0,
29
+	})
30
+	if err != nil {
31
+		h.d.Logger.WarnContext(r.Context(), "repo actions atom: list workflow runs", "repo_id", row.ID, "error", err)
32
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
33
+		return
34
+	}
35
+	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
36
+	writeActionsAtom(w, owner.Username, row.Name, runs, time.Now())
37
+}
38
+
39
+func writeActionsAtom(w io.Writer, owner, repoName string, runs []actionsdb.ListWorkflowRunsForRepoRow, now time.Time) {
40
+	type atomAuthor struct {
41
+		Name string `xml:"name"`
42
+	}
43
+	type atomEntry struct {
44
+		ID      string     `xml:"id"`
45
+		Title   string     `xml:"title"`
46
+		Updated string     `xml:"updated"`
47
+		Author  atomAuthor `xml:"author"`
48
+		Summary string     `xml:"summary"`
49
+		Link    struct {
50
+			Href string `xml:"href,attr"`
51
+			Rel  string `xml:"rel,attr,omitempty"`
52
+		} `xml:"link"`
53
+	}
54
+	type atomFeed struct {
55
+		XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
56
+		Title   string   `xml:"title"`
57
+		ID      string   `xml:"id"`
58
+		Updated string   `xml:"updated"`
59
+		Link    struct {
60
+			Href string `xml:"href,attr"`
61
+			Rel  string `xml:"rel,attr,omitempty"`
62
+		} `xml:"link"`
63
+		Entries []atomEntry `xml:"entry"`
64
+	}
65
+
66
+	feedUpdated := now.UTC()
67
+	if len(runs) > 0 {
68
+		feedUpdated = pgTime(runs[0].UpdatedAt, runs[0].CreatedAt.Time).UTC()
69
+	}
70
+	feed := atomFeed{
71
+		Title:   fmt.Sprintf("%s/%s Actions runs", owner, repoName),
72
+		ID:      fmt.Sprintf("urn:shithub:actions:%s:%s", owner, repoName),
73
+		Updated: feedUpdated.Format(time.RFC3339),
74
+	}
75
+	feed.Link.Href = fmt.Sprintf("/%s/%s/actions.atom", owner, repoName)
76
+	feed.Link.Rel = "self"
77
+
78
+	for _, run := range runs {
79
+		var e atomEntry
80
+		e.ID = fmt.Sprintf("urn:shithub:workflow_run:%d", run.ID)
81
+		e.Title = actionsAtomRunTitle(run)
82
+		e.Updated = pgTime(run.UpdatedAt, run.CreatedAt.Time).UTC().Format(time.RFC3339)
83
+		e.Author.Name = actionsAtomActor(run.ActorUsername)
84
+		e.Summary = actionsAtomRunSummary(run)
85
+		e.Link.Href = fmt.Sprintf("/%s/%s/actions/runs/%d", owner, repoName, run.RunIndex)
86
+		feed.Entries = append(feed.Entries, e)
87
+	}
88
+
89
+	enc := xml.NewEncoder(w)
90
+	enc.Indent("", "  ")
91
+	_, _ = io.WriteString(w, xml.Header)
92
+	_ = enc.Encode(feed)
93
+	_ = enc.Flush()
94
+}
95
+
96
+func actionsAtomRunTitle(run actionsdb.ListWorkflowRunsForRepoRow) string {
97
+	title := workflowDisplayName(run.WorkflowName, run.WorkflowFile)
98
+	state, _, _ := workflowRunState(run.Status, run.Conclusion)
99
+	return fmt.Sprintf("%s #%d %s", title, run.RunIndex, strings.ToLower(state))
100
+}
101
+
102
+func actionsAtomRunSummary(run actionsdb.ListWorkflowRunsForRepoRow) string {
103
+	parts := []string{
104
+		"Workflow: " + workflowDisplayName(run.WorkflowName, run.WorkflowFile),
105
+		"Event: " + workflowRunEventLabel(string(run.Event)),
106
+		"Status: " + string(run.Status),
107
+		"Branch: " + run.HeadRef,
108
+		"Commit: " + shortSHA(run.HeadSha),
109
+	}
110
+	if run.Conclusion.Valid {
111
+		parts = append(parts, "Conclusion: "+string(run.Conclusion.CheckConclusion))
112
+	}
113
+	parts = append(parts, "Run: #"+strconv.FormatInt(run.RunIndex, 10))
114
+	return strings.Join(parts, "\n")
115
+}
116
+
117
+func actionsAtomActor(username string) string {
118
+	username = strings.TrimSpace(username)
119
+	if username == "" {
120
+		return "shithub"
121
+	}
122
+	return username
123
+}
internal/web/handlers/repo/actions_atom_test.goadded
@@ -0,0 +1,79 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"bytes"
7
+	"encoding/xml"
8
+	"strings"
9
+	"testing"
10
+	"time"
11
+
12
+	"github.com/jackc/pgx/v5/pgtype"
13
+
14
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
15
+)
16
+
17
+func TestWriteActionsAtomEscapesRunsAndUsesLatestUpdate(t *testing.T) {
18
+	ts1 := pgtype.Timestamptz{Time: time.Date(2026, 5, 12, 10, 0, 0, 0, time.UTC), Valid: true}
19
+	ts2 := pgtype.Timestamptz{Time: time.Date(2026, 5, 12, 9, 0, 0, 0, time.UTC), Valid: true}
20
+	var buf bytes.Buffer
21
+	writeActionsAtom(&buf, "alice", "demo", []actionsdb.ListWorkflowRunsForRepoRow{
22
+		{
23
+			ID:            12,
24
+			RunIndex:      7,
25
+			WorkflowFile:  ".shithub/workflows/ci.yml",
26
+			WorkflowName:  "CI <release>",
27
+			HeadSha:       strings.Repeat("a", 40),
28
+			HeadRef:       "refs/heads/trunk",
29
+			Event:         actionsdb.WorkflowRunEventPush,
30
+			Status:        actionsdb.WorkflowRunStatusCompleted,
31
+			Conclusion:    actionsdb.NullCheckConclusion{CheckConclusion: actionsdb.CheckConclusionSuccess, Valid: true},
32
+			ActorUsername: "dev<one>",
33
+			CreatedAt:     ts2,
34
+			UpdatedAt:     ts1,
35
+		},
36
+	}, time.Date(2026, 5, 12, 8, 0, 0, 0, time.UTC))
37
+
38
+	type feed struct {
39
+		XMLName xml.Name `xml:"feed"`
40
+		Title   string   `xml:"title"`
41
+		Updated string   `xml:"updated"`
42
+		Entries []struct {
43
+			Title   string `xml:"title"`
44
+			Updated string `xml:"updated"`
45
+			Author  struct {
46
+				Name string `xml:"name"`
47
+			} `xml:"author"`
48
+			Summary string `xml:"summary"`
49
+			Link    struct {
50
+				Href string `xml:"href,attr"`
51
+			} `xml:"link"`
52
+		} `xml:"entry"`
53
+	}
54
+	var got feed
55
+	if err := xml.Unmarshal(buf.Bytes(), &got); err != nil {
56
+		t.Fatalf("unmarshal atom: %v\n%s", err, buf.String())
57
+	}
58
+	if got.Title != "alice/demo Actions runs" {
59
+		t.Fatalf("title = %q", got.Title)
60
+	}
61
+	if got.Updated != "2026-05-12T10:00:00Z" {
62
+		t.Fatalf("updated = %q", got.Updated)
63
+	}
64
+	if len(got.Entries) != 1 {
65
+		t.Fatalf("entries = %d", len(got.Entries))
66
+	}
67
+	if got.Entries[0].Title != "CI <release> #7 success" {
68
+		t.Fatalf("entry title = %q", got.Entries[0].Title)
69
+	}
70
+	if got.Entries[0].Author.Name != "dev<one>" {
71
+		t.Fatalf("author = %q", got.Entries[0].Author.Name)
72
+	}
73
+	if got.Entries[0].Link.Href != "/alice/demo/actions/runs/7" {
74
+		t.Fatalf("link = %q", got.Entries[0].Link.Href)
75
+	}
76
+	if !strings.Contains(got.Entries[0].Summary, "Conclusion: success") {
77
+		t.Fatalf("summary = %q", got.Entries[0].Summary)
78
+	}
79
+}
internal/web/handlers/repo/repo.gomodified
@@ -144,6 +144,7 @@ func (h *Handlers) MountRepoActionsStreams(r chi.Router) {
144144
 // two-segment route doesn't collide with the /{username} catch-all from S09;
145145
 // caller is responsible for ordering this BEFORE /{username}.
146146
 func (h *Handlers) MountRepoHome(r chi.Router) {
147
+	r.Get("/{owner}/{repo}/actions.atom", h.repoActionsAtom)
147148
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", h.repoActionStepLog)
148149
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", h.repoActionRunStatus)
149150
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}", h.repoActionRun)