mirror of
https://github.com/MervinPraison/PraisonAI-Tools.git
synced 2026-11-09 11:51:38 -10:00
Compare commits
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# PraisonAI-Tools environment variables (examples)
|
||||
#
|
||||
# Copy to `.env` and fill in the values for the tools you use.
|
||||
# Never commit real secrets.
|
||||
|
||||
# ── Stripe (StripeTool) ─────────────────────────────────────────────
|
||||
# Stripe secret key. Prefer least-privilege *restricted* keys (rk_...).
|
||||
# Use test keys (sk_test_/rk_test_) for development and live keys
|
||||
# (sk_live_/rk_live_) only in production. STRIPE_API_KEY is accepted as a
|
||||
# fallback if STRIPE_SECRET_KEY is unset.
|
||||
STRIPE_SECRET_KEY=sk_test_your_key_here
|
||||
# STRIPE_API_KEY=sk_test_your_key_here
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"templateRepo": "MervinPraison/github-automation-template",
|
||||
"stack": "claude",
|
||||
"profile": "tools",
|
||||
"mode": "hub",
|
||||
"hubRef": "v1.1.15",
|
||||
"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 * * *'
|
||||
- cron: '30 * * * *'
|
||||
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.20
|
||||
@@ -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.20
|
||||
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.20
|
||||
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.20
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
@@ -67,473 +53,44 @@ 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();
|
||||
}
|
||||
statuses: write # the assess job records security/outsider-review
|
||||
uses: MervinPraison/github-automation-template/.github/workflows/[email protected]
|
||||
secrets: inherit
|
||||
with:
|
||||
hub_ref: v1.1.20
|
||||
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.20
|
||||
@@ -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.20
|
||||
- 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.20
|
||||
@@ -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"
|
||||
@@ -646,6 +646,23 @@ videos = yt.search("python tutorial", limit=5)
|
||||
transcript = yt.get_transcript("dQw4w9WgXcQ")
|
||||
```
|
||||
|
||||
### Meeting Intelligence Tools
|
||||
|
||||
```python
|
||||
from praisonai_tools import FirefliesTool
|
||||
|
||||
# Fireflies.ai meeting intelligence (curated GraphQL operations)
|
||||
fireflies = FirefliesTool() # Uses FIREFLIES_API_KEY env var
|
||||
meetings = fireflies.list_recent_meetings(limit=5)
|
||||
meetings = fireflies.list_recent_meetings(limit=5, after="5") # cursor pagination
|
||||
transcript = fireflies.get_transcript(meeting_id="abc123")
|
||||
results = fireflies.search(keywords="roadmap")
|
||||
```
|
||||
|
||||
`FirefliesTool` distinguishes a missing key (`{"error": "FIREFLIES_API_KEY required"}`)
|
||||
from a rejected key (HTTP 401/403), and surfaces `retry_after` on rate limits.
|
||||
Set `FIREFLIES_API_URL` to override the endpoint (previews/proxies).
|
||||
|
||||
### Data Tools
|
||||
|
||||
```python
|
||||
@@ -669,6 +686,7 @@ air = weather.get_air_quality("Tokyo")
|
||||
| TTSTool | `OPENAI_API_KEY` or `ELEVENLABS_API_KEY` |
|
||||
| YouTubeTool | `YOUTUBE_API_KEY` |
|
||||
| WeatherTool | `OPENWEATHER_API_KEY` |
|
||||
| FirefliesTool | `FIREFLIES_API_KEY` (optional `FIREFLIES_API_URL`) |
|
||||
| SwarmScoreTool | None (uses public API) |
|
||||
|
||||
### Using with PraisonAI Agents
|
||||
|
||||
@@ -55,6 +55,7 @@ _MODULE_EXTRAS = {
|
||||
"langextract_tool": ("langextract",),
|
||||
"swarmscore_tool": ("swarmscore",),
|
||||
"composio_tool": ("composio",),
|
||||
"stripe_tool": ("stripe",),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Fireflies.ai Tool for PraisonAI Agents.
|
||||
|
||||
Curated GraphQL operations over the Fireflies.ai meeting-intelligence API:
|
||||
list recent meetings, fetch a transcript, and keyword search.
|
||||
|
||||
Design (Option B from the issue): each agent-callable method maps to a small,
|
||||
pre-approved GraphQL document rather than letting the LLM craft arbitrary
|
||||
queries. This keeps the surface predictable and robust.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools import FirefliesTool
|
||||
|
||||
fireflies = FirefliesTool()
|
||||
meetings = fireflies.list_recent_meetings(limit=5)
|
||||
transcript = fireflies.get_transcript(meeting_id="abc123")
|
||||
|
||||
Environment Variables:
|
||||
FIREFLIES_API_KEY: Fireflies API key (sent as ``Authorization: Bearer <key>``)
|
||||
FIREFLIES_API_URL: Optional endpoint override (defaults to the public API)
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from praisonai_tools.tools.base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_API_URL = "https://api.fireflies.ai/graphql"
|
||||
|
||||
|
||||
class FirefliesTool(BaseTool):
|
||||
"""Tool for querying Fireflies.ai meeting intelligence via curated GraphQL."""
|
||||
|
||||
name = "fireflies"
|
||||
description = (
|
||||
"Query Fireflies.ai meeting intelligence: list recent meetings, "
|
||||
"fetch transcripts, and search by keyword."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
api_url: Optional[str] = None,
|
||||
):
|
||||
self.api_key = api_key or os.getenv("FIREFLIES_API_KEY")
|
||||
self.api_url = api_url or os.getenv("FIREFLIES_API_URL") or DEFAULT_API_URL
|
||||
super().__init__()
|
||||
|
||||
def _graphql(self, query: str, variables: Dict = None) -> Dict:
|
||||
"""Execute a curated GraphQL document, returning normalized results.
|
||||
|
||||
Missing credentials are reported distinctly from HTTP auth failures so
|
||||
callers can tell a misconfiguration (no key) apart from a rejected key
|
||||
(401/403).
|
||||
"""
|
||||
if not self.api_key:
|
||||
return {"error": "FIREFLIES_API_KEY required"}
|
||||
|
||||
# Never transmit the API key over a plaintext endpoint.
|
||||
if not self.api_url.lower().startswith("https://"):
|
||||
return {"error": "FIREFLIES_API_URL must use HTTPS"}
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
return {"error": "requests not installed"}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
self.api_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"query": query, "variables": variables or {}},
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
if resp.status_code in (401, 403):
|
||||
return {
|
||||
"error": f"Fireflies authentication failed (HTTP {resp.status_code})",
|
||||
"status_code": resp.status_code,
|
||||
}
|
||||
if resp.status_code == 429:
|
||||
retry_after = resp.headers.get("Retry-After") or self._graphql_retry_after(resp)
|
||||
error = {"error": "Fireflies rate limit exceeded", "status_code": 429}
|
||||
if retry_after:
|
||||
error["retry_after"] = retry_after
|
||||
return error
|
||||
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
return {
|
||||
"error": f"Fireflies returned non-JSON response (HTTP {resp.status_code})",
|
||||
"status_code": resp.status_code,
|
||||
}
|
||||
|
||||
if payload.get("errors"):
|
||||
messages = [e.get("message", "unknown error") for e in payload["errors"]]
|
||||
return {"error": "; ".join(messages)}
|
||||
# Surface any remaining HTTP failure instead of letting an error body be
|
||||
# normalized into an empty/missing result by the operation handlers.
|
||||
if resp.status_code >= 400:
|
||||
return {
|
||||
"error": f"Fireflies request failed (HTTP {resp.status_code})",
|
||||
"status_code": resp.status_code,
|
||||
}
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _graphql_retry_after(resp) -> Optional[str]:
|
||||
"""Extract retryAfter from a GraphQL 429 body when the header is absent."""
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
return None
|
||||
for err in body.get("errors") or []:
|
||||
metadata = ((err.get("extensions") or {}).get("metadata")) or {}
|
||||
if metadata.get("retryAfter"):
|
||||
return str(metadata["retryAfter"])
|
||||
return None
|
||||
|
||||
def run(
|
||||
self,
|
||||
action: str = "list_recent_meetings",
|
||||
meeting_id: Optional[str] = None,
|
||||
keywords: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Union[str, Dict[str, Any], List[Dict[str, Any]]]:
|
||||
action = action.lower().replace("-", "_")
|
||||
|
||||
if action == "list_recent_meetings":
|
||||
return self.list_recent_meetings(**kwargs)
|
||||
elif action == "get_transcript":
|
||||
return self.get_transcript(meeting_id=meeting_id)
|
||||
elif action == "search":
|
||||
return self.search(keywords=keywords, **kwargs)
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
def list_recent_meetings(
|
||||
self, limit: int = 10, after: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List recent meetings/transcripts.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of meetings to return.
|
||||
after: Cursor (skip offset) for pagination. Explicit paging avoids
|
||||
unbounded scans of the account history.
|
||||
"""
|
||||
query = """
|
||||
query($limit: Int, $skip: Int) {
|
||||
transcripts(limit: $limit, skip: $skip) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
organizer_email
|
||||
}
|
||||
}
|
||||
"""
|
||||
limit = max(1, min(limit, 50)) # Fireflies caps page size at 50.
|
||||
variables: Dict[str, Any] = {"limit": limit}
|
||||
if after is not None:
|
||||
try:
|
||||
variables["skip"] = int(after)
|
||||
except (TypeError, ValueError):
|
||||
return [{"error": "after must be an integer cursor"}]
|
||||
|
||||
result = self._graphql(query, variables)
|
||||
if "error" in result:
|
||||
return [result]
|
||||
|
||||
transcripts = result.get("data", {}).get("transcripts") or []
|
||||
return [
|
||||
{
|
||||
"id": t.get("id"),
|
||||
"title": t.get("title"),
|
||||
"date": t.get("date"),
|
||||
"duration": t.get("duration"),
|
||||
"organizer_email": t.get("organizer_email"),
|
||||
}
|
||||
for t in transcripts
|
||||
]
|
||||
|
||||
def get_transcript(self, meeting_id: str) -> Dict[str, Any]:
|
||||
"""Fetch a single meeting transcript with summary and sentences."""
|
||||
if not meeting_id:
|
||||
return {"error": "meeting_id required"}
|
||||
|
||||
query = """
|
||||
query($id: String!) {
|
||||
transcript(id: $id) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
organizer_email
|
||||
summary { overview }
|
||||
sentences {
|
||||
text
|
||||
speaker_name
|
||||
start_time
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = self._graphql(query, {"id": meeting_id})
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
transcript = result.get("data", {}).get("transcript")
|
||||
if not transcript:
|
||||
return {"error": "transcript not found"}
|
||||
|
||||
summary = transcript.get("summary") or {}
|
||||
sentences = transcript.get("sentences") or []
|
||||
return {
|
||||
"id": transcript.get("id"),
|
||||
"title": transcript.get("title"),
|
||||
"date": transcript.get("date"),
|
||||
"duration": transcript.get("duration"),
|
||||
"organizer_email": transcript.get("organizer_email"),
|
||||
"summary": summary.get("overview"),
|
||||
"sentences": sentences,
|
||||
"sentence_count": len(sentences),
|
||||
}
|
||||
|
||||
def search(self, keywords: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Search meetings whose transcripts match the given keyword phrase."""
|
||||
if not keywords:
|
||||
return [{"error": "keywords required"}]
|
||||
|
||||
limit = max(1, min(limit, 50)) # Fireflies caps page size at 50.
|
||||
query = """
|
||||
query($keyword: String, $limit: Int) {
|
||||
transcripts(keyword: $keyword, limit: $limit) {
|
||||
id
|
||||
title
|
||||
date
|
||||
organizer_email
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = self._graphql(query, {"keyword": keywords, "limit": limit})
|
||||
if "error" in result:
|
||||
return [result]
|
||||
|
||||
transcripts = result.get("data", {}).get("transcripts") or []
|
||||
return [
|
||||
{
|
||||
"id": t.get("id"),
|
||||
"title": t.get("title"),
|
||||
"date": t.get("date"),
|
||||
"organizer_email": t.get("organizer_email"),
|
||||
}
|
||||
for t in transcripts
|
||||
]
|
||||
|
||||
|
||||
def list_fireflies_meetings(limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""List recent Fireflies meetings."""
|
||||
return FirefliesTool().list_recent_meetings(limit=limit)
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Keenable Search Tool for PraisonAI Agents.
|
||||
|
||||
Keyless (or keyed) web search using Keenable.
|
||||
|
||||
Keenable works with no API key and no extra install: it calls the keyless
|
||||
public endpoint with the Python standard library (``urllib``). Setting
|
||||
``KEENABLE_API_KEY`` switches to the authenticated endpoint for higher limits.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools import KeenableSearchTool
|
||||
|
||||
keenable = KeenableSearchTool()
|
||||
results = keenable.search("AI news 2024")
|
||||
|
||||
Environment Variables:
|
||||
KEENABLE_API_KEY: Optional. When set, the authenticated endpoint is used.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.request import HTTPRedirectHandler
|
||||
|
||||
from praisonai_tools.tools.base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KEENABLE_SEARCH_URL = "https://api.keenable.ai/v1/search"
|
||||
KEENABLE_PUBLIC_SEARCH_URL = "https://api.keenable.ai/v1/search/public"
|
||||
KEENABLE_APP_TITLE = "PraisonAI"
|
||||
KEENABLE_SEARCH_TIMEOUT_SECONDS = 30
|
||||
KEENABLE_MAX_RESPONSE_BYTES = 1_000_000
|
||||
KEENABLE_MAX_RESULTS = 50
|
||||
KEENABLE_MAX_TITLE_CHARS = 256
|
||||
KEENABLE_MAX_URL_CHARS = 2048
|
||||
KEENABLE_MAX_SNIPPET_CHARS = 1200
|
||||
|
||||
|
||||
def _truncate_text(value: Any, limit: int) -> str:
|
||||
"""Trim whitespace and cap ``value`` to ``limit`` characters."""
|
||||
text = str(value or "").strip()
|
||||
if len(text) > limit:
|
||||
return text[:limit - 1] + "\u2026"
|
||||
return text
|
||||
|
||||
|
||||
class _NoRedirect(HTTPRedirectHandler):
|
||||
"""Redirect handler that refuses to follow any redirect.
|
||||
|
||||
Keenable never redirects. Following a 3xx would resend ``X-API-Key`` to
|
||||
whatever host it points at, so the redirect is dropped (urllib then raises
|
||||
the 3xx as an error, surfaced to the caller) and no second request is sent.
|
||||
"""
|
||||
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
"""Return ``None`` so urllib does not issue a follow-up request."""
|
||||
return None
|
||||
|
||||
|
||||
class KeenableSearchTool(BaseTool):
|
||||
"""Tool for keyless (or keyed) web search using Keenable."""
|
||||
|
||||
name = "keenable_search"
|
||||
description = "Search the web using Keenable (keyless; an API key raises limits)."
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
"""Create the tool, resolving the API key from the argument or env.
|
||||
|
||||
Args:
|
||||
api_key: Optional Keenable API key. Falls back to the
|
||||
``KEENABLE_API_KEY`` environment variable; keyless if unset.
|
||||
"""
|
||||
self.api_key = (api_key or os.getenv("KEENABLE_API_KEY") or "").strip()
|
||||
super().__init__()
|
||||
|
||||
def run(
|
||||
self,
|
||||
action: str = "search",
|
||||
query: Optional[str] = None,
|
||||
max_results: int = 5,
|
||||
**kwargs,
|
||||
) -> Union[str, Dict[str, Any], List[Dict[str, Any]]]:
|
||||
"""Dispatch a Keenable action; only ``search`` is supported."""
|
||||
action = action.lower().replace("-", "_")
|
||||
if action == "search":
|
||||
return self.search(query=query, max_results=max_results)
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
def search(self, query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search the web with Keenable.
|
||||
|
||||
Args:
|
||||
query: Search query string.
|
||||
max_results: Maximum results to return (capped at 50).
|
||||
|
||||
Returns:
|
||||
List of dicts with ``title``, ``url``, ``snippet`` and ``provider``,
|
||||
or a single-item list with an ``error`` key on failure.
|
||||
"""
|
||||
if not query:
|
||||
return [{"error": "query is required"}]
|
||||
|
||||
result_limit = max(0, min(int(max_results), KEENABLE_MAX_RESULTS))
|
||||
if result_limit == 0:
|
||||
return []
|
||||
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, build_opener
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "praisonai-tools",
|
||||
# Required on keyless calls; names the app, carries no user identifier.
|
||||
"X-Keenable-Title": KEENABLE_APP_TITLE,
|
||||
}
|
||||
if self.api_key:
|
||||
headers["X-API-Key"] = self.api_key
|
||||
body = json.dumps({
|
||||
"query": query,
|
||||
"max_results": result_limit,
|
||||
"snippet_max_length": KEENABLE_MAX_SNIPPET_CHARS,
|
||||
}).encode("utf-8")
|
||||
request = Request(
|
||||
KEENABLE_SEARCH_URL if self.api_key else KEENABLE_PUBLIC_SEARCH_URL,
|
||||
data=body,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
opener = build_opener(_NoRedirect)
|
||||
with opener.open(request, timeout=KEENABLE_SEARCH_TIMEOUT_SECONDS) as response:
|
||||
raw = response.read(KEENABLE_MAX_RESPONSE_BYTES + 1)
|
||||
except URLError as exc:
|
||||
logger.error(f"Keenable search error: {exc}")
|
||||
return [{"error": str(exc)}]
|
||||
except Exception as exc:
|
||||
logger.error(f"Keenable search error: {exc}")
|
||||
return [{"error": str(exc)}]
|
||||
|
||||
if len(raw) > KEENABLE_MAX_RESPONSE_BYTES:
|
||||
return [{"error": "Keenable returned an oversized response"}]
|
||||
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except ValueError as exc:
|
||||
return [{"error": f"Keenable returned invalid JSON: {exc}"}]
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
|
||||
return [{"error": "Keenable returned an invalid response shape"}]
|
||||
|
||||
results = []
|
||||
for result in payload["results"]:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
url = result.get("url")
|
||||
if not isinstance(url, str) or not url or len(url) > KEENABLE_MAX_URL_CHARS:
|
||||
continue
|
||||
# The page text is in `snippet`; `description` is usually empty.
|
||||
snippet = " ".join(str(result.get("snippet") or result.get("description") or "").split())
|
||||
results.append({
|
||||
"title": _truncate_text(result.get("title", ""), KEENABLE_MAX_TITLE_CHARS),
|
||||
"url": url,
|
||||
"snippet": _truncate_text(snippet, KEENABLE_MAX_SNIPPET_CHARS),
|
||||
"provider": "keenable",
|
||||
})
|
||||
if len(results) >= result_limit:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
def keenable_search(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search the web with Keenable (keyless; KEENABLE_API_KEY raises limits)."""
|
||||
return KeenableSearchTool().search(query=query, max_results=max_results)
|
||||
@@ -17,6 +17,17 @@ Environment Variables:
|
||||
REDDIT_CLIENT_ID: Reddit API client ID
|
||||
REDDIT_CLIENT_SECRET: Reddit API client secret
|
||||
REDDIT_USER_AGENT: User agent string (optional)
|
||||
REDDIT_USERNAME: Reddit username (required for write actions)
|
||||
REDDIT_PASSWORD: Reddit password (required for write actions)
|
||||
REDDIT_ALLOW_WRITE: Set to "1" to enable write actions (post/comment)
|
||||
|
||||
Write actions (submit_post, post_comment) are disabled by default to reduce
|
||||
spam/abuse surface. They are only permitted when ``REDDIT_ALLOW_WRITE=1`` (or
|
||||
``allow_write=True``) and Reddit account credentials are configured. You are
|
||||
responsible for complying with Reddit's API terms when enabling writes.
|
||||
|
||||
Reddit rate-limits misconfigured clients aggressively. Use a descriptive
|
||||
User-Agent referencing your app id, e.g. ``script:my-app:v1.0 (by /u/you)``.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -32,13 +43,20 @@ class RedditTool(BaseTool):
|
||||
"""Tool for interacting with Reddit."""
|
||||
|
||||
name = "reddit"
|
||||
description = "Search Reddit, get posts from subreddits, and read comments."
|
||||
description = (
|
||||
"Search Reddit, get posts from subreddits, and read comments. "
|
||||
"Can also comment on posts and submit new posts when write actions "
|
||||
"are enabled (REDDIT_ALLOW_WRITE=1 with account credentials)."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: Optional[str] = None,
|
||||
client_secret: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
allow_write: Optional[bool] = None,
|
||||
):
|
||||
"""Initialize RedditTool.
|
||||
|
||||
@@ -46,13 +64,30 @@ class RedditTool(BaseTool):
|
||||
client_id: Reddit API client ID
|
||||
client_secret: Reddit API client secret
|
||||
user_agent: User agent string
|
||||
username: Reddit username (required for write actions)
|
||||
password: Reddit password (required for write actions)
|
||||
allow_write: Enable write actions. Defaults to the
|
||||
``REDDIT_ALLOW_WRITE`` env var ("1"/"true" enables).
|
||||
"""
|
||||
self.client_id = client_id or os.getenv("REDDIT_CLIENT_ID")
|
||||
self.client_secret = client_secret or os.getenv("REDDIT_CLIENT_SECRET")
|
||||
self.user_agent = user_agent or os.getenv("REDDIT_USER_AGENT", "PraisonAI/1.0")
|
||||
self.user_agent = user_agent or os.getenv("REDDIT_USER_AGENT") or self._default_user_agent()
|
||||
self.username = username or os.getenv("REDDIT_USERNAME")
|
||||
self.password = password or os.getenv("REDDIT_PASSWORD")
|
||||
if allow_write is None:
|
||||
allow_write = os.getenv("REDDIT_ALLOW_WRITE", "").lower() in ("1", "true", "yes")
|
||||
self.allow_write = allow_write
|
||||
self._reddit = None
|
||||
super().__init__()
|
||||
|
||||
def _default_user_agent(self) -> str:
|
||||
"""Build a compliant User-Agent referencing the app id.
|
||||
|
||||
Reddit recommends ``<platform>:<app id>:<version> (by /u/<user>)``.
|
||||
"""
|
||||
app_id = self.client_id or "unknown"
|
||||
return f"script:praisonai-{app_id}:1.0 (by PraisonAI)"
|
||||
|
||||
@property
|
||||
def reddit(self):
|
||||
"""Lazy-load Reddit client."""
|
||||
@@ -65,13 +100,31 @@ class RedditTool(BaseTool):
|
||||
if not self.client_id or not self.client_secret:
|
||||
raise ValueError("REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET required")
|
||||
|
||||
self._reddit = praw.Reddit(
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
user_agent=self.user_agent,
|
||||
)
|
||||
kwargs = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"user_agent": self.user_agent,
|
||||
}
|
||||
if self.username and self.password:
|
||||
kwargs["username"] = self.username
|
||||
kwargs["password"] = self.password
|
||||
|
||||
self._reddit = praw.Reddit(**kwargs)
|
||||
return self._reddit
|
||||
|
||||
def _check_write_allowed(self) -> Optional[Dict[str, Any]]:
|
||||
"""Return an error dict if write actions are not permitted, else None."""
|
||||
if not self.allow_write:
|
||||
return {
|
||||
"error": "Write actions disabled. Set REDDIT_ALLOW_WRITE=1 to enable "
|
||||
"posting/commenting.",
|
||||
}
|
||||
if not self.username or not self.password:
|
||||
return {
|
||||
"error": "REDDIT_USERNAME and REDDIT_PASSWORD required for write actions.",
|
||||
}
|
||||
return None
|
||||
|
||||
def run(
|
||||
self,
|
||||
action: str = "search",
|
||||
@@ -98,6 +151,15 @@ class RedditTool(BaseTool):
|
||||
return self.get_comments(post_id=post_id, limit=limit)
|
||||
elif action == "subreddit_info":
|
||||
return self.get_subreddit_info(subreddit=subreddit)
|
||||
elif action == "post_comment":
|
||||
return self.post_comment(post_id=post_id, body=kwargs.get("body"))
|
||||
elif action == "submit_post":
|
||||
return self.submit_post(
|
||||
subreddit=subreddit,
|
||||
title=kwargs.get("title"),
|
||||
body=kwargs.get("body"),
|
||||
url=kwargs.get("url"),
|
||||
)
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
@@ -284,6 +346,83 @@ class RedditTool(BaseTool):
|
||||
logger.error(f"Reddit get_subreddit_info error: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def post_comment(self, post_id: str, body: str) -> Dict[str, Any]:
|
||||
"""Reply with a comment to a post (write action).
|
||||
|
||||
Gated behind ``REDDIT_ALLOW_WRITE=1`` and account credentials.
|
||||
|
||||
Args:
|
||||
post_id: Reddit post ID to comment on
|
||||
body: Comment text
|
||||
|
||||
Returns:
|
||||
Result with the new comment id, or an error dict
|
||||
"""
|
||||
denied = self._check_write_allowed()
|
||||
if denied:
|
||||
return denied
|
||||
if not post_id:
|
||||
return {"error": "post_id is required"}
|
||||
if not body:
|
||||
return {"error": "body is required"}
|
||||
|
||||
try:
|
||||
submission = self.reddit.submission(id=post_id)
|
||||
comment = submission.reply(body)
|
||||
return {
|
||||
"success": True,
|
||||
"id": comment.id,
|
||||
"permalink": f"https://reddit.com{comment.permalink}",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Reddit post_comment error: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def submit_post(
|
||||
self,
|
||||
subreddit: str,
|
||||
title: str,
|
||||
body: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Submit a new post to a subreddit (write action).
|
||||
|
||||
Gated behind ``REDDIT_ALLOW_WRITE=1`` and account credentials.
|
||||
|
||||
Args:
|
||||
subreddit: Target subreddit name
|
||||
title: Post title
|
||||
body: Self-post text (mutually exclusive with url)
|
||||
url: Link URL (mutually exclusive with body)
|
||||
|
||||
Returns:
|
||||
Result with the new post id, or an error dict
|
||||
"""
|
||||
denied = self._check_write_allowed()
|
||||
if denied:
|
||||
return denied
|
||||
if not subreddit:
|
||||
return {"error": "subreddit is required"}
|
||||
if not title:
|
||||
return {"error": "title is required"}
|
||||
if url and body:
|
||||
return {"error": "provide either body or url, not both"}
|
||||
|
||||
try:
|
||||
sub = self.reddit.subreddit(subreddit)
|
||||
if url:
|
||||
submission = sub.submit(title, url=url)
|
||||
else:
|
||||
submission = sub.submit(title, selftext=body or "")
|
||||
return {
|
||||
"success": True,
|
||||
"id": submission.id,
|
||||
"permalink": f"https://reddit.com{submission.permalink}",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Reddit submit_post error: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def _format_post(self, post, include_body: bool = False) -> Dict[str, Any]:
|
||||
"""Format a Reddit post."""
|
||||
result = {
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Stripe Tool for PraisonAI Agents.
|
||||
|
||||
Narrow, audited Stripe operations — retrieve customers/payment intents,
|
||||
list payment links, and create payment links with required correlation
|
||||
metadata and optional idempotency keys.
|
||||
|
||||
Design constraint: prefer narrow, audited operations over a "god tool"
|
||||
that dumps the entire Stripe surface. One verb per action.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools import StripeTool
|
||||
|
||||
stripe = StripeTool() # reads STRIPE_SECRET_KEY (or STRIPE_API_KEY)
|
||||
customer = stripe.run(action="get_customer", customer_id="cus_123")
|
||||
link = stripe.run(
|
||||
action="create_payment_link",
|
||||
price_id="price_123",
|
||||
quantity=1,
|
||||
correlation_id="order-42",
|
||||
)
|
||||
|
||||
Environment Variables:
|
||||
STRIPE_SECRET_KEY: Stripe secret key. Restricted keys are recommended.
|
||||
Accepts test (``sk_test_``/``rk_test_``) or live
|
||||
(``sk_live_``/``rk_live_``) keys. STRIPE_API_KEY is also accepted
|
||||
as a fallback.
|
||||
|
||||
Security notes:
|
||||
* Secrets are read from the environment only and never logged.
|
||||
* Responses include a ``mode`` banner (``test``/``live``) derived from
|
||||
the key prefix so live operations are visible to callers.
|
||||
* Structured logs record only the operation, mode, idempotency key, and
|
||||
the Stripe object id — never full request/response payloads.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from praisonai_tools.tools.base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE = "https://api.stripe.com/v1"
|
||||
|
||||
_TEST_PREFIXES = ("sk_test_", "rk_test_")
|
||||
_LIVE_PREFIXES = ("sk_live_", "rk_live_")
|
||||
|
||||
|
||||
class StripeTool(BaseTool):
|
||||
"""Tool for narrow, audited Stripe operations."""
|
||||
|
||||
name = "stripe"
|
||||
description = (
|
||||
"Retrieve Stripe customers and payment intents, list payment links, "
|
||||
"and create payment links with required correlation metadata."
|
||||
)
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
self.api_key = (
|
||||
api_key
|
||||
or os.getenv("STRIPE_SECRET_KEY")
|
||||
or os.getenv("STRIPE_API_KEY")
|
||||
)
|
||||
super().__init__()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _mode(self) -> str:
|
||||
"""Return ``test``, ``live``, or ``unknown`` from the key prefix."""
|
||||
key = self.api_key or ""
|
||||
if key.startswith(_LIVE_PREFIXES):
|
||||
return "live"
|
||||
if key.startswith(_TEST_PREFIXES):
|
||||
return "test"
|
||||
return "unknown"
|
||||
|
||||
def _require_key(self) -> Optional[Dict[str, str]]:
|
||||
if not self.api_key:
|
||||
return {"error": "STRIPE_SECRET_KEY (or STRIPE_API_KEY) is required"}
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _import_requests():
|
||||
try:
|
||||
import requests
|
||||
return requests
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make a request to the Stripe REST API.
|
||||
|
||||
Centralises key validation, the optional ``requests`` import, the
|
||||
HTTP call, auditable logging, and error handling.
|
||||
"""
|
||||
err = self._require_key()
|
||||
if err:
|
||||
return err
|
||||
|
||||
requests = self._import_requests()
|
||||
if requests is None:
|
||||
return {"error": "requests package is not installed"}
|
||||
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"}
|
||||
if idempotency_key:
|
||||
headers["Idempotency-Key"] = idempotency_key
|
||||
|
||||
try:
|
||||
resp = requests.request(
|
||||
method,
|
||||
f"{API_BASE}/{endpoint}",
|
||||
headers=headers,
|
||||
data=data,
|
||||
timeout=30,
|
||||
)
|
||||
body = resp.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Stripe request error: %s", e)
|
||||
return {"error": f"API request failed: {e}"}
|
||||
except ValueError as e:
|
||||
logger.error("Stripe JSON decode error: %s", e)
|
||||
return {"error": "Failed to decode API response"}
|
||||
|
||||
if isinstance(body, dict) and "error" in body:
|
||||
stripe_err = body["error"]
|
||||
message = stripe_err.get("message", "Stripe API error")
|
||||
logger.error(
|
||||
"Stripe API error: op=%s mode=%s type=%s",
|
||||
endpoint, self._mode(), stripe_err.get("type"),
|
||||
)
|
||||
return {"error": message}
|
||||
|
||||
# Defensive: a non-2xx status without Stripe's usual ``error`` object
|
||||
# must not be treated as a successful payload.
|
||||
if resp.status_code >= 400:
|
||||
logger.error(
|
||||
"Stripe HTTP error: op=%s mode=%s status=%s",
|
||||
endpoint, self._mode(), resp.status_code,
|
||||
)
|
||||
return {"error": f"Stripe API returned HTTP {resp.status_code}"}
|
||||
|
||||
obj_id = body.get("id") if isinstance(body, dict) else None
|
||||
logger.info(
|
||||
"Stripe op=%s mode=%s idempotency_key=%s object_id=%s",
|
||||
endpoint, self._mode(), idempotency_key, obj_id,
|
||||
)
|
||||
return body
|
||||
|
||||
def _banner(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Attach a live/test mode banner to a successful response."""
|
||||
if isinstance(payload, dict) and "error" not in payload:
|
||||
payload = {"mode": self._mode(), **payload}
|
||||
return payload
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dispatcher
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(
|
||||
self,
|
||||
action: str = "get_customer",
|
||||
customer_id: Optional[str] = None,
|
||||
payment_intent_id: Optional[str] = None,
|
||||
price_id: Optional[str] = None,
|
||||
quantity: int = 1,
|
||||
correlation_id: Optional[str] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
**kwargs,
|
||||
) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
|
||||
"""Dispatch to the appropriate Stripe action.
|
||||
|
||||
Actions:
|
||||
get_customer — Retrieve a customer by id (read).
|
||||
get_payment_intent — Retrieve a payment intent by id (read).
|
||||
list_payment_links — List payment links (read).
|
||||
create_payment_link — Create a payment link (write, guarded).
|
||||
"""
|
||||
action = action.lower().replace("-", "_")
|
||||
|
||||
if action == "get_customer":
|
||||
return self.get_customer(customer_id=customer_id)
|
||||
elif action == "get_payment_intent":
|
||||
return self.get_payment_intent(payment_intent_id=payment_intent_id)
|
||||
elif action == "list_payment_links":
|
||||
return self.list_payment_links(limit=limit)
|
||||
elif action == "create_payment_link":
|
||||
return self.create_payment_link(
|
||||
price_id=price_id,
|
||||
quantity=quantity,
|
||||
correlation_id=correlation_id,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
else:
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 1 — Read-mostly safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_customer(self, customer_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Retrieve a customer by id.
|
||||
|
||||
Args:
|
||||
customer_id: The Stripe customer id (``cus_...``).
|
||||
"""
|
||||
if not customer_id:
|
||||
return {"error": "customer_id is required"}
|
||||
|
||||
result = self._request("get", f"customers/{customer_id}")
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
return self._banner({
|
||||
"id": result.get("id"),
|
||||
"email": result.get("email"),
|
||||
"name": result.get("name"),
|
||||
"created": result.get("created"),
|
||||
"currency": result.get("currency"),
|
||||
"delinquent": result.get("delinquent"),
|
||||
})
|
||||
|
||||
def get_payment_intent(
|
||||
self, payment_intent_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Retrieve a payment intent by id.
|
||||
|
||||
Args:
|
||||
payment_intent_id: The Stripe payment intent id (``pi_...``).
|
||||
"""
|
||||
if not payment_intent_id:
|
||||
return {"error": "payment_intent_id is required"}
|
||||
|
||||
result = self._request("get", f"payment_intents/{payment_intent_id}")
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
return self._banner({
|
||||
"id": result.get("id"),
|
||||
"amount": result.get("amount"),
|
||||
"currency": result.get("currency"),
|
||||
"status": result.get("status"),
|
||||
"customer": result.get("customer"),
|
||||
"created": result.get("created"),
|
||||
})
|
||||
|
||||
def list_payment_links(self, limit: int = 10) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
|
||||
"""List payment links.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of payment links to return (1–100).
|
||||
"""
|
||||
limit = max(1, min(int(limit), 100))
|
||||
result = self._request("get", f"payment_links?limit={limit}")
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
links = [
|
||||
{
|
||||
"id": link.get("id"),
|
||||
"url": link.get("url"),
|
||||
"active": link.get("active"),
|
||||
"metadata": link.get("metadata", {}),
|
||||
}
|
||||
for link in result.get("data", [])
|
||||
]
|
||||
return self._banner({"payment_links": links})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 2 — Mutations with guardrails
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_payment_link(
|
||||
self,
|
||||
price_id: Optional[str] = None,
|
||||
quantity: int = 1,
|
||||
correlation_id: Optional[str] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a payment link.
|
||||
|
||||
A ``correlation_id`` is required and stored as payment-link metadata
|
||||
so every created link is traceable back to the request that made it.
|
||||
An optional ``idempotency_key`` is surfaced to make retries safe.
|
||||
|
||||
Args:
|
||||
price_id: An existing Stripe Price id (``price_...``).
|
||||
quantity: Quantity for the line item (>= 1).
|
||||
correlation_id: Required correlation id stored as metadata.
|
||||
idempotency_key: Optional Stripe ``Idempotency-Key`` header value.
|
||||
"""
|
||||
if not price_id:
|
||||
return {"error": "price_id is required"}
|
||||
if not correlation_id:
|
||||
return {"error": "correlation_id is required (stored as metadata)"}
|
||||
try:
|
||||
quantity = int(quantity)
|
||||
except (TypeError, ValueError):
|
||||
return {"error": "quantity must be an integer"}
|
||||
if quantity < 1:
|
||||
return {"error": "quantity must be >= 1"}
|
||||
|
||||
data = {
|
||||
"line_items[0][price]": price_id,
|
||||
"line_items[0][quantity]": quantity,
|
||||
"metadata[correlation_id]": correlation_id,
|
||||
}
|
||||
result = self._request(
|
||||
"post",
|
||||
"payment_links",
|
||||
data=data,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if "error" in result:
|
||||
return result
|
||||
|
||||
return self._banner({
|
||||
"id": result.get("id"),
|
||||
"url": result.get("url"),
|
||||
"active": result.get("active"),
|
||||
"metadata": result.get("metadata", {}),
|
||||
})
|
||||
|
||||
|
||||
def get_stripe_customer(
|
||||
customer_id: str, api_key: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Retrieve a Stripe customer by id."""
|
||||
return StripeTool(api_key=api_key).get_customer(customer_id=customer_id)
|
||||
@@ -0,0 +1,484 @@
|
||||
"""You.com search and content tools for PraisonAI Agents.
|
||||
|
||||
Migrated from ``praisonaiagents.tools.youdotcom_tools`` (MervinPraison/PraisonAI#6168).
|
||||
|
||||
You.com provides AI-powered web search, news search, content extraction, and image search.
|
||||
This module requires the YDC_API_KEY environment variable to be set.
|
||||
|
||||
Usage:
|
||||
from praisonai_tools.tools.youdotcom_tool import ydc_search, ydc_news, ydc_contents
|
||||
|
||||
# Search the web (unified web + news)
|
||||
results = ydc_search("AI news 2024")
|
||||
|
||||
# Get live news
|
||||
news = ydc_news("technology trends")
|
||||
|
||||
# Extract content from URLs
|
||||
content = ydc_contents(["https://example.com"])
|
||||
|
||||
# Or use the class directly
|
||||
from praisonai_tools.tools.youdotcom_tool import YouTools
|
||||
you = YouTools()
|
||||
results = you.search("AI news")
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
import logging
|
||||
import os
|
||||
from importlib import util
|
||||
|
||||
|
||||
class YouTools:
|
||||
"""Comprehensive tools for web search and content using You.com API.
|
||||
|
||||
You.com provides AI-powered search capabilities optimized for LLM applications.
|
||||
|
||||
Features:
|
||||
- Unified web and news search with LLM-ready snippets
|
||||
- Live news search from authoritative sources
|
||||
- Content extraction from URLs (HTML/Markdown)
|
||||
- Image search
|
||||
- Advanced search operators support
|
||||
- Geographic and language targeting
|
||||
- Freshness controls
|
||||
|
||||
Requires:
|
||||
- youdotcom package: pip install youdotcom
|
||||
- YDC_API_KEY environment variable
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
"""Initialize YouTools.
|
||||
|
||||
Args:
|
||||
api_key: Optional API key. If not provided, uses YDC_API_KEY env var.
|
||||
"""
|
||||
self._api_key = api_key
|
||||
self._client = None
|
||||
|
||||
def _get_api_key(self) -> Optional[str]:
|
||||
"""Get API key from instance or environment."""
|
||||
return self._api_key or os.environ.get("YDC_API_KEY")
|
||||
|
||||
def _get_client(self):
|
||||
"""Get or create You.com client."""
|
||||
if self._client is None:
|
||||
if util.find_spec("youdotcom") is None:
|
||||
return None, "youdotcom package is not installed. Please install it using: pip install youdotcom"
|
||||
api_key = self._get_api_key()
|
||||
if not api_key:
|
||||
return None, "YDC_API_KEY environment variable is not set. Please set it to use You.com tools."
|
||||
|
||||
from youdotcom import You
|
||||
self._client = You(api_key_auth=api_key)
|
||||
return self._client, None
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
count: int = 10,
|
||||
freshness: Optional[str] = None,
|
||||
country: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
safesearch: str = "moderate",
|
||||
livecrawl: Optional[str] = None,
|
||||
livecrawl_formats: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search the web using You.com's unified search API.
|
||||
|
||||
Returns both web and news results in a single call with LLM-ready snippets.
|
||||
|
||||
Args:
|
||||
query: Search query (supports operators like site:, filetype:, +term, -term)
|
||||
count: Max results per section (default 10, max 100)
|
||||
freshness: Filter by recency - "day", "week", "month", "year" or "YYYY-MM-DDtoYYYY-MM-DD"
|
||||
country: ISO country code (e.g., "US", "GB", "FR")
|
||||
language: BCP 47 language code (e.g., "EN", "JP", "DE")
|
||||
offset: Pagination offset (0-9), results = offset * count
|
||||
safesearch: Content filter - "off", "moderate", "strict"
|
||||
livecrawl: Fetch full content - "web", "news", or "all"
|
||||
livecrawl_formats: Content format - "html" or "markdown"
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- results: {web: [...], news: [...]}
|
||||
- metadata: {query, search_uuid, latency}
|
||||
|
||||
Search Operators:
|
||||
- site:domain.com - Search within specific domain
|
||||
- filetype:pdf - Filter by file type
|
||||
- +term - Must include term
|
||||
- -term - Exclude term
|
||||
- AND, OR, NOT - Boolean operators
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
kwargs = {"query": query, "count": count, "offset": offset}
|
||||
|
||||
if freshness:
|
||||
kwargs["freshness"] = freshness
|
||||
if country:
|
||||
kwargs["country"] = country
|
||||
if language:
|
||||
kwargs["language"] = language
|
||||
if safesearch:
|
||||
kwargs["safesearch"] = safesearch
|
||||
if livecrawl:
|
||||
kwargs["livecrawl"] = livecrawl
|
||||
if livecrawl_formats:
|
||||
kwargs["livecrawl_formats"] = livecrawl_formats
|
||||
|
||||
response = client.search.unified(**kwargs)
|
||||
|
||||
# Convert response to dict if it's a model object
|
||||
if hasattr(response, 'model_dump'):
|
||||
return response.model_dump()
|
||||
elif hasattr(response, 'to_dict'):
|
||||
return response.to_dict()
|
||||
elif hasattr(response, '__dict__'):
|
||||
return self._convert_response(response)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com search error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def _convert_response(self, response) -> Dict[str, Any]:
|
||||
"""Convert SDK response object to dictionary."""
|
||||
result = {}
|
||||
|
||||
if hasattr(response, 'results'):
|
||||
results = response.results
|
||||
result['results'] = {}
|
||||
if hasattr(results, 'web') and results.web:
|
||||
result['results']['web'] = [
|
||||
{
|
||||
'url': getattr(r, 'url', ''),
|
||||
'title': getattr(r, 'title', ''),
|
||||
'description': getattr(r, 'description', ''),
|
||||
'snippets': getattr(r, 'snippets', []),
|
||||
'thumbnail_url': getattr(r, 'thumbnail_url', None),
|
||||
'page_age': getattr(r, 'page_age', None),
|
||||
'favicon_url': getattr(r, 'favicon_url', None),
|
||||
'authors': getattr(r, 'authors', []),
|
||||
}
|
||||
for r in results.web
|
||||
]
|
||||
if hasattr(results, 'news') and results.news:
|
||||
result['results']['news'] = [
|
||||
{
|
||||
'url': getattr(r, 'url', ''),
|
||||
'title': getattr(r, 'title', ''),
|
||||
'description': getattr(r, 'description', ''),
|
||||
'thumbnail_url': getattr(r, 'thumbnail_url', None),
|
||||
'page_age': getattr(r, 'page_age', None),
|
||||
}
|
||||
for r in results.news
|
||||
]
|
||||
|
||||
if hasattr(response, 'metadata'):
|
||||
meta = response.metadata
|
||||
result['metadata'] = {
|
||||
'query': getattr(meta, 'query', ''),
|
||||
'search_uuid': getattr(meta, 'search_uuid', ''),
|
||||
'latency': getattr(meta, 'latency', 0),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def get_contents(
|
||||
self,
|
||||
urls: Union[str, List[str]],
|
||||
format: str = "markdown",
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch content from URLs using You.com's content extraction API.
|
||||
|
||||
Args:
|
||||
urls: Single URL or list of URLs to fetch content from
|
||||
format: Content format - "html" or "markdown" (default: markdown)
|
||||
|
||||
Returns:
|
||||
Dict containing array of results with:
|
||||
- url: The fetched URL
|
||||
- title: Page title
|
||||
- html: HTML content (if format="html")
|
||||
- markdown: Markdown content (if format="markdown")
|
||||
- metadata: {site_name, favicon_url}
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
if isinstance(urls, str):
|
||||
urls = [urls]
|
||||
|
||||
# Use the contents endpoint
|
||||
response = client.search.contents(urls=urls, format=format)
|
||||
|
||||
# Convert response
|
||||
if hasattr(response, 'model_dump'):
|
||||
return {"results": response.model_dump()}
|
||||
elif isinstance(response, list):
|
||||
return {"results": [
|
||||
{
|
||||
'url': getattr(r, 'url', ''),
|
||||
'title': getattr(r, 'title', ''),
|
||||
'html': getattr(r, 'html', None),
|
||||
'markdown': getattr(r, 'markdown', None),
|
||||
'metadata': {
|
||||
'site_name': getattr(getattr(r, 'metadata', None), 'site_name', None) if hasattr(r, 'metadata') else None,
|
||||
'favicon_url': getattr(getattr(r, 'metadata', None), 'favicon_url', None) if hasattr(r, 'metadata') else None,
|
||||
}
|
||||
}
|
||||
for r in response
|
||||
]}
|
||||
return {"results": response}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com contents error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def live_news(
|
||||
self,
|
||||
query: str,
|
||||
count: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for live news using You.com's news API.
|
||||
|
||||
Note: This API may require early access. Contact [email protected] for access.
|
||||
|
||||
Args:
|
||||
query: News search query
|
||||
count: Maximum number of news results to return
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- news: {query, results: [...], metadata}
|
||||
- Each result has: title, description, url, page_age, source_name, thumbnail
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
kwargs = {"q": query}
|
||||
if count:
|
||||
kwargs["count"] = count
|
||||
|
||||
# Try to access live news endpoint
|
||||
if hasattr(client, 'livenews'):
|
||||
response = client.livenews.get(**kwargs)
|
||||
else:
|
||||
# Fallback: use unified search with news focus
|
||||
return self.search(query=query, count=count or 10)
|
||||
|
||||
if hasattr(response, 'model_dump'):
|
||||
return response.model_dump()
|
||||
elif hasattr(response, 'news'):
|
||||
news = response.news
|
||||
return {
|
||||
'news': {
|
||||
'query': getattr(getattr(news, 'query', None), 'original', query) if hasattr(news, 'query') else query,
|
||||
'results': [
|
||||
{
|
||||
'title': getattr(r, 'title', ''),
|
||||
'description': getattr(r, 'description', ''),
|
||||
'url': getattr(r, 'url', ''),
|
||||
'page_age': getattr(r, 'page_age', None),
|
||||
'age': getattr(r, 'age', None),
|
||||
'source_name': getattr(r, 'source_name', None),
|
||||
'thumbnail': getattr(getattr(r, 'thumbnail', None), 'src', None) if hasattr(r, 'thumbnail') else None,
|
||||
}
|
||||
for r in getattr(news, 'results', [])
|
||||
],
|
||||
'metadata': {
|
||||
'request_uuid': getattr(getattr(news, 'metadata', None), 'request_uuid', None) if hasattr(news, 'metadata') else None,
|
||||
}
|
||||
}
|
||||
}
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com live news error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def images(
|
||||
self,
|
||||
query: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for images using You.com's image search API.
|
||||
|
||||
Note: This API may require early access. Contact [email protected] for access.
|
||||
|
||||
Args:
|
||||
query: Image search query
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- images: {results: [...]}
|
||||
- metadata: {query, search_uuid}
|
||||
- Each result has: title, page_url, image_url
|
||||
"""
|
||||
client, error = self._get_client()
|
||||
if error:
|
||||
logging.error(error)
|
||||
return {"error": error}
|
||||
|
||||
try:
|
||||
if hasattr(client, 'images'):
|
||||
response = client.images.get(q=query)
|
||||
else:
|
||||
return {"error": "Image search not available in this SDK version"}
|
||||
|
||||
if hasattr(response, 'model_dump'):
|
||||
return response.model_dump()
|
||||
elif hasattr(response, 'images'):
|
||||
images = response.images
|
||||
return {
|
||||
'images': {
|
||||
'results': [
|
||||
{
|
||||
'title': getattr(r, 'title', ''),
|
||||
'page_url': getattr(r, 'page_url', ''),
|
||||
'image_url': getattr(r, 'image_url', ''),
|
||||
}
|
||||
for r in getattr(images, 'results', [])
|
||||
]
|
||||
},
|
||||
'metadata': {
|
||||
'query': getattr(getattr(response, 'metadata', None), 'query', query) if hasattr(response, 'metadata') else query,
|
||||
'search_uuid': getattr(getattr(response, 'metadata', None), 'search_uuid', None) if hasattr(response, 'metadata') else None,
|
||||
}
|
||||
}
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"You.com images error: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg}
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit."""
|
||||
if self._client is not None:
|
||||
# Close client if it has a close method
|
||||
if hasattr(self._client, 'close'):
|
||||
self._client.close()
|
||||
elif hasattr(self._client, '__exit__'):
|
||||
self._client.__exit__(exc_type, exc_val, exc_tb)
|
||||
return False
|
||||
|
||||
|
||||
# Standalone functions for direct import
|
||||
def ydc_search(
|
||||
query: str,
|
||||
count: int = 10,
|
||||
freshness: Optional[str] = None,
|
||||
country: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
safesearch: str = "moderate",
|
||||
livecrawl: Optional[str] = None,
|
||||
livecrawl_formats: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search the web using You.com's unified search API.
|
||||
|
||||
This is a convenience function that creates a YouTools instance.
|
||||
For repeated calls, consider using YouTools class directly.
|
||||
|
||||
Args:
|
||||
query: Search query (supports operators like site:, filetype:, +term, -term)
|
||||
count: Max results per section (default 10, max 100)
|
||||
freshness: Filter by recency - "day", "week", "month", "year"
|
||||
country: ISO country code (e.g., "US", "GB")
|
||||
language: BCP 47 language code (e.g., "EN", "JP")
|
||||
offset: Pagination offset (0-9)
|
||||
safesearch: Content filter - "off", "moderate", "strict"
|
||||
livecrawl: Fetch full content - "web", "news", or "all"
|
||||
livecrawl_formats: Content format - "html" or "markdown"
|
||||
|
||||
Returns:
|
||||
Dict containing search results with web and news sections
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.search(
|
||||
query=query,
|
||||
count=count,
|
||||
freshness=freshness,
|
||||
country=country,
|
||||
language=language,
|
||||
offset=offset,
|
||||
safesearch=safesearch,
|
||||
livecrawl=livecrawl,
|
||||
livecrawl_formats=livecrawl_formats,
|
||||
)
|
||||
|
||||
|
||||
def ydc_contents(
|
||||
urls: Union[str, List[str]],
|
||||
format: str = "markdown",
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch content from URLs using You.com's content extraction API.
|
||||
|
||||
Args:
|
||||
urls: Single URL or list of URLs to fetch content from
|
||||
format: Content format - "html" or "markdown"
|
||||
|
||||
Returns:
|
||||
Dict containing extracted content for each URL
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.get_contents(urls=urls, format=format)
|
||||
|
||||
|
||||
def ydc_news(
|
||||
query: str,
|
||||
count: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for live news using You.com's news API.
|
||||
|
||||
Args:
|
||||
query: News search query
|
||||
count: Maximum number of results
|
||||
|
||||
Returns:
|
||||
Dict containing news results
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.live_news(query=query, count=count)
|
||||
|
||||
|
||||
def ydc_images(
|
||||
query: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for images using You.com's image search API.
|
||||
|
||||
Args:
|
||||
query: Image search query
|
||||
|
||||
Returns:
|
||||
Dict containing image results
|
||||
"""
|
||||
tools = YouTools()
|
||||
return tools.images(query=query)
|
||||
|
||||
|
||||
# Alias for simple usage
|
||||
ydc = ydc_search
|
||||
|
||||
__all__ = ["YouTools", "ydc_search", "ydc_contents", "ydc_news", "ydc_images", "ydc"]
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-tools"
|
||||
version = "0.2.41"
|
||||
version = "0.2.46"
|
||||
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",
|
||||
]
|
||||
@@ -84,6 +85,9 @@ meeting = [
|
||||
"openai>=1.12.0",
|
||||
"chromadb>=0.4.0",
|
||||
]
|
||||
stripe = [
|
||||
"requests>=2.28.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://docs.praison.ai"
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,279 @@
|
||||
"""Unit tests for FirefliesTool."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.fireflies_tool import (
|
||||
FirefliesTool,
|
||||
list_fireflies_meetings,
|
||||
DEFAULT_API_URL,
|
||||
)
|
||||
|
||||
|
||||
def _mock_response(payload, status_code=200, headers=None):
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = payload
|
||||
resp.status_code = status_code
|
||||
resp.headers = headers or {}
|
||||
return resp
|
||||
|
||||
|
||||
# ── Configuration ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConfiguration:
|
||||
def test_api_key_from_arg(self):
|
||||
tool = FirefliesTool(api_key="ff_xyz")
|
||||
assert tool.api_key == "ff_xyz"
|
||||
|
||||
def test_api_key_from_env(self):
|
||||
with patch.dict(os.environ, {"FIREFLIES_API_KEY": "envkey"}, clear=True):
|
||||
tool = FirefliesTool()
|
||||
assert tool.api_key == "envkey"
|
||||
|
||||
def test_default_api_url(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
assert tool.api_url == DEFAULT_API_URL
|
||||
|
||||
def test_api_url_override_from_env(self):
|
||||
with patch.dict(
|
||||
os.environ, {"FIREFLIES_API_URL": "https://proxy.local/graphql"}, clear=True
|
||||
):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
assert tool.api_url == "https://proxy.local/graphql"
|
||||
|
||||
|
||||
# ── _graphql wiring ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGraphQL:
|
||||
def test_missing_key_returns_distinct_error(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = FirefliesTool()
|
||||
assert tool._graphql("query { x }") == {"error": "FIREFLIES_API_KEY required"}
|
||||
|
||||
def test_sends_bearer_header(self):
|
||||
tool = FirefliesTool(api_key="ff_xyz")
|
||||
with patch("requests.post") as post:
|
||||
post.return_value = _mock_response({"data": {"ok": True}})
|
||||
tool._graphql("query { x }", {"v": 1})
|
||||
kwargs = post.call_args.kwargs
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer ff_xyz"
|
||||
assert kwargs["json"] == {"query": "query { x }", "variables": {"v": 1}}
|
||||
|
||||
def test_maps_401_to_auth_error(self):
|
||||
tool = FirefliesTool(api_key="bad")
|
||||
with patch("requests.post", return_value=_mock_response({}, status_code=401)):
|
||||
result = tool._graphql("query { x }")
|
||||
assert result["status_code"] == 401
|
||||
assert "authentication failed" in result["error"]
|
||||
|
||||
def test_maps_403_to_auth_error(self):
|
||||
tool = FirefliesTool(api_key="bad")
|
||||
with patch("requests.post", return_value=_mock_response({}, status_code=403)):
|
||||
result = tool._graphql("query { x }")
|
||||
assert result["status_code"] == 403
|
||||
|
||||
def test_rate_limit_surfaces_retry_after(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
resp = _mock_response({}, status_code=429, headers={"Retry-After": "12"})
|
||||
with patch("requests.post", return_value=resp):
|
||||
result = tool._graphql("query { x }")
|
||||
assert result["status_code"] == 429
|
||||
assert result["retry_after"] == "12"
|
||||
|
||||
def test_graphql_errors_are_flattened(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
payload = {"errors": [{"message": "boom"}, {"message": "bad"}]}
|
||||
with patch("requests.post", return_value=_mock_response(payload)):
|
||||
assert tool._graphql("query { x }") == {"error": "boom; bad"}
|
||||
|
||||
def test_handles_request_exception(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
with patch("requests.post", side_effect=RuntimeError("net")):
|
||||
assert tool._graphql("q") == {"error": "net"}
|
||||
|
||||
def test_rejects_non_https_url(self):
|
||||
tool = FirefliesTool(api_key="x", api_url="http://insecure.local/graphql")
|
||||
with patch("requests.post") as post:
|
||||
result = tool._graphql("query { x }")
|
||||
post.assert_not_called()
|
||||
assert result == {"error": "FIREFLIES_API_URL must use HTTPS"}
|
||||
|
||||
def test_rate_limit_from_graphql_payload(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
payload = {
|
||||
"errors": [{"extensions": {"metadata": {"retryAfter": 30}}}]
|
||||
}
|
||||
resp = _mock_response(payload, status_code=429)
|
||||
with patch("requests.post", return_value=resp):
|
||||
result = tool._graphql("query { x }")
|
||||
assert result["status_code"] == 429
|
||||
assert result["retry_after"] == "30"
|
||||
|
||||
def test_generic_http_error_surfaces_status(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
resp = _mock_response({"data": {"transcripts": None}}, status_code=500)
|
||||
with patch("requests.post", return_value=resp):
|
||||
result = tool._graphql("query { x }")
|
||||
assert result["status_code"] == 500
|
||||
assert "HTTP 500" in result["error"]
|
||||
|
||||
|
||||
# ── list_recent_meetings ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListRecentMeetings:
|
||||
def test_returns_normalised_meetings(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
payload = {
|
||||
"data": {
|
||||
"transcripts": [
|
||||
{
|
||||
"id": "m1",
|
||||
"title": "Standup",
|
||||
"date": "2026-01-01",
|
||||
"duration": 15,
|
||||
"organizer_email": "[email protected]",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
with patch("requests.post", return_value=_mock_response(payload)):
|
||||
meetings = tool.list_recent_meetings(limit=5)
|
||||
assert len(meetings) == 1
|
||||
assert meetings[0]["title"] == "Standup"
|
||||
assert meetings[0]["organizer_email"] == "[email protected]"
|
||||
|
||||
def test_after_cursor_maps_to_skip(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
with patch(
|
||||
"requests.post", return_value=_mock_response({"data": {"transcripts": []}})
|
||||
) as post:
|
||||
tool.list_recent_meetings(limit=5, after="10")
|
||||
sent = post.call_args.kwargs["json"]["variables"]
|
||||
assert sent == {"limit": 5, "skip": 10}
|
||||
|
||||
def test_invalid_after_cursor(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
assert tool.list_recent_meetings(after="notint") == [
|
||||
{"error": "after must be an integer cursor"}
|
||||
]
|
||||
|
||||
def test_propagates_error(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
with patch("requests.post", side_effect=RuntimeError("net")):
|
||||
assert tool.list_recent_meetings() == [{"error": "net"}]
|
||||
|
||||
def test_limit_clamped_to_50(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
with patch(
|
||||
"requests.post", return_value=_mock_response({"data": {"transcripts": []}})
|
||||
) as post:
|
||||
tool.list_recent_meetings(limit=500)
|
||||
assert post.call_args.kwargs["json"]["variables"]["limit"] == 50
|
||||
|
||||
|
||||
# ── get_transcript ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetTranscript:
|
||||
def test_requires_meeting_id(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
assert tool.get_transcript(meeting_id="") == {"error": "meeting_id required"}
|
||||
|
||||
def test_returns_normalised_transcript(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
payload = {
|
||||
"data": {
|
||||
"transcript": {
|
||||
"id": "m1",
|
||||
"title": "Standup",
|
||||
"date": "2026-01-01",
|
||||
"duration": 15,
|
||||
"organizer_email": "[email protected]",
|
||||
"summary": {"overview": "We talked."},
|
||||
"sentences": [
|
||||
{"text": "Hi", "speaker_name": "A", "start_time": 0.0}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("requests.post", return_value=_mock_response(payload)):
|
||||
transcript = tool.get_transcript(meeting_id="m1")
|
||||
assert transcript["summary"] == "We talked."
|
||||
assert transcript["sentence_count"] == 1
|
||||
|
||||
def test_not_found(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
with patch(
|
||||
"requests.post",
|
||||
return_value=_mock_response({"data": {"transcript": None}}),
|
||||
):
|
||||
assert tool.get_transcript(meeting_id="missing") == {
|
||||
"error": "transcript not found"
|
||||
}
|
||||
|
||||
|
||||
# ── search ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSearch:
|
||||
def test_requires_keywords(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
assert tool.search(keywords="") == [{"error": "keywords required"}]
|
||||
|
||||
def test_returns_results(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
payload = {
|
||||
"data": {
|
||||
"transcripts": [
|
||||
{
|
||||
"id": "m1",
|
||||
"title": "Roadmap",
|
||||
"date": "2026-01-01",
|
||||
"organizer_email": "[email protected]",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
with patch("requests.post", return_value=_mock_response(payload)) as post:
|
||||
results = tool.search(keywords="roadmap", limit=3)
|
||||
sent = post.call_args.kwargs["json"]["variables"]
|
||||
assert sent == {"keyword": "roadmap", "limit": 3}
|
||||
assert results[0]["title"] == "Roadmap"
|
||||
|
||||
|
||||
# ── run() dispatcher ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRunDispatcher:
|
||||
def test_unknown_action(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
assert tool.run(action="bogus") == {"error": "Unknown action: bogus"}
|
||||
|
||||
def test_routes_get_transcript(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
with patch.object(tool, "get_transcript", return_value={"ok": True}) as m:
|
||||
out = tool.run(action="get-transcript", meeting_id="m1")
|
||||
m.assert_called_once_with(meeting_id="m1")
|
||||
assert out == {"ok": True}
|
||||
|
||||
def test_routes_search(self):
|
||||
tool = FirefliesTool(api_key="x")
|
||||
with patch.object(tool, "search", return_value=[]) as m:
|
||||
tool.run(action="search", keywords="hi", limit=5)
|
||||
m.assert_called_once_with(keywords="hi", limit=5)
|
||||
|
||||
|
||||
# ── Module-level helper ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListFirefliesMeetingsHelper:
|
||||
def test_delegates_to_tool(self):
|
||||
with patch.object(
|
||||
FirefliesTool, "list_recent_meetings", return_value=["x"]
|
||||
) as m:
|
||||
assert list_fireflies_meetings(limit=5) == ["x"]
|
||||
m.assert_called_once_with(limit=5)
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Unit tests for KeenableSearchTool."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.keenable_search_tool import (
|
||||
KEENABLE_MAX_RESULTS,
|
||||
KEENABLE_MAX_SNIPPET_CHARS,
|
||||
KEENABLE_MAX_TITLE_CHARS,
|
||||
KEENABLE_MAX_URL_CHARS,
|
||||
KEENABLE_PUBLIC_SEARCH_URL,
|
||||
KEENABLE_SEARCH_URL,
|
||||
KeenableSearchTool,
|
||||
_NoRedirect,
|
||||
keenable_search,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Minimal stand-in for an ``http.client.HTTPResponse`` context manager."""
|
||||
|
||||
def __init__(self, raw: bytes):
|
||||
self._raw = raw
|
||||
|
||||
def read(self, amt=None):
|
||||
"""Return at most ``amt`` bytes, honouring the caller's read cap."""
|
||||
if amt is None:
|
||||
return self._raw
|
||||
return self._raw[:amt]
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
|
||||
def _payload(results):
|
||||
return json.dumps({"results": results}).encode("utf-8")
|
||||
|
||||
|
||||
# ── Import + schema (CI smoke) ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestImportAndSchema:
|
||||
def test_import_via_package(self):
|
||||
from praisonai_tools import KeenableSearchTool as PkgTool
|
||||
|
||||
assert PkgTool is KeenableSearchTool
|
||||
|
||||
def test_schema_generation(self):
|
||||
tool = KeenableSearchTool()
|
||||
schema = tool.get_schema()
|
||||
assert schema["function"]["name"] == "keenable_search"
|
||||
assert "description" in schema["function"]
|
||||
assert "action" in schema["function"]["parameters"]["properties"]
|
||||
|
||||
|
||||
# ── Endpoint selection ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEndpointSelection:
|
||||
def test_public_endpoint_without_key(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = KeenableSearchTool()
|
||||
assert tool.api_key == ""
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_open(request, timeout=None):
|
||||
captured["url"] = request.full_url
|
||||
captured["headers"] = dict(request.header_items())
|
||||
return _FakeResponse(_payload([]))
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = fake_open
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
tool.search("python", max_results=3)
|
||||
|
||||
assert captured["url"] == KEENABLE_PUBLIC_SEARCH_URL
|
||||
header_names = {k.lower() for k in captured["headers"]}
|
||||
assert "x-api-key" not in header_names
|
||||
assert "X-keenable-title" in captured["headers"] or "X-Keenable-Title" in captured["headers"]
|
||||
|
||||
def test_authenticated_endpoint_with_key(self):
|
||||
tool = KeenableSearchTool(api_key="secret-key")
|
||||
captured = {}
|
||||
|
||||
def fake_open(request, timeout=None):
|
||||
captured["url"] = request.full_url
|
||||
captured["headers"] = dict(request.header_items())
|
||||
return _FakeResponse(_payload([]))
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = fake_open
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
tool.search("python", max_results=3)
|
||||
|
||||
assert captured["url"] == KEENABLE_SEARCH_URL
|
||||
# urllib title-cases header keys.
|
||||
assert captured["headers"].get("X-api-key") == "secret-key"
|
||||
|
||||
|
||||
# ── Result mapping + bounds ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResultMapping:
|
||||
def _run(self, tool, results):
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(_payload(results))
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
return tool.search("q", max_results=10)
|
||||
|
||||
def test_maps_fields(self):
|
||||
out = self._run(
|
||||
KeenableSearchTool(),
|
||||
[{"title": "T", "url": "https://example.com", "snippet": "hello world"}],
|
||||
)
|
||||
assert out == [{
|
||||
"title": "T",
|
||||
"url": "https://example.com",
|
||||
"snippet": "hello world",
|
||||
"provider": "keenable",
|
||||
}]
|
||||
|
||||
def test_skips_entries_without_url(self):
|
||||
out = self._run(
|
||||
KeenableSearchTool(),
|
||||
[{"title": "no url"}, {"title": "ok", "url": "https://a.com"}],
|
||||
)
|
||||
assert len(out) == 1
|
||||
assert out[0]["url"] == "https://a.com"
|
||||
|
||||
def test_respects_max_results(self):
|
||||
tool = KeenableSearchTool()
|
||||
many = [{"url": f"https://a.com/{i}"} for i in range(10)]
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(_payload(many))
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q", max_results=3)
|
||||
assert len(out) == 3
|
||||
|
||||
def test_zero_max_results_short_circuits(self):
|
||||
out = KeenableSearchTool().search("q", max_results=0)
|
||||
assert out == []
|
||||
|
||||
def test_empty_query_errors(self):
|
||||
out = KeenableSearchTool().search("", max_results=5)
|
||||
assert out == [{"error": "query is required"}]
|
||||
|
||||
|
||||
# ── Error handling ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
def test_invalid_shape_errors(self):
|
||||
tool = KeenableSearchTool()
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(b'{"not_results": 1}')
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out and "error" in out[0]
|
||||
|
||||
def test_oversized_response_errors(self):
|
||||
tool = KeenableSearchTool()
|
||||
oversized = b"x" * (1_000_001 + 1)
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(oversized)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out == [{"error": "Keenable returned an oversized response"}]
|
||||
|
||||
def test_http_error_is_captured(self):
|
||||
tool = KeenableSearchTool()
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = RuntimeError("boom")
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out == [{"error": "boom"}]
|
||||
|
||||
|
||||
# ── Protective bounds + redirect refusal ────────────────────────────
|
||||
|
||||
|
||||
class TestProtections:
|
||||
def _capture_body(self, tool, requested):
|
||||
"""Run a search and return the decoded JSON request body sent."""
|
||||
captured = {}
|
||||
|
||||
def fake_open(request, timeout=None):
|
||||
captured["body"] = json.loads(request.data.decode("utf-8"))
|
||||
return _FakeResponse(_payload([]))
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = fake_open
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
tool.search("q", max_results=requested)
|
||||
return captured["body"]
|
||||
|
||||
def test_caps_requested_results_at_limit(self):
|
||||
"""max_results above the hard cap is clamped before the request."""
|
||||
body = self._capture_body(KeenableSearchTool(), requested=1000)
|
||||
assert body["max_results"] == KEENABLE_MAX_RESULTS
|
||||
|
||||
def test_truncates_oversized_title(self):
|
||||
"""Titles longer than the cap are truncated with an ellipsis."""
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(
|
||||
_payload([{"title": "T" * 5000, "url": "https://a.com"}])
|
||||
)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = KeenableSearchTool().search("q")
|
||||
assert len(out[0]["title"]) == KEENABLE_MAX_TITLE_CHARS
|
||||
assert out[0]["title"].endswith("…")
|
||||
|
||||
def test_truncates_oversized_snippet(self):
|
||||
"""Snippets longer than the cap are truncated with an ellipsis."""
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(
|
||||
_payload([{"snippet": "s " * 5000, "url": "https://a.com"}])
|
||||
)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = KeenableSearchTool().search("q")
|
||||
assert len(out[0]["snippet"]) == KEENABLE_MAX_SNIPPET_CHARS
|
||||
assert out[0]["snippet"].endswith("…")
|
||||
|
||||
def test_skips_oversized_url(self):
|
||||
"""Results whose URL exceeds the length cap are dropped."""
|
||||
long_url = "https://a.com/" + "x" * KEENABLE_MAX_URL_CHARS
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(
|
||||
_payload([{"url": long_url}, {"url": "https://ok.com"}])
|
||||
)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = KeenableSearchTool().search("q")
|
||||
assert out == [{
|
||||
"title": "",
|
||||
"url": "https://ok.com",
|
||||
"snippet": "",
|
||||
"provider": "keenable",
|
||||
}]
|
||||
|
||||
def test_read_is_capped_at_limit(self):
|
||||
"""The body read is bounded, so an oversized stream still errors out."""
|
||||
tool = KeenableSearchTool()
|
||||
# Far larger than the cap; _FakeResponse.read honours the byte count,
|
||||
# so this verifies the read limit rather than a pre-sized buffer.
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(b"x" * 5_000_000)
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = tool.search("q")
|
||||
assert out == [{"error": "Keenable returned an oversized response"}]
|
||||
|
||||
def test_refuses_redirects(self):
|
||||
"""The redirect handler drops 3xx responses so no follow-up is sent."""
|
||||
handler = _NoRedirect()
|
||||
# Returning None is urllib's signal to NOT build/send a second request.
|
||||
assert handler.redirect_request(
|
||||
MagicMock(), MagicMock(), 302, "Found", {}, "https://evil.example"
|
||||
) is None
|
||||
|
||||
|
||||
# ── Module-level convenience function ───────────────────────────────
|
||||
|
||||
|
||||
class TestConvenienceFunction:
|
||||
def test_keenable_search_function(self):
|
||||
opener = MagicMock()
|
||||
opener.open.return_value = _FakeResponse(_payload([{"url": "https://a.com"}]))
|
||||
with patch("urllib.request.build_opener", return_value=opener):
|
||||
out = keenable_search("q", max_results=2)
|
||||
assert out[0]["provider"] == "keenable"
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Unit tests for RedditTool."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.reddit_tool import RedditTool
|
||||
|
||||
|
||||
# ── Import + schema (CI smoke) ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestImportAndSchema:
|
||||
def test_import_via_package(self):
|
||||
from praisonai_tools import RedditTool as PkgRedditTool
|
||||
|
||||
assert PkgRedditTool is RedditTool
|
||||
|
||||
def test_schema_generation(self):
|
||||
tool = RedditTool(client_id="cid", client_secret="secret")
|
||||
schema = tool.get_schema()
|
||||
assert schema["function"]["name"] == "reddit"
|
||||
assert "description" in schema["function"]
|
||||
assert "action" in schema["function"]["parameters"]["properties"]
|
||||
|
||||
|
||||
# ── User-Agent ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUserAgent:
|
||||
def test_default_user_agent_references_app_id(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = RedditTool(client_id="myappid", client_secret="secret")
|
||||
assert "myappid" in tool.user_agent
|
||||
|
||||
def test_explicit_user_agent_wins(self):
|
||||
tool = RedditTool(client_id="c", client_secret="s", user_agent="custom:ua:1.0")
|
||||
assert tool.user_agent == "custom:ua:1.0"
|
||||
|
||||
def test_env_user_agent_used(self):
|
||||
with patch.dict(os.environ, {"REDDIT_USER_AGENT": "env:ua:1.0"}, clear=True):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
assert tool.user_agent == "env:ua:1.0"
|
||||
|
||||
|
||||
# ── Write flag gating ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestWriteGating:
|
||||
def test_write_disabled_by_default(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
assert tool.allow_write is False
|
||||
|
||||
def test_env_enables_write(self):
|
||||
with patch.dict(os.environ, {"REDDIT_ALLOW_WRITE": "1"}, clear=True):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
assert tool.allow_write is True
|
||||
|
||||
def test_post_comment_blocked_when_write_disabled(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
result = tool.post_comment(post_id="abc", body="hi")
|
||||
assert "REDDIT_ALLOW_WRITE" in result["error"]
|
||||
|
||||
def test_submit_post_blocked_when_write_disabled(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
result = tool.submit_post(subreddit="test", title="T", body="B")
|
||||
assert "REDDIT_ALLOW_WRITE" in result["error"]
|
||||
|
||||
def test_write_requires_account_credentials(self):
|
||||
tool = RedditTool(client_id="c", client_secret="s", allow_write=True)
|
||||
result = tool.post_comment(post_id="abc", body="hi")
|
||||
assert "REDDIT_USERNAME" in result["error"]
|
||||
|
||||
|
||||
# ── Write actions (mocked praw) ─────────────────────────────────────
|
||||
|
||||
|
||||
def _writable_tool():
|
||||
return RedditTool(
|
||||
client_id="c",
|
||||
client_secret="s",
|
||||
username="u",
|
||||
password="p",
|
||||
allow_write=True,
|
||||
)
|
||||
|
||||
|
||||
class TestPostComment:
|
||||
def test_requires_post_id(self):
|
||||
tool = _writable_tool()
|
||||
assert tool.post_comment(post_id="", body="hi") == {"error": "post_id is required"}
|
||||
|
||||
def test_requires_body(self):
|
||||
tool = _writable_tool()
|
||||
assert tool.post_comment(post_id="abc", body="") == {"error": "body is required"}
|
||||
|
||||
def test_success(self):
|
||||
tool = _writable_tool()
|
||||
comment = MagicMock()
|
||||
comment.id = "c1"
|
||||
comment.permalink = "/r/test/comments/abc/x/c1/"
|
||||
submission = MagicMock()
|
||||
submission.reply.return_value = comment
|
||||
reddit = MagicMock()
|
||||
reddit.submission.return_value = submission
|
||||
with patch.object(RedditTool, "reddit", new=reddit):
|
||||
result = tool.post_comment(post_id="abc", body="hello")
|
||||
submission.reply.assert_called_once_with("hello")
|
||||
assert result["success"] is True
|
||||
assert result["id"] == "c1"
|
||||
|
||||
|
||||
class TestSubmitPost:
|
||||
def test_requires_subreddit(self):
|
||||
tool = _writable_tool()
|
||||
assert tool.submit_post(subreddit="", title="T") == {"error": "subreddit is required"}
|
||||
|
||||
def test_requires_title(self):
|
||||
tool = _writable_tool()
|
||||
assert tool.submit_post(subreddit="test", title="") == {"error": "title is required"}
|
||||
|
||||
def test_body_and_url_mutually_exclusive(self):
|
||||
tool = _writable_tool()
|
||||
result = tool.submit_post(subreddit="test", title="T", body="B", url="http://x")
|
||||
assert "not both" in result["error"]
|
||||
|
||||
def test_selftext_success(self):
|
||||
tool = _writable_tool()
|
||||
submission = MagicMock()
|
||||
submission.id = "p1"
|
||||
submission.permalink = "/r/test/comments/p1/"
|
||||
sub = MagicMock()
|
||||
sub.submit.return_value = submission
|
||||
reddit = MagicMock()
|
||||
reddit.subreddit.return_value = sub
|
||||
with patch.object(RedditTool, "reddit", new=reddit):
|
||||
result = tool.submit_post(subreddit="test", title="T", body="B")
|
||||
sub.submit.assert_called_once_with("T", selftext="B")
|
||||
assert result["id"] == "p1"
|
||||
|
||||
def test_link_success(self):
|
||||
tool = _writable_tool()
|
||||
submission = MagicMock()
|
||||
submission.id = "p2"
|
||||
submission.permalink = "/r/test/comments/p2/"
|
||||
sub = MagicMock()
|
||||
sub.submit.return_value = submission
|
||||
reddit = MagicMock()
|
||||
reddit.subreddit.return_value = sub
|
||||
with patch.object(RedditTool, "reddit", new=reddit):
|
||||
result = tool.submit_post(subreddit="test", title="T", url="http://x")
|
||||
sub.submit.assert_called_once_with("T", url="http://x")
|
||||
assert result["id"] == "p2"
|
||||
|
||||
|
||||
# ── run() dispatcher ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRunDispatcher:
|
||||
def test_unknown_action(self):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
assert tool.run(action="bogus") == {"error": "Unknown action: bogus"}
|
||||
|
||||
def test_routes_post_comment(self):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
with patch.object(tool, "post_comment", return_value={"ok": True}) as m:
|
||||
tool.run(action="post_comment", post_id="abc", body="hi")
|
||||
m.assert_called_once_with(post_id="abc", body="hi")
|
||||
|
||||
def test_routes_submit_post(self):
|
||||
tool = RedditTool(client_id="c", client_secret="s")
|
||||
with patch.object(tool, "submit_post", return_value={"ok": True}) as m:
|
||||
tool.run(action="submit-post", subreddit="test", title="T", body="B")
|
||||
m.assert_called_once_with(subreddit="test", title="T", body="B", url=None)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Unit tests for StripeTool."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.stripe_tool import StripeTool, get_stripe_customer
|
||||
|
||||
|
||||
def _mock_response(payload, status_code=200):
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = payload
|
||||
resp.status_code = status_code
|
||||
return resp
|
||||
|
||||
|
||||
# ── Key handling / mode banner ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestKeyAndMode:
|
||||
def test_env_secret_key_fallback(self):
|
||||
with patch.dict(os.environ, {"STRIPE_SECRET_KEY": "sk_test_x"}, clear=True):
|
||||
tool = StripeTool()
|
||||
assert tool.api_key == "sk_test_x"
|
||||
assert tool._mode() == "test"
|
||||
|
||||
def test_stripe_api_key_fallback(self):
|
||||
with patch.dict(os.environ, {"STRIPE_API_KEY": "sk_live_x"}, clear=True):
|
||||
tool = StripeTool()
|
||||
assert tool.api_key == "sk_live_x"
|
||||
assert tool._mode() == "live"
|
||||
|
||||
def test_restricted_key_prefixes(self):
|
||||
assert StripeTool(api_key="rk_test_x")._mode() == "test"
|
||||
assert StripeTool(api_key="rk_live_x")._mode() == "live"
|
||||
|
||||
def test_unknown_mode(self):
|
||||
assert StripeTool(api_key="weird")._mode() == "unknown"
|
||||
|
||||
def test_missing_key_returns_error(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
tool = StripeTool()
|
||||
assert tool.get_customer(customer_id="cus_1") == {
|
||||
"error": "STRIPE_SECRET_KEY (or STRIPE_API_KEY) is required"
|
||||
}
|
||||
|
||||
|
||||
# ── get_customer ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetCustomer:
|
||||
def test_requires_customer_id(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.get_customer(customer_id="") == {"error": "customer_id is required"}
|
||||
|
||||
def test_returns_normalised_customer_with_banner(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {
|
||||
"id": "cus_1",
|
||||
"email": "[email protected]",
|
||||
"name": "Alice",
|
||||
"created": 123,
|
||||
"currency": "usd",
|
||||
"delinquent": False,
|
||||
}
|
||||
with patch("requests.request", return_value=_mock_response(payload)) as req:
|
||||
result = tool.get_customer(customer_id="cus_1")
|
||||
|
||||
args, kwargs = req.call_args
|
||||
assert args[0] == "get"
|
||||
assert args[1].endswith("/customers/cus_1")
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer sk_test_x"
|
||||
assert result["mode"] == "test"
|
||||
assert result["id"] == "cus_1"
|
||||
assert result["email"] == "[email protected]"
|
||||
|
||||
def test_propagates_stripe_error(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {"error": {"message": "No such customer", "type": "invalid_request_error"}}
|
||||
with patch("requests.request", return_value=_mock_response(payload)):
|
||||
assert tool.get_customer(customer_id="cus_x") == {"error": "No such customer"}
|
||||
|
||||
def test_handles_request_exception(self):
|
||||
import requests
|
||||
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch(
|
||||
"requests.request",
|
||||
side_effect=requests.exceptions.RequestException("boom"),
|
||||
):
|
||||
result = tool.get_customer(customer_id="cus_1")
|
||||
assert result["error"].startswith("API request failed")
|
||||
|
||||
def test_non_2xx_without_error_object_is_failure(self):
|
||||
# A non-2xx response whose body lacks Stripe's usual ``error`` object
|
||||
# must not be treated as a successful payload.
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch(
|
||||
"requests.request",
|
||||
return_value=_mock_response({"unexpected": "shape"}, status_code=502),
|
||||
):
|
||||
result = tool.get_customer(customer_id="cus_1")
|
||||
assert result == {"error": "Stripe API returned HTTP 502"}
|
||||
|
||||
|
||||
# ── get_payment_intent ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetPaymentIntent:
|
||||
def test_requires_id(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.get_payment_intent(payment_intent_id="") == {
|
||||
"error": "payment_intent_id is required"
|
||||
}
|
||||
|
||||
def test_returns_normalised_intent(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {
|
||||
"id": "pi_1",
|
||||
"amount": 500,
|
||||
"currency": "usd",
|
||||
"status": "succeeded",
|
||||
"customer": "cus_1",
|
||||
"created": 1,
|
||||
}
|
||||
with patch("requests.request", return_value=_mock_response(payload)):
|
||||
result = tool.get_payment_intent(payment_intent_id="pi_1")
|
||||
assert result["status"] == "succeeded"
|
||||
assert result["amount"] == 500
|
||||
assert result["mode"] == "test"
|
||||
|
||||
|
||||
# ── list_payment_links ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListPaymentLinks:
|
||||
def test_returns_links(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {
|
||||
"data": [
|
||||
{"id": "plink_1", "url": "https://pay/1", "active": True, "metadata": {}},
|
||||
{"id": "plink_2", "url": "https://pay/2", "active": False, "metadata": {}},
|
||||
]
|
||||
}
|
||||
with patch("requests.request", return_value=_mock_response(payload)):
|
||||
result = tool.list_payment_links(limit=2)
|
||||
assert result["mode"] == "test"
|
||||
assert len(result["payment_links"]) == 2
|
||||
assert result["payment_links"][0]["id"] == "plink_1"
|
||||
|
||||
def test_clamps_limit(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch("requests.request", return_value=_mock_response({"data": []})) as req:
|
||||
tool.list_payment_links(limit=999)
|
||||
assert "limit=100" in req.call_args[0][1]
|
||||
|
||||
def test_propagates_error(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {"error": {"message": "bad"}}
|
||||
with patch("requests.request", return_value=_mock_response(payload)):
|
||||
assert tool.list_payment_links() == {"error": "bad"}
|
||||
|
||||
|
||||
# ── create_payment_link (guardrails) ────────────────────────────────
|
||||
|
||||
|
||||
class TestCreatePaymentLink:
|
||||
def test_requires_price_id(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.create_payment_link(
|
||||
price_id="", correlation_id="c1"
|
||||
) == {"error": "price_id is required"}
|
||||
|
||||
def test_requires_correlation_id(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.create_payment_link(price_id="price_1") == {
|
||||
"error": "correlation_id is required (stored as metadata)"
|
||||
}
|
||||
|
||||
def test_rejects_bad_quantity(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.create_payment_link(
|
||||
price_id="price_1", correlation_id="c1", quantity=0
|
||||
) == {"error": "quantity must be >= 1"}
|
||||
|
||||
def test_success_sends_metadata_and_idempotency_key(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
payload = {
|
||||
"id": "plink_1",
|
||||
"url": "https://pay/1",
|
||||
"active": True,
|
||||
"metadata": {"correlation_id": "order-42"},
|
||||
}
|
||||
with patch("requests.request", return_value=_mock_response(payload)) as req:
|
||||
result = tool.create_payment_link(
|
||||
price_id="price_1",
|
||||
quantity=2,
|
||||
correlation_id="order-42",
|
||||
idempotency_key="idem-1",
|
||||
)
|
||||
|
||||
args, kwargs = req.call_args
|
||||
assert args[0] == "post"
|
||||
assert args[1].endswith("/payment_links")
|
||||
assert kwargs["data"]["line_items[0][price]"] == "price_1"
|
||||
assert kwargs["data"]["line_items[0][quantity]"] == 2
|
||||
assert kwargs["data"]["metadata[correlation_id]"] == "order-42"
|
||||
assert kwargs["headers"]["Idempotency-Key"] == "idem-1"
|
||||
assert result["id"] == "plink_1"
|
||||
assert result["mode"] == "test"
|
||||
|
||||
|
||||
# ── run() dispatcher ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRunDispatcher:
|
||||
def test_unknown_action(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
assert tool.run(action="bogus") == {"error": "Unknown action: bogus"}
|
||||
|
||||
def test_routes_get_customer(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch.object(tool, "get_customer", return_value={"ok": True}) as m:
|
||||
tool.run(action="get_customer", customer_id="cus_1")
|
||||
m.assert_called_once_with(customer_id="cus_1")
|
||||
|
||||
def test_routes_create_payment_link(self):
|
||||
tool = StripeTool(api_key="sk_test_x")
|
||||
with patch.object(tool, "create_payment_link", return_value={"ok": True}) as m:
|
||||
tool.run(
|
||||
action="create-payment-link",
|
||||
price_id="price_1",
|
||||
quantity=1,
|
||||
correlation_id="c1",
|
||||
idempotency_key="i1",
|
||||
)
|
||||
m.assert_called_once_with(
|
||||
price_id="price_1", quantity=1, correlation_id="c1", idempotency_key="i1"
|
||||
)
|
||||
|
||||
|
||||
# ── Module-level helper ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHelper:
|
||||
def test_delegates_to_tool(self):
|
||||
with patch.object(StripeTool, "get_customer", return_value={"id": "cus_1"}) as m:
|
||||
assert get_stripe_customer("cus_1", api_key="sk_test_x") == {"id": "cus_1"}
|
||||
m.assert_called_once_with(customer_id="cus_1")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Unit tests for You.com tools (no network, no API key required)."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai_tools.tools.youdotcom_tool import (
|
||||
YouTools,
|
||||
ydc,
|
||||
ydc_search,
|
||||
ydc_contents,
|
||||
ydc_news,
|
||||
ydc_images,
|
||||
)
|
||||
|
||||
|
||||
_FIND_SPEC = "praisonai_tools.tools.youdotcom_tool.util.find_spec"
|
||||
|
||||
|
||||
def test_missing_api_key_returns_error(monkeypatch):
|
||||
monkeypatch.delenv("YDC_API_KEY", raising=False)
|
||||
with patch(_FIND_SPEC, return_value=object()):
|
||||
for fn, arg in (
|
||||
(ydc_search, "q"),
|
||||
(ydc_contents, "https://example.com"),
|
||||
(ydc_news, "q"),
|
||||
(ydc_images, "q"),
|
||||
):
|
||||
result = fn(arg)
|
||||
assert result == {
|
||||
"error": "YDC_API_KEY environment variable is not set. Please set it to use You.com tools."
|
||||
}
|
||||
|
||||
|
||||
def test_explicit_api_key_used_without_env(monkeypatch):
|
||||
monkeypatch.delenv("YDC_API_KEY", raising=False)
|
||||
fake_module = MagicMock()
|
||||
with patch(_FIND_SPEC, return_value=object()), patch.dict(
|
||||
"sys.modules", {"youdotcom": fake_module}
|
||||
):
|
||||
client, error = YouTools(api_key="k")._get_client()
|
||||
|
||||
assert error is None
|
||||
assert client is fake_module.You.return_value
|
||||
fake_module.You.assert_called_once_with(api_key_auth="k")
|
||||
|
||||
|
||||
def test_ydc_alias():
|
||||
assert ydc is ydc_search
|
||||
|
||||
|
||||
def test_search_uses_client_and_returns_dict():
|
||||
response = MagicMock()
|
||||
response.model_dump.return_value = {"results": {"web": [], "news": []}}
|
||||
client = MagicMock()
|
||||
client.search.unified.return_value = response
|
||||
|
||||
with patch.object(YouTools, "_get_client", return_value=(client, None)):
|
||||
result = YouTools().search("ai", count=3, freshness="week")
|
||||
|
||||
assert result == {"results": {"web": [], "news": []}}
|
||||
kwargs = client.search.unified.call_args.kwargs
|
||||
assert kwargs["query"] == "ai"
|
||||
assert kwargs["count"] == 3
|
||||
assert kwargs["freshness"] == "week"
|
||||
|
||||
|
||||
def test_search_exception_is_reported_as_error():
|
||||
client = MagicMock()
|
||||
client.search.unified.side_effect = RuntimeError("boom")
|
||||
|
||||
with patch.object(YouTools, "_get_client", return_value=(client, None)):
|
||||
result = YouTools().search("ai")
|
||||
|
||||
assert "boom" in result["error"]
|
||||
|
||||
|
||||
def test_discovered_via_package():
|
||||
import praisonai_tools.tools as tools_pkg
|
||||
|
||||
assert tools_pkg.YouTools is YouTools
|
||||
assert tools_pkg.ydc_search is ydc_search
|
||||
assert tools_pkg.ydc is ydc_search
|
||||
Reference in new issue
Block a user