mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 13:56:36 -10:00
Compare commits
28
Commits
No files matched your search
@@ -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 }}
|
||||
@@ -150,7 +150,7 @@ function claudeFinalAlreadyTriggered(comments) {
|
||||
}
|
||||
|
||||
function claudeFinalReady(comments, reviews = [], options = {}) {
|
||||
if (claudeFinalAlreadyTriggered(comments)) {
|
||||
if (!options.allowStaleRepost && claudeFinalAlreadyTriggered(comments)) {
|
||||
return { ready: false, reason: 'claude FINAL already triggered', already: true };
|
||||
}
|
||||
const prior = priorReviewersReady(comments, reviews, options);
|
||||
@@ -194,6 +194,8 @@ async function maybeTriggerClaudeFinal(github, owner, repo, prNumber, finalBody,
|
||||
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}`);
|
||||
|
||||
@@ -16,11 +16,15 @@ on:
|
||||
types: [submitted]
|
||||
pull_request:
|
||||
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:
|
||||
@@ -516,57 +520,153 @@ jobs:
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# 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)
|
||||
@@ -630,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
|
||||
|
||||
@@ -251,7 +251,11 @@ jobs:
|
||||
});
|
||||
|
||||
- name: Stash merge gate helpers
|
||||
run: cp .github/scripts/merge-gate.js /tmp/merge-gate.js
|
||||
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:
|
||||
@@ -259,6 +263,9 @@ jobs:
|
||||
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
|
||||
@@ -315,7 +322,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
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 }};
|
||||
@@ -354,7 +361,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
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 }}
|
||||
);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.127"
|
||||
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.124"
|
||||
__version__ = "0.3.129"
|
||||
@@ -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
|
||||
@@ -912,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:
|
||||
@@ -933,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:
|
||||
@@ -962,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"])
|
||||
@@ -974,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", {}))
|
||||
@@ -986,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"])
|
||||
@@ -995,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"])
|
||||
@@ -1030,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"]
|
||||
|
||||
@@ -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;
|
||||
@@ -480,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; }
|
||||
@@ -583,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;
|
||||
@@ -1064,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();
|
||||
@@ -1073,6 +1100,11 @@ 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
|
||||
@@ -1495,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; }
|
||||
@@ -1513,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;
|
||||
@@ -1063,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);
|
||||
@@ -1359,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';
|
||||
@@ -1907,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' },
|
||||
@@ -1925,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 (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -13,11 +13,12 @@ from __future__ import annotations
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
DASHBOARD_JS = FRONTEND / "dashboard.js"
|
||||
GENERATOR = ROOT / "scripts" / "patch_dashboard_modular.py"
|
||||
|
||||
|
||||
class TestDashboardShellOrder:
|
||||
@@ -48,3 +49,17 @@ class TestDashboardShellOrder:
|
||||
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():
|
||||
|
||||
@@ -25,18 +25,38 @@ class TestDashboardSessionSearch:
|
||||
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 initSessionSearch\(\)\s*\{(.*?)^}",
|
||||
r"function initCommandPalette\(\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "initSessionSearch not found"
|
||||
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()
|
||||
|
||||
@@ -207,7 +207,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiui"
|
||||
version = "0.3.127"
|
||||
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