mirror of
https://github.com/MervinPraison/PraisonAI-Tools.git
synced 2026-11-09 03:39:37 -10:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55edaf9a85 | ||
|
|
4c35c4817c | ||
|
|
f28385458c | ||
|
|
997898a5fa | ||
|
|
1ce2299a3d | ||
|
|
9d4e0e047c | ||
|
|
799bf1a1f9 | ||
|
|
ff10557b99 | ||
|
|
5f4f694dc0 | ||
|
|
bd84a17340 | ||
|
|
25e8bc6c6e | ||
|
|
d4bdd82739 | ||
|
|
88993be922 | ||
|
|
4c76cb69a3 | ||
|
|
01e14d91f8 | ||
|
|
936ff4f3fa | ||
|
|
30c1b54fe5 | ||
|
|
1eabeb4b25 |
No files matched your search
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"templateRepo": "MervinPraison/github-automation-template",
|
||||
"stack": "claude",
|
||||
"profile": "tools",
|
||||
"mode": "hub",
|
||||
"hubRef": "v1.1.8",
|
||||
"templateVersion": "1.1.6",
|
||||
"installedAt": "2026-09-07T12:54:25Z"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Final architecture review prompt (referenced by merge-gate.js via gate-config fields).
|
||||
# Most FINAL review text is assembled dynamically from gate-config.js at runtime.
|
||||
|
||||
See `finalClaudeScope`, `finalClaudeProductValue` in `.github/scripts/gate-config.js`.
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
const chain = require('./bot-pr-review-chain.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
assert('chainKickPosted requires both markers', chain.chainKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
{ user: { login: 'MervinPraison' }, body: '/review' },
|
||||
]));
|
||||
assert('chainKickPosted false with only coderabbit', !chain.chainKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('coderabbitKickPosted detects coderabbit', chain.coderabbitKickPosted([
|
||||
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('qodoKickPosted detects qodo', chain.qodoKickPosted([
|
||||
{ user: { login: 'github-actions[bot]' }, body: '/review' },
|
||||
]));
|
||||
assert('ignores coderabbit bot comment', !chain.coderabbitKickPosted([
|
||||
{ user: { login: 'coderabbitai[bot]' }, body: '@coderabbitai review' },
|
||||
]));
|
||||
assert('bot PR author match', chain.isBotOpenedPr({ user: { login: 'praisonai-triage-agent[bot]' } }));
|
||||
assert('generic bot type match', chain.isBotOpenedPr({ user: { login: 'my-bot', type: 'Bot' } }));
|
||||
assert('human author excluded', !chain.isBotOpenedPr({ user: { login: 'MervinPraison', type: 'User' } }));
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
module.exports = {
|
||||
repoFullName: 'MervinPraison/PraisonAI-Tools',
|
||||
triggerLogins: ['MervinPraison', 'github-actions[bot]'],
|
||||
productPathPrefixes: ['praisonai_tools/', 'tests/'],
|
||||
// Workflows stay gated; pyproject.toml is normal for optional tool deps.
|
||||
sensitivePathPatterns: [
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Load per-repo gate configuration (installed as .github/scripts/gate-config.js).
|
||||
*/
|
||||
function loadGateConfig() {
|
||||
try {
|
||||
return require('./gate-config.js');
|
||||
} catch (err) {
|
||||
if (err.code !== 'MODULE_NOT_FOUND') throw err;
|
||||
throw new Error(
|
||||
'Missing .github/scripts/gate-config.js — run install.sh from github-automation-template'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { loadGateConfig };
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
const chain = require('./pr-review-chain.js');
|
||||
|
||||
let failed = 0;
|
||||
function assert(name, cond) {
|
||||
if (!cond) {
|
||||
console.error('FAIL:', name);
|
||||
failed++;
|
||||
} else {
|
||||
console.log('ok:', name);
|
||||
}
|
||||
}
|
||||
|
||||
const coderabbit = {
|
||||
user: { login: 'coderabbitai[bot]' },
|
||||
body: '<!-- summarize by coderabbit.ai --> summary',
|
||||
created_at: '2026-07-02T07:16:00Z',
|
||||
};
|
||||
const greptile = {
|
||||
user: { login: 'greptile-apps[bot]' },
|
||||
body: '<h3>Greptile Summary</h3><p>Looks good</p>',
|
||||
created_at: '2026-07-02T07:19:00Z',
|
||||
};
|
||||
const copilotKick = {
|
||||
user: { login: 'MervinPraison' },
|
||||
body: '@copilot Do a thorough review',
|
||||
created_at: '2026-07-02T07:20:00Z',
|
||||
};
|
||||
const copilotReply = {
|
||||
user: { login: 'copilot-swe-agent' },
|
||||
body: 'Copilot review here',
|
||||
created_at: '2026-07-02T07:22:00Z',
|
||||
};
|
||||
|
||||
assert('prior not ready without greptile', !chain.priorReviewersReady([coderabbit]).ready);
|
||||
assert('prior ready with coderabbit + greptile', chain.priorReviewersReady([coderabbit, greptile]).ready);
|
||||
assert('copilot not ready before trigger', !chain.copilotReviewReady([coderabbit, greptile]).ready);
|
||||
assert(
|
||||
'copilot ready after reply',
|
||||
chain.copilotReviewReady([coderabbit, greptile, copilotKick, copilotReply]).ready
|
||||
);
|
||||
assert(
|
||||
'claude not ready before copilot',
|
||||
!chain.claudeFinalReady([coderabbit, greptile]).ready
|
||||
);
|
||||
assert(
|
||||
'claude ready after full chain',
|
||||
chain.claudeFinalReady([coderabbit, greptile, copilotKick, copilotReply]).ready
|
||||
);
|
||||
assert(
|
||||
'claude timeout fallback when copilot triggered but silent',
|
||||
chain.claudeFinalReady([coderabbit, greptile, copilotKick], [], { allowCopilotTimeout: true }).ready
|
||||
);
|
||||
assert(
|
||||
'no timeout fallback without copilot trigger',
|
||||
!chain.claudeFinalReady([coderabbit, greptile], [], { allowCopilotTimeout: true }).ready
|
||||
);
|
||||
assert(
|
||||
'skipCopilot proceeds after prior reviewers',
|
||||
chain.claudeFinalReady([coderabbit, greptile], [], { skipCopilot: true }).ready
|
||||
);
|
||||
const prev = process.env.REVIEW_CHAIN_SKIP_COPILOT;
|
||||
process.env.REVIEW_CHAIN_SKIP_COPILOT = '1';
|
||||
assert(
|
||||
'REVIEW_CHAIN_SKIP_COPILOT env skips copilot',
|
||||
chain.claudeFinalReady([coderabbit, greptile], [], {}).ready
|
||||
);
|
||||
process.env.REVIEW_CHAIN_SKIP_COPILOT = prev;
|
||||
|
||||
const geminiReview = {
|
||||
user: { login: 'gemini-code-assist[bot]' },
|
||||
body: '## Code Review\n\nThis pull request decomposes the large main.py file',
|
||||
submitted_at: '2026-07-03T08:08:00Z',
|
||||
};
|
||||
assert(
|
||||
'gemini pull review counts as prior reviewer',
|
||||
chain.hasGeminiReview([], [geminiReview])
|
||||
);
|
||||
assert(
|
||||
'prior ready with gemini review only (no gemini comment)',
|
||||
chain.priorReviewersReady([coderabbit, greptile], [geminiReview], { optionalWaitMs: 0 }).ready
|
||||
);
|
||||
|
||||
const oldPr = new Date(Date.now() - 31 * 60 * 1000).toISOString();
|
||||
assert(
|
||||
'prior not ready without coderabbit before timeout',
|
||||
!chain.priorReviewersReady([greptile], [], { prCreatedAt: new Date().toISOString(), requiredWaitMs: 30 * 60 * 1000 }).ready
|
||||
);
|
||||
assert(
|
||||
'prior timeout when coderabbit silent after 30m',
|
||||
chain.priorReviewersReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).ready
|
||||
);
|
||||
assert(
|
||||
'claude FINAL timeout when required reviewer silent and no prior FINAL',
|
||||
chain.claudeFinalReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).ready
|
||||
);
|
||||
assert(
|
||||
'claude FINAL timeout auto-skips copilot without explicit skipCopilot',
|
||||
chain.claudeFinalReady([greptile], [], { prCreatedAt: oldPr, requiredWaitMs: 30 * 60 * 1000 }).copilotSkipped
|
||||
);
|
||||
const finalClaude = {
|
||||
user: { login: 'github-actions[bot]' },
|
||||
body: '@claude You are the FINAL architecture reviewer for this PR.',
|
||||
created_at: '2026-07-02T07:25:00Z',
|
||||
};
|
||||
assert(
|
||||
'claude FINAL not re-posted when already triggered',
|
||||
!chain.claudeFinalReady([greptile, finalClaude], [], { prCreatedAt: oldPr, requiredWaitMs: 0 }).ready
|
||||
);
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -1,37 +0,0 @@
|
||||
name: Auto Issue Comment
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
trigger-claude:
|
||||
# Skip if issue already has 'claude' label to avoid double-trigger
|
||||
if: |
|
||||
!contains(toJSON(github.event.issue.labels), 'claude')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Add claude label to trigger analysis
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const labels = context.payload.issue.labels.map(l => l.name);
|
||||
if (labels.includes('claude')) {
|
||||
console.log('Issue already has claude label, skipping');
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ['claude']
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude Read this issue (title + body) and all existing comments. Implement or fix in `praisonai_tools/` with minimal changes; follow README and existing tool patterns (lazy optional deps, clear errors). Open a PR when you have commits.\n\n🤖 Auto-triage: label `claude` was added for filtering.'
|
||||
});
|
||||
@@ -1,14 +1,5 @@
|
||||
name: Auto PR Comment
|
||||
|
||||
# Review chain: CodeRabbit/Qodo/Gemini review first → Copilot → Claude (final)
|
||||
# For human PRs: CodeRabbit/Qodo auto-review → this workflow triggers Copilot → then Claude
|
||||
# For bot PRs: CodeRabbit/Qodo skip bots, so we trigger them explicitly first
|
||||
#
|
||||
# IMPORTANT: @copilot ignores bot comments. All @copilot mentions must use
|
||||
# PAT_TOKEN so the comment posts as MervinPraison (a real user).
|
||||
#
|
||||
# Claude trigger runs IN-BAND (same workflow) to avoid GitHub Actions
|
||||
# blocking bot-triggered workflows with action_required approval gates.
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
@@ -17,793 +8,26 @@ on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
pull_request_target:
|
||||
types: [synchronize]
|
||||
types: [opened, synchronize]
|
||||
schedule:
|
||||
- cron: '30 3,9,15,21 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number for manual FINAL (leave empty to sweep all open PRs)'
|
||||
required: false
|
||||
type: number
|
||||
|
||||
env:
|
||||
REVIEW_CHAIN_SKIP_COPILOT: '1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
MAX_POLL_ATTEMPTS: 20
|
||||
POLL_DELAY_MS: 30000
|
||||
CLAUDE_TRIGGER_LOGINS: '["MervinPraison","github-actions[bot]"]'
|
||||
# GH_TOKEN (user PAT) posts as MervinPraison for @copilot; fallback to github.token for @claude
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
CLAUDE_UI_SCOPE: 'SCOPE: Focus ONLY on PraisonAI-Tools (praisonai_tools/, tests). Do not expand into praisonaiagents or the monorepo unless the issue explicitly requires it.'
|
||||
CLAUDE_FINAL_PHASES_COMPACT: "**Phase 1: Review per AGENTS.md** (lazy imports, backward compat, import-time <200ms; SDK/product value gate — genuine agent-tool value, correct tool vs SDK vs Plugins layering, no scope creep)\n**Phase 2: FIX valid issues** found by prior reviewers — push to THIS branch\n**Phase 3: Final verdict** — approve PR if ready, else request changes"
|
||||
CLAUDE_FINAL_PHASES_DETAILED: "**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions (lazy imports, import-time <200ms)\n4. SDK/product value: review whether the change genuinely adds value — correct tool layering (agent-callable integrations here; lifecycle plugins in PraisonAI-Plugins; core SDK in praisonaiagents). Reject scope creep; request changes or recommend closing if value is unclear\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items"
|
||||
|
||||
jobs:
|
||||
# ================================================================
|
||||
# HUMAN PRs: Trigger @copilot AFTER CodeRabbit posts its summary
|
||||
# ================================================================
|
||||
copilot-after-coderabbit:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.user.login == 'coderabbitai[bot]' &&
|
||||
contains(github.event.comment.body, 'summary by coderabbit')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
pr_number: ${{ steps.trigger.outputs.pr_number }}
|
||||
triggered: ${{ steps.trigger.outputs.triggered }}
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
// Check for duplicates — posted by MervinPraison (PAT) or github-actions[bot] (legacy)
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@copilot') &&
|
||||
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
core.setOutput('triggered', 'false');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, Coderabbit, and Gemini first — incorporate their findings.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused? Any unnecessary code or scope creep?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. **Address reviewer feedback**: If Qodo, Coderabbit, or Gemini flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible'
|
||||
});
|
||||
core.setOutput('triggered', 'true');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
|
||||
# Fallback: Trigger @copilot after Qodo review (if Coderabbit doesn't fire)
|
||||
copilot-after-qodo:
|
||||
if: |
|
||||
github.event_name == 'pull_request_review' &&
|
||||
github.event.review.user.login == 'qodo-code-review[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
pr_number: ${{ steps.trigger.outputs.pr_number }}
|
||||
triggered: ${{ steps.trigger.outputs.triggered }}
|
||||
steps:
|
||||
- name: Post Copilot review request (as user via PAT)
|
||||
id: trigger
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@copilot') &&
|
||||
(c.user.login === 'MervinPraison' || c.user.login === 'github-actions[bot]')
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Copilot already triggered, skipping');
|
||||
core.setOutput('triggered', 'false');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above first.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. Suggest specific improvements with code examples where possible'
|
||||
});
|
||||
core.setOutput('triggered', 'true');
|
||||
core.setOutput('pr_number', context.issue.number.toString());
|
||||
|
||||
# ================================================================
|
||||
# FALLBACK: Claude trigger for cases where Copilot chain fails/skips
|
||||
# Ensures Claude always runs after sufficient review time
|
||||
# ================================================================
|
||||
claude-fallback-timeout:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'opened'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
review-chain:
|
||||
# Grant union of callee job permissions (repos with default_workflow_permissions: read
|
||||
# reject reusable calls when the caller ceiling is lower than any callee job).
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for review window (15 min)
|
||||
run: sleep 900
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
core.setOutput('comments_count', comments.data.length.toString());
|
||||
|
||||
const botReviews = comments.data.filter(c =>
|
||||
['coderabbitai[bot]', 'qodo-code-review[bot]', 'gemini-code-assist[bot]',
|
||||
'copilot-swe-agent', 'Copilot', 'greptile-apps[bot]'].some(bot =>
|
||||
c.user.login.toLowerCase().includes(bot.toLowerCase())
|
||||
)
|
||||
);
|
||||
core.setOutput('review_count', botReviews.length.toString());
|
||||
|
||||
- name: Aggregate reviews and trigger Claude
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const comments = await github.rest.issues.listComments({issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, per_page: 100});
|
||||
const reviews = {coderabbit: [], qodo: [], gemini: [], copilot: [], greptile: [], others: []};
|
||||
comments.data.forEach(c => {
|
||||
const login = c.user.login.toLowerCase();
|
||||
const body = c.body.substring(0, 500);
|
||||
if (login.includes('coderabbit')) reviews.coderabbit.push(body);
|
||||
else if (login.includes('qodo')) reviews.qodo.push(body);
|
||||
else if (login.includes('gemini')) reviews.gemini.push(body);
|
||||
else if (login.includes('copilot')) reviews.copilot.push(body);
|
||||
else if (login.includes('greptile')) reviews.greptile.push(body);
|
||||
else if (c.user.type === 'Bot') reviews.others.push(body);
|
||||
});
|
||||
const summaryParts = [];
|
||||
if (reviews.coderabbit.length) summaryParts.push('CodeRabbit: ' + reviews.coderabbit.length + ' comments');
|
||||
if (reviews.qodo.length) summaryParts.push('Qodo: ' + reviews.qodo.length + ' comments');
|
||||
if (reviews.gemini.length) summaryParts.push('Gemini: ' + reviews.gemini.length + ' comments');
|
||||
if (reviews.copilot.length) summaryParts.push('Copilot: ' + reviews.copilot.length + ' comments');
|
||||
if (reviews.greptile.length) summaryParts.push('Greptile: ' + reviews.greptile.length + ' comments');
|
||||
const reviewSummary = summaryParts.length ? summaryParts.join(' | ') : 'No bot reviews detected';
|
||||
const scope = process.env.CLAUDE_UI_SCOPE;
|
||||
const phases = process.env.CLAUDE_FINAL_PHASES_COMPACT;
|
||||
const claudeBody = '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Tools (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL prior reviewer comments. ' + reviewSummary + '.\n\n' +
|
||||
'Review Context: ' + comments.data.length + ' total comments, ' +
|
||||
(reviews.coderabbit.length + reviews.qodo.length + reviews.gemini.length + reviews.copilot.length + reviews.greptile.length) + ' bot reviews.\n\n' +
|
||||
phases;
|
||||
await github.rest.issues.createComment({issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
|
||||
console.log('Claude fallback triggered on PR #' + pr + ' with ' + reviewSummary);
|
||||
|
||||
# ================================================================
|
||||
# Claude FINAL review — polls for Copilot's response, then triggers
|
||||
# This runs IN-BAND under the PAT context (MervinPraison), avoiding
|
||||
# the action_required approval gate that blocks bot-triggered workflows.
|
||||
# ================================================================
|
||||
claude-after-copilot:
|
||||
needs: [copilot-after-coderabbit]
|
||||
if: |
|
||||
always() &&
|
||||
needs.copilot-after-coderabbit.result == 'success' &&
|
||||
needs.copilot-after-coderabbit.outputs.triggered == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// Poll for Copilot's response — check every 30s for up to 10 minutes
|
||||
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
|
||||
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
|
||||
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner,
|
||||
repo,
|
||||
per_page: 50,
|
||||
sort: 'created',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
// Look for Copilot's response (copilot-swe-agent or Copilot)
|
||||
const copilotResponse = comments.data.find(c =>
|
||||
(c.user.login === 'copilot-swe-agent' ||
|
||||
c.user.login === 'Copilot' ||
|
||||
c.user.login.toLowerCase().includes('copilot')) &&
|
||||
c.user.type === 'Bot'
|
||||
);
|
||||
|
||||
if (copilotResponse) {
|
||||
console.log(`Copilot responded at ${copilotResponse.created_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check PR reviews for Copilot
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
pull_number: pr,
|
||||
owner,
|
||||
repo
|
||||
});
|
||||
|
||||
const copilotReview = reviews.data.find(r =>
|
||||
r.user.login.toLowerCase().includes('copilot')
|
||||
);
|
||||
|
||||
if (copilotReview) {
|
||||
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
if (i < maxAttempts - 1) {
|
||||
console.log(`Copilot hasn't responded yet, waiting ${delayMs/1000}s...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Copilot did not respond within timeout, triggering Claude anyway');
|
||||
core.setOutput('copilot_responded', 'timeout');
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const scope = process.env.CLAUDE_UI_SCOPE;
|
||||
const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Tools (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n' + phases
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Qodo fallback path (same polling approach)
|
||||
# ================================================================
|
||||
claude-after-copilot-qodo:
|
||||
needs: [copilot-after-qodo]
|
||||
if: |
|
||||
always() &&
|
||||
needs.copilot-after-qodo.result == 'success' &&
|
||||
needs.copilot-after-qodo.outputs.triggered == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Wait for Copilot to respond (poll)
|
||||
id: poll
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }}
|
||||
POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const maxAttempts = parseInt(process.env.MAX_POLL_ATTEMPTS, 10);
|
||||
const delayMs = parseInt(process.env.POLL_DELAY_MS, 10);
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
console.log(`Poll attempt ${i + 1}/${maxAttempts}...`);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr, owner, repo,
|
||||
per_page: 50, sort: 'created', direction: 'desc'
|
||||
});
|
||||
const copilotResponse = comments.data.find(c =>
|
||||
(c.user.login === 'copilot-swe-agent' ||
|
||||
c.user.login === 'Copilot' ||
|
||||
c.user.login.toLowerCase().includes('copilot')) &&
|
||||
c.user.type === 'Bot'
|
||||
);
|
||||
if (copilotResponse) {
|
||||
console.log(`Copilot responded at ${copilotResponse.created_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
pull_number: pr, owner, repo
|
||||
});
|
||||
const copilotReview = reviews.data.find(r =>
|
||||
r.user.login.toLowerCase().includes('copilot')
|
||||
);
|
||||
if (copilotReview) {
|
||||
console.log(`Copilot review submitted at ${copilotReview.submitted_at}`);
|
||||
core.setOutput('copilot_responded', 'true');
|
||||
return;
|
||||
}
|
||||
if (i < maxAttempts - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
console.log('Copilot did not respond within timeout, triggering Claude anyway');
|
||||
core.setOutput('copilot_responded', 'timeout');
|
||||
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Trigger Claude final review
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const scope = process.env.CLAUDE_UI_SCOPE;
|
||||
const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Tools (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n' + phases
|
||||
});
|
||||
console.log(`Claude triggered on PR #${pr}`);
|
||||
|
||||
# ================================================================
|
||||
# Claude after Gemini — restores the trigger lost from the deleted
|
||||
# chain-claude-after-copilot.yml workflow.
|
||||
# Fires when Gemini posts a review comment on a PR or issue.
|
||||
# ================================================================
|
||||
claude-after-gemini:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
github.event.comment.user.login == 'gemini-code-assist[bot]' ||
|
||||
contains(github.event.comment.body, 'Review completed by Gemini CLI') ||
|
||||
contains(github.event.comment.body, 'The fix is ready for review. Please test')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if Claude already triggered
|
||||
id: check_claude
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
|
||||
|
||||
- name: Post Claude analysis request (Lead Engineer)
|
||||
if: steps.check_claude.outputs.already_triggered != 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const scope = process.env.CLAUDE_UI_SCOPE;
|
||||
const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED;
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAI-Tools (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n' + phases
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# Recovery: re-post FINAL @claude after push or when chain missed
|
||||
# ================================================================
|
||||
claude-review-recovery:
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'synchronize'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-post FINAL Claude review when stale or missing
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr))
|
||||
|| context.payload.pull_request.updated_at;
|
||||
const headPusher = context.payload.sender?.login || null;
|
||||
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
|
||||
if (mergeGate.shouldSkipFinalRecovery(comments, headPushedAt)) {
|
||||
core.info('Recent @claude within 35min and FINAL not stale — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr)) {
|
||||
core.info('Claude workflow in progress — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
|
||||
const staleOpts = { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 };
|
||||
const missingOpts = { allowCopilotTimeout: true, optionalWaitMs: 0 };
|
||||
|
||||
if (hasFinal && isStale) {
|
||||
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusher);
|
||||
if (gate.skip) {
|
||||
core.info(`Skip stale-FINAL recovery: ${gate.reason}`);
|
||||
return;
|
||||
}
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr, finalBody, core, staleOpts
|
||||
);
|
||||
if (result.posted) core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasFinal) {
|
||||
core.info('FINAL Claude review current on HEAD — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr, finalBody, core, missingOpts
|
||||
);
|
||||
if (result.posted) core.info(`Posted missing FINAL Claude review on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# Scheduled + manual: post missing or stale FINAL @claude on all open PRs
|
||||
post-missing-claude-final:
|
||||
if: |
|
||||
github.event_name == 'schedule' ||
|
||||
(github.event_name == 'workflow_dispatch' && !inputs.pr_number)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Post missing/stale FINAL @claude on open PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
let posted = 0;
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr.number);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at;
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
|
||||
if (hasFinal && !isStale) continue;
|
||||
if (mergeGate.hasRecentClaudeTrigger(comments, 10)) {
|
||||
core.info(`PR #${pr.number}: @claude within 10min — skip`);
|
||||
continue;
|
||||
}
|
||||
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr.number)) {
|
||||
core.info(`PR #${pr.number}: Claude in progress — skip`);
|
||||
continue;
|
||||
}
|
||||
if (hasFinal && isStale) {
|
||||
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt);
|
||||
if (gate.skip) {
|
||||
core.info(`PR #${pr.number}: skip stale — ${gate.reason}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const opts = hasFinal && isStale
|
||||
? { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 }
|
||||
: { allowCopilotTimeout: true, optionalWaitMs: 0 };
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr.number,
|
||||
mergeGate.FINAL_CLAUDE_REVIEW_BODY, core, opts
|
||||
);
|
||||
if (result.posted) {
|
||||
posted++;
|
||||
core.info(`Posted Claude FINAL on PR #${pr.number}`);
|
||||
}
|
||||
await ps.syncPipelineLabels(github, owner, repo, pr.number, core);
|
||||
if (posted >= 10) break;
|
||||
}
|
||||
core.info(`Missing/stale Claude FINAL sweep complete (${posted} posted)`);
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit → Claude directly (skip Copilot when no user PAT)
|
||||
# Copilot ignores github-actions[bot] comments; bot PRs use this path.
|
||||
# ================================================================
|
||||
claude-after-coderabbit-bot-pr:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.user.login == 'coderabbitai[bot]' &&
|
||||
contains(github.event.comment.body, 'summary by coderabbit')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Trigger Claude final review on bot PR
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = context.issue.number;
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr,
|
||||
});
|
||||
const author = pull.user.login;
|
||||
const isBot = author === 'github-actions[bot]' ||
|
||||
author === 'praisonai-triage-agent[bot]' ||
|
||||
pull.user.type === 'Bot';
|
||||
if (!isBot) {
|
||||
console.log('Human PR — Copilot chain handles Claude trigger');
|
||||
return;
|
||||
}
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Claude already triggered on PR #' + pr);
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer on this bot-authored PR. Push fixes directly to THIS branch (do NOT open a new PR). ' + process.env.CLAUDE_UI_SCOPE + ' Read ALL CodeRabbit/Qodo comments above.\n\n' + process.env.CLAUDE_FINAL_PHASES_COMPACT
|
||||
});
|
||||
console.log('Claude final review triggered on bot PR #' + pr);
|
||||
|
||||
# ================================================================
|
||||
# Manual: trigger Claude final review on any PR
|
||||
# ================================================================
|
||||
claude-manual-dispatch:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.pr_number
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Trigger Claude final review
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }}
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const pr = parseInt(process.env.PR_NUMBER, 10);
|
||||
const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS);
|
||||
const comments = await github.rest.issues.listComments({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const alreadyPosted = comments.data.some(c =>
|
||||
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
|
||||
);
|
||||
if (alreadyPosted) {
|
||||
console.log('Claude already triggered on PR #' + pr);
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Tools (not a fork), you are able to make modifications to this branch and push directly. ' + process.env.CLAUDE_UI_SCOPE + ' Read all prior reviewer comments.\n\n' + process.env.CLAUDE_FINAL_PHASES_COMPACT
|
||||
});
|
||||
console.log('Claude manually triggered on PR #' + pr);
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: kick CodeRabbit + Qodo
|
||||
# ================================================================
|
||||
bot-pr-trigger-reviews:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'opened' &&
|
||||
(
|
||||
github.event.pull_request.user.login == 'github-actions[bot]' ||
|
||||
github.event.pull_request.user.login == 'praisonai-triage-agent[bot]' ||
|
||||
github.event.pull_request.user.type == 'Bot'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Trigger CodeRabbit and Qodo reviews
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await chain.kickReviewChain(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Bot PR Recovery
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Specific PR number to kick (optional)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -1,17 +1,14 @@
|
||||
name: Auto CI Failure → Claude
|
||||
|
||||
# Detects internal PRs with failing CI and posts @claude with extracted failure details.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
workflows: [CI, Optimized Test Suite]
|
||||
types: [completed]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Optional PR number to process (default: scan blocked PRs)'
|
||||
required: false
|
||||
type: string
|
||||
schedule:
|
||||
@@ -21,10 +18,6 @@ concurrency:
|
||||
group: ci-failure-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.event.workflow_run.id || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
issues: write
|
||||
@@ -32,71 +25,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
detect-and-trigger:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect CI failures and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ciFix = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/ci-failure-claude.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const baseRepo = `${owner}/${repo}`;
|
||||
|
||||
async function processPR(prNumber) {
|
||||
return ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
|
||||
}
|
||||
|
||||
let results = [];
|
||||
|
||||
if (context.eventName === 'workflow_run') {
|
||||
const wr = context.payload.workflow_run;
|
||||
const result = await ciFix.processWorkflowRunFailure(
|
||||
github, owner, repo, wr, core
|
||||
);
|
||||
results.push({ event: 'workflow_run', workflow: wr.name, ...result });
|
||||
} else if (context.eventName === 'pull_request') {
|
||||
results.push({
|
||||
pr: context.payload.pull_request.number,
|
||||
...(await processPR(context.payload.pull_request.number)),
|
||||
});
|
||||
} else {
|
||||
let prNumbers = [];
|
||||
if (context.eventName === 'workflow_dispatch' && context.payload.inputs?.pr_number) {
|
||||
prNumbers = [Number(context.payload.inputs.pr_number)];
|
||||
} else {
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
const headRepo = pr.head?.repo?.full_name;
|
||||
if (headRepo && headRepo !== baseRepo) continue;
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
});
|
||||
const labels = issue.labels.map((l) => l.name);
|
||||
if (labels.includes('pipeline/blocked:ci') || labels.includes(ciFix.CI_FIX_LABEL)) {
|
||||
prNumbers.push(pr.number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const num of prNumbers) {
|
||||
results.push({ pr: num, ...(await processPR(num)) });
|
||||
}
|
||||
}
|
||||
|
||||
core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```');
|
||||
await core.summary.write();
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
pr_number: ${{ github.event.inputs.pr_number || '' }}
|
||||
@@ -1,46 +1,30 @@
|
||||
name: Claude PR Merge Gate
|
||||
|
||||
# Read-only Claude assesses merge readiness; GH_TOKEN merges (no @claude, no gh pr review).
|
||||
# Does not overlap with merge-conflict-claude.yml or auto-pr-comment @claude chains.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Scan for merge-ready PRs every 10 minutes (assess runs only when candidates exist).
|
||||
- cron: '*/10 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Optional PR number (scan all open internal PRs if empty)'
|
||||
required: false
|
||||
type: string
|
||||
merge_method:
|
||||
description: 'Merge method'
|
||||
required: false
|
||||
type: choice
|
||||
default: merge
|
||||
options:
|
||||
- merge
|
||||
- squash
|
||||
- rebase
|
||||
options: [merge, squash, rebase]
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Claude Assistant
|
||||
- CI
|
||||
types:
|
||||
- completed
|
||||
types: [completed]
|
||||
repository_dispatch:
|
||||
types:
|
||||
- claude-merge-gate
|
||||
types: [claude-merge-gate]
|
||||
|
||||
concurrency:
|
||||
group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
MAX_CANDIDATES: 3
|
||||
DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -59,7 +43,9 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
@@ -67,473 +53,43 @@ jobs:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Dispatch merge gate when PR pipeline is ready
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const mergeGate = require(path.join(process.env.HUB_SCRIPTS, 'merge-gate.js'));
|
||||
const wr = context.payload.workflow_run;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
if (wr.conclusion !== 'success') {
|
||||
core.info(`Skip dispatch: ${wr.name} conclusion=${wr.conclusion}`);
|
||||
return;
|
||||
}
|
||||
if (wr.conclusion !== 'success') return;
|
||||
const allowed = ['Claude Assistant', 'CI'];
|
||||
if (!allowed.includes(wr.name)) {
|
||||
core.info(`Skip dispatch: workflow ${wr.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowed.includes(wr.name)) return;
|
||||
const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(github, owner, repo, wr);
|
||||
if (!prNumber) {
|
||||
core.info('Skip dispatch: could not resolve PR from workflow run');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prNumber) return;
|
||||
const fromClaude = wr.name === 'Claude Assistant';
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, {
|
||||
skipGlobalClaudeRunCheck: fromClaude,
|
||||
skipRecentClaudeCooldown: fromClaude,
|
||||
}
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`PR #${prNumber} not ready: ${check.reasons.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, prNumber, core, {
|
||||
skipGlobalClaudeRunCheck: fromClaude,
|
||||
skipRecentClaudeCooldown: fromClaude,
|
||||
});
|
||||
if (!check.ready) return;
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'claude-merge-gate',
|
||||
owner, repo, event_type: 'claude-merge-gate',
|
||||
client_payload: { pr_number: prNumber },
|
||||
});
|
||||
core.info(`Dispatched merge gate for PR #${prNumber} after ${wr.name}`);
|
||||
|
||||
scan-candidates:
|
||||
merge-gate:
|
||||
if: github.event_name != 'workflow_run'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
outputs:
|
||||
matrix: ${{ steps.scan.outputs.matrix }}
|
||||
has_candidates: ${{ steps.scan.outputs.has_candidates }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Scan open PRs for merge gate eligibility
|
||||
id: scan
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const maxCandidates = parseInt(process.env.MAX_CANDIDATES || '5', 10);
|
||||
const inputPr = String(process.env.DISPATCH_PR_NUMBER || '').trim();
|
||||
|
||||
const candidates = [];
|
||||
const skipped = [];
|
||||
|
||||
if (inputPr) {
|
||||
const n = parseInt(inputPr, 10);
|
||||
if (Number.isNaN(n)) {
|
||||
throw new Error(`Invalid pr_number input: ${inputPr}`);
|
||||
}
|
||||
const result = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, n, core);
|
||||
if (result.ready) {
|
||||
candidates.push({ pr_number: n, head_sha: result.headSha });
|
||||
} else {
|
||||
skipped.push({ pr: n, reasons: result.reasons });
|
||||
}
|
||||
} else {
|
||||
const prNumbers = await mergeGate.listPrNumbersForMergeGateScan(
|
||||
github, owner, repo, core
|
||||
);
|
||||
const selected = await mergeGate.selectMergeGateCandidates(
|
||||
github, owner, repo, prNumbers, maxCandidates, core
|
||||
);
|
||||
candidates.push(...selected.candidates);
|
||||
skipped.push(...selected.skipped);
|
||||
}
|
||||
|
||||
core.info('Skipped: ' + JSON.stringify(skipped));
|
||||
core.setOutput('matrix', JSON.stringify({ include: candidates }));
|
||||
core.setOutput('has_candidates', candidates.length > 0 ? 'true' : 'false');
|
||||
|
||||
core.summary.addRaw('## Merge gate scan\n');
|
||||
core.summary.addRaw(`Candidates: ${candidates.length}\n`);
|
||||
core.summary.addRaw('```json\n' + JSON.stringify({ candidates, skipped }, null, 2) + '\n```');
|
||||
await core.summary.write();
|
||||
|
||||
if (inputPr && skipped.length === 1 && skipped[0].pr === parseInt(inputPr, 10)) {
|
||||
const reasons = skipped[0].reasons;
|
||||
const body = [
|
||||
'**Merge gate scan** — not eligible for auto-merge.',
|
||||
'',
|
||||
...reasons.map((r) => `- \`${r}\``),
|
||||
'',
|
||||
'**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.',
|
||||
'**Opt out:** label `no-auto-merge`.',
|
||||
].join('\n');
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: skipped[0].pr, body,
|
||||
});
|
||||
} else if (inputPr && candidates.length === 1) {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: candidates[0].pr_number,
|
||||
body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.',
|
||||
});
|
||||
}
|
||||
|
||||
await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 10 }, core);
|
||||
|
||||
claude-assess:
|
||||
needs: scan-candidates
|
||||
if: needs.scan-candidates.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Mark merge gate active
|
||||
id: mark_active
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
core.setOutput('started_at', new Date().toISOString());
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: ${{ matrix.pr_number }},
|
||||
labels: ['claude-merge-gate-active'],
|
||||
});
|
||||
|
||||
- name: Stash merge gate helpers
|
||||
run: |
|
||||
# Keep scripts together so require('./gate-config.js') works from /tmp
|
||||
mkdir -p /tmp/merge-gate-scripts /tmp/gh-actions
|
||||
cp .github/scripts/merge-gate.js .github/scripts/gate-config.js /tmp/merge-gate-scripts/
|
||||
cp -R .github/actions/claude-code-action /tmp/gh-actions/
|
||||
|
||||
- name: Fetch PR branch
|
||||
env:
|
||||
PR_NUMBER: ${{ matrix.pr_number }}
|
||||
run: |
|
||||
git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}"
|
||||
git checkout "pr-${PR_NUMBER}"
|
||||
# PR branches may predate pipeline actions — restore from main checkout
|
||||
mkdir -p .github/actions
|
||||
rm -rf .github/actions/claude-code-action
|
||||
cp -R /tmp/gh-actions/claude-code-action .github/actions/
|
||||
|
||||
- name: Claude merge gate assessment (read-only)
|
||||
id: assess
|
||||
uses: ./.github/actions/claude-code-action
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: inherit
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
model: claude-opus-4-8
|
||||
trigger_phrase: '@claude-merge-gate-internal'
|
||||
direct_prompt: |
|
||||
You are the MERGE GATE reviewer for PraisonAI PR #${{ matrix.pr_number }}.
|
||||
READ-ONLY: do not modify code, commit, push, rebase, or create branches.
|
||||
|
||||
CRITICAL OUTPUT RULE:
|
||||
Post exactly one PR comment via GitHub MCP containing either:
|
||||
MERGE_GATE_VERDICT: APPROVE
|
||||
or
|
||||
MERGE_GATE_VERDICT: BLOCK
|
||||
followed by a short rationale on the next lines.
|
||||
|
||||
NEVER include @claude in any comment. NEVER mention merge conflict resolution.
|
||||
|
||||
Review checklist:
|
||||
1. Read AGENTS.md and the PR diff on the current branch.
|
||||
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
|
||||
3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep).
|
||||
4. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
5. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
|
||||
6. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
|
||||
7. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}.
|
||||
8. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons.
|
||||
|
||||
Do not approve or merge via GitHub UI/API — only post the verdict comment.
|
||||
disallowed_tools: |
|
||||
Bash
|
||||
Edit
|
||||
Write
|
||||
Replace
|
||||
allowed_tools: |
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
Read
|
||||
timeout_minutes: 15
|
||||
|
||||
- name: Post fallback BLOCK if Opus did not comment
|
||||
id: fallback_verdict
|
||||
if: steps.assess.outcome != 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate-scripts/merge-gate.js');
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const existing = mergeGate.findMergeGateVerdict(
|
||||
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (existing) {
|
||||
core.info(`Opus verdict already posted: ${existing}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
const lines = [
|
||||
'MERGE_GATE_VERDICT: BLOCK',
|
||||
'',
|
||||
'Automated fallback — Opus merge gate assessment did not complete.',
|
||||
];
|
||||
if (check.ready) {
|
||||
lines.push('Deterministic gates passed, but Opus MERGE_GATE_VERDICT is required before merge.');
|
||||
} else {
|
||||
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: lines.join('\n'),
|
||||
});
|
||||
core.info('Posted fallback BLOCK — Opus verdict required');
|
||||
|
||||
- name: Require Opus MERGE_GATE_VERDICT
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate-scripts/merge-gate.js');
|
||||
const comments = await mergeGate.listAllComments(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const ctx = await mergeGate.loadPrContext(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(
|
||||
comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (!verdict) {
|
||||
core.setFailed(
|
||||
'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).'
|
||||
);
|
||||
return;
|
||||
}
|
||||
core.info(`Opus merge gate verdict: ${verdict}`);
|
||||
core.setOutput('verdict', verdict);
|
||||
|
||||
- name: Remove merge gate active label
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'claude-merge-gate-active',
|
||||
});
|
||||
core.info(`Removed claude-merge-gate-active from PR #${prNumber}`);
|
||||
} catch (e) {
|
||||
core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
|
||||
}
|
||||
|
||||
- name: Fail job if assessment failed
|
||||
if: steps.assess.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
merge-only:
|
||||
needs: [scan-candidates, claude-assess]
|
||||
if: |
|
||||
always() &&
|
||||
needs.scan-candidates.outputs.has_candidates == 'true' &&
|
||||
needs.claude-assess.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Re-check gates and merge
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
MERGE_METHOD: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
SCAN_HEAD_SHA: ${{ matrix.head_sha }}
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
const scanHeadSha = process.env.SCAN_HEAD_SHA;
|
||||
|
||||
const removeLabels = async () => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: 'claude-merge-gate-active',
|
||||
});
|
||||
} catch (e) {
|
||||
core.info('Label claude-merge-gate-active already removed or missing');
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const comments = ctx.comments;
|
||||
const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(
|
||||
comments, verdictMin, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (verdict !== 'APPROVE') {
|
||||
core.info(`Skip merge: verdict=${verdict || 'NONE'}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`Skip merge: ${check.reasons.join(', ')}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
if (check.headSha !== scanHeadSha) {
|
||||
core.info(`Skip merge: HEAD moved ${scanHeadSha.slice(0, 7)} → ${check.headSha.slice(0, 7)}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const method = process.env.MERGE_METHOD || 'merge';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let merged = false;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await github.rest.pulls.merge({
|
||||
owner, repo, pull_number: prNumber, merge_method: method,
|
||||
});
|
||||
merged = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
const msg = (e.message || '').toLowerCase();
|
||||
if (attempt < 3 && msg.includes('base branch was modified')) {
|
||||
core.info(`Merge attempt ${attempt} failed (base moved), retrying in 8s...`);
|
||||
await sleep(8000);
|
||||
const recheck = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
if (!recheck.ready) {
|
||||
core.info(`Skip merge retry: ${recheck.reasons.join(', ')}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
if (!merged) {
|
||||
core.info('Skip merge: exhausted retries');
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: ['auto-merged-by-gate'],
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: [
|
||||
'Merged by **Claude PR merge gate** (`claude-merge-gate.yml`).',
|
||||
`Verdict: MERGE_GATE_VERDICT: APPROVE`,
|
||||
`SHA: \`${check.headSha.slice(0, 7)}\``,
|
||||
`Method: ${method}`,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
core.info(`Merged PR #${prNumber}`);
|
||||
} finally {
|
||||
await removeLabels();
|
||||
}
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
|
||||
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Claude OAuth
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
code:
|
||||
description: 'Authorization code (leave empty for step 1)'
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
actions: write # Required for cache management
|
||||
contents: read # Required for basic repository access
|
||||
|
||||
jobs:
|
||||
auth:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: grll/claude-code-login@v1
|
||||
with:
|
||||
code: ${{ inputs.code }}
|
||||
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT }}
|
||||
+13
-156
@@ -5,163 +5,20 @@ on:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [assigned, labeled, opened]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
claude-response:
|
||||
env:
|
||||
ISSUE_NUMBER_RESOLVED: ${{ github.event.issue.number }}
|
||||
if: |
|
||||
(
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude'))
|
||||
) &&
|
||||
(
|
||||
!contains(github.actor, '[bot]') ||
|
||||
github.actor == 'github-actions[bot]' ||
|
||||
github.actor == 'praisonai-triage-agent[bot]'
|
||||
) &&
|
||||
github.actor != 'dependabot[bot]' &&
|
||||
github.actor != 'cursor[bot]' &&
|
||||
github.actor != 'renovate[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for Fork PR
|
||||
id: check_fork
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const isPR = context.payload.issue?.pull_request
|
||||
|| context.eventName === 'pull_request_review'
|
||||
|| context.eventName === 'pull_request_review_comment';
|
||||
if (!isPR) {
|
||||
core.setOutput('is_pr', 'false');
|
||||
core.setOutput('is_fork', 'false');
|
||||
core.setOutput('merge_state', '');
|
||||
return;
|
||||
}
|
||||
const prNumber = context.payload.pull_request?.number
|
||||
|| context.payload.issue?.number;
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
let mergeState = '';
|
||||
try {
|
||||
const gql = await github.graphql(
|
||||
`query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) { mergeStateStatus }
|
||||
}
|
||||
}`,
|
||||
{ owner: context.repo.owner, repo: context.repo.repo, number: prNumber }
|
||||
);
|
||||
mergeState = (gql.repository.pullRequest.mergeStateStatus || '').toUpperCase();
|
||||
} catch (e) {
|
||||
core.warning(`GraphQL mergeStateStatus failed: ${e.message}`);
|
||||
}
|
||||
if (!mergeState) {
|
||||
mergeState = (pr.data.mergeable_state || pr.data.merge_state_status || '').toUpperCase();
|
||||
}
|
||||
core.setOutput('is_pr', 'true');
|
||||
core.setOutput('pr_number', String(prNumber));
|
||||
core.setOutput('pr_branch', pr.data.head.ref);
|
||||
core.setOutput('head_repo', pr.data.head.repo?.full_name || '');
|
||||
core.setOutput('merge_state', mergeState);
|
||||
const baseRepo = `${context.repo.owner}/${context.repo.repo}`;
|
||||
if (pr.data.head.repo && pr.data.head.repo.full_name !== baseRepo) {
|
||||
core.setOutput('is_fork', 'true');
|
||||
return;
|
||||
}
|
||||
core.setOutput('is_fork', 'false');
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
ref: ${{ steps.check_fork.outputs.is_pr == 'true' && format('refs/pull/{0}/head', steps.check_fork.outputs.pr_number) || github.event.repository.default_branch }}
|
||||
|
||||
- name: Fetch PR branch
|
||||
if: steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true'
|
||||
env:
|
||||
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
|
||||
PR_NUMBER: ${{ steps.check_fork.outputs.pr_number }}
|
||||
run: |
|
||||
git fetch origin "pull/${PR_NUMBER}/head:${PR_BRANCH}"
|
||||
git checkout "${PR_BRANCH}"
|
||||
git fetch origin main
|
||||
|
||||
- name: Run Claude Code Action
|
||||
uses: anthropics/claude-code-action@beta
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GH_TOKEN }}
|
||||
model: claude-opus-4-8
|
||||
trigger_phrase: "@claude"
|
||||
label_trigger: "claude"
|
||||
allowed_bots: "praisonai-triage-agent[bot]"
|
||||
timeout_minutes: "30"
|
||||
direct_prompt: |
|
||||
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: FORK PR — comment only, do not push.\n\n' || (steps.check_fork.outputs.is_pr == 'true' && 'NOTE: Internal PR on MervinPraison/PraisonAI-Tools — you may push to this branch.\n\n' || '') }}
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && format('CONTEXT: PR #{0} on branch {1}. merge_state={2}.\n\n', steps.check_fork.outputs.pr_number, steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.merge_state) || '' }}
|
||||
You are implementing changes in MervinPraison/PraisonAI-Tools (Python tools for PraisonAI). Follow README.md and existing patterns under `praisonai_tools/`.
|
||||
|
||||
Primary scope: `praisonai_tools/`, `tests/`, `examples/`, `docs/`. Do not change `praisonaiagents` in this repo (it is not vendored here).
|
||||
|
||||
STEP 0 — GIT & GITHUB CLI:
|
||||
git config --global user.name "MervinPraison"
|
||||
git config --global user.email "[email protected]"
|
||||
gh auth setup-git
|
||||
|
||||
STEP 1 — ISSUE / PR:
|
||||
Read the issue (or PR) and all comments. Implement what is described with minimal, focused changes.
|
||||
|
||||
STEP 2 — LAYER VALIDATION (mandatory before coding):
|
||||
This repo IS the **Tools** layer (`praisonai-tools`). Agent-callable integrations belong here.
|
||||
- **Implement here:** third-party API tools, optional/heavy deps, vertical integrations
|
||||
- **Route to PraisonAI (core):** new protocols, agent runtime, built-in tools → comment and link MervinPraison/PraisonAI issue
|
||||
- **Route to PraisonAI (wrapper):** CLI/YAML wiring only → MervinPraison/PraisonAI
|
||||
- **Route to PraisonAI-Plugins:** lifecycle hooks, guardrails, policies → MervinPraison/PraisonAI-Plugins
|
||||
- **Reject:** duplicating tools already in `praisonaiagents.tools` (Tavily, DuckDuckGo, etc.)
|
||||
If the issue belongs in another layer, explain why and do not implement here.
|
||||
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && steps.check_fork.outputs.merge_state == 'DIRTY' && format('STEP 3 — RESOLVE MERGE CONFLICTS ON PR (branch: {0}, PR #{1}):\n- DO NOT create a new branch or PR\n- git fetch origin main && git rebase origin/main\n- Resolve conflicts: keep this PR intent, merge in main newer logic\n- git add -A && git rebase --continue (repeat until done)\n- git push --force-with-lease origin {0}\n- Run targeted pytest on changed paths\n- Comment files resolved and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number) || '' }}
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && steps.check_fork.outputs.merge_state != 'DIRTY' && format('STEP 3 — IMPLEMENT ON EXISTING PR (branch: {0}, PR #{1}):\n- DO NOT create a new branch or PR\n- git checkout {0}\n- Implement minimal, focused fixes\n- Commit and push: git push origin {0}\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number) || '' }}
|
||||
${{ steps.check_fork.outputs.is_pr != 'true' && 'STEP 3 — BRANCH (single name; reuse in STEP 5):\nBRANCH="claude/issue-${{ env.ISSUE_NUMBER_RESOLVED }}-$(date +%Y%m%d-%H%M%S)"\ngit checkout -b "$BRANCH"\n' || '' }}
|
||||
|
||||
STEP 4 — TEST / VERIFY:
|
||||
Run targeted pytest or smoke checks relevant to your edits (e.g. pytest tests/unit -q).
|
||||
|
||||
${{ steps.check_fork.outputs.is_pr == 'true' && 'STEP 5 — SKIP: Already on an open PR. Do NOT run gh pr create.\n' || format('STEP 5 — COMMIT, PUSH, OPEN PR (mandatory if you produced commits):\ngit add -A\ngit commit -m "feat: <short summary> (fixes #{0})"\ngit push -u origin "$BRANCH"\ngh pr create --repo "{1}" --base main --head "$BRANCH" --title "feat: <short title>" --body "Fixes #{0}"\nCRITICAL: Do not stop after `git push`. You MUST complete `gh pr create` and post the PR URL on the issue.\n', env.ISSUE_NUMBER_RESOLVED, github.repository) }}
|
||||
|
||||
allowed_tools: |
|
||||
Bash(git:*)
|
||||
Bash(python:*)
|
||||
Bash(pip:*)
|
||||
Bash(pytest:*)
|
||||
Bash(gh:*)
|
||||
Bash(python -m pytest:*)
|
||||
Bash(python -m pip:*)
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
BatchTool
|
||||
Edit
|
||||
Replace
|
||||
mcp__github__get_issue
|
||||
mcp__github__get_issue_comments
|
||||
mcp__github__update_issue
|
||||
claude:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
@@ -1,7 +1,5 @@
|
||||
name: Auto Merge Conflict → Claude
|
||||
|
||||
# Detects internal PRs with mergeStateStatus=DIRTY and posts @claude to trigger claude.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -12,11 +10,10 @@ on:
|
||||
- cron: '0 */6 * * *'
|
||||
|
||||
concurrency:
|
||||
group: merge-conflict-scan-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
group: merge-conflict-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
|
||||
permissions:
|
||||
@@ -27,138 +24,29 @@ jobs:
|
||||
detect-and-trigger:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: MervinPraison/github-automation-template/.github/actions/[email protected]
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
- name: Detect conflicting PRs and trigger Claude
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mg = require(path.join(process.env.HUB_SCRIPTS, 'merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const baseRepo = `${owner}/${repo}`;
|
||||
const isMainPush = context.eventName === 'push';
|
||||
const COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||
const AUTO_ACTORS = ['github-actions[bot]', 'MervinPraison'];
|
||||
const LABEL = 'claude-conflict-pending';
|
||||
const COMMENT_BODY = [
|
||||
'@claude this PR has merge conflicts with `main`.',
|
||||
'Please rebase onto latest `main`, resolve conflicts (keep this PR\'s intent, merge in newer main logic),',
|
||||
'run targeted tests, and force-push with `--force-with-lease`.',
|
||||
'Comment which files you resolved.'
|
||||
].join(' ');
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function getMergeState(prNumber) {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
mergeStateStatus
|
||||
isDraft
|
||||
headRef { repository { nameWithOwner } }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const result = await github.graphql(query, { owner, repo, number: prNumber });
|
||||
const prGql = result.repository.pullRequest;
|
||||
const status = (prGql?.mergeStateStatus || '').toUpperCase();
|
||||
if (status && status !== 'UNKNOWN') {
|
||||
return {
|
||||
status,
|
||||
isDraft: prGql.isDraft,
|
||||
headRepo: prGql.headRef?.repository?.nameWithOwner,
|
||||
};
|
||||
}
|
||||
if (attempt < 2) await sleep(10000);
|
||||
}
|
||||
const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
return {
|
||||
status: (pr.data.mergeable_state || '').toUpperCase(),
|
||||
isDraft: pr.data.draft,
|
||||
headRepo: pr.data.head.repo?.full_name,
|
||||
};
|
||||
const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 50 });
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
if (pr.head?.repo?.full_name !== baseRepo) continue;
|
||||
const state = await mg.getMergeState(github, owner, repo, pr.number);
|
||||
if (state.status !== 'DIRTY') continue;
|
||||
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: pr.number, per_page: 100 });
|
||||
if (mg.hasRecentConflictComment(comments)) continue;
|
||||
const body = '@claude this PR has merge conflicts with `main`. Rebase onto latest main, resolve conflicts, run tests, force-push with --force-with-lease.';
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['claude-conflict-pending'] });
|
||||
}
|
||||
|
||||
function hasRecentAutoComment(comments) {
|
||||
const cutoff = Date.now() - COOLDOWN_MS;
|
||||
return comments.some((c) => {
|
||||
if (!AUTO_ACTORS.includes(c.user.login)) return false;
|
||||
const body = (c.body || '').toLowerCase();
|
||||
if (!body.includes('@claude') || !body.includes('merge conflict')) return false;
|
||||
return new Date(c.created_at).getTime() > cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
async function processPR(prNumber) {
|
||||
const { status, isDraft, headRepo } = await getMergeState(prNumber);
|
||||
core.info(`PR #${prNumber}: mergeState=${status}, head=${headRepo}, draft=${isDraft}`);
|
||||
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
});
|
||||
const labels = issue.labels.map((l) => l.name);
|
||||
|
||||
if (status !== 'DIRTY' && labels.includes(LABEL)) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: LABEL,
|
||||
});
|
||||
core.info(`Removed ${LABEL} from PR #${prNumber}`);
|
||||
}
|
||||
|
||||
if (status !== 'DIRTY') return { skipped: true, reason: 'not dirty' };
|
||||
if (isDraft) return { skipped: true, reason: 'draft' };
|
||||
if (headRepo && headRepo !== baseRepo) return { skipped: true, reason: 'fork' };
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
if (!isMainPush && hasRecentAutoComment(comments)) {
|
||||
return { skipped: true, reason: 'recent auto comment' };
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: COMMENT_BODY,
|
||||
});
|
||||
if (!labels.includes(LABEL)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [LABEL],
|
||||
});
|
||||
}
|
||||
return { triggered: true };
|
||||
}
|
||||
|
||||
let prNumbers = [];
|
||||
if (context.eventName === 'pull_request') {
|
||||
prNumbers = [context.payload.pull_request.number];
|
||||
} else {
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
prNumbers = prs.map((p) => p.number);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const num of prNumbers) {
|
||||
results.push({ pr: num, ...(await processPR(num)) });
|
||||
}
|
||||
core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```');
|
||||
await core.summary.write();
|
||||
@@ -1,15 +1,9 @@
|
||||
name: Pipeline Status Sync
|
||||
|
||||
# Sync pipeline/* stage and blocker labels on open internal PRs.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
@@ -19,28 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Sync pipeline labels on open PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
await ps.syncOpenPullRequests(
|
||||
github, context.repo.owner, context.repo.repo,
|
||||
{ maxPrs: 30, dispatchMergeGate: true }, core
|
||||
);
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.8
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Sync Claude Secrets
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_repo:
|
||||
description: 'Destination repo (owner/name)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Sync Claude secrets to target repo
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
|
||||
TARGET: ${{ inputs.target_repo }}
|
||||
CLAUDE_APP_ID: ${{ secrets.CLAUDE_APP_ID }}
|
||||
CLAUDE_APP_PRIVATE_KEY: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
GH_TOKEN_VALUE: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "SECRETS_ADMIN_PAT is not configured in this repository"
|
||||
exit 1
|
||||
fi
|
||||
echo -n "$CLAUDE_APP_ID" | gh secret set CLAUDE_APP_ID --repo "$TARGET"
|
||||
echo -n "$CLAUDE_APP_PRIVATE_KEY" | gh secret set CLAUDE_APP_PRIVATE_KEY --repo "$TARGET"
|
||||
echo -n "$CLAUDE_CODE_OAUTH_TOKEN" | gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "$TARGET"
|
||||
echo -n "$GH_TOKEN_VALUE" | gh secret set GH_TOKEN --repo "$TARGET"
|
||||
echo "Synced CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN → $TARGET"
|
||||
@@ -0,0 +1,700 @@
|
||||
"""Meeting Agent Tools for PraisonAI Agents.
|
||||
|
||||
Eight agent-callable ``@tool`` functions covering the Phase 1 meeting-agent
|
||||
pipeline: transcription, meetings CRUD, summarisation, action-item extraction,
|
||||
and RAG indexing/search.
|
||||
|
||||
Tools
|
||||
-----
|
||||
1. ``transcribe_file`` - Whisper transcription with segment timestamps.
|
||||
2. ``save_meeting`` - Persist a meeting record (returns ``meeting_id``).
|
||||
3. ``get_meeting`` - Fetch a full meeting record.
|
||||
4. ``list_meetings`` - List meetings (limit/offset).
|
||||
5. ``summarize_transcript`` - Structured summary (map-reduce for long input).
|
||||
6. ``extract_action_items`` - Structured action items.
|
||||
7. ``index_meeting`` - Chunk/embed/upsert into a vector store (idempotent).
|
||||
8. ``search_meetings`` - Ranked cross-meeting semantic search.
|
||||
|
||||
Dependency hygiene
|
||||
-------------------
|
||||
``openai`` and ``chromadb`` are **lazy-imported inside functions**, never at
|
||||
module import time. They are optional extras::
|
||||
|
||||
pip install praisonai-tools[meeting]
|
||||
|
||||
Meetings are persisted in a small SQLite database (Python stdlib) under the app
|
||||
data dir. This is a thin, swappable persistence layer: when the canonical
|
||||
PAI-MEET-CORE schema is available it can back these same CRUD wrappers.
|
||||
|
||||
All tool outputs use deterministic serialisation (stable field order / sorted
|
||||
keys) so results are reproducible.
|
||||
|
||||
Environment Variables
|
||||
---------------------
|
||||
OPENAI_API_KEY Required for transcription/summaries/embeddings.
|
||||
OPENAI_BASE_URL Optional custom OpenAI-compatible endpoint.
|
||||
PRAISONAI_MEETINGS_DB Optional path to the SQLite meetings database.
|
||||
PRAISONAI_MEETINGS_DIR Optional app data dir (db + vector store live here).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from praisonai_tools.tools.decorator import tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Chunking / embedding defaults (issue spec).
|
||||
_CHUNK_SIZE_TOKENS = 500
|
||||
_CHUNK_OVERLAP_TOKENS = 50
|
||||
_SUMMARY_CHUNK_TOKENS = 6000
|
||||
_SUMMARY_OVERLAP_TOKENS = 200
|
||||
_EMBEDDING_MODEL = "text-embedding-3-small"
|
||||
_CHAT_MODEL = "gpt-4o-mini"
|
||||
_WHISPER_MODEL = "whisper-1"
|
||||
_COLLECTION = "meetings"
|
||||
|
||||
# Rough token->char ratio used only for deterministic, dependency-free chunking.
|
||||
_CHARS_PER_TOKEN = 4
|
||||
|
||||
|
||||
# ── Paths / persistence ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _app_dir() -> Path:
|
||||
"""Return the app data directory (created if missing)."""
|
||||
env = os.getenv("PRAISONAI_MEETINGS_DIR")
|
||||
base = Path(env) if env else Path.home() / ".praisonai" / "meetings"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
|
||||
def _db_path() -> str:
|
||||
env = os.getenv("PRAISONAI_MEETINGS_DB")
|
||||
if env:
|
||||
Path(env).parent.mkdir(parents=True, exist_ok=True)
|
||||
return env
|
||||
return str(_app_dir() / "meetings.db")
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
"""Open the meetings DB, creating the schema on first use."""
|
||||
conn = sqlite3.connect(_db_path())
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meetings (
|
||||
meeting_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
source TEXT,
|
||||
metadata TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
def _row_to_meeting(row: sqlite3.Row) -> Dict[str, Any]:
|
||||
"""Convert a DB row to a deterministic meeting dict (stable key order)."""
|
||||
try:
|
||||
metadata = json.loads(row["metadata"]) if row["metadata"] else {}
|
||||
except (ValueError, TypeError):
|
||||
metadata = {}
|
||||
return {
|
||||
"created_at": row["created_at"],
|
||||
"meeting_id": row["meeting_id"],
|
||||
"metadata": metadata,
|
||||
"source": row["source"],
|
||||
"title": row["title"],
|
||||
}
|
||||
|
||||
|
||||
# ── Lazy clients ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_openai_client():
|
||||
"""Lazily construct an OpenAI client. Raises clear errors on misconfig."""
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"OPENAI_API_KEY is required. Set it in the environment before "
|
||||
"calling meeting transcription/summarisation/embedding tools."
|
||||
)
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError as exc: # pragma: no cover - trivial
|
||||
raise ImportError(
|
||||
"openai package required. Install with: pip install praisonai-tools[meeting]"
|
||||
) from exc
|
||||
base_url = os.getenv("OPENAI_BASE_URL")
|
||||
if base_url:
|
||||
return OpenAI(api_key=api_key, base_url=base_url)
|
||||
return OpenAI(api_key=api_key)
|
||||
|
||||
|
||||
def _get_vector_collection():
|
||||
"""Lazily open (or create) the persistent Chroma collection."""
|
||||
try:
|
||||
import chromadb
|
||||
except ImportError as exc: # pragma: no cover - trivial
|
||||
raise ImportError(
|
||||
"chromadb package required. Install with: pip install praisonai-tools[meeting]"
|
||||
) from exc
|
||||
client = chromadb.PersistentClient(path=str(_app_dir() / "chroma"))
|
||||
return client.get_or_create_collection(_COLLECTION)
|
||||
|
||||
|
||||
def _embed(texts: List[str]) -> List[List[float]]:
|
||||
"""Embed a list of texts with the configured embedding model."""
|
||||
client = _get_openai_client()
|
||||
resp = client.embeddings.create(model=_EMBEDDING_MODEL, input=texts)
|
||||
return [item.embedding for item in resp.data]
|
||||
|
||||
|
||||
# ── Chunking helpers ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _chunk_spans(text: str, size_tokens: int, overlap_tokens: int) -> List[tuple]:
|
||||
"""Split text into overlapping windows, returning (chunk, char_start, char_end).
|
||||
|
||||
Uses a deterministic character-window approximation so no tokenizer
|
||||
dependency is needed. Character offsets refer to the pre-strip window and
|
||||
let callers map each chunk back to source positions (e.g. segment times).
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
size = max(1, size_tokens * _CHARS_PER_TOKEN)
|
||||
overlap = max(0, min(overlap_tokens * _CHARS_PER_TOKEN, size - 1))
|
||||
step = size - overlap
|
||||
spans: List[tuple] = []
|
||||
start = 0
|
||||
length = len(text)
|
||||
while start < length:
|
||||
end = min(start + size, length)
|
||||
chunk = text[start:end].strip()
|
||||
if chunk:
|
||||
spans.append((chunk, start, end))
|
||||
start += step
|
||||
return spans
|
||||
|
||||
|
||||
def _chunk_text(text: str, size_tokens: int, overlap_tokens: int) -> List[str]:
|
||||
"""Split text into overlapping chunks by approximate token size.
|
||||
|
||||
Returns a list of non-empty chunk strings (see :func:`_chunk_spans`).
|
||||
"""
|
||||
return [chunk for chunk, _s, _e in _chunk_spans(text, size_tokens, overlap_tokens)]
|
||||
|
||||
|
||||
def _segment_char_offsets(segments: List[Dict[str, Any]]) -> List[tuple]:
|
||||
"""Return (char_start, char_end, seg_start, seg_end) for concatenated segments.
|
||||
|
||||
The transcript indexed here is assumed to be the segment texts joined by a
|
||||
single space, matching how ``transcribe_file`` emits ``segments``.
|
||||
"""
|
||||
offsets: List[tuple] = []
|
||||
pos = 0
|
||||
for i, seg in enumerate(segments):
|
||||
seg_text = str(seg.get("text", "")).strip()
|
||||
if i > 0:
|
||||
pos += 1 # the joining space
|
||||
char_start = pos
|
||||
pos += len(seg_text)
|
||||
offsets.append(
|
||||
(char_start, pos, float(seg.get("start", 0.0)), float(seg.get("end", 0.0)))
|
||||
)
|
||||
return offsets
|
||||
|
||||
|
||||
def _timestamps_for_span(
|
||||
char_start: int, char_end: int, seg_offsets: List[tuple]
|
||||
) -> tuple:
|
||||
"""Map a chunk's char span to the (start, end) time of the segments it covers."""
|
||||
if not seg_offsets:
|
||||
return 0.0, 0.0
|
||||
start_time: Optional[float] = None
|
||||
end_time: Optional[float] = None
|
||||
for cs, ce, s_start, s_end in seg_offsets:
|
||||
if ce > char_start and cs < char_end: # overlap
|
||||
if start_time is None:
|
||||
start_time = s_start
|
||||
end_time = s_end
|
||||
if start_time is None:
|
||||
return 0.0, 0.0
|
||||
return start_time, end_time if end_time is not None else start_time
|
||||
|
||||
|
||||
# ── LLM JSON helpers ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _chat_json(system: str, user: str) -> Dict[str, Any]:
|
||||
"""Call the chat model requesting a JSON object and parse it."""
|
||||
client = _get_openai_client()
|
||||
resp = client.chat.completions.create(
|
||||
model=_CHAT_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0,
|
||||
)
|
||||
content = resp.choices[0].message.content or "{}"
|
||||
try:
|
||||
return json.loads(content)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
# ── 1. transcribe_file ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
def transcribe_file(media_path: str) -> Dict[str, Any]:
|
||||
"""Transcribe an audio/video file to text with segment timestamps.
|
||||
|
||||
Uses OpenAI Whisper (``whisper-1``) via the audio transcriptions API and
|
||||
returns word/segment level timing.
|
||||
|
||||
Args:
|
||||
media_path: Path to the audio or video file to transcribe.
|
||||
|
||||
Returns:
|
||||
Dict with ``text``, ``segments`` (start/end/text), ``duration_seconds``
|
||||
and ``language``. On failure returns ``{"error": "..."}`` with a clear
|
||||
message (missing file, API 403 key permissions, timeout, empty audio).
|
||||
"""
|
||||
path = Path(media_path)
|
||||
if not media_path or not path.exists():
|
||||
return {"error": f"Media file not found: {media_path}"}
|
||||
|
||||
try:
|
||||
client = _get_openai_client()
|
||||
except (ValueError, ImportError) as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
def _do_transcribe():
|
||||
with open(path, "rb") as audio_file:
|
||||
return client.audio.transcriptions.create(
|
||||
model=_WHISPER_MODEL,
|
||||
file=audio_file,
|
||||
response_format="verbose_json",
|
||||
timestamp_granularities=["segment"],
|
||||
)
|
||||
|
||||
try:
|
||||
response = _do_transcribe()
|
||||
except Exception as exc: # noqa: BLE001 - map to clear messages
|
||||
message = str(exc)
|
||||
status = getattr(exc, "status_code", None)
|
||||
exc_name = type(exc).__name__.lower()
|
||||
if status == 403 or "403" in message or "permission" in message.lower():
|
||||
return {"error": "Transcription failed: API key lacks permissions (403)."}
|
||||
if "timeout" in exc_name or "timeout" in message.lower():
|
||||
# Retry once, then fail with a clear message.
|
||||
try:
|
||||
response = _do_transcribe()
|
||||
except Exception: # noqa: BLE001
|
||||
return {"error": "Transcription failed: request timed out after retry."}
|
||||
else:
|
||||
return {"error": f"Transcription failed: {message}"}
|
||||
|
||||
text = (getattr(response, "text", "") or "").strip()
|
||||
segments_raw = getattr(response, "segments", None) or []
|
||||
segments = [
|
||||
{
|
||||
"end": float(getattr(seg, "end", 0.0)),
|
||||
"start": float(getattr(seg, "start", 0.0)),
|
||||
"text": (getattr(seg, "text", "") or "").strip(),
|
||||
}
|
||||
for seg in segments_raw
|
||||
]
|
||||
|
||||
if not text and not segments:
|
||||
return {"error": "No speech detected in recording"}
|
||||
|
||||
return {
|
||||
"duration_seconds": float(getattr(response, "duration", 0.0) or 0.0),
|
||||
"language": getattr(response, "language", "unknown") or "unknown",
|
||||
"segments": segments,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
|
||||
# ── 2-4. Meetings CRUD ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
def save_meeting(
|
||||
title: str,
|
||||
source: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Persist a meeting record and return its generated ``meeting_id``.
|
||||
|
||||
Thin wrapper over the meetings persistence layer (SQLite by default).
|
||||
|
||||
Args:
|
||||
title: Human-readable meeting title.
|
||||
source: Optional origin of the meeting (e.g. file path, URL).
|
||||
metadata: Optional JSON-serialisable metadata dict.
|
||||
|
||||
Returns:
|
||||
Dict ``{"meeting_id": "<uuid>"}`` or ``{"error": "..."}``.
|
||||
"""
|
||||
if not title:
|
||||
return {"error": "title is required"}
|
||||
meeting_id = str(uuid.uuid4())
|
||||
from datetime import datetime, timezone
|
||||
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
with closing(_connect()) as conn, conn:
|
||||
conn.execute(
|
||||
"INSERT INTO meetings (meeting_id, title, source, metadata, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
meeting_id,
|
||||
title,
|
||||
source,
|
||||
json.dumps(metadata or {}, sort_keys=True),
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("save_meeting error: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
return {"meeting_id": meeting_id}
|
||||
|
||||
|
||||
@tool
|
||||
def get_meeting(meeting_id: str) -> Dict[str, Any]:
|
||||
"""Fetch a full meeting record by id.
|
||||
|
||||
Args:
|
||||
meeting_id: The meeting identifier returned by ``save_meeting``.
|
||||
|
||||
Returns:
|
||||
The meeting record dict, or ``{"error": "..."}`` if not found.
|
||||
"""
|
||||
if not meeting_id:
|
||||
return {"error": "meeting_id is required"}
|
||||
try:
|
||||
with closing(_connect()) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM meetings WHERE meeting_id = ?", (meeting_id,)
|
||||
).fetchone()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("get_meeting error: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
if row is None:
|
||||
return {"error": f"Meeting not found: {meeting_id}"}
|
||||
return _row_to_meeting(row)
|
||||
|
||||
|
||||
@tool
|
||||
def list_meetings(limit: int = 20, offset: int = 0) -> List[Dict[str, Any]]:
|
||||
"""List stored meetings, most recent first.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of meetings to return (default 20).
|
||||
offset: Number of meetings to skip for pagination (default 0).
|
||||
|
||||
Returns:
|
||||
A list of meeting record dicts (possibly empty), or a single-element
|
||||
list ``[{"error": "..."}]`` on failure.
|
||||
"""
|
||||
try:
|
||||
limit = max(0, int(limit))
|
||||
offset = max(0, int(offset))
|
||||
with closing(_connect()) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM meetings ORDER BY created_at DESC, meeting_id DESC "
|
||||
"LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("list_meetings error: %s", exc)
|
||||
return [{"error": str(exc)}]
|
||||
return [_row_to_meeting(r) for r in rows]
|
||||
|
||||
|
||||
# ── 5. summarize_transcript ─────────────────────────────────────────
|
||||
|
||||
|
||||
def _summarize_chunk(transcript: str) -> Dict[str, Any]:
|
||||
system = (
|
||||
"You are a meeting summariser. Return a JSON object with keys: "
|
||||
"'summary' (2-3 paragraph string), 'decisions' (array of strings), "
|
||||
"'topics' (array of strings), and 'key_quotes' (array of objects with "
|
||||
"'speaker' and 'quote'). Base output only on the transcript."
|
||||
)
|
||||
return _chat_json(system, transcript)
|
||||
|
||||
|
||||
def _merge_summaries(parts: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
merged_summary = "\n\n".join(
|
||||
str(p.get("summary", "")).strip() for p in parts if p.get("summary")
|
||||
)
|
||||
decisions: List[str] = []
|
||||
topics: List[str] = []
|
||||
quotes: List[Dict[str, Any]] = []
|
||||
for p in parts:
|
||||
for d in p.get("decisions", []) or []:
|
||||
if d not in decisions:
|
||||
decisions.append(d)
|
||||
for t in p.get("topics", []) or []:
|
||||
if t not in topics:
|
||||
topics.append(t)
|
||||
for q in p.get("key_quotes", []) or []:
|
||||
quotes.append(
|
||||
{"quote": q.get("quote", ""), "speaker": q.get("speaker")}
|
||||
)
|
||||
return {
|
||||
"decisions": decisions,
|
||||
"key_quotes": quotes,
|
||||
"summary": merged_summary,
|
||||
"topics": topics,
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def summarize_transcript(transcript: str) -> Dict[str, Any]:
|
||||
"""Summarise a meeting transcript into structured JSON.
|
||||
|
||||
For long transcripts a map-reduce strategy is used: the transcript is split
|
||||
into overlapping chunks, each is summarised, and the partial summaries are
|
||||
merged.
|
||||
|
||||
Args:
|
||||
transcript: The full plain-text transcript.
|
||||
|
||||
Returns:
|
||||
Dict with ``summary`` (str), ``decisions`` (list), ``topics`` (list) and
|
||||
``key_quotes`` (list of ``{speaker, quote}``). ``{"error": "..."}`` on
|
||||
failure.
|
||||
"""
|
||||
if not transcript or not transcript.strip():
|
||||
return {"error": "transcript is required"}
|
||||
try:
|
||||
chunks = _chunk_text(
|
||||
transcript, _SUMMARY_CHUNK_TOKENS, _SUMMARY_OVERLAP_TOKENS
|
||||
)
|
||||
if len(chunks) <= 1:
|
||||
result = _summarize_chunk(transcript)
|
||||
else:
|
||||
partials = [_summarize_chunk(c) for c in chunks]
|
||||
result = _merge_summaries(partials)
|
||||
except (ValueError, ImportError) as exc:
|
||||
return {"error": str(exc)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("summarize_transcript error: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
|
||||
return {
|
||||
"decisions": result.get("decisions", []) or [],
|
||||
"key_quotes": result.get("key_quotes", []) or [],
|
||||
"summary": result.get("summary", "") or "",
|
||||
"topics": result.get("topics", []) or [],
|
||||
}
|
||||
|
||||
|
||||
# ── 6. extract_action_items ─────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
def extract_action_items(transcript: str) -> Dict[str, Any]:
|
||||
"""Extract action items from a transcript as structured JSON.
|
||||
|
||||
Args:
|
||||
transcript: The full plain-text transcript.
|
||||
|
||||
Returns:
|
||||
Dict ``{"action_items": [{description, owner, due_date, priority}, ...]}``
|
||||
or ``{"error": "..."}`` on failure.
|
||||
"""
|
||||
if not transcript or not transcript.strip():
|
||||
return {"error": "transcript is required"}
|
||||
system = (
|
||||
"You extract action items from meeting transcripts. Return a JSON object "
|
||||
"with a single key 'action_items': an array of objects each with keys "
|
||||
"'description' (string), 'owner' (string or null), 'due_date' (ISO date "
|
||||
"string or null), and 'priority' (one of 'low', 'medium', 'high' or null). "
|
||||
"Only include real, actionable items."
|
||||
)
|
||||
try:
|
||||
result = _chat_json(system, transcript)
|
||||
except (ValueError, ImportError) as exc:
|
||||
return {"error": str(exc)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("extract_action_items error: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
|
||||
items = result.get("action_items", []) or []
|
||||
normalised = [
|
||||
{
|
||||
"description": it.get("description", ""),
|
||||
"due_date": it.get("due_date"),
|
||||
"owner": it.get("owner"),
|
||||
"priority": it.get("priority"),
|
||||
}
|
||||
for it in items
|
||||
if isinstance(it, dict)
|
||||
]
|
||||
return {"action_items": normalised}
|
||||
|
||||
|
||||
# ── 7. index_meeting ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
def index_meeting(
|
||||
meeting_id: str,
|
||||
transcript: Optional[str] = None,
|
||||
segments: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Chunk, embed and upsert a meeting transcript into the vector store.
|
||||
|
||||
Idempotent: existing chunks for ``meeting_id`` are replaced only *after* the
|
||||
new embeddings are computed, so a failed re-index never leaves the meeting
|
||||
unsearchable.
|
||||
|
||||
Args:
|
||||
meeting_id: The meeting identifier.
|
||||
transcript: Optional transcript text. If omitted, the transcript is read
|
||||
from the stored meeting metadata (``metadata['transcript']``).
|
||||
segments: Optional ``transcribe_file`` segments (``start``/``end``/``text``).
|
||||
When provided, each indexed chunk is tagged with the real
|
||||
``timestamp_start``/``timestamp_end`` of the segments it covers.
|
||||
Falls back to stored ``metadata['segments']`` when omitted.
|
||||
|
||||
Returns:
|
||||
Dict ``{"chunks_indexed": N}`` or ``{"error": "..."}``.
|
||||
"""
|
||||
if not meeting_id:
|
||||
return {"error": "meeting_id is required"}
|
||||
|
||||
meeting = get_meeting(meeting_id)
|
||||
if "error" in meeting:
|
||||
return meeting
|
||||
|
||||
text = transcript
|
||||
title = meeting.get("title", "")
|
||||
meta = meeting.get("metadata") or {}
|
||||
if not text:
|
||||
text = meta.get("transcript")
|
||||
if not text or not str(text).strip():
|
||||
return {"error": "No transcript available to index"}
|
||||
|
||||
if segments is None:
|
||||
segments = meta.get("segments")
|
||||
seg_offsets = _segment_char_offsets(segments) if segments else []
|
||||
|
||||
try:
|
||||
collection = _get_vector_collection()
|
||||
|
||||
spans = _chunk_spans(text, _CHUNK_SIZE_TOKENS, _CHUNK_OVERLAP_TOKENS)
|
||||
if not spans:
|
||||
return {"chunks_indexed": 0}
|
||||
|
||||
chunks = [c for c, _s, _e in spans]
|
||||
# Embed first; only replace existing chunks once embedding succeeds so a
|
||||
# failure never destroys the previously searchable version.
|
||||
embeddings = _embed(chunks)
|
||||
|
||||
ids = [f"{meeting_id}:{i}" for i in range(len(chunks))]
|
||||
metadatas = []
|
||||
for i, (_chunk, cs, ce) in enumerate(spans):
|
||||
ts_start, ts_end = _timestamps_for_span(cs, ce, seg_offsets)
|
||||
metadatas.append(
|
||||
{
|
||||
"chunk_index": i,
|
||||
"meeting_id": meeting_id,
|
||||
"timestamp_end": ts_end,
|
||||
"timestamp_start": ts_start,
|
||||
"title": title,
|
||||
}
|
||||
)
|
||||
|
||||
# Delete existing chunks for this meeting so re-index is idempotent.
|
||||
collection.delete(where={"meeting_id": meeting_id})
|
||||
collection.add(
|
||||
ids=ids,
|
||||
documents=chunks,
|
||||
embeddings=embeddings,
|
||||
metadatas=metadatas,
|
||||
)
|
||||
except (ValueError, ImportError) as exc:
|
||||
return {"error": str(exc)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("index_meeting error: %s", exc)
|
||||
return {"error": str(exc)}
|
||||
|
||||
return {"chunks_indexed": len(chunks)}
|
||||
|
||||
|
||||
# ── 8. search_meetings ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@tool
|
||||
def search_meetings(query: str, limit: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Semantic search across indexed meetings, returning ranked snippets.
|
||||
|
||||
Args:
|
||||
query: The natural-language search query.
|
||||
limit: Maximum number of results to return (default 5).
|
||||
|
||||
Returns:
|
||||
A list of ``{meeting_id, title, snippet, score, timestamp_start,
|
||||
timestamp_end}`` dicts ranked by relevance (best first), or
|
||||
``[{"error": "..."}]`` on failure.
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
return [{"error": "query is required"}]
|
||||
try:
|
||||
limit = max(1, int(limit))
|
||||
collection = _get_vector_collection()
|
||||
query_embedding = _embed([query])[0]
|
||||
results = collection.query(
|
||||
query_embeddings=[query_embedding], n_results=limit
|
||||
)
|
||||
except (ValueError, ImportError) as exc:
|
||||
return [{"error": str(exc)}]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("search_meetings error: %s", exc)
|
||||
return [{"error": str(exc)}]
|
||||
|
||||
documents = (results.get("documents") or [[]])[0]
|
||||
metadatas = (results.get("metadatas") or [[]])[0]
|
||||
distances = (results.get("distances") or [[]])[0]
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for i, doc in enumerate(documents):
|
||||
meta = metadatas[i] if i < len(metadatas) else {}
|
||||
distance = distances[i] if i < len(distances) else None
|
||||
# Chroma's default space is squared-L2 (unbounded above). Map to a
|
||||
# bounded, monotonically decreasing relevance score in (0, 1].
|
||||
score = (
|
||||
round(1.0 / (1.0 + float(distance)), 6) if distance is not None else None
|
||||
)
|
||||
snippet = (doc or "")[:280]
|
||||
items.append(
|
||||
{
|
||||
"meeting_id": meta.get("meeting_id"),
|
||||
"score": score,
|
||||
"snippet": snippet,
|
||||
"timestamp_end": meta.get("timestamp_end", 0.0),
|
||||
"timestamp_start": meta.get("timestamp_start", 0.0),
|
||||
"title": meta.get("title"),
|
||||
}
|
||||
)
|
||||
return items
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-tools"
|
||||
version = "0.2.37"
|
||||
version = "0.2.42"
|
||||
description = "Extended tools for PraisonAI Agents"
|
||||
authors = [
|
||||
{name = "Mervin Praison"}
|
||||
@@ -40,6 +40,7 @@ praisonai-tools = "praisonai_tools.cli:main"
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.27.0",
|
||||
"playwright>=1.40",
|
||||
"imageio-ffmpeg>=0.5",
|
||||
]
|
||||
@@ -80,6 +81,10 @@ video-motion = [
|
||||
composio = [
|
||||
"composio>=0.5.0",
|
||||
]
|
||||
meeting = [
|
||||
"openai>=1.12.0",
|
||||
"chromadb>=0.4.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://docs.praison.ai"
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Unit tests for meeting_tools.
|
||||
|
||||
All external clients (OpenAI, Chroma) are mocked so no network or heavy
|
||||
dependency is required. The SQLite persistence is redirected to a temp dir.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_tools.tools import meeting_tools as mt
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_app_dir(tmp_path, monkeypatch):
|
||||
"""Point the meetings DB and vector store at a temp dir per test."""
|
||||
monkeypatch.setenv("PRAISONAI_MEETINGS_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("PRAISONAI_MEETINGS_DB", str(tmp_path / "meetings.db"))
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
||||
yield
|
||||
|
||||
|
||||
# ── transcribe_file ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTranscribeFile:
|
||||
def test_missing_file(self):
|
||||
result = mt.transcribe_file.__wrapped__("/no/such/file.mp3")
|
||||
assert "error" in result
|
||||
assert "not found" in result["error"].lower()
|
||||
|
||||
def _fake_response(self):
|
||||
return SimpleNamespace(
|
||||
text="Good morning everyone",
|
||||
duration=12.5,
|
||||
language="en",
|
||||
segments=[
|
||||
SimpleNamespace(start=0.0, end=4.2, text="Good morning everyone"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_success(self, tmp_path):
|
||||
audio = tmp_path / "a.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
client = MagicMock()
|
||||
client.audio.transcriptions.create.return_value = self._fake_response()
|
||||
with patch.object(mt, "_get_openai_client", return_value=client):
|
||||
result = mt.transcribe_file.__wrapped__(str(audio))
|
||||
assert result["text"] == "Good morning everyone"
|
||||
assert result["language"] == "en"
|
||||
assert result["duration_seconds"] == 12.5
|
||||
assert result["segments"][0]["start"] == 0.0
|
||||
assert result["segments"][0]["end"] == 4.2
|
||||
|
||||
def test_403_error_mapping(self, tmp_path):
|
||||
audio = tmp_path / "a.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
exc = Exception("403 forbidden")
|
||||
setattr(exc, "status_code", 403)
|
||||
client = MagicMock()
|
||||
client.audio.transcriptions.create.side_effect = exc
|
||||
with patch.object(mt, "_get_openai_client", return_value=client):
|
||||
result = mt.transcribe_file.__wrapped__(str(audio))
|
||||
assert "403" in result["error"] or "permission" in result["error"].lower()
|
||||
|
||||
def test_timeout_retries_once_then_fails(self, tmp_path):
|
||||
audio = tmp_path / "a.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
|
||||
class TimeoutError_(Exception):
|
||||
pass
|
||||
|
||||
client = MagicMock()
|
||||
client.audio.transcriptions.create.side_effect = TimeoutError_("timeout")
|
||||
with patch.object(mt, "_get_openai_client", return_value=client):
|
||||
result = mt.transcribe_file.__wrapped__(str(audio))
|
||||
assert "timed out" in result["error"].lower()
|
||||
assert client.audio.transcriptions.create.call_count == 2
|
||||
|
||||
def test_empty_audio(self, tmp_path):
|
||||
audio = tmp_path / "a.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
client = MagicMock()
|
||||
client.audio.transcriptions.create.return_value = SimpleNamespace(
|
||||
text="", duration=0.0, language="en", segments=[]
|
||||
)
|
||||
with patch.object(mt, "_get_openai_client", return_value=client):
|
||||
result = mt.transcribe_file.__wrapped__(str(audio))
|
||||
assert result["error"] == "No speech detected in recording"
|
||||
|
||||
|
||||
# ── Meetings CRUD ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMeetingsCRUD:
|
||||
def test_save_and_get(self):
|
||||
saved = mt.save_meeting.__wrapped__(
|
||||
title="Weekly standup", source="file.mp3", metadata={"team": "eng"}
|
||||
)
|
||||
mid = saved["meeting_id"]
|
||||
assert mid
|
||||
record = mt.get_meeting.__wrapped__(mid)
|
||||
assert record["title"] == "Weekly standup"
|
||||
assert record["source"] == "file.mp3"
|
||||
assert record["metadata"] == {"team": "eng"}
|
||||
|
||||
def test_save_requires_title(self):
|
||||
assert "error" in mt.save_meeting.__wrapped__(title="")
|
||||
|
||||
def test_get_not_found(self):
|
||||
assert "error" in mt.get_meeting.__wrapped__("nope")
|
||||
|
||||
def test_list(self):
|
||||
mt.save_meeting.__wrapped__(title="A")
|
||||
mt.save_meeting.__wrapped__(title="B")
|
||||
meetings = mt.list_meetings.__wrapped__(limit=10, offset=0)
|
||||
assert len(meetings) == 2
|
||||
titles = {m["title"] for m in meetings}
|
||||
assert titles == {"A", "B"}
|
||||
|
||||
def test_list_pagination(self):
|
||||
for i in range(3):
|
||||
mt.save_meeting.__wrapped__(title=f"M{i}")
|
||||
page = mt.list_meetings.__wrapped__(limit=1, offset=1)
|
||||
assert len(page) == 1
|
||||
|
||||
|
||||
# ── summarize_transcript ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSummarize:
|
||||
def test_requires_transcript(self):
|
||||
assert "error" in mt.summarize_transcript.__wrapped__("")
|
||||
|
||||
def test_single_chunk(self):
|
||||
payload = {
|
||||
"summary": "It was a good meeting.",
|
||||
"decisions": ["Ship on Friday"],
|
||||
"topics": ["release"],
|
||||
"key_quotes": [{"speaker": "Alex", "quote": "Ship it"}],
|
||||
}
|
||||
with patch.object(mt, "_chat_json", return_value=payload):
|
||||
result = mt.summarize_transcript.__wrapped__("short transcript")
|
||||
assert result["summary"] == "It was a good meeting."
|
||||
assert result["decisions"] == ["Ship on Friday"]
|
||||
assert result["topics"] == ["release"]
|
||||
assert result["key_quotes"][0]["quote"] == "Ship it"
|
||||
|
||||
def test_map_reduce_merges_chunks(self):
|
||||
long_text = "word " * 8000 # forces multiple summary chunks
|
||||
calls = []
|
||||
|
||||
def fake_chat(system, user):
|
||||
idx = len(calls)
|
||||
calls.append(user)
|
||||
return {
|
||||
"summary": f"para{idx}",
|
||||
"decisions": [f"d{idx}"],
|
||||
"topics": ["t"],
|
||||
"key_quotes": [{"speaker": None, "quote": f"q{idx}"}],
|
||||
}
|
||||
|
||||
with patch.object(mt, "_chat_json", side_effect=fake_chat):
|
||||
result = mt.summarize_transcript.__wrapped__(long_text)
|
||||
|
||||
assert len(calls) > 1
|
||||
assert "para0" in result["summary"]
|
||||
assert "t" in result["topics"] # deduped
|
||||
assert result["topics"].count("t") == 1
|
||||
assert len(result["decisions"]) == len(calls)
|
||||
|
||||
|
||||
# ── extract_action_items ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestActionItems:
|
||||
def test_requires_transcript(self):
|
||||
assert "error" in mt.extract_action_items.__wrapped__("")
|
||||
|
||||
def test_returns_valid_schema(self):
|
||||
payload = {
|
||||
"action_items": [
|
||||
{
|
||||
"description": "Fix API bug",
|
||||
"owner": "Alex",
|
||||
"due_date": "2026-09-05",
|
||||
"priority": "high",
|
||||
}
|
||||
]
|
||||
}
|
||||
with patch.object(mt, "_chat_json", return_value=payload):
|
||||
result = mt.extract_action_items.__wrapped__("transcript")
|
||||
item = result["action_items"][0]
|
||||
assert set(item.keys()) == {"description", "owner", "due_date", "priority"}
|
||||
assert item["owner"] == "Alex"
|
||||
|
||||
def test_handles_empty(self):
|
||||
with patch.object(mt, "_chat_json", return_value={}):
|
||||
result = mt.extract_action_items.__wrapped__("transcript")
|
||||
assert result["action_items"] == []
|
||||
|
||||
|
||||
# ── Fake in-memory vector collection ────────────────────────────────
|
||||
|
||||
|
||||
class _FakeCollection:
|
||||
"""Minimal Chroma-like collection using cosine-ish distance on tags."""
|
||||
|
||||
def __init__(self):
|
||||
self.store = {} # id -> (document, metadata, embedding)
|
||||
|
||||
def delete(self, where=None):
|
||||
if not where:
|
||||
self.store.clear()
|
||||
return
|
||||
mid = where.get("meeting_id")
|
||||
for k in [k for k, v in self.store.items() if v[1].get("meeting_id") == mid]:
|
||||
del self.store[k]
|
||||
|
||||
def add(self, ids, documents, embeddings, metadatas):
|
||||
for i, _id in enumerate(ids):
|
||||
self.store[_id] = (documents[i], metadatas[i], embeddings[i])
|
||||
|
||||
def query(self, query_embeddings, n_results):
|
||||
q = query_embeddings[0]
|
||||
|
||||
def dist(emb):
|
||||
return sum((a - b) ** 2 for a, b in zip(q, emb))
|
||||
|
||||
ranked = sorted(self.store.items(), key=lambda kv: dist(kv[1][2]))
|
||||
ranked = ranked[:n_results]
|
||||
return {
|
||||
"documents": [[v[0] for _, v in ranked]],
|
||||
"metadatas": [[v[1] for _, v in ranked]],
|
||||
"distances": [[dist(v[2]) for _, v in ranked]],
|
||||
}
|
||||
|
||||
|
||||
class TestIndexAndSearch:
|
||||
def _embed_map(self):
|
||||
# deterministic embeddings keyed by content keyword
|
||||
def fake_embed(texts):
|
||||
out = []
|
||||
for t in texts:
|
||||
if "apple" in t.lower():
|
||||
out.append([1.0, 0.0])
|
||||
elif "banana" in t.lower():
|
||||
out.append([0.0, 1.0])
|
||||
else:
|
||||
out.append([0.5, 0.5])
|
||||
return out
|
||||
|
||||
return fake_embed
|
||||
|
||||
def test_index_and_rank(self):
|
||||
coll = _FakeCollection()
|
||||
m1 = mt.save_meeting.__wrapped__(title="Apple meeting")["meeting_id"]
|
||||
m2 = mt.save_meeting.__wrapped__(title="Banana meeting")["meeting_id"]
|
||||
with patch.object(mt, "_get_vector_collection", return_value=coll), patch.object(
|
||||
mt, "_embed", side_effect=self._embed_map()
|
||||
):
|
||||
mt.index_meeting.__wrapped__(m1, transcript="all about apple pie")
|
||||
mt.index_meeting.__wrapped__(m2, transcript="all about banana bread")
|
||||
results = mt.search_meetings.__wrapped__("apple", limit=2)
|
||||
|
||||
assert results[0]["meeting_id"] == m1
|
||||
assert results[0]["score"] is not None
|
||||
assert results[0]["title"] == "Apple meeting"
|
||||
|
||||
def test_reindex_does_not_duplicate(self):
|
||||
coll = _FakeCollection()
|
||||
mid = mt.save_meeting.__wrapped__(title="M")["meeting_id"]
|
||||
with patch.object(mt, "_get_vector_collection", return_value=coll), patch.object(
|
||||
mt, "_embed", side_effect=self._embed_map()
|
||||
):
|
||||
r1 = mt.index_meeting.__wrapped__(mid, transcript="apple " * 600)
|
||||
count_after_first = len(coll.store)
|
||||
r2 = mt.index_meeting.__wrapped__(mid, transcript="apple " * 600)
|
||||
count_after_second = len(coll.store)
|
||||
|
||||
assert r1["chunks_indexed"] == r2["chunks_indexed"]
|
||||
assert count_after_first == count_after_second
|
||||
|
||||
def test_index_missing_transcript(self):
|
||||
mid = mt.save_meeting.__wrapped__(title="M")["meeting_id"]
|
||||
result = mt.index_meeting.__wrapped__(mid)
|
||||
assert "error" in result
|
||||
|
||||
def test_search_requires_query(self):
|
||||
assert "error" in mt.search_meetings.__wrapped__("")[0]
|
||||
|
||||
def test_segments_map_to_chunk_timestamps(self):
|
||||
coll = _FakeCollection()
|
||||
mid = mt.save_meeting.__wrapped__(title="Timed")["meeting_id"]
|
||||
segments = [
|
||||
{"start": 0.0, "end": 5.0, "text": "apple " * 200},
|
||||
{"start": 5.0, "end": 12.0, "text": "banana " * 200},
|
||||
]
|
||||
transcript = " ".join(s["text"].strip() for s in segments)
|
||||
with patch.object(mt, "_get_vector_collection", return_value=coll), patch.object(
|
||||
mt, "_embed", side_effect=self._embed_map()
|
||||
):
|
||||
mt.index_meeting.__wrapped__(mid, transcript=transcript, segments=segments)
|
||||
results = mt.search_meetings.__wrapped__("banana", limit=1)
|
||||
|
||||
assert results[0]["meeting_id"] == mid
|
||||
# The best match ("banana") must carry a non-zero timestamp derived
|
||||
# from the second segment, not the recording start.
|
||||
assert results[0]["timestamp_start"] > 0.0
|
||||
assert results[0]["timestamp_end"] >= results[0]["timestamp_start"]
|
||||
|
||||
def test_failed_reindex_preserves_existing_chunks(self):
|
||||
coll = _FakeCollection()
|
||||
mid = mt.save_meeting.__wrapped__(title="M")["meeting_id"]
|
||||
with patch.object(mt, "_get_vector_collection", return_value=coll), patch.object(
|
||||
mt, "_embed", side_effect=self._embed_map()
|
||||
):
|
||||
mt.index_meeting.__wrapped__(mid, transcript="apple " * 600)
|
||||
assert len(coll.store) > 0
|
||||
original = dict(coll.store)
|
||||
|
||||
def boom(_texts):
|
||||
raise RuntimeError("embedding backend down")
|
||||
|
||||
with patch.object(mt, "_get_vector_collection", return_value=coll), patch.object(
|
||||
mt, "_embed", side_effect=boom
|
||||
):
|
||||
result = mt.index_meeting.__wrapped__(mid, transcript="apple " * 600)
|
||||
|
||||
assert "error" in result
|
||||
# Previous chunks must survive a failed re-index.
|
||||
assert coll.store == original
|
||||
|
||||
def test_search_score_bounded_for_large_distance(self):
|
||||
coll = _FakeCollection()
|
||||
mid = mt.save_meeting.__wrapped__(title="Far")["meeting_id"]
|
||||
|
||||
def far_embed(texts):
|
||||
# Query and doc are far apart -> squared-L2 distance > 1.
|
||||
return [[10.0, 10.0] for _ in texts]
|
||||
|
||||
with patch.object(mt, "_get_vector_collection", return_value=coll), patch.object(
|
||||
mt, "_embed", side_effect=lambda t: [[0.0, 0.0] for _ in t]
|
||||
):
|
||||
mt.index_meeting.__wrapped__(mid, transcript="apple pie")
|
||||
with patch.object(mt, "_get_vector_collection", return_value=coll), patch.object(
|
||||
mt, "_embed", side_effect=far_embed
|
||||
):
|
||||
results = mt.search_meetings.__wrapped__("pie", limit=1)
|
||||
|
||||
assert 0.0 < results[0]["score"] <= 1.0
|
||||
|
||||
|
||||
# ── Lazy import hygiene ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_no_heavy_deps_at_import_time():
|
||||
import sys
|
||||
|
||||
# meeting_tools is already imported at top; ensure it did not pull heavy deps.
|
||||
mod = sys.modules["praisonai_tools.tools.meeting_tools"]
|
||||
src = open(mod.__file__).read()
|
||||
# No top-level (unindented) imports of openai/chromadb.
|
||||
for line in src.splitlines():
|
||||
if line.startswith("import openai") or line.startswith("from openai"):
|
||||
raise AssertionError("openai imported at module top level")
|
||||
if line.startswith("import chromadb") or line.startswith("from chromadb"):
|
||||
raise AssertionError("chromadb imported at module top level")
|
||||
|
||||
|
||||
def test_all_tools_are_registerable():
|
||||
import praisonai_tools
|
||||
|
||||
for name in (
|
||||
"transcribe_file",
|
||||
"save_meeting",
|
||||
"get_meeting",
|
||||
"list_meetings",
|
||||
"summarize_transcript",
|
||||
"extract_action_items",
|
||||
"index_meeting",
|
||||
"search_meetings",
|
||||
):
|
||||
exported = getattr(praisonai_tools, name, None)
|
||||
assert exported is not None, f"{name} is not exported"
|
||||
assert exported is getattr(mt, name), f"{name} export is shadowed"
|
||||
Reference in new issue
Block a user