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
68 changes: 68 additions & 0 deletions github_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

import httpx
import json
import io
import re
import zipfile
from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta, timezone
import asyncio
Expand Down Expand Up @@ -268,6 +271,71 @@ async def get_workflow_run_status(
print(f"Failed to get workflow run status: {e}")
return {"status": "error", "conclusion": "failure", "error": str(e)}

async def get_workflow_run_logs_zip(
self, run_id: str, repo: str = GITHUB_REPO
) -> Optional[bytes]:
"""
Download the workflow run logs as a zip archive.
"""
url = f"{self.base_url}/repos/{repo}/actions/runs/{run_id}/logs"

try:
async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.get(url, headers=self.headers)
response.raise_for_status()
return response.content
except Exception as e:
print(f"Failed to download workflow run logs: {e}")
return None

def parse_pr_status_records(self, log_zip_bytes: bytes) -> List[Dict[str, Any]]:
"""
Extract PR_STATUS JSON records from a workflow log zip archive.
"""
if not log_zip_bytes:
return []

records = []
pattern = re.compile(r"PR_STATUS\s+(\{.*?\})")

try:
with zipfile.ZipFile(io.BytesIO(log_zip_bytes)) as zip_file:
for info in zip_file.infolist():
if info.is_dir():
continue
with zip_file.open(info) as log_file:
for raw_line in log_file:
line = raw_line.decode("utf-8", errors="ignore")
for match in pattern.finditer(line):
try:
records.append(json.loads(match.group(1)))
except json.JSONDecodeError:
continue
except Exception as e:
print(f"Failed to parse workflow run logs: {e}")

return records

async def get_applied_prs_from_logs(
self, run_id: str, repo: str = GITHUB_REPO
) -> List[Dict[str, Any]]:
"""
Return PR_STATUS records with state=applied from workflow run logs.
"""
log_zip_bytes = await self.get_workflow_run_logs_zip(run_id, repo)
records = self.parse_pr_status_records(log_zip_bytes)

applied = {}
for record in records:
if record.get("state") != "applied":
continue
pr = record.get("pr")
if pr is None:
continue
applied[int(pr)] = record

return [applied[pr] for pr in sorted(applied.keys())]

async def wait_for_workflow_completion(
self,
run_id: str,
Expand Down
57 changes: 57 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,63 @@ async def get_staging_nodes(
}


@app.get("/api/staging/{run_id}/tested-prs")
async def get_staging_tested_prs(
run_id: int,
current_user: User = Depends(get_current_user_optional),
db: Session = Depends(get_db),
):
"""Extract applied PRs from GitHub Actions logs for a staging run"""
if not current_user:
raise HTTPException(status_code=401, detail="Authentication required")

staging_run = db.query(StagingRun).filter(StagingRun.id == run_id).first()
if not staging_run:
raise HTTPException(status_code=404, detail="Staging run not found")

github_actions_id = None
for step in staging_run.steps:
if step.step_type == StagingStepType.GITHUB_WORKFLOW and step.github_actions_id:
github_actions_id = step.github_actions_id
break

if not github_actions_id:
return {
"success": False,
"error": "GitHub Actions run ID not available for this staging run",
"applied_prs": [],
}

github_token = get_setting(GITHUB_TOKEN)
if not github_token:
return {
"success": False,
"error": "GitHub token not configured",
"applied_prs": [],
}

try:
from github_integration import GitHubWorkflowManager

github_manager = GitHubWorkflowManager(github_token)
applied_prs = await github_manager.get_applied_prs_from_logs(
str(github_actions_id)
)

return {
"success": True,
"github_actions_id": str(github_actions_id),
"applied_prs": applied_prs,
}
except Exception as e:
print(f"Error extracting tested PRs: {e}")
return {
"success": False,
"error": f"Failed to extract tested PRs: {str(e)}",
"applied_prs": [],
}


@app.post("/staging/{run_id}/cancel")
async def cancel_staging_run(
run_id: int,
Expand Down
155 changes: 154 additions & 1 deletion templates/staging_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,47 @@ <h6 class="mb-0">{{ step.step_type.value.replace('_', ' ').title() }}</h6>
{% endfor %}
</div>

<!-- Tested PRs Section -->
<div class="mt-4">
<div class="card" id="testedPrsCard">
<div class="card-header" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#testedPrsContent" aria-expanded="false">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="fas fa-code-branch me-2"></i>Tested PRs
</h6>
<div>
<button class="btn btn-sm btn-outline-primary me-2" id="refreshTestedPrsBtn" onclick="loadTestedPrs()">
<i class="fas fa-sync-alt me-1"></i>Refresh
</button>
<i class="fas fa-chevron-down"></i>
</div>
</div>
</div>
<div class="collapse" id="testedPrsContent">
<div class="card-body">
<div id="testedPrsLoading" class="text-center py-3" style="display: none;">
<i class="fas fa-spinner fa-spin fa-2x text-primary mb-2"></i>
<p class="text-muted">Loading tested PRs...</p>
</div>

<div id="testedPrsError" class="alert alert-danger" style="display: none;">
<i class="fas fa-exclamation-triangle me-2"></i>
<span id="testedPrsErrorMessage"></span>
</div>

<div id="testedPrsEmpty" class="alert alert-info" style="display: none;">
<i class="fas fa-info-circle me-2"></i>
No applied PRs found in the workflow logs.
</div>

<div id="testedPrsData" style="display: none;">
<div class="list-group" id="testedPrsList"></div>
</div>
</div>
</div>
</div>
</div>

<!-- Node Results Section -->
<div class="mt-4">
<div class="card" id="nodeResultsCard">
Expand Down Expand Up @@ -438,6 +479,118 @@ <h6 class="mb-0"><i class="fas fa-stop-circle me-2"></i>Cancel Run</h6>
{% endif %}

<script>
// Tested PRs functionality
function hideAllTestedPrsStates() {
document.getElementById('testedPrsLoading').style.display = 'none';
document.getElementById('testedPrsError').style.display = 'none';
document.getElementById('testedPrsEmpty').style.display = 'none';
document.getElementById('testedPrsData').style.display = 'none';
}

function showTestedPrsError(message) {
hideAllTestedPrsStates();
document.getElementById('testedPrsErrorMessage').textContent = message;
document.getElementById('testedPrsError').style.display = 'block';
}

function renderTestedPrsList(prs) {
const list = document.getElementById('testedPrsList');
list.innerHTML = '';

prs.forEach(pr => {
const prNumber = pr.pr || 'unknown';
const prUrl = pr.pr_url || '#';
const repo = pr.apply_repo || pr.tree_repo || 'unknown repo';
const branch = pr.apply_branch || 'unknown branch';
const treeUrl = pr.apply_tree_url || pr.tree_url;

const item = document.createElement('a');
item.className = 'list-group-item list-group-item-action';
item.href = prUrl;
item.target = '_blank';
item.innerHTML = `
<div class="d-flex w-100 justify-content-between align-items-center">
<div>
<strong>PR ${prNumber}</strong>
<div class="small text-muted">${repo} @ ${branch}</div>
</div>
<span class="badge bg-success">Applied</span>
</div>
`;

if (treeUrl) {
const treeLink = document.createElement('div');
treeLink.className = 'small text-muted mt-1';
treeLink.innerHTML = `Tree: <a href="${treeUrl}" target="_blank" class="text-decoration-none">${treeUrl}</a>`;
item.appendChild(treeLink);
}

list.appendChild(item);
});
}

async function loadTestedPrs() {
const runId = {{ staging_run.id }};
const refreshBtn = document.getElementById('refreshTestedPrsBtn');

hideAllTestedPrsStates();
document.getElementById('testedPrsLoading').style.display = 'block';

if (refreshBtn) {
refreshBtn.disabled = true;
refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Loading...';
}

try {
const response = await fetch(`/api/staging/${runId}/tested-prs`);
if (!response.ok) {
if (response.status === 401) {
showTestedPrsError('Authentication required. Please log in again.');
return;
}
throw new Error(`HTTP error! status: ${response.status}`);
}

const data = await response.json();
if (!data.success) {
showTestedPrsError(data.error || 'Failed to load tested PRs');
return;
}

if (!data.applied_prs || data.applied_prs.length === 0) {
hideAllTestedPrsStates();
document.getElementById('testedPrsEmpty').style.display = 'block';
return;
}

renderTestedPrsList(data.applied_prs);
hideAllTestedPrsStates();
document.getElementById('testedPrsData').style.display = 'block';
} catch (error) {
console.error('Error loading tested PRs:', error);
showTestedPrsError(`Failed to load tested PRs: ${error.message}`);
} finally {
if (refreshBtn) {
refreshBtn.disabled = false;
refreshBtn.innerHTML = '<i class="fas fa-sync-alt me-1"></i>Refresh';
}
}
}

document.getElementById('testedPrsContent').addEventListener('show.bs.collapse', function() {
const dataElement = document.getElementById('testedPrsData');
const loadingElement = document.getElementById('testedPrsLoading');
const emptyElement = document.getElementById('testedPrsEmpty');
const errorElement = document.getElementById('testedPrsError');

if (dataElement.style.display === 'none' &&
loadingElement.style.display === 'none' &&
emptyElement.style.display === 'none' &&
errorElement.style.display === 'none') {
loadTestedPrs();
}
});

// Node Results functionality
let countdownInterval = null;

Expand Down Expand Up @@ -649,4 +802,4 @@ <h6 class="mb-0"><i class="fas fa-stop-circle me-2"></i>Cancel Run</h6>
});
</script>

{% endblock %}
{% endblock %}