Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/` or `.claude/skills/<name>/`. 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.
Expand Down
19 changes: 18 additions & 1 deletion brief.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions detect/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions detect/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"testing"

"github.com/git-pkgs/brief"
Expand Down Expand Up @@ -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"} {
Expand Down
20 changes: 20 additions & 0 deletions detect/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
32 changes: 32 additions & 0 deletions detect/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
17 changes: 17 additions & 0 deletions report/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
26 changes: 26 additions & 0 deletions report/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions report/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
28 changes: 28 additions & 0 deletions report/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading