diff --git a/README.md b/README.md index 61231bb..e5f6757 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,8 @@ Alongside the toolchain, brief picks up the conventional documents that describe Matching is case-insensitive and checks the repo root first, then `docs/`, `.github/`, and `.gitlab/`. Paths in the output are repo-relative, so a funding file found under `.github/` is reported as `.github/FUNDING.yml` rather than just the basename. In JSON the groups appear as nested objects under `resources.legal`, `resources.community`, `resources.security`, and `resources.metadata`. +Issue and pull request templates are reported under `resources.templates` so that contributors and coding agents can find and follow them. Unlike the single-path resources above this is a list: every template file found is included, split into `issue` and `pull_request` arrays plus a `config` path for the issue chooser. Detection covers the locations recognised by GitHub, GitLab, Gitea, and Forgejo, which means the repo root, `docs/`, `.github/`, `.gitea/`, `.forgejo/`, and `.gitlab/`, in both single-file form (`PULL_REQUEST_TEMPLATE.md`, `issue_template.md`) and directory form (`ISSUE_TEMPLATE/`, `PULL_REQUEST_TEMPLATE/`, GitLab's `issue_templates/` and `merge_request_templates/`). Merge request templates are reported under `pull_request` rather than getting their own field. + ## Agent skills Separately from resources, brief reports agent skills the project provides. These are packaged instructions an AI coding agent can load on demand, not guidance on how to work on this codebase. Detection currently covers Anthropic's `SKILL.md` convention: a `SKILL.md` file with YAML frontmatter under `skills//` or `.claude/skills//`. Each skill is listed with its name and description from the frontmatter (falling back to the directory name) and the path to its `SKILL.md`. In JSON they appear under `skills` with a `format` field set to `claude` so other skill formats can be added later without changing the shape. diff --git a/brief.go b/brief.go index b09a125..a19f930 100644 --- a/brief.go +++ b/brief.go @@ -113,6 +113,22 @@ type ResourceInfo struct { Community map[string]string `json:"community,omitempty"` Security map[string]string `json:"security,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` + + Templates *TemplateInfo `json:"templates,omitempty"` +} + +// TemplateInfo lists issue and pull request templates the project provides so +// that contributors (and agents) can follow them. Paths are relative to the +// repository root. PullRequest also covers GitLab merge request templates. +type TemplateInfo struct { + Issue []string `json:"issue,omitempty"` + PullRequest []string `json:"pull_request,omitempty"` + Config string `json:"config,omitempty"` +} + +// Empty reports whether no templates were found. +func (t *TemplateInfo) Empty() bool { + return t == nil || (len(t.Issue) == 0 && len(t.PullRequest) == 0 && t.Config == "") } // Group returns the map for the named resource group, creating it if needed. @@ -148,7 +164,8 @@ func (r *ResourceInfo) Empty() bool { return r.Readme == "" && r.Changelog == "" && r.Roadmap == "" && r.License == "" && r.Agents == "" && len(r.Legal) == 0 && len(r.Community) == 0 && - len(r.Security) == 0 && len(r.Metadata) == 0 + len(r.Security) == 0 && len(r.Metadata) == 0 && + r.Templates.Empty() } // Skill is an agent skill the project provides: packaged instructions an AI diff --git a/detect/detect.go b/detect/detect.go index 5bcd51f..699ad12 100644 --- a/detect/detect.go +++ b/detect/detect.go @@ -1207,12 +1207,94 @@ func (e *Engine) detectResources() *brief.ResourceInfo { } } + res.Templates = e.detectTemplates() + if res.Empty() { return nil } return res } +var ( + templateBaseDirs = []string{".", ".github", ".gitea", ".forgejo", ".gitlab", "docs"} + templateExts = map[string]bool{".md": true, ".yaml": true, ".yml": true, ".txt": true} +) + +// detectTemplates finds issue and pull/merge request templates across the +// locations recognised by GitHub, GitLab, Gitea and Forgejo. Both single-file +// templates and template directories are checked. +func (e *Engine) detectTemplates() *brief.TemplateInfo { + t := &brief.TemplateInfo{} + for _, base := range templateBaseDirs { + entries, err := os.ReadDir(filepath.Join(e.Root, filepath.FromSlash(base))) + if err != nil { + continue + } + for _, ent := range entries { + name := ent.Name() + lower := strings.ToLower(name) + rel := name + if base != "." { + rel = path.Join(base, name) + } + if ent.IsDir() { + switch lower { + case "issue_template", "issue_templates": + e.collectTemplates(rel, &t.Issue, &t.Config) + case "pull_request_template", "merge_request_templates": + e.collectTemplates(rel, &t.PullRequest, nil) + } + continue + } + if !templateExts[path.Ext(lower)] || !e.isTracked(rel) { + continue + } + switch strings.TrimSuffix(lower, path.Ext(lower)) { + case "issue_template": + t.Issue = append(t.Issue, rel) + case "pull_request_template": + t.PullRequest = append(t.PullRequest, rel) + } + } + } + sort.Strings(t.Issue) + sort.Strings(t.PullRequest) + if t.Empty() { + return nil + } + return t +} + +// collectTemplates lists template files in dir, separating the issue chooser +// config.yml from actual templates. +func (e *Engine) collectTemplates(dir string, into *[]string, config *string) { + entries, err := os.ReadDir(filepath.Join(e.Root, filepath.FromSlash(dir))) + if err != nil { + return + } + for _, ent := range entries { + if ent.IsDir() { + continue + } + name := ent.Name() + lower := strings.ToLower(name) + if !templateExts[path.Ext(lower)] { + continue + } + rel := path.Join(dir, name) + if !e.isTracked(rel) { + continue + } + if config != nil && (lower == "config.yml" || lower == "config.yaml") { + if *config == "" { + *config = rel + } + continue + } + *into = append(*into, rel) + } +} + // findResource searches for the first file matching any of the resource's // patterns, in the repo root and then each configured subdirectory. Matching // is case-insensitive. It returns the absolute path and the path relative to diff --git a/detect/detect_test.go b/detect/detect_test.go index 11e1802..807e31b 100644 --- a/detect/detect_test.go +++ b/detect/detect_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "testing" "github.com/git-pkgs/brief" @@ -209,6 +210,69 @@ func TestResourceCaseInsensitive(t *testing.T) { } } +func TestDetectTemplates(t *testing.T) { + dir := t.TempDir() + for _, p := range []string{ + ".github/ISSUE_TEMPLATE/bug_report.md", + ".github/ISSUE_TEMPLATE/feature.yml", + ".github/ISSUE_TEMPLATE/config.yml", + ".github/ISSUE_TEMPLATE/ignore.png", + ".github/PULL_REQUEST_TEMPLATE.md", + ".gitlab/merge_request_templates/Default.md", + ".gitlab/issue_templates/Bug.md", + ".forgejo/pull_request_template.yaml", + "docs/issue_template.md", + } { + writeFile(t, dir, p, "x") + } + + engine := New(loadKB(t), dir) + r, err := engine.Run() + if err != nil { + t.Fatalf("Run: %v", err) + } + tpl := r.Resources.Templates + if tpl == nil { + t.Fatal("expected templates") + } + + wantIssue := []string{ + ".github/ISSUE_TEMPLATE/bug_report.md", + ".github/ISSUE_TEMPLATE/feature.yml", + ".gitlab/issue_templates/Bug.md", + "docs/issue_template.md", + } + if !slices.Equal(tpl.Issue, wantIssue) { + t.Errorf("issue = %v, want %v", tpl.Issue, wantIssue) + } + + wantPR := []string{ + ".forgejo/pull_request_template.yaml", + ".github/PULL_REQUEST_TEMPLATE.md", + ".gitlab/merge_request_templates/Default.md", + } + if !slices.Equal(tpl.PullRequest, wantPR) { + t.Errorf("pull_request = %v, want %v", tpl.PullRequest, wantPR) + } + + if tpl.Config != ".github/ISSUE_TEMPLATE/config.yml" { + t.Errorf("config = %q", tpl.Config) + } +} + +func TestDetectTemplatesNone(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "README.md", "x") + engine := New(loadKB(t), dir) + r, err := engine.Run() + if err != nil { + t.Fatalf("Run: %v", err) + } + if r.Resources.Templates != nil { + t.Errorf("expected nil templates, got %+v", r.Resources.Templates) + } +} + func TestResourceRootBeatsSubdir(t *testing.T) { dir := t.TempDir() for _, p := range []string{"CONTRIBUTING.md", ".github/CONTRIBUTING.md"} { diff --git a/detect/filter.go b/detect/filter.go index f8ebf17..e45b386 100644 --- a/detect/filter.go +++ b/detect/filter.go @@ -209,6 +209,26 @@ func (fc *filterContext) filterResources(res *brief.ResourceInfo, changedFiles [ filterGroup("security", res.Security) filterGroup("metadata", res.Metadata) + if t := res.Templates; t != nil { + ft := &brief.TemplateInfo{} + for _, p := range t.Issue { + if hit(p) { + ft.Issue = append(ft.Issue, p) + } + } + for _, p := range t.PullRequest { + if hit(p) { + ft.PullRequest = append(ft.PullRequest, p) + } + } + if hit(t.Config) { + ft.Config = t.Config + } + if !ft.Empty() { + out.Templates = ft + } + } + if out.Empty() { return nil } diff --git a/detect/filter_test.go b/detect/filter_test.go index 8454b89..87b62ae 100644 --- a/detect/filter_test.go +++ b/detect/filter_test.go @@ -42,6 +42,38 @@ func TestFilterResources(t *testing.T) { } } +func TestFilterTemplates(t *testing.T) { + res := &brief.ResourceInfo{ + Templates: &brief.TemplateInfo{ + Issue: []string{ + ".github/ISSUE_TEMPLATE/bug.md", + ".github/ISSUE_TEMPLATE/feature.yml", + }, + PullRequest: []string{".github/PULL_REQUEST_TEMPLATE.md"}, + Config: ".github/ISSUE_TEMPLATE/config.yml", + }, + } + fc := &filterContext{} + + out := fc.filterResources(res, []string{".github/ISSUE_TEMPLATE/bug.md"}) + if out == nil || out.Templates == nil { + t.Fatal("expected filtered templates") + } + if len(out.Templates.Issue) != 1 || out.Templates.Issue[0] != ".github/ISSUE_TEMPLATE/bug.md" { + t.Errorf("issue = %v", out.Templates.Issue) + } + if len(out.Templates.PullRequest) != 0 { + t.Errorf("pull_request should be empty, got %v", out.Templates.PullRequest) + } + if out.Templates.Config != "" { + t.Errorf("config should be empty, got %q", out.Templates.Config) + } + + if fc.filterResources(res, []string{"main.go"}) != nil { + t.Error("expected nil when no templates changed") + } +} + func TestFilterSkills(t *testing.T) { skills := []brief.Skill{ {Name: "pdf", Path: "skills/pdf/SKILL.md", Format: "claude"}, diff --git a/report/markdown.go b/report/markdown.go index 0cd41c2..2ae0a39 100644 --- a/report/markdown.go +++ b/report/markdown.go @@ -265,6 +265,23 @@ func mdResources(w io.Writer, res *brief.ResourceInfo) { mdResourceGroup(w, "Community", res.Community) mdResourceGroup(w, "Security", res.Security) mdResourceGroup(w, "Metadata", res.Metadata) + mdTemplates(w, res.Templates) +} + +func mdTemplates(w io.Writer, t *brief.TemplateInfo) { + if t.Empty() { + return + } + _, _ = fmt.Fprintln(w, "- Templates:") + for _, p := range t.Issue { + _, _ = fmt.Fprintf(w, " - issue: %s\n", sanitize(p)) + } + for _, p := range t.PullRequest { + _, _ = fmt.Fprintf(w, " - pull request: %s\n", sanitize(p)) + } + if t.Config != "" { + _, _ = fmt.Fprintf(w, " - config: %s\n", sanitize(t.Config)) + } } func mdResource(w io.Writer, path string) { diff --git a/report/markdown_test.go b/report/markdown_test.go index a8c71d9..6868fea 100644 --- a/report/markdown_test.go +++ b/report/markdown_test.go @@ -192,6 +192,32 @@ func TestMarkdownResources(t *testing.T) { } } +func TestMarkdownTemplates(t *testing.T) { + r := &brief.Report{ + Version: "dev", + Path: "/tmp/test", + Resources: &brief.ResourceInfo{ + Templates: &brief.TemplateInfo{ + Issue: []string{".github/ISSUE_TEMPLATE/bug.md"}, + PullRequest: []string{".github/PULL_REQUEST_TEMPLATE.md"}, + Config: ".github/ISSUE_TEMPLATE/config.yml", + }, + }, + } + + var buf bytes.Buffer + Markdown(&buf, r, false) + out := buf.String() + + want := "- Templates:\n" + + " - issue: .github/ISSUE_TEMPLATE/bug.md\n" + + " - pull request: .github/PULL_REQUEST_TEMPLATE.md\n" + + " - config: .github/ISSUE_TEMPLATE/config.yml\n" + if !strings.Contains(out, want) { + t.Errorf("missing templates section\ngot:\n%s", out) + } +} + func TestMarkdownSanitizesCIMatrix(t *testing.T) { r := &brief.Report{ Version: "dev", diff --git a/report/report.go b/report/report.go index 8d6e04d..8bf5652 100644 --- a/report/report.go +++ b/report/report.go @@ -300,6 +300,22 @@ func printResources(w io.Writer, res *brief.ResourceInfo) { printResourceGroup(w, "Community", res.Community) printResourceGroup(w, "Security", res.Security) printResourceGroup(w, "Metadata", res.Metadata) + printTemplates(w, res.Templates) +} + +func printTemplates(w io.Writer, t *brief.TemplateInfo) { + if t.Empty() { + return + } + for _, p := range t.Issue { + _, _ = fmt.Fprintf(w, "Templates: issue %s\n", sanitize(p)) + } + for _, p := range t.PullRequest { + _, _ = fmt.Fprintf(w, "Templates: pull request %s\n", sanitize(p)) + } + if t.Config != "" { + _, _ = fmt.Fprintf(w, "Templates: config %s\n", sanitize(t.Config)) + } } func printResourceGroup(w io.Writer, label string, group map[string]string) { diff --git a/report/report_test.go b/report/report_test.go index ff114ef..93330de 100644 --- a/report/report_test.go +++ b/report/report_test.go @@ -249,6 +249,34 @@ func TestHumanSkills(t *testing.T) { } } +func TestHumanTemplates(t *testing.T) { + r := &brief.Report{ + Version: "dev", + Path: "/tmp/test", + Resources: &brief.ResourceInfo{ + Templates: &brief.TemplateInfo{ + Issue: []string{".github/ISSUE_TEMPLATE/bug.md"}, + PullRequest: []string{".github/PULL_REQUEST_TEMPLATE.md"}, + Config: ".github/ISSUE_TEMPLATE/config.yml", + }, + }, + } + + var buf bytes.Buffer + Human(&buf, r, false) + out := buf.String() + + if !strings.Contains(out, "Templates: issue .github/ISSUE_TEMPLATE/bug.md") { + t.Errorf("missing issue template line\ngot:\n%s", out) + } + if !strings.Contains(out, "Templates: pull request .github/PULL_REQUEST_TEMPLATE.md") { + t.Errorf("missing pr template line\ngot:\n%s", out) + } + if !strings.Contains(out, "Templates: config .github/ISSUE_TEMPLATE/config.yml") { + t.Errorf("missing config line\ngot:\n%s", out) + } +} + func TestHumanSanitizesResources(t *testing.T) { r := &brief.Report{ Version: "dev",