mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
58
Commits
No files matched your search
@@ -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
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
name: "Claude Code Action"
|
||||
description: "Run Claude Code in GitHub Actions workflows"
|
||||
|
||||
inputs:
|
||||
anthropic_api_key:
|
||||
description: "Anthropic API key"
|
||||
required: false
|
||||
claude_code_oauth_token:
|
||||
description: "Claude Code OAuth Token for Claude Pro"
|
||||
required: false
|
||||
github_token:
|
||||
description: "GitHub token for Claude to operate with"
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
trigger_phrase:
|
||||
description: "The trigger phrase to look for in comments, issue/PR bodies, and issue titles"
|
||||
required: false
|
||||
default: "@claude"
|
||||
assignee_trigger:
|
||||
description: "The assignee username that triggers the action (e.g. @claude). Only used for issue assignment"
|
||||
required: false
|
||||
max_turns:
|
||||
description: "Maximum number of conversation turns Claude can take"
|
||||
required: false
|
||||
timeout_minutes:
|
||||
description: "Timeout in minutes for execution"
|
||||
required: false
|
||||
default: "30"
|
||||
model:
|
||||
description: "Model to use (provider-specific format required for Bedrock/Vertex)"
|
||||
required: false
|
||||
use_bedrock:
|
||||
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
default: "false"
|
||||
use_vertex:
|
||||
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
default: "false"
|
||||
allowed_tools:
|
||||
description: "Additional tools for Claude to use (the base GitHub tools will always be included)"
|
||||
required: false
|
||||
default: ""
|
||||
disallowed_tools:
|
||||
description: "Tools that Claude should never use"
|
||||
required: false
|
||||
default: ""
|
||||
custom_instructions:
|
||||
description: "Additional custom instructions to include in the prompt for Claude"
|
||||
required: false
|
||||
default: ""
|
||||
mcp_config:
|
||||
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
|
||||
required: false
|
||||
default: ""
|
||||
claude_env:
|
||||
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
|
||||
required: false
|
||||
default: ""
|
||||
direct_prompt:
|
||||
description: "Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows)"
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install Claude Code
|
||||
shell: bash
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Install GitHub MCP Server
|
||||
shell: bash
|
||||
run: |
|
||||
claude mcp add-json github '{
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"-e",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
"ghcr.io/github/github-mcp-server:sha-ff3036d"
|
||||
],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ inputs.github_token }}"
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Extract GitHub Context and Create Prompt
|
||||
shell: bash
|
||||
id: prepare_context
|
||||
env:
|
||||
# Pass user-controlled inputs via environment variables to prevent script injection (GHSL-2025-093)
|
||||
EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
EVENT_ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
EVENT_ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
EVENT_PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_REVIEW_BODY: ${{ github.event.review.body }}
|
||||
EVENT_ASSIGNEE_LOGIN: ${{ github.event.assignee.login }}
|
||||
INPUT_TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
|
||||
INPUT_ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
|
||||
INPUT_DIRECT_PROMPT: ${{ inputs.direct_prompt }}
|
||||
EVENT_ACTION: ${{ github.event.action }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
run: |
|
||||
echo "🔍 Extracting GitHub context from event: $EVENT_NAME"
|
||||
|
||||
# Function to check for trigger phrase
|
||||
check_trigger() {
|
||||
local text="$1"
|
||||
local trigger="$INPUT_TRIGGER_PHRASE"
|
||||
if [[ "$text" == *"$trigger"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Extract context based on event type
|
||||
TRIGGER_FOUND="false"
|
||||
USER_REQUEST=""
|
||||
CONTEXT_INFO=""
|
||||
|
||||
case "$EVENT_NAME" in
|
||||
"issue_comment")
|
||||
COMMENT_BODY="$EVENT_COMMENT_BODY"
|
||||
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
|
||||
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
|
||||
|
||||
if check_trigger "$COMMENT_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$COMMENT_BODY"
|
||||
CONTEXT_INFO="Issue Comment on #$ISSUE_NUMBER: $ISSUE_TITLE"
|
||||
fi
|
||||
;;
|
||||
|
||||
"pull_request_review_comment")
|
||||
COMMENT_BODY="$EVENT_COMMENT_BODY"
|
||||
PR_TITLE="$EVENT_PR_TITLE"
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
|
||||
if check_trigger "$COMMENT_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$COMMENT_BODY"
|
||||
CONTEXT_INFO="PR Comment on #$PR_NUMBER: $PR_TITLE"
|
||||
fi
|
||||
;;
|
||||
|
||||
"pull_request_review")
|
||||
REVIEW_BODY="$EVENT_REVIEW_BODY"
|
||||
PR_TITLE="$EVENT_PR_TITLE"
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
|
||||
if check_trigger "$REVIEW_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$REVIEW_BODY"
|
||||
CONTEXT_INFO="PR Review on #$PR_NUMBER: $PR_TITLE"
|
||||
fi
|
||||
;;
|
||||
|
||||
"issues")
|
||||
ISSUE_BODY="$EVENT_ISSUE_BODY"
|
||||
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
|
||||
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
|
||||
|
||||
if check_trigger "$ISSUE_TITLE" || check_trigger "$ISSUE_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$ISSUE_BODY"
|
||||
CONTEXT_INFO="Issue #$ISSUE_NUMBER: $ISSUE_TITLE"
|
||||
elif [[ "$EVENT_ACTION" == "assigned" && -n "$INPUT_ASSIGNEE_TRIGGER" ]]; then
|
||||
ASSIGNEE="$EVENT_ASSIGNEE_LOGIN"
|
||||
if [[ "$ASSIGNEE" == "$INPUT_ASSIGNEE_TRIGGER" ]]; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$ISSUE_BODY"
|
||||
CONTEXT_INFO="Issue #$ISSUE_NUMBER assigned to $ASSIGNEE: $ISSUE_TITLE"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check for direct prompt override
|
||||
if [[ -n "$INPUT_DIRECT_PROMPT" ]]; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$INPUT_DIRECT_PROMPT"
|
||||
CONTEXT_INFO="Automated GitHub workflow"
|
||||
fi
|
||||
|
||||
if [[ "$TRIGGER_FOUND" != "true" ]]; then
|
||||
echo "❌ No trigger phrase found or direct prompt provided. Exiting gracefully."
|
||||
echo "SKIP_EXECUTION=true" >> $GITHUB_ENV
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "✅ Trigger found! Context: $CONTEXT_INFO"
|
||||
|
||||
# Create comprehensive prompt
|
||||
mkdir -p /tmp/claude-action
|
||||
cat > /tmp/claude-action/github-context-prompt.txt << EOF
|
||||
You are Claude Code, an AI assistant helping with GitHub workflows and code.
|
||||
|
||||
Repository: $REPO_NAME
|
||||
Context: $CONTEXT_INFO
|
||||
Event: $EVENT_NAME
|
||||
|
||||
User Request:
|
||||
$USER_REQUEST
|
||||
|
||||
Please analyze the request and provide helpful assistance.
|
||||
|
||||
CRITICAL INSTRUCTION: If your task involves modifying codebase files to add a feature or fix a bug:
|
||||
|
||||
STEP 0 — SETUP GIT IDENTITY:
|
||||
git config user.name "MervinPraison"
|
||||
git config user.email "[email protected]"
|
||||
|
||||
STEP 1 — READ GUIDELINES:
|
||||
MANDATORY: Before writing any code, you MUST carefully read "src/praisonai-agents/AGENTS.md" to understand this repository's extremely specific Architecture, Protocol-First design rules, and Agent-Centric Philosophy.
|
||||
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
|
||||
Before implementing anything, answer these questions:
|
||||
- CORE vs WRAPPER vs TOOLS ROUTING:
|
||||
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
|
||||
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
|
||||
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- Is there a name collision with existing exports in __init__.py?
|
||||
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
|
||||
STEP 3 — IMPLEMENT:
|
||||
- Create a new git branch appropriately named (e.g. \`feature/issue-<number>\`)
|
||||
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
|
||||
- Keep changes small and backward-compatible
|
||||
|
||||
STEP 4 — TEST:
|
||||
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- Ensure no regressions
|
||||
|
||||
STEP 5 — CREATE PR:
|
||||
- Commit with descriptive message, push the branch
|
||||
- Use the GitHub CLI (\`gh pr create --title "..." --body "Fixes #<number>"\`) to open a Pull Request
|
||||
CRITICAL: You MUST create the PR automatically using \`gh pr create\`. Do NOT just provide a link.
|
||||
|
||||
Respond naturally and helpfully to the user's request using the available tools.
|
||||
EOF
|
||||
|
||||
echo "PROMPT_FILE=/tmp/claude-action/github-context-prompt.txt" >> $GITHUB_ENV
|
||||
echo "SKIP_EXECUTION=false" >> $GITHUB_ENV
|
||||
|
||||
- name: Run Claude Code
|
||||
if: env.SKIP_EXECUTION != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "🚀 Running Claude Code with GitHub context..."
|
||||
|
||||
# Build command arguments
|
||||
CMD_ARGS=("-p" "--verbose" "--output-format" "stream-json")
|
||||
|
||||
# Add max turns if specified
|
||||
if [[ -n "${{ inputs.max_turns }}" ]]; then
|
||||
CMD_ARGS+=("--max-turns" "${{ inputs.max_turns }}")
|
||||
fi
|
||||
|
||||
# Add allowed tools (include GitHub tools by default)
|
||||
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__create_comment,Read,Write,Edit,Bash"
|
||||
if [[ -n "${{ inputs.allowed_tools }}" ]]; then
|
||||
TOOLS="$TOOLS,${{ inputs.allowed_tools }}"
|
||||
fi
|
||||
CMD_ARGS+=("--allowedTools" "$TOOLS")
|
||||
|
||||
# Add disallowed tools
|
||||
if [[ -n "${{ inputs.disallowed_tools }}" ]]; then
|
||||
CMD_ARGS+=("--disallowedTools" "${{ inputs.disallowed_tools }}")
|
||||
fi
|
||||
|
||||
echo "📝 Executing Claude Code with prompt from file..."
|
||||
|
||||
# Execute Claude Code with timeout, using stdin for the prompt
|
||||
TIMEOUT_SECONDS=$((${{ inputs.timeout_minutes }} * 60))
|
||||
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "${{ env.PROMPT_FILE }}"
|
||||
|
||||
echo "✅ Claude Code execution completed"
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
@@ -0,0 +1,91 @@
|
||||
name: "Claude Issue Triage Action"
|
||||
description: "Automatically triage GitHub issues using Claude Code"
|
||||
|
||||
inputs:
|
||||
timeout_minutes:
|
||||
description: "Timeout in minutes for execution"
|
||||
required: false
|
||||
default: "5"
|
||||
claude_code_oauth_token:
|
||||
description: "Claude Code OAuth token for Pro subscription access"
|
||||
required: true
|
||||
github_token:
|
||||
description: "GitHub token with repo and issues permissions"
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create prompt
|
||||
id: generate_prompt
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
cat > /tmp/claude-prompts/claude-issue-triage-prompt.txt << 'EOF'
|
||||
You're an issue triage assistant for GitHub issues. Your task is to analyze the issue and select appropriate labels from the provided list.
|
||||
|
||||
IMPORTANT: Don't post any comments or messages to the issue. Your only action should be to apply labels.
|
||||
|
||||
Issue Information:
|
||||
- REPO: ${{ github.repository }}
|
||||
- ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
TASK OVERVIEW:
|
||||
|
||||
1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else.
|
||||
|
||||
2. Next, use the GitHub tools to get context about the issue:
|
||||
- You have access to these tools:
|
||||
- mcp__github__get_issue: Use this to retrieve the current issue's details including title, description, and existing labels
|
||||
- mcp__github__get_issue_comments: Use this to read any discussion or additional context provided in the comments
|
||||
- mcp__github__update_issue: Use this to apply labels to the issue (do not use this for commenting)
|
||||
- mcp__github__search_issues: Use this to find similar issues that might provide context for proper categorization and to identify potential duplicate issues
|
||||
- mcp__github__list_issues: Use this to understand patterns in how other issues are labeled
|
||||
- Start by using mcp__github__get_issue to get the issue details
|
||||
|
||||
3. Analyze the issue content, considering:
|
||||
- The issue title and description
|
||||
- The type of issue (bug report, feature request, question, etc.)
|
||||
- Technical areas mentioned
|
||||
- Severity or priority indicators
|
||||
- User impact
|
||||
- Components affected
|
||||
|
||||
4. Select appropriate labels from the available labels list provided above:
|
||||
- Choose labels that accurately reflect the issue's nature
|
||||
- Be specific but comprehensive
|
||||
- Select priority labels if you can determine urgency (high-priority, med-priority, or low-priority)
|
||||
- Consider platform labels (android, ios) if applicable
|
||||
- If you find similar issues using mcp__github__search_issues, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue.
|
||||
|
||||
5. Apply the selected labels:
|
||||
- Use mcp__github__update_issue to apply your selected labels
|
||||
- DO NOT post any comments explaining your decision
|
||||
- DO NOT communicate directly with users
|
||||
- If no labels are clearly applicable, do not apply any labels
|
||||
|
||||
IMPORTANT GUIDELINES:
|
||||
- Be thorough in your analysis
|
||||
- Only select labels from the provided list above
|
||||
- DO NOT post any comments to the issue
|
||||
- Your ONLY action should be to apply labels using mcp__github__update_issue
|
||||
- It's okay to not add any labels if none are clearly applicable
|
||||
EOF
|
||||
|
||||
echo "PROMPT<<DELIMITER" >> $GITHUB_OUTPUT
|
||||
cat /tmp/claude-prompts/claude-issue-triage-prompt.txt >> $GITHUB_OUTPUT
|
||||
echo "DELIMITER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Claude Code
|
||||
uses: ./.github/actions/claude-code-action
|
||||
with:
|
||||
direct_prompt: ${{ steps.generate_prompt.outputs.PROMPT }}
|
||||
allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"
|
||||
timeout_minutes: ${{ inputs.timeout_minutes }}
|
||||
claude_code_oauth_token: ${{ inputs.claude_code_oauth_token }}
|
||||
github_token: ${{ inputs.github_token }}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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]',
|
||||
],
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* 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 (!options.allowStaleRepost && 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,
|
||||
allowStaleRepost: options.allowStaleRepost === true,
|
||||
skipCopilot: options.skipCopilot,
|
||||
});
|
||||
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,
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
@@ -15,12 +15,16 @@ on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
types: [opened, synchronize]
|
||||
pull_request_target:
|
||||
types: [synchronize]
|
||||
schedule:
|
||||
- cron: '30 3,9,15,21 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to trigger Claude final review on'
|
||||
required: true
|
||||
description: 'PR number for manual FINAL (leave empty to sweep all open PRs)'
|
||||
required: false
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
@@ -34,6 +38,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 +207,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 +233,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 +337,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 +457,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,65 +510,163 @@ 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
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
|
||||
# Trigger them explicitly. Do NOT trigger Copilot here — it will
|
||||
# be triggered by copilot-after-coderabbit/qodo when they finish.
|
||||
# Recovery: re-post FINAL @claude after push or when chain missed
|
||||
# ================================================================
|
||||
bot-pr-trigger-reviews:
|
||||
claude-review-recovery:
|
||||
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'
|
||||
)
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'synchronize'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Trigger CodeRabbit and Qodo reviews
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-post FINAL Claude review when stale or missing
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// 1. Trigger CodeRabbit review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '@coderabbitai review'
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr))
|
||||
|| context.payload.pull_request.updated_at;
|
||||
const headPusher = context.payload.sender?.login || null;
|
||||
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
|
||||
if (mergeGate.shouldSkipFinalRecovery(comments, headPushedAt)) {
|
||||
core.info('Recent @claude within 35min and FINAL not stale — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr)) {
|
||||
core.info('Claude workflow in progress — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
|
||||
const staleOpts = { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 };
|
||||
const missingOpts = { allowCopilotTimeout: true, optionalWaitMs: 0 };
|
||||
|
||||
if (hasFinal && isStale) {
|
||||
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusher);
|
||||
if (gate.skip) {
|
||||
core.info(`Skip stale-FINAL recovery: ${gate.reason}`);
|
||||
return;
|
||||
}
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr, finalBody, core, staleOpts
|
||||
);
|
||||
if (result.posted) core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasFinal) {
|
||||
core.info('FINAL Claude review current on HEAD — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr, finalBody, core, missingOpts
|
||||
);
|
||||
if (result.posted) core.info(`Posted missing FINAL Claude review on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# Scheduled + manual: post missing or stale FINAL @claude on all open PRs
|
||||
post-missing-claude-final:
|
||||
if: |
|
||||
github.event_name == 'schedule' ||
|
||||
(github.event_name == 'workflow_dispatch' && !inputs.pr_number)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Post missing/stale FINAL @claude on open PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
console.log(`Triggered CodeRabbit for bot PR #${pr}`);
|
||||
let posted = 0;
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr.number);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at;
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
|
||||
// 2. Trigger Qodo review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '/review'
|
||||
});
|
||||
console.log(`Triggered Qodo for bot PR #${pr}`);
|
||||
if (hasFinal && !isStale) continue;
|
||||
if (mergeGate.hasRecentClaudeTrigger(comments, 10)) {
|
||||
core.info(`PR #${pr.number}: @claude within 10min — skip`);
|
||||
continue;
|
||||
}
|
||||
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr.number)) {
|
||||
core.info(`PR #${pr.number}: Claude in progress — skip`);
|
||||
continue;
|
||||
}
|
||||
if (hasFinal && isStale) {
|
||||
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt);
|
||||
if (gate.skip) {
|
||||
core.info(`PR #${pr.number}: skip stale — ${gate.reason}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Trigger Gemini review (commented out)
|
||||
// await github.rest.issues.createComment({
|
||||
// issue_number: pr, owner, repo,
|
||||
// body: '@gemini review this PR'
|
||||
// });
|
||||
// console.log(`Triggered Gemini for bot PR #${pr}`);
|
||||
|
||||
// NOTE: @copilot is NOT triggered here.
|
||||
// It will be triggered by copilot-after-coderabbit or copilot-after-qodo
|
||||
// once those bots finish, ensuring Copilot always reviews LAST.
|
||||
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
|
||||
const opts = hasFinal && isStale
|
||||
? { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 }
|
||||
: { allowCopilotTimeout: true, optionalWaitMs: 0 };
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr.number,
|
||||
mergeGate.FINAL_CLAUDE_REVIEW_BODY, core, opts
|
||||
);
|
||||
if (result.posted) {
|
||||
posted++;
|
||||
core.info(`Posted Claude FINAL on PR #${pr.number}`);
|
||||
}
|
||||
await ps.syncPipelineLabels(github, owner, repo, pr.number, core);
|
||||
if (posted >= 10) break;
|
||||
}
|
||||
core.info(`Missing/stale Claude FINAL sweep complete (${posted} posted)`);
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit → Claude directly (skip Copilot when no user PAT)
|
||||
@@ -595,7 +722,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);
|
||||
|
||||
@@ -603,7 +730,7 @@ jobs:
|
||||
# Manual: trigger Claude final review on any PR
|
||||
# ================================================================
|
||||
claude-manual-dispatch:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.pr_number
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
@@ -635,6 +762,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);
|
||||
@@ -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();
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
python:
|
||||
name: python
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
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: |
|
||||
# Keep scripts together so require('./gate-config.js') works from /tmp
|
||||
mkdir -p /tmp/merge-gate-scripts /tmp/gh-actions
|
||||
cp .github/scripts/merge-gate.js .github/scripts/gate-config.js /tmp/merge-gate-scripts/
|
||||
cp -R .github/actions/claude-code-action /tmp/gh-actions/
|
||||
|
||||
- name: Fetch PR branch
|
||||
env:
|
||||
PR_NUMBER: ${{ matrix.pr_number }}
|
||||
run: |
|
||||
git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}"
|
||||
git checkout "pr-${PR_NUMBER}"
|
||||
mkdir -p .github/actions
|
||||
rm -rf .github/actions/claude-code-action
|
||||
cp -R /tmp/gh-actions/claude-code-action .github/actions/
|
||||
|
||||
- name: Claude merge gate assessment (read-only)
|
||||
id: assess
|
||||
uses: ./.github/actions/claude-code-action
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: inherit
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
model: claude-opus-4-8
|
||||
trigger_phrase: '@claude-merge-gate-internal'
|
||||
direct_prompt: |
|
||||
You are the MERGE GATE reviewer for PraisonAI PR #${{ matrix.pr_number }}.
|
||||
READ-ONLY: do not modify code, commit, push, rebase, or create branches.
|
||||
|
||||
CRITICAL OUTPUT RULE:
|
||||
Post exactly one PR comment via GitHub MCP containing either:
|
||||
MERGE_GATE_VERDICT: APPROVE
|
||||
or
|
||||
MERGE_GATE_VERDICT: BLOCK
|
||||
followed by a short rationale on the next lines.
|
||||
|
||||
NEVER include @claude in any comment. NEVER mention merge conflict resolution.
|
||||
|
||||
Review checklist:
|
||||
1. Read AGENTS.md and the PR diff on the current branch.
|
||||
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
|
||||
3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep).
|
||||
4. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
|
||||
5. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
|
||||
6. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
|
||||
7. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}.
|
||||
8. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons.
|
||||
|
||||
Do not approve or merge via GitHub UI/API — only post the verdict comment.
|
||||
disallowed_tools: |
|
||||
Bash
|
||||
Edit
|
||||
Write
|
||||
Replace
|
||||
allowed_tools: |
|
||||
View
|
||||
GlobTool
|
||||
GrepTool
|
||||
Read
|
||||
timeout_minutes: 15
|
||||
|
||||
- name: Post fallback BLOCK if Opus did not comment
|
||||
id: fallback_verdict
|
||||
if: steps.assess.outcome != 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate-scripts/merge-gate.js');
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const existing = mergeGate.findMergeGateVerdict(
|
||||
ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (existing) {
|
||||
core.info(`Opus verdict already posted: ${existing}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
const lines = [
|
||||
'MERGE_GATE_VERDICT: BLOCK',
|
||||
'',
|
||||
'Automated fallback — Opus merge gate assessment did not complete.',
|
||||
];
|
||||
if (check.ready) {
|
||||
lines.push('Deterministic gates passed, but Opus MERGE_GATE_VERDICT is required before merge.');
|
||||
} else {
|
||||
lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`));
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: lines.join('\n'),
|
||||
});
|
||||
core.info('Posted fallback BLOCK — Opus verdict required');
|
||||
|
||||
- name: Require Opus MERGE_GATE_VERDICT
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate-scripts/merge-gate.js');
|
||||
const comments = await mergeGate.listAllComments(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const ctx = await mergeGate.loadPrContext(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(
|
||||
comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (!verdict) {
|
||||
core.setFailed(
|
||||
'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).'
|
||||
);
|
||||
return;
|
||||
}
|
||||
core.info(`Opus merge gate verdict: ${verdict}`);
|
||||
core.setOutput('verdict', verdict);
|
||||
|
||||
- name: Remove merge gate active label
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'claude-merge-gate-active',
|
||||
});
|
||||
core.info(`Removed claude-merge-gate-active from PR #${prNumber}`);
|
||||
} catch (e) {
|
||||
core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`);
|
||||
}
|
||||
|
||||
- name: Fail job if assessment failed
|
||||
if: steps.assess.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
merge-only:
|
||||
needs: [scan-candidates, claude-assess]
|
||||
if: |
|
||||
always() &&
|
||||
needs.scan-candidates.outputs.has_candidates == 'true' &&
|
||||
needs.claude-assess.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Re-check gates and merge
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
MERGE_METHOD: ${{ github.event.inputs.merge_method || 'merge' }}
|
||||
SCAN_HEAD_SHA: ${{ matrix.head_sha }}
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
const scanHeadSha = process.env.SCAN_HEAD_SHA;
|
||||
|
||||
const removeLabels = async () => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: 'claude-merge-gate-active',
|
||||
});
|
||||
} catch (e) {
|
||||
core.info('Label claude-merge-gate-active already removed or missing');
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber);
|
||||
const comments = ctx.comments;
|
||||
const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString();
|
||||
const verdict = mergeGate.findMergeGateVerdict(
|
||||
comments, verdictMin, ctx.headPushedAt, { excludeAutomatedFallback: true }
|
||||
);
|
||||
if (verdict !== 'APPROVE') {
|
||||
core.info(`Skip merge: verdict=${verdict || 'NONE'}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const check = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
if (!check.ready) {
|
||||
core.info(`Skip merge: ${check.reasons.join(', ')}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
if (check.headSha !== scanHeadSha) {
|
||||
core.info(`Skip merge: HEAD moved ${scanHeadSha.slice(0, 7)} → ${check.headSha.slice(0, 7)}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const method = process.env.MERGE_METHOD || 'merge';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let merged = false;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await github.rest.pulls.merge({
|
||||
owner, repo, pull_number: prNumber, merge_method: method,
|
||||
});
|
||||
merged = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
const msg = (e.message || '').toLowerCase();
|
||||
if (attempt < 3 && msg.includes('base branch was modified')) {
|
||||
core.info(`Merge attempt ${attempt} failed (base moved), retrying in 8s...`);
|
||||
await sleep(8000);
|
||||
const recheck = await mergeGate.evaluatePipelineQuiescent(
|
||||
github, owner, repo, prNumber, core, { forMergeStep: true }
|
||||
);
|
||||
if (!recheck.ready) {
|
||||
core.info(`Skip merge retry: ${recheck.reasons.join(', ')}`);
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
if (!merged) {
|
||||
core.info('Skip merge: exhausted retries');
|
||||
await removeLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: ['auto-merged-by-gate'],
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber,
|
||||
body: [
|
||||
'Merged by **Claude PR merge gate** (`claude-merge-gate.yml`).',
|
||||
`Verdict: MERGE_GATE_VERDICT: APPROVE`,
|
||||
`SHA: \`${check.headSha.slice(0, 7)}\``,
|
||||
`Method: ${method}`,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
core.info(`Merged PR #${prNumber}`);
|
||||
} finally {
|
||||
await removeLabels();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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,"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
volumes:
|
||||
- aiui-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8082/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8082/health/live"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -81,7 +81,7 @@ curl -H "Authorization: Bearer <api-key>" http://localhost:8082/api/config
|
||||
```
|
||||
|
||||
**Exempt paths** (always accessible without auth):
|
||||
- `/health`, `/api/health` — health checks
|
||||
- `/health`, `/health/live`, `/health/ready`, `/api/health` — health checks
|
||||
- `/api/auth/login`, `/login` — login endpoints
|
||||
- `/api/protocol`, `/api/protocol/negotiate` — protocol discovery
|
||||
|
||||
@@ -146,8 +146,36 @@ This affects:
|
||||
### Health Endpoint
|
||||
|
||||
```bash
|
||||
# Fast liveness probe (<500ms) - use for load balancers and K8s livenessProbe
|
||||
curl http://localhost:8082/health/live
|
||||
# Returns: {"status": "ok", "timestamp": "2024-..."}
|
||||
|
||||
# Readiness probe with parallel feature checks - use for K8s readinessProbe
|
||||
curl http://localhost:8082/health/ready
|
||||
|
||||
# Deep diagnostics (parallel feature checks, deep by default)
|
||||
curl http://localhost:8082/health
|
||||
# Returns: {"status": "healthy", "timestamp": "2024-..."}
|
||||
# Pass ?deep=false for an immediate liveness response
|
||||
```
|
||||
|
||||
### Kubernetes Probes
|
||||
|
||||
Use the fast liveness endpoint for probes to avoid timeouts:
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 8082
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 1
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 8082
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
```
|
||||
|
||||
### Docker HEALTHCHECK
|
||||
@@ -156,7 +184,7 @@ The Dockerfile includes a built-in health check:
|
||||
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:${AIUI_PORT}/health || exit 1
|
||||
CMD curl -f http://localhost:${AIUI_PORT}/health/live || exit 1
|
||||
```
|
||||
|
||||
### CLI Health Commands
|
||||
@@ -199,7 +227,7 @@ curl -H "X-API-Key: <key>" http://localhost:8082/api/config
|
||||
|
||||
- **Internal networks**: Use `AUTH_ENFORCE=false` if behind a trusted reverse proxy
|
||||
- **Public exposure**: Always set `AUTH_ENFORCE=true` and use HTTPS
|
||||
- **Load balancers**: Health check path is `/health` (exempt from auth)
|
||||
- **Load balancers**: Health check path is `/health/live` (fast, exempt from auth)
|
||||
|
||||
## Managed Hosting
|
||||
|
||||
@@ -209,7 +237,9 @@ Hosting platforms should expose these endpoints:
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `/health` | Health check (returns `{"status": "healthy"}`) |
|
||||
| `/health/live` | Fast liveness probe (returns `{"status": "ok"}`) |
|
||||
| `/health/ready` | Readiness probe (parallel feature checks) |
|
||||
| `/health` | Deep health (parallel feature checks; `?deep=false` for liveness) |
|
||||
| `/api/protocol` | Protocol version and capabilities |
|
||||
| `/api/protocol/negotiate` | Capability negotiation |
|
||||
| `/api/features` | List of registered features |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.122"
|
||||
version = "0.3.129"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -199,11 +199,11 @@ text = text.replace(
|
||||
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);""",
|
||||
if (shellHeader.children.length) root.insertBefore(shellHeader, main);""",
|
||||
)
|
||||
|
||||
# 6) selectPage toolbar
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.121"
|
||||
__version__ = "0.3.129"
|
||||
+11
-9
@@ -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:
|
||||
@@ -1859,7 +1861,7 @@ def health_check(
|
||||
from praisonaiui.health_utils import is_success_status
|
||||
|
||||
try:
|
||||
with urlopen(f"{server}/health") as resp:
|
||||
with urlopen(f"{server}/health/live") as resp:
|
||||
data = _json.loads(resp.read())
|
||||
status = data.get("status", "unknown")
|
||||
ts = data.get("timestamp", "")
|
||||
@@ -1988,7 +1990,7 @@ def provider_health(
|
||||
from urllib.request import urlopen
|
||||
|
||||
try:
|
||||
with urlopen(f"{server}/health") as resp:
|
||||
with urlopen(f"{server}/health/ready?deep=false") as resp:
|
||||
data = _json.loads(resp.read())
|
||||
provider = data.get("provider", {})
|
||||
console.print(f"[green]✓[/green] Provider: {provider.get('name', 'unknown')}")
|
||||
@@ -3098,7 +3100,7 @@ def doctor(
|
||||
return "pass", f"running on {server.split('://')[-1]}"
|
||||
return "warn", f"status: {status}"
|
||||
|
||||
checks.append(_check("Server Health", "/health", _health_extractor))
|
||||
checks.append(_check("Server Health", "/health/live", _health_extractor))
|
||||
|
||||
# Check 2: Provider Status
|
||||
def _provider_extractor(data):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -72,19 +72,30 @@ SUPPORTED_PLATFORMS = [
|
||||
|
||||
# Secret field patterns that should never be persisted in config
|
||||
SECRET_FIELD_PATTERNS = {
|
||||
"api_key", "bot_token", "app_password", "password", "secret",
|
||||
"api_key", "bot_token", "app_password", "password", "secret",
|
||||
"private_key", "access_token", "refresh_token", "webhook_secret"
|
||||
}
|
||||
|
||||
# Suffixes that identify secret-bearing config keys not covered by the
|
||||
# exact-match SECRET_FIELD_PATTERNS set (e.g. app_token, signing_secret).
|
||||
_SECRET_KEY_SUFFIXES = ("_token", "_key", "_secret", "_password")
|
||||
|
||||
|
||||
def _is_secret_key(key: str) -> bool:
|
||||
"""True if a config key holds a secret and must be redacted on read."""
|
||||
lowered = key.lower()
|
||||
return lowered in SECRET_FIELD_PATTERNS or lowered.endswith(_SECRET_KEY_SUFFIXES)
|
||||
|
||||
|
||||
def _validate_config_security(config: Dict[str, Any]) -> Optional[str]:
|
||||
"""Validate channel config doesn't contain inline secrets.
|
||||
|
||||
|
||||
Returns error message if secrets detected, None if safe.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
for key, value in config.items():
|
||||
if key.lower() in SECRET_FIELD_PATTERNS:
|
||||
if _is_secret_key(key):
|
||||
if isinstance(value, str) and value and not value.startswith("env:"):
|
||||
# Check for common secret patterns
|
||||
if (value.startswith(("sk-", "xoxb-", "xoxp-", "xapp-", "am_", "bot")) or
|
||||
@@ -93,40 +104,50 @@ def _validate_config_security(config: Dict[str, Any]) -> Optional[str]:
|
||||
f"'{key}_ref': 'env:YOUR_ENV_VAR'")
|
||||
return None
|
||||
|
||||
|
||||
def _redact_config_secrets(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Redact secret values in config for API responses."""
|
||||
"""Redact secret values in config for API responses.
|
||||
|
||||
Any key matching a known secret name or a secret-bearing suffix is
|
||||
replaced with ``***REDACTED***`` and a ``<key>_set`` boolean is added so
|
||||
the UI can indicate whether a value is configured without exposing it.
|
||||
Values referencing env vars (``env:...``) are left as-is — they contain
|
||||
no literal secret.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
redacted = config.copy()
|
||||
for key, value in config.items():
|
||||
if key.lower() in SECRET_FIELD_PATTERNS and isinstance(value, str) and value:
|
||||
if not value.startswith("env:"):
|
||||
redacted[key] = "***REDACTED***"
|
||||
if _is_secret_key(key) and isinstance(value, str) and value:
|
||||
if value.startswith("env:"):
|
||||
continue
|
||||
redacted[key] = "***REDACTED***"
|
||||
redacted[f"{key}_set"] = True
|
||||
return redacted
|
||||
|
||||
def _get_config_secret(config: Dict[str, Any], key: str, env_fallback: Optional[str] = None) -> str:
|
||||
"""Resolve a secret configuration value, supporting direct values, _ref keys, and env vars."""
|
||||
if not isinstance(config, dict):
|
||||
return os.environ.get(env_fallback, "") if env_fallback else ""
|
||||
|
||||
|
||||
# 1. Check direct key (e.g., "bot_token")
|
||||
val = config.get(key)
|
||||
if isinstance(val, str) and val.startswith("env:"):
|
||||
return os.environ.get(val[4:], "")
|
||||
if val:
|
||||
return str(val)
|
||||
|
||||
|
||||
# 2. Check ref key (e.g., "bot_token_ref")
|
||||
ref_val = config.get(f"{key}_ref")
|
||||
if isinstance(ref_val, str) and ref_val.startswith("env:"):
|
||||
return os.environ.get(ref_val[4:], "")
|
||||
if ref_val:
|
||||
return str(ref_val)
|
||||
|
||||
|
||||
# 3. Fallback to default env var if provided
|
||||
if env_fallback:
|
||||
return os.environ.get(env_fallback, "")
|
||||
|
||||
|
||||
return ""
|
||||
|
||||
# In-memory channel registry — loaded from unified config.yaml
|
||||
@@ -224,13 +245,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 +278,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 +438,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)
|
||||
@@ -899,14 +933,14 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
# Auto-start enabled channels on first request (lazy startup)
|
||||
await self._auto_start_enabled_channels()
|
||||
self._sync_running_status()
|
||||
|
||||
|
||||
# Redact secrets in all channel configs
|
||||
channels = []
|
||||
for channel in _channels.values():
|
||||
safe_channel = channel.copy()
|
||||
safe_channel["config"] = _redact_config_secrets(channel.get("config", {}))
|
||||
channels.append(safe_channel)
|
||||
|
||||
|
||||
return JSONResponse({"channels": channels, "count": len(channels)})
|
||||
|
||||
async def _add(self, request: Request) -> JSONResponse:
|
||||
@@ -920,7 +954,7 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
status_code=400,
|
||||
)
|
||||
config = body.get("config", {})
|
||||
|
||||
|
||||
# Security validation: reject inline secrets
|
||||
security_error = _validate_config_security(config)
|
||||
if security_error:
|
||||
@@ -949,7 +983,7 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
# Sync live status before responding so the frontend sees the real state
|
||||
self._sync_running_status()
|
||||
_persist_channels()
|
||||
|
||||
|
||||
# Redact secrets in response
|
||||
response_entry = entry.copy()
|
||||
response_entry["config"] = _redact_config_secrets(entry["config"])
|
||||
@@ -961,7 +995,7 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
if not channel:
|
||||
return JSONResponse({"error": "Channel not found"}, status_code=404)
|
||||
self._sync_running_status()
|
||||
|
||||
|
||||
# Redact secrets in config
|
||||
safe_channel = channel.copy()
|
||||
safe_channel["config"] = _redact_config_secrets(channel.get("config", {}))
|
||||
@@ -973,7 +1007,7 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
if not channel:
|
||||
return JSONResponse({"error": "Channel not found"}, status_code=404)
|
||||
body = await request.json()
|
||||
|
||||
|
||||
# Security validation for config updates
|
||||
if "config" in body:
|
||||
security_error = _validate_config_security(body["config"])
|
||||
@@ -982,12 +1016,12 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
{"error": security_error},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
for key in ("name", "platform", "enabled", "config"):
|
||||
if key in body:
|
||||
channel[key] = body[key]
|
||||
_persist_channels()
|
||||
|
||||
|
||||
# Redact secrets in response
|
||||
safe_channel = channel.copy()
|
||||
safe_channel["config"] = _redact_config_secrets(channel["config"])
|
||||
@@ -1017,7 +1051,11 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
channel["start_error"] = error
|
||||
else:
|
||||
await self._stop_channel_bot(channel_id)
|
||||
return JSONResponse(channel)
|
||||
|
||||
# Redact secrets in response
|
||||
safe_channel = channel.copy()
|
||||
safe_channel["config"] = _redact_config_secrets(channel.get("config", {}))
|
||||
return JSONResponse(safe_channel)
|
||||
|
||||
async def _status(self, request: Request) -> JSONResponse:
|
||||
channel_id = request.path_params["channel_id"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
+114
-23
@@ -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,41 +947,82 @@ 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.
|
||||
|
||||
For backward compatibility, performs deep health checks on all features.
|
||||
Use /health/live for fast liveness checks or /health/ready for parallel checks.
|
||||
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()
|
||||
for name, feat in get_features().items():
|
||||
try:
|
||||
fh = await feat.health()
|
||||
if fh.get("sdk_gap"):
|
||||
sdk_gaps.append(
|
||||
{
|
||||
"feature": name,
|
||||
"message": fh.get("sdk_gap_message", "SDK gap"),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
features = get_features()
|
||||
feature_tasks = [_safe_health(name, feat) for name, feat in features.items()]
|
||||
|
||||
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(
|
||||
{
|
||||
"feature": name,
|
||||
"message": fh.get("sdk_gap_message", "SDK gap"),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
provider_info.update(await _safe_provider())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"status": "ok",
|
||||
@@ -983,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)
|
||||
|
||||
|
||||
@@ -1440,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)})
|
||||
@@ -1527,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.
|
||||
|
||||
@@ -3026,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
|
||||
@@ -3508,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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -316,8 +316,12 @@ const DASHBOARD_STYLE = `
|
||||
padding: 20px; font-size: 15px; font-weight: 600;
|
||||
letter-spacing: -0.01em; border-bottom: 1px solid var(--db-border);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
min-width: 0; padding-right: 28px;
|
||||
}
|
||||
.db-sidebar-header .logo { font-size: 20px; }
|
||||
.db-sidebar-header > span:not(.logo) {
|
||||
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.db-sidebar-header .logo { font-size: 20px; flex-shrink: 0; }
|
||||
.db-sidebar-toggle {
|
||||
position: absolute;
|
||||
right: -12px;
|
||||
@@ -408,6 +412,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); }
|
||||
@@ -449,6 +484,9 @@ const DASHBOARD_STYLE = `
|
||||
@keyframes db-spin { to { transform: rotate(360deg); } }
|
||||
.db-spinner { display: inline-block; width: 24px; height: 24px; border: 2px solid var(--db-border); border-top-color: var(--db-accent); border-radius: 50%; animation: db-spin 0.8s linear infinite; }
|
||||
|
||||
/* Init error banner */
|
||||
.db-error { margin: 24px; padding: 16px 20px; border: 1px solid #b91c1c; border-radius: 8px; background: rgba(185, 28, 28, 0.12); color: #fca5a5; font-size: 14px; }
|
||||
|
||||
/* Hide React docs layout when dashboard is active */
|
||||
.db-active .sidebar:not(.db-sidebar), .db-active .topnav, .db-active .toc-sidebar,
|
||||
.db-active > .main-content, .db-active > nav:not(.db-sidebar) { display: none !important; }
|
||||
@@ -552,6 +590,22 @@ const DASHBOARD_STYLE = `
|
||||
appearance: none; cursor: pointer;
|
||||
}
|
||||
.db-form-select:focus { border-color: var(--db-accent); }
|
||||
.db-form-select option, select.db-form-select option {
|
||||
background-color: var(--db-sidebar-bg);
|
||||
color: var(--db-text);
|
||||
}
|
||||
.db-form-select option:checked, select.db-form-select option:checked {
|
||||
background-color: var(--db-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.db-active select option, #root select option {
|
||||
background-color: var(--db-sidebar-bg);
|
||||
color: var(--db-text);
|
||||
}
|
||||
.db-active select option:checked, #root select option:checked {
|
||||
background-color: var(--db-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.db-form-textarea {
|
||||
width: 100%; padding: 8px 12px; font-size: 13px; color: var(--db-text);
|
||||
background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: 6px;
|
||||
@@ -694,6 +748,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; }
|
||||
|
||||
@@ -942,10 +1009,13 @@ function openCommandPalette() {
|
||||
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 items = pagesData
|
||||
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))
|
||||
@@ -1004,6 +1074,10 @@ function runPaletteItem(idx) {
|
||||
const page = _paletteResults[idx];
|
||||
if (!page) return;
|
||||
closeCommandPalette();
|
||||
if (page.action === 'sessions') {
|
||||
openSessionSearch();
|
||||
return;
|
||||
}
|
||||
selectPage(page.id);
|
||||
}
|
||||
|
||||
@@ -1013,7 +1087,11 @@ function closeCommandPalette() {
|
||||
_paletteResults = [];
|
||||
}
|
||||
|
||||
let _paletteInitialized = false;
|
||||
|
||||
function initCommandPalette() {
|
||||
if (_paletteInitialized) return;
|
||||
_paletteInitialized = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
@@ -1022,6 +1100,184 @@ function initCommandPalette() {
|
||||
});
|
||||
}
|
||||
|
||||
window.aiui = window.aiui || {};
|
||||
window.aiui.openCommandPalette = openCommandPalette;
|
||||
window.aiui.closeCommandPalette = closeCommandPalette;
|
||||
window.openCommandPalette = 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');
|
||||
@@ -1044,6 +1300,9 @@ async function init() {
|
||||
|
||||
// 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) => {
|
||||
@@ -1106,11 +1365,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(/^\//, '') || '';
|
||||
@@ -1268,6 +1527,20 @@ function buildSidebar(pages) {
|
||||
return sidebar;
|
||||
}
|
||||
|
||||
function showDashboardInitError(message) {
|
||||
const root = document.getElementById('root');
|
||||
if (!root) return;
|
||||
let banner = document.getElementById('db-init-error');
|
||||
if (!banner) {
|
||||
banner = document.createElement('div');
|
||||
banner.id = 'db-init-error';
|
||||
banner.className = 'db-error';
|
||||
banner.setAttribute('role', 'alert');
|
||||
root.appendChild(banner);
|
||||
}
|
||||
banner.textContent = message;
|
||||
}
|
||||
|
||||
async function selectPage(pageId) {
|
||||
// Cleanup previous view if it has a cleanup function
|
||||
if (_activeCleanup) { try { _activeCleanup(); } catch(e) {} _activeCleanup = null; }
|
||||
@@ -1286,7 +1559,15 @@ async function selectPage(pageId) {
|
||||
|
||||
const page = pagesData.find(p => p.id === pageId);
|
||||
const main = document.getElementById('db-main-content');
|
||||
if (!main || !page) return;
|
||||
if (!main) {
|
||||
console.error('[AIUI] selectPage aborted: #db-main-content not in DOM — dashboard shell incomplete');
|
||||
showDashboardInitError('Dashboard shell layout incomplete. Reload the page or check the dashboard plugin.');
|
||||
return;
|
||||
}
|
||||
if (!page) {
|
||||
console.error(`[AIUI] selectPage aborted: page '${pageId}' not found in pagesData`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set page header (controlled by dashboard config protocol)
|
||||
const showPageHeader = main.dataset.showPageHeader !== 'false';
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* Config-driven: auto-discovers agents and sessions from existing APIs.
|
||||
*/
|
||||
|
||||
import { showToast } from '../toast.js';
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────
|
||||
let ws = null;
|
||||
let currentSessionId = null;
|
||||
@@ -19,6 +21,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 +787,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;
|
||||
@@ -1051,6 +1065,7 @@ function connectWebSocket() {
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('[Chat] WebSocket error:', e);
|
||||
setStatus('error');
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[Chat] WebSocket connection failed:', e);
|
||||
@@ -1347,6 +1362,9 @@ function appendMessage(role, content, agentName, elements) {
|
||||
|
||||
const msgEl = document.createElement('div');
|
||||
msgEl.className = 'chat-msg chat-msg-' + role;
|
||||
if (role === 'system' && typeof content === 'string' && content.startsWith('❌')) {
|
||||
msgEl.setAttribute('role', 'alert');
|
||||
}
|
||||
|
||||
const avatarEl = document.createElement('div');
|
||||
avatarEl.className = 'chat-msg-avatar';
|
||||
@@ -1895,12 +1913,20 @@ function sendMessage() {
|
||||
if (pendingAttachments.length > 0) {
|
||||
payload.attachment_ids = pendingAttachments.map(a => a.id);
|
||||
}
|
||||
ws.send(JSON.stringify(payload));
|
||||
setStreaming(true);
|
||||
setStatus('streaming');
|
||||
try {
|
||||
ws.send(JSON.stringify(payload));
|
||||
setStreaming(true);
|
||||
setStatus('streaming');
|
||||
} catch (e) {
|
||||
const msg = 'Connection lost. Message not sent.';
|
||||
appendMessage('system', '❌ ' + msg);
|
||||
setStatus('error');
|
||||
try { showToast(msg, 'error'); } catch (_) {}
|
||||
}
|
||||
} else {
|
||||
// Fallback to HTTP
|
||||
setStatus('sending…');
|
||||
const offlineHint = (typeof navigator !== 'undefined' && navigator.onLine === false) ? 'You are offline. ' : '';
|
||||
fetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1913,13 +1939,19 @@ function sendMessage() {
|
||||
}).then((r) => r.json()).then((data) => {
|
||||
if (data.error) {
|
||||
appendMessage('system', '❌ ' + data.error);
|
||||
setStatus('error');
|
||||
try { showToast(data.error, 'error'); } catch (_) {}
|
||||
} else if (data.content || data.response) {
|
||||
appendMessage('assistant', data.content || data.response, data.agent_name);
|
||||
setStatus('connected');
|
||||
} else {
|
||||
setStatus('connected');
|
||||
}
|
||||
setStatus('connected');
|
||||
}).catch((e) => {
|
||||
appendMessage('system', '❌ ' + e.message);
|
||||
const msg = offlineHint + (e.message || 'Network error');
|
||||
appendMessage('system', '❌ ' + msg);
|
||||
setStatus('error');
|
||||
try { showToast(msg, 'error'); } catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Tests for channel secret redaction in API responses.
|
||||
|
||||
Regression coverage for issue #176 (recurrence of #58): ``GET /api/channels``
|
||||
and related handlers serialized the in-memory ``_channels`` dict verbatim,
|
||||
exposing ``config.bot_token`` and other secrets in the browser Network panel
|
||||
without authentication. Read handlers must redact secrets while the write path
|
||||
still accepts and uses the real token for bot startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiui.features import channels as channels_mod
|
||||
from praisonaiui.features.channels import (
|
||||
ChannelsFeature,
|
||||
_redact_config_secrets,
|
||||
_validate_config_security,
|
||||
)
|
||||
|
||||
_DISCORD_TOKEN = "MTQ" + "a1b2c3d4e5f6g7h8i9j0k1l2"
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, path_params=None, body=None):
|
||||
self.path_params = path_params or {}
|
||||
self._body = body or {}
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
@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 = True
|
||||
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
|
||||
|
||||
|
||||
def _seed_channel():
|
||||
entry = {
|
||||
"id": "e4a6d6e1d0cf",
|
||||
"name": "DhivyaBot",
|
||||
"platform": "discord",
|
||||
"enabled": True,
|
||||
"running": True,
|
||||
"config": {"bot_token": _DISCORD_TOKEN},
|
||||
}
|
||||
channels_mod._channels[entry["id"]] = entry
|
||||
return entry
|
||||
|
||||
|
||||
def _body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
# ── Redaction helper ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_redact_covers_suffix_secret_keys():
|
||||
out = _redact_config_secrets(
|
||||
{
|
||||
"bot_token": _DISCORD_TOKEN,
|
||||
"app_token": "xapp-1-abc",
|
||||
"signing_secret": "shh",
|
||||
"client_secret": "shh2",
|
||||
"api_key": "k",
|
||||
"webhook_url": "https://example.com/hook",
|
||||
}
|
||||
)
|
||||
assert out["bot_token"] == "***REDACTED***"
|
||||
assert out["app_token"] == "***REDACTED***"
|
||||
assert out["signing_secret"] == "***REDACTED***"
|
||||
assert out["client_secret"] == "***REDACTED***"
|
||||
assert out["api_key"] == "***REDACTED***"
|
||||
assert out["webhook_url"] == "https://example.com/hook"
|
||||
assert out["bot_token_set"] is True
|
||||
assert out["app_token_set"] is True
|
||||
|
||||
|
||||
def test_redact_preserves_env_references():
|
||||
out = _redact_config_secrets({"bot_token": "env:DISCORD_BOT_TOKEN"})
|
||||
assert out["bot_token"] == "env:DISCORD_BOT_TOKEN"
|
||||
assert "bot_token_set" not in out
|
||||
|
||||
|
||||
def test_redact_is_non_mutating():
|
||||
original = {"bot_token": _DISCORD_TOKEN}
|
||||
_redact_config_secrets(original)
|
||||
assert original["bot_token"] == _DISCORD_TOKEN
|
||||
|
||||
|
||||
# ── Write path rejects inline secrets on suffix keys ─────────────────
|
||||
|
||||
|
||||
def test_validate_rejects_inline_suffix_secret():
|
||||
err = _validate_config_security({"app_token": "xapp-1-" + "a" * 30})
|
||||
assert err is not None
|
||||
assert "app_token" in err
|
||||
|
||||
|
||||
def test_validate_allows_env_reference_suffix_key():
|
||||
assert _validate_config_security({"signing_secret": "env:SLACK_SIGNING_SECRET"}) is None
|
||||
|
||||
|
||||
# ── Read handlers must not leak ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_redacts_bot_token():
|
||||
_seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
with patch.object(feature, "_sync_running_status"):
|
||||
resp = await feature._list(_Req())
|
||||
body = _body(resp)
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
cfg = body["channels"][0]["config"]
|
||||
assert cfg["bot_token"] == "***REDACTED***"
|
||||
assert cfg["bot_token_set"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_redacts_bot_token():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
with patch.object(feature, "_sync_running_status"):
|
||||
resp = await feature._get(_Req(path_params={"channel_id": entry["id"]}))
|
||||
body = _body(resp)
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
assert body["config"]["bot_token"] == "***REDACTED***"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_response_redacts_bot_token():
|
||||
feature = ChannelsFeature()
|
||||
req = _Req(
|
||||
body={
|
||||
"id": "newch",
|
||||
"name": "New",
|
||||
"platform": "discord",
|
||||
"config": {"bot_token": "env:DISCORD_BOT_TOKEN"},
|
||||
}
|
||||
)
|
||||
with patch.object(feature, "_start_channel_bot", return_value=None), patch.object(
|
||||
feature, "_sync_running_status"
|
||||
), patch.object(channels_mod, "_persist_channels"):
|
||||
resp = await feature._add(req)
|
||||
body = _body(resp)
|
||||
assert resp.status_code == 201
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
assert body["config"]["bot_token"] == "env:DISCORD_BOT_TOKEN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_response_redacts_bot_token():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
req = _Req(
|
||||
path_params={"channel_id": entry["id"]},
|
||||
body={"name": "Renamed"},
|
||||
)
|
||||
with patch.object(channels_mod, "_persist_channels"):
|
||||
resp = await feature._update(req)
|
||||
body = _body(resp)
|
||||
assert body["name"] == "Renamed"
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
assert body["config"]["bot_token"] == "***REDACTED***"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_response_redacts_bot_token():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
req = _Req(path_params={"channel_id": entry["id"]})
|
||||
with patch.object(feature, "_stop_channel_bot", return_value=None):
|
||||
resp = await feature._toggle(req)
|
||||
body = _body(resp)
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
assert body["config"]["bot_token"] == "***REDACTED***"
|
||||
|
||||
|
||||
# ── Write path must keep the real secret internally ──────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_store_retains_real_token_after_read():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
with patch.object(feature, "_sync_running_status"):
|
||||
await feature._list(_Req())
|
||||
assert channels_mod._channels[entry["id"]]["config"]["bot_token"] == _DISCORD_TOKEN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_channel_bot_reads_unredacted_token():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_direct(platform, token, agent, config):
|
||||
captured["token"] = token
|
||||
return None
|
||||
|
||||
with patch.object(feature, "_get_gateway", return_value=None), patch.object(
|
||||
feature, "_create_bot_direct", side_effect=_fake_direct
|
||||
):
|
||||
await feature._start_channel_bot(entry["id"], entry)
|
||||
|
||||
assert captured["token"] == _DISCORD_TOKEN
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Regression tests for #177 — offline chat send produces no user-visible error.
|
||||
|
||||
The browser E2E audit simulated offline mode on /chat and found no visible
|
||||
error feedback. These source-parsing tests assert the fixes:
|
||||
|
||||
RC-2 selectPage shows a shell error (.db-error / role=alert via
|
||||
showDashboardInitError) when #db-main-content is missing instead
|
||||
of returning silently.
|
||||
RC-3 the HTTP fallback catch path surfaces an offline hint plus a toast.
|
||||
RC-3b system error bubbles (prefixed with ❌) get role="alert" for a11y.
|
||||
RC-4 ws.send() is wrapped in try/catch with a user-visible error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 TestSelectPageShellError:
|
||||
def test_shell_error_helper_defined(self):
|
||||
src = DASHBOARD_JS.read_text()
|
||||
assert "function showDashboardInitError(" in src
|
||||
assert "'alert'" in src
|
||||
assert "db-error" in src
|
||||
|
||||
def test_select_page_calls_shell_error_on_missing_main(self):
|
||||
src = DASHBOARD_JS.read_text()
|
||||
idx = src.find("const main = document.getElementById('db-main-content')")
|
||||
assert idx != -1
|
||||
region = src[idx:idx + 400]
|
||||
assert "if (!main)" in region
|
||||
assert "showDashboardInitError(" in region
|
||||
|
||||
|
||||
class TestChatOfflineMessaging:
|
||||
def test_toast_imported(self):
|
||||
src = CHAT_JS.read_text()
|
||||
assert "import { showToast } from '../toast.js';" in src
|
||||
|
||||
def test_offline_hint_in_fallback(self):
|
||||
src = CHAT_JS.read_text()
|
||||
assert "navigator.onLine === false" in src
|
||||
assert "You are offline. " in src
|
||||
|
||||
def test_fallback_catch_shows_toast(self):
|
||||
src = CHAT_JS.read_text()
|
||||
idx = src.find("fetch('/api/chat/send'")
|
||||
assert idx != -1
|
||||
region = src[idx:idx + 900]
|
||||
assert ".catch(" in region
|
||||
assert "showToast(" in region
|
||||
assert "appendMessage('system'" in region
|
||||
assert "setStatus('error')" in region
|
||||
|
||||
|
||||
class TestSystemErrorRoleAlert:
|
||||
def test_append_message_sets_role_alert(self):
|
||||
src = CHAT_JS.read_text()
|
||||
idx = src.find("function appendMessage(")
|
||||
assert idx != -1
|
||||
region = src[idx:idx + 700]
|
||||
assert "setAttribute('role', 'alert')" in region
|
||||
assert "startsWith('❌')" in region
|
||||
|
||||
|
||||
class TestWebSocketSendGuard:
|
||||
def test_ws_send_wrapped_in_try_catch(self):
|
||||
src = CHAT_JS.read_text()
|
||||
idx = src.find("ws.send(JSON.stringify(payload))")
|
||||
assert idx != -1
|
||||
before = src[max(0, idx - 120):idx]
|
||||
after = src[idx:idx + 300]
|
||||
assert "try {" in before
|
||||
assert "catch" in after
|
||||
assert "Connection lost. Message not sent." in after
|
||||
@@ -68,7 +68,7 @@ class TestDoctorCommand:
|
||||
def test_doctor_health_extractor_accepts_success_statuses(self, status):
|
||||
"""Test doctor command's health extractor accepts both ok and healthy."""
|
||||
def mock_api_get(server, path):
|
||||
if path == "/health":
|
||||
if path == "/health/live":
|
||||
return {"status": status}
|
||||
elif path == "/api/provider":
|
||||
return {"name": "TestProvider"}
|
||||
@@ -102,7 +102,7 @@ class TestDoctorCommand:
|
||||
def test_doctor_health_extractor_warns_for_non_success_statuses(self, status):
|
||||
"""Test doctor command's health extractor warns for non-success statuses."""
|
||||
def mock_api_get(server, path):
|
||||
if path == "/health":
|
||||
if path == "/health/live":
|
||||
return {"status": status}
|
||||
return {}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Regression tests for #178 — Ctrl+K command palette exposure and idempotency.
|
||||
|
||||
The dashboard command palette (initCommandPalette / openCommandPalette /
|
||||
.db-cmdk-overlay) is implemented in the shipped bundle, but the acceptance
|
||||
criteria require openCommandPalette to be reachable from the global scope
|
||||
(``typeof openCommandPalette === 'function'``) and require that repeated
|
||||
initialisation (HMR / full reload) does not double-register the keydown
|
||||
handler. These source-parsing tests mirror ``test_dashboard_shell_order.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 TestCommandPalette:
|
||||
def _src(self) -> str:
|
||||
return DASHBOARD_JS.read_text()
|
||||
|
||||
def test_open_command_palette_exposed_globally(self):
|
||||
src = self._src()
|
||||
assert "window.openCommandPalette = openCommandPalette" in src, (
|
||||
"openCommandPalette must be exposed on window for the global "
|
||||
"'typeof openCommandPalette === function' acceptance criterion"
|
||||
)
|
||||
|
||||
def test_open_command_palette_on_aiui_namespace(self):
|
||||
src = self._src()
|
||||
assert "window.aiui.openCommandPalette = openCommandPalette" in src
|
||||
|
||||
def test_init_command_palette_guarded_against_double_register(self):
|
||||
src = self._src()
|
||||
m = re.search(
|
||||
r"function initCommandPalette\([^)]*\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "initCommandPalette not found"
|
||||
body = m.group(1)
|
||||
assert "_paletteInitialized" in body, (
|
||||
"initCommandPalette must guard against duplicate registration"
|
||||
)
|
||||
assert "return" in body, (
|
||||
"initCommandPalette must early-return when already initialised"
|
||||
)
|
||||
|
||||
def test_init_command_palette_called_after_build(self):
|
||||
src = self._src()
|
||||
build_idx = src.find("await buildDashboard()")
|
||||
init_idx = src.find("initCommandPalette();")
|
||||
assert build_idx != -1, "buildDashboard call not found"
|
||||
assert init_idx != -1, "initCommandPalette call not found"
|
||||
assert build_idx < init_idx, (
|
||||
"initCommandPalette must run after buildDashboard so the shell exists"
|
||||
)
|
||||
|
||||
def test_ctrl_and_meta_k_bound(self):
|
||||
src = self._src()
|
||||
assert re.search(
|
||||
r"e\.ctrlKey \|\| e\.metaKey", src
|
||||
), "Ctrl+K / Cmd+K must both open the palette"
|
||||
@@ -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")
|
||||
@@ -12,9 +12,9 @@ def test_feature_registry_basic():
|
||||
assert registry.is_implemented("site")
|
||||
assert registry.is_implemented("seo")
|
||||
assert registry.is_implemented("a11y")
|
||||
|
||||
# Test experimental features
|
||||
assert registry.is_experimental("i18n")
|
||||
# i18n graduated to implemented (since 0.5.0)
|
||||
assert registry.is_implemented("i18n")
|
||||
assert not registry.is_experimental("i18n")
|
||||
|
||||
# Test non-existent features
|
||||
assert not registry.is_implemented("nonexistent")
|
||||
@@ -34,14 +34,14 @@ def test_feature_registry_with_config():
|
||||
experimental = registry.get_experimental_fields(config)
|
||||
assert experimental == []
|
||||
|
||||
# Config with experimental features
|
||||
config_with_experimental = Config(
|
||||
# i18n is now an implemented feature, so it is not reported as experimental
|
||||
config_with_i18n = Config(
|
||||
site=SiteConfig(title="Test"),
|
||||
i18n=I18nConfig(locales=["en", "es"])
|
||||
)
|
||||
|
||||
experimental = registry.get_experimental_fields(config_with_experimental)
|
||||
assert "i18n" in experimental
|
||||
experimental = registry.get_experimental_fields(config_with_i18n)
|
||||
assert "i18n" not in experimental
|
||||
|
||||
|
||||
def test_feature_info():
|
||||
@@ -55,5 +55,5 @@ def test_feature_info():
|
||||
|
||||
i18n_info = registry.get_feature("i18n")
|
||||
assert i18n_info is not None
|
||||
assert i18n_info.status == FeatureStatus.EXPERIMENTAL
|
||||
assert i18n_info.since is None
|
||||
assert i18n_info.status == FeatureStatus.IMPLEMENTED
|
||||
assert i18n_info.since == "0.5.0"
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Regression tests for issue #172.
|
||||
|
||||
Two dark-theme defects on the dashboard shell:
|
||||
|
||||
1. Native ``<select>`` option popups inherited the dashboard's light
|
||||
foreground colour while the browser painted a white system background,
|
||||
producing unreadable white-on-white platform labels (WCAG AA failure).
|
||||
|
||||
2. The sidebar header brand text could overlap an adjacent control because
|
||||
the long title had no overflow/ellipsis guard and the absolutely
|
||||
positioned toggle reserved no space.
|
||||
|
||||
Both fixes live in the committed dashboard bundle
|
||||
``src/praisonaiui/templates/frontend/plugins/dashboard.js`` (``DASHBOARD_STYLE``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
DASHBOARD_JS = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend"
|
||||
/ "plugins" / "dashboard.js"
|
||||
)
|
||||
|
||||
|
||||
def _style() -> str:
|
||||
src = DASHBOARD_JS.read_text()
|
||||
m = re.search(r"const DASHBOARD_STYLE = `(.*?)`;", src, re.DOTALL)
|
||||
assert m, "DASHBOARD_STYLE template literal not found"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
class TestSelectOptionContrast:
|
||||
"""Native option popups must use a dark surface with light text."""
|
||||
|
||||
def test_form_select_option_has_explicit_colors(self):
|
||||
style = _style()
|
||||
m = re.search(r"\.db-form-select option[^{]*\{([^}]*)\}", style)
|
||||
assert m, "no explicit styling for .db-form-select option"
|
||||
body = m.group(1)
|
||||
assert "background-color" in body
|
||||
assert "color" in body
|
||||
assert "var(--db-text)" in body
|
||||
|
||||
def test_generic_dashboard_select_option_styled(self):
|
||||
style = _style()
|
||||
assert ".db-active select option" in style or "#root select option" in style
|
||||
|
||||
def test_selected_option_is_highlighted(self):
|
||||
style = _style()
|
||||
assert "option:checked" in style
|
||||
|
||||
|
||||
class TestSidebarHeaderNoOverlap:
|
||||
"""Sidebar brand text must not overlap adjacent controls."""
|
||||
|
||||
def test_header_reserves_space_and_truncates(self):
|
||||
style = _style()
|
||||
m = re.search(r"\n \.db-sidebar-header\s*\{([^}]*)\}", style)
|
||||
assert m, ".db-sidebar-header base rule not found"
|
||||
body = m.group(1)
|
||||
assert "min-width: 0" in body or "min-width:0" in body
|
||||
assert "padding-right" in body
|
||||
|
||||
def test_header_label_has_ellipsis(self):
|
||||
style = _style()
|
||||
assert "text-overflow: ellipsis" in style
|
||||
assert "white-space: nowrap" in style
|
||||
|
||||
def test_logo_does_not_shrink(self):
|
||||
style = _style()
|
||||
m = re.search(r"\.db-sidebar-header \.logo\s*\{([^}]*)\}", style)
|
||||
assert m, ".db-sidebar-header .logo rule not found"
|
||||
assert "flex-shrink: 0" in m.group(1)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Regression tests for #175 — selectPage() silent abort when #db-main-content missing.
|
||||
|
||||
The dashboard style (`dashboard.js`) skips React. When the shell layout is
|
||||
incomplete and `#db-main-content` is absent, `selectPage()` used to return
|
||||
silently, leaving every view empty with no console error or user-visible signal.
|
||||
These source-parsing tests assert the defensive guard and error surface are
|
||||
wired in, 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 TestSelectPageGuard:
|
||||
def _select_page_body(self) -> str:
|
||||
src = DASHBOARD_JS.read_text()
|
||||
m = re.search(
|
||||
r"async function selectPage\(pageId\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "selectPage not found"
|
||||
return m.group(1)
|
||||
|
||||
def test_missing_main_logs_console_error(self):
|
||||
body = self._select_page_body()
|
||||
m = re.search(
|
||||
r"if \(!main\)\s*\{(.*?)\}", body, re.DOTALL,
|
||||
)
|
||||
assert m, "guard for missing #db-main-content not found"
|
||||
guard = m.group(1)
|
||||
assert "console.error" in guard
|
||||
assert "db-main-content" in guard
|
||||
|
||||
def test_missing_main_surfaces_init_error(self):
|
||||
body = self._select_page_body()
|
||||
m = re.search(
|
||||
r"if \(!main\)\s*\{(.*?)\}", body, re.DOTALL,
|
||||
)
|
||||
assert m, "guard for missing #db-main-content not found"
|
||||
assert "showDashboardInitError" in m.group(1)
|
||||
|
||||
def test_missing_page_logs_console_error(self):
|
||||
body = self._select_page_body()
|
||||
m = re.search(
|
||||
r"if \(!page\)\s*\{(.*?)\}", body, re.DOTALL,
|
||||
)
|
||||
assert m, "guard for missing page not found"
|
||||
assert "console.error" in m.group(1)
|
||||
|
||||
def test_show_dashboard_init_error_helper_present(self):
|
||||
src = DASHBOARD_JS.read_text()
|
||||
m = re.search(
|
||||
r"function showDashboardInitError\(message\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "showDashboardInitError helper not found"
|
||||
helper = m.group(1)
|
||||
assert "db-init-error" in helper
|
||||
assert "role" in helper and "alert" in helper
|
||||
|
||||
def test_no_silent_combined_return(self):
|
||||
body = self._select_page_body()
|
||||
assert "if (!main || !page) return;" not in body
|
||||
@@ -0,0 +1,65 @@
|
||||
"""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
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = (
|
||||
ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
DASHBOARD_JS = FRONTEND / "dashboard.js"
|
||||
GENERATOR = ROOT / "scripts" / "patch_dashboard_modular.py"
|
||||
|
||||
|
||||
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"
|
||||
|
||||
def test_generator_emits_main_before_insert_before(self):
|
||||
src = GENERATOR.read_text()
|
||||
append_idx = src.find("root.appendChild(main);\n const shellHeader")
|
||||
insert_idx = src.find("root.insertBefore(shellHeader, main)")
|
||||
assert append_idx != -1, (
|
||||
"generator must emit root.appendChild(main) before shellHeader"
|
||||
)
|
||||
assert insert_idx != -1, (
|
||||
"generator must emit root.insertBefore(shellHeader, main)"
|
||||
)
|
||||
assert append_idx < insert_idx, (
|
||||
"generator must append main to root before insertBefore references it"
|
||||
)
|
||||
@@ -3,11 +3,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _praisonai_jobs_available() -> bool:
|
||||
try:
|
||||
return importlib.util.find_spec("praisonai.jobs") is not None
|
||||
except ModuleNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_jobs_client():
|
||||
import praisonaiui.server as server
|
||||
@@ -30,8 +38,15 @@ def test_jobs_submit_and_get(simple_jobs_client):
|
||||
assert detail.json()["id"] == job_id
|
||||
|
||||
|
||||
def _praisonai_jobs_available() -> bool:
|
||||
try:
|
||||
return __import__("importlib").util.find_spec("praisonai.jobs") is not None
|
||||
except ModuleNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
__import__("importlib").util.find_spec("praisonai.jobs") is None,
|
||||
not _praisonai_jobs_available(),
|
||||
reason="praisonai package not installed",
|
||||
)
|
||||
def test_sdk_job_store_roundtrip():
|
||||
|
||||
@@ -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)
|
||||
@@ -102,6 +106,128 @@ class TestHealthEndpoint:
|
||||
response = client.get(endpoint)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_health_live_under_500ms(self, client):
|
||||
"""Liveness probe must respond well under 500ms."""
|
||||
import time
|
||||
|
||||
start = time.perf_counter()
|
||||
response = client.get("/health/live")
|
||||
duration = time.perf_counter() - start
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
assert duration < 0.5
|
||||
|
||||
def test_health_deep_false_is_liveness(self, client):
|
||||
"""Legacy /health?deep=false returns an immediate liveness response."""
|
||||
response = client.get("/health?deep=false")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "timestamp" in data
|
||||
assert "provider" not in data
|
||||
|
||||
def test_health_slow_feature_does_not_block(self, client):
|
||||
"""A slow feature must not block the legacy /health response."""
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from praisonaiui.features import _features
|
||||
|
||||
class _SlowFeature:
|
||||
async def health(self):
|
||||
await asyncio.sleep(5)
|
||||
return {"healthy": True}
|
||||
|
||||
_features["__slow_test__"] = _SlowFeature()
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
response = client.get("/health")
|
||||
duration = time.perf_counter() - start
|
||||
assert response.status_code == 200
|
||||
assert duration < 2.0
|
||||
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."""
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""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):
|
||||
# The Ctrl+K binding is owned by the command palette, which exposes a
|
||||
# "Search sessions…" entry that routes to openSessionSearch(). Assert the
|
||||
# UX contract (Ctrl+K reaches session search) rather than where the
|
||||
# keydown listener physically lives.
|
||||
src = self._src()
|
||||
assert "initSessionSearch" in src
|
||||
# Ctrl+K / Cmd+K ownership lives in the command palette
|
||||
# (initCommandPalette), which exposes a "Search sessions…" entry that
|
||||
# calls openSessionSearch. initSessionSearch only owns Escape-to-close.
|
||||
m = re.search(
|
||||
r"function initCommandPalette\(\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "initCommandPalette 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
|
||||
# The palette must route the "sessions" action to openSessionSearch().
|
||||
assert "openSessionSearch()" in src
|
||||
assert "action: 'sessions'" in src or 'action: "sessions"' in src
|
||||
|
||||
# initSessionSearch owns only Escape-to-close for the session palette.
|
||||
m = re.search(
|
||||
r"function initSessionSearch\(\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "initSessionSearch not found"
|
||||
assert "Escape" in m.group(1), (
|
||||
"initSessionSearch must own Escape-to-close for the session palette"
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -207,7 +207,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiui"
|
||||
version = "0.3.122"
|
||||
version = "0.3.129"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
@@ -1745,7 +1745,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
@@ -3352,11 +3352,11 @@ resolution-markers = [
|
||||
"python_full_version < '3.11' and sys_platform == 'win32'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "click", marker = "sys_platform == 'win32'" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform == 'win32'" },
|
||||
{ name = "onnxruntime", marker = "sys_platform == 'win32'" },
|
||||
{ name = "python-dotenv", marker = "sys_platform == 'win32'" },
|
||||
{ name = "click" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/8fdd991142ad3e037179a494b153f463024e5a211ef3ad948b955c26b4de/magika-0.6.2.tar.gz", hash = "sha256:37eb6ae8020f6e68f231bc06052c0a0cbe8e6fa27492db345e8dc867dbceb067", size = 3036634, upload-time = "2025-05-02T14:54:18.88Z" }
|
||||
wheels = [
|
||||
@@ -3376,11 +3376,11 @@ resolution-markers = [
|
||||
"python_full_version < '3.11' and sys_platform != 'win32'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "click", marker = "sys_platform != 'win32'" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform != 'win32'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform != 'win32'" },
|
||||
{ name = "onnxruntime", marker = "sys_platform != 'win32'" },
|
||||
{ name = "python-dotenv", marker = "sys_platform != 'win32'" },
|
||||
{ name = "click" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/f3/3d1dcdd7b9c41d589f5cff252d32ed91cdf86ba84391cfc81d9d8773571d/magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1", size = 3042784, upload-time = "2025-10-30T15:22:34.499Z" }
|
||||
wheels = [
|
||||
@@ -6416,7 +6416,7 @@ resolution-markers = [
|
||||
"python_full_version < '3.11' and sys_platform != 'win32'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
|
||||
wheels = [
|
||||
@@ -6482,7 +6482,7 @@ resolution-markers = [
|
||||
"python_full_version == '3.11.*' and sys_platform != 'win32'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" } },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
||||
wheels = [
|
||||
@@ -6814,8 +6814,8 @@ name = "standard-aifc"
|
||||
version = "3.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "standard-chunk", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "audioop-lts" },
|
||||
{ name = "standard-chunk" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" }
|
||||
wheels = [
|
||||
|
||||
Reference in new issue
Block a user