502 lines
13 KiB
Go
502 lines
13 KiB
Go
|
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||
|
|
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io/fs"
|
||
|
|
"os"
|
||
|
|
"os/exec"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/gin-gonic/gin"
|
||
|
|
"gopkg.in/yaml.v3"
|
||
|
|
)
|
||
|
|
|
||
|
|
// WorkspaceAPI exposes project context derived from .core/ and git status.
|
||
|
|
type WorkspaceAPI struct {
|
||
|
|
root string
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewWorkspaceAPI(root string) *WorkspaceAPI {
|
||
|
|
return &WorkspaceAPI{root: root}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (w *WorkspaceAPI) Name() string { return "workspace-api" }
|
||
|
|
func (w *WorkspaceAPI) BasePath() string { return "/api/v1/workspace" }
|
||
|
|
|
||
|
|
func (w *WorkspaceAPI) RegisterRoutes(rg *gin.RouterGroup) {
|
||
|
|
rg.GET("/status", w.status)
|
||
|
|
rg.GET("/conventions", w.conventions)
|
||
|
|
rg.GET("/impact", w.impact)
|
||
|
|
}
|
||
|
|
|
||
|
|
type workspaceStatusResponse struct {
|
||
|
|
Root string `json:"root"`
|
||
|
|
Git gitStatusSummary `json:"git"`
|
||
|
|
CoreFiles []workspaceFile `json:"coreFiles"`
|
||
|
|
Counts workspaceFileCount `json:"counts"`
|
||
|
|
UpdatedAt string `json:"updatedAt"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type workspaceConventionsResponse struct {
|
||
|
|
Root string `json:"root"`
|
||
|
|
Sources []string `json:"sources"`
|
||
|
|
Build buildConfigSummary `json:"build"`
|
||
|
|
Conventions []string `json:"conventions"`
|
||
|
|
Notes []string `json:"notes"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type workspaceImpactResponse struct {
|
||
|
|
Root string `json:"root"`
|
||
|
|
Git gitStatusSummary `json:"git"`
|
||
|
|
ImpactedAreas []string `json:"impactedAreas"`
|
||
|
|
SuggestedChecks []string `json:"suggestedChecks"`
|
||
|
|
Notes []string `json:"notes"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type workspaceFileCount struct {
|
||
|
|
Total int `json:"total"`
|
||
|
|
Text int `json:"text"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type workspaceFile struct {
|
||
|
|
Path string `json:"path"`
|
||
|
|
Size int64 `json:"size"`
|
||
|
|
Modified string `json:"modified"`
|
||
|
|
Preview string `json:"preview,omitempty"`
|
||
|
|
Content string `json:"content,omitempty"`
|
||
|
|
IsText bool `json:"isText"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type gitStatusSummary struct {
|
||
|
|
Branch string `json:"branch,omitempty"`
|
||
|
|
RawHeader string `json:"rawHeader,omitempty"`
|
||
|
|
Clean bool `json:"clean"`
|
||
|
|
Changes []gitChange `json:"changes,omitempty"`
|
||
|
|
ChangeCounts gitChangeCounts `json:"changeCounts"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type gitChangeCounts struct {
|
||
|
|
Staged int `json:"staged"`
|
||
|
|
Unstaged int `json:"unstaged"`
|
||
|
|
Untracked int `json:"untracked"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type gitChange struct {
|
||
|
|
Code string `json:"code"`
|
||
|
|
Path string `json:"path"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type buildConfigSummary struct {
|
||
|
|
Version string `json:"version,omitempty"`
|
||
|
|
ProjectName string `json:"projectName,omitempty"`
|
||
|
|
ProjectDescription string `json:"projectDescription,omitempty"`
|
||
|
|
Binary string `json:"binary,omitempty"`
|
||
|
|
BuildType string `json:"buildType,omitempty"`
|
||
|
|
CGO bool `json:"cgo"`
|
||
|
|
Flags []string `json:"flags,omitempty"`
|
||
|
|
Ldflags []string `json:"ldflags,omitempty"`
|
||
|
|
Targets []buildTarget `json:"targets,omitempty"`
|
||
|
|
RawFiles []string `json:"rawFiles,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type buildTarget struct {
|
||
|
|
OS string `json:"os,omitempty"`
|
||
|
|
Arch string `json:"arch,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (w *WorkspaceAPI) status(c *gin.Context) {
|
||
|
|
snapshot, err := collectWorkspaceSnapshot(w.root)
|
||
|
|
if err != nil {
|
||
|
|
c.JSON(500, gin.H{"error": err.Error()})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
c.JSON(200, workspaceStatusResponse{
|
||
|
|
Root: snapshot.Root,
|
||
|
|
Git: snapshot.Git,
|
||
|
|
CoreFiles: snapshot.CoreFiles,
|
||
|
|
Counts: snapshot.Counts,
|
||
|
|
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (w *WorkspaceAPI) conventions(c *gin.Context) {
|
||
|
|
snapshot, err := collectWorkspaceSnapshot(w.root)
|
||
|
|
if err != nil {
|
||
|
|
c.JSON(500, gin.H{"error": err.Error()})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
conventions := []string{
|
||
|
|
"Use UK English in documentation and user-facing strings.",
|
||
|
|
"Use conventional commits: type(scope): description.",
|
||
|
|
"Go code lives in package main for this module.",
|
||
|
|
"Prefer core build for production builds and core go test for Go tests.",
|
||
|
|
}
|
||
|
|
|
||
|
|
notes := []string{
|
||
|
|
"Design input was derived from CLAUDE.md, docs/development.md, and .core/build.yaml.",
|
||
|
|
}
|
||
|
|
if !snapshot.Git.Clean {
|
||
|
|
notes = append(notes, "The worktree is dirty, so any new work should account for local changes.")
|
||
|
|
}
|
||
|
|
|
||
|
|
c.JSON(200, workspaceConventionsResponse{
|
||
|
|
Root: snapshot.Root,
|
||
|
|
Sources: snapshot.SourceFiles,
|
||
|
|
Build: snapshot.Build,
|
||
|
|
Conventions: conventions,
|
||
|
|
Notes: notes,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (w *WorkspaceAPI) impact(c *gin.Context) {
|
||
|
|
snapshot, err := collectWorkspaceSnapshot(w.root)
|
||
|
|
if err != nil {
|
||
|
|
c.JSON(500, gin.H{"error": err.Error()})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
impactedAreas := classifyImpact(snapshot.Git.Changes, snapshot.SourceFiles)
|
||
|
|
suggestedChecks := []string{"go build ./...", "go vet ./...", "go test ./... -count=1 -timeout 120s", "go test -cover ./..."}
|
||
|
|
|
||
|
|
if hasPathPrefix(snapshot.SourceFiles, ".core/") {
|
||
|
|
suggestedChecks = append([]string{"core build"}, suggestedChecks...)
|
||
|
|
}
|
||
|
|
if hasAnyImpact(snapshot.Git.Changes, "frontend/") {
|
||
|
|
suggestedChecks = append(suggestedChecks, "cd frontend && npm test")
|
||
|
|
}
|
||
|
|
|
||
|
|
notes := []string{
|
||
|
|
"Impact categories are inferred from changed paths and .core configuration files.",
|
||
|
|
}
|
||
|
|
if snapshot.Git.Clean {
|
||
|
|
notes = append(notes, "The worktree is clean, so there is no active change set to assess.")
|
||
|
|
}
|
||
|
|
|
||
|
|
c.JSON(200, workspaceImpactResponse{
|
||
|
|
Root: snapshot.Root,
|
||
|
|
Git: snapshot.Git,
|
||
|
|
ImpactedAreas: impactedAreas,
|
||
|
|
SuggestedChecks: uniqueStrings(suggestedChecks),
|
||
|
|
Notes: notes,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
type workspaceSnapshot struct {
|
||
|
|
Root string
|
||
|
|
Git gitStatusSummary
|
||
|
|
CoreFiles []workspaceFile
|
||
|
|
Counts workspaceFileCount
|
||
|
|
Build buildConfigSummary
|
||
|
|
SourceFiles []string
|
||
|
|
}
|
||
|
|
|
||
|
|
func collectWorkspaceSnapshot(root string) (*workspaceSnapshot, error) {
|
||
|
|
if root == "" {
|
||
|
|
root = "."
|
||
|
|
}
|
||
|
|
|
||
|
|
absRoot, err := filepath.Abs(root)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("resolve workspace root: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
coreFiles, counts, sourceFiles, err := readCoreFiles(absRoot)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
gitStatus, err := readGitStatus(absRoot)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
buildSummary := parseBuildConfig(coreFiles)
|
||
|
|
|
||
|
|
return &workspaceSnapshot{
|
||
|
|
Root: absRoot,
|
||
|
|
Git: gitStatus,
|
||
|
|
CoreFiles: coreFiles,
|
||
|
|
Counts: counts,
|
||
|
|
Build: buildSummary,
|
||
|
|
SourceFiles: sourceFiles,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func readCoreFiles(root string) ([]workspaceFile, workspaceFileCount, []string, error) {
|
||
|
|
coreDir := filepath.Join(root, ".core")
|
||
|
|
entries := []workspaceFile{}
|
||
|
|
sourceFiles := []string{}
|
||
|
|
counts := workspaceFileCount{}
|
||
|
|
|
||
|
|
info, err := os.Stat(coreDir)
|
||
|
|
if err != nil {
|
||
|
|
if errors.Is(err, fs.ErrNotExist) {
|
||
|
|
return entries, counts, sourceFiles, nil
|
||
|
|
}
|
||
|
|
return nil, counts, sourceFiles, fmt.Errorf("inspect .core directory: %w", err)
|
||
|
|
}
|
||
|
|
if !info.IsDir() {
|
||
|
|
return nil, counts, sourceFiles, fmt.Errorf("%s is not a directory", coreDir)
|
||
|
|
}
|
||
|
|
|
||
|
|
err = filepath.WalkDir(coreDir, func(path string, d fs.DirEntry, walkErr error) error {
|
||
|
|
if walkErr != nil {
|
||
|
|
return walkErr
|
||
|
|
}
|
||
|
|
if d.IsDir() {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
stat, err := d.Info()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
rel, err := filepath.Rel(root, path)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
data, err := os.ReadFile(path)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
text := isLikelyText(data)
|
||
|
|
if text {
|
||
|
|
counts.Text++
|
||
|
|
}
|
||
|
|
counts.Total++
|
||
|
|
|
||
|
|
entries = append(entries, workspaceFile{
|
||
|
|
Path: filepath.ToSlash(rel),
|
||
|
|
Size: stat.Size(),
|
||
|
|
Modified: stat.ModTime().UTC().Format(time.RFC3339),
|
||
|
|
Preview: filePreview(data, 8, 480),
|
||
|
|
Content: fileContent(data, 64*1024),
|
||
|
|
IsText: text,
|
||
|
|
})
|
||
|
|
sourceFiles = append(sourceFiles, filepath.ToSlash(rel))
|
||
|
|
return nil
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, counts, sourceFiles, fmt.Errorf("read .core files: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return entries, counts, sourceFiles, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func filePreview(data []byte, maxLines int, maxBytes int) string {
|
||
|
|
if len(data) == 0 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
if len(data) > maxBytes {
|
||
|
|
data = data[:maxBytes]
|
||
|
|
}
|
||
|
|
|
||
|
|
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||
|
|
lines := make([]string, 0, maxLines)
|
||
|
|
for scanner.Scan() {
|
||
|
|
lines = append(lines, scanner.Text())
|
||
|
|
if len(lines) >= maxLines {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||
|
|
}
|
||
|
|
|
||
|
|
func fileContent(data []byte, maxBytes int) string {
|
||
|
|
if len(data) == 0 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
if len(data) > maxBytes {
|
||
|
|
data = data[:maxBytes]
|
||
|
|
}
|
||
|
|
return strings.TrimSpace(string(data))
|
||
|
|
}
|
||
|
|
|
||
|
|
func isLikelyText(data []byte) bool {
|
||
|
|
for _, b := range data {
|
||
|
|
if b == 0 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
func readGitStatus(root string) (gitStatusSummary, error) {
|
||
|
|
cmd := exec.Command("git", "-C", root, "status", "--short", "--branch", "--untracked-files=all")
|
||
|
|
out, err := cmd.Output()
|
||
|
|
if err != nil {
|
||
|
|
return gitStatusSummary{}, fmt.Errorf("git status: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||
|
|
summary := gitStatusSummary{Clean: true}
|
||
|
|
if len(lines) == 1 && lines[0] == "" {
|
||
|
|
return summary, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
for i, line := range lines {
|
||
|
|
if line == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if i == 0 && strings.HasPrefix(line, "## ") {
|
||
|
|
summary.RawHeader = strings.TrimSpace(strings.TrimPrefix(line, "## "))
|
||
|
|
summary.Branch = parseGitBranch(summary.RawHeader)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if len(line) < 3 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
change := gitChange{Code: line[:2], Path: strings.TrimSpace(line[3:])}
|
||
|
|
summary.Changes = append(summary.Changes, change)
|
||
|
|
summary.Clean = false
|
||
|
|
switch change.Code {
|
||
|
|
case "??":
|
||
|
|
summary.ChangeCounts.Untracked++
|
||
|
|
default:
|
||
|
|
if change.Code[0] != ' ' {
|
||
|
|
summary.ChangeCounts.Staged++
|
||
|
|
}
|
||
|
|
if change.Code[1] != ' ' {
|
||
|
|
summary.ChangeCounts.Unstaged++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return summary, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseGitBranch(header string) string {
|
||
|
|
if header == "" {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
branch := header
|
||
|
|
if idx := strings.Index(branch, "..."); idx >= 0 {
|
||
|
|
branch = branch[:idx]
|
||
|
|
}
|
||
|
|
branch = strings.TrimSpace(branch)
|
||
|
|
branch = strings.TrimPrefix(branch, "(detached from ")
|
||
|
|
branch = strings.TrimSuffix(branch, ")")
|
||
|
|
return branch
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseBuildConfig(files []workspaceFile) buildConfigSummary {
|
||
|
|
summary := buildConfigSummary{}
|
||
|
|
for _, file := range files {
|
||
|
|
if filepath.Base(file.Path) != "build.yaml" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
summary.RawFiles = append(summary.RawFiles, file.Path)
|
||
|
|
content := file.Content
|
||
|
|
if content == "" {
|
||
|
|
content = file.Preview
|
||
|
|
}
|
||
|
|
var raw struct {
|
||
|
|
Version any `yaml:"version"`
|
||
|
|
Project struct {
|
||
|
|
Name string `yaml:"name"`
|
||
|
|
Description string `yaml:"description"`
|
||
|
|
Binary string `yaml:"binary"`
|
||
|
|
} `yaml:"project"`
|
||
|
|
Build struct {
|
||
|
|
Type string `yaml:"type"`
|
||
|
|
CGO bool `yaml:"cgo"`
|
||
|
|
Flags []string `yaml:"flags"`
|
||
|
|
Ldflags []string `yaml:"ldflags"`
|
||
|
|
} `yaml:"build"`
|
||
|
|
Targets []buildTarget `yaml:"targets"`
|
||
|
|
}
|
||
|
|
if err := yaml.Unmarshal([]byte(content), &raw); err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
summary.Version = fmt.Sprint(raw.Version)
|
||
|
|
summary.ProjectName = raw.Project.Name
|
||
|
|
summary.ProjectDescription = raw.Project.Description
|
||
|
|
summary.Binary = raw.Project.Binary
|
||
|
|
summary.BuildType = raw.Build.Type
|
||
|
|
summary.CGO = raw.Build.CGO
|
||
|
|
summary.Flags = append(summary.Flags, raw.Build.Flags...)
|
||
|
|
summary.Ldflags = append(summary.Ldflags, raw.Build.Ldflags...)
|
||
|
|
summary.Targets = append(summary.Targets, raw.Targets...)
|
||
|
|
break
|
||
|
|
}
|
||
|
|
return summary
|
||
|
|
}
|
||
|
|
|
||
|
|
func classifyImpact(changes []gitChange, sourceFiles []string) []string {
|
||
|
|
areas := []string{}
|
||
|
|
for _, change := range changes {
|
||
|
|
path := change.Path
|
||
|
|
switch {
|
||
|
|
case strings.HasPrefix(path, ".core/"):
|
||
|
|
areas = append(areas, "build configuration")
|
||
|
|
case path == "go.mod" || path == "go.sum":
|
||
|
|
areas = append(areas, "dependency graph")
|
||
|
|
case strings.HasSuffix(path, ".go"):
|
||
|
|
areas = append(areas, "Go backend")
|
||
|
|
case strings.HasPrefix(path, "frontend/"):
|
||
|
|
areas = append(areas, "Angular frontend")
|
||
|
|
case strings.HasPrefix(path, "docs/") || strings.HasSuffix(path, ".md"):
|
||
|
|
areas = append(areas, "documentation")
|
||
|
|
case strings.HasPrefix(path, "build/"):
|
||
|
|
areas = append(areas, "packaging and platform build files")
|
||
|
|
default:
|
||
|
|
areas = append(areas, "general project context")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if hasPathPrefix(sourceFiles, ".core/") {
|
||
|
|
areas = append(areas, "workspace metadata")
|
||
|
|
}
|
||
|
|
if len(changes) == 0 {
|
||
|
|
areas = append(areas, "no active changes")
|
||
|
|
}
|
||
|
|
|
||
|
|
return uniqueStrings(areas)
|
||
|
|
}
|
||
|
|
|
||
|
|
func hasPathPrefix(paths []string, prefix string) bool {
|
||
|
|
for _, path := range paths {
|
||
|
|
if strings.HasPrefix(path, prefix) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func hasAnyImpact(changes []gitChange, prefix string) bool {
|
||
|
|
for _, change := range changes {
|
||
|
|
if strings.HasPrefix(change.Path, prefix) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func uniqueStrings(values []string) []string {
|
||
|
|
seen := make(map[string]struct{}, len(values))
|
||
|
|
out := make([]string, 0, len(values))
|
||
|
|
for _, value := range values {
|
||
|
|
value = strings.TrimSpace(value)
|
||
|
|
if value == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if _, ok := seen[value]; ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
seen[value] = struct{}{}
|
||
|
|
out = append(out, value)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|