272 lines
7.0 KiB
Go
272 lines
7.0 KiB
Go
package doccheck
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"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 TestRoadmapTraceabilityOwnersAreComplete(t *testing.T) {
|
|
root := repositoryRoot(t)
|
|
data, err := os.ReadFile(filepath.Join(root, "docs", "roadmap", "implementation.md"))
|
|
if err != nil {
|
|
t.Fatalf("read implementation roadmap: %v", err)
|
|
}
|
|
|
|
statuses := roadmapSummaryStatuses(t, string(data))
|
|
checked := 0
|
|
for _, owner := range roadmapTraceabilityOwners(t, string(data)) {
|
|
checked++
|
|
if statuses[owner] != "Completed" {
|
|
t.Errorf("traceability owner %d has status %q, want Completed", owner, statuses[owner])
|
|
}
|
|
}
|
|
if checked == 0 {
|
|
t.Fatal("roadmap traceability inventory has no owners")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func roadmapSummaryStatuses(t *testing.T, document string) map[int]string {
|
|
t.Helper()
|
|
statuses := map[int]string{}
|
|
for _, line := range strings.Split(document, "\n") {
|
|
fields := strings.Split(line, "|")
|
|
if len(fields) != 6 {
|
|
continue
|
|
}
|
|
owner, err := strconv.Atoi(strings.TrimSpace(fields[1]))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
statuses[owner] = strings.TrimSpace(fields[4])
|
|
}
|
|
if len(statuses) == 0 {
|
|
t.Fatal("roadmap summary has no status entries")
|
|
}
|
|
return statuses
|
|
}
|
|
|
|
func roadmapTraceabilityOwners(t *testing.T, document string) []int {
|
|
t.Helper()
|
|
const inventoryHeading = "## Finding traceability inventory"
|
|
const nextHeading = "## Open Questions"
|
|
start := strings.Index(document, inventoryHeading)
|
|
if start < 0 {
|
|
t.Fatal("roadmap traceability inventory is missing")
|
|
}
|
|
inventory := document[start+len(inventoryHeading):]
|
|
if end := strings.Index(inventory, nextHeading); end >= 0 {
|
|
inventory = inventory[:end]
|
|
}
|
|
|
|
owners := []int{}
|
|
for _, line := range strings.Split(inventory, "\n") {
|
|
fields := strings.Split(line, "|")
|
|
if len(fields) != 4 {
|
|
continue
|
|
}
|
|
owner, err := strconv.Atoi(strings.TrimSpace(fields[2]))
|
|
if err == nil {
|
|
owners = append(owners, owner)
|
|
}
|
|
}
|
|
return owners
|
|
}
|
|
|
|
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
|
|
}
|