Skip to content
Open
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
35 changes: 35 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# CODEOWNERS - Auto-assign reviewers based on file paths
# Documentation: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners

# Default: feihongxu0824 reviews everything not covered below
* @feihongxu0824

# .github directory
/.github/ @Cuiyus
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 /.github/ CODEOWNERS entry means this PR itself won't auto-assign @Cuiyus

@Cuiyus is listed as the owner of /.github/, but this PR is being submitted by @feihongxu0824 who is almost certainly a MEMBER/OWNER, so the community-pr-handler.yml would skip it anyway. More importantly, for future community PRs that touch .github/ files, the community-pr-handler.yml will correctly assign @Cuiyus — but native GitHub CODEOWNERS review requests are only triggered automatically if the CODEOWNERS file is in the default branch. Until this PR is merged, any in-flight fork PRs touching .github/ files won't automatically request a review from @Cuiyus via native GitHub CODEOWNERS. This is expected behaviour, just worth being aware of.


# cmake, examples, scripts
/cmake/ @egolearner
/examples/ @egolearner
/scripts/ @egolearner

# src/db, tests
/src/db/ @zhourrr
/tests/ @zhourrr

# src/core, src/turbo
/src/core/ @richyreachy
/src/turbo/ @richyreachy

# src/ailego
/src/ailego/ @iaojnh

# tools
/tools/ @JalinWang

# src/binding
/src/binding/ @Cuiyus

# src/include, thirdparty, .gitmodules
/src/include/ @chinaux
/thirdparty/ @chinaux
.gitmodules @chinaux
24 changes: 24 additions & 0 deletions .github/auto-assign-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Auto-assign configuration
# Documentation: https://github.com/kentaro-m/auto-assign-action
#
# NOTE: Reviewers are assigned via CODEOWNERS file based on file paths.
# This config only handles assignee assignment.

# Automatically add PR author as assignee
addAssignees: author

# Number of assignees to add (0 = add all from the list)
numberOfAssignees: 0

# Reviewers are handled by CODEOWNERS, not here
addReviewers: false

# Skip draft PRs
skipDraft: true

# Skip keywords in PR title (won't assign if title contains these)
skipKeywords:
- wip
- WIP
- draft
- DRAFT
25 changes: 25 additions & 0 deletions .github/workflows/auto-assign.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Auto Assign PR

on:
pull_request_target:
types: [opened, reopened, ready_for_review]

permissions:
contents: read
pull-requests: write

jobs:
auto-assign:
runs-on: ubuntu-latest
steps:
- name: Checkout for config
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref }}
sparse-checkout: .github/auto-assign-config.yml
sparse-checkout-cone-mode: false

- name: Auto Assign PR Author
uses: kentaro-m/auto-assign-action@v2.0.0
with:
configuration-path: '.github/auto-assign-config.yml'
148 changes: 148 additions & 0 deletions .github/workflows/community-pr-handler.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
name: Community PR Handler

on:
pull_request_target:
types: [opened, reopened, ready_for_review]

permissions:
contents: read
pull-requests: write

jobs:
handle-community-pr:
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: Check if community contributor
id: check
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const prAuthor = pr.user.login;
const association = pr.author_association;

console.log('========== PR Info ==========');
console.log(`PR Number: #${pr.number}`);
console.log(`PR Title: ${pr.title}`);
console.log(`PR Author: ${prAuthor}`);
console.log(`Author Association: ${association}`);
console.log(`Base Branch: ${pr.base.ref}`);
console.log(`Head Branch: ${pr.head.ref}`);
console.log(`Head Repo: ${pr.head.repo?.full_name || 'same repo'}`);

// OWNER, MEMBER, COLLABORATOR are maintainers; others are community contributors
const maintainerRoles = ['OWNER', 'MEMBER', 'COLLABORATOR'];
const isCommunity = !maintainerRoles.includes(association);

console.log('========== Decision ==========');
console.log(`Maintainer Roles: ${maintainerRoles.join(', ')}`);
console.log(`Is Community Contributor: ${isCommunity}`);

if (!isCommunity) {
console.log('Skipping: PR author is a maintainer, no action needed.');
}

core.setOutput('is_community', isCommunity);

- name: Checkout base branch for CODEOWNERS
if: steps.check.outputs.is_community == 'true'
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref }}
sparse-checkout: .github/CODEOWNERS
sparse-checkout-cone-mode: false

- name: Assign reviewer as assignee
if: steps.check.outputs.is_community == 'true'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const prNumber = context.payload.pull_request.number;

console.log('========== Fetching Changed Files ==========');
// Get changed files
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
const changedFiles = files.map(f => f.filename);
Comment on lines +66 to +71
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 listFiles is not paginated — large PRs will silently miss files

github.rest.pulls.listFiles returns at most 30 results by default (max 100 per page, max 3000 total via pagination). For PRs touching many files, only the first page is fetched, so some changed files will be ignored and their CODEOWNERS reviewers will never be assigned.

Use github.paginate to collect all pages:

const files = await github.paginate(github.rest.pulls.listFiles, {
  owner: context.repo.owner,
  repo: context.repo.repo,
  pull_number: prNumber,
  per_page: 100,
});
const changedFiles = files.map(f => f.filename);

console.log(`Total changed files: ${changedFiles.length}`);
changedFiles.forEach((f, i) => console.log(` [${i + 1}] ${f}`));

console.log('========== Parsing CODEOWNERS ==========');
// Parse CODEOWNERS
let codeowners = [];
try {
const content = fs.readFileSync('.github/CODEOWNERS', 'utf8');
const lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('#'));
console.log(`Found ${lines.length} rules in CODEOWNERS`);
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 2) {
const pattern = parts[0];
const owners = parts.slice(1).map(o => o.replace('@', ''));
codeowners.push({ pattern, owners });
console.log(` Rule: "${pattern}" -> [${owners.join(', ')}]`);
}
}
} catch (e) {
console.log('ERROR: Could not read CODEOWNERS:', e.message);
return;
}

console.log('========== Matching Files to Owners ==========');
// Find matching owners for changed files
const matchedOwners = new Set();
for (const file of changedFiles) {
let matchedOwner = null;
let matchedPattern = null;
for (const rule of codeowners) {
const pattern = rule.pattern;
if (pattern === '*') {
matchedOwner = rule.owners[0];
matchedPattern = pattern;
} else if (pattern.endsWith('/')) {
const dir = pattern.replace(/^\//, '').replace(/\/$/, '');
if (file.startsWith(dir + '/') || file.startsWith(dir)) {
matchedOwner = rule.owners[0];
matchedPattern = pattern;
}
} else if (file === pattern || file === pattern.replace(/^\//, '')) {
matchedOwner = rule.owners[0];
matchedPattern = pattern;
}
Comment on lines +102 to +116
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 CODEOWNERS pattern matching logic uses first-match but CODEOWNERS semantics require last-match

The inner loop iterates all rules and continuously overwrites matchedOwner without ever breaking. Because the CODEOWNERS file is ordered * first then specific directories, this accidentally produces correct "last match wins" behaviour today. But it also means the wildcard * sets feihongxu0824 unconditionally, even if a more specific rule is later matched and overrides it — which relies entirely on rule ordering.

More critically, the parser only handles three pattern shapes:

  • Literal *
  • Patterns ending with / (directory prefixes)
  • Exact file paths

Any future CODEOWNERS rules using standard glob patterns (e.g., *.md, docs/**, /src/*.h) will silently fail to match, causing files to fall through to feihongxu0824 (the * default) rather than their correct owner. Consider using the ignore npm package (already available in actions/github-script) or a dedicated CODEOWNERS parser to match rules correctly.

}
if (matchedOwner) {
matchedOwners.add(matchedOwner);
console.log(` "${file}" -> matched "${matchedPattern}" -> @${matchedOwner}`);
} else {
console.log(` "${file}" -> NO MATCH`);
}
}

console.log('========== Setting Assignees ==========');
// Set assignees
const assignees = Array.from(matchedOwners);
console.log(`Assignees to set: [${assignees.join(', ')}]`);

if (assignees.length > 0) {
try {
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
assignees: assignees
});
console.log('SUCCESS: Assignees set successfully!');
} catch (e) {
console.log('ERROR: Failed to set assignees:', e.message);
console.log('Error details:', JSON.stringify(e, null, 2));
}
} else {
console.log('WARNING: No assignees to set - no matching owners found.');
}

console.log('========== Done ==========');
Loading