Skip to content

Commit 482517c

Browse files
authored
Change review flow (#3561)
Auto-assigning Docs team reviewers is really noisy, so we are trying out this GH action that puts the oneous on the PR author to tag one of us when they are ready for review.
1 parent 91577a6 commit 482517c

File tree

3 files changed

+184
-25
lines changed

3 files changed

+184
-25
lines changed

.github/CODEOWNERS

Lines changed: 0 additions & 25 deletions
This file was deleted.

.github/OWNERS

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Doc ownership — used by the pr-welcome-comment workflow to tell
2+
# PR authors who to request review from. Uses CODEOWNERS syntax
3+
# (last matching rule wins).
4+
#
5+
# This file intentionally is NOT named CODEOWNERS so that GitHub
6+
# does not auto-request reviewers.
7+
8+
# Owner of this file
9+
/.github/OWNERS @lnhsingh
10+
11+
# Default owners for everything in the repo.
12+
* @lnhsingh @katmayb @npentrel @fjmorris
13+
14+
# Any file in the `/src/oss`
15+
# and any of its subdirectories.
16+
/src/oss/ @npentrel @lnhsingh
17+
18+
# Any file in the `/src/oss/python/integrations`
19+
# and any of its subdirectories.
20+
/src/oss/python/integrations/ @mdrxy
21+
22+
# Any file in the `/src/langsmith`
23+
# and any of its subdirectories.
24+
/src/langsmith/ @katmayb @fjmorris
25+
26+
# Any file in `/src/langsmith/fleet/`
27+
# (must come after the general /src/langsmith/ rule — last match wins).
28+
/src/langsmith/fleet/ @lnhsingh
29+
30+
# security.txt in `/src/.well-known/`
31+
/src/.well-known/security.txt @jkennedyvz
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Post a welcome comment on new PRs telling the author who owns
2+
# the docs areas they're changing (derived from .github/OWNERS).
3+
#
4+
# NOTE: This uses `pull_request_target` so it has write access to
5+
# comment on PRs from forks. The script only reads OWNERS from
6+
# the base branch and lists changed files — no untrusted code is executed.
7+
8+
name: PR Welcome Comment
9+
10+
on:
11+
pull_request_target:
12+
types: [opened, ready_for_review]
13+
14+
jobs:
15+
welcome-comment:
16+
runs-on: ubuntu-latest
17+
permissions:
18+
contents: read
19+
pull-requests: write
20+
21+
# Skip draft PRs — the comment will be posted when marked ready
22+
if: ${{ !github.event.pull_request.draft }}
23+
24+
steps:
25+
- name: Post welcome comment with owners
26+
uses: actions/github-script@v8
27+
with:
28+
github-token: ${{ secrets.GITHUB_TOKEN }}
29+
script: |
30+
const { owner, repo } = context.repo;
31+
const pr = context.payload.pull_request;
32+
const author = pr.user.login;
33+
34+
// --- Fetch OWNERS from the base branch ---
35+
let ownersContent;
36+
try {
37+
const { data } = await github.rest.repos.getContent({
38+
owner, repo,
39+
path: '.github/OWNERS',
40+
ref: pr.base.ref
41+
});
42+
ownersContent = Buffer.from(data.content, 'base64').toString();
43+
} catch (error) {
44+
console.log('Could not read .github/OWNERS:', error.message);
45+
return;
46+
}
47+
48+
// --- Parse OWNERS rules (CODEOWNERS syntax) ---
49+
const rules = [];
50+
for (const line of ownersContent.split('\n')) {
51+
const trimmed = line.trim();
52+
if (!trimmed || trimmed.startsWith('#')) continue;
53+
const parts = trimmed.split(/\s+/);
54+
const pattern = parts[0];
55+
const owners = parts.slice(1).filter(o => o.startsWith('@'));
56+
if (owners.length > 0) rules.push({ pattern, owners });
57+
}
58+
59+
// Last matching rule wins (same as GitHub CODEOWNERS behavior)
60+
function findOwners(filepath) {
61+
let matched = null;
62+
for (const rule of rules) {
63+
let pat = rule.pattern.startsWith('/') ? rule.pattern.slice(1) : rule.pattern;
64+
let matches = false;
65+
if (pat === '*') {
66+
matches = true;
67+
} else if (pat.endsWith('/')) {
68+
matches = filepath.startsWith(pat);
69+
} else if (pat.includes('*')) {
70+
const re = new RegExp(
71+
'^' + pat.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
72+
);
73+
matches = re.test(filepath);
74+
} else {
75+
matches = filepath === pat;
76+
}
77+
if (matches) matched = rule;
78+
}
79+
return matched ? matched.owners : [];
80+
}
81+
82+
// --- Map file paths to human-readable product names ---
83+
function getProductName(filepath) {
84+
const map = [
85+
['src/langsmith/fleet/', 'LangSmith Fleet'],
86+
['src/langsmith/', 'LangSmith'],
87+
['src/oss/deepagents/', 'Deep Agents'],
88+
['src/oss/langgraph/', 'LangGraph'],
89+
['src/oss/langchain/', 'LangChain'],
90+
['src/oss/python/integrations/', 'Python integrations'],
91+
['src/oss/javascript/integrations/', 'JavaScript integrations'],
92+
['src/oss/', 'open source'],
93+
];
94+
for (const [prefix, name] of map) {
95+
if (filepath.startsWith(prefix)) return name;
96+
}
97+
return null;
98+
}
99+
100+
// --- Get changed files ---
101+
const files = await github.paginate(github.rest.pulls.listFiles, {
102+
owner, repo, pull_number: pr.number, per_page: 100
103+
});
104+
105+
// --- Group by product, merging owners across files ---
106+
const productOwners = new Map();
107+
for (const file of files) {
108+
const owners = findOwners(file.filename);
109+
const product = getProductName(file.filename);
110+
if (!product || owners.length === 0) continue;
111+
112+
const ownerNames = owners.map(o => o.replace(/^@/, ''));
113+
114+
// Skip areas where the author is already an owner
115+
if (ownerNames.some(o => o.toLowerCase() === author.toLowerCase())) continue;
116+
117+
if (!productOwners.has(product)) {
118+
productOwners.set(product, new Set());
119+
}
120+
for (const o of ownerNames) {
121+
productOwners.get(product).add(o);
122+
}
123+
}
124+
125+
if (productOwners.size === 0) {
126+
if (author.toLowerCase() === 'lnhsingh') return;
127+
productOwners.set('General changes', new Set(['lnhsingh']));
128+
}
129+
130+
// --- Format the comment ---
131+
function formatOwners(ownerSet) {
132+
const mentions = [...ownerSet].map(o => `\`@${o}\``);
133+
if (mentions.length === 1) return mentions[0];
134+
if (mentions.length === 2) return `${mentions[0]} or ${mentions[1]}`;
135+
return mentions.slice(0, -1).join(', ') + `, or ${mentions[mentions.length - 1]}`;
136+
}
137+
138+
const areas = [...productOwners.entries()];
139+
const lines = areas.map(([product, owners]) =>
140+
`- ${formatOwners(owners)} (${product})`
141+
);
142+
const body = [
143+
`Thanks for opening a docs PR, @${author}! When it's ready for review, please add the relevant reviewers:\n`,
144+
lines.join('\n')
145+
].join('\n');
146+
147+
await github.rest.issues.createComment({
148+
owner, repo,
149+
issue_number: pr.number,
150+
body
151+
});
152+
153+
console.log(`Posted welcome comment on PR #${pr.number}`);

0 commit comments

Comments
 (0)