fix: update support query docs #1371
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Post a welcome comment on new PRs telling the author who owns | |
| # the docs areas they're changing (derived from .github/OWNERS). | |
| # | |
| # NOTE: This uses `pull_request_target` so it has write access to | |
| # comment on PRs from forks. The script only reads OWNERS from | |
| # the base branch and lists changed files — no untrusted code is executed. | |
| name: PR Welcome Comment | |
| on: | |
| pull_request_target: | |
| types: [opened, ready_for_review] | |
| jobs: | |
| welcome-comment: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| # Skip draft PRs — the comment will be posted when marked ready | |
| if: ${{ !github.event.pull_request.draft }} | |
| steps: | |
| - name: Post welcome comment with owners | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const pr = context.payload.pull_request; | |
| const author = pr.user.login; | |
| // --- Fetch OWNERS from the base branch --- | |
| let ownersContent; | |
| try { | |
| const { data } = await github.rest.repos.getContent({ | |
| owner, repo, | |
| path: '.github/OWNERS', | |
| ref: pr.base.ref | |
| }); | |
| ownersContent = Buffer.from(data.content, 'base64').toString(); | |
| } catch (error) { | |
| console.log('Could not read .github/OWNERS:', error.message); | |
| return; | |
| } | |
| // --- Parse OWNERS rules (CODEOWNERS syntax + auto-request marker) --- | |
| // A trailing # auto-request[: @a @b] comment opts the matching | |
| // rule into GitHub review-request auto-assignment. The bare form | |
| // requests every owner on the rule; the scoped form requests only | |
| // the listed subset. | |
| const rules = []; | |
| for (const rawLine of ownersContent.split('\n')) { | |
| const trimmed = rawLine.trim(); | |
| if (!trimmed) continue; | |
| const hashIdx = trimmed.indexOf('#'); | |
| const ruleText = (hashIdx === -1 ? trimmed : trimmed.slice(0, hashIdx)).trim(); | |
| const commentText = hashIdx === -1 ? '' : trimmed.slice(hashIdx + 1).trim(); | |
| if (!ruleText) continue; | |
| const parts = ruleText.split(/\s+/); | |
| const pattern = parts[0]; | |
| const owners = parts.slice(1).filter(o => o.startsWith('@')); | |
| if (owners.length === 0) continue; | |
| let autoRequest = null; | |
| const autoMatch = commentText.match(/^auto-request\b\s*:?\s*(.*)$/i); | |
| if (autoMatch) { | |
| const listed = autoMatch[1] | |
| .split(/[\s,]+/) | |
| .filter(t => t.startsWith('@')) | |
| .map(t => t.replace(/^@/, '')); | |
| const ownerNames = owners.map(o => o.replace(/^@/, '')); | |
| if (listed.length === 0) { | |
| autoRequest = ownerNames; | |
| } else { | |
| const ownerSet = new Set(ownerNames.map(o => o.toLowerCase())); | |
| const filtered = listed.filter(u => ownerSet.has(u.toLowerCase())); | |
| const dropped = listed.filter(u => !ownerSet.has(u.toLowerCase())); | |
| if (dropped.length > 0) { | |
| console.log( | |
| `OWNERS: ignoring auto-request ${dropped.length === 1 ? 'entry' : 'entries'} ` + | |
| `not in owner list for ${pattern}: ${dropped.map(u => '@' + u).join(', ')}` | |
| ); | |
| } | |
| autoRequest = filtered; | |
| } | |
| } | |
| rules.push({ pattern, owners, autoRequest }); | |
| } | |
| // Last matching rule wins (same as GitHub CODEOWNERS behavior) | |
| function matchRule(filepath) { | |
| let matched = null; | |
| for (const rule of rules) { | |
| let pat = rule.pattern.startsWith('/') ? rule.pattern.slice(1) : rule.pattern; | |
| let matches = false; | |
| if (pat === '*') { | |
| matches = true; | |
| } else if (pat.endsWith('/')) { | |
| matches = filepath.startsWith(pat); | |
| } else if (pat.includes('*')) { | |
| const re = new RegExp( | |
| '^' + pat.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') | |
| ); | |
| matches = re.test(filepath); | |
| } else { | |
| matches = filepath === pat; | |
| } | |
| if (matches) matched = rule; | |
| } | |
| return matched; | |
| } | |
| // --- Map file paths to human-readable product names --- | |
| function getProductName(filepath) { | |
| const map = [ | |
| ['src/langsmith/fleet/', 'LangSmith Fleet'], | |
| ['src/langsmith/', 'LangSmith'], | |
| ['src/oss/deepagents/', 'Deep Agents'], | |
| ['src/oss/langgraph/', 'LangGraph'], | |
| ['src/oss/langchain/', 'LangChain'], | |
| ['src/oss/python/integrations/', 'Python integrations'], | |
| ['src/oss/javascript/integrations/', 'JavaScript integrations'], | |
| ['src/oss/', 'open source'], | |
| ]; | |
| for (const [prefix, name] of map) { | |
| if (filepath.startsWith(prefix)) return name; | |
| } | |
| return null; | |
| } | |
| // --- Get changed files --- | |
| const files = await github.paginate(github.rest.pulls.listFiles, { | |
| owner, repo, pull_number: pr.number, per_page: 100 | |
| }); | |
| // --- Group by product, merging owners across files --- | |
| const productOwners = new Map(); | |
| const autoRequestReviewers = new Set(); | |
| let authorIsOwner = false; | |
| for (const file of files) { | |
| const rule = matchRule(file.filename); | |
| const owners = rule ? rule.owners : []; | |
| const ownerNames = owners.map(o => o.replace(/^@/, '')); | |
| if (ownerNames.some(o => o.toLowerCase() === author.toLowerCase())) { | |
| authorIsOwner = true; | |
| } | |
| if (rule && rule.autoRequest) { | |
| for (const u of rule.autoRequest) { | |
| if (u.toLowerCase() !== author.toLowerCase()) { | |
| autoRequestReviewers.add(u); | |
| } | |
| } | |
| } | |
| const product = getProductName(file.filename); | |
| if (!product || owners.length === 0) continue; | |
| // Skip areas where the author is already an owner | |
| if (ownerNames.some(o => o.toLowerCase() === author.toLowerCase())) continue; | |
| if (!productOwners.has(product)) { | |
| productOwners.set(product, new Set()); | |
| } | |
| for (const o of ownerNames) { | |
| productOwners.get(product).add(o); | |
| } | |
| } | |
| // --- Auto-request opted-in reviewers (any PR author) --- | |
| if (autoRequestReviewers.size > 0) { | |
| const reviewers = [...autoRequestReviewers]; | |
| try { | |
| await github.rest.pulls.requestReviewers({ | |
| owner, repo, | |
| pull_number: pr.number, | |
| reviewers | |
| }); | |
| console.log(`Auto-requested reviewers ${reviewers.join(', ')} on PR #${pr.number}`); | |
| } catch (error) { | |
| console.log(`Failed to auto-request reviewers ${reviewers.join(', ')}: ${error.message}`); | |
| } | |
| } | |
| if (productOwners.size === 0) { | |
| // Skip fallback if author owns any of the changed files | |
| if (authorIsOwner || author.toLowerCase() === 'lnhsingh') return; | |
| productOwners.set('General changes', new Set(['lnhsingh'])); | |
| } | |
| // --- Auto-assign reviewers for bot authors --- | |
| const senderType = context.payload.pull_request.user.type; | |
| if (senderType === 'Bot') { | |
| const changelogPatterns = [ | |
| '[LangGraph Server Changelog Bot]', | |
| '[Self-Hosted Changelog Bot]' | |
| ]; | |
| let reviewers; | |
| if (changelogPatterns.some(p => pr.title.includes(p))) { | |
| reviewers = ['lnhsingh', 'katmayb']; | |
| } else { | |
| const fromOwners = [...new Set( | |
| [...productOwners.values()].flatMap(s => [...s]) | |
| )]; | |
| const reviewerSet = new Set(fromOwners); | |
| // Some bots name the humans who should review in the PR | |
| // body. Each parser below extracts those reviewers from the | |
| // PR. A bot with a parser skips the lnhsingh fallback and | |
| // uses whatever its parser returns; any other bot falls back | |
| // to lnhsingh. | |
| const botReviewerParsers = { | |
| // open-swe names the triggering user by display name in an | |
| // "Opened collaboratively by <Name> and open-swe." line; | |
| // resolve it to a GitHub login via the search API. | |
| 'open-swe[bot]': async () => { | |
| const collabMatch = (pr.body || '').match( | |
| /collaboratively by (.+?) and open-swe/i | |
| ); | |
| if (!collabMatch) return []; | |
| const displayName = collabMatch[1].trim(); | |
| try { | |
| const { data } = await github.rest.search.users({ | |
| q: `fullname:${displayName} org:langchain-ai` | |
| }); | |
| if (data.total_count === 1) { | |
| console.log(`Resolved "${displayName}" to @${data.items[0].login}`); | |
| return [data.items[0].login]; | |
| } | |
| if (data.total_count === 0) { | |
| // Retry without org filter for private memberships | |
| const retry = await github.rest.search.users({ | |
| q: `fullname:${displayName}` | |
| }); | |
| if (retry.data.total_count === 1) { | |
| console.log(`Resolved "${displayName}" to @${retry.data.items[0].login}`); | |
| return [retry.data.items[0].login]; | |
| } | |
| console.log(`Could not uniquely resolve "${displayName}" (${retry.data.total_count} results without org filter)`); | |
| } else { | |
| console.log(`Could not uniquely resolve "${displayName}" (${data.total_count} results in org)`); | |
| } | |
| } catch (error) { | |
| console.log(`Failed to resolve "${displayName}": ${error.message}`); | |
| } | |
| return []; | |
| }, | |
| // langsmith-fleet lists reviewers directly in the PR body | |
| // as "Requested review from: @user1, @user2". The @-handle | |
| // regex only matches valid GitHub usernames. | |
| 'langsmith-fleet[bot]': async () => { | |
| const line = (pr.body || '').match(/Requested review from:\s*(.+)/i); | |
| const handles = line ? line[1].match(/@[A-Za-z0-9-]+/g) || [] : []; | |
| console.log(`langsmith-fleet reviewers: ${handles.join(', ') || '(none found)'}`); | |
| return handles.map(h => h.slice(1)); | |
| }, | |
| }; | |
| const parseBotReviewers = botReviewerParsers[author]; | |
| if (parseBotReviewers) { | |
| for (const u of await parseBotReviewers()) reviewerSet.add(u); | |
| } else { | |
| reviewerSet.add('lnhsingh'); | |
| } | |
| reviewers = [...reviewerSet]; | |
| } | |
| // requestReviewers rejects an empty list, so fall back to | |
| // lnhsingh when no reviewer could be determined. | |
| if (reviewers.length === 0) { | |
| console.log(`No reviewers determined for bot PR #${pr.number}; defaulting to lnhsingh`); | |
| reviewers = ['lnhsingh']; | |
| } | |
| await github.rest.pulls.requestReviewers({ | |
| owner, repo, | |
| pull_number: pr.number, | |
| reviewers | |
| }); | |
| console.log(`Auto-assigned reviewers ${reviewers.join(', ')} on bot PR #${pr.number}`); | |
| return; | |
| } | |
| // --- Format the comment --- | |
| function formatOwners(ownerSet) { | |
| const mentions = [...ownerSet].map(o => `\`@${o}\``); | |
| if (mentions.length === 1) return mentions[0]; | |
| if (mentions.length === 2) return `${mentions[0]} or ${mentions[1]}`; | |
| return mentions.slice(0, -1).join(', ') + `, or ${mentions[mentions.length - 1]}`; | |
| } | |
| const areas = [...productOwners.entries()]; | |
| const lines = areas.map(([product, owners]) => | |
| `- ${formatOwners(owners)} (${product})` | |
| ); | |
| const body = [ | |
| `Thanks for opening a docs PR, @${author}! When it's ready for review, please add the relevant reviewers:\n`, | |
| lines.join('\n') | |
| ].join('\n'); | |
| await github.rest.issues.createComment({ | |
| owner, repo, | |
| issue_number: pr.number, | |
| body | |
| }); | |
| console.log(`Posted welcome comment on PR #${pr.number}`); |