Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions .github/workflows/constellos-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Constellos Review Workflow
# AI-powered code reviews on PRs using Claude
#
# This workflow is provisioned by the Constellos app.
# Configure agents via .constellos/config.json in your repo.

name: Constellos Review
on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: read
issues: read
actions: read

jobs:
# Read config and determine which agents to run
config:
runs-on: ubuntu-latest
outputs:
agents: ${{ steps.read.outputs.agents }}
steps:
- uses: actions/checkout@v4
- id: read
run: |
if [ -f ".constellos/config.json" ]; then
AGENTS=$(jq -c '[.agents | to_entries[] | select(.value.enabled) | .key]' .constellos/config.json)
else
# Default: run both agents
AGENTS='["requirements","code-quality"]'
fi
echo "agents=$AGENTS" >> $GITHUB_OUTPUT

# Run each enabled agent in parallel
review:
needs: config
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
agent: ${{ fromJson(needs.config.outputs.agents) }}
steps:
- uses: actions/checkout@v4

# Requirements reviewer
- name: Run requirements review
id: requirements
if: matrix.agent == 'requirements'
uses: constellos/github-agents/.github/actions/requirements-reviewer@main
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
pr_number: ${{ github.event.number }}
branch: ${{ github.head_ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}

- name: Post requirements comment
if: matrix.agent == 'requirements'
run: |
curl -X POST "https://github.constellos.ai/results" \
-H "Content-Type: application/json" \
-d '{
"owner": "'${{ github.repository_owner }}'",
"repo": "'${{ github.event.repository.name }}'",
"pr_number": ${{ github.event.number }},
"sha": "'${{ github.event.pull_request.head.sha }}'",
"run_id": ${{ github.run_id }},
"review_name": "Requirements",
"results": {
"passed": ${{ steps.requirements.outputs.passed }},
"result_json": ${{ steps.requirements.outputs.result }},
"checks_passed": ${{ steps.requirements.outputs.checks_passed }},
"checks_failed": ${{ steps.requirements.outputs.checks_failed }},
"checks_skipped": ${{ steps.requirements.outputs.checks_skipped }},
"prompt_file": ".constellos/agents/requirements.md"
}
}'

# Code quality reviewer
- name: Run code-quality review
id: code-quality
if: matrix.agent == 'code-quality'
uses: constellos/github-agents/.github/actions/code-quality-reviewer@main
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
pr_number: ${{ github.event.number }}
github_token: ${{ secrets.GITHUB_TOKEN }}

- name: Post code-quality comment
if: matrix.agent == 'code-quality'
run: |
curl -X POST "https://github.constellos.ai/results" \
-H "Content-Type: application/json" \
-d '{
"owner": "'${{ github.repository_owner }}'",
"repo": "'${{ github.event.repository.name }}'",
"pr_number": ${{ github.event.number }},
"sha": "'${{ github.event.pull_request.head.sha }}'",
"run_id": ${{ github.run_id }},
"review_name": "Code Quality",
"results": {
"passed": ${{ steps.code-quality.outputs.passed }},
"result_json": ${{ steps.code-quality.outputs.result }},
"checks_passed": ${{ steps.code-quality.outputs.checks_passed }},
"checks_failed": ${{ steps.code-quality.outputs.checks_failed }},
"checks_skipped": ${{ steps.code-quality.outputs.checks_skipped }},
"prompt_file": ".constellos/agents/code-quality.md"
}
}'

# Context reviewer
- name: Run context review
id: context
if: matrix.agent == 'context'
uses: constellos/github-agents/.github/actions/context-reviewer@main
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
pr_number: ${{ github.event.number }}
github_token: ${{ secrets.GITHUB_TOKEN }}

- name: Post context comment
if: matrix.agent == 'context'
run: |
curl -X POST "https://github.constellos.ai/results" \
-H "Content-Type: application/json" \
-d '{
"owner": "'${{ github.repository_owner }}'",
"repo": "'${{ github.event.repository.name }}'",
"pr_number": ${{ github.event.number }},
"sha": "'${{ github.event.pull_request.head.sha }}'",
"run_id": ${{ github.run_id }},
"review_name": "Context",
"results": {
"passed": ${{ steps.context.outputs.passed }},
"result_json": ${{ steps.context.outputs.result }},
"checks_passed": ${{ steps.context.outputs.checks_passed }},
"checks_failed": ${{ steps.context.outputs.checks_failed }},
"checks_skipped": ${{ steps.context.outputs.checks_skipped }},
"prompt_file": ".constellos/agents/context.md"
}
}'
106 changes: 85 additions & 21 deletions plugins/github-orchestration/hooks/commit-session-await-ci-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,11 @@ import {
awaitCIWithFailFast,
getLatestCIRun as getCIRunDetails,
extractAllPreviews,
extractLinkedIssuesFromPR,
extractLinkedIssuesWithInfo,
type GroupedPreviewUrls,
type LinkedIssueInfo,
} from '../shared/hooks/utils/ci-status.js';
import { getSessionIssues, type IssueReference } from '../shared/hooks/utils/session-issues.js';
import { addPRToState } from '../shared/hooks/utils/github-state.js';
import { exec } from 'child_process';
import { promisify } from 'util';
Expand Down Expand Up @@ -725,6 +727,33 @@ Session-Timestamp: ${timestamp}${branch ? `\nBranch: ${branch}` : ''}
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>`;
}

/**
* Format a linked issue for display
* @param issue - Issue info with number, url, and repo
* @param prRepo - PR's repository for comparison
* @returns Formatted issue string
*/
function formatLinkedIssue(issue: LinkedIssueInfo, prRepo: string): string {
// If same repo as PR, show just #number
// If different repo, show owner/repo#number
const prefix = issue.repo === prRepo ? `#${issue.number}` : `${issue.repo}#${issue.number}`;
const titlePart = issue.title ? ` - ${issue.title}` : '';
return `${prefix}${titlePart} → ${issue.url}`;
}

/**
* Format a session issue for display (other issues section)
* @param issue - Issue reference from session tracking
* @param currentRepo - Current repository for comparison
* @returns Formatted issue string
*/
function formatSessionIssue(issue: IssueReference, currentRepo: string): string {
// If same repo, show just #number; if different, show owner/repo#number
const prefix = issue.repo === currentRepo ? `#${issue.number}` : `${issue.repo}#${issue.number}`;
const titlePart = issue.title ? ` - ${issue.title}` : '';
return `${prefix}${titlePart} → ${issue.url}`;
}

/**
* Format PR status message when commit was made and PR exists
* @param commitSha - Commit SHA
Expand All @@ -737,7 +766,9 @@ Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>`;
* @param ciRun.conclusion - CI run conclusion
* @param ciRun.name - CI run name
* @param groupedPreviews - Preview URLs grouped by provider
* @param linkedIssues - Issue numbers linked from PR body
* @param linkedIssues - Issues linked from PR body (closes when merged)
* @param otherIssues - Other issues created during session but not linked to PR
* @param prRepo - PR's repository name (owner/repo)
* @returns Formatted message
* @example
*/
Expand All @@ -746,7 +777,9 @@ function formatPRStatusWithCommit(
prCheck: { prNumber: number; prUrl: string },
ciRun: { url?: string; status?: string; conclusion?: string; name?: string },
groupedPreviews: GroupedPreviewUrls,
linkedIssues: number[]
linkedIssues: LinkedIssueInfo[],
otherIssues: IssueReference[],
prRepo: string
): string {
const ciPassed = ciRun.conclusion === 'success';
const ciFailed = ciRun.conclusion === 'failure';
Expand All @@ -762,11 +795,19 @@ function formatPRStatusWithCommit(
message += `🔄 CI: ${ciRun.url} ${statusIcon} ${ciRun.conclusion || ciRun.status || 'pending'}\n`;
}

// Linked issues (if any)
// Linked issues (closes when merged) - nested under PR
if (linkedIssues.length > 0) {
message += '\n📌 Linked Issue(s):\n';
for (const issueNum of linkedIssues) {
message += ` • #${issueNum}\n`;
message += '\n 📌 Linked Issues (closes when merged):\n';
for (const issue of linkedIssues) {
message += ` • ${formatLinkedIssue(issue, prRepo)}\n`;
}
}

// Other issues created during session (not linked to PR)
if (otherIssues.length > 0) {
message += '\n📝 Other Issues Created:\n';
for (const issue of otherIssues) {
message += ` • ${formatSessionIssue(issue, prRepo)}\n`;
}
}

Expand Down Expand Up @@ -809,15 +850,19 @@ function formatPRStatusWithCommit(
* @param ciRun.conclusion - CI run conclusion
* @param ciRun.name - CI run name
* @param groupedPreviews - Preview URLs grouped by provider
* @param linkedIssues - Issue numbers linked from PR body
* @param linkedIssues - Issues linked from PR body (closes when merged)
* @param otherIssues - Other issues created during session but not linked to PR
* @param prRepo - PR's repository name (owner/repo)
* @returns Formatted message
* @example
*/
function formatPRStatusInfo(
prCheck: { prNumber: number; prUrl: string },
ciRun: { url?: string; status?: string; conclusion?: string; name?: string },
groupedPreviews: GroupedPreviewUrls,
linkedIssues: number[]
linkedIssues: LinkedIssueInfo[],
otherIssues: IssueReference[],
prRepo: string
): string {
// Header (simplified - this function only called when CI passed or pending)
let message = '✅ PR Ready for Review\n\n';
Expand All @@ -830,11 +875,19 @@ function formatPRStatusInfo(
message += `🔄 [View CI Run](${ciRun.url}) ✅ success\n`;
}

// Linked issues (if any)
// Linked issues (closes when merged) - nested under PR
if (linkedIssues.length > 0) {
message += '\n📌 Linked Issue(s):\n';
for (const issueNum of linkedIssues) {
message += ` • #${issueNum}\n`;
message += '\n 📌 Linked Issues (closes when merged):\n';
for (const issue of linkedIssues) {
message += ` • ${formatLinkedIssue(issue, prRepo)}\n`;
}
}

// Other issues created during session (not linked to PR)
if (otherIssues.length > 0) {
message += '\n📝 Other Issues Created:\n';
for (const issue of otherIssues) {
message += ` • ${formatSessionIssue(issue, prRepo)}\n`;
}
}

Expand Down Expand Up @@ -1215,22 +1268,33 @@ ${checksTable}
checks: ciResult.checks
});

// Fetch PR details, previews, and linked issues in parallel
const [ciRun, groupedPreviews, linkedIssues] = await Promise.all([
// Fetch PR details, previews, linked issues, and session issues in parallel
const [ciRun, groupedPreviews, linkedIssues, sessionIssues] = await Promise.all([
getCIRunDetails(prCheck.prNumber, repoRoot).then(r => r ?? {}),
extractAllPreviews(prCheck.prNumber, repoRoot),
extractLinkedIssuesFromPR(prCheck.prNumber, repoRoot),
extractLinkedIssuesWithInfo(prCheck.prNumber, repoRoot),
getSessionIssues(input.session_id, repoRoot),
]);

// Track PR in github.json
// Extract PR repo from URL for display formatting
const prRepoMatch = prCheck.prUrl.match(/github\.com\/([^/]+\/[^/]+)\/pull/);
const prRepo = prRepoMatch ? prRepoMatch[1] : '';

// Filter session issues to find "other issues" not linked to the PR
const linkedIssueKeys = new Set(linkedIssues.map(i => `${i.repo}#${i.number}`));
const otherIssues = sessionIssues.filter(
issue => !linkedIssueKeys.has(`${issue.repo}#${issue.number}`)
);

// Track PR in github.json (store just issue numbers for backwards compatibility)
await addPRToState(
input.session_id,
{
number: prCheck.prNumber,
url: prCheck.prUrl,
title: '', // We don't have title here, could fetch if needed
createdAt: new Date().toISOString(),
linkedIssues,
linkedIssues: linkedIssues.map(i => i.number),
},
repoRoot
);
Expand All @@ -1252,19 +1316,19 @@ ${checksTable}
// Show PR status after commit (non-blocking)
return {
decision: 'approve',
systemMessage: formatPRStatusWithCommit(commitSha, { prNumber: prCheck.prNumber, prUrl: prCheck.prUrl }, ciRun, groupedPreviews, linkedIssues),
systemMessage: formatPRStatusWithCommit(commitSha, { prNumber: prCheck.prNumber, prUrl: prCheck.prUrl }, ciRun, groupedPreviews, linkedIssues, otherIssues, prRepo),
};
} else if (commitsAheadOfMain > 0) {
// Show PR status to user (non-blocking)
return {
decision: 'approve',
systemMessage: formatPRStatusInfo({ prNumber: prCheck.prNumber, prUrl: prCheck.prUrl }, ciRun, groupedPreviews, linkedIssues),
systemMessage: formatPRStatusInfo({ prNumber: prCheck.prNumber, prUrl: prCheck.prUrl }, ciRun, groupedPreviews, linkedIssues, otherIssues, prRepo),
};
} else {
// Always show PR status when PR exists, even if no new commits this session (non-blocking)
return {
decision: 'approve',
systemMessage: formatPRStatusInfo({ prNumber: prCheck.prNumber, prUrl: prCheck.prUrl }, ciRun, groupedPreviews, linkedIssues),
systemMessage: formatPRStatusInfo({ prNumber: prCheck.prNumber, prUrl: prCheck.prUrl }, ciRun, groupedPreviews, linkedIssues, otherIssues, prRepo),
};
}
}
Expand Down
Loading
Loading