feat: git command, build improvements, and go fmt git-aware (#74)
* feat(go): make go fmt git-aware by default - By default, only check changed Go files (modified, staged, untracked) - Add --all flag to check all files (previous behaviour) - Reduces noise when running fmt on large codebases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(build): minimal output by default, add missing i18n - Default output now shows single line: "Success Built N artifacts (dir)" - Add --verbose/-v flag to show full detailed output - Add all missing i18n translations for build commands - Errors still show failure reason in minimal mode Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add root-level `core git` command - Create pkg/gitcmd with git workflow commands as root menu - Export command builders from pkg/dev (AddCommitCommand, etc.) - Commands available under both `core git` and `core dev` for compatibility - Git commands: health, commit, push, pull, work, sync, apply - GitHub orchestration stays in dev: issues, reviews, ci, impact Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(qa): add docblock coverage checking Implement docblock/docstring coverage analysis for Go code: - New `core qa docblock` command to check coverage - Shows compact file:line list when under threshold - Integrate with `core go qa` as a default check - Add --docblock-threshold flag (default 80%) The checker uses Go AST parsing to find exported symbols (functions, types, consts, vars) without documentation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback - Fix doc comment: "status" → "health" in gitcmd package - Implement --check flag for `core go fmt` (exits non-zero if files need formatting) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: add docstrings for 100% coverage Add documentation comments to all exported symbols: - pkg/build: ProjectType constants - pkg/cli: LogLevel, RenderStyle, TableStyle - pkg/framework: ServiceFor, MustServiceFor, Core.Core - pkg/git: GitError.Error, GitError.Unwrap - pkg/i18n: Handler Match/Handle methods - pkg/log: Level constants - pkg/mcp: Tool input/output types - pkg/php: Service constants, QA types, service methods - pkg/process: ServiceError.Error - pkg/repos: RepoType constants - pkg/setup: ChangeType, ChangeCategory constants - pkg/workspace: AddWorkspaceCommands Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: standardize line endings to LF Add .gitattributes to enforce LF line endings for all text files. Normalize all existing files to use Unix-style line endings. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback - cmd_format.go: validate --check/--fix mutual exclusivity, capture stderr - cmd_docblock.go: return error instead of os.Exit(1) for proper error handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit review feedback (round 2) - linuxkit.go: propagate state update errors, handle cmd.Wait() errors in waitForExit - mcp.go: guard against empty old_string in editDiff to prevent runaway edits - cmd_docblock.go: log parse errors instead of silently skipping Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fa8e5334a5
commit
0072650fd9
105 changed files with 1608 additions and 674 deletions
23
.gitattributes
vendored
Normal file
23
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Normalize all text files to LF
|
||||
* text=auto eol=lf
|
||||
|
||||
# Ensure shell scripts use LF
|
||||
*.sh text eol=lf
|
||||
|
||||
# Ensure Go files use LF
|
||||
*.go text eol=lf
|
||||
|
||||
# Ensure JSON/YAML use LF
|
||||
*.json text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
|
||||
# Binary files
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
|
|
@ -369,7 +369,8 @@ func scanFile(fset *token.FileSet, filename string, file *ast.File) []KeyUsage {
|
|||
}
|
||||
|
||||
// Check for T(), C(), i18n.T(), i18n.C()
|
||||
if funcName == "T" || funcName == "i18n.T" || funcName == "_" || funcName == "i18n._" {
|
||||
switch funcName {
|
||||
case "T", "i18n.T", "_", "i18n._":
|
||||
if key := extractStringArg(call, 0); key != "" {
|
||||
pos := fset.Position(call.Pos())
|
||||
usages = append(usages, KeyUsage{
|
||||
|
|
@ -379,7 +380,7 @@ func scanFile(fset *token.FileSet, filename string, file *ast.File) []KeyUsage {
|
|||
Function: "T",
|
||||
})
|
||||
}
|
||||
} else if funcName == "C" || funcName == "i18n.C" {
|
||||
case "C", "i18n.C":
|
||||
if key := extractStringArg(call, 0); key != "" {
|
||||
pos := fset.Position(call.Pos())
|
||||
usages = append(usages, KeyUsage{
|
||||
|
|
@ -389,7 +390,7 @@ func scanFile(fset *token.FileSet, filename string, file *ast.File) []KeyUsage {
|
|||
Function: "C",
|
||||
})
|
||||
}
|
||||
} else if funcName == "I" || funcName == "i18n.I" {
|
||||
case "I", "i18n.I":
|
||||
if key := extractStringArg(call, 0); key != "" {
|
||||
pos := fset.Position(call.Pos())
|
||||
usages = append(usages, KeyUsage{
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
// - setup: Repository cloning and setup
|
||||
// - doctor: Environment health checks
|
||||
// - test: Test runner with coverage
|
||||
// - qa: Quality assurance workflows
|
||||
|
||||
package variants
|
||||
|
||||
|
|
@ -29,9 +30,11 @@ import (
|
|||
_ "github.com/host-uk/core/pkg/dev"
|
||||
_ "github.com/host-uk/core/pkg/docs"
|
||||
_ "github.com/host-uk/core/pkg/doctor"
|
||||
_ "github.com/host-uk/core/pkg/gitcmd"
|
||||
_ "github.com/host-uk/core/pkg/go"
|
||||
_ "github.com/host-uk/core/pkg/php"
|
||||
_ "github.com/host-uk/core/pkg/pkgcmd"
|
||||
_ "github.com/host-uk/core/pkg/qa"
|
||||
_ "github.com/host-uk/core/pkg/sdk"
|
||||
_ "github.com/host-uk/core/pkg/security"
|
||||
_ "github.com/host-uk/core/pkg/setup"
|
||||
|
|
|
|||
|
|
@ -295,12 +295,6 @@ func (c *Client) checkResponse(resp *http.Response) error {
|
|||
}
|
||||
}
|
||||
|
||||
// mustReadAll reads all bytes from a reader, returning empty slice on error.
|
||||
func mustReadAll(r io.Reader) []byte {
|
||||
data, _ := io.ReadAll(r)
|
||||
return data
|
||||
}
|
||||
|
||||
// Ping tests the connection to the API server.
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
const op = "agentic.Client.Ping"
|
||||
|
|
|
|||
|
|
@ -10,13 +10,21 @@ import (
|
|||
// ProjectType represents a detected project type.
|
||||
type ProjectType string
|
||||
|
||||
// Project type constants for build detection.
|
||||
const (
|
||||
// ProjectTypeGo indicates a standard Go project with go.mod.
|
||||
ProjectTypeGo ProjectType = "go"
|
||||
// ProjectTypeWails indicates a Wails desktop application.
|
||||
ProjectTypeWails ProjectType = "wails"
|
||||
// ProjectTypeNode indicates a Node.js project with package.json.
|
||||
ProjectTypeNode ProjectType = "node"
|
||||
// ProjectTypePHP indicates a PHP/Laravel project with composer.json.
|
||||
ProjectTypePHP ProjectType = "php"
|
||||
// ProjectTypeDocker indicates a Docker-based project with Dockerfile.
|
||||
ProjectTypeDocker ProjectType = "docker"
|
||||
// ProjectTypeLinuxKit indicates a LinuxKit VM configuration.
|
||||
ProjectTypeLinuxKit ProjectType = "linuxkit"
|
||||
// ProjectTypeTaskfile indicates a project using Taskfile automation.
|
||||
ProjectTypeTaskfile ProjectType = "taskfile"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ var (
|
|||
outputDir string
|
||||
doArchive bool
|
||||
doChecksum bool
|
||||
verbose bool
|
||||
|
||||
// Docker/LinuxKit specific flags
|
||||
configPath string
|
||||
|
|
@ -62,7 +63,7 @@ var buildCmd = &cobra.Command{
|
|||
Short: i18n.T("cmd.build.short"),
|
||||
Long: i18n.T("cmd.build.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum, configPath, format, push, imageName, noSign, notarize)
|
||||
return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum, configPath, format, push, imageName, noSign, notarize, verbose)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +102,7 @@ func initBuildFlags() {
|
|||
// Main build command flags
|
||||
buildCmd.Flags().StringVar(&buildType, "type", "", i18n.T("cmd.build.flag.type"))
|
||||
buildCmd.Flags().BoolVar(&ciMode, "ci", false, i18n.T("cmd.build.flag.ci"))
|
||||
buildCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, i18n.T("common.flag.verbose"))
|
||||
buildCmd.Flags().StringVar(&targets, "targets", "", i18n.T("cmd.build.flag.targets"))
|
||||
buildCmd.Flags().StringVar(&outputDir, "output", "", i18n.T("cmd.build.flag.output"))
|
||||
buildCmd.Flags().BoolVar(&doArchive, "archive", true, i18n.T("cmd.build.flag.archive"))
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import (
|
|||
)
|
||||
|
||||
// runProjectBuild handles the main `core build` command with auto-detection.
|
||||
func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool) error {
|
||||
func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool, verbose bool) error {
|
||||
// Get current working directory as project root
|
||||
projectDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
|
|
@ -80,8 +80,8 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
|
|||
binaryName = filepath.Base(projectDir)
|
||||
}
|
||||
|
||||
// Print build info (unless CI mode)
|
||||
if !ciMode {
|
||||
// Print build info (verbose mode only)
|
||||
if verbose && !ciMode {
|
||||
fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.build")), i18n.T("cmd.build.building_project"))
|
||||
fmt.Printf(" %s %s\n", i18n.T("cmd.build.label.type"), buildTargetStyle.Render(string(projectType)))
|
||||
fmt.Printf(" %s %s\n", i18n.T("cmd.build.label.output"), buildTargetStyle.Render(outputDir))
|
||||
|
|
@ -120,12 +120,12 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
|
|||
artifacts, err := builder.Build(ctx, cfg, buildTargets)
|
||||
if err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("common.error.failed", map[string]any{"Action": "build"}), err)
|
||||
fmt.Printf("%s %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
if verbose && !ciMode {
|
||||
fmt.Printf("%s %s\n", buildSuccessStyle.Render(i18n.T("common.label.success")), i18n.T("cmd.build.built_artifacts", map[string]interface{}{"Count": len(artifacts)}))
|
||||
fmt.Println()
|
||||
for _, artifact := range artifacts {
|
||||
|
|
@ -151,7 +151,7 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
|
|||
}
|
||||
|
||||
if signCfg.Enabled && runtime.GOOS == "darwin" {
|
||||
if !ciMode {
|
||||
if verbose && !ciMode {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.sign")), i18n.T("cmd.build.signing_binaries"))
|
||||
}
|
||||
|
|
@ -182,7 +182,7 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
|
|||
// Archive artifacts if enabled
|
||||
var archivedArtifacts []build.Artifact
|
||||
if doArchive && len(artifacts) > 0 {
|
||||
if !ciMode {
|
||||
if verbose && !ciMode {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.archive")), i18n.T("cmd.build.creating_archives"))
|
||||
}
|
||||
|
|
@ -195,7 +195,7 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
|
|||
return err
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
if verbose && !ciMode {
|
||||
for _, artifact := range archivedArtifacts {
|
||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||
if err != nil {
|
||||
|
|
@ -213,19 +213,19 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
|
|||
// Compute checksums if enabled
|
||||
var checksummedArtifacts []build.Artifact
|
||||
if doChecksum && len(archivedArtifacts) > 0 {
|
||||
checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, archivedArtifacts, signCfg, ciMode)
|
||||
checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, archivedArtifacts, signCfg, ciMode, verbose)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if doChecksum && len(artifacts) > 0 && !doArchive {
|
||||
// Checksum raw binaries if archiving is disabled
|
||||
checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, artifacts, signCfg, ciMode)
|
||||
checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, artifacts, signCfg, ciMode, verbose)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Output results for CI mode
|
||||
// Output results
|
||||
if ciMode {
|
||||
// Determine which artifacts to output (prefer checksummed > archived > raw)
|
||||
var outputArtifacts []build.Artifact
|
||||
|
|
@ -243,14 +243,21 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi
|
|||
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "marshal artifacts"}), err)
|
||||
}
|
||||
fmt.Println(string(output))
|
||||
} else if !verbose {
|
||||
// Minimal output: just success with artifact count
|
||||
fmt.Printf("%s %s %s\n",
|
||||
buildSuccessStyle.Render(i18n.T("common.label.success")),
|
||||
i18n.T("cmd.build.built_artifacts", map[string]interface{}{"Count": len(artifacts)}),
|
||||
buildDimStyle.Render(fmt.Sprintf("(%s)", outputDir)),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeAndWriteChecksums computes checksums for artifacts and writes CHECKSUMS.txt.
|
||||
func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, artifacts []build.Artifact, signCfg signing.SignConfig, ciMode bool) ([]build.Artifact, error) {
|
||||
if !ciMode {
|
||||
func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, artifacts []build.Artifact, signCfg signing.SignConfig, ciMode bool, verbose bool) ([]build.Artifact, error) {
|
||||
if verbose && !ciMode {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.checksum")), i18n.T("cmd.build.computing_checksums"))
|
||||
}
|
||||
|
|
@ -282,7 +289,7 @@ func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string,
|
|||
}
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
if verbose && !ciMode {
|
||||
for _, artifact := range checksummedArtifacts {
|
||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ func downloadPWA(baseURL, destDir string) error {
|
|||
if err != nil {
|
||||
return fmt.Errorf("%s %s: %w", i18n.T("common.error.failed", map[string]any{"Action": "fetch URL"}), baseURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
|
|
@ -152,7 +152,7 @@ func fetchManifest(manifestURL string) (map[string]interface{}, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var manifest map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
|
||||
|
|
@ -195,7 +195,7 @@ func downloadAsset(assetURL, destDir string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
u, err := url.Parse(assetURL)
|
||||
if err != nil {
|
||||
|
|
@ -211,7 +211,7 @@ func downloadAsset(assetURL, destDir string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
defer func() { _ = out.Close() }()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return err
|
||||
|
|
@ -310,13 +310,13 @@ func copyDir(src, dst string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
defer func() { _ = srcFile.Close() }()
|
||||
|
||||
dstFile, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
defer func() { _ = dstFile.Close() }()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []b
|
|||
if detected, _ := taskBuilder.Detect(cfg.ProjectDir); detected {
|
||||
return taskBuilder.Build(ctx, cfg, targets)
|
||||
}
|
||||
return nil, fmt.Errorf("Wails v3 projects require a Taskfile for building")
|
||||
return nil, fmt.Errorf("wails v3 projects require a Taskfile for building")
|
||||
}
|
||||
|
||||
// Wails v2 strategy: Use 'wails build'
|
||||
|
|
@ -252,15 +252,6 @@ func fileExists(path string) bool {
|
|||
return !info.IsDir()
|
||||
}
|
||||
|
||||
// dirExists checks if a directory exists.
|
||||
func dirExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.IsDir()
|
||||
}
|
||||
|
||||
// fileOrDirExists checks if a file or directory exists.
|
||||
func fileOrDirExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
|
|
|
|||
|
|
@ -60,35 +60,6 @@ tasks:
|
|||
return dir
|
||||
}
|
||||
|
||||
// setupWailsTestProjectWithFrontend creates a Wails project with frontend directory.
|
||||
func setupWailsTestProjectWithFrontend(t *testing.T, lockFile string) string {
|
||||
t.Helper()
|
||||
dir := setupWailsTestProject(t)
|
||||
|
||||
// Create frontend directory
|
||||
frontendDir := filepath.Join(dir, "frontend")
|
||||
err := os.MkdirAll(frontendDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create package.json
|
||||
packageJSON := `{
|
||||
"name": "frontend",
|
||||
"scripts": {
|
||||
"build": "echo building frontend"
|
||||
}
|
||||
}`
|
||||
err = os.WriteFile(filepath.Join(frontendDir, "package.json"), []byte(packageJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create lock file if specified
|
||||
if lockFile != "" {
|
||||
err = os.WriteFile(filepath.Join(frontendDir, lockFile), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
// setupWailsV2TestProject creates a Wails v2 project structure.
|
||||
func setupWailsV2TestProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
|
@ -181,12 +152,8 @@ func TestWailsBuilder_Build_V2_Good(t *testing.T) {
|
|||
// This will likely fail in a real run because we can't easily mock the full wails v2 build process
|
||||
// (which needs a valid project with main.go etc).
|
||||
// But it validates we are trying to run the command.
|
||||
// For now, we expect an error but check it's the *right* error (from wails CLI)
|
||||
_, err := builder.Build(context.Background(), cfg, targets)
|
||||
if err != nil {
|
||||
// If it fails, it should be because wails build failed, not because logic was wrong
|
||||
// assert.Contains(t, err.Error(), "wails build failed")
|
||||
}
|
||||
// For now, we just verify it attempts the build - error is expected
|
||||
_, _ = builder.Build(context.Background(), cfg, targets)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ func ConfigExists(dir string) bool {
|
|||
func (cfg *BuildConfig) ToTargets() []Target {
|
||||
targets := make([]Target, len(cfg.Targets))
|
||||
for i, t := range cfg.Targets {
|
||||
targets[i] = Target{OS: t.OS, Arch: t.Arch}
|
||||
targets[i] = Target(t)
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ func (p *PIDFile) Acquire() error {
|
|||
}
|
||||
}
|
||||
// Stale PID file, remove it
|
||||
os.Remove(p.path)
|
||||
_ = os.Remove(p.path)
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
|
|
@ -183,13 +183,13 @@ func (h *HealthServer) Start() error {
|
|||
for _, check := range checks {
|
||||
if err := check(); err != nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
fmt.Fprintf(w, "unhealthy: %v\n", err)
|
||||
_, _ = fmt.Fprintf(w, "unhealthy: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintln(w, "ok")
|
||||
_, _ = fmt.Fprintln(w, "ok")
|
||||
})
|
||||
|
||||
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -199,12 +199,12 @@ func (h *HealthServer) Start() error {
|
|||
|
||||
if !ready {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
fmt.Fprintln(w, "not ready")
|
||||
_, _ = fmt.Fprintln(w, "not ready")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintln(w, "ready")
|
||||
_, _ = fmt.Fprintln(w, "ready")
|
||||
})
|
||||
|
||||
listener, err := net.Listen("tcp", h.addr)
|
||||
|
|
@ -322,7 +322,7 @@ func (d *Daemon) Start() error {
|
|||
if d.health != nil {
|
||||
if err := d.health.Start(); err != nil {
|
||||
if d.pid != nil {
|
||||
d.pid.Release()
|
||||
_ = d.pid.Release()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ func TestHealthServer(t *testing.T) {
|
|||
|
||||
err := hs.Start()
|
||||
require.NoError(t, err)
|
||||
defer hs.Stop(context.Background())
|
||||
defer func() { _ = hs.Stop(context.Background()) }()
|
||||
|
||||
addr := hs.Addr()
|
||||
require.NotEmpty(t, addr)
|
||||
|
|
@ -135,7 +135,7 @@ func TestHealthServer(t *testing.T) {
|
|||
|
||||
err := hs.Start()
|
||||
require.NoError(t, err)
|
||||
defer hs.Stop(context.Background())
|
||||
defer func() { _ = hs.Stop(context.Background()) }()
|
||||
|
||||
addr := hs.Addr()
|
||||
|
||||
|
|
@ -193,7 +193,7 @@ func TestDaemon(t *testing.T) {
|
|||
|
||||
err := d.Start()
|
||||
require.NoError(t, err)
|
||||
defer d.Stop()
|
||||
defer func() { _ = d.Stop() }()
|
||||
|
||||
err = d.Start()
|
||||
assert.Error(t, err)
|
||||
|
|
@ -218,7 +218,7 @@ func TestDaemon(t *testing.T) {
|
|||
|
||||
err := d.Start()
|
||||
require.NoError(t, err)
|
||||
defer d.Stop()
|
||||
defer func() { _ = d.Stop() }()
|
||||
|
||||
addr := d.HealthAddr()
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ func NewI18nService(opts I18nOptions) func(*framework.Core) (any, error) {
|
|||
}
|
||||
|
||||
if opts.Language != "" {
|
||||
svc.SetLanguage(opts.Language)
|
||||
_ = svc.SetLanguage(opts.Language)
|
||||
}
|
||||
|
||||
// Set mode if specified
|
||||
|
|
@ -131,7 +131,7 @@ func (s *I18nService) T(key string, args ...map[string]any) string {
|
|||
|
||||
// SetLanguage changes the current language.
|
||||
func (s *I18nService) SetLanguage(lang string) {
|
||||
s.svc.SetLanguage(lang)
|
||||
_ = s.svc.SetLanguage(lang)
|
||||
}
|
||||
|
||||
// Language returns the current language.
|
||||
|
|
|
|||
|
|
@ -98,9 +98,10 @@ func isValidRegion(r Region) bool {
|
|||
func findMatchingBracket(s string, start int) int {
|
||||
depth := 0
|
||||
for i := start; i < len(s); i++ {
|
||||
if s[i] == '[' {
|
||||
switch s[i] {
|
||||
case '[':
|
||||
depth++
|
||||
} else if s[i] == ']' {
|
||||
case ']':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i
|
||||
|
|
|
|||
|
|
@ -8,11 +8,17 @@ import (
|
|||
// LogLevel aliases for backwards compatibility.
|
||||
type LogLevel = log.Level
|
||||
|
||||
// Log level constants aliased from the log package.
|
||||
const (
|
||||
// LogLevelQuiet suppresses all output.
|
||||
LogLevelQuiet = log.LevelQuiet
|
||||
// LogLevelError shows only error messages.
|
||||
LogLevelError = log.LevelError
|
||||
// LogLevelWarn shows warnings and errors.
|
||||
LogLevelWarn = log.LevelWarn
|
||||
// LogLevelInfo shows info, warnings, and errors.
|
||||
LogLevelInfo = log.LevelInfo
|
||||
// LogLevelDebug shows all messages including debug.
|
||||
LogLevelDebug = log.LevelDebug
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ func captureOutput(f func()) string {
|
|||
|
||||
f()
|
||||
|
||||
w.Close()
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
_, _ = io.Copy(&buf, r)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ func Prompt(label, defaultVal string) (string, error) {
|
|||
return input, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Select presents numbered options and returns the selected value.
|
||||
func Select(label string, options []string) (string, error) {
|
||||
fmt.Println(label)
|
||||
|
|
|
|||
|
|
@ -8,16 +8,25 @@ import (
|
|||
// RenderStyle controls how layouts are rendered.
|
||||
type RenderStyle int
|
||||
|
||||
// Render style constants for layout output.
|
||||
const (
|
||||
RenderFlat RenderStyle = iota // No borders
|
||||
RenderSimple // --- separators
|
||||
RenderBoxed // Unicode box drawing
|
||||
// RenderFlat uses no borders or decorations.
|
||||
RenderFlat RenderStyle = iota
|
||||
// RenderSimple uses --- separators between sections.
|
||||
RenderSimple
|
||||
// RenderBoxed uses Unicode box drawing characters.
|
||||
RenderBoxed
|
||||
)
|
||||
|
||||
var currentRenderStyle = RenderFlat
|
||||
|
||||
// UseRenderFlat sets the render style to flat (no borders).
|
||||
func UseRenderFlat() { currentRenderStyle = RenderFlat }
|
||||
|
||||
// UseRenderSimple sets the render style to simple (--- separators).
|
||||
func UseRenderSimple() { currentRenderStyle = RenderSimple }
|
||||
|
||||
// UseRenderBoxed sets the render style to boxed (Unicode box drawing).
|
||||
func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
|
||||
|
||||
// Render outputs the layout to terminal.
|
||||
|
|
|
|||
|
|
@ -141,11 +141,9 @@ func Shutdown() {
|
|||
return
|
||||
}
|
||||
instance.cancel()
|
||||
instance.core.ServiceShutdown(instance.ctx)
|
||||
_ = instance.core.ServiceShutdown(instance.ctx)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Signal Service (internal) ---
|
||||
|
||||
type signalService struct {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const (
|
|||
ColourAmber600 = "#d97706"
|
||||
ColourOrange500 = "#f97316"
|
||||
ColourYellow500 = "#eab308"
|
||||
ColourEmerald500= "#10b981"
|
||||
ColourEmerald500 = "#10b981"
|
||||
ColourPurple500 = "#a855f7"
|
||||
ColourViolet400 = "#a78bfa"
|
||||
ColourViolet500 = "#8b5cf6"
|
||||
|
|
@ -112,6 +112,7 @@ type Table struct {
|
|||
Style TableStyle
|
||||
}
|
||||
|
||||
// TableStyle configures the appearance of table output.
|
||||
type TableStyle struct {
|
||||
HeaderStyle *AnsiStyle
|
||||
CellStyle *AnsiStyle
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ func GhAuthenticated() bool {
|
|||
return strings.Contains(string(output), "Logged in")
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ConfirmOption configures Confirm behaviour.
|
||||
type ConfirmOption func(*confirmConfig)
|
||||
|
||||
|
|
@ -473,8 +471,6 @@ func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOpt
|
|||
return ChooseMulti(question, items, opts...)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// GitClone clones a GitHub repository to the specified path.
|
||||
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
|
||||
func GitClone(ctx context.Context, org, repo, path string) error {
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
|
|||
|
||||
// Start the process
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
_ = logFile.Close()
|
||||
return nil, fmt.Errorf("failed to start VM: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -146,13 +146,13 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
|
|||
// Save state
|
||||
if err := m.state.Add(container); err != nil {
|
||||
// Try to kill the process we just started
|
||||
cmd.Process.Kill()
|
||||
logFile.Close()
|
||||
_ = cmd.Process.Kill()
|
||||
_ = logFile.Close()
|
||||
return nil, fmt.Errorf("failed to save state: %w", err)
|
||||
}
|
||||
|
||||
// Close log file handle (process has its own)
|
||||
logFile.Close()
|
||||
_ = logFile.Close()
|
||||
|
||||
// Start a goroutine to wait for process exit and update state
|
||||
go m.waitForExit(container.ID, cmd)
|
||||
|
|
@ -170,12 +170,12 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
|
|||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
logFile.Close()
|
||||
_ = logFile.Close()
|
||||
return nil, fmt.Errorf("failed to get stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
_ = logFile.Close()
|
||||
return nil, fmt.Errorf("failed to start VM: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -183,19 +183,19 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
|
|||
|
||||
// Save state before waiting
|
||||
if err := m.state.Add(container); err != nil {
|
||||
cmd.Process.Kill()
|
||||
logFile.Close()
|
||||
_ = cmd.Process.Kill()
|
||||
_ = logFile.Close()
|
||||
return nil, fmt.Errorf("failed to save state: %w", err)
|
||||
}
|
||||
|
||||
// Copy output to both log and stdout
|
||||
go func() {
|
||||
mw := io.MultiWriter(logFile, os.Stdout)
|
||||
io.Copy(mw, stdout)
|
||||
_, _ = io.Copy(mw, stdout)
|
||||
}()
|
||||
go func() {
|
||||
mw := io.MultiWriter(logFile, os.Stderr)
|
||||
io.Copy(mw, stderr)
|
||||
_, _ = io.Copy(mw, stderr)
|
||||
}()
|
||||
|
||||
// Wait for the process to complete
|
||||
|
|
@ -205,20 +205,26 @@ func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions
|
|||
container.Status = StatusStopped
|
||||
}
|
||||
|
||||
logFile.Close()
|
||||
m.state.Update(container)
|
||||
_ = logFile.Close()
|
||||
if err := m.state.Update(container); err != nil {
|
||||
return container, fmt.Errorf("update container state: %w", err)
|
||||
}
|
||||
|
||||
return container, nil
|
||||
}
|
||||
|
||||
// waitForExit monitors a detached process and updates state when it exits.
|
||||
func (m *LinuxKitManager) waitForExit(id string, cmd *exec.Cmd) {
|
||||
cmd.Wait()
|
||||
err := cmd.Wait()
|
||||
|
||||
container, ok := m.state.Get(id)
|
||||
if ok {
|
||||
if err != nil {
|
||||
container.Status = StatusError
|
||||
} else {
|
||||
container.Status = StatusStopped
|
||||
m.state.Update(container)
|
||||
}
|
||||
_ = m.state.Update(container)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -238,7 +244,7 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error {
|
|||
if err != nil {
|
||||
// Process doesn't exist, update state
|
||||
container.Status = StatusStopped
|
||||
m.state.Update(container)
|
||||
_ = m.state.Update(container)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -246,14 +252,14 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error {
|
|||
if err := process.Signal(syscall.SIGTERM); err != nil {
|
||||
// Process might already be gone
|
||||
container.Status = StatusStopped
|
||||
m.state.Update(container)
|
||||
_ = m.state.Update(container)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait for graceful shutdown with timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
process.Wait()
|
||||
_, _ = process.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
|
|
@ -262,11 +268,11 @@ func (m *LinuxKitManager) Stop(ctx context.Context, id string) error {
|
|||
// Process exited gracefully
|
||||
case <-time.After(10 * time.Second):
|
||||
// Force kill
|
||||
process.Signal(syscall.SIGKILL)
|
||||
_ = process.Signal(syscall.SIGKILL)
|
||||
<-done
|
||||
case <-ctx.Done():
|
||||
// Context cancelled
|
||||
process.Signal(syscall.SIGKILL)
|
||||
_ = process.Signal(syscall.SIGKILL)
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
|
|
@ -283,7 +289,7 @@ func (m *LinuxKitManager) List(ctx context.Context) ([]*Container, error) {
|
|||
if c.Status == StatusRunning {
|
||||
if !isProcessRunning(c.PID) {
|
||||
c.Status = StatusStopped
|
||||
m.state.Update(c)
|
||||
_ = m.state.Update(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -346,7 +352,7 @@ func newFollowReader(ctx context.Context, path string) (*followReader, error) {
|
|||
}
|
||||
|
||||
// Seek to end
|
||||
file.Seek(0, io.SeekEnd)
|
||||
_, _ = file.Seek(0, io.SeekEnd)
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ func TestLinuxKitManager_Stop_Good(t *testing.T) {
|
|||
PID: 999999, // Non-existent PID
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
manager.State().Add(container)
|
||||
_ = manager.State().Add(container)
|
||||
|
||||
ctx := context.Background()
|
||||
err := manager.Stop(ctx, "abc12345")
|
||||
|
|
@ -213,17 +213,18 @@ func TestLinuxKitManager_Stop_Bad_NotFound(t *testing.T) {
|
|||
func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) {
|
||||
manager, _, tmpDir := newTestManager(t)
|
||||
statePath := filepath.Join(tmpDir, "containers.json")
|
||||
state, _ := LoadState(statePath)
|
||||
state, err := LoadState(statePath)
|
||||
require.NoError(t, err)
|
||||
manager = NewLinuxKitManagerWithHypervisor(state, NewMockHypervisor())
|
||||
|
||||
container := &Container{
|
||||
ID: "abc12345",
|
||||
Status: StatusStopped,
|
||||
}
|
||||
state.Add(container)
|
||||
_ = state.Add(container)
|
||||
|
||||
ctx := context.Background()
|
||||
err := manager.Stop(ctx, "abc12345")
|
||||
err = manager.Stop(ctx, "abc12345")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not running")
|
||||
|
|
@ -232,11 +233,12 @@ func TestLinuxKitManager_Stop_Bad_NotRunning(t *testing.T) {
|
|||
func TestLinuxKitManager_List_Good(t *testing.T) {
|
||||
manager, _, tmpDir := newTestManager(t)
|
||||
statePath := filepath.Join(tmpDir, "containers.json")
|
||||
state, _ := LoadState(statePath)
|
||||
state, err := LoadState(statePath)
|
||||
require.NoError(t, err)
|
||||
manager = NewLinuxKitManagerWithHypervisor(state, NewMockHypervisor())
|
||||
|
||||
state.Add(&Container{ID: "aaa11111", Status: StatusStopped})
|
||||
state.Add(&Container{ID: "bbb22222", Status: StatusStopped})
|
||||
_ = state.Add(&Container{ID: "aaa11111", Status: StatusStopped})
|
||||
_ = state.Add(&Container{ID: "bbb22222", Status: StatusStopped})
|
||||
|
||||
ctx := context.Background()
|
||||
containers, err := manager.List(ctx)
|
||||
|
|
@ -248,11 +250,12 @@ func TestLinuxKitManager_List_Good(t *testing.T) {
|
|||
func TestLinuxKitManager_List_Good_VerifiesRunningStatus(t *testing.T) {
|
||||
manager, _, tmpDir := newTestManager(t)
|
||||
statePath := filepath.Join(tmpDir, "containers.json")
|
||||
state, _ := LoadState(statePath)
|
||||
state, err := LoadState(statePath)
|
||||
require.NoError(t, err)
|
||||
manager = NewLinuxKitManagerWithHypervisor(state, NewMockHypervisor())
|
||||
|
||||
// Add a "running" container with a fake PID that doesn't exist
|
||||
state.Add(&Container{
|
||||
_ = state.Add(&Container{
|
||||
ID: "abc12345",
|
||||
Status: StatusRunning,
|
||||
PID: 999999, // PID that almost certainly doesn't exist
|
||||
|
|
@ -272,17 +275,18 @@ func TestLinuxKitManager_Logs_Good(t *testing.T) {
|
|||
|
||||
// Create a log file manually
|
||||
logsDir := filepath.Join(tmpDir, "logs")
|
||||
os.MkdirAll(logsDir, 0755)
|
||||
require.NoError(t, os.MkdirAll(logsDir, 0755))
|
||||
|
||||
container := &Container{ID: "abc12345"}
|
||||
manager.State().Add(container)
|
||||
_ = manager.State().Add(container)
|
||||
|
||||
// Override the default logs dir for testing by creating the log file
|
||||
// at the expected location
|
||||
logContent := "test log content\nline 2\n"
|
||||
logPath, _ := LogPath("abc12345")
|
||||
os.MkdirAll(filepath.Dir(logPath), 0755)
|
||||
os.WriteFile(logPath, []byte(logContent), 0644)
|
||||
logPath, err := LogPath("abc12345")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755))
|
||||
require.NoError(t, os.WriteFile(logPath, []byte(logContent), 0644))
|
||||
|
||||
ctx := context.Background()
|
||||
reader, err := manager.Logs(ctx, "abc12345", false)
|
||||
|
|
@ -309,9 +313,10 @@ func TestLinuxKitManager_Logs_Bad_NoLogFile(t *testing.T) {
|
|||
manager, _, _ := newTestManager(t)
|
||||
|
||||
// Use a unique ID that won't have a log file
|
||||
uniqueID, _ := GenerateID()
|
||||
uniqueID, err := GenerateID()
|
||||
require.NoError(t, err)
|
||||
container := &Container{ID: uniqueID}
|
||||
manager.State().Add(container)
|
||||
_ = manager.State().Add(container)
|
||||
|
||||
ctx := context.Background()
|
||||
reader, err := manager.Logs(ctx, uniqueID, false)
|
||||
|
|
@ -341,7 +346,7 @@ func TestLinuxKitManager_Exec_Bad_NotRunning(t *testing.T) {
|
|||
manager, _, _ := newTestManager(t)
|
||||
|
||||
container := &Container{ID: "abc12345", Status: StatusStopped}
|
||||
manager.State().Add(container)
|
||||
_ = manager.State().Add(container)
|
||||
|
||||
ctx := context.Background()
|
||||
err := manager.Exec(ctx, "abc12345", []string{"ls"})
|
||||
|
|
@ -420,19 +425,19 @@ func TestQemuHypervisor_BuildCommand_Good(t *testing.T) {
|
|||
assert.Contains(t, args, "-nographic")
|
||||
}
|
||||
|
||||
|
||||
func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) {
|
||||
manager, _, _ := newTestManager(t)
|
||||
|
||||
// Create a unique container ID
|
||||
uniqueID, _ := GenerateID()
|
||||
uniqueID, err := GenerateID()
|
||||
require.NoError(t, err)
|
||||
container := &Container{ID: uniqueID}
|
||||
manager.State().Add(container)
|
||||
_ = manager.State().Add(container)
|
||||
|
||||
// Create a log file at the expected location
|
||||
logPath, err := LogPath(uniqueID)
|
||||
require.NoError(t, err)
|
||||
os.MkdirAll(filepath.Dir(logPath), 0755)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(logPath), 0755))
|
||||
|
||||
// Write initial content
|
||||
err = os.WriteFile(logPath, []byte("initial log content\n"), 0644)
|
||||
|
|
@ -455,8 +460,7 @@ func TestLinuxKitManager_Logs_Good_Follow(t *testing.T) {
|
|||
assert.Equal(t, "EOF", readErr.Error())
|
||||
|
||||
// Close the reader
|
||||
err = reader.Close()
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, reader.Close())
|
||||
}
|
||||
|
||||
func TestFollowReader_Read_Good_WithData(t *testing.T) {
|
||||
|
|
@ -480,7 +484,7 @@ func TestFollowReader_Read_Good_WithData(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
_, err = f.WriteString("new line\n")
|
||||
require.NoError(t, err)
|
||||
f.Close()
|
||||
require.NoError(t, f.Close())
|
||||
|
||||
// Give the reader time to poll
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
|
@ -513,7 +517,7 @@ func TestFollowReader_Read_Good_ContextCancel(t *testing.T) {
|
|||
_, readErr := reader.Read(buf)
|
||||
assert.Equal(t, "EOF", readErr.Error())
|
||||
|
||||
reader.Close()
|
||||
_ = reader.Close()
|
||||
}
|
||||
|
||||
func TestFollowReader_Close_Good(t *testing.T) {
|
||||
|
|
@ -766,7 +770,7 @@ func TestLinuxKitManager_Stop_Good_ProcessExitedWhileRunning(t *testing.T) {
|
|||
PID: 999999, // Non-existent PID
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
manager.State().Add(container)
|
||||
_ = manager.State().Add(container)
|
||||
|
||||
ctx := context.Background()
|
||||
err := manager.Stop(ctx, "test1234")
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ var (
|
|||
applyContinue bool // Continue on error
|
||||
)
|
||||
|
||||
// addApplyCommand adds the 'apply' command to dev.
|
||||
func addApplyCommand(parent *cli.Command) {
|
||||
// AddApplyCommand adds the 'apply' command to dev.
|
||||
func AddApplyCommand(parent *cli.Command) {
|
||||
applyCmd := &cli.Command{
|
||||
Use: "apply",
|
||||
Short: i18n.T("cmd.dev.apply.short"),
|
||||
|
|
|
|||
|
|
@ -63,9 +63,7 @@ type StatusBundleOptions struct {
|
|||
// Includes: dev (orchestration), git services. No agentic - commits not available.
|
||||
func NewStatusBundle(opts StatusBundleOptions) (*StatusBundle, error) {
|
||||
c, err := framework.New(
|
||||
framework.WithService(NewService(ServiceOptions{
|
||||
RegistryPath: opts.RegistryPath,
|
||||
})),
|
||||
framework.WithService(NewService(ServiceOptions(opts))),
|
||||
framework.WithService(git.NewService(git.ServiceOptions{})),
|
||||
// No agentic service - TaskCommit will be unhandled
|
||||
framework.WithServiceLock(),
|
||||
|
|
|
|||
|
|
@ -229,11 +229,12 @@ func printWorkflowRun(run WorkflowRun) {
|
|||
case "failure":
|
||||
status = ciFailureStyle.Render("x")
|
||||
case "":
|
||||
if run.Status == "in_progress" {
|
||||
switch run.Status {
|
||||
case "in_progress":
|
||||
status = ciPendingStyle.Render("*")
|
||||
} else if run.Status == "queued" {
|
||||
case "queued":
|
||||
status = ciPendingStyle.Render("o")
|
||||
} else {
|
||||
default:
|
||||
status = ciSkippedStyle.Render("-")
|
||||
}
|
||||
case "skipped":
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ var (
|
|||
commitAll bool
|
||||
)
|
||||
|
||||
// addCommitCommand adds the 'commit' command to the given parent command.
|
||||
func addCommitCommand(parent *cli.Command) {
|
||||
// AddCommitCommand adds the 'commit' command to the given parent command.
|
||||
func AddCommitCommand(parent *cli.Command) {
|
||||
commitCmd := &cli.Command{
|
||||
Use: "commit",
|
||||
Short: i18n.T("cmd.dev.commit.short"),
|
||||
|
|
|
|||
|
|
@ -68,16 +68,16 @@ func AddDevCommands(root *cli.Command) {
|
|||
}
|
||||
root.AddCommand(devCmd)
|
||||
|
||||
// Git operations
|
||||
addWorkCommand(devCmd)
|
||||
addHealthCommand(devCmd)
|
||||
addCommitCommand(devCmd)
|
||||
addPushCommand(devCmd)
|
||||
addPullCommand(devCmd)
|
||||
// Git operations (also available under 'core git')
|
||||
AddWorkCommand(devCmd)
|
||||
AddHealthCommand(devCmd)
|
||||
AddCommitCommand(devCmd)
|
||||
AddPushCommand(devCmd)
|
||||
AddPullCommand(devCmd)
|
||||
|
||||
// Safe git operations for AI agents
|
||||
addFileSyncCommand(devCmd)
|
||||
addApplyCommand(devCmd)
|
||||
// Safe git operations for AI agents (also available under 'core git')
|
||||
AddFileSyncCommand(devCmd)
|
||||
AddApplyCommand(devCmd)
|
||||
|
||||
// GitHub integration
|
||||
addIssuesCommand(devCmd)
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ var (
|
|||
fileSyncPush bool
|
||||
)
|
||||
|
||||
// addFileSyncCommand adds the 'sync' command to dev for file syncing.
|
||||
func addFileSyncCommand(parent *cli.Command) {
|
||||
// AddFileSyncCommand adds the 'sync' command to dev for file syncing.
|
||||
func AddFileSyncCommand(parent *cli.Command) {
|
||||
syncCmd := &cli.Command{
|
||||
Use: "sync <file-or-dir>",
|
||||
Short: i18n.T("cmd.dev.file_sync.short"),
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ var (
|
|||
healthVerbose bool
|
||||
)
|
||||
|
||||
// addHealthCommand adds the 'health' command to the given parent command.
|
||||
func addHealthCommand(parent *cli.Command) {
|
||||
// AddHealthCommand adds the 'health' command to the given parent command.
|
||||
func AddHealthCommand(parent *cli.Command) {
|
||||
healthCmd := &cli.Command{
|
||||
Use: "health",
|
||||
Short: i18n.T("cmd.dev.health.short"),
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ var (
|
|||
pullAll bool
|
||||
)
|
||||
|
||||
// addPullCommand adds the 'pull' command to the given parent command.
|
||||
func addPullCommand(parent *cli.Command) {
|
||||
// AddPullCommand adds the 'pull' command to the given parent command.
|
||||
func AddPullCommand(parent *cli.Command) {
|
||||
pullCmd := &cli.Command{
|
||||
Use: "pull",
|
||||
Short: i18n.T("cmd.dev.pull.short"),
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ var (
|
|||
pushForce bool
|
||||
)
|
||||
|
||||
// addPushCommand adds the 'push' command to the given parent command.
|
||||
func addPushCommand(parent *cli.Command) {
|
||||
// AddPushCommand adds the 'push' command to the given parent command.
|
||||
func AddPushCommand(parent *cli.Command) {
|
||||
pushCmd := &cli.Command{
|
||||
Use: "push",
|
||||
Short: i18n.T("cmd.dev.push.short"),
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ var (
|
|||
workRegistryPath string
|
||||
)
|
||||
|
||||
// addWorkCommand adds the 'work' command to the given parent command.
|
||||
func addWorkCommand(parent *cli.Command) {
|
||||
// AddWorkCommand adds the 'work' command to the given parent command.
|
||||
func AddWorkCommand(parent *cli.Command) {
|
||||
workCmd := &cli.Command{
|
||||
Use: "work",
|
||||
Short: i18n.T("cmd.dev.work.short"),
|
||||
|
|
@ -342,5 +342,3 @@ func claudeEditCommit(ctx context.Context, repoPath, repoName, registryPath stri
|
|||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ func (s *CDNSource) LatestVersion(ctx context.Context) (string, error) {
|
|||
if err != nil || resp.StatusCode != 200 {
|
||||
return "latest", nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// For now, just return latest - could parse manifest for version
|
||||
return "latest", nil
|
||||
|
|
@ -64,7 +64,7 @@ func (s *CDNSource) Download(ctx context.Context, dest string, progress func(dow
|
|||
if err != nil {
|
||||
return fmt.Errorf("cdn.Download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("cdn.Download: HTTP %d", resp.StatusCode)
|
||||
|
|
@ -81,7 +81,7 @@ func (s *CDNSource) Download(ctx context.Context, dest string, progress func(dow
|
|||
if err != nil {
|
||||
return fmt.Errorf("cdn.Download: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
// Copy with progress
|
||||
total := resp.ContentLength
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ func TestCDNSource_LatestVersion_Good(t *testing.T) {
|
|||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/manifest.json" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"version": "1.2.3"}`)
|
||||
_, _ = fmt.Fprint(w, `{"version": "1.2.3"}`)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ func TestCDNSource_Download_Good(t *testing.T) {
|
|||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/test.img" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, content)
|
||||
_, _ = fmt.Fprint(w, content)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
|
@ -151,7 +151,7 @@ func TestCDNSource_Download_Good_NoProgress(t *testing.T) {
|
|||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, content)
|
||||
_, _ = fmt.Fprint(w, content)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -180,7 +180,7 @@ func TestCDNSource_Download_Good_LargeFile(t *testing.T) {
|
|||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(content)
|
||||
_, _ = w.Write(content)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -268,7 +268,7 @@ func TestCDNSource_Download_Good_CreatesDestDir(t *testing.T) {
|
|||
content := "test content"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, content)
|
||||
_, _ = fmt.Fprint(w, content)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ var (
|
|||
headerStyle = cli.HeaderStyle
|
||||
confirm = cli.Confirm
|
||||
docsFoundStyle = cli.SuccessStyle
|
||||
docsMissingStyle = cli.DimStyle
|
||||
docsFileStyle = cli.InfoStyle
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -356,6 +356,7 @@ func (c *Core) Display() Display {
|
|||
return d
|
||||
}
|
||||
|
||||
// Core returns self, implementing the CoreProvider interface.
|
||||
func (c *Core) Core() *Core { return c }
|
||||
|
||||
// Assets returns the embedded filesystem containing the application's assets.
|
||||
|
|
|
|||
|
|
@ -73,8 +73,6 @@ type Stoppable interface {
|
|||
|
||||
// Core is the central application object that manages services, assets, and communication.
|
||||
type Core struct {
|
||||
once sync.Once
|
||||
initErr error
|
||||
App any // GUI runtime (e.g., Wails App) - set by WithApp option
|
||||
assets embed.FS
|
||||
Features *Features
|
||||
|
|
|
|||
|
|
@ -55,11 +55,12 @@ func NewServiceRuntime[T any](c *Core, opts T) *ServiceRuntime[T] {
|
|||
return core.NewServiceRuntime(c, opts)
|
||||
}
|
||||
|
||||
// Re-export generic functions
|
||||
// ServiceFor retrieves a typed service from the core container by name.
|
||||
func ServiceFor[T any](c *Core, name string) (T, error) {
|
||||
return core.ServiceFor[T](c, name)
|
||||
}
|
||||
|
||||
// MustServiceFor retrieves a typed service or panics if not found.
|
||||
func MustServiceFor[T any](c *Core, name string) T {
|
||||
return core.MustServiceFor[T](c, name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -249,6 +249,7 @@ type GitError struct {
|
|||
Stderr string
|
||||
}
|
||||
|
||||
// Error returns the git error message, preferring stderr output.
|
||||
func (e *GitError) Error() string {
|
||||
// Return just the stderr message, trimmed
|
||||
msg := strings.TrimSpace(e.Stderr)
|
||||
|
|
@ -258,6 +259,7 @@ func (e *GitError) Error() string {
|
|||
return e.Err.Error()
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error for error chain inspection.
|
||||
func (e *GitError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,10 +70,7 @@ func (s *Service) OnStartup(ctx context.Context) error {
|
|||
func (s *Service) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) {
|
||||
switch m := q.(type) {
|
||||
case QueryStatus:
|
||||
statuses := Status(context.Background(), StatusOptions{
|
||||
Paths: m.Paths,
|
||||
Names: m.Names,
|
||||
})
|
||||
statuses := Status(context.Background(), StatusOptions(m))
|
||||
s.lastStatus = statuses
|
||||
return statuses, true, nil
|
||||
|
||||
|
|
|
|||
44
pkg/gitcmd/cmd_git.go
Normal file
44
pkg/gitcmd/cmd_git.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Package gitcmd provides git workflow commands as a root-level command.
|
||||
//
|
||||
// Git Operations:
|
||||
// - health: Show status across repos
|
||||
// - commit: Claude-assisted commit message generation
|
||||
// - push: Push repos with unpushed commits
|
||||
// - pull: Pull repos that are behind remote
|
||||
// - work: Combined status, commit, and push workflow
|
||||
//
|
||||
// Safe Operations (for AI agents):
|
||||
// - file-sync: Sync files across repos with auto commit/push
|
||||
// - apply: Run command across repos with auto commit/push
|
||||
package gitcmd
|
||||
|
||||
import (
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/dev"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddGitCommands)
|
||||
}
|
||||
|
||||
// AddGitCommands registers the 'git' command and all subcommands.
|
||||
func AddGitCommands(root *cli.Command) {
|
||||
gitCmd := &cli.Command{
|
||||
Use: "git",
|
||||
Short: i18n.T("cmd.git.short"),
|
||||
Long: i18n.T("cmd.git.long"),
|
||||
}
|
||||
root.AddCommand(gitCmd)
|
||||
|
||||
// Import git commands from dev package
|
||||
dev.AddHealthCommand(gitCmd) // Shows repo status
|
||||
dev.AddCommitCommand(gitCmd)
|
||||
dev.AddPushCommand(gitCmd)
|
||||
dev.AddPullCommand(gitCmd)
|
||||
dev.AddWorkCommand(gitCmd)
|
||||
|
||||
// Safe operations for AI agents
|
||||
dev.AddFileSyncCommand(gitCmd)
|
||||
dev.AddApplyCommand(gitCmd)
|
||||
}
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
|
|
@ -12,14 +15,34 @@ var (
|
|||
fmtFix bool
|
||||
fmtDiff bool
|
||||
fmtCheck bool
|
||||
fmtAll bool
|
||||
)
|
||||
|
||||
func addGoFmtCommand(parent *cli.Command) {
|
||||
fmtCmd := &cli.Command{
|
||||
Use: "fmt",
|
||||
Short: "Format Go code",
|
||||
Long: "Format Go code using goimports or gofmt",
|
||||
Long: "Format Go code using goimports or gofmt. By default only checks changed files.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
// Get list of files to check
|
||||
var files []string
|
||||
if fmtAll {
|
||||
// Check all Go files
|
||||
files = []string{"."}
|
||||
} else {
|
||||
// Only check changed Go files (git-aware)
|
||||
files = getChangedGoFiles()
|
||||
if len(files) == 0 {
|
||||
cli.Print("%s\n", i18n.T("cmd.go.fmt.no_changes"))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Validate flag combinations
|
||||
if fmtCheck && fmtFix {
|
||||
return cli.Err("--check and --fix are mutually exclusive")
|
||||
}
|
||||
|
||||
fmtArgs := []string{}
|
||||
if fmtFix {
|
||||
fmtArgs = append(fmtArgs, "-w")
|
||||
|
|
@ -30,7 +53,7 @@ func addGoFmtCommand(parent *cli.Command) {
|
|||
if !fmtFix && !fmtDiff {
|
||||
fmtArgs = append(fmtArgs, "-l")
|
||||
}
|
||||
fmtArgs = append(fmtArgs, ".")
|
||||
fmtArgs = append(fmtArgs, files...)
|
||||
|
||||
// Try goimports first, fall back to gofmt
|
||||
var execCmd *exec.Cmd
|
||||
|
|
@ -40,6 +63,20 @@ func addGoFmtCommand(parent *cli.Command) {
|
|||
execCmd = exec.Command("gofmt", fmtArgs...)
|
||||
}
|
||||
|
||||
// For --check mode, capture output to detect unformatted files
|
||||
if fmtCheck {
|
||||
output, err := execCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
os.Stderr.Write(output)
|
||||
return err
|
||||
}
|
||||
if len(output) > 0 {
|
||||
os.Stdout.Write(output)
|
||||
return cli.Err("files need formatting (use --fix)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
|
|
@ -47,25 +84,85 @@ func addGoFmtCommand(parent *cli.Command) {
|
|||
}
|
||||
|
||||
fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("common.flag.fix"))
|
||||
fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, "Show diff of changes")
|
||||
fmtCmd.Flags().BoolVar(&fmtCheck, "check", false, "Check if formatted (exit 1 if not)")
|
||||
fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, i18n.T("common.flag.diff"))
|
||||
fmtCmd.Flags().BoolVar(&fmtCheck, "check", false, i18n.T("cmd.go.fmt.flag.check"))
|
||||
fmtCmd.Flags().BoolVar(&fmtAll, "all", false, i18n.T("cmd.go.fmt.flag.all"))
|
||||
|
||||
parent.AddCommand(fmtCmd)
|
||||
}
|
||||
|
||||
var lintFix bool
|
||||
// getChangedGoFiles returns Go files that have been modified, staged, or are untracked.
|
||||
func getChangedGoFiles() []string {
|
||||
var files []string
|
||||
|
||||
// Get modified and staged files
|
||||
cmd := exec.Command("git", "diff", "--name-only", "--diff-filter=ACMR", "HEAD")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
files = append(files, filterGoFiles(string(output))...)
|
||||
}
|
||||
|
||||
// Get untracked files
|
||||
cmd = exec.Command("git", "ls-files", "--others", "--exclude-standard")
|
||||
output, err = cmd.Output()
|
||||
if err == nil {
|
||||
files = append(files, filterGoFiles(string(output))...)
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
seen := make(map[string]bool)
|
||||
var unique []string
|
||||
for _, f := range files {
|
||||
if !seen[f] {
|
||||
seen[f] = true
|
||||
// Verify file exists (might have been deleted)
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
unique = append(unique, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
|
||||
// filterGoFiles filters a newline-separated list of files to only include .go files.
|
||||
func filterGoFiles(output string) []string {
|
||||
var goFiles []string
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
for scanner.Scan() {
|
||||
file := strings.TrimSpace(scanner.Text())
|
||||
if file != "" && filepath.Ext(file) == ".go" {
|
||||
goFiles = append(goFiles, file)
|
||||
}
|
||||
}
|
||||
return goFiles
|
||||
}
|
||||
|
||||
var (
|
||||
lintFix bool
|
||||
lintAll bool
|
||||
)
|
||||
|
||||
func addGoLintCommand(parent *cli.Command) {
|
||||
lintCmd := &cli.Command{
|
||||
Use: "lint",
|
||||
Short: "Run golangci-lint",
|
||||
Long: "Run golangci-lint for comprehensive static analysis",
|
||||
Long: "Run golangci-lint for comprehensive static analysis. By default only lints changed files.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
lintArgs := []string{"run"}
|
||||
if lintFix {
|
||||
lintArgs = append(lintArgs, "--fix")
|
||||
}
|
||||
|
||||
if !lintAll {
|
||||
// Use --new-from-rev=HEAD to only report issues in uncommitted changes
|
||||
// This is golangci-lint's native way to handle incremental linting
|
||||
lintArgs = append(lintArgs, "--new-from-rev=HEAD")
|
||||
}
|
||||
|
||||
// Always lint all packages
|
||||
lintArgs = append(lintArgs, "./...")
|
||||
|
||||
execCmd := exec.Command("golangci-lint", lintArgs...)
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
|
|
@ -74,6 +171,7 @@ func addGoLintCommand(parent *cli.Command) {
|
|||
}
|
||||
|
||||
lintCmd.Flags().BoolVar(&lintFix, "fix", false, i18n.T("common.flag.fix"))
|
||||
lintCmd.Flags().BoolVar(&lintAll, "all", false, i18n.T("cmd.go.lint.flag.all"))
|
||||
|
||||
parent.AddCommand(lintCmd)
|
||||
}
|
||||
|
|
|
|||
621
pkg/go/cmd_qa.go
621
pkg/go/cmd_qa.go
|
|
@ -2,89 +2,351 @@ package gocmd
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"github.com/host-uk/core/pkg/qa"
|
||||
)
|
||||
|
||||
var qaFix bool
|
||||
// QA command flags - comprehensive options for all agents
|
||||
var (
|
||||
qaFix bool
|
||||
qaChanged bool
|
||||
qaAll bool
|
||||
qaSkip string
|
||||
qaOnly string
|
||||
qaCoverage bool
|
||||
qaThreshold float64
|
||||
qaDocblockThreshold float64
|
||||
qaJSON bool
|
||||
qaVerbose bool
|
||||
qaQuiet bool
|
||||
qaTimeout time.Duration
|
||||
qaShort bool
|
||||
qaRace bool
|
||||
qaBench bool
|
||||
qaFailFast bool
|
||||
qaMod bool
|
||||
qaCI bool
|
||||
)
|
||||
|
||||
func addGoQACommand(parent *cli.Command) {
|
||||
qaCmd := &cli.Command{
|
||||
Use: "qa",
|
||||
Short: "Run QA checks",
|
||||
Long: "Run code quality checks: formatting, vetting, linting, and testing",
|
||||
RunE: runGoQADefault,
|
||||
Long: `Run comprehensive code quality checks for Go projects.
|
||||
|
||||
Checks available: fmt, vet, lint, test, race, vuln, sec, bench, docblock
|
||||
|
||||
Examples:
|
||||
core go qa # Default: fmt, lint, test
|
||||
core go qa --fix # Auto-fix formatting and lint issues
|
||||
core go qa --only=test # Only run tests
|
||||
core go qa --skip=vuln,sec # Skip vulnerability and security scans
|
||||
core go qa --coverage --threshold=80 # Require 80% coverage
|
||||
core go qa --changed # Only check changed files (git-aware)
|
||||
core go qa --ci # CI mode: strict, coverage, fail-fast
|
||||
core go qa --race --short # Quick tests with race detection
|
||||
core go qa --json # Output results as JSON`,
|
||||
RunE: runGoQA,
|
||||
}
|
||||
|
||||
qaCmd.PersistentFlags().BoolVar(&qaFix, "fix", false, i18n.T("common.flag.fix"))
|
||||
// Fix and modification flags (persistent so subcommands inherit them)
|
||||
qaCmd.PersistentFlags().BoolVar(&qaFix, "fix", false, "Auto-fix issues where possible")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaMod, "mod", false, "Run go mod tidy before checks")
|
||||
|
||||
// Subcommands for individual checks
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "fmt",
|
||||
Short: "Check/fix code formatting",
|
||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"fmt"}) },
|
||||
})
|
||||
// Scope flags
|
||||
qaCmd.PersistentFlags().BoolVar(&qaChanged, "changed", false, "Only check changed files (git-aware)")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaAll, "all", false, "Check all files (override git-aware)")
|
||||
qaCmd.PersistentFlags().StringVar(&qaSkip, "skip", "", "Skip checks (comma-separated: fmt,vet,lint,test,race,vuln,sec,bench)")
|
||||
qaCmd.PersistentFlags().StringVar(&qaOnly, "only", "", "Only run these checks (comma-separated)")
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "vet",
|
||||
Short: "Run go vet",
|
||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"vet"}) },
|
||||
})
|
||||
// Coverage flags
|
||||
qaCmd.PersistentFlags().BoolVar(&qaCoverage, "coverage", false, "Include coverage reporting")
|
||||
qaCmd.PersistentFlags().BoolVarP(&qaCoverage, "cov", "c", false, "Include coverage reporting (shorthand)")
|
||||
qaCmd.PersistentFlags().Float64Var(&qaThreshold, "threshold", 0, "Minimum coverage threshold (0-100), fail if below")
|
||||
qaCmd.PersistentFlags().Float64Var(&qaDocblockThreshold, "docblock-threshold", 80, "Minimum docblock coverage threshold (0-100)")
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "lint",
|
||||
Short: "Run golangci-lint",
|
||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"lint"}) },
|
||||
})
|
||||
// Test flags
|
||||
qaCmd.PersistentFlags().BoolVar(&qaShort, "short", false, "Run tests with -short flag")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaRace, "race", false, "Include race detection in tests")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaBench, "bench", false, "Include benchmarks")
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "test",
|
||||
Short: "Run tests",
|
||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"test"}) },
|
||||
})
|
||||
// Output flags
|
||||
qaCmd.PersistentFlags().BoolVar(&qaJSON, "json", false, "Output results as JSON")
|
||||
qaCmd.PersistentFlags().BoolVarP(&qaVerbose, "verbose", "v", false, "Show verbose output")
|
||||
qaCmd.PersistentFlags().BoolVarP(&qaQuiet, "quiet", "q", false, "Only show errors")
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "race",
|
||||
Short: "Run tests with race detector",
|
||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"race"}) },
|
||||
})
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "vuln",
|
||||
Short: "Check for vulnerabilities",
|
||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"vuln"}) },
|
||||
})
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "sec",
|
||||
Short: "Run security scanner",
|
||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"sec"}) },
|
||||
})
|
||||
// Control flags
|
||||
qaCmd.PersistentFlags().DurationVar(&qaTimeout, "timeout", 10*time.Minute, "Timeout for all checks")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaFailFast, "fail-fast", false, "Stop on first failure")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaCI, "ci", false, "CI mode: strict checks, coverage required, fail-fast")
|
||||
|
||||
// Preset subcommands for convenience
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "quick",
|
||||
Short: "Quick QA: fmt, vet, lint",
|
||||
RunE: func(cmd *cli.Command, args []string) error { return runQAChecks([]string{"fmt", "vet", "lint"}) },
|
||||
Short: "Quick QA: fmt, vet, lint (no tests)",
|
||||
RunE: func(cmd *cli.Command, args []string) error { qaOnly = "fmt,vet,lint"; return runGoQA(cmd, args) },
|
||||
})
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "full",
|
||||
Short: "Full QA: all checks including race, vuln, sec",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runQAChecks([]string{"fmt", "vet", "lint", "test", "race", "vuln", "sec"})
|
||||
qaOnly = "fmt,vet,lint,test,race,vuln,sec"
|
||||
return runGoQA(cmd, args)
|
||||
},
|
||||
})
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "pre-commit",
|
||||
Short: "Pre-commit checks: fmt --fix, lint --fix, test --short",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
qaFix = true
|
||||
qaShort = true
|
||||
qaOnly = "fmt,lint,test"
|
||||
return runGoQA(cmd, args)
|
||||
},
|
||||
})
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "pr",
|
||||
Short: "PR checks: full QA with coverage threshold",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
qaCoverage = true
|
||||
if qaThreshold == 0 {
|
||||
qaThreshold = 50 // Default PR threshold
|
||||
}
|
||||
qaOnly = "fmt,vet,lint,test"
|
||||
return runGoQA(cmd, args)
|
||||
},
|
||||
})
|
||||
|
||||
parent.AddCommand(qaCmd)
|
||||
}
|
||||
|
||||
// runGoQADefault runs the default QA checks (fmt, vet, lint, test)
|
||||
func runGoQADefault(cmd *cli.Command, args []string) error {
|
||||
return runQAChecks([]string{"fmt", "vet", "lint", "test"})
|
||||
// QAResult holds the result of a QA run for JSON output
|
||||
type QAResult struct {
|
||||
Success bool `json:"success"`
|
||||
Duration string `json:"duration"`
|
||||
Checks []CheckResult `json:"checks"`
|
||||
Coverage *float64 `json:"coverage,omitempty"`
|
||||
Threshold *float64 `json:"threshold,omitempty"`
|
||||
}
|
||||
|
||||
// CheckResult holds the result of a single check
|
||||
type CheckResult struct {
|
||||
Name string `json:"name"`
|
||||
Passed bool `json:"passed"`
|
||||
Duration string `json:"duration"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
func runGoQA(cmd *cli.Command, args []string) error {
|
||||
// Apply CI mode defaults
|
||||
if qaCI {
|
||||
qaCoverage = true
|
||||
qaFailFast = true
|
||||
if qaThreshold == 0 {
|
||||
qaThreshold = 50
|
||||
}
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.get", "working directory"))
|
||||
}
|
||||
|
||||
// Detect if this is a Go project
|
||||
if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
|
||||
return cli.Err("not a Go project (no go.mod found)")
|
||||
}
|
||||
|
||||
// Determine which checks to run
|
||||
checkNames := determineChecks()
|
||||
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print("%s %s\n\n", cli.DimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "Go QA"))
|
||||
}
|
||||
|
||||
// Run go mod tidy if requested
|
||||
if qaMod {
|
||||
if !qaQuiet {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("→"), "Running go mod tidy...")
|
||||
}
|
||||
modCmd := exec.Command("go", "mod", "tidy")
|
||||
modCmd.Dir = cwd
|
||||
if err := modCmd.Run(); err != nil {
|
||||
return cli.Wrap(err, "go mod tidy failed")
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), qaTimeout)
|
||||
defer cancel()
|
||||
|
||||
startTime := time.Now()
|
||||
checks := buildChecks(checkNames)
|
||||
results := make([]CheckResult, 0, len(checks))
|
||||
passed := 0
|
||||
failed := 0
|
||||
|
||||
for _, check := range checks {
|
||||
checkStart := time.Now()
|
||||
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("→"), i18n.Progress(check.Name))
|
||||
}
|
||||
|
||||
output, err := runCheckCapture(ctx, cwd, check)
|
||||
checkDuration := time.Since(checkStart)
|
||||
|
||||
result := CheckResult{
|
||||
Name: check.Name,
|
||||
Duration: checkDuration.Round(time.Millisecond).String(),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.Passed = false
|
||||
result.Error = err.Error()
|
||||
if qaVerbose {
|
||||
result.Output = output
|
||||
}
|
||||
failed++
|
||||
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print(" %s %s\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), err.Error())
|
||||
if qaVerbose && output != "" {
|
||||
cli.Text(output)
|
||||
}
|
||||
}
|
||||
|
||||
if qaFailFast {
|
||||
results = append(results, result)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
result.Passed = true
|
||||
if qaVerbose {
|
||||
result.Output = output
|
||||
}
|
||||
passed++
|
||||
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print(" %s %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
// Run coverage if requested
|
||||
var coverageVal *float64
|
||||
if qaCoverage && !qaFailFast || (qaCoverage && failed == 0) {
|
||||
cov, err := runCoverage(ctx, cwd)
|
||||
if err == nil {
|
||||
coverageVal = &cov
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print("\n%s %.1f%%\n", cli.DimStyle.Render("Coverage:"), cov)
|
||||
}
|
||||
if qaThreshold > 0 && cov < qaThreshold {
|
||||
failed++
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print(" %s Coverage %.1f%% below threshold %.1f%%\n",
|
||||
cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
duration := time.Since(startTime).Round(time.Millisecond)
|
||||
|
||||
// JSON output
|
||||
if qaJSON {
|
||||
qaResult := QAResult{
|
||||
Success: failed == 0,
|
||||
Duration: duration.String(),
|
||||
Checks: results,
|
||||
Coverage: coverageVal,
|
||||
}
|
||||
if qaThreshold > 0 {
|
||||
qaResult.Threshold = &qaThreshold
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(qaResult)
|
||||
}
|
||||
|
||||
// Summary
|
||||
if !qaQuiet {
|
||||
cli.Blank()
|
||||
if failed > 0 {
|
||||
cli.Print("%s %s, %s (%s)\n",
|
||||
cli.ErrorStyle.Render(cli.Glyph(":cross:")),
|
||||
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
|
||||
i18n.T("i18n.count.check", failed)+" "+i18n.T("i18n.done.fail"),
|
||||
duration)
|
||||
} else {
|
||||
cli.Print("%s %s (%s)\n",
|
||||
cli.SuccessStyle.Render(cli.Glyph(":check:")),
|
||||
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
|
||||
duration)
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func determineChecks() []string {
|
||||
// If --only is specified, use those
|
||||
if qaOnly != "" {
|
||||
return strings.Split(qaOnly, ",")
|
||||
}
|
||||
|
||||
// Default checks
|
||||
checks := []string{"fmt", "lint", "test", "docblock"}
|
||||
|
||||
// Add race if requested
|
||||
if qaRace {
|
||||
// Replace test with race (which includes test)
|
||||
for i, c := range checks {
|
||||
if c == "test" {
|
||||
checks[i] = "race"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add bench if requested
|
||||
if qaBench {
|
||||
checks = append(checks, "bench")
|
||||
}
|
||||
|
||||
// Remove skipped checks
|
||||
if qaSkip != "" {
|
||||
skipMap := make(map[string]bool)
|
||||
for _, s := range strings.Split(qaSkip, ",") {
|
||||
skipMap[strings.TrimSpace(s)] = true
|
||||
}
|
||||
filtered := make([]string, 0, len(checks))
|
||||
for _, c := range checks {
|
||||
if !skipMap[c] {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
checks = filtered
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
// QACheck represents a single QA check.
|
||||
|
|
@ -94,127 +356,92 @@ type QACheck struct {
|
|||
Args []string
|
||||
}
|
||||
|
||||
func runQAChecks(checkNames []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.get", "working directory"))
|
||||
}
|
||||
|
||||
// Detect if this is a Go project
|
||||
if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
|
||||
return cli.Err("not a Go project (no %s found)", i18n.T("gram.word.go_mod"))
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n\n", cli.DimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "Go QA"))
|
||||
|
||||
checks := buildChecksForNames(checkNames)
|
||||
|
||||
ctx := context.Background()
|
||||
startTime := time.Now()
|
||||
passed := 0
|
||||
failed := 0
|
||||
|
||||
for _, check := range checks {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("→"), i18n.Progress(check.Name))
|
||||
|
||||
if err := runCheck(ctx, cwd, check); err != nil {
|
||||
cli.Print(" %s %s\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), err.Error())
|
||||
failed++
|
||||
} else {
|
||||
cli.Print(" %s %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
|
||||
passed++
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
cli.Blank()
|
||||
duration := time.Since(startTime).Round(time.Millisecond)
|
||||
|
||||
if failed > 0 {
|
||||
cli.Print("%s %s, %s (%s)\n",
|
||||
cli.ErrorStyle.Render(cli.Glyph(":cross:")),
|
||||
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
|
||||
i18n.T("i18n.count.check", failed)+" "+i18n.T("i18n.done.fail"),
|
||||
duration)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cli.Print("%s %s (%s)\n",
|
||||
cli.SuccessStyle.Render(cli.Glyph(":check:")),
|
||||
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
|
||||
duration)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildChecksForNames(names []string) []QACheck {
|
||||
allChecks := map[string]QACheck{
|
||||
"fmt": {
|
||||
Name: "format",
|
||||
Command: "gofmt",
|
||||
Args: fmtArgs(qaFix),
|
||||
},
|
||||
"vet": {
|
||||
Name: "vet",
|
||||
Command: "go",
|
||||
Args: []string{"vet", "./..."},
|
||||
},
|
||||
"lint": {
|
||||
Name: "lint",
|
||||
Command: "golangci-lint",
|
||||
Args: lintArgs(qaFix),
|
||||
},
|
||||
"test": {
|
||||
Name: "test",
|
||||
Command: "go",
|
||||
Args: []string{"test", "./..."},
|
||||
},
|
||||
"race": {
|
||||
Name: "test",
|
||||
Command: "go",
|
||||
Args: []string{"test", "-race", "./..."},
|
||||
},
|
||||
"vuln": {
|
||||
Name: "scan",
|
||||
Command: "govulncheck",
|
||||
Args: []string{"./..."},
|
||||
},
|
||||
"sec": {
|
||||
Name: "scan",
|
||||
Command: "gosec",
|
||||
Args: []string{"-quiet", "./..."},
|
||||
},
|
||||
}
|
||||
|
||||
func buildChecks(names []string) []QACheck {
|
||||
var checks []QACheck
|
||||
for _, name := range names {
|
||||
if check, ok := allChecks[name]; ok {
|
||||
name = strings.TrimSpace(name)
|
||||
check := buildCheck(name)
|
||||
if check.Command != "" {
|
||||
checks = append(checks, check)
|
||||
}
|
||||
}
|
||||
return checks
|
||||
}
|
||||
|
||||
func fmtArgs(fix bool) []string {
|
||||
if fix {
|
||||
return []string{"-w", "."}
|
||||
func buildCheck(name string) QACheck {
|
||||
switch name {
|
||||
case "fmt", "format":
|
||||
args := []string{"-l", "."}
|
||||
if qaFix {
|
||||
args = []string{"-w", "."}
|
||||
}
|
||||
return []string{"-l", "."}
|
||||
}
|
||||
return QACheck{Name: "format", Command: "gofmt", Args: args}
|
||||
|
||||
func lintArgs(fix bool) []string {
|
||||
case "vet":
|
||||
return QACheck{Name: "vet", Command: "go", Args: []string{"vet", "./..."}}
|
||||
|
||||
case "lint":
|
||||
args := []string{"run"}
|
||||
if fix {
|
||||
if qaFix {
|
||||
args = append(args, "--fix")
|
||||
}
|
||||
if qaChanged && !qaAll {
|
||||
args = append(args, "--new-from-rev=HEAD")
|
||||
}
|
||||
args = append(args, "./...")
|
||||
return args
|
||||
return QACheck{Name: "lint", Command: "golangci-lint", Args: args}
|
||||
|
||||
case "test":
|
||||
args := []string{"test"}
|
||||
if qaShort {
|
||||
args = append(args, "-short")
|
||||
}
|
||||
if qaVerbose {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
args = append(args, "./...")
|
||||
return QACheck{Name: "test", Command: "go", Args: args}
|
||||
|
||||
case "race":
|
||||
args := []string{"test", "-race"}
|
||||
if qaShort {
|
||||
args = append(args, "-short")
|
||||
}
|
||||
if qaVerbose {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
args = append(args, "./...")
|
||||
return QACheck{Name: "race", Command: "go", Args: args}
|
||||
|
||||
case "bench":
|
||||
args := []string{"test", "-bench=.", "-benchmem", "-run=^$"}
|
||||
args = append(args, "./...")
|
||||
return QACheck{Name: "bench", Command: "go", Args: args}
|
||||
|
||||
case "vuln":
|
||||
return QACheck{Name: "vuln", Command: "govulncheck", Args: []string{"./..."}}
|
||||
|
||||
case "sec":
|
||||
return QACheck{Name: "sec", Command: "gosec", Args: []string{"-quiet", "./..."}}
|
||||
|
||||
case "docblock":
|
||||
// Special internal check - handled separately
|
||||
return QACheck{Name: "docblock", Command: "_internal_"}
|
||||
|
||||
default:
|
||||
return QACheck{}
|
||||
}
|
||||
}
|
||||
|
||||
func runCheck(ctx context.Context, dir string, check QACheck) error {
|
||||
func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) {
|
||||
// Handle internal checks
|
||||
if check.Command == "_internal_" {
|
||||
return runInternalCheck(check)
|
||||
}
|
||||
|
||||
// Check if command exists
|
||||
if _, err := exec.LookPath(check.Command); err != nil {
|
||||
return cli.Err("%s: %s", check.Command, i18n.T("i18n.done.miss"))
|
||||
return "", cli.Err("%s: not installed", check.Command)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, check.Command, check.Args...)
|
||||
|
|
@ -224,18 +451,100 @@ func runCheck(ctx context.Context, dir string, check QACheck) error {
|
|||
if check.Name == "format" && len(check.Args) > 0 && check.Args[0] == "-l" {
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return err
|
||||
return string(output), err
|
||||
}
|
||||
if len(output) > 0 {
|
||||
// Show files that need formatting
|
||||
if !qaQuiet && !qaJSON {
|
||||
cli.Text(string(output))
|
||||
return cli.Err("%s (use --fix)", i18n.T("i18n.fail.format", i18n.T("i18n.count.file", len(output))))
|
||||
}
|
||||
return nil
|
||||
return string(output), cli.Err("files need formatting (use --fix)")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// For other commands, stream or capture based on quiet mode
|
||||
if qaQuiet || qaJSON {
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
return cmd.Run()
|
||||
return "", cmd.Run()
|
||||
}
|
||||
|
||||
func runCoverage(ctx context.Context, dir string) (float64, error) {
|
||||
args := []string{"test", "-cover", "-coverprofile=/tmp/coverage.out"}
|
||||
if qaShort {
|
||||
args = append(args, "-short")
|
||||
}
|
||||
args = append(args, "./...")
|
||||
|
||||
cmd := exec.CommandContext(ctx, "go", args...)
|
||||
cmd.Dir = dir
|
||||
if !qaQuiet && !qaJSON {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Parse coverage
|
||||
coverCmd := exec.CommandContext(ctx, "go", "tool", "cover", "-func=/tmp/coverage.out")
|
||||
output, err := coverCmd.Output()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Parse last line for total coverage
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(lines) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
lastLine := lines[len(lines)-1]
|
||||
fields := strings.Fields(lastLine)
|
||||
if len(fields) < 3 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Parse percentage (e.g., "45.6%")
|
||||
pctStr := strings.TrimSuffix(fields[len(fields)-1], "%")
|
||||
var pct float64
|
||||
if _, err := fmt.Sscanf(pctStr, "%f", &pct); err == nil {
|
||||
return pct, nil
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// runInternalCheck runs internal Go-based checks (not external commands).
|
||||
func runInternalCheck(check QACheck) (string, error) {
|
||||
switch check.Name {
|
||||
case "docblock":
|
||||
result, err := qa.CheckDocblockCoverage([]string{"./..."})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result.Threshold = qaDocblockThreshold
|
||||
result.Passed = result.Coverage >= qaDocblockThreshold
|
||||
|
||||
if !result.Passed {
|
||||
var output strings.Builder
|
||||
output.WriteString(fmt.Sprintf("Docblock coverage: %.1f%% (threshold: %.1f%%)\n",
|
||||
result.Coverage, qaDocblockThreshold))
|
||||
for _, m := range result.Missing {
|
||||
output.WriteString(fmt.Sprintf("%s:%d\n", m.File, m.Line))
|
||||
}
|
||||
return output.String(), cli.Err("docblock coverage %.1f%% below threshold %.1f%%",
|
||||
result.Coverage, qaDocblockThreshold)
|
||||
}
|
||||
return fmt.Sprintf("Docblock coverage: %.1f%%", result.Coverage), nil
|
||||
|
||||
default:
|
||||
return "", cli.Err("unknown internal check: %s", check.Name)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -797,4 +797,3 @@ func TestProgressSubjectMatchesExpected(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ import (
|
|||
// LabelHandler handles i18n.label.{word} → "Status:" patterns.
|
||||
type LabelHandler struct{}
|
||||
|
||||
// Match returns true for keys starting with "i18n.label.".
|
||||
func (h LabelHandler) Match(key string) bool {
|
||||
return strings.HasPrefix(key, "i18n.label.")
|
||||
}
|
||||
|
||||
// Handle transforms label keys into formatted labels with colons.
|
||||
func (h LabelHandler) Handle(key string, args []any, next func() string) string {
|
||||
word := strings.TrimPrefix(key, "i18n.label.")
|
||||
return Label(word)
|
||||
|
|
@ -23,10 +25,12 @@ func (h LabelHandler) Handle(key string, args []any, next func() string) string
|
|||
// ProgressHandler handles i18n.progress.{verb} → "Building..." patterns.
|
||||
type ProgressHandler struct{}
|
||||
|
||||
// Match returns true for keys starting with "i18n.progress.".
|
||||
func (h ProgressHandler) Match(key string) bool {
|
||||
return strings.HasPrefix(key, "i18n.progress.")
|
||||
}
|
||||
|
||||
// Handle transforms progress keys into gerund phrases like "Building...".
|
||||
func (h ProgressHandler) Handle(key string, args []any, next func() string) string {
|
||||
verb := strings.TrimPrefix(key, "i18n.progress.")
|
||||
if len(args) > 0 {
|
||||
|
|
@ -40,10 +44,12 @@ func (h ProgressHandler) Handle(key string, args []any, next func() string) stri
|
|||
// CountHandler handles i18n.count.{noun} → "5 files" patterns.
|
||||
type CountHandler struct{}
|
||||
|
||||
// Match returns true for keys starting with "i18n.count.".
|
||||
func (h CountHandler) Match(key string) bool {
|
||||
return strings.HasPrefix(key, "i18n.count.")
|
||||
}
|
||||
|
||||
// Handle transforms count keys into pluralized phrases like "5 files".
|
||||
func (h CountHandler) Handle(key string, args []any, next func() string) string {
|
||||
noun := strings.TrimPrefix(key, "i18n.count.")
|
||||
if len(args) > 0 {
|
||||
|
|
@ -56,10 +62,12 @@ func (h CountHandler) Handle(key string, args []any, next func() string) string
|
|||
// DoneHandler handles i18n.done.{verb} → "File deleted" patterns.
|
||||
type DoneHandler struct{}
|
||||
|
||||
// Match returns true for keys starting with "i18n.done.".
|
||||
func (h DoneHandler) Match(key string) bool {
|
||||
return strings.HasPrefix(key, "i18n.done.")
|
||||
}
|
||||
|
||||
// Handle transforms done keys into past-tense completion messages.
|
||||
func (h DoneHandler) Handle(key string, args []any, next func() string) string {
|
||||
verb := strings.TrimPrefix(key, "i18n.done.")
|
||||
if len(args) > 0 {
|
||||
|
|
@ -73,10 +81,12 @@ func (h DoneHandler) Handle(key string, args []any, next func() string) string {
|
|||
// FailHandler handles i18n.fail.{verb} → "Failed to delete file" patterns.
|
||||
type FailHandler struct{}
|
||||
|
||||
// Match returns true for keys starting with "i18n.fail.".
|
||||
func (h FailHandler) Match(key string) bool {
|
||||
return strings.HasPrefix(key, "i18n.fail.")
|
||||
}
|
||||
|
||||
// Handle transforms fail keys into failure messages like "Failed to delete".
|
||||
func (h FailHandler) Handle(key string, args []any, next func() string) string {
|
||||
verb := strings.TrimPrefix(key, "i18n.fail.")
|
||||
if len(args) > 0 {
|
||||
|
|
@ -90,10 +100,12 @@ func (h FailHandler) Handle(key string, args []any, next func() string) string {
|
|||
// NumericHandler handles i18n.numeric.{format} → formatted numbers.
|
||||
type NumericHandler struct{}
|
||||
|
||||
// Match returns true for keys starting with "i18n.numeric.".
|
||||
func (h NumericHandler) Match(key string) bool {
|
||||
return strings.HasPrefix(key, "i18n.numeric.")
|
||||
}
|
||||
|
||||
// Handle transforms numeric keys into locale-formatted numbers.
|
||||
func (h NumericHandler) Handle(key string, args []any, next func() string) string {
|
||||
if len(args) == 0 {
|
||||
return next()
|
||||
|
|
|
|||
|
|
@ -170,15 +170,63 @@
|
|||
"flag.push": "Push container image to registry",
|
||||
"flag.targets": "Comma-separated build targets (e.g., linux/amd64,darwin/arm64)",
|
||||
"flag.type": "Project type override (go, wails, node, php)",
|
||||
"label.build": "Build",
|
||||
"label.type": "Type:",
|
||||
"label.output": "Output:",
|
||||
"label.binary": "Binary:",
|
||||
"label.targets": "Targets:",
|
||||
"label.sign": "Sign",
|
||||
"label.archive": "Archive",
|
||||
"label.checksum": "Checksum",
|
||||
"label.ok": "OK",
|
||||
"building_project": "Building project",
|
||||
"built_artifacts": "Built {{.Count}} artifacts",
|
||||
"signing_binaries": "Signing binaries",
|
||||
"creating_archives": "Creating archives",
|
||||
"computing_checksums": "Computing checksums",
|
||||
"error.invalid_target": "invalid target format \"{{.Target}}\", expected OS/arch (e.g., linux/amd64)",
|
||||
"error.no_project_type": "no supported project type detected in {{.Dir}}\nSupported types: go (go.mod), wails (wails.json), node (package.json), php (composer.json)",
|
||||
"error.no_targets": "no build targets specified",
|
||||
"error.signing_failed": "signing failed",
|
||||
"error.notarization_failed": "notarization failed",
|
||||
"error.archive_failed": "archive creation failed",
|
||||
"error.checksum_failed": "checksum generation failed",
|
||||
"error.gpg_signing_failed": "GPG signing failed",
|
||||
"error.node_not_implemented": "Node.js builds not yet implemented",
|
||||
"error.php_not_implemented": "PHP builds not yet implemented",
|
||||
"error.unsupported_type": "unsupported project type",
|
||||
"from_path.short": "Build from a local directory",
|
||||
"from_path.flag.path": "Path to application directory",
|
||||
"from_path.starting": "Building from path:",
|
||||
"from_path.generating_template": "Generating application template...",
|
||||
"from_path.copying_files": "Copying application files...",
|
||||
"from_path.compiling": "Compiling application...",
|
||||
"from_path.success": "Build complete:",
|
||||
"from_path.error.invalid_path": "invalid path",
|
||||
"from_path.error.must_be_directory": "path must be a directory",
|
||||
"from_path.error.go_mod_tidy": "go mod tidy failed",
|
||||
"from_path.error.go_build": "go build failed",
|
||||
"pwa.short": "Build from a live PWA URL",
|
||||
"pwa.flag.url": "URL of the PWA to build",
|
||||
"pwa.starting": "Building PWA from URL:",
|
||||
"pwa.downloading_to": "Downloading to:",
|
||||
"pwa.no_manifest": "No manifest.json found, using defaults",
|
||||
"pwa.found_manifest": "Found manifest:",
|
||||
"pwa.download_complete": "Download complete",
|
||||
"pwa.error.no_manifest_tag": "no manifest link tag found in HTML",
|
||||
"sdk.short": "Generate API SDKs from OpenAPI spec",
|
||||
"sdk.long": "Generate API SDKs from an OpenAPI specification file. Supports multiple languages including TypeScript, Go, and PHP.",
|
||||
"sdk.flag.dry_run": "Show what would be generated without writing files",
|
||||
"sdk.flag.lang": "Target language (typescript, go, php)",
|
||||
"sdk.flag.version": "SDK version to generate"
|
||||
"sdk.flag.version": "SDK version to generate",
|
||||
"sdk.label": "SDK",
|
||||
"sdk.generating": "Generating SDK",
|
||||
"sdk.dry_run_mode": "(dry run - no files will be written)",
|
||||
"sdk.language_label": "Language:",
|
||||
"sdk.languages_label": "Languages:",
|
||||
"sdk.would_generate": "Would generate SDK",
|
||||
"sdk.generated_label": "Generated:",
|
||||
"sdk.complete": "SDK generation complete"
|
||||
},
|
||||
"ci": {
|
||||
"short": "Publish releases (dry-run by default)",
|
||||
|
|
@ -298,13 +346,22 @@
|
|||
"install_macos": "brew install git gh php composer node pnpm docker",
|
||||
"ssh_missing": "SSH key missing - run: ssh-keygen && gh ssh-key add"
|
||||
},
|
||||
"git": {
|
||||
"short": "Git workflow commands",
|
||||
"long": "Git workflow commands for managing repositories. Includes status, commit, push, pull operations and safe multi-repo commands for AI agents."
|
||||
},
|
||||
"go": {
|
||||
"short": "Go development tools",
|
||||
"long": "Go development tools including testing, formatting, linting, and module management.",
|
||||
"test.short": "Run Go tests",
|
||||
"cov.short": "Run tests with coverage report",
|
||||
"fmt.short": "Format Go code",
|
||||
"fmt.no_changes": "No changed Go files to format.",
|
||||
"fmt.flag.check": "Check if formatted (exit 1 if not)",
|
||||
"fmt.flag.all": "Check all files, not just changed ones",
|
||||
"lint.short": "Run golangci-lint",
|
||||
"lint.no_changes": "No changed Go files to lint.",
|
||||
"lint.flag.all": "Lint all files, not just changed ones",
|
||||
"install.short": "Install Go binary",
|
||||
"mod.short": "Module management",
|
||||
"work.short": "Workspace management",
|
||||
|
|
@ -497,7 +554,13 @@
|
|||
"issues.category.triage": "Needs Triage",
|
||||
"issues.hint.needs_response": "commented recently",
|
||||
"issues.hint.blocked": "Waiting on dependency",
|
||||
"issues.hint.triage": "Add labels and assignee"
|
||||
"issues.hint.triage": "Add labels and assignee",
|
||||
"docblock.short": "Check docstring coverage for Go code",
|
||||
"docblock.long": "Analyse Go packages for docstring coverage on exported symbols. Checks functions, types, constants, and variables.",
|
||||
"docblock.flag.threshold": "Minimum coverage percentage required",
|
||||
"docblock.missing_docs": "Missing documentation:",
|
||||
"docblock.coverage": "Docstring coverage:",
|
||||
"docblock.use_verbose": "Run with -v to see missing docstrings"
|
||||
},
|
||||
"test": {
|
||||
"short": "Run Go tests with coverage",
|
||||
|
|
@ -542,6 +605,7 @@
|
|||
},
|
||||
"label": {
|
||||
"done": "Done",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"warning": "Warning",
|
||||
"info": "Info",
|
||||
|
|
|
|||
|
|
@ -24,11 +24,17 @@ import (
|
|||
// Level defines logging verbosity.
|
||||
type Level int
|
||||
|
||||
// Logging level constants ordered by increasing verbosity.
|
||||
const (
|
||||
// LevelQuiet suppresses all log output.
|
||||
LevelQuiet Level = iota
|
||||
// LevelError shows only error messages.
|
||||
LevelError
|
||||
// LevelWarn shows warnings and errors.
|
||||
LevelWarn
|
||||
// LevelInfo shows informational messages, warnings, and errors.
|
||||
LevelInfo
|
||||
// LevelDebug shows all messages including debug details.
|
||||
LevelDebug
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -87,37 +87,44 @@ func (s *Service) registerTools() {
|
|||
}, s.getSupportedLanguages)
|
||||
}
|
||||
|
||||
// Tool input/output types
|
||||
// Tool input/output types for MCP file operations.
|
||||
|
||||
// ReadFileInput contains parameters for reading a file.
|
||||
type ReadFileInput struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// ReadFileOutput contains the result of reading a file.
|
||||
type ReadFileOutput struct {
|
||||
Content string `json:"content"`
|
||||
Language string `json:"language"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// WriteFileInput contains parameters for writing a file.
|
||||
type WriteFileInput struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// WriteFileOutput contains the result of writing a file.
|
||||
type WriteFileOutput struct {
|
||||
Success bool `json:"success"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// ListDirectoryInput contains parameters for listing a directory.
|
||||
type ListDirectoryInput struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// ListDirectoryOutput contains the result of listing a directory.
|
||||
type ListDirectoryOutput struct {
|
||||
Entries []DirectoryEntry `json:"entries"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// DirectoryEntry represents a single entry in a directory listing.
|
||||
type DirectoryEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
|
|
@ -125,66 +132,80 @@ type DirectoryEntry struct {
|
|||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// CreateDirectoryInput contains parameters for creating a directory.
|
||||
type CreateDirectoryInput struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// CreateDirectoryOutput contains the result of creating a directory.
|
||||
type CreateDirectoryOutput struct {
|
||||
Success bool `json:"success"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// DeleteFileInput contains parameters for deleting a file.
|
||||
type DeleteFileInput struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// DeleteFileOutput contains the result of deleting a file.
|
||||
type DeleteFileOutput struct {
|
||||
Success bool `json:"success"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// RenameFileInput contains parameters for renaming a file.
|
||||
type RenameFileInput struct {
|
||||
OldPath string `json:"oldPath"`
|
||||
NewPath string `json:"newPath"`
|
||||
}
|
||||
|
||||
// RenameFileOutput contains the result of renaming a file.
|
||||
type RenameFileOutput struct {
|
||||
Success bool `json:"success"`
|
||||
OldPath string `json:"oldPath"`
|
||||
NewPath string `json:"newPath"`
|
||||
}
|
||||
|
||||
// FileExistsInput contains parameters for checking file existence.
|
||||
type FileExistsInput struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// FileExistsOutput contains the result of checking file existence.
|
||||
type FileExistsOutput struct {
|
||||
Exists bool `json:"exists"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// DetectLanguageInput contains parameters for detecting file language.
|
||||
type DetectLanguageInput struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// DetectLanguageOutput contains the detected programming language.
|
||||
type DetectLanguageOutput struct {
|
||||
Language string `json:"language"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// GetSupportedLanguagesInput is an empty struct for the languages query.
|
||||
type GetSupportedLanguagesInput struct{}
|
||||
|
||||
// GetSupportedLanguagesOutput contains the list of supported languages.
|
||||
type GetSupportedLanguagesOutput struct {
|
||||
Languages []LanguageInfo `json:"languages"`
|
||||
}
|
||||
|
||||
// LanguageInfo describes a supported programming language.
|
||||
type LanguageInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Extensions []string `json:"extensions"`
|
||||
}
|
||||
|
||||
// EditDiffInput contains parameters for editing a file via diff.
|
||||
type EditDiffInput struct {
|
||||
Path string `json:"path"`
|
||||
OldString string `json:"old_string"`
|
||||
|
|
@ -192,6 +213,7 @@ type EditDiffInput struct {
|
|||
ReplaceAll bool `json:"replace_all,omitempty"`
|
||||
}
|
||||
|
||||
// EditDiffOutput contains the result of a diff-based edit operation.
|
||||
type EditDiffOutput struct {
|
||||
Path string `json:"path"`
|
||||
Success bool `json:"success"`
|
||||
|
|
@ -308,6 +330,10 @@ func (s *Service) getSupportedLanguages(ctx context.Context, req *mcp.CallToolRe
|
|||
}
|
||||
|
||||
func (s *Service) editDiff(ctx context.Context, req *mcp.CallToolRequest, input EditDiffInput) (*mcp.CallToolResult, EditDiffOutput, error) {
|
||||
if input.OldString == "" {
|
||||
return nil, EditDiffOutput{}, fmt.Errorf("old_string cannot be empty")
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(input.Path)
|
||||
if err != nil {
|
||||
return nil, EditDiffOutput{}, fmt.Errorf("failed to read file: %w", err)
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ func AddPHPCommands(root *cobra.Command) {
|
|||
}
|
||||
root.AddCommand(phpCmd)
|
||||
|
||||
|
||||
// Development
|
||||
addPHPDevCommand(phpCmd)
|
||||
addPHPLogsCommand(phpCmd)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import (
|
|||
"syscall"
|
||||
"time"
|
||||
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -157,7 +156,7 @@ func runPHPDev(opts phpDevOptions) error {
|
|||
if err != nil {
|
||||
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("warning")), i18n.T("i18n.fail.get", "logs"))
|
||||
} else {
|
||||
defer logsReader.Close()
|
||||
defer func() { _ = logsReader.Close() }()
|
||||
|
||||
scanner := bufio.NewScanner(logsReader)
|
||||
for scanner.Scan() {
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ func BuildDocker(ctx context.Context, opts DockerBuildOptions) error {
|
|||
if err := os.WriteFile(tempDockerfile, []byte(content), 0644); err != nil {
|
||||
return cli.WrapVerb(err, "write", "Dockerfile")
|
||||
}
|
||||
defer os.Remove(tempDockerfile)
|
||||
defer func() { _ = os.Remove(tempDockerfile) }()
|
||||
|
||||
dockerfilePath = tempDockerfile
|
||||
}
|
||||
|
|
@ -233,7 +233,7 @@ func BuildLinuxKit(ctx context.Context, opts LinuxKitBuildOptions) error {
|
|||
if err := os.WriteFile(tempYAML, []byte(content), 0644); err != nil {
|
||||
return cli.WrapVerb(err, "write", "template")
|
||||
}
|
||||
defer os.Remove(tempYAML)
|
||||
defer func() { _ = os.Remove(tempYAML) }()
|
||||
|
||||
// Build LinuxKit image
|
||||
args := []string{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package php
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
|
@ -106,12 +107,12 @@ func TestLookupLinuxKit_Bad(t *testing.T) {
|
|||
origPath := os.Getenv("PATH")
|
||||
origCommonPaths := commonLinuxKitPaths
|
||||
defer func() {
|
||||
os.Setenv("PATH", origPath)
|
||||
_ = os.Setenv("PATH", origPath)
|
||||
commonLinuxKitPaths = origCommonPaths
|
||||
}()
|
||||
|
||||
// Set PATH to empty and clear common paths
|
||||
os.Setenv("PATH", "")
|
||||
_ = os.Setenv("PATH", "")
|
||||
commonLinuxKitPaths = []string{}
|
||||
|
||||
_, err := lookupLinuxKit()
|
||||
|
|
@ -202,7 +203,7 @@ func TestBuildDocker_Bad(t *testing.T) {
|
|||
|
||||
t.Run("fails for non-PHP project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := BuildDocker(nil, DockerBuildOptions{ProjectDir: dir})
|
||||
err := BuildDocker(context.TODO(), DockerBuildOptions{ProjectDir: dir})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a PHP project")
|
||||
})
|
||||
|
|
@ -213,7 +214,7 @@ func TestBuildLinuxKit_Bad(t *testing.T) {
|
|||
|
||||
t.Run("fails for non-PHP project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := BuildLinuxKit(nil, LinuxKitBuildOptions{ProjectDir: dir})
|
||||
err := BuildLinuxKit(context.TODO(), LinuxKitBuildOptions{ProjectDir: dir})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a PHP project")
|
||||
})
|
||||
|
|
@ -221,7 +222,7 @@ func TestBuildLinuxKit_Bad(t *testing.T) {
|
|||
|
||||
func TestServeProduction_Bad(t *testing.T) {
|
||||
t.Run("fails without image name", func(t *testing.T) {
|
||||
err := ServeProduction(nil, ServeOptions{})
|
||||
err := ServeProduction(context.TODO(), ServeOptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "image name is required")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ func LoadCoolifyConfigFromFile(path string) (*CoolifyConfig, error) {
|
|||
}
|
||||
return nil, cli.WrapVerb(err, "open", ".env file")
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
|
|
@ -177,7 +177,7 @@ func (c *CoolifyClient) TriggerDeploy(ctx context.Context, appID string, force b
|
|||
if err != nil {
|
||||
return nil, cli.Wrap(err, "request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
|
||||
return nil, c.parseError(resp)
|
||||
|
|
@ -210,7 +210,7 @@ func (c *CoolifyClient) GetDeployment(ctx context.Context, appID, deploymentID s
|
|||
if err != nil {
|
||||
return nil, cli.Wrap(err, "request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, c.parseError(resp)
|
||||
|
|
@ -242,7 +242,7 @@ func (c *CoolifyClient) ListDeployments(ctx context.Context, appID string, limit
|
|||
if err != nil {
|
||||
return nil, cli.Wrap(err, "request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, c.parseError(resp)
|
||||
|
|
@ -280,7 +280,7 @@ func (c *CoolifyClient) Rollback(ctx context.Context, appID, deploymentID string
|
|||
if err != nil {
|
||||
return nil, cli.Wrap(err, "request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
|
||||
return nil, c.parseError(resp)
|
||||
|
|
@ -312,7 +312,7 @@ func (c *CoolifyClient) GetApp(ctx context.Context, appID string) (*CoolifyApp,
|
|||
if err != nil {
|
||||
return nil, cli.Wrap(err, "request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, c.parseError(resp)
|
||||
|
|
|
|||
|
|
@ -11,11 +11,17 @@ import (
|
|||
// DetectedService represents a service that was detected in a Laravel project.
|
||||
type DetectedService string
|
||||
|
||||
// Detected service constants for Laravel projects.
|
||||
const (
|
||||
// ServiceFrankenPHP indicates FrankenPHP server is detected.
|
||||
ServiceFrankenPHP DetectedService = "frankenphp"
|
||||
// ServiceVite indicates Vite frontend bundler is detected.
|
||||
ServiceVite DetectedService = "vite"
|
||||
// ServiceHorizon indicates Laravel Horizon queue dashboard is detected.
|
||||
ServiceHorizon DetectedService = "horizon"
|
||||
// ServiceReverb indicates Laravel Reverb WebSocket server is detected.
|
||||
ServiceReverb DetectedService = "reverb"
|
||||
// ServiceRedis indicates Redis cache/queue backend is detected.
|
||||
ServiceRedis DetectedService = "redis"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
)
|
||||
|
||||
// FormatOptions configures PHP code formatting.
|
||||
|
|
@ -60,15 +61,20 @@ type AnalyseOptions struct {
|
|||
// FormatterType represents the detected formatter.
|
||||
type FormatterType string
|
||||
|
||||
// Formatter type constants.
|
||||
const (
|
||||
// FormatterPint indicates Laravel Pint code formatter.
|
||||
FormatterPint FormatterType = "pint"
|
||||
)
|
||||
|
||||
// AnalyserType represents the detected static analyser.
|
||||
type AnalyserType string
|
||||
|
||||
// Static analyser type constants.
|
||||
const (
|
||||
// AnalyserPHPStan indicates standard PHPStan analyser.
|
||||
AnalyserPHPStan AnalyserType = "phpstan"
|
||||
// AnalyserLarastan indicates Laravel-specific Larastan analyser.
|
||||
AnalyserLarastan AnalyserType = "larastan"
|
||||
)
|
||||
|
||||
|
|
@ -279,7 +285,9 @@ type PsalmOptions struct {
|
|||
// PsalmType represents the detected Psalm configuration.
|
||||
type PsalmType string
|
||||
|
||||
// Psalm configuration type constants.
|
||||
const (
|
||||
// PsalmStandard indicates standard Psalm configuration.
|
||||
PsalmStandard PsalmType = "psalm"
|
||||
)
|
||||
|
||||
|
|
@ -695,9 +703,13 @@ type QAOptions struct {
|
|||
// QAStage represents a stage in the QA pipeline.
|
||||
type QAStage string
|
||||
|
||||
// QA pipeline stage constants.
|
||||
const (
|
||||
// QAStageQuick runs fast checks only (audit, fmt, stan).
|
||||
QAStageQuick QAStage = "quick"
|
||||
// QAStageStandard runs standard checks including tests.
|
||||
QAStageStandard QAStage = "standard"
|
||||
// QAStageFull runs all checks including slow security scans.
|
||||
QAStageFull QAStage = "full"
|
||||
)
|
||||
|
||||
|
|
@ -816,7 +828,7 @@ func RunSecurityChecks(ctx context.Context, opts SecurityOptions) (*SecurityResu
|
|||
for _, audit := range auditResults {
|
||||
check := SecurityCheck{
|
||||
ID: audit.Tool + "_audit",
|
||||
Name: strings.Title(audit.Tool) + " Security Audit",
|
||||
Name: i18n.Title(audit.Tool) + " Security Audit",
|
||||
Description: "Check " + audit.Tool + " dependencies for vulnerabilities",
|
||||
Severity: "critical",
|
||||
Passed: audit.Vulnerabilities == 0 && audit.Error == nil,
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ type FrankenPHPOptions struct {
|
|||
KeyFile string
|
||||
}
|
||||
|
||||
// Start launches the FrankenPHP Octane server.
|
||||
func (s *FrankenPHPService) Start(ctx context.Context) error {
|
||||
args := []string{
|
||||
"artisan", "octane:start",
|
||||
|
|
@ -239,6 +240,7 @@ func (s *FrankenPHPService) Start(ctx context.Context) error {
|
|||
return s.startProcess(ctx, "php", args, nil)
|
||||
}
|
||||
|
||||
// Stop terminates the FrankenPHP server process.
|
||||
func (s *FrankenPHPService) Stop() error {
|
||||
return s.stopProcess()
|
||||
}
|
||||
|
|
@ -277,6 +279,7 @@ type ViteOptions struct {
|
|||
PackageManager string
|
||||
}
|
||||
|
||||
// Start launches the Vite development server.
|
||||
func (s *ViteService) Start(ctx context.Context) error {
|
||||
var cmdName string
|
||||
var args []string
|
||||
|
|
@ -299,6 +302,7 @@ func (s *ViteService) Start(ctx context.Context) error {
|
|||
return s.startProcess(ctx, cmdName, args, nil)
|
||||
}
|
||||
|
||||
// Stop terminates the Vite development server.
|
||||
func (s *ViteService) Stop() error {
|
||||
return s.stopProcess()
|
||||
}
|
||||
|
|
@ -319,10 +323,12 @@ func NewHorizonService(dir string) *HorizonService {
|
|||
}
|
||||
}
|
||||
|
||||
// Start launches the Laravel Horizon queue worker.
|
||||
func (s *HorizonService) Start(ctx context.Context) error {
|
||||
return s.startProcess(ctx, "php", []string{"artisan", "horizon"}, nil)
|
||||
}
|
||||
|
||||
// Stop terminates Horizon using its terminate command.
|
||||
func (s *HorizonService) Stop() error {
|
||||
// Horizon has its own terminate command
|
||||
cmd := exec.Command("php", "artisan", "horizon:terminate")
|
||||
|
|
@ -358,6 +364,7 @@ type ReverbOptions struct {
|
|||
Port int
|
||||
}
|
||||
|
||||
// Start launches the Laravel Reverb WebSocket server.
|
||||
func (s *ReverbService) Start(ctx context.Context) error {
|
||||
args := []string{
|
||||
"artisan", "reverb:start",
|
||||
|
|
@ -367,6 +374,7 @@ func (s *ReverbService) Start(ctx context.Context) error {
|
|||
return s.startProcess(ctx, "php", args, nil)
|
||||
}
|
||||
|
||||
// Stop terminates the Reverb WebSocket server.
|
||||
func (s *ReverbService) Stop() error {
|
||||
return s.stopProcess()
|
||||
}
|
||||
|
|
@ -400,6 +408,7 @@ type RedisOptions struct {
|
|||
ConfigFile string
|
||||
}
|
||||
|
||||
// Start launches the Redis server.
|
||||
func (s *RedisService) Start(ctx context.Context) error {
|
||||
args := []string{
|
||||
"--port", cli.Sprintf("%d", s.port),
|
||||
|
|
@ -414,6 +423,7 @@ func (s *RedisService) Start(ctx context.Context) error {
|
|||
return s.startProcess(ctx, "redis-server", args, nil)
|
||||
}
|
||||
|
||||
// Stop terminates Redis using the shutdown command.
|
||||
func (s *RedisService) Stop() error {
|
||||
// Try graceful shutdown via redis-cli
|
||||
cmd := exec.Command("redis-cli", "-p", cli.Sprintf("%d", s.port), "shutdown", "nosave")
|
||||
|
|
|
|||
|
|
@ -40,8 +40,11 @@ type TestOptions struct {
|
|||
// TestRunner represents the detected test runner.
|
||||
type TestRunner string
|
||||
|
||||
// Test runner type constants.
|
||||
const (
|
||||
// TestRunnerPest indicates Pest testing framework.
|
||||
TestRunnerPest TestRunner = "pest"
|
||||
// TestRunnerPHPUnit indicates PHPUnit testing framework.
|
||||
TestRunnerPHPUnit TestRunner = "phpunit"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -165,18 +165,3 @@ func (p *Process) CloseStdin() error {
|
|||
p.stdin = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// setStatus updates the process status (internal use).
|
||||
func (p *Process) setStatus(status Status) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.Status = status
|
||||
}
|
||||
|
||||
// setExitCode sets the exit code and duration (internal use).
|
||||
func (p *Process) setExitCode(code int, duration time.Duration) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.ExitCode = code
|
||||
p.Duration = duration
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ type ServiceError struct {
|
|||
msg string
|
||||
}
|
||||
|
||||
// Error returns the service error message.
|
||||
func (e *ServiceError) Error() string {
|
||||
return e.msg
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,4 +255,3 @@ func TestService_Clear(t *testing.T) {
|
|||
assert.Len(t, svc.List(), 0)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
353
pkg/qa/cmd_docblock.go
Normal file
353
pkg/qa/cmd_docblock.go
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
// cmd_docblock.go implements docblock/docstring coverage checking for Go code.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// core qa docblock # Check current directory
|
||||
// core qa docblock ./pkg/... # Check specific packages
|
||||
// core qa docblock --threshold=80 # Require 80% coverage
|
||||
package qa
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
)
|
||||
|
||||
// Docblock command flags
|
||||
var (
|
||||
docblockThreshold float64
|
||||
docblockVerbose bool
|
||||
docblockJSON bool
|
||||
)
|
||||
|
||||
// addDocblockCommand adds the 'docblock' command to qa.
|
||||
func addDocblockCommand(parent *cli.Command) {
|
||||
docblockCmd := &cli.Command{
|
||||
Use: "docblock [packages...]",
|
||||
Short: i18n.T("cmd.qa.docblock.short"),
|
||||
Long: i18n.T("cmd.qa.docblock.long"),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
paths := args
|
||||
if len(paths) == 0 {
|
||||
paths = []string{"./..."}
|
||||
}
|
||||
return RunDocblockCheck(paths, docblockThreshold, docblockVerbose, docblockJSON)
|
||||
},
|
||||
}
|
||||
|
||||
docblockCmd.Flags().Float64Var(&docblockThreshold, "threshold", 80, i18n.T("cmd.qa.docblock.flag.threshold"))
|
||||
docblockCmd.Flags().BoolVarP(&docblockVerbose, "verbose", "v", false, i18n.T("common.flag.verbose"))
|
||||
docblockCmd.Flags().BoolVar(&docblockJSON, "json", false, i18n.T("common.flag.json"))
|
||||
|
||||
parent.AddCommand(docblockCmd)
|
||||
}
|
||||
|
||||
// DocblockResult holds the result of a docblock coverage check.
|
||||
type DocblockResult struct {
|
||||
Coverage float64 `json:"coverage"`
|
||||
Threshold float64 `json:"threshold"`
|
||||
Total int `json:"total"`
|
||||
Documented int `json:"documented"`
|
||||
Missing []MissingDocblock `json:"missing,omitempty"`
|
||||
Passed bool `json:"passed"`
|
||||
}
|
||||
|
||||
// MissingDocblock represents an exported symbol without documentation.
|
||||
type MissingDocblock struct {
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // func, type, const, var
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// RunDocblockCheck checks docblock coverage for the given packages.
|
||||
func RunDocblockCheck(paths []string, threshold float64, verbose, jsonOutput bool) error {
|
||||
result, err := CheckDocblockCoverage(paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.Threshold = threshold
|
||||
result.Passed = result.Coverage >= threshold
|
||||
|
||||
if jsonOutput {
|
||||
data, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
if !result.Passed {
|
||||
return cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, threshold)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort missing by file then line
|
||||
sort.Slice(result.Missing, func(i, j int) bool {
|
||||
if result.Missing[i].File != result.Missing[j].File {
|
||||
return result.Missing[i].File < result.Missing[j].File
|
||||
}
|
||||
return result.Missing[i].Line < result.Missing[j].Line
|
||||
})
|
||||
|
||||
// Print result
|
||||
if verbose && len(result.Missing) > 0 {
|
||||
cli.Print("%s\n\n", i18n.T("cmd.qa.docblock.missing_docs"))
|
||||
for _, m := range result.Missing {
|
||||
cli.Print(" %s:%d: %s %s\n",
|
||||
dimStyle.Render(m.File),
|
||||
m.Line,
|
||||
dimStyle.Render(m.Kind),
|
||||
m.Name,
|
||||
)
|
||||
}
|
||||
cli.Blank()
|
||||
}
|
||||
|
||||
// Summary
|
||||
coverageStr := fmt.Sprintf("%.1f%%", result.Coverage)
|
||||
thresholdStr := fmt.Sprintf("%.1f%%", threshold)
|
||||
|
||||
if result.Passed {
|
||||
cli.Print("%s %s %s/%s (%s >= %s)\n",
|
||||
successStyle.Render(i18n.T("common.label.success")),
|
||||
i18n.T("cmd.qa.docblock.coverage"),
|
||||
fmt.Sprintf("%d", result.Documented),
|
||||
fmt.Sprintf("%d", result.Total),
|
||||
successStyle.Render(coverageStr),
|
||||
thresholdStr,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Print("%s %s %s/%s (%s < %s)\n",
|
||||
errorStyle.Render(i18n.T("common.label.error")),
|
||||
i18n.T("cmd.qa.docblock.coverage"),
|
||||
fmt.Sprintf("%d", result.Documented),
|
||||
fmt.Sprintf("%d", result.Total),
|
||||
errorStyle.Render(coverageStr),
|
||||
thresholdStr,
|
||||
)
|
||||
|
||||
// Always show compact file:line list when failing (token-efficient for AI agents)
|
||||
if len(result.Missing) > 0 {
|
||||
cli.Blank()
|
||||
for _, m := range result.Missing {
|
||||
cli.Print("%s:%d\n", m.File, m.Line)
|
||||
}
|
||||
}
|
||||
|
||||
return cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, threshold)
|
||||
}
|
||||
|
||||
// CheckDocblockCoverage analyzes Go packages for docblock coverage.
|
||||
func CheckDocblockCoverage(patterns []string) (*DocblockResult, error) {
|
||||
result := &DocblockResult{}
|
||||
|
||||
// Expand patterns to actual directories
|
||||
dirs, err := expandPatterns(patterns)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
|
||||
for _, dir := range dirs {
|
||||
pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool {
|
||||
return !strings.HasSuffix(fi.Name(), "_test.go")
|
||||
}, parser.ParseComments)
|
||||
if err != nil {
|
||||
// Log parse errors but continue to check other directories
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to parse %s: %v\n", dir, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
for filename, file := range pkg.Files {
|
||||
checkFile(fset, filename, file, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.Total > 0 {
|
||||
result.Coverage = float64(result.Documented) / float64(result.Total) * 100
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// expandPatterns expands Go package patterns like ./... to actual directories.
|
||||
func expandPatterns(patterns []string) ([]string, error) {
|
||||
var dirs []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if strings.HasSuffix(pattern, "/...") {
|
||||
// Recursive pattern
|
||||
base := strings.TrimSuffix(pattern, "/...")
|
||||
if base == "." {
|
||||
base = "."
|
||||
}
|
||||
err := filepath.Walk(base, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Skip errors
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
// Skip vendor, testdata, and hidden directories (but not "." itself)
|
||||
name := info.Name()
|
||||
if name == "vendor" || name == "testdata" || (strings.HasPrefix(name, ".") && name != ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
// Check if directory has Go files
|
||||
if hasGoFiles(path) && !seen[path] {
|
||||
dirs = append(dirs, path)
|
||||
seen[path] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// Single directory
|
||||
path := pattern
|
||||
if !seen[path] && hasGoFiles(path) {
|
||||
dirs = append(dirs, path)
|
||||
seen[path] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
// hasGoFiles checks if a directory contains Go files.
|
||||
func hasGoFiles(dir string) bool {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") && !strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// checkFile analyzes a single file for docblock coverage.
|
||||
func checkFile(fset *token.FileSet, filename string, file *ast.File, result *DocblockResult) {
|
||||
// Make filename relative if possible
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
if rel, err := filepath.Rel(cwd, filename); err == nil {
|
||||
filename = rel
|
||||
}
|
||||
}
|
||||
|
||||
for _, decl := range file.Decls {
|
||||
switch d := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
// Skip unexported functions
|
||||
if !ast.IsExported(d.Name.Name) {
|
||||
continue
|
||||
}
|
||||
// Skip methods on unexported types
|
||||
if d.Recv != nil && len(d.Recv.List) > 0 {
|
||||
if recvType := getReceiverTypeName(d.Recv.List[0].Type); recvType != "" && !ast.IsExported(recvType) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
result.Total++
|
||||
if d.Doc != nil && len(d.Doc.List) > 0 {
|
||||
result.Documented++
|
||||
} else {
|
||||
pos := fset.Position(d.Pos())
|
||||
result.Missing = append(result.Missing, MissingDocblock{
|
||||
File: filename,
|
||||
Line: pos.Line,
|
||||
Name: d.Name.Name,
|
||||
Kind: "func",
|
||||
})
|
||||
}
|
||||
|
||||
case *ast.GenDecl:
|
||||
for _, spec := range d.Specs {
|
||||
switch s := spec.(type) {
|
||||
case *ast.TypeSpec:
|
||||
if !ast.IsExported(s.Name.Name) {
|
||||
continue
|
||||
}
|
||||
result.Total++
|
||||
// Type can have doc on GenDecl or TypeSpec
|
||||
if (d.Doc != nil && len(d.Doc.List) > 0) || (s.Doc != nil && len(s.Doc.List) > 0) {
|
||||
result.Documented++
|
||||
} else {
|
||||
pos := fset.Position(s.Pos())
|
||||
result.Missing = append(result.Missing, MissingDocblock{
|
||||
File: filename,
|
||||
Line: pos.Line,
|
||||
Name: s.Name.Name,
|
||||
Kind: "type",
|
||||
})
|
||||
}
|
||||
|
||||
case *ast.ValueSpec:
|
||||
// Check exported consts and vars
|
||||
for _, name := range s.Names {
|
||||
if !ast.IsExported(name.Name) {
|
||||
continue
|
||||
}
|
||||
result.Total++
|
||||
// Value can have doc on GenDecl or ValueSpec
|
||||
if (d.Doc != nil && len(d.Doc.List) > 0) || (s.Doc != nil && len(s.Doc.List) > 0) {
|
||||
result.Documented++
|
||||
} else {
|
||||
pos := fset.Position(name.Pos())
|
||||
result.Missing = append(result.Missing, MissingDocblock{
|
||||
File: filename,
|
||||
Line: pos.Line,
|
||||
Name: name.Name,
|
||||
Kind: kindFromToken(d.Tok),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getReceiverTypeName extracts the type name from a method receiver.
|
||||
func getReceiverTypeName(expr ast.Expr) string {
|
||||
switch t := expr.(type) {
|
||||
case *ast.Ident:
|
||||
return t.Name
|
||||
case *ast.StarExpr:
|
||||
return getReceiverTypeName(t.X)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// kindFromToken returns a string representation of the token kind.
|
||||
func kindFromToken(tok token.Token) string {
|
||||
switch tok {
|
||||
case token.CONST:
|
||||
return "const"
|
||||
case token.VAR:
|
||||
return "var"
|
||||
default:
|
||||
return "value"
|
||||
}
|
||||
}
|
||||
|
|
@ -41,4 +41,5 @@ func AddQACommands(root *cli.Command) {
|
|||
addReviewCommand(qaCmd)
|
||||
addHealthCommand(qaCmd)
|
||||
addIssuesCommand(qaCmd)
|
||||
addDocblockCommand(qaCmd)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"text/template"
|
||||
|
||||
"github.com/host-uk/core/pkg/build"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
)
|
||||
|
||||
//go:embed templates/chocolatey/*.tmpl templates/chocolatey/tools/*.tmpl
|
||||
|
|
@ -79,7 +80,7 @@ func (p *ChocolateyPublisher) Publish(ctx context.Context, release *Release, pub
|
|||
|
||||
data := chocolateyTemplateData{
|
||||
PackageName: packageName,
|
||||
Title: fmt.Sprintf("%s CLI", strings.Title(projectName)),
|
||||
Title: fmt.Sprintf("%s CLI", i18n.Title(projectName)),
|
||||
Description: fmt.Sprintf("%s CLI", projectName),
|
||||
Repository: repo,
|
||||
Version: version,
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ func (p *NpmPublisher) executePublish(ctx context.Context, data npmTemplateData,
|
|||
}
|
||||
|
||||
// Create .npmrc with token
|
||||
npmrc := fmt.Sprintf("//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n")
|
||||
npmrc := "//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n"
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, ".npmrc"), []byte(npmrc), 0600); err != nil {
|
||||
return fmt.Errorf("npm.Publish: failed to write .npmrc: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,10 +31,15 @@ type RegistryDefaults struct {
|
|||
// RepoType indicates the role of a repository in the ecosystem.
|
||||
type RepoType string
|
||||
|
||||
// Repository type constants for ecosystem classification.
|
||||
const (
|
||||
// RepoTypeFoundation indicates core foundation packages.
|
||||
RepoTypeFoundation RepoType = "foundation"
|
||||
// RepoTypeModule indicates reusable module packages.
|
||||
RepoTypeModule RepoType = "module"
|
||||
// RepoTypeProduct indicates end-user product applications.
|
||||
RepoTypeProduct RepoType = "product"
|
||||
// RepoTypeTemplate indicates starter templates.
|
||||
RepoTypeTemplate RepoType = "template"
|
||||
)
|
||||
|
||||
|
|
@ -219,7 +224,7 @@ func detectOrg(repoPath string) string {
|
|||
func (r *Registry) List() []*Repo {
|
||||
repos := make([]*Repo, 0, len(r.Repos))
|
||||
for _, repo := range r.Repos {
|
||||
repos = repos
|
||||
|
||||
repos = append(repos, repo)
|
||||
}
|
||||
return repos
|
||||
|
|
|
|||
|
|
@ -64,8 +64,6 @@ func TestSDK_GenerateLanguage_Bad(t *testing.T) {
|
|||
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
|
||||
s := New(tmpDir, nil)
|
||||
|
||||
err = s.GenerateLanguage(context.Background(), "invalid-lang")
|
||||
|
|
|
|||
|
|
@ -150,22 +150,6 @@ func runGHAPI(endpoint string) ([]byte, error) {
|
|||
return output, nil
|
||||
}
|
||||
|
||||
// severityRank returns a numeric rank for severity (higher = more severe).
|
||||
func severityRank(severity string) int {
|
||||
switch strings.ToLower(severity) {
|
||||
case "critical":
|
||||
return 4
|
||||
case "high":
|
||||
return 3
|
||||
case "medium":
|
||||
return 2
|
||||
case "low":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// severityStyle returns the appropriate style for a severity level.
|
||||
func severityStyle(severity string) *cli.AnsiStyle {
|
||||
switch strings.ToLower(severity) {
|
||||
|
|
|
|||
|
|
@ -14,20 +14,30 @@ import (
|
|||
// ChangeType indicates the type of change being made.
|
||||
type ChangeType string
|
||||
|
||||
// Change type constants for GitHub configuration diffs.
|
||||
const (
|
||||
// ChangeCreate indicates a new resource to be created.
|
||||
ChangeCreate ChangeType = "create"
|
||||
// ChangeUpdate indicates an existing resource to be updated.
|
||||
ChangeUpdate ChangeType = "update"
|
||||
// ChangeDelete indicates a resource to be deleted.
|
||||
ChangeDelete ChangeType = "delete"
|
||||
// ChangeSkip indicates a resource that requires no changes.
|
||||
ChangeSkip ChangeType = "skip"
|
||||
)
|
||||
|
||||
// ChangeCategory groups changes by type.
|
||||
type ChangeCategory string
|
||||
|
||||
// Change category constants for grouping GitHub configuration changes.
|
||||
const (
|
||||
// CategoryLabel indicates label-related changes.
|
||||
CategoryLabel ChangeCategory = "label"
|
||||
// CategoryWebhook indicates webhook-related changes.
|
||||
CategoryWebhook ChangeCategory = "webhook"
|
||||
// CategoryProtection indicates branch protection changes.
|
||||
CategoryProtection ChangeCategory = "protection"
|
||||
// CategorySecurity indicates security settings changes.
|
||||
CategorySecurity ChangeCategory = "security"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@ package main
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Read package.json
|
||||
data, err := ioutil.ReadFile("package.json")
|
||||
data, err := os.ReadFile("package.json")
|
||||
if err != nil {
|
||||
fmt.Println("Error reading package.json, skipping version file generation.")
|
||||
os.Exit(0)
|
||||
|
|
@ -26,7 +25,7 @@ func main() {
|
|||
|
||||
// Create the version file
|
||||
content := fmt.Sprintf("package updater\n\n// Generated by go:generate. DO NOT EDIT.\n\nconst PkgVersion = %q\n", pkg.Version)
|
||||
err = ioutil.WriteFile("version.go", []byte(content), 0644)
|
||||
err = os.WriteFile("version.go", []byte(content), 0644)
|
||||
if err != nil {
|
||||
fmt.Printf("Error writing version file: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// AddWorkspaceCommands registers workspace management commands.
|
||||
func AddWorkspaceCommands(root *cobra.Command) {
|
||||
wsCmd := &cobra.Command{
|
||||
Use: "workspace",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue