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.
562
   ref defaults to the repo's default branch). Returns 204 No Content
562
   ref defaults to the repo's default branch). Returns 204 No Content
563
   on success. Synchronous trigger.Enqueue (no discovery — file is
563
   on success. Synchronous trigger.Enqueue (no discovery — file is
564
   named in the URL). Auth: requires repo write.
564
   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.
565
 
588
 
566
 ### What S41b deliberately doesn't do
589
 ### What S41b deliberately doesn't do
567
 
590
 
@@ -572,8 +595,6 @@ defer to S41g where the lifecycle work touches that surface anyway.
572
   but no caller produces them yet. S41b-2 adds the sweep + the
595
   but no caller produces them yet. S41b-2 adds the sweep + the
573
   `robfig/cron/v3` dep + `shithubd-cron.service` wiring.
596
   `robfig/cron/v3` dep + `shithubd-cron.service` wiring.
574
 - External-PR triggers. Conservative collaborator gate above.
597
 - External-PR triggers. Conservative collaborator gate above.
575
-- `workflow_run` webhook events. S41h adds the webhook event family
576
-  + atom feed.
577
 
598
 
578
 ## Secrets + variables settings surface (S41c)
599
 ## Secrets + variables settings surface (S41c)
579
 
600
 
docs/public/SUMMARY.mdmodified
@@ -28,6 +28,8 @@
28
 - [Issues](./api/issues.md)
28
 - [Issues](./api/issues.md)
29
 - [Pull requests](./api/pulls.md)
29
 - [Pull requests](./api/pulls.md)
30
 - [Status checks](./api/checks.md)
30
 - [Status checks](./api/checks.md)
31
+- [Actions workflow API](./api/actions.md)
32
+- [Actions runner API](./api/actions-runner.md)
31
 - [Webhooks](./api/webhooks.md)
33
 - [Webhooks](./api/webhooks.md)
32
 - [Search](./api/search.md)
34
 - [Search](./api/search.md)
33
 - [Admin (site-admin only)](./api/admin.md)
35
 - [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
4
 `repo:write`. The token's user must also have write permission on the
4
 `repo:write`. The token's user must also have write permission on the
5
 repository that owns the target run or job.
5
 repository that owns the target run or job.
6
 
6
 
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
+
7
 ## Cancel job
20
 ## Cancel job
8
 
21
 
9
 ```text
22
 ```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) {
144
 // two-segment route doesn't collide with the /{username} catch-all from S09;
144
 // two-segment route doesn't collide with the /{username} catch-all from S09;
145
 // caller is responsible for ordering this BEFORE /{username}.
145
 // caller is responsible for ordering this BEFORE /{username}.
146
 func (h *Handlers) MountRepoHome(r chi.Router) {
146
 func (h *Handlers) MountRepoHome(r chi.Router) {
147
+	r.Get("/{owner}/{repo}/actions.atom", h.repoActionsAtom)
147
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", h.repoActionStepLog)
148
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", h.repoActionStepLog)
148
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", h.repoActionRunStatus)
149
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", h.repoActionRunStatus)
149
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}", h.repoActionRun)
150
 	r.Get("/{owner}/{repo}/actions/runs/{runIndex}", h.repoActionRun)