Compare commits

...
Author SHA1 Message Date
MervinPraison 88307cf5b0 infra: add merge gate, release gate, and pipeline automation
Port PraisonAI gate scripts with gate-config.js for PraisonAIUI paths,
single-package release preflight, pipeline label sync, Opus merge gate,
CI failure Claude trigger, pypi-auto patch releases, and review-chain hooks.
2026-07-08 09:36:56 +01:00
MervinPraison 140dd3a443 fix(version): update aiui to version 0.3.127 in project files
Updated the version in pyproject.toml and uv.lock to reflect the latest release.
2026-07-05 06:21:52 +01:00
MervinPraison 20b29caae3 chore(version): update aiui version to 0.3.126
Bumped the version in pyproject.toml and uv.lock to reflect the latest release.
2026-06-29 10:06:23 +01:00
MervinPraison d6cff26261 feat(version): update aiui version to 0.3.125
Incremented the version of the aiui project to 0.3.125 in both
pyproject.toml and uv.lock files to reflect the latest release.
2026-06-29 10:05:57 +01:00
Mervin Praison 7018271836 Merge pull request #171 from MervinPraison/claude/issue-170-20260628-120000
feat: world-class kanban board — dense cards, editable drawer, debounced live updates
2026-06-29 10:05:26 +01:00
Mervin Praison 61e5fd04ed Merge pull request #169 from MervinPraison/claude/issue-167-20260626-120000
fix: harden channel auto-start lifecycle and running state
2026-06-29 10:04:59 +01:00
Mervin Praison 80deeb451a Merge pull request #168 from MervinPraison/claude/issue-166-20260626-000000
fix: dashboard buildDashboard insertBefore NotFoundError (#166)
2026-06-29 10:04:46 +01:00
MervinPraisonandClaude 9726411010 feat: enrich kanban cards, editable drawer, debounced live updates (fixes #170)
Gap 1: card DTO now carries tenant, created_at, comment_count and child
progress; cardElement renders dense chrome (checkbox, status dot, id,
priority badge, tenant chip, assignee, age, comment count, progress pill).

Gap 2: new POST /api/kanban/tasks/{id}/comments route plus add_comment on
the store protocol; drawer is now editable (title/assignee/priority),
markdown-rendered (XSS-safe), shows a comment thread with Enter-to-submit
and run/event history rows.

Gap 3: SSE consumer refetches on a 300ms debounce, the open drawer
live-refreshes on events for its task, and destructive moves/delete prompt
for confirmation.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-06-28 04:23:35 +00:00
MervinPraisonandClaude 7cb20bca32 fix: harden channel auto-start lifecycle and running state (fixes #167)
Gateway _channel_tasks may be a dict or a list depending on SDK version;
calling .append() on a dict raised 'dict' object has no attribute 'append'
during auto-start. Register defensively for both shapes and never let a
gateway bookkeeping failure abort an already-started bot.

Also force running=False whenever start_error is set (in _sync_running_status
and the auto-start failure paths) so the API no longer reports running=true
alongside an error, and log full tracebacks via logger.exception.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-06-26 06:20:46 +00:00
MervinPraisonandClaude 11898f2c7d fix: append main before insertBefore in dashboard buildDashboard (fixes #166)
buildDashboard() called root.insertBefore(shellHeader, main) before main was
a child of root, throwing NotFoundError and aborting the dashboard plugin
init, which blocked Ctrl+K and the browser chat input. Append main to root
first so it is a valid reference node for the guarded header insert.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-06-26 06:19:37 +00:00
MervinPraisonandCursor 0b62e7dcec feat: add PyPI Release workflow for aiui package
Manual workflow_dispatch release matching PraisonAI: patch bump,
uv publish, PyPI wait, commit, tag, and GitHub release. Update
instruction.md with PraisonAIUI publish steps.

Co-authored-by: Cursor <[email protected]>
2026-06-25 07:48:12 +01:00
MervinPraisonandCursor fdef44b303 feat: add SDK/product value gate to Claude FINAL review prompts
Centralise PraisonAIUI scope and phase text in workflow env vars and
require Claude to evaluate genuine UI value, correct layering vs
praisonaiagents, and reject scope creep before approving PRs.

Co-authored-by: Cursor <[email protected]>
2026-06-25 07:47:13 +01:00
Mervin Praison 596bb2684c Merge pull request #164 from MervinPraison/claude/issue-162-20260625-0601
fix: sync __version__.py to 0.3.124 (fixes #162)
2026-06-25 07:46:27 +01:00
Mervin Praison 047b86b89f Merge pull request #165 from MervinPraison/claude/issue-163-20260625-0601
feat: parallelize legacy /health and add short-TTL cache
2026-06-25 07:46:10 +01:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] f60f81e086 feat: parallelize legacy /health and add short-TTL cache (fixes #163)
Run provider.health() concurrently with feature checks under a single
bounded gather instead of awaiting the provider first, and cache the deep
health payload for a short TTL so repeat probes stay fast.

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-06-25 06:04:59 +00:00
praisonai-triage-agent[bot]andMervin Praison 62d20465b5 fix: sync __version__.py to 0.3.124 (fixes #162)
Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 06:02:41 +00:00
MervinPraison c2f8af6ad0 chore(release): bump version to 0.3.124
Updated version in pyproject.toml and uv.lock to reflect new release.
2026-06-24 23:50:34 +01:00
Mervin Praison 9bb312c8c4 Merge pull request #160 from MervinPraison/claude/issue-155-20260624-120000
feat: Ctrl+K session search in dashboard style (C-01, #155)
2026-06-24 23:50:10 +01:00
MervinPraison d07e41e69b fix(version): update aiui version to 0.3.123
Updated the version in pyproject.toml and uv.lock to reflect the new release.
2026-06-24 23:49:50 +01:00
MervinPraisonandClaude 043aa5c711 feat: port Ctrl+K session search to dashboard style (fixes #155)
Completes C-01 parity for the dashboard presentation plane. PR #145 shipped
SessionSearch in React layouts only, so the primary ops example
(15-dashboard-test, style="dashboard") never mounted React and Ctrl+K did
nothing. This adds a vanilla-JS command palette to dashboard.js (Ctrl+K /
Cmd+K toggle, debounced filter, arrow-key nav, Enter/click select, Esc close)
backed by GET /sessions, and wires chat.js to load the chosen session via an
aiui:session-select event.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-06-24 22:23:18 +00:00
MervinPraisonandCursor 4c5dadd255 fix: dedupe merge-conflict Claude triggers on main push bursts
Main pushes bypassed the 12h cooldown and concurrent merge runs each
posted @claude for every open DIRTY PR. Apply cooldown always, skip when
claude-conflict-pending label exists, cancel stale scans, and sync fork
handling plus FINAL review gate from PraisonAI.

Co-authored-by: Cursor <[email protected]>
2026-06-24 23:22:54 +01:00
Mervin Praison 86a6018c81 Merge pull request #156 from MervinPraison/claude/issue-153-20260624-0952
fix: sync __version__.py to 0.3.122 (fixes #153)
2026-06-24 23:18:29 +01:00
Mervin Praison 5f37843e3d Merge pull request #157 from MervinPraison/claude/issue-150-20260624-000000
fix(compiler): write all build output as UTF-8 (Windows cp1252)
2026-06-24 23:18:20 +01:00
Mervin Praison 4e2e14699c Merge pull request #158 from MervinPraison/claude/issue-151-20260624-000000
feat: implement /api/provider/health JSON route + guard /api/* catch-all
2026-06-24 23:18:06 +01:00
Mervin Praison 5e512d28da Merge pull request #159 from MervinPraison/claude/issue-152-20260624-0952
feat: parallelize legacy /health and migrate consumers to fast probes
2026-06-24 23:17:44 +01:00
Mervin Praison 420f3776d8 Merge pull request #161 from MervinPraison/claude/issue-154-20260624-0952
feat: MCP manager view + Ctrl+K command palette (B-08, B-02)
2026-06-24 23:17:08 +01:00
praisonai-triage-agent[bot]andMervin Praison 6afb282d76 feat: add MCP manager view and Ctrl+K command palette to dashboard (fixes #154)
Implements B-08 (MCP manager) and B-02 (command palette) from the OpenClaw
Control UI parity umbrella. MCP view lists/connects/disconnects servers via
the existing /api/mcp/* routes; command palette navigates pages via Ctrl+K.

Co-authored-by: Mervin Praison <[email protected]>
2026-06-24 09:58:56 +00:00
MervinPraisonandClaude faec8047b4 feat: implement /api/provider/health JSON route and guard /api/* catch-all (fixes #151)
Register a dedicated GET /api/provider/health handler before the SPA
catch-all so API clients receive JSON instead of dashboard HTML. Unmatched
/api/* paths now return a JSON 404 in both the static-mount fallback and the
path:path catch-all. Docs updated to match the implementation schema.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-06-24 09:57:43 +00:00
MervinPraisonandClaude 0d3baebaf3 fix(compiler): write all text output as UTF-8 (fixes #150)
Path.write_text/open default to the locale encoding (cp1252 on
Windows), causing UnicodeEncodeError when compiled HTML/JSON/CSS
contains non-ASCII content such as the U+2728 sparkle from docs.
Add explicit encoding="utf-8" to every text write in the build
and config pipeline.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-06-24 09:57:42 +00:00
praisonai-triage-agent[bot]andMervin Praison fe1bcd5e28 fix: sync __version__.py to 0.3.122 (fixes #153)
Co-authored-by: Mervin Praison <[email protected]>
2026-06-24 09:54:25 +00:00
45 changed files with 5384 additions and 131 deletions

No files matched your search

+17 -1
View File
@@ -138,7 +138,23 @@ Final rule: conclude only when evidence shows missing = 0.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PUBLISH WORKFLOW (reference)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Step 1 — Publish praisonaiagents (Core SDK):
GitHub Actions (recommended):
GitHub → Actions → "PyPI Release" → Run workflow → bump: patch
Requires repo secrets: PYPI_TOKEN, GH_TOKEN
Requires GitHub Environment "pypi" with required reviewers (Settings → Environments)
Manual — Publish aiui (PraisonAIUI):
cd /Users/praison/PraisonAIUI
# Bump version in pyproject.toml and src/praisonaiui/__version__.py
rm -rf dist && uv lock && uv build && uv publish --token "$PYPI_TOKEN"
git add pyproject.toml uv.lock src/praisonaiui/__version__.py
git commit -m "Release vX.Y.Z" && git tag vX.Y.Z && git push && git push --tags
gh release create vX.Y.Z --title "PraisonAIUI vX.Y.Z" --notes "Release vX.Y.Z" --latest
PyPI package name: aiui
Verify: pip index versions aiui | head -1
PraisonAI stack (separate repo — publish SDK before wrapper):
cd /Users/praison/praisonai-package/src/praisonai-agents
praisonai publish pypi
# Uses uv internally: uv lock → uv build → uv publish
+153
View File
@@ -0,0 +1,153 @@
/**
* Idempotent CodeRabbit/Qodo kick for bot-opened PRs.
* @see .github/workflows/auto-pr-comment.yml, claude.yml, bot-pr-recovery.yml
*/
const KICK_AUTHORS = new Set(['MervinPraison', 'github-actions[bot]']);
const BOT_PR_AUTHORS = new Set(['praisonai-triage-agent[bot]', 'github-actions[bot]']);
async function listAllComments(github, owner, repo, issueNumber) {
if (typeof github.paginate === 'function') {
return github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
}
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
return data;
}
function kickAuthored(comment, marker) {
return (
KICK_AUTHORS.has(comment.user?.login) &&
(comment.body || '').includes(marker)
);
}
function coderabbitKickPosted(comments) {
return comments.some((c) => kickAuthored(c, '@coderabbitai review'));
}
function qodoKickPosted(comments) {
return comments.some((c) => kickAuthored(c, '/review'));
}
function chainKickPosted(comments) {
return coderabbitKickPosted(comments) && qodoKickPosted(comments);
}
function isBotOpenedPr(pr) {
if (BOT_PR_AUTHORS.has(pr.user?.login)) return true;
return pr.user?.type === 'Bot';
}
async function kickReviewChain(github, owner, repo, prNumber, core, preFetchedComments = null) {
const comments = preFetchedComments || await listAllComments(github, owner, repo, prNumber);
const needCoderabbit = !coderabbitKickPosted(comments);
const needQodo = !qodoKickPosted(comments);
if (!needCoderabbit && !needQodo) {
core?.info?.(`Review chain already kicked on PR #${prNumber}`);
return { kicked: false, reason: 'already_kicked' };
}
if (needCoderabbit) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: '@coderabbitai review',
});
}
if (needQodo) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: '/review',
});
}
core?.info?.(`Kicked review chain for PR #${prNumber} (coderabbit=${needCoderabbit}, qodo=${needQodo})`);
return { kicked: true, coderabbit: needCoderabbit, qodo: needQodo };
}
async function findOpenPrForIssue(github, owner, repo, issueNumber) {
const prefix = `claude/issue-${issueNumber}-`;
const { data: prs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
sort: 'created',
direction: 'desc',
per_page: 30,
});
return (
prs.find(
(p) => (p.head?.ref || '').startsWith(prefix) && isBotOpenedPr(p)
) || null
);
}
async function kickReviewChainForIssue(github, owner, repo, issueNumber, core) {
const pr = await findOpenPrForIssue(github, owner, repo, issueNumber);
if (!pr) {
core?.info?.(`No open PR for issue #${issueNumber}, skipping review kick`);
return { kicked: false, reason: 'no_pr' };
}
const result = await kickReviewChain(github, owner, repo, pr.number, core);
return { ...result, prNumber: pr.number };
}
async function recoverStalledBotPrs(github, owner, repo, options, core) {
const { prNumber = null, minAgeMs = 10 * 60 * 1000, maxRecover = 10 } = options || {};
let prs;
if (prNumber) {
const { data } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
prs = [data];
} else if (typeof github.paginate === 'function') {
prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
} else {
const { data } = await github.rest.pulls.list({ owner, repo, state: 'open', per_page: 100 });
prs = data;
}
const cutoff = Date.now() - minAgeMs;
let recovered = 0;
for (const pr of prs) {
if (recovered >= maxRecover) break;
if (!isBotOpenedPr(pr)) continue;
if (!prNumber && new Date(pr.created_at).getTime() > cutoff) continue;
const comments = await listAllComments(github, owner, repo, pr.number);
if (chainKickPosted(comments)) continue;
await kickReviewChain(github, owner, repo, pr.number, core, comments);
recovered += 1;
}
core?.info?.(`Recovery complete (${recovered} PR(s) kicked)`);
return recovered;
}
module.exports = {
KICK_AUTHORS,
BOT_PR_AUTHORS,
listAllComments,
chainKickPosted,
coderabbitKickPosted,
qodoKickPosted,
isBotOpenedPr,
kickReviewChain,
findOpenPrForIssue,
kickReviewChainForIssue,
recoverStalledBotPrs,
};
@@ -0,0 +1,35 @@
#!/usr/bin/env node
/**
* Run: node .github/scripts/ci-failure-claude-selftest.js
*/
const ciFix = require('./ci-failure-claude.js');
const mergeGate = require('./merge-gate.js');
const config = require('./gate-config.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed += 1;
} else {
console.log('ok:', name);
}
}
const LOG = `
python UNKNOWN STEP FAILED (0.0100s) tests/unit/test_example.py::test_foo - AssertionError: bar
python UNKNOWN STEP ##[error]Process completed with exit code 1.
`;
const parsed = ciFix.parsePytestFailures(LOG);
assert('parses pytest failure', parsed.length === 1);
assert('uses CI workflow list', config.ciFailureWorkflowRuns.includes('CI'));
const comment = ciFix.buildCiFixComment({
headSha: 'abc1234567890abcdef1234567890abcdef12',
failedChecks: [{ name: 'python', workflow: 'CI', html_url: 'https://example.com/job/1' }],
failureSummaries: [{ jobName: 'python', failures: parsed }],
});
assert('comment mentions product guardrails', comment.includes('Product guardrails'));
process.exit(failed ? 1 : 0);
+294
View File
@@ -0,0 +1,294 @@
/**
* Post @claude comments with CI failure details for internal PRs.
* @see .github/workflows/ci-failure-claude.yml
*/
const mergeGate = require('./merge-gate.js');
const config = require('./gate-config.js');
const CI_FIX_LABEL = 'claude-ci-fix-pending';
const COOLDOWN_MS = 12 * 60 * 60 * 1000;
const AUTO_ACTORS = mergeGate.AUTO_ACTORS;
const MAX_FAILURES = 15;
const MAX_JOBS_TO_FETCH = 5;
function shortSha(headSha) {
return (headSha || '').slice(0, 8);
}
function ciFixShaMarker(headSha) {
return `ci failed on head \`${shortSha(headSha)}`.toLowerCase();
}
function isCiFixComment(comment) {
const body = (comment.body || '').toLowerCase();
return body.includes('@claude') && body.includes('ci failed on head');
}
function hasCiFixCommentForSha(comments, headSha) {
const marker = shortSha(headSha).toLowerCase();
return comments.some((c) => {
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!isCiFixComment(c)) return false;
return (c.body || '').toLowerCase().includes(marker);
});
}
function hasRecentCiFixComment(comments, headSha) {
const cutoff = Date.now() - COOLDOWN_MS;
const marker = shortSha(headSha).toLowerCase();
return comments.some((c) => {
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!isCiFixComment(c)) return false;
const body = (c.body || '').toLowerCase();
if (body.includes(marker)) return true;
return new Date(c.created_at).getTime() > cutoff;
});
}
function shouldSkipCiFix({ comments, headSha, labels, hasFinal, claudeInProgress, failedChecks }) {
if (!failedChecks.length) return { skip: true, reason: 'no failed checks' };
if (labels.includes(CI_FIX_LABEL)) return { skip: true, reason: 'ci fix pending' };
if (!hasFinal) return { skip: true, reason: 'awaiting final claude review trigger' };
if (claudeInProgress) return { skip: true, reason: 'claude in progress' };
if (hasCiFixCommentForSha(comments, headSha)) {
return { skip: true, reason: 'already commented for this sha' };
}
if (hasRecentCiFixComment(comments, headSha)) {
return { skip: true, reason: 'recent ci fix comment cooldown' };
}
return { skip: false };
}
function parsePytestFailures(logText) {
if (!logText) return [];
const lines = logText.split('\n');
const failures = [];
const seen = new Set();
for (let i = lines.length - 1; i >= 0 && failures.length < MAX_FAILURES; i -= 1) {
const line = lines[i].trim();
if (!line) continue;
const pytestMatch = line.match(
/FAILED\s*(?:\([^)]+\))?\s+(tests\/[^\s]+(?:::[^\s-]+)*)\s*-\s*(.+)/i
);
if (pytestMatch) {
const testId = pytestMatch[1];
if (!seen.has(testId)) {
seen.add(testId);
failures.push({ testId, error: pytestMatch[2].trim() });
}
continue;
}
const errorMatch = line.match(/##\[error\](.+)/);
if (errorMatch && /tests\//.test(errorMatch[1])) {
const msg = errorMatch[1].trim();
if (!seen.has(msg)) {
seen.add(msg);
failures.push({ testId: msg, error: msg });
}
}
}
return failures;
}
async function fetchJobFailureSummary(github, owner, repo, jobId, jobName) {
try {
const response = await github.rest.actions.downloadJobLogsForWorkflowRun({
owner,
repo,
job_id: jobId,
});
let logText = typeof response.data === 'string' ? response.data : String(response.data || '');
if (logText.length > 100000) {
logText = logText.slice(-100000);
}
return { jobName, jobId, failures: parsePytestFailures(logText) };
} catch (err) {
return { jobName, jobId, failures: [], error: err.message };
}
}
function mapFailedChecks(runs) {
return runs.map((run) => ({
name: run.name,
id: run.id,
html_url: run.html_url || run.details_url,
workflow: run.app?.slug || run.check_suite?.app?.slug || run.name,
}));
}
function buildCiFixComment({ headSha, failedChecks, failureSummaries }) {
const parts = [
`@claude CI failed on HEAD \`${shortSha(headSha)}\`. Please fix the failures below and push to this branch.`,
'',
'## Failed checks',
];
for (const check of failedChecks) {
parts.push(`- **${check.workflow || check.name}** / \`${check.name}\` — ${check.html_url}`);
}
parts.push('', '## Failures (extracted)');
let idx = 0;
for (const summary of failureSummaries) {
for (const failure of summary.failures) {
idx += 1;
if (idx > MAX_FAILURES) break;
parts.push(`${idx}. \`${failure.testId}\` — \`${failure.error}\``);
parts.push(` - Job: \`${summary.jobName}\``);
}
if (idx >= MAX_FAILURES) break;
}
if (idx === 0) {
parts.push('_(Could not extract pytest details — see job logs above.)_');
}
const exampleTest = failureSummaries.find((s) => s.failures.length)?.failures[0]?.testId;
parts.push(
'',
'## Critical review first',
'Before changing code or tests, decide **which side is wrong**:',
'1. **Legitimate feature change** — the PR intent is correct but implementation or tests need updating. Preserve product guarantees; update tests only when behaviour intentionally changed and document why.',
'2. **Regression / bug in this PR** — the failure exposes a real breakage introduced here. Fix the implementation; **do not weaken, skip, or delete tests** just to go green.',
'3. **Pre-existing flake or unrelated failure** — say so explicitly; prefer fixing the root cause over masking it.',
'',
'**Product guardrails (AGENTS.md):**',
'- Do not disturb PraisonAIUI contracts or frontend/backend layering to accommodate scope creep.',
'- Tests must continue to guard backward compatibility and UX behaviour — passing CI by lowering test standards is not acceptable.',
'- If the feature does not genuinely add product value, recommend reverting or narrowing scope instead of patching around failures.',
'',
'## What to do',
'1. State your verdict: **legitimate fix**, **regression fix**, or **needs human review** — and why (1–3 sentences).',
'2. Fix root cause with **minimal changes**; never bloat the Agent class with extra params.',
exampleTest
? `3. Run failing tests locally, e.g. \`pytest ${exampleTest} -q\`, plus any related tests touched by the PR.`
: '3. Run failing tests locally with targeted pytest, plus any related tests touched by the PR.',
'4. Push to this branch and comment: files changed, review verdict, and why tests still protect product behaviour.',
);
return parts.join('\n');
}
async function maybeClearCiFixLabel(github, owner, repo, prNumber, labels, headSha, core) {
if (!labels.includes(CI_FIX_LABEL)) return false;
const ciGreen = await mergeGate.allChecksGreenOnSha(github, owner, repo, headSha, core);
if (!ciGreen) return false;
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: CI_FIX_LABEL,
});
core?.info?.(`Removed ${CI_FIX_LABEL} from PR #${prNumber}`);
} catch (err) {
if (err.status !== 404) throw err;
}
return true;
}
async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts = {}) {
const baseRepo = `${owner}/${repo}`;
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
const pr = ctx.pr;
if (pr.state !== 'open') return { skipped: true, reason: 'not open' };
if (pr.draft) return { skipped: true, reason: 'draft' };
const headRepo = pr.head.repo?.full_name;
if (headRepo && headRepo !== baseRepo && !pr.maintainer_can_modify) {
return { skipped: true, reason: 'fork without maintainer edits' };
}
const headSha = pr.head.sha;
const labels = ctx.labels;
if (await maybeClearCiFixLabel(github, owner, repo, prNumber, labels, headSha, core)) {
return { skipped: true, reason: 'ci green, label cleared' };
}
const ciGreen = await mergeGate.allChecksGreenOnSha(github, owner, repo, headSha, core);
if (ciGreen) return { skipped: true, reason: 'ci green' };
const runs = await mergeGate.listChecksOnSha(github, owner, repo, headSha);
const failedRuns = mergeGate.listFailedChecksOnSha(runs);
if (failedRuns.length === 0) {
return { skipped: true, reason: 'no failed checks yet (may be pending)' };
}
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(ctx.comments);
const claudeInProgress = await mergeGate.hasInProgressClaudeAssistant(
github, owner, repo, prNumber
);
const skip = shouldSkipCiFix({
comments: ctx.comments,
headSha,
labels,
hasFinal,
claudeInProgress,
failedChecks: failedRuns,
});
if (skip.skip) return { skipped: true, reason: skip.reason };
const failedChecks = mapFailedChecks(failedRuns);
const failureSummaries = [];
for (const check of failedChecks.slice(0, MAX_JOBS_TO_FETCH)) {
failureSummaries.push(
await fetchJobFailureSummary(github, owner, repo, check.id, check.name)
);
}
const body = buildCiFixComment({ headSha, failedChecks, failureSummaries });
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [CI_FIX_LABEL],
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
core?.info?.(`Posted CI fix @claude on PR #${prNumber} (${shortSha(headSha)})`);
return { triggered: true, headSha: shortSha(headSha) };
}
async function processWorkflowRunFailure(github, owner, repo, workflowRun, core) {
if (workflowRun.conclusion !== 'failure') {
return { skipped: true, reason: `conclusion=${workflowRun.conclusion}` };
}
const allowed = config.ciFailureWorkflowRuns;
if (!allowed.includes(workflowRun.name)) {
return { skipped: true, reason: `workflow ${workflowRun.name}` };
}
const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(
github, owner, repo, workflowRun
);
if (!prNumber) return { skipped: true, reason: 'no linked PR' };
return maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
}
module.exports = {
CI_FIX_LABEL,
COOLDOWN_MS,
ciFixShaMarker,
isCiFixComment,
hasCiFixCommentForSha,
hasRecentCiFixComment,
shouldSkipCiFix,
parsePytestFailures,
fetchJobFailureSummary,
buildCiFixComment,
maybeClearCiFixLabel,
maybeTriggerCiFixClaude,
processWorkflowRunFailure,
};
+32
View File
@@ -0,0 +1,32 @@
/**
* PraisonAIUI gate configuration — imported by merge-gate, release-gate, ci-failure-claude.
*/
module.exports = {
repoFullName: 'MervinPraison/PraisonAIUI',
productPathPrefixes: ['src/praisonaiui/', 'src/frontend/', 'tests/'],
sensitivePathPatterns: [
/^\.github\/workflows\//,
/^pyproject\.toml$/,
/credentials\.json$/i,
],
requiredCheckPatterns: [/^ci$/i, /python/i, /test/i, /lint/i, /ruff/i],
ciWorkflowFile: 'ci.yml',
ciWorkflowName: 'CI',
mergeGateWorkflowRuns: ['CI', 'Claude Assistant'],
ciFailureWorkflowRuns: ['CI'],
pypiPackageName: 'aiui',
packagePaths: ['src/praisonaiui', 'src/frontend', 'pyproject.toml'],
finalClaudeScope:
'SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, src/frontend, tests, docs). '
+ 'Do NOT expand into praisonaiagents or the monorepo unless the PR explicitly requires it.',
finalClaudeProductValue:
'4. Product value: review whether the change genuinely adds PraisonAIUI UX value and correct '
+ 'layering (UI/frontend here; core agent logic belongs in praisonaiagents). Reject scope creep.',
agentPyChecks: false,
reviewBotLogins: [
'coderabbitai[bot]',
'qodo-code-review[bot]',
'greptile-apps[bot]',
],
};
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env node
/**
* Local self-test for merge-gate.js heuristics (no GitHub API).
* Run: node .github/scripts/merge-gate-selftest.js
*/
const mg = require('./merge-gate.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed++;
} else {
console.log('ok:', name);
}
}
// Stale FINAL: push after FINAL, no @claude since head
const finals = [
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T08:00:00Z' },
];
assert('stale when head after final', mg.isStaleFinalAfterPush(finals, '2026-06-12T09:00:00Z'));
const withRecovery = [
...finals,
{ user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T09:30:00Z' },
];
assert('not stale when @claude after head', !mg.isStaleFinalAfterPush(withRecovery, '2026-06-12T09:00:00Z'));
const withClaudeReply = [
...finals,
{
user: { login: 'praisonai-triage-agent[bot]' },
body: "**Claude finished @MervinPraison's task** —— [View job](https://github.com/)",
created_at: '2026-06-12T08:30:00Z',
},
];
assert('not stale when Claude replied after FINAL', !mg.isStaleFinalAfterPush(withClaudeReply, '2026-06-12T09:00:00Z'));
assert('claude final reply detected', mg.isClaudeFinalReplyComment(withClaudeReply[1]));
assert('cancelled detect-and-trigger does not block', mg.OPTIONAL_CANCELLED_CHECKS.has('detect-and-trigger'));
// Stale-FINAL recovery guards (PR #2560 push loop)
const nowMs = Date.now();
const iso = (ms) => new Date(ms).toISOString();
const firstFinal = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: iso(nowMs - 20 * 60 * 1000),
};
const pushSoonAfter = mg.shouldSkipStaleFinalRecovery(
[firstFinal],
iso(nowMs - 16 * 60 * 1000)
);
assert('debounce push soon after FINAL', pushSoonAfter.skip && pushSoonAfter.reason.includes('soon after'));
const secondFinal = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: iso(nowMs - 20 * 60 * 1000),
};
const capped = mg.shouldSkipStaleFinalRecovery(
[firstFinal, secondFinal],
iso(nowMs - 2 * 60 * 1000)
);
assert('hourly cap blocks third FINAL in window', capped.skip && capped.reason.includes('capped'));
const botPush = mg.shouldSkipStaleFinalRecovery(
[firstFinal],
iso(nowMs - 16 * 60 * 1000),
'praisonai-triage-agent[bot]'
);
assert('skip when automation pushed head', botPush.skip && botPush.reason.includes('automation'));
assert('claude automation login', mg.isClaudeAutomationLogin('praisonai-triage-agent[bot]'));
// Bot CHANGES_REQUESTED then APPROVE
const reviews = [
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T08:00:00Z' },
{ user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'APPROVED', submitted_at: '2026-06-12T09:00:00Z' },
];
assert('bot approve clears CR', !mg.hasAnyChangesRequested(reviews));
assert('human CR blocks', mg.hasAnyChangesRequested([
{ user: { login: 'MervinPraison', type: 'User' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T09:00:00Z' },
]));
// Verdict after HEAD
const verdictComments = [
{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T08:00:00Z' },
];
assert('verdict before head rejected', mg.findMergeGateVerdict(verdictComments, null, '2026-06-12T09:00:00Z') === null);
assert('verdict after head accepted', mg.findMergeGateVerdict(
[{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T10:00:00Z' }],
null,
'2026-06-12T09:00:00Z'
) === 'APPROVE');
const fallbackApprove = [
{
body: 'MERGE_GATE_VERDICT: APPROVE\n\nAutomated fallback — Claude assess did not post a verdict comment.',
created_at: '2026-06-12T10:00:00Z',
},
];
assert(
'automated fallback APPROVE ignored when Opus required',
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z', { excludeAutomatedFallback: true }) === null
);
assert(
'automated fallback APPROVE still visible without Opus-only filter',
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z') === 'APPROVE'
);
const fallbackBlockNewFormat = [
{
body: 'MERGE_GATE_VERDICT: BLOCK\n\nAutomated fallback — Opus merge gate assessment did not complete.',
created_at: '2026-06-12T10:00:00Z',
},
];
assert(
'new-format fallback BLOCK ignored when Opus required',
mg.findMergeGateVerdict(fallbackBlockNewFormat, null, '2026-06-12T09:00:00Z', { excludeAutomatedFallback: true }) === null
);
assert(
'new-format fallback BLOCK detected by marker helper',
mg.isAutomatedFallbackVerdict(fallbackBlockNewFormat[0].body)
);
assert(
'findMergeGateVerdict null-safe with explicit null options',
mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z', null) === 'APPROVE'
);
const noise = [{ user: { login: 'MervinPraison' }, body: '**Merge gate scan** — wait for `@claude`', created_at: new Date().toISOString() }];
assert('diagnostic comment not a trigger', !mg.hasRecentClaudeTrigger(noise, 35));
// Cooldown skip requires an actual verdict on HEAD, not just a FINAL trigger comment
const recentFinalOnHead = [
{
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: '2026-06-27T10:00:00Z',
},
];
assert(
'final trigger alone does not count as verdict on head',
mg.findMergeGateVerdict(recentFinalOnHead, null, '2026-06-27T09:55:00Z') === null
);
const recentVerdictOnHead = [
...recentFinalOnHead,
{
user: { login: 'github-actions[bot]' },
body: 'MERGE_GATE_VERDICT: APPROVE',
created_at: '2026-06-27T10:05:00Z',
},
];
assert(
'verdict on head skips cooldown gate',
mg.findMergeGateVerdict(recentVerdictOnHead, null, '2026-06-27T09:55:00Z') === 'APPROVE'
);
// Sensitive + secrets
assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1);
assert('ci-only label exempts workflows', mg.sensitivePathReasons(
[{ filename: '.github/workflows/foo.yml' }],
[mg.WORKFLOW_ONLY_LABEL]
).length === 0);
assert('ci-only label not exempt mixed changes', mg.sensitivePathReasons(
[{ filename: '.github/workflows/foo.yml' }, { filename: 'src/praisonaiui/foo.py' }],
[mg.WORKFLOW_ONLY_LABEL]
).length === 1);
assert('secret in patch', mg.secretScanReasons([{ filename: 'x.py', patch: '+key = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"' }]).length === 1);
// Claude run scoping — other PR branches must not block
assert('other branch claude does not block', !mg.hasBlockingClaudeRunForPr(
[{ event: 'issue_comment', head_branch: 'other-branch' }],
'my-branch'
));
assert('same branch claude blocks', mg.hasBlockingClaudeRunForPr(
[{ event: 'issue_comment', head_branch: 'my-branch' }],
'my-branch'
));
assert('issues event never blocks', !mg.hasBlockingClaudeRunForPr(
[{ event: 'issues', head_branch: 'my-branch' }],
'my-branch'
));
// Conflict rebase clears after bot completion + FINAL on HEAD (within 12h cooldown)
const conflictNowMs = Date.now();
const conflictIso = (offsetMs) => new Date(conflictNowMs + offsetMs).toISOString();
const conflictTrigger = {
user: { login: 'MervinPraison' },
body: '@claude this PR has merge conflicts with main. Please rebase',
created_at: conflictIso(-30 * 60 * 1000),
};
const rebaseDone = {
user: { login: 'praisonai-triage-agent[bot]' },
body: 'Rebase complete — PR #2308 onto latest main',
created_at: conflictIso(-29 * 60 * 1000),
};
const finalAfterRebase = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: conflictIso(-20 * 60 * 1000),
};
const headAfterRebase = conflictIso(-21 * 60 * 1000);
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment(
[conflictTrigger, rebaseDone],
headAfterRebase
));
// Tests heuristic
assert('product code without tests', mg.missingTestsReason([{ filename: 'src/praisonaiui/a/b.py', additions: 3 }]) !== null);
assert('product code with tests ok', mg.missingTestsReason([
{ filename: 'src/praisonaiui/a/b.py', additions: 3 },
{ filename: 'tests/test_x.py', additions: 10 },
]) === null);
// PR size
assert('large PR blocked', mg.prSizeReasons([{ additions: 900 }]).length > 0);
assert('internal PR link accepted', mg.isInternalPullRequestLink(
{ base: { repo: { full_name: 'MervinPraison/PraisonAIUI' } } },
'MervinPraison',
'PraisonAIUI'
));
assert('fork sync PR link rejected', !mg.isInternalPullRequestLink(
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAIUI' } } },
'MervinPraison',
'PraisonAIUI'
));
process.exit(failed ? 1 : 0);
+900
View File
@@ -0,0 +1,900 @@
/**
* Shared merge-gate helpers for claude-merge-gate.yml
* @see .github/workflows/claude-merge-gate.yml
*/
const config = require('./gate-config.js');
const CLAUDE_TRIGGER_LOGINS = ['MervinPraison', 'github-actions[bot]'];
const AUTO_ACTORS = CLAUDE_TRIGGER_LOGINS;
const CONFLICT_COOLDOWN_MS = 12 * 60 * 60 * 1000;
const CLAUDE_ACTIVE_MS = 35 * 60 * 1000;
const STALE_FINAL_RECOVERY_WINDOW_MS = 60 * 60 * 1000;
const STALE_FINAL_MAX_PER_WINDOW = 2;
const PUSH_AFTER_FINAL_DEBOUNCE_MS = 15 * 60 * 1000;
const ALLOWED_MERGE_STATES = new Set(['CLEAN', 'UNSTABLE']);
const AGENT_PY_MAX_AUTO_LINES = 100;
const PR_MAX_AUTO_ADDITIONS = 800;
const PR_MAX_AUTO_FILES = 30;
const MANUAL_ONLY_LABELS = new Set(['security', 'breaking-change', 'needs-manual-review', 'release']);
const WORKFLOW_ONLY_LABEL = 'merge-gate-ci-only';
const CI_ONLY_PATH_PREFIXES = ['.github/workflows/', '.github/actions/', '.github/scripts/merge-gate'];
const SDK_PATH_PREFIXES = config.productPathPrefixes;
const SENSITIVE_PATH_PATTERNS = config.sensitivePathPatterns;
const REQUIRED_SDK_CHECK_PATTERNS = config.requiredCheckPatterns;
const SECRET_PATTERNS = [
/sk-[a-zA-Z0-9]{20,}/,
/AKIA[0-9A-Z]{16}/,
/ghp_[a-zA-Z0-9]{36,}/,
/github_pat_/,
/Bearer eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/,
/-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/,
];
const BLOCK_LABELS = new Set([
'claude-conflict-pending',
'claude-merge-gate-active',
'no-auto-merge',
'auto-merged-by-gate',
]);
const MERGE_READY_LABEL = 'pipeline/merge-ready';
/** Superseded concurrency runs; must not block merge when real tests passed. */
const OPTIONAL_CANCELLED_CHECKS = new Set(['detect-and-trigger']);
const BOT_REVIEWER_PATTERNS = [
'coderabbit',
'qodo',
'gemini',
'copilot',
'greptile',
];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function isFinalClaudeTriggerComment(c) {
const body = (c.body || '').toLowerCase();
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!body.includes('@claude')) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
}
function isClaudeTriggerNoise(c) {
const body = c.body || '';
if (body.includes('Merge gate scan')) return true;
if (body.includes('MERGE_GATE_VERDICT')) return true;
if (body.includes('Merged by **Claude PR merge gate**')) return true;
return false;
}
function isClaudeFinalReplyComment(c) {
const login = (c.user?.login || '').toLowerCase();
if (!login.includes('praisonai-triage')) return false;
return (c.body || '').includes('Claude finished');
}
function hasRecentClaudeTrigger(comments, minutes = 35) {
const cutoff = Date.now() - minutes * 60 * 1000;
return comments.some((c) => {
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
if (isClaudeTriggerNoise(c)) return false;
if (!(c.body || '').includes('@claude')) return false;
return new Date(c.created_at).getTime() > cutoff;
});
}
function isConflictRebaseTriggerComment(c) {
if (!AUTO_ACTORS.includes(c.user.login)) return false;
const body = (c.body || '').toLowerCase();
return body.includes('@claude') && body.includes('merge conflict');
}
function isConflictRebaseCompletionComment(c) {
const login = (c.user?.login || '').toLowerCase();
if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
const body = (c.body || '').toLowerCase();
return (
body.includes('rebase complete') ||
body.includes('rebase onto') ||
(body.includes('conflict') && body.includes('resolved'))
);
}
function conflictRebaseQuiescent(comments, headPushedAt) {
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return true;
const latestConflict = conflictTriggers.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const conflictTime = new Date(latestConflict.created_at).getTime();
const rebaseDone = comments.some(
(c) =>
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
);
if (!rebaseDone) return false;
return finalClaudeCompletedOnSha(comments, headPushedAt);
}
function hasRecentConflictComment(comments, headPushedAt = null) {
const cutoff = Date.now() - CONFLICT_COOLDOWN_MS;
const hasRecentTrigger = comments.some((c) => {
if (!isConflictRebaseTriggerComment(c)) return false;
return new Date(c.created_at).getTime() > cutoff;
});
if (!hasRecentTrigger) return false;
if (headPushedAt && conflictRebaseQuiescent(comments, headPushedAt)) {
return false;
}
return true;
}
function isBotReviewer(login, userType) {
const lower = (login || '').toLowerCase();
if (userType === 'Bot') return true;
if (lower.endsWith('[bot]')) return true;
return ['coderabbit', 'qodo', 'gemini', 'copilot', 'greptile'].some((p) => lower.includes(p));
}
function latestReviewsByUser(reviews) {
const latestByUser = new Map();
for (const r of reviews) {
const login = r.user?.login;
if (!login) continue;
const prev = latestByUser.get(login);
if (!prev || new Date(r.submitted_at) > new Date(prev.submitted_at)) {
latestByUser.set(login, r);
}
}
return latestByUser;
}
function hasHumanChangesRequested(reviews) {
for (const [login, review] of latestReviewsByUser(reviews)) {
if (review.state !== 'CHANGES_REQUESTED') continue;
if (!isBotReviewer(login, review.user?.type)) return true;
}
return false;
}
function hasAnyChangesRequested(reviews) {
for (const [, review] of latestReviewsByUser(reviews)) {
if (review.state === 'CHANGES_REQUESTED') return true;
}
return false;
}
function hasFinalClaudeReviewTrigger(comments) {
return comments.some(isFinalClaudeTriggerComment);
}
function isStaleFinalAfterPush(comments, headPushedAt) {
if (!headPushedAt) return false;
const headTime = new Date(headPushedAt).getTime();
const finals = comments.filter(isFinalClaudeTriggerComment);
if (finals.length === 0) return true;
const latestFinal = finals.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const finalTime = new Date(latestFinal.created_at).getTime();
if (headTime <= finalTime + 60000) return false;
const claudeRepliedAfterFinal = comments.some((c) => {
if (!isClaudeFinalReplyComment(c)) return false;
return new Date(c.created_at).getTime() >= finalTime - 60000;
});
if (claudeRepliedAfterFinal) return false;
const claudeSinceHead = comments.some((c) => {
if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false;
if (isClaudeTriggerNoise(c)) return false;
if (!(c.body || '').includes('@claude')) return false;
return new Date(c.created_at).getTime() >= headTime - 60000;
});
return !claudeSinceHead;
}
function needsStaleFinalRecovery(comments, headPushedAt) {
return (
hasFinalClaudeReviewTrigger(comments) &&
isStaleFinalAfterPush(comments, headPushedAt)
);
}
function shouldSkipFinalRecovery(comments, headPushedAt) {
const isStale = isStaleFinalAfterPush(comments, headPushedAt);
if (isStale) return false;
return hasRecentClaudeTrigger(comments, 35);
}
function countFinalTriggersSince(comments, sinceMs) {
return comments.filter(
(c) => isFinalClaudeTriggerComment(c) && new Date(c.created_at).getTime() > sinceMs
).length;
}
function isClaudeAutomationLogin(login) {
const lower = (login || '').toLowerCase();
return lower.includes('praisonai-triage') || lower === 'github-actions[bot]';
}
function isPushSoonAfterLatestFinal(comments, headPushedAt) {
if (!headPushedAt) return false;
const finals = comments.filter(isFinalClaudeTriggerComment);
if (finals.length === 0) return false;
const latestFinal = finals.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const finalTime = new Date(latestFinal.created_at).getTime();
const headTime = new Date(headPushedAt).getTime();
if (headTime <= finalTime) return false;
return headTime - finalTime < PUSH_AFTER_FINAL_DEBOUNCE_MS;
}
/** Returns { skip: true, reason } when stale-FINAL recovery should not post. */
function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin = null) {
if (!isStaleFinalAfterPush(comments, headPushedAt)) {
return { skip: true, reason: 'not stale' };
}
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
return { skip: true, reason: 'head pushed by Claude automation' };
}
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
return {
skip: true,
reason: 'head pushed soon after FINAL (wait for CI / batched fixes)',
};
}
const windowStart = Date.now() - STALE_FINAL_RECOVERY_WINDOW_MS;
const finalsInWindow = countFinalTriggersSince(comments, windowStart);
if (finalsInWindow >= STALE_FINAL_MAX_PER_WINDOW) {
return {
skip: true,
reason: `stale-FINAL capped (${finalsInWindow} FINAL triggers in last hour)`,
};
}
return { skip: false, reason: '' };
}
function finalClaudeCompletedOnSha(comments, headPushedAt) {
if (!hasFinalClaudeReviewTrigger(comments)) return false;
if (isStaleFinalAfterPush(comments, headPushedAt)) return false;
return true;
}
const FINAL_CLAUDE_REVIEW_BODY =
`@claude You are the FINAL architecture reviewer. If the branch is under ${config.repoFullName} (not a fork), you are able to make modifications to this branch and push directly. ${config.finalClaudeScope} Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**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)\n${config.finalClaudeProductValue}\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`;
async function getMergeState(github, owner, repo, prNumber) {
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
mergeStateStatus
maintainerCanModify
isDraft
headRefOid
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,
headSha: prGql.headRefOid,
maintainerCanModify: prGql.maintainerCanModify === true,
};
}
if (attempt < 2) await sleep(10000);
}
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
return {
status: (pr.mergeable_state || '').toUpperCase(),
isDraft: pr.draft,
headRepo: pr.head.repo?.full_name,
headSha: pr.head.sha,
maintainerCanModify: pr.maintainer_can_modify === true,
};
}
async function listChecksOnSha(github, owner, repo, sha) {
const { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: sha,
per_page: 100,
});
return (data.check_runs || []).filter((r) => r.head_sha === sha);
}
function listFailedChecksOnSha(runs) {
return (runs || []).filter((run) => {
if (run.status !== 'completed') return false;
return run.conclusion === 'failure';
});
}
async function allChecksGreenOnSha(github, owner, repo, sha, core) {
const runs = await listChecksOnSha(github, owner, repo, sha);
if (runs.length === 0) {
core?.info?.(`No check runs on ${sha.slice(0, 7)} — allowing (e.g. docs-only PR)`);
return true;
}
for (const run of runs) {
if (run.status !== 'completed') {
core?.info?.(`Check pending: ${run.name} (${run.status})`);
return false;
}
const ok =
['success', 'neutral', 'skipped'].includes(run.conclusion) ||
(run.conclusion === 'cancelled' && OPTIONAL_CANCELLED_CHECKS.has(run.name));
if (!ok) {
core?.info?.(`Check failed: ${run.name} (${run.conclusion})`);
return false;
}
}
return true;
}
function claudeRunBlocksPr(run, headRef) {
if (!run || run.event === 'issues') return false;
if (!headRef) return true;
return run.head_branch === headRef;
}
function hasBlockingClaudeRunForPr(runs, headRef) {
return (runs || []).some((r) => claudeRunBlocksPr(r, headRef));
}
async function hasInProgressClaudeAssistant(github, owner, repo, prNumber = null) {
try {
const { data } = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'claude.yml',
status: 'in_progress',
per_page: 20,
});
const runs = (data.workflow_runs || []).filter((r) => r.event !== 'issues');
if (runs.length === 0) return false;
if (prNumber == null) return runs.length > 0;
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const headRef = pr.head.ref;
return hasBlockingClaudeRunForPr(runs, headRef);
} catch {
return false;
}
}
function isCiOnlyChange(files) {
if (!files.length) return false;
return files.every((f) => CI_ONLY_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
}
function isInternalPullRequestLink(link, owner, repo) {
if (link?.base?.repo?.full_name === `${owner}/${repo}`) return true;
const baseUrl = link?.base?.repo?.url || '';
return baseUrl.endsWith(`/repos/${owner}/${repo}`);
}
async function resolvePrNumberFromLinkedPullRequests(github, owner, repo, linked) {
const repoFull = `${owner}/${repo}`;
const internal = (linked || []).filter((l) => isInternalPullRequestLink(l, owner, repo));
for (const link of internal) {
if (!link.number) continue;
try {
const { data } = await github.rest.pulls.get({
owner,
repo,
pull_number: link.number,
});
if (data.state === 'open' && data.base?.repo?.full_name === repoFull) {
return data.number;
}
} catch (err) {
if (err.status !== 404) throw err;
}
}
return null;
}
async function resolvePrNumberFromHeadBranch(github, owner, repo, branch) {
if (!branch || branch === 'main' || branch === 'master') return null;
const { data } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
head: `${owner}:${branch}`,
per_page: 1,
});
return data[0]?.number || null;
}
async function resolvePrNumberFromWorkflowRun(github, owner, repo, workflowRun) {
const fromLinked = await resolvePrNumberFromLinkedPullRequests(
github, owner, repo, workflowRun.pull_requests
);
if (fromLinked) return fromLinked;
const fromBranch = await resolvePrNumberFromHeadBranch(
github, owner, repo, workflowRun.head_branch
);
if (fromBranch) return fromBranch;
return null;
}
async function listPullFiles(github, owner, repo, prNumber) {
if (typeof github.paginate === 'function') {
return github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
}
const { data } = await github.rest.pulls.listFiles({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
return data;
}
function countNewAgentParams(patch) {
if (!patch) return 0;
return patch
.split('\n')
.filter((l) => l.startsWith('+') && !l.startsWith('+++'))
.filter((l) => {
const t = l.slice(1).trim();
if (!t || t.startsWith('#') || t.startsWith('"""') || t.startsWith("'''")) return false;
if (/^(def|class|@|return\b|if\b|elif\b|else\b|for\b|while\b)/.test(t)) return false;
if (/^\w+\s*[:=]/.test(t) && !t.startsWith('self.')) return true;
return false;
}).length;
}
function getAgentPyChangeFromFiles(files) {
const agentFile = files.find((f) => f.filename.endsWith('praisonaiagents/agent/agent.py'));
if (!agentFile) {
return { touched: false, additions: 0, newParams: 0 };
}
return {
touched: true,
additions: agentFile.additions || 0,
newParams: countNewAgentParams(agentFile.patch || ''),
};
}
async function getAgentPyChange(github, owner, repo, prNumber) {
const files = await listPullFiles(github, owner, repo, prNumber);
return getAgentPyChangeFromFiles(files);
}
function touchesSdk(files) {
return files.some((f) => SDK_PATH_PREFIXES.some((p) => f.filename.startsWith(p)));
}
function hasManualOnlyLabel(labels) {
return labels.some((l) => MANUAL_ONLY_LABELS.has(l));
}
function sensitivePathReasons(files, labels = []) {
if (labels.includes(WORKFLOW_ONLY_LABEL) && isCiOnlyChange(files)) {
return [];
}
const reasons = [];
for (const f of files) {
if (SENSITIVE_PATH_PATTERNS.some((p) => p.test(f.filename))) {
reasons.push(`sensitive path: ${f.filename}`);
break;
}
}
return reasons;
}
function prSizeReasons(files) {
const reasons = [];
const totalAdditions = files.reduce((sum, f) => sum + (f.additions || 0), 0);
if (totalAdditions > PR_MAX_AUTO_ADDITIONS) {
reasons.push(`PR +${totalAdditions} lines (>${PR_MAX_AUTO_ADDITIONS}) requires manual review`);
}
if (files.length > PR_MAX_AUTO_FILES) {
reasons.push(`${files.length} files changed (>${PR_MAX_AUTO_FILES}) requires manual review`);
}
return reasons;
}
function missingTestsReason(files) {
const productAdds = files.filter(
(f) =>
SDK_PATH_PREFIXES.some((p) => f.filename.startsWith(p)) &&
f.filename.startsWith('src/praisonaiui/') &&
f.filename.endsWith('.py') &&
!f.filename.endsWith('__init__.py') &&
(f.additions || 0) > 0
);
if (productAdds.length === 0) return null;
const hasTestChange = files.some(
(f) => /\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename) || /_test\.py$/.test(f.filename)
);
if (!hasTestChange) return 'Product code added without test file changes — requires manual review';
return null;
}
function secretScanReasons(files) {
for (const f of files) {
if (/\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename)) continue;
const patch = f.patch || '';
if (!patch) continue;
for (const pattern of SECRET_PATTERNS) {
if (pattern.test(patch)) {
return [`possible secret in diff (${f.filename}) — requires manual review`];
}
}
}
return [];
}
async function sdkTestChecksReason(github, owner, repo, sha, files, core) {
if (!touchesSdk(files)) return null;
const { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: sha,
per_page: 100,
});
const runs = (data.check_runs || []).filter((r) => r.head_sha === sha);
if (runs.length === 0) {
return 'SDK code changed but no CI checks on HEAD';
}
const testRuns = runs.filter((r) => REQUIRED_SDK_CHECK_PATTERNS.some((p) => p.test(r.name || '')));
if (testRuns.length === 0) {
return 'SDK code changed but no test check runs on HEAD';
}
core?.info?.(`SDK test checks on HEAD: ${testRuns.map((r) => r.name).join(', ')}`);
return null;
}
function manualReviewReasonForAgentPy(agentChange) {
if (!agentChange.touched) return null;
if (agentChange.additions > AGENT_PY_MAX_AUTO_LINES) {
return `agent.py +${agentChange.additions} lines (>${AGENT_PY_MAX_AUTO_LINES}) requires manual review`;
}
if (agentChange.newParams > 0) {
return `agent.py adds ${agentChange.newParams} new Agent param(s) — requires manual review`;
}
return null;
}
async function listAllComments(github, owner, repo, issueNumber) {
if (typeof github.paginate === 'function') {
return github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
}
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
return data;
}
async function getHeadCommitDate(github, owner, repo, prNumber) {
try {
let commits;
if (typeof github.paginate === 'function') {
commits = await github.paginate(github.rest.pulls.listCommits, {
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
} else {
const { data } = await github.rest.pulls.listCommits({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
commits = data;
}
const last = commits[commits.length - 1];
return last?.commit?.committer?.date || last?.commit?.author?.date || null;
} catch {
return null;
}
}
async function loadPrContext(github, owner, repo, prNumber) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: prNumber });
const comments = await listAllComments(github, owner, repo, prNumber);
const { data: reviews } = await github.rest.pulls.listReviews({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
const mergeState = await getMergeState(github, owner, repo, prNumber);
const headSha = mergeState.headSha || pr.head.sha;
const headCommitDate = await getHeadCommitDate(github, owner, repo, prNumber);
const headPushedAt = headCommitDate || pr.updated_at;
return {
pr,
issue,
comments,
reviews,
mergeState,
headSha,
headPushedAt,
labels: issue.labels.map((l) => l.name),
baseRepo: `${owner}/${repo}`,
};
}
async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, options = {}) {
const {
forMergeStep = false,
skipGlobalClaudeRunCheck = false,
skipRecentClaudeCooldown = false,
} = options;
const ctx = await loadPrContext(github, owner, repo, prNumber);
const reasons = [];
if (ctx.pr.draft) reasons.push('draft');
if (ctx.pr.state !== 'open') reasons.push('not open');
if (ctx.labels.includes('auto-merged-by-gate')) reasons.push('already merged by gate');
if (ctx.labels.includes('no-auto-merge')) reasons.push('no-auto-merge label');
if (ctx.labels.includes('claude-conflict-pending')) reasons.push('claude-conflict-pending');
if (!forMergeStep && ctx.labels.includes('claude-merge-gate-active')) {
reasons.push('claude-merge-gate-active');
}
const { status, headRepo, maintainerCanModify } = ctx.mergeState;
if (!ALLOWED_MERGE_STATES.has(status)) reasons.push(`mergeState=${status}`);
if (headRepo && headRepo !== ctx.baseRepo) {
reasons.push('fork PR');
}
if (hasRecentConflictComment(ctx.comments, ctx.headPushedAt)) {
reasons.push('recent merge-conflict @claude');
}
if (!skipRecentClaudeCooldown && hasRecentClaudeTrigger(ctx.comments, 35)) {
const verdictOnHead = findMergeGateVerdict(ctx.comments, null, ctx.headPushedAt) !== null;
if (!verdictOnHead) reasons.push('recent @claude within 35min');
}
if (!skipGlobalClaudeRunCheck && (await hasInProgressClaudeAssistant(github, owner, repo, prNumber))) {
reasons.push('claude.yml in progress');
}
const checksOk = await allChecksGreenOnSha(github, owner, repo, ctx.headSha, core);
if (!checksOk) reasons.push('CI not green on HEAD');
if (!finalClaudeCompletedOnSha(ctx.comments, ctx.headPushedAt)) {
if (!hasFinalClaudeReviewTrigger(ctx.comments)) {
reasons.push('no FINAL Claude review trigger');
} else if (isStaleFinalAfterPush(ctx.comments, ctx.headPushedAt)) {
reasons.push('stale FINAL after new commits (needs @claude re-review)');
} else {
reasons.push('FINAL Claude not complete on HEAD');
}
}
if (hasAnyChangesRequested(ctx.reviews)) reasons.push('CHANGES_REQUESTED review');
if (hasManualOnlyLabel(ctx.labels)) {
const manualLabel = ctx.labels.find((l) => MANUAL_ONLY_LABELS.has(l));
reasons.push(`manual-only label: ${manualLabel}`);
}
const pullFiles = await listPullFiles(github, owner, repo, prNumber);
if (config.agentPyChecks) {
const agentChange = getAgentPyChangeFromFiles(pullFiles);
const agentManual = manualReviewReasonForAgentPy(agentChange);
if (agentManual) reasons.push(agentManual);
}
reasons.push(...sensitivePathReasons(pullFiles, ctx.labels));
reasons.push(...prSizeReasons(pullFiles));
reasons.push(...secretScanReasons(pullFiles));
const testsReason = missingTestsReason(pullFiles);
if (testsReason) reasons.push(testsReason);
const sdkCheckReason = await sdkTestChecksReason(github, owner, repo, ctx.headSha, pullFiles, core);
if (sdkCheckReason) reasons.push(sdkCheckReason);
if (ctx.pr.mergeable === false) reasons.push('not mergeable');
const ready = reasons.length === 0;
return {
ready,
reasons,
headSha: ctx.headSha,
prNumber,
};
}
async function listPrNumbersForMergeGateScan(github, owner, repo, core) {
let mergeReadyIssues;
if (typeof github.paginate === 'function') {
mergeReadyIssues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: 'open',
labels: MERGE_READY_LABEL,
per_page: 100,
});
} else {
({ data: mergeReadyIssues } = await github.rest.issues.listForRepo({
owner,
repo,
state: 'open',
labels: MERGE_READY_LABEL,
per_page: 100,
}));
}
const mergeReady = mergeReadyIssues
.filter((issue) => issue.pull_request)
.map((issue) => issue.number)
.sort((a, b) => a - b);
if (mergeReady.length > 0) {
core?.info?.(
`Merge gate scan: ${mergeReady.length} ${MERGE_READY_LABEL} PR(s) (skipping unlabelled open PRs)`
);
return mergeReady;
}
core?.info?.(`No ${MERGE_READY_LABEL} PRs — scanning oldest open PRs`);
const prs =
typeof github.paginate === 'function'
? await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
sort: 'created',
direction: 'asc',
})
: (await github.rest.pulls.list({
owner,
repo,
state: 'open',
per_page: 100,
sort: 'created',
direction: 'asc',
})).data;
return prs.map((pr) => pr.number);
}
async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCandidates, core) {
const readyList = [];
const skipped = [];
for (const num of prNumbers) {
if (readyList.length >= maxCandidates) {
core?.info?.(`Found ${maxCandidates} candidate(s) — skipping remaining PR scans`);
break;
}
const result = await evaluatePipelineQuiescent(github, owner, repo, num, core);
if (result.ready) {
readyList.push({ pr_number: num, head_sha: result.headSha });
} else {
skipped.push({ pr: num, reasons: result.reasons });
}
}
readyList.sort((a, b) => a.pr_number - b.pr_number);
return {
candidates: readyList.slice(0, maxCandidates),
skipped,
};
}
const AUTOMATED_FALLBACK_MARKER = 'Automated fallback —';
function isAutomatedFallbackVerdict(body) {
return (body || '').includes(AUTOMATED_FALLBACK_MARKER);
}
function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null, options = {}) {
const { excludeAutomatedFallback = false } = options || {};
const minTime = minCreatedAt ? new Date(minCreatedAt).getTime() : 0;
const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0;
const gateComments = comments
.filter((c) => {
if (!(c.body || '').includes('MERGE_GATE_VERDICT:')) return false;
if (excludeAutomatedFallback && isAutomatedFallbackVerdict(c.body)) return false;
const created = new Date(c.created_at).getTime();
if (minTime && created < minTime) return false;
if (headTime && created < headTime) return false;
return true;
})
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
if (gateComments.length === 0) return null;
const body = gateComments[0].body || '';
if (body.includes('MERGE_GATE_VERDICT: APPROVE')) return 'APPROVE';
if (body.includes('MERGE_GATE_VERDICT: BLOCK')) return 'BLOCK';
return null;
}
module.exports = {
CLAUDE_TRIGGER_LOGINS,
AUTO_ACTORS,
isFinalClaudeTriggerComment,
isClaudeTriggerNoise,
isClaudeFinalReplyComment,
hasRecentClaudeTrigger,
hasRecentConflictComment,
isConflictRebaseTriggerComment,
isConflictRebaseCompletionComment,
conflictRebaseQuiescent,
hasHumanChangesRequested,
hasAnyChangesRequested,
hasFinalClaudeReviewTrigger,
finalClaudeCompletedOnSha,
getMergeState,
OPTIONAL_CANCELLED_CHECKS,
listChecksOnSha,
listFailedChecksOnSha,
allChecksGreenOnSha,
hasInProgressClaudeAssistant,
claudeRunBlocksPr,
hasBlockingClaudeRunForPr,
isCiOnlyChange,
isInternalPullRequestLink,
resolvePrNumberFromLinkedPullRequests,
resolvePrNumberFromHeadBranch,
resolvePrNumberFromWorkflowRun,
WORKFLOW_ONLY_LABEL,
getAgentPyChange,
getAgentPyChangeFromFiles,
manualReviewReasonForAgentPy,
countNewAgentParams,
listPullFiles,
touchesSdk,
hasManualOnlyLabel,
sensitivePathReasons,
prSizeReasons,
missingTestsReason,
secretScanReasons,
sdkTestChecksReason,
listAllComments,
getHeadCommitDate,
isStaleFinalAfterPush,
needsStaleFinalRecovery,
shouldSkipFinalRecovery,
shouldSkipStaleFinalRecovery,
isClaudeAutomationLogin,
isPushSoonAfterLatestFinal,
countFinalTriggersSince,
STALE_FINAL_RECOVERY_WINDOW_MS,
STALE_FINAL_MAX_PER_WINDOW,
FINAL_CLAUDE_REVIEW_BODY,
loadPrContext,
evaluatePipelineQuiescent,
MERGE_READY_LABEL,
listPrNumbersForMergeGateScan,
selectMergeGateCandidates,
isAutomatedFallbackVerdict,
AUTOMATED_FALLBACK_MARKER,
findMergeGateVerdict,
};
@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* Run: node .github/scripts/pipeline-status-selftest.js
*/
const ps = require('./pipeline-status.js');
const mg = require('./merge-gate.js');
const config = require('./gate-config.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed++;
} else {
console.log('ok:', name);
}
}
const kickComments = [
{ user: { login: 'MervinPraison' }, body: '@coderabbitai review' },
{ user: { login: 'MervinPraison' }, body: '/review' },
];
const finalComment = {
user: { login: 'MervinPraison' },
body: '@claude You are the FINAL architecture reviewer.',
created_at: '2026-06-26T10:00:00Z',
};
assert(
'reviews pending when not kicked',
ps.deriveStage([], { ready: false, reasons: ['no FINAL Claude review trigger'] })
=== 'pipeline/reviews-pending'
);
assert(
'final pending after kick',
ps.deriveStage(kickComments, { ready: false, reasons: ['no FINAL Claude review trigger'] })
=== 'pipeline/final-claude-pending'
);
assert(
'awaiting merge gate after bot review comment',
ps.deriveStage(
[
{ user: { login: 'coderabbitai[bot]' }, body: 'review summary' },
finalComment,
],
{ ready: false, reasons: ['recent @claude within 35min'] }
) === 'pipeline/awaiting-merge-gate'
);
assert('review bot logins configured', config.reviewBotLogins.includes('coderabbitai[bot]'));
assert(
'merge ready',
ps.deriveStage([...kickComments, finalComment], { ready: true, reasons: [] })
=== 'pipeline/merge-ready'
);
const blockers = ps.deriveBlockerLabels({
ready: false,
reasons: ['CI not green on HEAD', 'SDK code added without test file changes — requires manual review'],
});
assert('maps ci blocker', blockers.includes('pipeline/blocked:ci'));
assert('maps manual blocker', blockers.includes('pipeline/blocked:manual-review'));
assert(
'internal link matches upstream base',
mg.isInternalPullRequestLink(
{ base: { repo: { full_name: 'MervinPraison/PraisonAI' } } },
'MervinPraison',
'PraisonAI'
)
);
assert(
'fork sync link rejected',
!mg.isInternalPullRequestLink(
{ number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } },
'MervinPraison',
'PraisonAI'
)
);
process.exit(failed ? 1 : 0);
+264
View File
@@ -0,0 +1,264 @@
/**
* Sync pipeline stage/blocker labels on open PRs.
* @see .github/workflows/pipeline-status-sync.yml
*/
const mergeGate = require('./merge-gate.js');
const chain = require('./bot-pr-review-chain.js');
const ciFix = require('./ci-failure-claude.js');
const config = require('./gate-config.js');
const PIPELINE_PREFIX = 'pipeline/';
const STAGE_LABELS = [
'pipeline/reviews-pending',
'pipeline/final-claude-pending',
'pipeline/awaiting-merge-gate',
'pipeline/merge-ready',
'pipeline/merged',
];
const BLOCKER_LABELS = [
'pipeline/blocked:ci',
'pipeline/blocked:conflict',
'pipeline/blocked:manual-review',
'pipeline/blocked:cooldown',
'pipeline/blocked:stale-final',
'pipeline/blocked:no-final',
];
const ALL_PIPELINE_LABELS = [...STAGE_LABELS, ...BLOCKER_LABELS];
const LABEL_SPECS = [
{ name: 'pipeline/reviews-pending', color: 'fbca04', description: 'Waiting for CodeRabbit/Qodo/Copilot reviews' },
{ name: 'pipeline/final-claude-pending', color: 'd4c5f9', description: 'Reviews done; waiting for FINAL @claude' },
{ name: 'pipeline/awaiting-merge-gate', color: 'c5def5', description: 'FINAL done; waiting for merge gate / CI' },
{ name: 'pipeline/merge-ready', color: '0e8a16', description: 'Eligible for merge gate auto-merge' },
{ name: 'pipeline/merged', color: '6f42c1', description: 'Merged via pipeline' },
{ name: 'pipeline/blocked:ci', color: 'd93f0b', description: 'Blocked: CI not green on HEAD' },
{ name: 'pipeline/blocked:conflict', color: 'b60205', description: 'Blocked: merge conflict or rebase pending' },
{ name: 'pipeline/blocked:manual-review', color: 'e99695', description: 'Blocked: requires manual review' },
{ name: 'pipeline/blocked:cooldown', color: 'fef2c0', description: 'Blocked: post-push or @claude cooldown' },
{ name: 'pipeline/blocked:stale-final', color: 'f9d0c4', description: 'Blocked: FINAL stale after new commits' },
{ name: 'pipeline/blocked:no-final', color: 'ededed', description: 'Blocked: no FINAL @claude trigger yet' },
];
function reviewChainStarted(comments) {
if (chain.chainKickPosted(comments)) return true;
return comments.some((c) => config.reviewBotLogins.includes(c.user?.login));
}
function deriveStage(comments, evalResult) {
if (evalResult.ready) return 'pipeline/merge-ready';
if (!reviewChainStarted(comments)) return 'pipeline/reviews-pending';
if (!mergeGate.hasFinalClaudeReviewTrigger(comments)) return 'pipeline/final-claude-pending';
return 'pipeline/awaiting-merge-gate';
}
function reasonToBlockerLabel(reason) {
const r = (reason || '').toLowerCase();
if (r.includes('ci not green') || r.includes('sdk code changed but no ci')) return 'pipeline/blocked:ci';
if (
r.includes('conflict') ||
r.includes('mergestate=dirty') ||
r.includes('not mergeable')
) return 'pipeline/blocked:conflict';
if (
r.includes('manual') ||
r.includes('requires manual') ||
r.includes('sensitive path') ||
r.includes('no-auto-merge') ||
r.includes('manual-only label') ||
r.includes('without test') ||
r.includes('product code') ||
r.includes('files changed') ||
r.includes('possible secret')
) return 'pipeline/blocked:manual-review';
if (r.includes('recent @claude') || r.includes('post-push buffer')) return 'pipeline/blocked:cooldown';
if (r.includes('stale final')) return 'pipeline/blocked:stale-final';
if (r.includes('no final claude')) return 'pipeline/blocked:no-final';
if (r.includes('final claude not complete')) return 'pipeline/blocked:stale-final';
if (r.includes('claude.yml in progress') || r.includes('claude-merge-gate-active')) {
return null;
}
return null;
}
function deriveBlockerLabels(evalResult) {
if (evalResult.ready) return [];
const labels = new Set();
for (const reason of evalResult.reasons || []) {
const mapped = reasonToBlockerLabel(reason);
if (mapped && mapped.startsWith('pipeline/blocked:')) labels.add(mapped);
}
return [...labels];
}
function computePipelineLabels(comments, evalResult) {
const stage = deriveStage(comments, evalResult);
const blockers = stage === 'pipeline/merge-ready' ? [] : deriveBlockerLabels(evalResult);
return { stage, blockers, all: [stage, ...blockers] };
}
async function ensurePipelineLabels(github, owner, repo, core) {
let existing;
if (typeof github.paginate === 'function') {
existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
owner,
repo,
per_page: 100,
});
} else {
({ data: existing } = await github.rest.issues.listLabelsForRepo({
owner,
repo,
per_page: 100,
}));
}
const names = new Set(existing.map((l) => l.name));
for (const spec of LABEL_SPECS) {
if (names.has(spec.name)) continue;
try {
await github.rest.issues.createLabel({
owner,
repo,
name: spec.name,
color: spec.color,
description: spec.description,
});
core?.info?.(`Created label ${spec.name}`);
} catch (err) {
if (err.status !== 422) throw err;
core?.info?.(`Label ${spec.name} already exists`);
}
}
}
async function syncPipelineLabels(github, owner, repo, prNumber, core) {
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
if (ctx.pr.state !== 'open') return { synced: false, reason: 'not_open' };
const evalResult = await mergeGate.evaluatePipelineQuiescent(
github, owner, repo, prNumber, core
);
const { stage, blockers, all } = computePipelineLabels(ctx.comments, evalResult);
const current = ctx.labels.filter((l) => l.startsWith(PIPELINE_PREFIX));
const desired = new Set(all);
const toRemove = current.filter((l) => !desired.has(l) && l !== 'pipeline/merged');
const toAdd = all.filter((l) => !current.includes(l));
for (const name of toRemove) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
} catch (err) {
if (err.status !== 404) throw err;
}
}
if (toAdd.length) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: toAdd,
});
}
core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`);
const ciNotGreen = (evalResult.reasons || []).some((r) =>
r.toLowerCase().includes('ci not green')
);
if (ciNotGreen || blockers.includes('pipeline/blocked:ci')) {
await ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
} else {
await ciFix.maybeClearCiFixLabel(
github, owner, repo, prNumber, ctx.labels, ctx.headSha, core
);
}
return {
synced: true,
stage,
blockers,
ready: evalResult.ready,
reasons: evalResult.reasons,
labels: ctx.labels,
createdAt: new Date(ctx.pr.created_at).getTime(),
};
}
async function dispatchMergeGateForOldestReady(github, owner, repo, readyCandidates, core) {
if (!readyCandidates.length) return 0;
readyCandidates.sort((a, b) => a.createdAt - b.createdAt);
for (const cand of readyCandidates) {
if ((cand.labels || []).includes('claude-merge-gate-active')) {
core?.info?.(`Skip dispatch PR #${cand.prNumber}: merge gate already active`);
continue;
}
await github.rest.repos.createDispatchEvent({
owner,
repo,
event_type: 'claude-merge-gate',
client_payload: { pr_number: cand.prNumber },
});
core?.info?.(`Dispatched merge gate for ready PR #${cand.prNumber}`);
return cand.prNumber;
}
return 0;
}
async function syncOpenPullRequests(github, owner, repo, options, core) {
const { maxPrs = 20, dispatchMergeGate = true } = options || {};
await ensurePipelineLabels(github, owner, repo, core);
let prs;
if (maxPrs <= 100) {
({ data: prs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
per_page: 100,
}));
} else {
prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
}
let synced = 0;
const readyCandidates = [];
for (const pr of prs) {
if (synced >= maxPrs) break;
if (pr.draft) continue;
if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue;
const result = await syncPipelineLabels(github, owner, repo, pr.number, core);
if (result.ready) {
readyCandidates.push({
prNumber: pr.number,
createdAt: result.createdAt || new Date(pr.created_at).getTime(),
labels: result.labels || [],
});
}
synced += 1;
}
let dispatched = 0;
if (dispatchMergeGate && readyCandidates.length) {
dispatched = await dispatchMergeGateForOldestReady(
github, owner, repo, readyCandidates, core
);
}
core?.info?.(`Pipeline label sync complete (${synced} PR(s), dispatched=${dispatched || 'none'})`);
return synced;
}
module.exports = {
STAGE_LABELS,
BLOCKER_LABELS,
ALL_PIPELINE_LABELS,
deriveStage,
deriveBlockerLabels,
computePipelineLabels,
ensurePipelineLabels,
syncPipelineLabels,
syncOpenPullRequests,
dispatchMergeGateForOldestReady,
};
+298
View File
@@ -0,0 +1,298 @@
/**
* PR review pipeline: CodeRabbit / Greptile (+ optional Qodo/Gemini) → Claude (FINAL).
* Copilot step optional — set REVIEW_CHAIN_SKIP_COPILOT=1 or skipCopilot: true to bypass.
* @see .github/workflows/auto-pr-comment.yml
*/
function isSkipCopilot(options = {}) {
if (options.skipCopilot === true) return true;
if (options.skipCopilot === false) return false;
return process.env.REVIEW_CHAIN_SKIP_COPILOT === '1';
}
const COPILOT_TRIGGER_LOGINS = new Set(['MervinPraison', 'github-actions[bot]']);
const CLAUDE_TRIGGER_LOGINS = new Set(['MervinPraison', 'github-actions[bot]']);
const REQUIRED_PRIOR = ['coderabbit', 'greptile'];
const OPTIONAL_PRIOR = ['qodo', 'gemini'];
const OPTIONAL_PRIOR_WAIT_MS = 20 * 60 * 1000;
function loginOf(item) {
return (item?.user?.login || '').toLowerCase();
}
function bodyOf(item) {
return item?.body || '';
}
function hasCoderabbitSummary(comments) {
return comments.some(
(c) =>
loginOf(c).includes('coderabbit') &&
bodyOf(c).toLowerCase().includes('summarize by coderabbit')
);
}
function hasGreptileReview(comments) {
return comments.some((c) => {
if (!loginOf(c).includes('greptile')) return false;
const body = bodyOf(c);
return body.includes('Greptile Summary') || body.includes('<h3>Greptile Summary</h3>') || body.length > 150;
});
}
function hasQodoReview(comments, reviews = []) {
if (comments.some((c) => loginOf(c).includes('qodo'))) return true;
return reviews.some((r) => loginOf(r).includes('qodo'));
}
function hasGeminiReview(comments, reviews = []) {
if (reviews.some((r) => loginOf(r).includes('gemini'))) return true;
return comments.some((c) => {
if (loginOf(c).includes('gemini')) return true;
const body = bodyOf(c);
return (
body.includes('Review completed by Gemini CLI') ||
body.includes('The fix is ready for review. Please test')
);
});
}
const PRIOR_CHECKS = {
coderabbit: (comments) => hasCoderabbitSummary(comments),
greptile: (comments) => hasGreptileReview(comments),
qodo: (comments, reviews) => hasQodoReview(comments, reviews),
gemini: (comments, reviews) => hasGeminiReview(comments, reviews),
};
function priorReviewerStatus(comments, reviews = []) {
const status = {};
for (const [id, check] of Object.entries(PRIOR_CHECKS)) {
status[id] = check(comments, reviews);
}
return status;
}
function priorReviewersReady(comments, reviews = [], options = {}) {
const waitMs = options.optionalWaitMs ?? OPTIONAL_PRIOR_WAIT_MS;
const prCreatedAt = options.prCreatedAt || null;
const status = priorReviewerStatus(comments, reviews);
const missingRequired = REQUIRED_PRIOR.filter((id) => !status[id]);
if (missingRequired.length) {
return { ready: false, reason: `waiting for ${missingRequired.join(', ')}`, status };
}
const missingOptional = OPTIONAL_PRIOR.filter((id) => !status[id]);
if (missingOptional.length && prCreatedAt) {
const age = Date.now() - new Date(prCreatedAt).getTime();
if (age < waitMs) {
return {
ready: false,
reason: `waiting for optional ${missingOptional.join(', ')} (${Math.round((waitMs - age) / 60000)}m left)`,
status,
};
}
}
return { ready: true, reason: '', status };
}
function copilotTriggerComment(comments) {
return comments.find(
(c) =>
bodyOf(c).includes('@copilot') &&
COPILOT_TRIGGER_LOGINS.has(c.user?.login) &&
!bodyOf(c).toLowerCase().includes('@claude')
);
}
function copilotTriggered(comments) {
return Boolean(copilotTriggerComment(comments));
}
function copilotReviewReady(comments, reviews = []) {
const trigger = copilotTriggerComment(comments);
if (!trigger) {
return { ready: false, reason: 'copilot not triggered yet' };
}
const triggerTime = new Date(trigger.created_at).getTime();
const copilotComment = comments.some((c) => {
if (c === trigger) return false;
if (new Date(c.created_at).getTime() < triggerTime) return false;
const login = loginOf(c);
return (
login.includes('copilot') ||
c.user?.login === 'Copilot' ||
c.user?.login === 'copilot-swe-agent'
);
});
const copilotReview = reviews.some((r) => {
if (new Date(r.submitted_at).getTime() < triggerTime) return false;
return loginOf(r).includes('copilot');
});
if (copilotComment || copilotReview) {
return { ready: true, reason: '' };
}
return { ready: false, reason: 'awaiting copilot review' };
}
function isFinalClaudeTriggerComment(c) {
const body = bodyOf(c).toLowerCase();
if (!CLAUDE_TRIGGER_LOGINS.has(c.user?.login)) return false;
if (!body.includes('@claude')) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
}
function claudeFinalAlreadyTriggered(comments) {
return comments.some(isFinalClaudeTriggerComment);
}
function claudeFinalReady(comments, reviews = [], options = {}) {
if (claudeFinalAlreadyTriggered(comments)) {
return { ready: false, reason: 'claude FINAL already triggered', already: true };
}
const prior = priorReviewersReady(comments, reviews, options);
if (!prior.ready) {
return { ready: false, reason: prior.reason };
}
if (isSkipCopilot(options)) {
return { ready: true, reason: 'copilot skipped', copilotSkipped: true };
}
const copilot = copilotReviewReady(comments, reviews);
if (copilot.ready) {
return { ready: true, reason: '' };
}
if (options.allowCopilotTimeout && copilotTriggered(comments)) {
return { ready: true, reason: 'copilot timeout fallback', copilotSkipped: true };
}
return { ready: false, reason: copilot.reason };
}
/** Post @copilot when prior reviewers are ready, then @claude FINAL when Copilot done (or timeout). */
async function advanceReviewChain(github, owner, repo, prNumber, finalBody, core, options = {}) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const chainOpts = {
prCreatedAt: pr.created_at,
optionalWaitMs: options.optionalWaitMs ?? 0,
};
const copilot = options.skipCopilot
? { triggered: false, reason: 'skipped' }
: await maybeTriggerCopilot(github, owner, repo, prNumber, core, chainOpts);
const claude = await maybeTriggerClaudeFinal(
github, owner, repo, prNumber, finalBody, core,
{ ...options, ...chainOpts }
);
return { copilot, claude };
}
async function maybeTriggerClaudeFinal(github, owner, repo, prNumber, finalBody, core, options = {}) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
const gate = claudeFinalReady(comments, reviews, {
prCreatedAt: pr.created_at,
optionalWaitMs: options.optionalWaitMs,
allowCopilotTimeout: options.allowCopilotTimeout !== false,
});
if (!gate.ready) {
core?.info?.(`PR #${prNumber}: skip Claude FINAL — ${gate.reason}`);
return { posted: false, reason: gate.reason };
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: finalBody,
});
const note = gate.copilotSkipped ? ' (Copilot timeout fallback)' : '';
core?.info?.(`Posted Claude FINAL on PR #${prNumber}${note}`);
return { posted: true, reason: '' };
}
const COPILOT_REVIEW_BODY =
'@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, CodeRabbit, Gemini, and Greptile 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, Gemini, or Greptile flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible';
async function listCommentsAndReviews(github, owner, repo, prNumber) {
let comments;
if (typeof github.paginate === 'function') {
comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
} else {
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
comments = data;
}
const { data: reviews } = await github.rest.pulls.listReviews({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
return { comments, reviews };
}
async function maybeTriggerCopilot(github, owner, repo, prNumber, core, options = {}) {
const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
if (copilotTriggered(comments)) {
core?.info?.(`Copilot already triggered on PR #${prNumber}`);
return { triggered: false, reason: 'already_triggered' };
}
const prior = priorReviewersReady(comments, reviews, options);
if (!prior.ready) {
core?.info?.(`PR #${prNumber}: not ready for Copilot — ${prior.reason}`);
return { triggered: false, reason: prior.reason };
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: COPILOT_REVIEW_BODY,
});
core?.info?.(`Posted @copilot on PR #${prNumber} after prior reviewers`);
return { triggered: true };
}
async function pollCopilotResponse(github, owner, repo, prNumber, options = {}) {
const maxAttempts = options.maxAttempts ?? 20;
const delayMs = options.delayMs ?? 30000;
for (let i = 0; i < maxAttempts; i += 1) {
const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber);
const copilot = copilotReviewReady(comments, reviews);
if (copilot.ready) {
return { ready: true, reason: '' };
}
if (i < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
return { ready: false, reason: 'copilot timeout' };
}
module.exports = {
isSkipCopilot,
REQUIRED_PRIOR,
OPTIONAL_PRIOR,
COPILOT_REVIEW_BODY,
priorReviewerStatus,
priorReviewersReady,
copilotTriggered,
copilotReviewReady,
claudeFinalReady,
claudeFinalAlreadyTriggered,
isFinalClaudeTriggerComment,
maybeTriggerCopilot,
maybeTriggerClaudeFinal,
advanceReviewChain,
pollCopilotResponse,
hasGeminiReview,
listCommentsAndReviews,
};
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* Run: node .github/scripts/release-gate-selftest.js
*/
const rg = require('./release-gate.js');
const config = require('./gate-config.js');
let failed = 0;
function assert(name, cond) {
if (!cond) {
console.error('FAIL:', name);
failed += 1;
} else {
console.log('ok:', name);
}
}
assert('package name is aiui', config.pypiPackageName === 'aiui');
assert('bumpPatch works', rg.bumpPatch('0.3.127') === '0.3.128');
try {
const versions = rg.readVersionsFromTree(process.cwd());
assert('reads current version', /^\d+\.\d+\.\d+$/.test(versions.current));
assert('computes target patch', versions.target === rg.bumpPatch(versions.current));
} catch (err) {
console.error('FAIL: readVersionsFromTree', err.message);
failed += 1;
}
assert('PACKAGE_PATHS includes pyproject', rg.PACKAGE_PATHS.includes('pyproject.toml'));
process.exit(failed ? 1 : 0);
+205
View File
@@ -0,0 +1,205 @@
/**
* Release gate preflight — path changes, CI SHA, dedupe, PyPI version checks (PraisonAIUI).
*/
const https = require('https');
const config = require('./gate-config.js');
const PACKAGE_PATHS = config.packagePaths;
const ACTIVE_RELEASE_STATUSES = new Set([
'queued', 'in_progress', 'waiting', 'pending', 'requested',
]);
function bumpPatch(version) {
const parts = version.split('.');
if (parts.length !== 3) throw new Error(`Invalid version: ${version}`);
return `${parts[0]}.${parts[1]}.${Number(parts[2]) + 1}`;
}
function readVersionsFromTree(root = '.') {
const fs = require('fs');
const path = require('path');
const toml = fs.readFileSync(path.join(root, 'pyproject.toml'), 'utf8');
const match = toml.match(/^version\s*=\s*"([^"]+)"/m);
if (!match) throw new Error('Could not read aiui version from pyproject.toml');
const current = match[1];
const target = bumpPatch(current);
return { current, target, packageName: config.pypiPackageName };
}
function pypiVersionExists(packageName, version) {
return new Promise((resolve) => {
const req = https.get(
`https://pypi.org/pypi/${packageName}/${version}/json`,
{ timeout: 15000 },
(res) => {
res.resume();
resolve(res.statusCode === 200);
}
);
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
async function hasActiveReleaseRun(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'pypi-release.yml',
per_page: 20,
});
return runs.data.workflow_runs.some(
(r) => ACTIVE_RELEASE_STATUSES.has(r.status) && !r.conclusion
);
}
function utcDayStart(now = new Date()) {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
const dayStart = utcDayStart(now);
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'pypi-release.yml',
status: 'completed',
per_page: 30,
});
return runs.data.workflow_runs.some(
(r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart
);
}
async function lastGreenCiSha(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: config.ciWorkflowFile,
branch: 'main',
status: 'completed',
per_page: 30,
});
const hit = runs.data.workflow_runs.find((r) => r.conclusion === 'success');
return hit ? hit.head_sha : '';
}
async function evaluateReleasePreflight(github, owner, repo, options, core) {
const {
headSha,
isCiTrigger = false,
bump = 'patch',
} = options;
const reasons = [];
const out = {
ready: false,
reasons,
headSha: headSha || '',
lastTag: '',
targetVersion: '',
packageName: config.pypiPackageName,
};
if (bump !== 'patch') {
reasons.push('only patch auto-release supported');
return out;
}
if (await hasActiveReleaseRun(github, owner, repo)) {
reasons.push('PyPI Release already in progress or awaiting approval');
return out;
}
const referenceTime = options.now instanceof Date ? options.now : new Date();
if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) {
const day = referenceTime.toISOString().slice(0, 10);
reasons.push(`already released today (UTC ${day}); max one patch release per day`);
return out;
}
let versions;
try {
versions = readVersionsFromTree();
} catch (err) {
reasons.push(err.message);
return out;
}
out.targetVersion = versions.target;
const onPypi = await pypiVersionExists(config.pypiPackageName, versions.target);
if (onPypi) {
reasons.push(`already published: ${config.pypiPackageName}==${versions.target}`);
return out;
}
const { execSync } = require('child_process');
let lastTag = '';
try {
lastTag = execSync('git describe --tags --match "v*" --abbrev=0', { encoding: 'utf8' }).trim();
} catch {
reasons.push('no v* tag found');
return out;
}
out.lastTag = lastTag;
let changed = '';
try {
changed = execSync(
`git diff --name-only ${lastTag} HEAD -- ${PACKAGE_PATHS.join(' ')}`,
{ encoding: 'utf8' }
).trim();
} catch {
reasons.push('git diff failed');
return out;
}
if (!changed) {
reasons.push(`no changes in ${PACKAGE_PATHS.join(' or ')} since ${lastTag}`);
return out;
}
if (isCiTrigger) {
let mainSha = '';
try {
mainSha = execSync('git rev-parse origin/main', { encoding: 'utf8' }).trim();
} catch {
reasons.push('could not resolve origin/main');
return out;
}
if (headSha !== mainSha) {
reasons.push(`superseded: green SHA ${headSha.slice(0, 7)} != main ${mainSha.slice(0, 7)}`);
return out;
}
out.headSha = headSha;
} else {
const evalSha = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
out.headSha = evalSha;
const greenSha = await lastGreenCiSha(github, owner, repo);
if (greenSha !== evalSha) {
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
return out;
}
}
out.ready = true;
out.reasons = ['ready'];
if (core) core.info(`Release preflight passed for ${out.headSha || headSha}`);
return out;
}
module.exports = {
bumpPatch,
readVersionsFromTree,
pypiVersionExists,
hasSuccessfulReleaseToday,
utcDayStart,
evaluateReleasePreflight,
lastGreenCiSha,
ACTIVE_RELEASE_STATUSES,
PACKAGE_PATHS,
};
+80 -11
View File
@@ -15,7 +15,7 @@ on:
pull_request_review:
types: [submitted]
pull_request:
types: [opened]
types: [opened, synchronize]
workflow_dispatch:
inputs:
pr_number:
@@ -34,6 +34,9 @@ env:
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 PraisonAIUI (src/praisonaiui, src/frontend, tests, docs). 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 UI value, correct UI vs praisonaiagents 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 — stronger PraisonAIUI UX and correct layering (UI/frontend here; core agent logic belongs 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:
# ================================================================
@@ -200,12 +203,12 @@ jobs:
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 claudeBody = '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, src/frontend, tests, docs). Read ALL prior reviewer comments. ' + reviewSummary + '.\n\n' +
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/PraisonAIUI (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' +
'**Phase 1: Review per AGENTS.md** (lazy imports, backward compat, import-time <200ms)\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';
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);
@@ -226,6 +229,10 @@ jobs:
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
@@ -326,14 +333,30 @@ jobs:
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/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, tests, docs). Do not expand scope into praisonaiagents or the monorepo unless the issue explicitly requires it. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**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\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (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)
# ================================================================
@@ -430,11 +453,13 @@ jobs:
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/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, tests, docs). Do not expand scope into praisonaiagents or the monorepo unless the issue explicitly requires it. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**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\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (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}`);
@@ -481,11 +506,13 @@ jobs:
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/PraisonAIUI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on PraisonAIUI (src/praisonaiui, tests, docs). Do not expand scope into praisonaiagents or the monorepo unless the issue explicitly requires it. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**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\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items'
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAIUI (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
});
# ================================================================
@@ -595,7 +622,7 @@ jobs:
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). SCOPE: PraisonAIUI only (src/praisonaiui, src/frontend, tests, docs). Read ALL CodeRabbit/Qodo comments above.\n\n**Phase 1:** Review per AGENTS.md (lazy imports, backward compat, import-time <200ms)\n**Phase 2:** FIX valid reviewer issues on this branch\n**Phase 3:** Approve PR if ready, else request changes with clear action items'
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);
@@ -635,6 +662,48 @@ jobs:
issue_number: pr,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@claude You are the FINAL architecture reviewer. Push fixes directly to THIS branch. SCOPE: PraisonAIUI (src/praisonaiui, src/frontend, tests, docs). Read all prior reviewer comments.\n\n**Phase 1:** Review per AGENTS.md\n**Phase 2:** FIX valid issues on this branch\n**Phase 3:** Approve PR if ready, else request changes'
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAIUI (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);
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);
+102
View File
@@ -0,0 +1,102 @@
name: Auto CI Failure → Claude
# Detects internal PRs with failing CI and posts @claude with extracted failure details.
on:
workflow_run:
workflows: [CI]
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:
- cron: '15 */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
actions: read
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();
+1
View File
@@ -8,6 +8,7 @@ on:
jobs:
python:
name: python
runs-on: ubuntu-latest
strategy:
matrix:
+531
View File
@@ -0,0 +1,531 @@
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
workflow_run:
workflows:
- Claude Assistant
- CI
types:
- completed
repository_dispatch:
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
issues: read
actions: read
jobs:
auto-dispatch:
if: github.event_name == 'workflow_run'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
actions: write
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: 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 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;
}
const allowed = ['Claude Assistant', 'CI'];
if (!allowed.includes(wr.name)) {
core.info(`Skip dispatch: workflow ${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;
}
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;
}
await github.rest.repos.createDispatchEvent({
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:
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: cp .github/scripts/merge-gate.js /tmp/merge-gate.js
- 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}"
- 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.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.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();
}
+33 -14
View File
@@ -13,7 +13,7 @@ on:
concurrency:
group: merge-conflict-scan-${{ github.repository }}
cancel-in-progress: false
cancel-in-progress: true
env:
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
@@ -35,7 +35,6 @@ jobs:
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';
@@ -54,6 +53,7 @@ jobs:
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
mergeStateStatus
maintainerCanModify
isDraft
headRef { repository { nameWithOwner } }
}
@@ -69,6 +69,7 @@ jobs:
status,
isDraft: prGql.isDraft,
headRepo: prGql.headRef?.repository?.nameWithOwner,
maintainerCanModify: prGql.maintainerCanModify === true,
};
}
if (attempt < 2) await sleep(10000);
@@ -78,9 +79,20 @@ jobs:
status: (pr.data.mergeable_state || '').toUpperCase(),
isDraft: pr.data.draft,
headRepo: pr.data.head.repo?.full_name,
maintainerCanModify: pr.data.maintainer_can_modify === true,
};
}
function hasFinalClaudeReviewTrigger(comments) {
return comments.some((c) => {
const body = (c.body || '').toLowerCase();
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!body.includes('@claude')) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
});
}
function hasRecentAutoComment(comments) {
const cutoff = Date.now() - COOLDOWN_MS;
return comments.some((c) => {
@@ -92,8 +104,8 @@ jobs:
}
async function processPR(prNumber) {
const { status, isDraft, headRepo } = await getMergeState(prNumber);
core.info(`PR #${prNumber}: mergeState=${status}, head=${headRepo}, draft=${isDraft}`);
const { status, isDraft, headRepo, maintainerCanModify } = await getMergeState(prNumber);
core.info(`PR #${prNumber}: mergeState=${status}, head=${headRepo}, draft=${isDraft}, maintainerCanModify=${maintainerCanModify}`);
const { data: issue } = await github.rest.issues.get({
owner,
@@ -114,7 +126,12 @@ jobs:
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' };
if (headRepo && headRepo !== baseRepo && !maintainerCanModify) {
return { skipped: true, reason: 'fork without maintainer edits' };
}
if (labels.includes(LABEL)) {
return { skipped: true, reason: 'conflict resolution pending' };
}
const { data: comments } = await github.rest.issues.listComments({
owner,
@@ -122,24 +139,26 @@ jobs:
issue_number: prNumber,
per_page: 100,
});
if (!isMainPush && hasRecentAutoComment(comments)) {
if (hasRecentAutoComment(comments)) {
return { skipped: true, reason: 'recent auto comment' };
}
if (!hasFinalClaudeReviewTrigger(comments)) {
return { skipped: true, reason: 'awaiting final claude review trigger' };
}
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [LABEL],
});
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 };
}
+129
View File
@@ -0,0 +1,129 @@
name: Nightly Release Gate
# Preflight before PyPI Release (patch bump).
# Primary: CI success on main (workflow_run).
# Backstop: nightly cron at 00:00 UTC.
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Run preflight only; do not dispatch pypi-release.yml'
required: false
default: false
type: boolean
workflow_run:
workflows:
- CI
types:
- completed
branches:
- main
permissions:
contents: read
actions: write
concurrency:
group: nightly-release-gate
cancel-in-progress: false
jobs:
preflight:
if: |
github.event_name == 'workflow_dispatch' ||
github.event_name == 'schedule' ||
(
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.event == 'push'
)
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
- name: Resolve trigger context
id: ctx
run: |
if [ "${{ github.event_name }}" = "workflow_run" ]; then
echo "checkout_sha=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT"
echo "source=ci" >> "$GITHUB_OUTPUT"
echo "is_ci=true" >> "$GITHUB_OUTPUT"
else
echo "checkout_sha=main" >> "$GITHUB_OUTPUT"
echo "source=nightly" >> "$GITHUB_OUTPUT"
echo "is_ci=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ steps.ctx.outputs.checkout_sha }}
fetch-depth: 0
- name: Fetch main and tags
run: git fetch --tags origin main
- name: Preflight — path changes, CI SHA, dedupe
id: preflight
uses: actions/github-script@v7
env:
HEAD_SHA: ${{ steps.ctx.outputs.checkout_sha }}
IS_CI: ${{ steps.ctx.outputs.is_ci }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const rg = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/release-gate.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const headSha = process.env.HEAD_SHA;
const isCi = process.env.IS_CI === 'true';
const result = await rg.evaluateReleasePreflight(
github, owner, repo,
{ headSha, isCiTrigger: isCi, bump: 'patch' },
core
);
core.setOutput('should_release', result.ready ? 'true' : 'false');
core.setOutput('reason', result.reasons.join('; '));
core.setOutput('head_sha', result.headSha || headSha);
core.setOutput('last_tag', result.lastTag || '');
core.setOutput('target_version', result.targetVersion || '');
core.summary.addRaw('## Release gate preflight\n');
core.summary.addRaw(`Ready: ${result.ready}\n`);
core.summary.addRaw(`Reason: ${result.reasons.join('; ')}\n`);
if (result.targetVersion) {
core.summary.addRaw(`Target: aiui==${result.targetVersion}\n`);
}
await core.summary.write();
if (!result.ready) {
core.info(`Skip release: ${result.reasons.join('; ')}`);
}
- name: Dry run summary
if: inputs.dry_run == true
run: |
echo "Dry run — preflight only, no pypi-release dispatch."
echo "should_release=${{ steps.preflight.outputs.should_release }}"
echo "reason=${{ steps.preflight.outputs.reason }}"
- name: Dispatch PyPI Release (patch)
if: steps.preflight.outputs.should_release == 'true' && inputs.dry_run != true
run: |
set -euo pipefail
gh workflow run pypi-release.yml \
--repo "${GITHUB_REPOSITORY}" \
--ref main \
-f bump=patch \
-f source=${{ steps.ctx.outputs.source }} \
-f trigger_sha=${{ steps.preflight.outputs.head_sha }} \
-f dry_run=false
echo "Dispatched pypi-release.yml (patch, source=${{ steps.ctx.outputs.source }})"
echo "Uses pypi-auto environment for ci/nightly patch releases."
@@ -0,0 +1,42 @@
name: Pipeline Status Sync
# Sync pipeline/* stage and blocker labels on open internal PRs.
on:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch:
permissions:
pull-requests: read
issues: write
actions: write
id-token: write
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
);
+232
View File
@@ -0,0 +1,232 @@
name: PyPI Release
# Requires GitHub Environment "pypi" (manual) or "pypi-auto" (ci/nightly patch).
# Secrets: PYPI_TOKEN (or PYPI_API_TOKEN), GH_TOKEN
on:
workflow_dispatch:
inputs:
bump:
description: 'Version bump type for aiui'
required: true
default: patch
type: choice
options:
- patch
- minor
- major
dry_run:
description: 'Preview versions and steps without publishing or pushing'
required: false
default: false
type: boolean
version:
description: 'Override version (empty = auto bump from pyproject.toml)'
required: false
default: ''
type: string
source:
description: 'Trigger source (audit trail)'
required: false
default: manual
type: string
trigger_sha:
description: 'SHA that triggered this release'
required: false
default: ''
type: string
permissions:
contents: write
concurrency:
group: pypi-release
cancel-in-progress: false
jobs:
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: >-
${{
(inputs.source == 'ci' || inputs.source == 'nightly') &&
inputs.bump == 'patch' &&
'pypi-auto' || 'pypi'
}}
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
PACKAGE: aiui
steps:
- name: Release audit
run: |
echo "Source: ${{ inputs.source }}"
echo "Trigger SHA: ${{ inputs.trigger_sha }}"
echo "Bump: ${{ inputs.bump }}"
echo "Environment: ${{ (inputs.source == 'ci' || inputs.source == 'nightly') && inputs.bump == 'patch' && 'pypi-auto' || 'pypi' }}"
- name: Checkout main
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GH_TOKEN }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.11'
- name: Set up uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
- name: Configure git
run: |
git config user.name "MervinPraison"
git config user.email "[email protected]"
gh auth setup-git
- name: Compute release version
id: versions
env:
BUMP: ${{ inputs.bump }}
VERSION_OVERRIDE: ${{ inputs.version }}
run: |
python <<'PY'
import os
import re
import sys
from pathlib import Path
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
bump = os.environ["BUMP"]
override = os.environ.get("VERSION_OVERRIDE", "").strip()
def bump_version(version: str) -> str:
parts = version.split(".")
if len(parts) != 3:
print(f"Invalid version: {version}", file=sys.stderr)
sys.exit(1)
maj, min_, patch = (int(parts[0]), int(parts[1]), int(parts[2]))
if bump == "major":
return f"{maj + 1}.0.0"
if bump == "minor":
return f"{maj}.{min_ + 1}.0"
return f"{maj}.{min_}.{patch + 1}"
pyproject = Path("pyproject.toml")
content = pyproject.read_text()
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
if not match:
print("Could not read version from pyproject.toml", file=sys.stderr)
sys.exit(1)
current = match.group(1)
if not SEMVER.match(current):
print(f"Invalid current version: {current}", file=sys.stderr)
sys.exit(1)
if override:
if not SEMVER.match(override):
print(f"Invalid version override: {override}", file=sys.stderr)
sys.exit(1)
new_version = override
else:
new_version = bump_version(current)
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
fh.write(f"current_version={current}\n")
fh.write(f"new_version={new_version}\n")
print(f"aiui: {current} -> {new_version}")
PY
- name: Dry run summary
if: inputs.dry_run
run: |
echo "Dry run — no publish, commit, or push."
echo "Package: ${{ env.PACKAGE }}"
echo "Version: ${{ steps.versions.outputs.current_version }} -> ${{ steps.versions.outputs.new_version }}"
echo "Would: bump pyproject.toml + __version__.py, uv publish, commit, tag, GitHub release"
- name: Bump version files
if: inputs.dry_run != true
env:
CURRENT: ${{ steps.versions.outputs.current_version }}
NEW_VERSION: ${{ steps.versions.outputs.new_version }}
run: |
set -euo pipefail
python <<'PY'
import os
import re
from pathlib import Path
current = os.environ["CURRENT"]
new = os.environ["NEW_VERSION"]
pyproject = Path("pyproject.toml")
content = pyproject.read_text()
pyproject.write_text(content.replace(f'version = "{current}"', f'version = "{new}"', 1))
version_py = Path("src/praisonaiui/__version__.py")
vcontent = version_py.read_text()
version_py.write_text(re.sub(
r'__version__ = "[^"]+"',
f'__version__ = "{new}"',
vcontent,
count=1,
))
PY
- name: Publish aiui to PyPI
if: inputs.dry_run != true
run: |
set -euo pipefail
rm -rf dist
uv lock
uv build
uv publish
- name: Wait for aiui on PyPI
if: inputs.dry_run != true
run: |
set -euo pipefail
VERSION="${{ steps.versions.outputs.new_version }}"
MAX_WAIT=600
INTERVAL=30
START=$(date +%s)
while true; do
if curl -fsSL "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json" >/dev/null; then
echo "✅ ${PACKAGE}==${VERSION} is available on PyPI"
exit 0
fi
ELAPSED=$(( $(date +%s) - START ))
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
echo "❌ Timeout waiting for ${PACKAGE}==${VERSION} on PyPI"
exit 1
fi
echo "⏳ Waiting for ${PACKAGE}==${VERSION} ($(( MAX_WAIT - ELAPSED ))s remaining...)"
sleep "$INTERVAL"
done
- name: Commit, tag, and release
if: inputs.dry_run != true
env:
VERSION: ${{ steps.versions.outputs.new_version }}
run: |
set -euo pipefail
TAG="v${VERSION}"
git add pyproject.toml uv.lock src/praisonaiui/__version__.py
git diff --cached --quiet && { echo "No version files to commit"; exit 0; }
git commit -m "Release ${TAG}"
git tag -f "${TAG}"
git pull --rebase origin main
git tag -f "${TAG}"
git push origin main
git push --tags -f
gh release create "${TAG}" \
--title "PraisonAIUI ${TAG}" \
--notes "Release ${TAG}" \
--latest
- name: Verify release
if: inputs.dry_run != true
run: echo "✅ ${{ env.PACKAGE }}==${{ steps.versions.outputs.new_version }}"
@@ -25,7 +25,8 @@ jobs:
run: |
gh workflow run sync-secrets-to-aiui.yml \
--repo MervinPraison/PraisonAI \
--ref main
--ref main \
-f target_repo=MervinPraison/PraisonAIUI
echo "Triggered: https://github.com/MervinPraison/PraisonAI/actions/workflows/sync-secrets-to-aiui.yml"
echo ""
echo "If sync fails (401/403), refresh SECRETS_ADMIN_PAT in PraisonAI,"
+17 -1
View File
@@ -138,7 +138,23 @@ Final rule: conclude only when evidence shows missing = 0.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PUBLISH WORKFLOW (reference)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Step 1 — Publish praisonaiagents (Core SDK):
GitHub Actions (recommended):
GitHub → Actions → "PyPI Release" → Run workflow → bump: patch
Requires repo secrets: PYPI_TOKEN, GH_TOKEN
Requires GitHub Environment "pypi" with required reviewers (Settings → Environments)
Manual — Publish aiui (PraisonAIUI):
cd /Users/praison/PraisonAIUI
# Bump version in pyproject.toml and src/praisonaiui/__version__.py
rm -rf dist && uv lock && uv build && uv publish --token "$PYPI_TOKEN"
git add pyproject.toml uv.lock src/praisonaiui/__version__.py
git commit -m "Release vX.Y.Z" && git tag vX.Y.Z && git push && git push --tags
gh release create vX.Y.Z --title "PraisonAIUI vX.Y.Z" --notes "Release vX.Y.Z" --latest
PyPI package name: aiui
Verify: pip index versions aiui | head -1
PraisonAI stack (separate repo — publish SDK before wrapper):
cd /Users/praison/praisonai-package/src/praisonai-agents
praisonai publish pypi
# Uses uv internally: uv lock → uv build → uv publish
+16 -3
View File
@@ -48,18 +48,31 @@ Returns information about the active AI provider.
### GET /api/provider/health
Returns detailed health information from the provider gateway.
Returns detailed JSON health information from the provider gateway. Always
responds with `Content-Type: application/json`. The base fields from the
provider's `health()` payload are merged with the normalised fields below.
**Response:**
```json
{
"type": "gateway",
"status": "ok",
"provider": "PraisonAIProvider",
"type": "PraisonAIProvider",
"agents": 5,
"detail": "All agents operational"
"detail": "ok"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `status` | string | One of the health status values above. |
| `type` | string | Provider type (from `provider` field, falls back to class name). |
| `agents` | integer | Count of agents reported by the provider. |
| `detail` | string | Human-readable detail; `"ok"` for healthy providers. |
> **Note:** Any unmatched `/api/*` path returns a JSON `404` (never the SPA
> HTML shell), so API clients can rely on parsing the response body.
## CLI Commands
### health-check
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.122"
version = "0.3.127"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.121"
__version__ = "0.3.124"
+8 -6
View File
@@ -157,7 +157,7 @@ routes:
template: "docs"
"""
config_path.write_text(minimal_config)
config_path.write_text(minimal_config, encoding="utf-8")
console.print(Panel(f"[green]✓[/green] Created {config_path}", title="Success"))
@@ -598,7 +598,7 @@ def dev(
if dark_mode is not None:
config["site"]["theme"]["darkMode"] = dark_mode
tmp_cfg = example_path / ".aiui.tmp.yaml"
with open(tmp_cfg, "w") as f:
with open(tmp_cfg, "w", encoding="utf-8") as f:
yaml.dump(config, f, default_flow_style=False)
build_config = str(tmp_cfg)
@@ -1025,7 +1025,7 @@ def dev(
if example_name and example_name in examples:
yaml_path = examples_dir / example_name / "aiui.template.yaml"
# Write the new YAML
with open(yaml_path, "w") as f:
with open(yaml_path, "w", encoding="utf-8") as f:
f.write(yaml_content)
console.print(
f"[yellow]Rebuilding {example_name} with edited YAML...[/yellow]"
@@ -1520,12 +1520,14 @@ def run(
},
"style": style,
}
with open(ui_config_path, "w") as f:
with open(ui_config_path, "w", encoding="utf-8") as f:
_json.dump(ui_cfg, f, indent=2)
# Write minimal route-manifest and docs-nav so the SPA doesn't error
(output / "route-manifest.json").write_text('{"routes": []}')
(output / "docs-nav.json").write_text('{"items": []}')
(output / "route-manifest.json").write_text(
'{"routes": []}', encoding="utf-8"
)
(output / "docs-nav.json").write_text('{"items": []}', encoding="utf-8")
# Find available port
def find_available_port(start_port: int, max_attempts: int = 10) -> int:
+8 -4
View File
@@ -422,7 +422,9 @@ class Compiler:
def _write_json(self, path: Path, data: dict, indent: int | None) -> None:
"""Write JSON data to file."""
path.write_text(json.dumps(data, indent=indent, ensure_ascii=False))
path.write_text(
json.dumps(data, indent=indent, ensure_ascii=False), encoding="utf-8"
)
def _copy_viewer(self, output_dir: Path) -> None:
"""Copy frontend bundle to output directory."""
@@ -478,7 +480,7 @@ class Compiler:
"document.documentElement.classList.add('dark');",
"/* light mode — no dark class */",
)
html_path.write_text(html)
html_path.write_text(html, encoding="utf-8")
def _copy_plugins(self, output_dir: Path, frontend_dir: Path) -> None:
"""Copy frontend plugins and generate plugins.json config."""
@@ -519,7 +521,9 @@ class Compiler:
ordered.append(p)
plugins_config = {"plugins": ordered}
(plugins_dst / "plugins.json").write_text(json.dumps(plugins_config, indent=2))
(plugins_dst / "plugins.json").write_text(
json.dumps(plugins_config, indent=2), encoding="utf-8"
)
def _generate_route_pages(self, output_dir: Path, nav: dict) -> list[str]:
"""Generate per-route HTML files for SPA fallback and SEO.
@@ -593,7 +597,7 @@ class Compiler:
f'<div id="root"><noscript>{noscript_content}</noscript></div>',
)
page_file.write_text(html)
page_file.write_text(html, encoding="utf-8")
files.append(f"{relative}/index.html")
return files
+1 -1
View File
@@ -171,7 +171,7 @@ class YAMLConfigStore:
# Write to temp file in same directory, then atomic rename
fd, tmp_path = tempfile.mkstemp(dir=str(self._path.parent), suffix=".yaml.tmp")
try:
with os.fdopen(fd, "w") as f:
with os.fdopen(fd, "w", encoding="utf-8") as f:
yaml.dump(
self._data,
f,
+20 -7
View File
@@ -224,13 +224,15 @@ class ChannelsFeature(BaseFeatureProtocol):
try:
error = await self._start_channel_bot(channel_id, entry)
if error:
entry["running"] = False
entry["start_error"] = error
logger.warning("Auto-start '%s' failed: %s", channel_id, error)
else:
logger.info("Auto-started channel '%s' (%s)", channel_id, entry.get("platform"))
except Exception as e:
entry["running"] = False
entry["start_error"] = str(e)
logger.warning("Auto-start '%s' exception: %s", channel_id, e)
logger.exception("Auto-start '%s' exception: %s", channel_id, e)
self._sync_running_status()
# ── Gateway integration ──────────────────────────────────────────
@@ -255,6 +257,10 @@ class ChannelsFeature(BaseFeatureProtocol):
all_bots.setdefault(ch_id, info.get("bot"))
for ch_id, ch in _channels.items():
if ch.get("start_error"):
ch["running"] = False
continue
bot = all_bots.get(ch_id)
info = _live_bots.get(ch_id, {})
task = info.get("task")
@@ -411,12 +417,19 @@ class ChannelsFeature(BaseFeatureProtocol):
# Also register in gateway if available
if gw is not None:
channel_bots = getattr(gw, "_channel_bots", None)
channel_tasks = getattr(gw, "_channel_tasks", None)
if channel_bots is not None:
channel_bots[channel_id] = bot
if channel_tasks is not None:
channel_tasks.append(task)
try:
channel_bots = getattr(gw, "_channel_bots", None)
if isinstance(channel_bots, dict):
channel_bots[channel_id] = bot
channel_tasks = getattr(gw, "_channel_tasks", None)
if isinstance(channel_tasks, dict):
channel_tasks[channel_id] = task
elif isinstance(channel_tasks, list):
channel_tasks.append(task)
except Exception as e:
logger.warning(
"Gateway task registration failed for '%s': %s", channel_id, e
)
entry["running"] = True
entry.pop("start_error", None)
+65 -2
View File
@@ -46,10 +46,33 @@ def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _child_progress(task: Dict[str, Any]) -> Optional[Dict[str, int]]:
"""Derive done/total subtask progress from task.children or meta.
Accepts either a list of child dicts under "children" (each with a status)
or an explicit {"done": int, "total": int} under meta["progress"].
Returns None when there is nothing to report.
"""
meta = task.get("meta") or {}
explicit = meta.get("progress")
if isinstance(explicit, dict):
total = explicit.get("total")
done = explicit.get("done")
if isinstance(total, int) and total > 0 and isinstance(done, int):
return {"done": max(0, min(done, total)), "total": total}
children = task.get("children")
if isinstance(children, list) and children:
total = len(children)
done = sum(1 for c in children if isinstance(c, dict) and c.get("status") == "done")
return {"done": done, "total": total}
return None
def _task_card(task: Dict[str, Any]) -> Dict[str, Any]:
"""Normalise task dict to board card component."""
title = (task.get("title") or task.get("id") or "Task")[:120]
footer_parts = [p for p in (task.get("assignee"), task.get("priority")) if p]
comments = task.get("comments") or []
return {
"id": task.get("id"),
"title": title,
@@ -57,8 +80,12 @@ def _task_card(task: Dict[str, Any]) -> Dict[str, Any]:
"status": task.get("status"),
"assignee": task.get("assignee"),
"priority": task.get("priority"),
"tenant": task.get("tenant"),
"created_at": task.get("created_at"),
"comment_count": len(comments),
"progress": _child_progress(task),
"body": task.get("body"),
"comments": task.get("comments") or [],
"comments": comments,
"meta": task.get("meta") or {},
}
@@ -90,6 +117,9 @@ class KanbanStoreProtocol(ABC):
@abstractmethod
def move_task(self, task_id: str, status: str) -> Optional[Dict[str, Any]]: ...
@abstractmethod
def add_comment(self, task_id: str, comment: Dict[str, Any]) -> Optional[Dict[str, Any]]: ...
@abstractmethod
def bulk_update(self, task_ids: List[str], status: str) -> Dict[str, Any]: ...
@@ -174,6 +204,7 @@ class SimpleKanbanStore(KanbanStoreProtocol):
"priority": data.get("priority"),
"tenant": data.get("tenant"),
"board": data.get("board", "default"),
"children": data.get("children") or [],
"comments": [],
"meta": data.get("meta") or {},
"created_at": _now_iso(),
@@ -188,7 +219,7 @@ class SimpleKanbanStore(KanbanStoreProtocol):
task = self._tasks.get(task_id)
if not task:
return None
for key in ("title", "body", "assignee", "priority", "tenant", "meta"):
for key in ("title", "body", "assignee", "priority", "tenant", "meta", "children"):
if key in data:
task[key] = data[key]
if "status" in data and data["status"] in VALID_STATUSES:
@@ -209,6 +240,24 @@ class SimpleKanbanStore(KanbanStoreProtocol):
self._emit("task_moved", task, {"from_status": old, "to_status": status})
return dict(task)
def add_comment(self, task_id: str, comment: Dict[str, Any]) -> Optional[Dict[str, Any]]:
task = self._tasks.get(task_id)
if not task:
return None
text = str((comment or {}).get("text") or "").strip()
if not text:
return None
entry = {
"id": f"c_{uuid.uuid4().hex[:8]}",
"author": (comment or {}).get("author") or "human",
"text": text,
"created_at": _now_iso(),
}
task.setdefault("comments", []).append(entry)
task["updated_at"] = _now_iso()
self._emit("task_commented", task, {"comment_id": entry["id"]})
return dict(task)
def bulk_update(self, task_ids: List[str], status: str) -> Dict[str, Any]:
if status not in VALID_STATUSES:
return {"updated": 0, "errors": ["invalid status"]}
@@ -264,6 +313,9 @@ class InjectedKanbanStore(KanbanStoreProtocol):
def move_task(self, task_id: str, status: str) -> Optional[Dict[str, Any]]:
return self._call("move_task", task_id, status)
def add_comment(self, task_id: str, comment: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return self._call("add_comment", task_id, comment)
def bulk_update(self, task_ids: List[str], status: str) -> Dict[str, Any]:
return self._call("bulk_update", task_ids, status)
@@ -331,6 +383,7 @@ class KanbanFeature(BaseFeatureProtocol):
Route("/api/kanban/tasks/{task_id}", self._patch, methods=["PATCH"]),
Route("/api/kanban/tasks/{task_id}", self._delete, methods=["DELETE"]),
Route("/api/kanban/tasks/{task_id}/move", self._move, methods=["POST"]),
Route("/api/kanban/tasks/{task_id}/comments", self._add_comment, methods=["POST"]),
Route("/api/kanban/events", self._events, methods=["GET"]),
Route("/api/kanban/health", self._health, methods=["GET"]),
Route("/api/kanban/boards", self._boards, methods=["GET"]),
@@ -380,6 +433,16 @@ class KanbanFeature(BaseFeatureProtocol):
return JSONResponse({"error": "not found or invalid status"}, status_code=404)
return JSONResponse(task)
async def _add_comment(self, request: Request) -> JSONResponse:
store = get_kanban_store()
body = await request.json()
if not isinstance(body, dict) or not str(body.get("text") or "").strip():
return JSONResponse({"error": "text required"}, status_code=400)
task = store.add_comment(request.path_params["task_id"], body)
if not task:
return JSONResponse({"error": "not found"}, status_code=404)
return JSONResponse(task)
async def _bulk(self, request: Request) -> JSONResponse:
store = get_kanban_store()
body = await request.json()
+1 -1
View File
@@ -278,7 +278,7 @@ def _save_data() -> None:
"saved_at": time.time(),
}
_data_file.parent.mkdir(parents=True, exist_ok=True)
with open(_data_file, "w") as f:
with open(_data_file, "w", encoding="utf-8") as f:
json.dump(data, f)
except Exception:
pass # Silently fail
+109 -26
View File
@@ -825,8 +825,17 @@ async def _plugins_config(request: Request) -> JSONResponse:
return JSONResponse({"plugins": plugins})
async def _serve_index(request: Request) -> HTMLResponse:
"""Serve dynamically generated HTML based on active style."""
async def _serve_index(request: Request) -> Response:
"""Serve dynamically generated HTML based on active style.
Unmatched ``/api/*`` paths return a JSON 404 instead of the SPA shell so
API clients never receive HTML with a 200 status.
"""
if request.url.path.startswith("/api/"):
return JSONResponse(
{"error": "Not Found", "path": request.url.path},
status_code=404,
)
style = _effective_style # Single source of truth (set by create_app)
return HTMLResponse(_build_html(style))
@@ -938,49 +947,70 @@ def register_page_action(page_id: str):
return decorator
_DEEP_HEALTH_CACHE: Dict[str, Any] = {}
_DEEP_HEALTH_CACHE_TIME: float = 0.0
_DEEP_HEALTH_TTL: float = 5.0
async def health(request: Request) -> JSONResponse:
"""Health check endpoint - deep health with feature checks.
Performs deep health checks on all features in parallel with a per-feature
timeout so a single slow feature cannot block the whole response. Pass
?deep=false for an immediate liveness response. Use /health/live for the
fastest liveness probe or /health/ready for the readiness probe.
The provider check and every feature check run concurrently under a single
overall deadline so neither the provider nor a slow feature can serialise
the response. A short-TTL cache absorbs repeated probes. Pass ?deep=false
for an immediate liveness response. Use /health/live for the fastest
liveness probe or /health/ready for the readiness probe.
"""
import asyncio
import time
if request.query_params.get("deep", "true").lower() == "false":
return await health_live(request)
global _DEEP_HEALTH_CACHE, _DEEP_HEALTH_CACHE_TIME
now = time.monotonic()
if _DEEP_HEALTH_CACHE and (now - _DEEP_HEALTH_CACHE_TIME) < _DEEP_HEALTH_TTL:
cached = dict(_DEEP_HEALTH_CACHE)
cached["timestamp"] = datetime.utcnow().isoformat()
cached["cached"] = True
return JSONResponse(cached)
provider = get_provider()
provider_info = {"name": type(provider).__name__}
try:
provider_health = await provider.health()
provider_info.update(provider_health)
except Exception:
pass
async def _safe_provider() -> Dict[str, Any]:
try:
return await asyncio.wait_for(provider.health(), timeout=0.5)
except Exception:
return {}
sdk_gaps: list = []
integrated_flag: Optional[bool] = None
async def _safe_health(name: str, feat) -> tuple[str, Dict[str, Any]]:
try:
result = await asyncio.wait_for(feat.health(), timeout=0.5)
return (name, result)
except asyncio.TimeoutError:
return (name, {"healthy": False, "detail": "timeout"})
except Exception as e:
return (name, {"healthy": False, "detail": str(e)})
try:
from praisonaiui.backends import is_integrated_mode
from praisonaiui.features import get_features
integrated_flag = is_integrated_mode()
async def _safe_health(name: str, feat) -> tuple[str, Dict[str, Any]]:
try:
result = await asyncio.wait_for(feat.health(), timeout=0.5)
return (name, result)
except asyncio.TimeoutError:
return (name, {"healthy": False, "detail": "timeout"})
except Exception as e:
return (name, {"healthy": False, "detail": str(e)})
features = get_features()
health_tasks = [_safe_health(name, feat) for name, feat in features.items()]
results = await asyncio.gather(*health_tasks)
feature_tasks = [_safe_health(name, feat) for name, feat in features.items()]
for name, fh in results:
provider_result, *feature_results = await asyncio.gather(
_safe_provider(), *feature_tasks
)
provider_info.update(provider_result)
for name, fh in feature_results:
if fh.get("sdk_gap"):
sdk_gaps.append(
{
@@ -989,7 +1019,10 @@ async def health(request: Request) -> JSONResponse:
}
)
except Exception:
pass
try:
provider_info.update(await _safe_provider())
except Exception:
pass
payload: Dict[str, Any] = {
"status": "ok",
@@ -1000,6 +1033,9 @@ async def health(request: Request) -> JSONResponse:
payload["sdk_gaps"] = sdk_gaps
if integrated_flag is not None:
payload["integrated"] = integrated_flag
_DEEP_HEALTH_CACHE = payload
_DEEP_HEALTH_CACHE_TIME = time.monotonic()
return JSONResponse(payload)
@@ -1457,7 +1493,7 @@ async def api_config_handler(request: Request) -> JSONResponse:
config_data = body.get("config", body)
import yaml
with open(_config_path, "w") as f:
with open(_config_path, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
_config_cache = config_data
return JSONResponse({"status": "saved", "config_path": str(_config_path)})
@@ -1544,6 +1580,34 @@ async def api_provider(request: Request) -> JSONResponse:
return JSONResponse(info)
async def api_provider_health(request: Request) -> JSONResponse:
"""Return JSON health information for the active provider gateway."""
provider = get_provider()
try:
health = await provider.health()
except Exception as exc:
health = {"status": "error", "detail": str(exc)}
agents: list = []
try:
listed = await provider.list_agents()
if isinstance(listed, list):
agents = listed
except Exception:
agents = []
status = health.get("status", "unknown")
detail = health.get("detail") or (
"ok" if status in ("ok", "healthy") else str(health)
)
body = {
**health,
"type": health.get("provider", type(provider).__name__),
"status": status,
"agents": len(agents),
"detail": detail,
}
return JSONResponse(body)
async def api_action_click(request: Request) -> JSONResponse:
"""Handle action button clicks and dispatch to registered callbacks.
@@ -3043,6 +3107,7 @@ def create_app(
# Note: /api/usage is now provided by PraisonAIUsage feature
Route("/api/debug", api_debug, methods=["GET"]),
Route("/api/provider", api_provider, methods=["GET"]),
Route("/api/provider/health", api_provider_health, methods=["GET"]),
# Action callback API
Route("/api/actions/{action_id}/click", api_action_click, methods=["POST"]),
# Page registry protocol
@@ -3331,6 +3396,14 @@ def create_app(
"description": "Execution nodes & presence",
"order": 15,
},
{
"id": "mcp",
"title": "MCP",
"icon": "🔌",
"group": "Agent",
"description": "Model Context Protocol servers",
"order": 30,
},
{
"id": "config",
"title": "Config",
@@ -3425,6 +3498,7 @@ def create_app(
"sessions": "/sessions",
"cron": "/api/schedules",
"channels": "/api/channels",
"mcp": "/api/mcp/servers",
}
# Read page whitelist from config.yaml if set_pages() was not called
@@ -3516,6 +3590,15 @@ def create_app(
response_status = 404
if response_status == 404:
request_path = scope.get("path", "")
if request_path.startswith("/api/"):
# Never serve the SPA HTML shell for unmatched API paths.
response = JSONResponse(
{"error": "Not Found", "path": request_path},
status_code=404,
)
await response(scope, receive, send)
return
# SPA fallback — serve the generated index page
from starlette.responses import HTMLResponse
@@ -35,6 +35,60 @@ function el(tag, attrs, children) {
return node;
}
function relativeTime(value) {
if (!value) return '';
const then = typeof value === 'number' ? value * 1000 : Date.parse(value);
if (!then || Number.isNaN(then)) return '';
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
function escapeHtml(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderMarkdown(text) {
const safe = escapeHtml(text);
const lines = safe.split(/\r?\n/);
const html = [];
let inCode = false;
let listOpen = false;
const closeList = () => { if (listOpen) { html.push('</ul>'); listOpen = false; } };
lines.forEach((raw) => {
if (/^```/.test(raw.trim())) {
if (inCode) { html.push('</code></pre>'); inCode = false; }
else { closeList(); html.push('<pre><code>'); inCode = true; }
return;
}
if (inCode) { html.push(raw + '\n'); return; }
let line = raw;
line = line.replace(/`([^`]+)`/g, '<code>$1</code>');
line = line.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
line = line.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
const heading = line.match(/^(#{1,3})\s+(.*)$/);
if (heading) { closeList(); html.push(`<h${heading[1].length}>${heading[2]}</h${heading[1].length}>`); return; }
const bullet = line.match(/^\s*[-*]\s+(.*)$/);
if (bullet) { if (!listOpen) { html.push('<ul>'); listOpen = true; } html.push(`<li>${bullet[1]}</li>`); return; }
closeList();
if (line.trim() === '') html.push('<br/>');
else html.push(`<p>${line}</p>`);
});
closeList();
if (inCode) html.push('</code></pre>');
return html.join('');
}
function cardElement(card, { selectable, selected, onSelect, onOpen }) {
const wrap = el('div', {
className: `aiui-board-card${selected ? ' aiui-board-card-selected' : ''}`,
@@ -42,18 +96,48 @@ function cardElement(card, { selectable, selected, onSelect, onOpen }) {
'data-task-id': card.id || '',
'data-status': card.status || '',
});
const inner = el('div', { className: 'db-card' });
if (card.title) inner.appendChild(el('div', { className: 'db-card-title', text: card.title }));
if (card.value != null) inner.appendChild(el('div', { className: 'db-card-value', text: String(card.value) }));
const footer = card.footer || card.assignee || '';
if (footer) inner.appendChild(el('div', { className: 'db-card-footer', text: footer }));
const inner = el('div', { className: 'db-card aiui-board-card-dense' });
const head = el('div', { className: 'aiui-board-card-head' });
if (selectable && onSelect) {
const box = el('input', {
type: 'checkbox',
className: 'aiui-board-card-check',
'aria-label': 'Select task',
});
box.checked = !!selected;
box.addEventListener('click', (e) => { e.stopPropagation(); onSelect(card.id, true); });
head.appendChild(box);
}
if (card.id) head.appendChild(el('span', { className: 'aiui-board-card-id', text: card.id }));
const colour = STATUS_COLOURS[card.status];
if (card.status) {
const dot = el('span', { className: 'aiui-board-status-dot', text: '●', title: card.status });
if (colour) dot.style.color = colour;
head.appendChild(dot);
}
if (card.priority) {
inner.appendChild(el('span', {
className: 'aiui-board-badge',
text: String(card.priority),
style: 'font-size:10px;margin-left:6px;opacity:0.8',
head.appendChild(el('span', { className: 'aiui-board-badge aiui-board-priority', text: String(card.priority) }));
}
inner.appendChild(head);
if (card.title) inner.appendChild(el('div', { className: 'db-card-title aiui-board-card-name', text: card.title }));
if (card.value != null) inner.appendChild(el('div', { className: 'db-card-value', text: String(card.value) }));
const foot = el('div', { className: 'aiui-board-card-foot' });
if (card.tenant) foot.appendChild(el('span', { className: 'aiui-board-chip aiui-board-tenant', text: String(card.tenant) }));
if (card.assignee) foot.appendChild(el('span', { className: 'aiui-board-chip', text: String(card.assignee) }));
const age = relativeTime(card.created_at);
if (age) foot.appendChild(el('span', { className: 'aiui-board-card-age', text: age }));
if (card.comment_count) foot.appendChild(el('span', { className: 'aiui-board-card-count', text: `\uD83D\uDCAC ${card.comment_count}` }));
if (card.progress && card.progress.total) {
foot.appendChild(el('span', {
className: 'aiui-board-progress',
text: `${card.progress.done}/${card.progress.total}`,
}));
}
if (foot.childNodes.length) inner.appendChild(foot);
wrap.appendChild(inner);
wrap.addEventListener('click', (e) => {
if (e.shiftKey && onSelect) onSelect(card.id, true);
@@ -148,7 +232,9 @@ function renderBoardDOM(container, data, opts) {
e.preventDefault();
trash.classList.remove('aiui-board-drop-target');
const taskId = e.dataTransfer.getData('text/plain');
if (taskId && opts.onDelete) await opts.onDelete(taskId);
if (!taskId || !opts.onDelete) return;
if (typeof confirm === 'function' && !confirm('Permanently delete this task?')) return;
await opts.onDelete(taskId);
});
wrap.appendChild(trash);
}
@@ -156,46 +242,170 @@ function renderBoardDOM(container, data, opts) {
return wrap;
}
function openTaskDrawer(card, { apiBase, onClose, onRefresh }) {
function historyRows(card) {
const meta = card.meta || {};
const rows = [];
const events = meta.events || meta.history;
if (Array.isArray(events)) {
events.forEach((ev) => {
if (typeof ev === 'string') rows.push({ text: ev });
else if (ev && typeof ev === 'object') {
const label = ev.type || ev.event || ev.status || 'event';
const when = relativeTime(ev.ts || ev.created_at || ev.time);
rows.push({ text: ev.text || ev.message || label, when });
}
});
}
return rows;
}
function openTaskDrawer(card, { apiBase, onClose, onRefresh, fetchTask }) {
const overlay = el('div', { className: 'aiui-board-drawer-overlay' });
const drawer = el('div', { className: 'aiui-board-drawer' });
const close = () => overlay.remove();
drawer.appendChild(el('button', {
className: 'aiui-board-drawer-close',
text: '×',
onclick: close,
}));
drawer.appendChild(el('h3', { text: card.title || 'Task' }));
if (card.body) drawer.appendChild(el('pre', { className: 'aiui-board-drawer-body', text: card.body }));
else drawer.appendChild(el('p', { className: 'aiui-board-drawer-meta', text: `ID: ${card.id || '—'}` }));
const meta = el('div', { className: 'aiui-board-drawer-meta' });
meta.textContent = [card.status, card.assignee, card.priority].filter(Boolean).join(' · ');
drawer.appendChild(meta);
let current = card;
let closed = false;
const close = () => { closed = true; overlay.remove(); if (onClose) onClose(); };
if (apiBase && card.id) {
const actions = el('div', { className: 'aiui-board-drawer-actions' });
['blocked', 'ready', 'done'].forEach((st) => {
actions.appendChild(el('button', {
className: 'db-btn db-btn-sm',
text: st,
onclick: async () => {
await fetch(`${apiBase}/tasks/${card.id}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: st }),
});
if (onRefresh) await onRefresh();
close();
if (onClose) onClose();
},
}));
const editable = !!(apiBase && card.id);
async function patch(data) {
if (!editable) return;
await fetch(`${apiBase}/tasks/${current.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
drawer.appendChild(actions);
if (onRefresh) await onRefresh();
await reload();
}
async function reload() {
if (closed) return;
if (fetchTask && current.id) {
try {
const fresh = await fetchTask(current.id);
if (fresh) current = { ...current, ...fresh };
} catch (_) { /* keep stale */ }
}
render();
}
function field(label, value, onSave) {
const row = el('div', { className: 'aiui-board-drawer-field' });
row.appendChild(el('label', { className: 'aiui-board-drawer-label', text: label }));
const input = el('input', { className: 'aiui-board-drawer-input', value: value == null ? '' : String(value) });
if (!editable) input.disabled = true;
input.addEventListener('change', () => onSave(input.value));
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); onSave(input.value); } });
row.appendChild(input);
return row;
}
function render() {
drawer.innerHTML = '';
drawer.appendChild(el('button', { className: 'aiui-board-drawer-close', text: '×', onclick: close }));
drawer.appendChild(field('Title', current.title, (v) => patch({ title: v })));
if (current.body != null) {
const bodyEl = el('div', { className: 'aiui-board-drawer-body aiui-board-markdown' });
bodyEl.innerHTML = renderMarkdown(current.body);
drawer.appendChild(bodyEl);
}
const grid = el('div', { className: 'aiui-board-drawer-grid' });
grid.appendChild(field('Assignee', current.assignee, (v) => patch({ assignee: v })));
grid.appendChild(field('Priority', current.priority, (v) => patch({ priority: v })));
drawer.appendChild(grid);
const meta = el('div', { className: 'aiui-board-drawer-meta' });
meta.textContent = [current.status, current.tenant, relativeTime(current.created_at)].filter(Boolean).join(' · ');
drawer.appendChild(meta);
const hist = historyRows(current);
if (hist.length) {
drawer.appendChild(el('h4', { className: 'aiui-board-drawer-h', text: 'History' }));
const histWrap = el('div', { className: 'aiui-board-history' });
hist.forEach((r) => {
const row = el('div', { className: 'aiui-board-history-row' });
row.appendChild(el('span', { className: 'aiui-board-history-text', text: r.text }));
if (r.when) row.appendChild(el('span', { className: 'aiui-board-history-when', text: r.when }));
histWrap.appendChild(row);
});
drawer.appendChild(histWrap);
}
drawer.appendChild(el('h4', { className: 'aiui-board-drawer-h', text: 'Comments' }));
const thread = el('div', { className: 'aiui-board-comments' });
(current.comments || []).forEach((c) => {
const item = el('div', { className: 'aiui-board-comment' });
const head = el('div', { className: 'aiui-board-comment-head' });
head.appendChild(el('span', { className: 'aiui-board-comment-author', text: c.author || 'human' }));
const when = relativeTime(c.created_at);
if (when) head.appendChild(el('span', { className: 'aiui-board-comment-when', text: when }));
item.appendChild(head);
const text = el('div', { className: 'aiui-board-comment-text aiui-board-markdown' });
text.innerHTML = renderMarkdown(typeof c === 'string' ? c : (c.text || ''));
item.appendChild(text);
thread.appendChild(item);
});
drawer.appendChild(thread);
if (editable) {
const box = el('textarea', {
className: 'aiui-board-comment-input',
placeholder: 'Add a comment… (Enter to submit, Shift+Enter for newline)',
rows: '2',
});
const submit = async () => {
const value = box.value.trim();
if (!value) return;
box.value = '';
await fetch(`${apiBase}/tasks/${current.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: value }),
});
if (onRefresh) await onRefresh();
await reload();
};
box.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
});
drawer.appendChild(box);
const actions = el('div', { className: 'aiui-board-drawer-actions' });
['blocked', 'ready', 'done'].forEach((st) => {
actions.appendChild(el('button', {
className: 'db-btn db-btn-sm',
text: st,
onclick: async () => {
if ((st === 'done' || st === 'blocked') && !confirm(`Move task to "${st}"?`)) return;
await fetch(`${apiBase}/tasks/${current.id}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: st }),
});
if (onRefresh) await onRefresh();
close();
},
}));
});
drawer.appendChild(actions);
}
}
render();
overlay.appendChild(drawer);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.body.appendChild(overlay);
return {
id: card.id,
isOpen: () => !closed,
refresh: reload,
close,
};
}
/**
@@ -205,8 +415,11 @@ export function createInteractiveBoard(root, opts = {}) {
let destroyed = false;
let pollTimer = null;
let eventSource = null;
let debounceTimer = null;
let openDrawer = null;
const selected = new Set();
let lastData = null;
const debounceMs = opts.debounceMs != null ? opts.debounceMs : 300;
const toolbar = el('div', { className: 'aiui-board-toolbar' });
root.appendChild(toolbar);
@@ -231,6 +444,14 @@ export function createInteractiveBoard(root, opts = {}) {
if (!res.ok) throw new Error(await res.text());
}
const drawerApiBase = (opts.drawerApiBase || apiBase).replace(/\/$/, '');
async function fetchTask(taskId) {
const res = await fetch(`${drawerApiBase}/tasks/${taskId}`);
if (!res.ok) return null;
return res.json();
}
async function defaultBulk(status) {
if (!selected.size) return;
const res = await fetch(`${apiBase}/tasks/bulk`, {
@@ -303,7 +524,15 @@ export function createInteractiveBoard(root, opts = {}) {
showTrash: opts.showTrash !== false,
selected,
onSelect: opts.interactive !== false ? onSelect : null,
onOpen: (card) => openTaskDrawer(card, { apiBase: opts.drawerApiBase || apiBase, onRefresh: refresh }),
onOpen: (card) => {
if (openDrawer && openDrawer.isOpen()) openDrawer.close();
openDrawer = openTaskDrawer(card, {
apiBase: drawerApiBase,
onRefresh: refresh,
fetchTask,
onClose: () => { openDrawer = null; },
});
},
onMove: async (taskId, status) => {
await (opts.onMove || defaultMove)(taskId, status);
await refresh();
@@ -319,12 +548,28 @@ export function createInteractiveBoard(root, opts = {}) {
}
}
function debouncedRefresh() {
if (!debounceMs) { refresh(); return; }
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = null; refresh(); }, debounceMs);
}
function handleEvent(raw) {
let ev = null;
try { ev = raw ? JSON.parse(raw) : null; } catch (_) { ev = null; }
if (openDrawer && openDrawer.isOpen() && ev && ev.task_id && ev.task_id === openDrawer.id) {
openDrawer.refresh();
}
debouncedRefresh();
}
function connectEvents() {
if (!opts.eventsUrl || typeof EventSource === 'undefined') return;
const url = opts.eventsUrl + (opts.eventsUrl.includes('?') ? '&' : '?') + `board=${encodeURIComponent(opts.currentBoard || 'default')}`;
const activeBoard = opts.boardState?.id || opts.currentBoard || 'default';
const url = opts.eventsUrl + (opts.eventsUrl.includes('?') ? '&' : '?') + `board=${encodeURIComponent(activeBoard)}`;
try {
eventSource = new EventSource(url);
eventSource.onmessage = () => refresh();
eventSource.onmessage = (e) => handleEvent(e && e.data);
} catch (_) { /* polling fallback */ }
}
@@ -337,7 +582,9 @@ export function createInteractiveBoard(root, opts = {}) {
destroy() {
destroyed = true;
if (pollTimer) clearInterval(pollTimer);
if (debounceTimer) clearTimeout(debounceTimer);
if (eventSource) eventSource.close();
if (openDrawer && openDrawer.isOpen()) openDrawer.close();
root.innerHTML = '';
},
};
@@ -348,4 +595,4 @@ export function createBoard(root, opts = {}) {
return createInteractiveBoard(root, { ...opts, interactive: false, showTrash: false });
}
export { renderBoardDOM, el };
export { renderBoardDOM, el, renderMarkdown, relativeTime, escapeHtml };
@@ -174,6 +174,7 @@ const BUILTIN_VIEWS = {
'jobs-board': '/plugins/views/jobs-board.js',
auth: '/plugins/views/auth.js',
api: '/plugins/views/api.js',
mcp: '/plugins/views/mcp.js',
};
const SLOT_REGISTRY = {};
@@ -407,6 +408,37 @@ const DASHBOARD_STYLE = `
.aiui-board-drawer-body { white-space: pre-wrap; font-size: 13px; color: var(--db-text-dim); }
.aiui-board-drawer-meta { font-size: 12px; color: var(--db-text-dim); margin: 8px 0; }
.aiui-board-drawer-actions { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.aiui-board-card-dense { padding: 10px 12px; }
.aiui-board-card-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.aiui-board-card-check { margin: 0; cursor: pointer; }
.aiui-board-card-id { font-family: var(--db-mono, monospace); font-size: 10px; color: var(--db-text-dim); }
.aiui-board-status-dot { font-size: 10px; line-height: 1; }
.aiui-board-priority { margin-left: auto; font-size: 10px; opacity: 0.85; }
.aiui-board-card-name { font-size: 13px; text-transform: none; letter-spacing: 0; color: var(--db-text); margin: 0 0 6px; font-weight: 500; }
.aiui-board-card-foot { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11px; color: var(--db-text-dim); }
.aiui-board-chip { padding: 1px 6px; border-radius: 999px; background: rgba(255,255,255,0.06); font-size: 10px; }
.aiui-board-tenant { background: var(--db-accent-glow); color: var(--db-accent); }
.aiui-board-card-age { opacity: 0.8; }
.aiui-board-card-count { opacity: 0.9; }
.aiui-board-progress { margin-left: auto; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--db-border); font-size: 10px; }
.aiui-board-drawer-field { margin: 8px 0; }
.aiui-board-drawer-label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--db-text-dim); margin-bottom: 4px; }
.aiui-board-drawer-input { width: 100%; padding: 6px 8px; background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: var(--db-radius); color: var(--db-text); font-size: 13px; }
.aiui-board-drawer-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.aiui-board-drawer-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--db-text-dim); margin: 16px 0 8px; }
.aiui-board-markdown { font-size: 13px; line-height: 1.5; color: var(--db-text-dim); }
.aiui-board-markdown p { margin: 0 0 6px; }
.aiui-board-markdown pre { background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: var(--db-radius); padding: 8px; overflow-x: auto; }
.aiui-board-markdown code { font-family: var(--db-mono, monospace); font-size: 12px; }
.aiui-board-history { display: flex; flex-direction: column; gap: 4px; }
.aiui-board-history-row { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--db-text-dim); }
.aiui-board-history-when { opacity: 0.7; white-space: nowrap; }
.aiui-board-comments { display: flex; flex-direction: column; gap: 10px; }
.aiui-board-comment { border-left: 2px solid var(--db-border); padding-left: 10px; }
.aiui-board-comment-head { display: flex; gap: 8px; align-items: baseline; }
.aiui-board-comment-author { font-size: 12px; font-weight: 600; color: var(--db-text); }
.aiui-board-comment-when { font-size: 11px; color: var(--db-text-dim); }
.aiui-board-comment-input { width: 100%; margin-top: 10px; padding: 8px; background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: var(--db-radius); color: var(--db-text); font-size: 13px; resize: vertical; }
.aiui-board-page-header { margin-bottom: 8px; }
.db-slot-host { min-height: 0; }
.db-sidebar-footer { margin-top: auto; padding: 10px 14px; border-top: 1px solid var(--db-border); font-size: 11px; color: var(--db-text-dim); }
@@ -693,6 +725,19 @@ const DASHBOARD_STYLE = `
.db-dialog-title { font-size: 18px; font-weight: 600; margin: 0 0 4px; }
.db-dialog-desc { font-size: 13px; color: var(--db-text-dim); margin: 0 0 16px; }
/* ── Session Search Palette (Ctrl+K) ─────────────────── */
.db-session-search-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 300; display: flex; align-items: flex-start; justify-content: center; padding-top: 12vh; backdrop-filter: blur(4px); }
.db-session-search { background: var(--db-sidebar-bg); border: 1px solid var(--db-border); border-radius: 12px; width: 560px; max-width: 90vw; max-height: 60vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 12px 40px rgba(0,0,0,0.4); }
.db-session-search-input { width: 100%; padding: 16px 18px; font-size: 15px; color: var(--db-text); background: transparent; border: none; border-bottom: 1px solid var(--db-border); outline: none; box-sizing: border-box; }
.db-session-search-list { overflow-y: auto; padding: 6px; }
.db-session-search-empty { padding: 24px; text-align: center; font-size: 13px; color: var(--db-text-dim); }
.db-session-search-group { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--db-text-dim); padding: 10px 12px 4px; }
.db-session-search-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border-radius: 8px; cursor: pointer; }
.db-session-search-item:hover, .db-session-search-item.active { background: var(--db-hover); }
.db-session-search-item-title { font-size: 14px; font-weight: 500; color: var(--db-text); }
.db-session-search-item-meta { font-size: 12px; color: var(--db-text-dim); }
.db-session-search-item-current { font-size: 11px; padding: 2px 8px; border-radius: 6px; background: var(--db-accent); color: #fff; }
/* ── Caption ─────────────────────────────────────────── */
.db-caption { font-size: 12px; color: var(--db-text-dim); margin-bottom: 8px; }
@@ -747,6 +792,20 @@ const DASHBOARD_STYLE = `
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes slideIn { from { transform: translateY(-20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
/* ── Command Palette (Ctrl+K) ────────────────────────── */
.db-cmdk-overlay { position: fixed; inset: 0; z-index: 10002; display: flex; align-items: flex-start; justify-content: center; padding-top: 12vh; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); animation: fadeIn 0.15s ease; }
.db-cmdk-box { width: 560px; max-width: 92vw; background: var(--db-sidebar-bg); border: 1px solid var(--db-border); border-radius: 12px; box-shadow: 0 16px 60px rgba(0,0,0,0.55); overflow: hidden; animation: slideIn 0.15s ease; }
.db-cmdk-input { width: 100%; box-sizing: border-box; padding: 16px 20px; font-size: 15px; color: var(--db-text); background: transparent; border: none; border-bottom: 1px solid var(--db-border); outline: none; }
.db-cmdk-input::placeholder { color: var(--db-text-dim); }
.db-cmdk-list { max-height: 50vh; overflow-y: auto; padding: 6px; }
.db-cmdk-item { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 8px; cursor: pointer; font-size: 14px; color: var(--db-text); }
.db-cmdk-item.active { background: var(--db-accent); color: #fff; }
.db-cmdk-item.active .db-cmdk-group { color: rgba(255,255,255,0.8); }
.db-cmdk-icon { width: 20px; text-align: center; flex-shrink: 0; }
.db-cmdk-title { flex: 1; }
.db-cmdk-group { font-size: 11px; color: var(--db-text-dim); text-transform: uppercase; letter-spacing: 0.04em; }
.db-cmdk-empty { padding: 24px; text-align: center; font-size: 13px; color: var(--db-text-dim); }
`;
let activePageId = null;
@@ -909,6 +968,284 @@ function setupMobileNav(root, sidebar) {
}
}
let _paletteEl = null;
let _paletteActive = -1;
let _paletteResults = [];
function openCommandPalette() {
if (_paletteEl) { closeCommandPalette(); return; }
const overlay = document.createElement('div');
overlay.className = 'db-cmdk-overlay';
overlay.innerHTML = `
<div class="db-cmdk-box" role="dialog" aria-label="Command palette">
<input class="db-cmdk-input" type="text" placeholder="Search pages and actions…" autocomplete="off" spellcheck="false">
<div class="db-cmdk-list"></div>
</div>`;
document.body.appendChild(overlay);
_paletteEl = overlay;
const input = overlay.querySelector('.db-cmdk-input');
const listEl = overlay.querySelector('.db-cmdk-list');
const sessionAction = { id: '__sessions', title: 'Search sessions…', icon: '⌕', group: 'Actions', action: 'sessions' };
const renderResults = (q) => {
const query = q.trim().toLowerCase();
const candidates = pagesData
.filter((p) => p.id !== activePageId || query)
.concat([sessionAction]);
const items = candidates
.map((p) => ({ page: p, score: paletteScore(p, query) }))
.filter((x) => query === '' || x.score > 0)
.sort((a, b) => b.score - a.score || (a.page.order || 0) - (b.page.order || 0))
.slice(0, 50)
.map((x) => x.page);
_paletteResults = items;
_paletteActive = items.length ? 0 : -1;
listEl.innerHTML = items.length
? items
.map(
(p, i) =>
`<div class="db-cmdk-item${i === 0 ? ' active' : ''}" data-idx="${i}" data-id="${p.id}"><span class="db-cmdk-icon">${p.icon || '•'}</span><span class="db-cmdk-title">${p.title}</span>${p.group ? `<span class="db-cmdk-group">${p.group}</span>` : ''}</div>`
)
.join('')
: '<div class="db-cmdk-empty">No matches</div>';
listEl.querySelectorAll('.db-cmdk-item').forEach((el) => {
el.addEventListener('click', () => runPaletteItem(parseInt(el.dataset.idx, 10)));
el.addEventListener('mousemove', () => setPaletteActive(parseInt(el.dataset.idx, 10)));
});
};
input.addEventListener('input', () => renderResults(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setPaletteActive(_paletteActive + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setPaletteActive(_paletteActive - 1); }
else if (e.key === 'Enter') { e.preventDefault(); runPaletteItem(_paletteActive); }
else if (e.key === 'Escape') { e.preventDefault(); closeCommandPalette(); }
});
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeCommandPalette(); });
renderResults('');
input.focus();
}
function paletteScore(page, query) {
if (!query) return 1;
const hay = `${page.title} ${page.id} ${page.group || ''} ${page.description || ''}`.toLowerCase();
if (hay.includes(query)) return 100 - hay.indexOf(query);
let qi = 0;
for (let i = 0; i < hay.length && qi < query.length; i++) {
if (hay[i] === query[qi]) qi++;
}
return qi === query.length ? 1 : 0;
}
function setPaletteActive(idx) {
if (!_paletteEl || !_paletteResults.length) return;
const n = _paletteResults.length;
_paletteActive = ((idx % n) + n) % n;
const items = _paletteEl.querySelectorAll('.db-cmdk-item');
items.forEach((el, i) => el.classList.toggle('active', i === _paletteActive));
const activeEl = items[_paletteActive];
if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
}
function runPaletteItem(idx) {
const page = _paletteResults[idx];
if (!page) return;
closeCommandPalette();
if (page.action === 'sessions') {
openSessionSearch();
return;
}
selectPage(page.id);
}
function closeCommandPalette() {
if (_paletteEl) { _paletteEl.remove(); _paletteEl = null; }
_paletteActive = -1;
_paletteResults = [];
}
function initCommandPalette() {
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
openCommandPalette();
}
});
}
// ── Session Search Palette ──────────────────────────────────────────
// Vanilla-JS port of the React SessionSearch / useSessionSearch behavior so
// that the dashboard style reaches C-01 parity. The palette lists sessions
// from GET /sessions with live filtering; Enter or click navigates to the
// chat page and loads the chosen session. It is reachable from the Ctrl+K
// command palette ("Search sessions…") and via window.aiui.openSessionSearch.
let sessionSearchOpen = false;
let sessionSearchOverlay = null;
let sessionSearchInitialized = false;
function getCurrentSessionId() {
try {
const params = new URLSearchParams(location.search);
return params.get('session') || null;
} catch (e) {
return null;
}
}
function selectSession(sessionId) {
closeSessionSearch();
// Navigate to the chat page first so its view mounts and registers the
// aiui:session-select listener, then dispatch on the next tick.
const navigate = (typeof selectPage === 'function')
? Promise.resolve(selectPage('chat'))
: Promise.resolve();
navigate.finally(() => {
window.dispatchEvent(new CustomEvent('aiui:session-select', { detail: { sessionId } }));
});
}
function renderSessionSearchList(listEl, sessions, query, currentSessionId) {
const q = (query || '').trim().toLowerCase();
const filtered = q
? sessions.filter((s) => (`${s.title || ''} ${s.id || ''}`).toLowerCase().includes(q))
: sessions;
listEl.innerHTML = '';
if (!filtered.length) {
const empty = document.createElement('div');
empty.className = 'db-session-search-empty';
empty.textContent = sessions.length ? 'No sessions matching your search.' : 'No sessions found. Start a chat to create one.';
listEl.appendChild(empty);
return;
}
filtered.forEach((session, i) => {
const item = document.createElement('div');
item.className = 'db-session-search-item' + (i === 0 ? ' active' : '');
item.dataset.sessionId = session.id || '';
const info = document.createElement('div');
const title = document.createElement('div');
title.className = 'db-session-search-item-title';
title.textContent = session.title || 'Untitled Session';
info.appendChild(title);
const meta = document.createElement('div');
meta.className = 'db-session-search-item-meta';
const count = session.message_count !== undefined ? `${session.message_count} messages` : '';
meta.textContent = count;
info.appendChild(meta);
item.appendChild(info);
if (currentSessionId && currentSessionId === session.id) {
const badge = document.createElement('span');
badge.className = 'db-session-search-item-current';
badge.textContent = 'Current';
item.appendChild(badge);
}
item.addEventListener('click', () => selectSession(session.id));
item.addEventListener('mouseenter', () => {
listEl.querySelectorAll('.db-session-search-item').forEach((el) => el.classList.remove('active'));
item.classList.add('active');
});
listEl.appendChild(item);
});
}
async function openSessionSearch() {
if (sessionSearchOpen) return;
sessionSearchOpen = true;
const overlay = document.createElement('div');
overlay.className = 'db-session-search-overlay';
const palette = document.createElement('div');
palette.className = 'db-session-search';
palette.setAttribute('role', 'dialog');
palette.setAttribute('aria-label', 'Search sessions');
const input = document.createElement('input');
input.className = 'db-session-search-input';
input.type = 'text';
input.placeholder = 'Search sessions...';
input.setAttribute('aria-label', 'Search sessions');
const list = document.createElement('div');
list.className = 'db-session-search-list';
list.innerHTML = '<div class="db-session-search-empty">Loading sessions...</div>';
palette.appendChild(input);
palette.appendChild(list);
overlay.appendChild(palette);
document.body.appendChild(overlay);
sessionSearchOverlay = overlay;
const currentSessionId = getCurrentSessionId();
let sessions = [];
let debounce = null;
input.addEventListener('input', () => {
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => {
renderSessionSearchList(list, sessions, input.value, currentSessionId);
}, 150);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const active = list.querySelector('.db-session-search-item.active') || list.querySelector('.db-session-search-item');
if (active && active.dataset.sessionId) selectSession(active.dataset.sessionId);
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const items = Array.from(list.querySelectorAll('.db-session-search-item'));
if (!items.length) return;
let idx = items.findIndex((el) => el.classList.contains('active'));
items.forEach((el) => el.classList.remove('active'));
idx = e.key === 'ArrowDown' ? Math.min(items.length - 1, idx + 1) : Math.max(0, idx - 1);
items[idx].classList.add('active');
items[idx].scrollIntoView({ block: 'nearest' });
}
});
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeSessionSearch(); });
try {
const res = await fetch('/sessions');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
sessions = data.sessions || [];
} catch (err) {
console.error('[AIUI] Failed to fetch sessions:', err);
sessions = [];
}
renderSessionSearchList(list, sessions, '', currentSessionId);
input.focus();
}
function closeSessionSearch() {
sessionSearchOpen = false;
if (sessionSearchOverlay) {
sessionSearchOverlay.remove();
sessionSearchOverlay = null;
}
}
function initSessionSearch() {
if (sessionSearchInitialized) return;
sessionSearchInitialized = true;
// The Ctrl+K binding is owned by the command palette (initCommandPalette),
// which exposes a "Search sessions…" entry that calls openSessionSearch.
// Here we only handle Escape so the session palette can be dismissed.
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && sessionSearchOpen) {
closeSessionSearch();
}
});
}
window.aiui = window.aiui || {};
window.aiui.openSessionSearch = openSessionSearch;
async function init() {
// Inject styles
const style = document.createElement('style');
@@ -925,9 +1262,15 @@ async function init() {
}
await buildDashboard();
// Initialize Ctrl+K / Cmd+K command palette
initCommandPalette();
// Initialize approval stream for modal notifications
initApprovalStream();
// Initialize Ctrl+K session search palette
initSessionSearch();
// Listen for approval events from chat stream
window.addEventListener('aiui:approval-required', (e) => {
@@ -990,11 +1333,11 @@ async function buildDashboard() {
root.appendChild(sidebar);
setupMobileNav(root, sidebar);
}
root.appendChild(main);
const shellHeader = document.createElement('div');
shellHeader.id = 'db-shell-header-slot';
renderSlot('shell:header', shellHeader);
if (shellHeader.children.length) root.insertBefore(shellHeader, main);
root.appendChild(main);
// Resolve initial page from URL path or default to first page
const pathId = location.pathname.replace(/^\//, '') || '';
@@ -19,6 +19,7 @@ let currentRunId = null;
let agents = [];
let messageQueue = [];
let focusMode = false;
let sessionSelectHandler = null;
let containerRef = null;
let currentDeltaEl = null;
let currentDeltaBodyEl = null;
@@ -784,10 +785,21 @@ export async function render(container) {
await loadSessions();
connectWebSocket();
// Load a session chosen from the dashboard Ctrl+K search palette
sessionSelectHandler = (e) => {
const id = e.detail && e.detail.sessionId;
if (id) loadSession(id);
};
window.addEventListener('aiui:session-select', sessionSelectHandler);
}
export function cleanup() {
if (ws) { try { ws.close(); } catch(e) {} ws = null; }
if (sessionSelectHandler) {
window.removeEventListener('aiui:session-select', sessionSelectHandler);
sessionSelectHandler = null;
}
currentSessionId = null;
currentAgentName = null;
isStreaming = false;
@@ -0,0 +1,214 @@
/**
* MCP Manager View — list, connect, and disconnect Model Context Protocol servers.
* API: GET /api/mcp/servers, POST /api/mcp/connect, POST /api/mcp/disconnect/{name}
*/
import { showToast, showConfirm } from '../toast.js';
const STATUS_STYLE = {
connected: 'background:rgba(34,197,94,0.15);color:#22c55e',
connecting: 'background:rgba(234,179,8,0.15);color:#eab308',
error: 'background:rgba(239,68,68,0.15);color:#ef4444',
disconnected: 'background:rgba(113,113,122,0.2);color:var(--db-text-dim)',
};
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let servers = [];
let loadError = null;
try {
const r = await fetch('/api/mcp/servers');
const d = await r.json();
servers = d.servers || [];
if (d.error) loadError = d.error;
} catch (e) {
loadError = e.message;
}
const connected = servers.filter((s) => s.status === 'connected').length;
const totalTools = servers.reduce((n, s) => n + ((s.tools && s.tools.length) || 0), 0);
container.innerHTML = `
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin-bottom:24px">
<div class="db-card"><div class="db-card-title">Servers</div><div class="db-card-value">${servers.length}</div><div class="db-card-footer">${connected} connected</div></div>
<div class="db-card"><div class="db-card-title">Tools</div><div class="db-card-value">${totalTools}</div></div>
<div class="db-card"><div class="db-card-title">Health</div><div class="db-card-value">${servers.length ? Math.round((connected / servers.length) * 100) : 0}%</div></div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<h3 style="margin:0;font-size:15px;font-weight:600">MCP Servers</h3>
<button id="mcp-add" class="db-btn db-btn-primary">+ Connect Server</button>
</div>
${loadError ? `<div class="db-alert db-alert-warning"><span>⚠️</span><span>${esc(loadError)}</span></div>` : ''}
<div id="mcp-list"></div>
<div id="mcp-form-host"></div>
`;
const list = container.querySelector('#mcp-list');
servers.forEach((s) => {
const card = document.createElement('div');
card.className = 'db-card';
card.style.cssText = 'margin-bottom:10px;padding:16px 20px';
const isConnected = s.status === 'connected';
const statusStyle = STATUS_STYLE[s.status] || STATUS_STYLE.disconnected;
const tools = s.tools || [];
card.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
<div style="display:flex;align-items:center;gap:10px;flex:1;min-width:0">
<span style="font-size:20px">🔌</span>
<div style="min-width:0">
<div style="font-weight:500;font-size:14px">${esc(s.name)}</div>
<div style="font-size:12px;color:var(--db-text-dim)">${esc(s.transport || '')} · ${tools.length} tool${tools.length !== 1 ? 's' : ''}</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:11px;padding:3px 10px;border-radius:12px;${statusStyle}">${esc(s.status || 'unknown')}</span>
${isConnected ? `<button class="mcp-disconnect db-btn db-btn-destructive" data-name="${esc(s.name)}" style="padding:4px 12px;font-size:11px">Disconnect</button>` : ''}
</div>
</div>
${s.last_error ? `<div style="font-size:12px;color:#ef4444;margin-top:8px">${esc(s.last_error)}</div>` : ''}
${tools.length ? `<details style="margin-top:10px"><summary style="cursor:pointer;font-size:12px;color:var(--db-text-dim)">Tools (${tools.length})</summary><div style="margin-top:8px;display:flex;flex-direction:column;gap:6px">${tools
.map(
(t) =>
`<div style="font-size:12px"><code style="color:var(--db-text)">${esc(t.name)}</code>${t.description ? ` <span style="color:var(--db-text-dim)">— ${esc(t.description)}</span>` : ''}</div>`
)
.join('')}</div></details>` : ''}
`;
list.appendChild(card);
});
if (servers.length === 0 && !loadError) {
list.innerHTML = '<div class="db-viewer"><pre>No MCP servers connected. Click "Connect Server" to add one.</pre></div>';
}
container.querySelectorAll('.mcp-disconnect').forEach((b) =>
b.addEventListener('click', async () => {
const name = b.dataset.name;
if (!(await showConfirm('Disconnect MCP Server', `Disconnect "${name}"?`))) return;
try {
const r = await fetch(`/api/mcp/disconnect/${encodeURIComponent(name)}`, { method: 'POST' });
const d = await r.json();
if (r.ok && d.success) {
showToast(`Disconnected from ${name}`, 'success');
render(container);
} else {
showToast(d.error || 'Disconnect failed', 'error');
}
} catch (e) {
showToast(e.message, 'error');
}
})
);
const addBtn = container.querySelector('#mcp-add');
const formHost = container.querySelector('#mcp-form-host');
addBtn.addEventListener('click', () => openConnectForm(formHost, container));
}
function openConnectForm(host, container) {
host.innerHTML = `
<div class="db-card" style="margin-top:16px;padding:20px">
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600">Connect MCP Server</h3>
<div class="db-form-group">
<label class="db-form-label">Name</label>
<input id="mcp-f-name" class="db-form-input" placeholder="my-server">
</div>
<div class="db-form-group">
<label class="db-form-label">Transport</label>
<select id="mcp-f-transport" class="db-form-select">
<option value="stdio">stdio (command)</option>
<option value="sse">SSE / HTTP (url)</option>
</select>
</div>
<div id="mcp-f-stdio">
<div class="db-form-group">
<label class="db-form-label">Command</label>
<input id="mcp-f-command" class="db-form-input" placeholder="npx">
</div>
<div class="db-form-group">
<label class="db-form-label">Arguments (space-separated)</label>
<input id="mcp-f-args" class="db-form-input" placeholder="-y @modelcontextprotocol/server-filesystem /tmp">
</div>
</div>
<div id="mcp-f-url" style="display:none">
<div class="db-form-group">
<label class="db-form-label">URL</label>
<input id="mcp-f-urlval" class="db-form-input" placeholder="https://example.com/sse">
</div>
</div>
<div class="db-button-group" style="margin:0">
<button id="mcp-f-submit" class="db-btn db-btn-primary">Connect</button>
<button id="mcp-f-cancel" class="db-btn">Cancel</button>
</div>
</div>
`;
const transportSel = host.querySelector('#mcp-f-transport');
const stdioBlock = host.querySelector('#mcp-f-stdio');
const urlBlock = host.querySelector('#mcp-f-url');
transportSel.addEventListener('change', () => {
const isStdio = transportSel.value === 'stdio';
stdioBlock.style.display = isStdio ? '' : 'none';
urlBlock.style.display = isStdio ? 'none' : '';
});
host.querySelector('#mcp-f-cancel').addEventListener('click', () => {
host.innerHTML = '';
});
host.querySelector('#mcp-f-submit').addEventListener('click', async () => {
const name = host.querySelector('#mcp-f-name').value.trim();
if (!name) {
showToast('Name is required', 'error');
return;
}
const body = { name };
if (transportSel.value === 'stdio') {
const command = host.querySelector('#mcp-f-command').value.trim();
if (!command) {
showToast('Command is required for stdio', 'error');
return;
}
body.command = command;
const argsRaw = host.querySelector('#mcp-f-args').value.trim();
body.args = argsRaw ? argsRaw.split(/\s+/) : [];
} else {
const url = host.querySelector('#mcp-f-urlval').value.trim();
if (!url) {
showToast('URL is required', 'error');
return;
}
body.url = url;
}
const submitBtn = host.querySelector('#mcp-f-submit');
submitBtn.disabled = true;
submitBtn.textContent = 'Connecting…';
try {
const r = await fetch('/api/mcp/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json();
if (r.ok && d.server) {
showToast(`Connected to ${d.server.name}`, 'success');
host.innerHTML = '';
render(container);
} else {
showToast(d.error || d.hint || 'Connection failed', 'error');
submitBtn.disabled = false;
submitBtn.textContent = 'Connect';
}
} catch (e) {
showToast(e.message, 'error');
submitBtn.disabled = false;
submitBtn.textContent = 'Connect';
}
});
}
+2 -2
View File
@@ -256,7 +256,7 @@ def _write_disk_cache(data: dict) -> None:
try:
path = _disk_cache_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data))
path.write_text(json.dumps(data), encoding="utf-8")
except OSError:
pass # Non-critical — build still works
@@ -357,7 +357,7 @@ def inject_theme_css(
# Write to theme.css
theme_file = output_dir / "assets" / "theme.css"
theme_file.parent.mkdir(parents=True, exist_ok=True)
theme_file.write_text(css)
theme_file.write_text(css, encoding="utf-8")
def get_available_themes() -> list[str]:
+13
View File
@@ -734,6 +734,19 @@ class TestPagesAPI:
if ev:
assert ev["group"] == "Control"
def test_mcp_page_registered(self, client):
r = client.get("/api/pages")
pages = r.json().get("pages", r.json())
mcp = next((p for p in pages if p["id"] == "mcp"), None)
assert mcp is not None, "MCP page should be registered"
assert mcp["group"] == "Agent"
assert mcp["api_endpoint"] == "/api/mcp/servers"
def test_mcp_servers_endpoint(self, client):
r = client.get("/api/mcp/servers")
assert r.status_code == 200
assert "servers" in r.json()
# ===========================================================================
# Persistence across restart
+95 -1
View File
@@ -5,7 +5,12 @@ from __future__ import annotations
from starlette.testclient import TestClient
from praisonaiui.backends import clear_backends, set_backend
from praisonaiui.features.kanban import get_kanban_store, reset_kanban_store
from praisonaiui.features.kanban import (
_child_progress,
_task_card,
get_kanban_store,
reset_kanban_store,
)
from praisonaiui.server import create_app, reset_state
@@ -81,6 +86,9 @@ def test_kanban_uses_injected_store():
def move_task(self, task_id, status):
return None
def add_comment(self, task_id, comment):
return None
def bulk_update(self, task_ids, status):
return {"updated": 0}
@@ -105,6 +113,92 @@ def test_kanban_uses_injected_store():
reset_kanban_store()
def test_task_card_enriched_dto():
card = _task_card(
{
"id": "t1",
"title": "Refactor auth",
"status": "running",
"assignee": "coder",
"priority": "P2",
"tenant": "acme",
"created_at": "2024-01-01T00:00:00+00:00",
"comments": [{"text": "a"}, {"text": "b"}],
}
)
assert card["tenant"] == "acme"
assert card["created_at"] == "2024-01-01T00:00:00+00:00"
assert card["comment_count"] == 2
assert card["progress"] is None
def test_child_progress_from_children_and_meta():
children = {"children": [{"status": "done"}, {"status": "ready"}, {"status": "done"}]}
assert _child_progress(children) == {"done": 2, "total": 3}
explicit = {"meta": {"progress": {"done": 1, "total": 4}}}
assert _child_progress(explicit) == {"done": 1, "total": 4}
assert _child_progress({}) is None
assert _child_progress({"meta": {"progress": {"done": 0, "total": 0}}}) is None
def test_card_in_board_carries_progress_and_counts():
clear_backends()
client = _client()
store = get_kanban_store()
store.create_task(
{
"id": "px",
"title": "Parent",
"status": "todo",
"tenant": "acme",
"children": [{"status": "done"}, {"status": "todo"}],
}
)
board = client.get("/api/kanban/board").json()
todo_cards = next(c for c in board["columns"] if c["id"] == "todo")["cards"]
card = next(c for c in todo_cards if c["id"] == "px")
assert card["tenant"] == "acme"
assert card["progress"] == {"done": 1, "total": 2}
assert card["comment_count"] == 0
clear_backends()
def test_kanban_add_comment():
clear_backends()
client = _client()
create = client.post("/api/kanban/tasks", json={"title": "Has comments", "status": "todo"})
task_id = create.json()["id"]
resp = client.post(f"/api/kanban/tasks/{task_id}/comments", json={"text": "please unblock"})
assert resp.status_code == 200
task = resp.json()
assert len(task["comments"]) == 1
assert task["comments"][0]["text"] == "please unblock"
assert task["comments"][0]["author"] == "human"
board = client.get("/api/kanban/board").json()
todo_cards = next(c for c in board["columns"] if c["id"] == "todo")["cards"]
card = next(c for c in todo_cards if c["id"] == task_id)
assert card["comment_count"] == 1
clear_backends()
def test_kanban_add_comment_validation():
clear_backends()
client = _client()
create = client.post("/api/kanban/tasks", json={"title": "T", "status": "todo"})
task_id = create.json()["id"]
empty = client.post(f"/api/kanban/tasks/{task_id}/comments", json={"text": " "})
assert empty.status_code == 400
missing = client.post("/api/kanban/tasks/nope/comments", json={"text": "hi"})
assert missing.status_code == 404
clear_backends()
def test_jobs_board_endpoint():
clear_backends()
client = _client()
+166
View File
@@ -0,0 +1,166 @@
"""Tests for channel auto-start lifecycle and running-status correctness.
Regression coverage for issue #167: auto-start raised
``'dict' object has no attribute 'append'`` when the gateway exposed
``_channel_tasks`` as a dict, and reported ``running: true`` despite a
``start_error`` being set.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from praisonaiui.features import channels as channels_mod
from praisonaiui.features.channels import ChannelsFeature
class _FakeBot:
def __init__(self) -> None:
self.is_running = False
self.started = False
async def start(self) -> None:
self.started = True
class _GatewayDictTasks:
def __init__(self) -> None:
self._channel_bots: dict = {}
self._channel_tasks: dict = {}
self._agents: dict = {}
class _GatewayListTasks:
def __init__(self) -> None:
self._channel_bots: dict = {}
self._channel_tasks: list = []
self._agents: dict = {}
@pytest.fixture(autouse=True)
def _isolate_channel_state():
saved_channels = dict(channels_mod._channels)
saved_bots = dict(channels_mod._live_bots)
saved_flag = ChannelsFeature._auto_started
channels_mod._channels.clear()
channels_mod._live_bots.clear()
ChannelsFeature._auto_started = False
yield
channels_mod._channels.clear()
channels_mod._channels.update(saved_channels)
channels_mod._live_bots.clear()
channels_mod._live_bots.update(saved_bots)
ChannelsFeature._auto_started = saved_flag
@pytest.mark.asyncio
async def test_start_channel_bot_dict_channel_tasks_no_attribute_error():
feature = ChannelsFeature()
gw = _GatewayDictTasks()
bot = _FakeBot()
entry = {"id": "ch1", "platform": "discord", "config": {"bot_token": "x" * 30}}
with patch.object(feature, "_get_gateway", return_value=gw), patch.object(
feature, "_create_bot_direct", return_value=bot
), patch.object(feature, "_attach_chat_bridge"):
error = await feature._start_channel_bot("ch1", entry)
assert error is None
assert "ch1" in gw._channel_tasks
assert gw._channel_bots["ch1"] is bot
assert entry["running"] is True
assert "start_error" not in entry
@pytest.mark.asyncio
async def test_start_channel_bot_list_channel_tasks_no_regression():
feature = ChannelsFeature()
gw = _GatewayListTasks()
bot = _FakeBot()
entry = {"id": "ch2", "platform": "discord", "config": {"bot_token": "x" * 30}}
with patch.object(feature, "_get_gateway", return_value=gw), patch.object(
feature, "_create_bot_direct", return_value=bot
), patch.object(feature, "_attach_chat_bridge"):
error = await feature._start_channel_bot("ch2", entry)
assert error is None
assert len(gw._channel_tasks) == 1
assert gw._channel_bots["ch2"] is bot
assert entry["running"] is True
def test_sync_running_status_start_error_forces_not_running():
feature = ChannelsFeature()
channels_mod._channels["chx"] = {
"id": "chx",
"platform": "discord",
"running": True,
"start_error": "boom",
}
with patch.object(feature, "_get_gateway", return_value=None):
feature._sync_running_status()
assert channels_mod._channels["chx"]["running"] is False
@pytest.mark.asyncio
async def test_auto_start_runs_once_per_process():
feature = ChannelsFeature()
channels_mod._channels["ch3"] = {
"id": "ch3",
"platform": "discord",
"enabled": True,
"config": {"bot_token": "x" * 30},
}
with patch.object(
feature, "_start_channel_bot", return_value=None
) as mock_start:
await feature._auto_start_enabled_channels()
await feature._auto_start_enabled_channels()
assert mock_start.call_count == 1
@pytest.mark.asyncio
async def test_auto_start_skips_disabled_channels():
feature = ChannelsFeature()
channels_mod._channels["ch4"] = {
"id": "ch4",
"platform": "discord",
"enabled": False,
"config": {"bot_token": "x" * 30},
}
with patch.object(
feature, "_start_channel_bot", return_value=None
) as mock_start:
await feature._auto_start_enabled_channels()
assert mock_start.call_count == 0
@pytest.mark.asyncio
async def test_auto_start_failure_sets_not_running_and_error():
feature = ChannelsFeature()
entry = {
"id": "ch5",
"platform": "discord",
"enabled": True,
"running": True,
"config": {"bot_token": "x" * 30},
}
channels_mod._channels["ch5"] = entry
async def _boom(channel_id, e):
raise AttributeError("'dict' object has no attribute 'append'")
with patch.object(feature, "_start_channel_bot", side_effect=_boom):
await feature._auto_start_enabled_channels()
assert entry["running"] is False
assert "start_error" in entry
+53
View File
@@ -599,3 +599,56 @@ class TestCompositionResolver:
assert len(left_sidebar_widgets) == 1
assert left_sidebar_widgets[0]["props"]["searchable"] is True
assert "collapsible" not in left_sidebar_widgets[0]["props"]
class TestCompilerUtf8Writes:
"""Regression tests for issue #150 — UTF-8 on write (Windows cp1252)."""
SPARKLE = "\u2728"
def test_doc_page_written_as_utf8(self, tmp_path: Path):
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "index.md").write_text(
f"# Welcome\n\n## {self.SPARKLE} Features\n\nHello!", encoding="utf-8"
)
config = Config(
site=SiteConfig(title="Unicode Site"),
content=ContentConfig(docs=ContentSourceConfig(dir=str(docs_dir))),
templates={"docs": TemplateConfig(layout="Default", slots={})},
routes=[RouteConfig(match="/docs/**", template="docs")],
)
compiler = Compiler(config, base_path=tmp_path)
result = compiler.compile(tmp_path / "output")
assert result.success is True
page = tmp_path / "output" / "docs" / "index" / "index.html"
assert page.exists()
raw = page.read_bytes()
assert self.SPARKLE in raw.decode("utf-8")
assert b"\xe2\x9c\xa8" in raw
def test_write_json_uses_utf8(self, minimal_config: Config, tmp_path: Path):
compiler = Compiler(minimal_config, base_path=tmp_path)
target = tmp_path / "out.json"
compiler._write_json(target, {"sparkle": self.SPARKLE}, indent=2)
raw = target.read_bytes()
assert self.SPARKLE in raw.decode("utf-8")
assert b"\xe2\x9c\xa8" in raw
def test_plugins_json_uses_utf8(self, tmp_path: Path):
frontend_dir = tmp_path / "frontend"
plugins_dst = frontend_dir / "plugins"
plugins_dst.mkdir(parents=True)
config = Config(
site=SiteConfig(title="Plugin Site"),
templates={"docs": TemplateConfig(layout="Default", slots={})},
routes=[RouteConfig(match="/docs/**", template="docs")],
)
compiler = Compiler(config, base_path=tmp_path)
compiler._copy_plugins(tmp_path / "output", frontend_dir)
plugins_file = tmp_path / "output" / "plugins" / "plugins.json"
assert plugins_file.exists()
assert plugins_file.read_bytes().decode("utf-8")
+50
View File
@@ -0,0 +1,50 @@
"""Regression tests for #166 — buildDashboard() insertBefore NotFoundError.
buildDashboard() called `root.insertBefore(shellHeader, main)` before `main`
was a child of `root`, violating the DOM contract and throwing
NotFoundError. That aborted the dashboard plugin init, blocking Ctrl+K and
the browser chat input. These source-parsing tests assert `main` is appended
to `root` before any insertBefore references it, mirroring
`test_session_search_dashboard.py`.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
DASHBOARD_JS = FRONTEND / "dashboard.js"
class TestDashboardShellOrder:
def _build_body(self) -> str:
src = DASHBOARD_JS.read_text()
m = re.search(
r"function buildDashboard\([^)]*\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
)
assert m, "buildDashboard not found"
return m.group(1)
def test_main_appended_before_insert_before(self):
body = self._build_body()
append_idx = body.find("root.appendChild(main)")
insert_idx = body.find("root.insertBefore(shellHeader, main)")
assert append_idx != -1, "root.appendChild(main) not found"
assert insert_idx != -1, "root.insertBefore(shellHeader, main) not found"
assert append_idx < insert_idx, (
"main must be appended to root before insertBefore(shellHeader, main)"
)
def test_shell_header_insert_guarded_by_children(self):
body = self._build_body()
m = re.search(
r"if \(shellHeader\.children\.length\)\s*"
r"root\.insertBefore\(shellHeader, main\)",
body,
)
assert m, "shellHeader insert must be guarded by children.length"
+83
View File
@@ -20,6 +20,10 @@ def client():
# Clear state before each test
_agents.clear()
_callbacks.clear()
import praisonaiui.server as _srv
_srv._DEEP_HEALTH_CACHE = {}
_srv._DEEP_HEALTH_CACHE_TIME = 0.0
set_datastore(MemoryDataStore())
app = create_app()
return TestClient(app)
@@ -145,6 +149,85 @@ class TestHealthEndpoint:
finally:
_features.pop("__slow_test__", None)
def test_health_many_features_under_budget(self, client):
"""Deep /health with many slow features stays bounded (parallel)."""
import asyncio
import time
from praisonaiui.features import _features
class _SlowFeature:
async def health(self):
await asyncio.sleep(0.4)
return {"healthy": True}
for i in range(40):
_features[f"__perf_{i}__"] = _SlowFeature()
try:
start = time.perf_counter()
response = client.get("/health")
duration = time.perf_counter() - start
assert response.status_code == 200
assert duration < 1.5
finally:
for i in range(40):
_features.pop(f"__perf_{i}__", None)
def test_health_deep_is_cached(self, client):
"""Repeat /health probes hit the short-TTL cache and stay fast."""
import time
first = client.get("/health")
assert first.status_code == 200
start = time.perf_counter()
second = client.get("/health")
duration = time.perf_counter() - start
assert second.status_code == 200
assert second.json().get("cached") is True
assert duration < 0.1
class TestProviderHealthEndpoint:
"""Tests for the provider health JSON route (#151)."""
def test_api_provider_health_is_json(self, client):
"""/api/provider/health returns JSON, not the SPA HTML shell."""
response = client.get("/api/provider/health")
assert response.status_code == 200
assert "application/json" in response.headers["content-type"]
body = response.json()
assert "status" in body
assert "type" in body
assert "agents" in body
assert "detail" in body
def test_api_provider_health_accept_json(self, client):
"""Accept: application/json still yields JSON body."""
response = client.get(
"/api/provider/health", headers={"Accept": "application/json"}
)
assert response.status_code == 200
assert "application/json" in response.headers["content-type"]
assert "status" in response.json()
def test_unknown_api_path_not_html(self, client):
"""Unmatched /api/* paths return JSON 404, never SPA HTML 200."""
response = client.get("/api/this-route-does-not-exist")
assert response.status_code == 404
assert "text/html" not in response.headers.get("content-type", "")
assert "application/json" in response.headers["content-type"]
def test_provider_health_route_before_catchall(self, client):
"""The dedicated route is registered before the SPA catch-all."""
paths = [getattr(r, "path", None) for r in client.app.routes]
assert "/api/provider/health" in paths
catchall = next(
(i for i, p in enumerate(paths) if p == "/{path:path}"), None
)
if catchall is not None:
assert paths.index("/api/provider/health") < catchall
class TestAgentsEndpoint:
"""Tests for agents endpoint."""
+113
View File
@@ -0,0 +1,113 @@
"""Regression tests for C-01 — Ctrl+K session search in the dashboard style.
Issue #155: PR #145 shipped SessionSearch in React layouts only. The dashboard
style (`dashboard.js`) skips React entirely, so Ctrl+K never reached the primary
ops example. These tests assert the vanilla-JS palette is wired into
`dashboard.js` (and its session-select handler into the chat view), mirroring the
source-parsing approach used by `test_form_input_names.py`.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
DASHBOARD_JS = FRONTEND / "dashboard.js"
CHAT_JS = FRONTEND / "views" / "chat.js"
class TestDashboardSessionSearch:
def _src(self) -> str:
return DASHBOARD_JS.read_text()
def test_ctrl_k_keydown_handler_present(self):
src = self._src()
assert "initSessionSearch" in src
m = re.search(
r"function initSessionSearch\(\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
)
assert m, "initSessionSearch not found"
body = m.group(1)
assert "keydown" in body
assert "ctrlKey" in body and "metaKey" in body
assert "'k'" in body or '"k"' in body
assert "preventDefault" in body
def test_palette_fetches_sessions_endpoint(self):
src = self._src()
m = re.search(
r"async function openSessionSearch\(\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
)
assert m, "openSessionSearch not found"
assert "/sessions" in m.group(1)
def test_select_navigates_via_selectPage(self):
src = self._src()
m = re.search(
r"function selectSession\(sessionId\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
)
assert m, "selectSession not found"
body = m.group(1)
assert "selectPage" in body
assert "aiui:session-select" in body
def test_init_invokes_session_search(self):
src = self._src()
init_idx = src.find("async function init()")
assert init_idx != -1, "init() not found"
invoke_idx = src.find("initSessionSearch()", init_idx)
approval_idx = src.find("initApprovalStream()", init_idx)
build_idx = src.find("await buildDashboard()", init_idx)
assert invoke_idx != -1, "initSessionSearch() not invoked in init()"
assert build_idx < invoke_idx, "session search must init after dashboard build"
assert abs(invoke_idx - approval_idx) < 400, (
"initSessionSearch() should be wired alongside initApprovalStream()"
)
def test_public_api_exposes_open_session_search(self):
src = self._src()
assert "window.aiui.openSessionSearch" in src
def test_escape_closes_palette(self):
src = self._src()
assert "closeSessionSearch" in src
m = re.search(
r"function initSessionSearch\(\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
)
assert "Escape" in m.group(1)
class TestChatViewSessionSelectListener:
def _src(self) -> str:
return CHAT_JS.read_text()
def test_chat_listens_for_session_select(self):
src = self._src()
assert "aiui:session-select" in src
assert "addEventListener('aiui:session-select'" in src
def test_chat_loads_selected_session(self):
src = self._src()
m = re.search(
r"sessionSelectHandler\s*=\s*\(e\)\s*=>\s*\{(.*?)\};",
src, re.DOTALL,
)
assert m, "sessionSelectHandler not found"
assert "loadSession" in m.group(1)
def test_chat_cleanup_removes_listener(self):
src = self._src()
m = re.search(
r"export function cleanup\(\)\s*\{(.*?)\n}",
src, re.DOTALL,
)
assert m, "cleanup() not found"
assert "removeEventListener('aiui:session-select'" in m.group(1)
Generated
+1 -1
View File
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.122"
version = "0.3.127"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },