Skip to content

Group by section for GitLab CODEOWNERS #133

@OmerGronich

Description

@OmerGronich

Problem

--group-by owner groups by the first owner on each CODEOWNERS rule. In GitLab repos where every section shares a common approval group as the first owner, all findings collapse into one group.

[billing] @core-reviewers @alice @bob
src/billing/

[notifications] @core-reviewers @alice @bob
src/notifications/

[search] @core-reviewers @charlie @dave
src/search/

These are 3 distinct code areas, but --group-by owner produces 1 group (@core-reviewers). --group-by directory doesn't help either — real-world sections often span multiple directory trees.

Root cause: parse_section_header() in crates/cli/src/codeowners.rs (line ~221) computes the section name but never stores it. The CodeOwners struct only tracks owners, so the section name is lost.

Reproduction

Click to expand full repro steps
mkdir -p /tmp/fallow-section-repro/src/{billing,notifications,search,admin}
mkdir -p /tmp/fallow-section-repro/.gitlab

cat > /tmp/fallow-section-repro/package.json << 'EOF'
{ "name": "section-repro", "private": true }
EOF

cat > /tmp/fallow-section-repro/tsconfig.json << 'EOF'
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "outDir": "dist" }, "include": ["src"] }
EOF

cat > /tmp/fallow-section-repro/src/billing/invoice.ts << 'EOF'
export const currency = "USD";
export function unusedGenerateReceipt() { return "receipt"; }
export function unusedApplyDiscount() { return "discounted"; }
EOF

cat > /tmp/fallow-section-repro/src/notifications/email.ts << 'EOF'
export const defaultSender = "noreply@example.com";
export function unusedSendSMS() { return "sent"; }
EOF

cat > /tmp/fallow-section-repro/src/search/indexer.ts << 'EOF'
export const indexName = "products";
export function unusedReindex() { return "reindexed"; }
export function unusedPurgeIndex() { return "purged"; }
EOF

cat > /tmp/fallow-section-repro/src/admin/dashboard.ts << 'EOF'
export const adminPath = "/admin";
export function unusedExportCSV() { return "exported"; }
EOF

cat > /tmp/fallow-section-repro/src/index.ts << 'EOF'
import { currency } from "./billing/invoice";
import { defaultSender } from "./notifications/email";
import { indexName } from "./search/indexer";
import { adminPath } from "./admin/dashboard";
console.log(currency, defaultSender, indexName, adminPath);
EOF

cat > /tmp/fallow-section-repro/.gitlab/CODEOWNERS << 'EOF'
[billing] @core-reviewers @alice @bob
src/billing/

[notifications] @core-reviewers @alice @bob
src/notifications/

[search] @core-reviewers @charlie @dave
src/search/

[admin] @core-reviewers @eve
src/admin/
EOF

cd /tmp/fallow-section-repro
fallow dead-code --group-by owner --format json

Result: 1 group (@core-reviewers, 6 issues) instead of 4 groups.

Desired result — 4 groups, one per section:

{
  "schema_version": 4,
  "grouped_by": "section",
  "total_issues": 6,
  "groups": [
    {
      "key": "billing",
      "owners": ["@core-reviewers", "@alice", "@bob"],
      "total_issues": 2,
      "unused_exports": [
        { "path": "src/billing/invoice.ts", "export_name": "unusedGenerateReceipt", "line": 2 },
        { "path": "src/billing/invoice.ts", "export_name": "unusedApplyDiscount", "line": 3 }
      ]
    },
    {
      "key": "notifications",
      "owners": ["@core-reviewers", "@alice", "@bob"],
      "total_issues": 1,
      "unused_exports": [
        { "path": "src/notifications/email.ts", "export_name": "unusedSendSMS", "line": 2 }
      ]
    },
    {
      "key": "search",
      "owners": ["@core-reviewers", "@charlie", "@dave"],
      "total_issues": 2,
      "unused_exports": [
        { "path": "src/search/indexer.ts", "export_name": "unusedReindex", "line": 2 },
        { "path": "src/search/indexer.ts", "export_name": "unusedPurgeIndex", "line": 3 }
      ]
    },
    {
      "key": "admin",
      "owners": ["@core-reviewers", "@eve"],
      "total_issues": 1,
      "unused_exports": [
        { "path": "src/admin/dashboard.ts", "export_name": "unusedExportCSV", "line": 2 }
      ]
    }
  ]
}

Note: billing and notifications have the same owners but are separate groups — the section name is the key, owners are metadata.

Proposed solution

Group by section name ([SectionName] from GitLab CODEOWNERS headers). Section names are better grouping keys than owners because they're stable (don't change when reviewers rotate), produce a manageable number of groups, and represent intentional ownership boundaries.

parse_section_header() already computes the name but doesn't return it. Storing it alongside each rule would make it available for grouping. The API design (--group-by section, enhancement to --group-by owner, etc.) is your call.

Edge cases

  • Last-match-wins: Section determined by the last matching rule, same as owner today
  • Rules before first section: (no section) group
  • Duplicate section names: Merge or disambiguate — your call
  • ^[optional] sections: ^ only affects approval, not grouping
  • No sections (plain GitHub CODEOWNERS): Error or fallback to owner grouping

Alternatives considered

  1. --group-by directory: Breaks when sections span multiple directory trees
  2. Post-processing JSON: Works but duplicates CODEOWNERS parsing
  3. --group-by rule: Loses human-readable section names, doesn't handle multi-pattern sections

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions