Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/go/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ install-deps-dev: ## install dependencies for development
@# https://aquasecurity.github.io/trivy/v0.18.3/installation/#install-script
@which trivy || curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b $(TOOLS_DIR) v$(TRIVY_VERSION)
@which actionlint || echo "install actionlint https://github.com/rhysd/actionlint"
@# https://github.com/swaggo/swag — dev-only, not a runtime dependency
@which swag || go install github.com/swaggo/swag/v2/cmd/swag@latest

.PHONY: openapi
openapi: ## regenerate the OpenAPI spec from handler annotations (requires swag v2)
swag init --v3.1 -g cmd/serve/doc.go -o cmd/serve/webui --ot yaml

.PHONY: format-check
format-check: ## format check
Expand Down
25 changes: 25 additions & 0 deletions src/go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,31 @@ health check. Unversioned `/tasks` endpoints remain available for compatibility.
Like `run`, `serve` defaults to read-only permission approval; pass `--yolo` to
change the server default, or set `yolo=true` on an individual task request.

#### Browser UI (Scalar API reference)

Once the server is running, open **http://127.0.0.1:8080/docs/** in your
browser to access the built-in Scalar API reference UI. From there you can
explore all endpoints and send requests interactively using the built-in
try-console.

Key points:

- The UI is **fully offline** — the Scalar bundle and the OpenAPI spec are
embedded in the binary; no network access is required.
- The OpenAPI 3.1 spec is also available at `GET /swagger.yaml`.
- `GET /` redirects to `/docs/`.

To regenerate the spec after changing handler annotations, install `swag` (once)
and run `make openapi`:

```shell
# install swag v2 (dev tool only, not a runtime dependency)
make install-deps-dev

# regenerate cmd/serve/webui/swagger.yaml from handler annotations
make openapi
```

## Development instructions

### Local development
Expand Down
31 changes: 31 additions & 0 deletions src/go/cmd/serve/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
Copyright © 2024 ks6088ts

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/

// Package serve implements the HTTP task server command.
//
// This file is the swag general-info anchor: `swag init -g cmd/serve/doc.go`.
//
// @title Copilot Task API
// @version 1.0
// @description REST API for submitting GitHub Copilot tasks and tracking their progress.
// @BasePath /v1
package serve
68 changes: 61 additions & 7 deletions src/go/cmd/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,17 @@ Endpoints:
GET /v1/tasks/{id} Get the status, progress, and result of a task.
GET /v1/tasks List submitted tasks.
GET /healthz Health check.
GET /docs/ Interactive API browser (Scalar UI, offline).
GET /swagger.yaml OpenAPI 3.1 spec.

Tasks run asynchronously in the background. Poll GET /v1/tasks/{id} until the
status is "done" or "failed". The unversioned /tasks endpoints are also
available for compatibility.

Open http://127.0.0.1:8080/docs/ in a browser to explore and try the API
interactively. All web assets are embedded in the binary; no network access is
required.

By default, only read permission requests are approved automatically. Pass
--yolo to make task execution approve every tool permission request.

Expand Down Expand Up @@ -208,10 +214,9 @@ func GetCommand() *cobra.Command {
return serveCmd
}

// startServer wires up the HTTP routes, starts the listener, and blocks until
// ctx is cancelled (Ctrl+C).
func startServer(ctx context.Context, addr, cliURL string, defaultYolo bool) error {
store := newTaskStore()
// newMux builds and returns a configured ServeMux with all API and UI routes
// registered. Extracting this from startServer makes the routing unit-testable.
func newMux(store *taskStore, cliURL string, defaultYolo bool) *http.ServeMux {
mux := http.NewServeMux()

createTask := func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -235,9 +240,20 @@ func startServer(ctx context.Context, addr, cliURL string, defaultYolo bool) err
// GET /tasks — list all tasks
mux.HandleFunc("GET /tasks", listTasks)
mux.HandleFunc("GET /v1/tasks", listTasks)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})

// GET /healthz — health check
mux.HandleFunc("GET /healthz", handleHealthz)

registerWebUI(mux)

return mux
}

// startServer wires up the HTTP routes, starts the listener, and blocks until
// ctx is cancelled (Ctrl+C).
func startServer(ctx context.Context, addr, cliURL string, defaultYolo bool) error {
store := newTaskStore()
mux := newMux(store, cliURL, defaultYolo)

srv := &http.Server{
Addr: addr,
Expand Down Expand Up @@ -460,7 +476,29 @@ func uploadFileName(field string, index int, filename string) string {
return fmt.Sprintf("%s-%d-%s", field, index, base)
}

// handleHealthz handles GET /healthz.
//
// @Summary Health check
// @Description Returns the health status of the server.
// @Tags system
// @Produce json
// @Success 200 {object} map[string]string "ok"
// @Router /healthz [get]
func handleHealthz(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// handleCreateTask handles POST /tasks.
//
// @Summary Create a task
// @Description Submit a new Copilot task. Accepts JSON or multipart/form-data.
// @Tags tasks
// @Accept json
// @Produce json
// @Param task body taskRequest true "Task request"
// @Success 202 {object} taskCreatedResponse "Task created"
// @Failure 400 {object} map[string]string "Bad request"
// @Router /tasks [post]
func handleCreateTask(w http.ResponseWriter, r *http.Request, store *taskStore, defaultCLIURL string, defaultYolo bool) {
req, cleanup, err := parseTaskRequest(r)
if err != nil {
Expand Down Expand Up @@ -527,6 +565,15 @@ func handleCreateTask(w http.ResponseWriter, r *http.Request, store *taskStore,
}

// handleGetTask handles GET /tasks/{id}.
//
// @Summary Get a task
// @Description Get the current status, progress, and result of a task.
// @Tags tasks
// @Produce json
// @Param id path string true "Task ID"
// @Success 200 {object} Task "Task state"
// @Failure 404 {object} map[string]string "Task not found"
// @Router /tasks/{id} [get]
func handleGetTask(w http.ResponseWriter, r *http.Request, store *taskStore) {
id := r.PathValue("id")
task, ok := store.get(id)
Expand All @@ -538,6 +585,13 @@ func handleGetTask(w http.ResponseWriter, r *http.Request, store *taskStore) {
}

// handleListTasks handles GET /tasks.
//
// @Summary List tasks
// @Description List all submitted tasks.
// @Tags tasks
// @Produce json
// @Success 200 {array} Task "List of tasks"
// @Router /tasks [get]
func handleListTasks(w http.ResponseWriter, _ *http.Request, store *taskStore) {
store.mu.RLock()
tasks := make([]Task, 0, len(store.tasks))
Expand Down
71 changes: 71 additions & 0 deletions src/go/cmd/serve/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)

Expand Down Expand Up @@ -89,3 +90,73 @@ func assertFileExists(t *testing.T, path string) {
t.Fatalf("os.Stat(%q) error = %v", path, err)
}
}

// newTestMux creates a ServeMux backed by a fresh in-memory store. It is used
// by the route-level tests to exercise the full routing table without starting
// a real TCP server.
func newTestMux() *http.ServeMux {
return newMux(newTaskStore(), "", false)
}

func TestDocsRouteReturnsHTML(t *testing.T) {
mux := newTestMux()
req := httptest.NewRequest(http.MethodGet, "/docs/", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Fatalf("GET /docs/ status = %d, want 200", w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.HasPrefix(ct, "text/html") {
t.Fatalf("GET /docs/ Content-Type = %q, want text/html prefix", ct)
}
}

func TestSwaggerYAMLRouteReturnsOpenAPISpec(t *testing.T) {
mux := newTestMux()
req := httptest.NewRequest(http.MethodGet, "/swagger.yaml", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Fatalf("GET /swagger.yaml status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "openapi:") {
t.Fatalf("GET /swagger.yaml body does not contain 'openapi:' key")
}
if !strings.Contains(body, "paths:") {
t.Fatalf("GET /swagger.yaml body does not contain 'paths:' key")
}
}

func TestRootRedirectsToDocs(t *testing.T) {
mux := newTestMux()
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)

if w.Code != http.StatusFound && w.Code != http.StatusMovedPermanently && w.Code/100 != 3 {
t.Fatalf("GET / status = %d, want 3xx redirect", w.Code)
}
loc := w.Header().Get("Location")
if loc != "/docs/" {
t.Fatalf("GET / Location = %q, want /docs/", loc)
}
}

func TestHealthzRoute(t *testing.T) {
mux := newTestMux()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Fatalf("GET /healthz status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"ok"`) {
t.Fatalf("GET /healthz body = %q, want body containing \"ok\"", body)
}
}
61 changes: 61 additions & 0 deletions src/go/cmd/serve/webui.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
Copyright © 2024 ks6088ts

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package serve

import (
"embed"
"io/fs"
"net/http"
)

//go:embed webui
var webuiFS embed.FS

// registerWebUI registers the static web UI and OpenAPI spec routes on mux.
//
// - GET /docs/ — serves the embedded Scalar API reference page.
// - GET /swagger.yaml — serves the embedded OpenAPI 3.1 spec.
// - GET /{$} — redirects the root path to /docs/.
func registerWebUI(mux *http.ServeMux) {
sub, err := fs.Sub(webuiFS, "webui")
if err != nil {
// This can only fail if the embed directive is wrong, which would be a
// compile-time/init-time programming error rather than a runtime error.
panic("webui: fs.Sub failed: " + err.Error())
}

fileServer := http.FileServerFS(sub)

// Serve the full /docs/ tree (index.html, scalar.standalone.js, etc.)
mux.Handle("GET /docs/", http.StripPrefix("/docs/", fileServer))

// Expose the OpenAPI spec at the root level for convenience.
mux.HandleFunc("GET /swagger.yaml", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = "/swagger.yaml"
fileServer.ServeHTTP(w, r)
})

// Redirect root to the docs UI.
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/docs/", http.StatusFound)
})
}
21 changes: 21 additions & 0 deletions src/go/cmd/serve/webui/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Copilot Task API</title>
</head>
<body>
<script
id="api-reference"
data-url="/swagger.yaml"
src="./scalar.standalone.js"
></script>
<script>
document.getElementById('api-reference').dataset.configuration =
JSON.stringify({
servers: [{ url: '' }],
});
</script>
</body>
</html>
2,385 changes: 2,385 additions & 0 deletions src/go/cmd/serve/webui/scalar.standalone.js

Large diffs are not rendered by default.

Loading