Skip to content

Commit b4f8cde

Browse files
shahar-cauraclaude
andcommitted
CAURA-351: Add OpenAPI-first web dashboard foundation
- OpenAPI 3.0.3 spec as SSoT with oapi-codegen strict server generation - Go HTTP server: /health, /runs, /runs/{id} handlers + SSE hub with fsnotify - Svelte 5 SPA shell embedded in Go binary via go:embed - `forge serve` command (standalone, no forge.yaml required) - Makefile targets: generate, web, lint-spec, test-api Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1a4cbda commit b4f8cde

31 files changed

Lines changed: 3328 additions & 9 deletions

Makefile

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: build install test test-v lint clean validate fmt vet run release-dry
1+
.PHONY: build install test test-v lint clean validate fmt vet run release-dry generate web lint-spec test-api
22

33
BINARY := forge
44
BUILD_DIR := bin
@@ -8,7 +8,7 @@ COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "none")
88
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
99
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
1010

11-
build:
11+
build: web
1212
go build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY) ./cmd/forge
1313

1414
install:
@@ -33,7 +33,7 @@ vet:
3333
go vet ./...
3434

3535
clean:
36-
rm -rf $(BUILD_DIR) coverage.out dist
36+
rm -rf $(BUILD_DIR) coverage.out dist web/dist web/node_modules
3737

3838
validate:
3939
gh auth status
@@ -43,6 +43,23 @@ validate:
4343
release-dry:
4444
goreleaser release --snapshot --clean
4545

46+
generate:
47+
oapi-codegen -config api/oapi-codegen.yaml -exclude-operation-ids streamEvents api/openapi.yaml
48+
49+
web:
50+
cd web && npm ci && npm run build
51+
52+
lint-spec:
53+
npx @stoplight/spectral-cli lint api/openapi.yaml --ruleset api/.spectral.yaml
54+
55+
test-api:
56+
@echo "Starting server for contract tests..."
57+
@PORT=$$(python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()'); \
58+
./$(BUILD_DIR)/$(BINARY) serve --port $$PORT & SERVER_PID=$$!; \
59+
sleep 1; \
60+
schemathesis run api/openapi.yaml --base-url http://localhost:$$PORT/api --mode=all; \
61+
EXIT=$$?; kill $$SERVER_PID 2>/dev/null; exit $$EXIT
62+
4663
# Catch-all so `make run <path>` doesn't error on the path argument.
4764
%:
4865
@:

api/.spectral.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
extends:
2+
- spectral:oas
3+
rules:
4+
oas3-api-servers: off
5+
info-contact: off
6+
info-description: off

api/oapi-codegen.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package: server
2+
output: internal/server/api.gen.go
3+
generate:
4+
std-http-server: true
5+
strict-server: true
6+
models: true
7+
embedded-spec: true
8+
output-options:
9+
skip-prune: true

api/openapi.yaml

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
openapi: 3.0.3
2+
info:
3+
title: Forge Dashboard API
4+
description: REST API for the Forge CI/CD dashboard
5+
version: 0.1.0
6+
7+
servers:
8+
- url: /api
9+
10+
paths:
11+
/health:
12+
get:
13+
operationId: getHealth
14+
summary: Health check
15+
tags: [system]
16+
responses:
17+
"200":
18+
description: Server is healthy
19+
content:
20+
application/json:
21+
schema:
22+
$ref: "#/components/schemas/HealthResponse"
23+
24+
/runs:
25+
get:
26+
operationId: listRuns
27+
summary: List pipeline runs
28+
tags: [runs]
29+
parameters:
30+
- name: status
31+
in: query
32+
schema:
33+
$ref: "#/components/schemas/RunStatus"
34+
- name: limit
35+
in: query
36+
schema:
37+
type: integer
38+
minimum: 1
39+
maximum: 100
40+
default: 20
41+
- name: offset
42+
in: query
43+
schema:
44+
type: integer
45+
minimum: 0
46+
default: 0
47+
responses:
48+
"200":
49+
description: List of runs
50+
content:
51+
application/json:
52+
schema:
53+
$ref: "#/components/schemas/RunList"
54+
55+
/runs/{id}:
56+
get:
57+
operationId: getRun
58+
summary: Get run detail
59+
tags: [runs]
60+
parameters:
61+
- name: id
62+
in: path
63+
required: true
64+
schema:
65+
type: string
66+
responses:
67+
"200":
68+
description: Run detail
69+
content:
70+
application/json:
71+
schema:
72+
$ref: "#/components/schemas/Run"
73+
"404":
74+
description: Run not found
75+
content:
76+
application/json:
77+
schema:
78+
$ref: "#/components/schemas/ErrorResponse"
79+
80+
/events:
81+
get:
82+
operationId: streamEvents
83+
summary: SSE event stream for real-time run updates
84+
tags: [events]
85+
responses:
86+
"200":
87+
description: SSE event stream
88+
content:
89+
text/event-stream:
90+
schema:
91+
type: string
92+
93+
components:
94+
schemas:
95+
RunStatus:
96+
type: string
97+
enum: [active, completed, failed]
98+
99+
StepStatus:
100+
type: string
101+
enum: [pending, running, completed, failed]
102+
103+
StepState:
104+
type: object
105+
required: [name, status]
106+
properties:
107+
name:
108+
type: string
109+
status:
110+
$ref: "#/components/schemas/StepStatus"
111+
error:
112+
type: string
113+
114+
Run:
115+
type: object
116+
required: [id, plan_path, status, created_at, updated_at, steps]
117+
properties:
118+
id:
119+
type: string
120+
plan_path:
121+
type: string
122+
mode:
123+
type: string
124+
status:
125+
$ref: "#/components/schemas/RunStatus"
126+
created_at:
127+
type: string
128+
format: date-time
129+
updated_at:
130+
type: string
131+
format: date-time
132+
branch:
133+
type: string
134+
worktree_path:
135+
type: string
136+
pr_url:
137+
type: string
138+
pr_number:
139+
type: integer
140+
issue_key:
141+
type: string
142+
issue_url:
143+
type: string
144+
cr_feedback:
145+
type: string
146+
cr_fix_summary:
147+
type: string
148+
plan_title:
149+
type: string
150+
source_issue:
151+
type: integer
152+
steps:
153+
type: array
154+
items:
155+
$ref: "#/components/schemas/StepState"
156+
157+
RunList:
158+
type: object
159+
required: [runs, total]
160+
properties:
161+
runs:
162+
type: array
163+
items:
164+
$ref: "#/components/schemas/Run"
165+
total:
166+
type: integer
167+
168+
ErrorResponse:
169+
type: object
170+
required: [code, message]
171+
properties:
172+
code:
173+
type: integer
174+
message:
175+
type: string
176+
177+
HealthResponse:
178+
type: object
179+
required: [status, version, uptime_seconds]
180+
properties:
181+
status:
182+
type: string
183+
version:
184+
type: string
185+
uptime_seconds:
186+
type: integer

cmd/forge/cmd_serve.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package main
2+
3+
import (
4+
"log/slog"
5+
"os"
6+
"os/signal"
7+
8+
"github.com/shahar-caura/forge/internal/server"
9+
"github.com/shahar-caura/forge/internal/state"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
func newServeCmd(logger *slog.Logger) *cobra.Command {
14+
var port int
15+
var runsDir string
16+
17+
cmd := &cobra.Command{
18+
Use: "serve",
19+
Short: "Start the dashboard web server",
20+
RunE: func(cmd *cobra.Command, args []string) error {
21+
if runsDir != "" {
22+
state.SetRunsDir(runsDir)
23+
}
24+
25+
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt)
26+
defer stop()
27+
28+
srv := server.New(port, runsDir, version, logger)
29+
return srv.Run(ctx)
30+
},
31+
}
32+
33+
cmd.Flags().IntVar(&port, "port", 8080, "HTTP server port")
34+
cmd.Flags().StringVar(&runsDir, "runs-dir", ".forge/runs", "path to runs state directory")
35+
36+
return cmd
37+
}

cmd/forge/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ func newRootCmd(logger *slog.Logger) *cobra.Command {
4747
newEditCmd(logger),
4848
newCompletionCmd(),
4949
newCleanupCmd(logger),
50+
newServeCmd(logger),
5051
)
5152

5253
return root

go.mod

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,29 @@ module github.com/shahar-caura/forge
33
go 1.25.0
44

55
require (
6+
github.com/fsnotify/fsnotify v1.9.0
7+
github.com/getkin/kin-openapi v0.133.0
8+
github.com/oapi-codegen/runtime v1.1.2
69
github.com/spf13/cobra v1.10.2
710
github.com/stretchr/testify v1.11.1
811
gopkg.in/yaml.v3 v3.0.1
912
)
1013

1114
require (
15+
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
1216
github.com/davecgh/go-spew v1.1.1 // indirect
17+
github.com/go-openapi/jsonpointer v0.21.0 // indirect
18+
github.com/go-openapi/swag v0.23.0 // indirect
19+
github.com/google/uuid v1.5.0 // indirect
1320
github.com/inconshreveable/mousetrap v1.1.0 // indirect
21+
github.com/josharian/intern v1.0.0 // indirect
22+
github.com/mailru/easyjson v0.7.7 // indirect
23+
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
24+
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
25+
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
26+
github.com/perimeterx/marshmallow v1.1.5 // indirect
1427
github.com/pmezard/go-difflib v1.0.0 // indirect
1528
github.com/spf13/pflag v1.0.9 // indirect
29+
github.com/woodsbury/decimal128 v1.3.0 // indirect
30+
golang.org/x/sys v0.15.0 // indirect
1631
)

go.sum

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,67 @@
1+
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
2+
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
3+
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
4+
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
15
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
6+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
27
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
38
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
9+
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
10+
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
11+
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
12+
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
13+
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
14+
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
15+
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
16+
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
17+
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
18+
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
19+
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
20+
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
421
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
522
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
23+
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
24+
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
25+
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
26+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
27+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
28+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
29+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
30+
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
31+
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
32+
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
33+
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
34+
github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
35+
github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
36+
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
37+
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
38+
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
39+
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
40+
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
41+
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
642
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
743
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
44+
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
45+
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
846
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
947
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
1048
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
1149
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
1250
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
51+
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
52+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
53+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
1354
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
1455
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
56+
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
57+
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
58+
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
59+
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
1560
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
16-
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
61+
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
62+
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
1763
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
64+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
65+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
1866
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
1967
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)