Skip to content
Closed
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

# 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
24 changes: 24 additions & 0 deletions .github/workflows/auto-assign.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Auto Assign PR

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

permissions:
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'
147 changes: 147 additions & 0 deletions .github/workflows/community-pr-handler.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
name: Community PR Handler

on:
pull_request_target:
types: [opened, reopened, ready_for_review]
Comment on lines +3 to +5
Copy link

Choose a reason for hiding this comment

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

P2 pull_request_target + checkout requires caution

The workflow uses the pull_request_target trigger, which runs with write permissions against the base repository. Although the actions/checkout@v4 step correctly checks out the base branch (not the PR head), the pulls.listFiles and issues.addAssignees calls operate on data from the PR author's fork. If a malicious contributor crafts a file with a specially crafted filename (e.g., containing shell metacharacters), the logging statements (console.log) in the script could be misused. This is low-risk here since no eval or shell execution is performed on filenames, but it's worth being aware of the pull_request_target attack surface and ensuring no future changes execute arbitrary content from the PR.


permissions:
pull-requests: write
Comment on lines +7 to +8
Copy link

Choose a reason for hiding this comment

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

P0 Missing issues: write permission

The workflow calls github.rest.issues.addAssignees (line 132) to set assignees on the PR, but the permissions block only grants pull-requests: write. Assigning users to an issue/PR via the GitHub REST API requires issues: write — without it, the API call will fail with a "Resource not accessible by integration" error, and the core functionality of this workflow (assigning code owners as reviewers) will silently fail.

Suggested change
permissions:
pull-requests: write
permissions:
pull-requests: write
issues: 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
});
Comment on lines +65 to +69
Copy link

Choose a reason for hiding this comment

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

P1 listFiles not paginated — files beyond page 1 will be silently skipped

github.rest.pulls.listFiles returns at most 30 files per page by default. For any PR that touches more than 30 files, only the first 30 are fetched here, so CODEOWNERS owners for the remaining files are never computed and never assigned. Use github.paginate to consume 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);

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)) {
Copy link

Choose a reason for hiding this comment

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

P1 Directory pattern matching too broad

The condition || file.startsWith(dir) (without a trailing /) causes false-positive matches. For example, a hypothetical file src/db_utils.cpp or src/binding_test.go at the repository root would incorrectly match the /src/db/ or /src/binding/ CODEOWNERS patterns because "src/db_utils.cpp".startsWith("src/db") evaluates to true.

Only the file.startsWith(dir + '/') check is correct for directory patterns. Remove the bare file.startsWith(dir) branch:

Suggested change
if (file.startsWith(dir + '/') || file.startsWith(dir)) {
if (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 +99 to +115
Copy link

Choose a reason for hiding this comment

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

P2 Only first owner per CODEOWNERS rule is assigned

The script always uses rule.owners[0], ignoring any additional owners listed on the same CODEOWNERS line. While the current CODEOWNERS file only has one owner per pattern, this is a fragile assumption. If a pattern is later updated to have multiple owners (e.g., /src/core/ @richyreachy @feihongxu0824), only the first will ever be assigned, which may lead to missed review coverage.

Consider using rule.owners (the full array) when building the matchedOwners set:

// Instead of: matchedOwner = rule.owners[0];
rule.owners.forEach(o => matchedOwners.add(o));

}
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