package doccheck import ( "fmt" "os" "path/filepath" "regexp" "strings" "testing" "gopkg.in/yaml.v3" ) var markdownLinkPattern = regexp.MustCompile(`\[[^\]]+\]\(([^)]+)\)`) func TestDocumentationLinksResolve(t *testing.T) { root := repositoryRoot(t) for _, path := range []string{ filepath.Join(root, "README.md"), filepath.Join(root, "docs"), filepath.Join(root, "examples"), } { walkDocumentationLinks(t, root, path) } } func TestWoodpeckerWorkflowDependencies(t *testing.T) { root := repositoryRoot(t) paths, err := filepath.Glob(filepath.Join(root, ".woodpecker", "*.yml")) if err != nil { t.Fatalf("list Woodpecker workflows: %v", err) } if len(paths) == 0 { t.Fatal("no Woodpecker workflows found") } for _, path := range paths { data, err := os.ReadFile(path) if err != nil { t.Fatalf("read %q: %v", path, err) } var workflow woodpeckerWorkflow if err := yaml.Unmarshal(data, &workflow); err != nil { t.Fatalf("parse %q: %v", path, err) } if len(workflow.Steps) == 0 { t.Fatalf("workflow %q has no steps", path) } validateWorkflowDependencies(t, path, workflow.Steps) } } func TestReleaseWorkflowRequiresValidation(t *testing.T) { root := repositoryRoot(t) path := filepath.Join(root, ".woodpecker", "release.yml") data, err := os.ReadFile(path) if err != nil { t.Fatalf("read release workflow: %v", err) } var workflow woodpeckerWorkflow if err := yaml.Unmarshal(data, &workflow); err != nil { t.Fatalf("parse release workflow: %v", err) } if !workflowDependsOn(workflow.Steps, "publish-release", "validate", map[string]bool{}) { t.Fatal("publish-release must depend on validate so validation failures block releases") } } func repositoryRoot(t *testing.T) string { t.Helper() root, err := filepath.Abs(filepath.Join("..", "..")) if err != nil { t.Fatalf("resolve repository root: %v", err) } return root } func walkDocumentationLinks(t *testing.T, root, directory string) { t.Helper() if err := filepath.WalkDir(directory, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if entry.IsDir() || filepath.Ext(path) != ".md" { return nil } data, err := os.ReadFile(path) if err != nil { return err } for _, match := range markdownLinkPattern.FindAllStringSubmatch(string(data), -1) { target := strings.TrimSpace(match[1]) if !isLocalDocumentationTarget(target) { continue } target = strings.Trim(strings.SplitN(strings.SplitN(target, "#", 2)[0], "?", 2)[0], "<>") if target == "" { continue } resolved := filepath.Clean(filepath.Join(filepath.Dir(path), target)) if _, err := os.Stat(resolved); err != nil { t.Errorf("%s links to missing local target %q (%s): %v", relativeToRoot(root, path), target, relativeToRoot(root, resolved), err) } } return nil }); err != nil { t.Fatalf("walk documentation in %q: %v", directory, err) } } func isLocalDocumentationTarget(target string) bool { target = strings.TrimSpace(target) return target != "" && !strings.HasPrefix(target, "#") && !strings.HasPrefix(target, "/") && !strings.Contains(target, "://") && !strings.HasPrefix(target, "mailto:") } func relativeToRoot(root, path string) string { relative, err := filepath.Rel(root, path) if err != nil { return path } return relative } type woodpeckerWorkflow struct { Steps map[string]woodpeckerStep `yaml:"steps"` } type woodpeckerStep struct { DependsOn woodpeckerDependencies `yaml:"depends_on"` } type woodpeckerDependencies []string func (d *woodpeckerDependencies) UnmarshalYAML(value *yaml.Node) error { switch value.Kind { case yaml.ScalarNode: *d = []string{value.Value} return nil case yaml.SequenceNode: dependencies := make([]string, 0, len(value.Content)) for _, item := range value.Content { if item.Kind != yaml.ScalarNode { return fmt.Errorf("dependency must be a step name") } dependencies = append(dependencies, item.Value) } *d = dependencies return nil default: return fmt.Errorf("dependency must be a string or list of strings") } } func validateWorkflowDependencies(t *testing.T, path string, steps map[string]woodpeckerStep) { t.Helper() for name, step := range steps { for _, dependency := range step.DependsOn { if _, ok := steps[dependency]; !ok { t.Errorf("workflow %q step %q depends on undefined step %q", path, name, dependency) } } } visiting := map[string]bool{} visited := map[string]bool{} var visit func(string) visit = func(name string) { if visiting[name] { t.Errorf("workflow %q has a dependency cycle at step %q", path, name) return } if visited[name] { return } visiting[name] = true for _, dependency := range steps[name].DependsOn { if _, ok := steps[dependency]; ok { visit(dependency) } } visiting[name] = false visited[name] = true } for name := range steps { visit(name) } } func workflowDependsOn(steps map[string]woodpeckerStep, stepName, dependencyName string, visited map[string]bool) bool { if visited[stepName] { return false } visited[stepName] = true for _, dependency := range steps[stepName].DependsOn { if dependency == dependencyName || workflowDependsOn(steps, dependency, dependencyName, visited) { return true } } return false }