package issue import ( "fmt" "regexp" "strings" ) var ( titlePrefixRe = regexp.MustCompile( `(?i)^\s*(\[[^\]]+\]|(fix|feat|feature|bug|task|test|chore|refactor)\s*:)`) cyrillicRe = regexp.MustCompile(`(?i)[а-яё]`) ) // Validate reports what is wrong with an issue. // // Errors mean the issue is not well-formed in the canonical format; warnings // mean it deviates from its type template. Pass knownIDs to have dependencies // resolved against a store; pass nil to skip that check. func Validate(i *Issue, knownIDs map[string]bool) (errs, warns []string) { switch { case i.ID == "": errs = append(errs, "no `id:` — the slug is the issue's identity") case !IsSlug(i.ID): errs = append(errs, fmt.Sprintf("id %q is not a slug (lowercase, digits, single dashes)", i.ID)) } if !contains(States, i.State) { errs = append(errs, fmt.Sprintf("state %q must be one of: %s", i.State, strings.Join(States, ", "))) } var types []string severities := 0 for _, l := range i.Labels { if strings.HasPrefix(l, "type/") { types = append(types, l) } if strings.HasPrefix(l, "severity/") { severities++ } } switch { case len(types) != 1: found := strings.Join(types, ", ") if found == "" { found = "none" } errs = append(errs, fmt.Sprintf("need exactly one type/* label, found %d: %s", len(types), found)) case !KnownType(i.Type()): errs = append(errs, fmt.Sprintf("unknown type %q — known: %s", i.Type(), strings.Join(TypeNames(), ", "))) } if severities > 1 { errs = append(errs, "at most one severity/* label") } if s := i.Severity(); s != "" && !KnownSeverity(s) { warns = append(warns, fmt.Sprintf("unknown severity %q", s)) } if i.Title == "" { errs = append(errs, "no `# Title` heading below the metadata block") } else { if titlePrefixRe.MatchString(i.Title) { head := i.Title if len(head) > 24 { head = head[:24] } errs = append(errs, fmt.Sprintf( "title carries a type prefix (%q) — the type lives in the label", head)) } if cyrillicRe.MatchString(i.Title) { errs = append(errs, "title must be English, imperative mood (prose stays Russian)") } } for _, h := range RequiredSections { if !strings.Contains(i.Body, h) { errs = append(errs, "missing section "+h) } } if i.Type() != "draft" && !strings.Contains(i.Body, ACSection) { errs = append(errs, "missing section "+ACSection) } if strings.Contains(i.Body, SpecSection) && SectionBody(i.Body, SpecSection) == "" { errs = append(errs, "## Spec is empty — put a repo path, a URL, or the literal `none`") } for _, h := range ExpectedSections[i.Type()] { if !strings.Contains(i.Body, h) { warns = append(warns, fmt.Sprintf("type/%s template usually has %s", i.Type(), h)) } } if contains(i.Depends, i.ID) { errs = append(errs, "depends on itself") } if knownIDs != nil { for _, d := range i.Depends { if !knownIDs[d] { warns = append(warns, fmt.Sprintf("depends on %q, which is not in the store", d)) } } } // `depends:` is the machine-readable graph; the body section is prose for // humans. They drift silently unless something says so. Name the section // the reference actually came from — for a container that is `## Issues`. for _, r := range BodyDepRefs(i.Body) { if !strings.HasPrefix(r.Ref, "#") && !contains(i.Depends, r.Ref) { warns = append(warns, fmt.Sprintf( "%s mentions %q but `depends:` does not list it", r.Section, r.Ref)) } } // An unticked checkbox is never a finding — neither an error nor a warning. // `- [ ]` is work not done yet, which is the normal state of a perfectly // well-formed issue. Reading that state is the `ac` command's job. return errs, warns } func contains(xs []string, x string) bool { for _, v := range xs { if v == x { return true } } return false }