import { Router, Request, Response } from 'express'; import axios from 'axios'; const router = Router(); // Woodpecker API config - uses env vars or falls back const WOODPECKER_SERVER = process.env.WOODPECKER_SERVER || 'https://ci.cannabrands.app'; const WOODPECKER_TOKEN = process.env.WOODPECKER_TOKEN; const GITEA_SERVER = process.env.GITEA_SERVER || 'https://code.cannabrands.app'; const GITEA_TOKEN = process.env.GITEA_TOKEN; const REPO_OWNER = 'Creationshop'; const REPO_NAME = 'dispensary-scraper'; interface PipelineStep { name: string; state: 'pending' | 'running' | 'success' | 'failure' | 'skipped'; started?: number; stopped?: number; } interface PipelineInfo { number: number; status: string; event: string; branch: string; message: string; commit: string; author: string; created: number; started?: number; finished?: number; steps?: PipelineStep[]; } interface DeployStatusResponse { running: { sha: string; sha_full: string; build_time: string; image_tag: string; }; latest: { sha: string; sha_full: string; message: string; author: string; timestamp: string; } | null; is_latest: boolean; commits_behind: number; pipeline: PipelineInfo | null; error?: string; } /** * Fetch latest commit from Gitea */ async function getLatestCommit(): Promise<{ sha: string; message: string; author: string; timestamp: string; } | null> { if (!GITEA_TOKEN) { console.warn('[DeployStatus] GITEA_TOKEN not set, skipping latest commit fetch'); return null; } try { const response = await axios.get( `${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/commits?limit=1`, { headers: { Authorization: `token ${GITEA_TOKEN}` }, timeout: 5000, } ); if (response.data && response.data.length > 0) { const commit = response.data[0]; return { sha: commit.sha, message: commit.commit?.message?.split('\n')[0] || '', author: commit.commit?.author?.name || commit.author?.login || 'unknown', timestamp: commit.commit?.author?.date || commit.created, }; } } catch (error: any) { console.error('[DeployStatus] Failed to fetch latest commit:', error.message); } return null; } /** * Fetch latest pipeline from Woodpecker */ async function getLatestPipeline(): Promise { if (!WOODPECKER_TOKEN) { console.warn('[DeployStatus] WOODPECKER_TOKEN not set, skipping pipeline fetch'); return null; } try { // Get latest pipeline const listResponse = await axios.get( `${WOODPECKER_SERVER}/api/repos/${REPO_OWNER}/${REPO_NAME}/pipelines?page=1&per_page=1`, { headers: { Authorization: `Bearer ${WOODPECKER_TOKEN}` }, timeout: 5000, } ); if (!listResponse.data || listResponse.data.length === 0) { return null; } const pipeline = listResponse.data[0]; // Get pipeline steps let steps: PipelineStep[] = []; try { const stepsResponse = await axios.get( `${WOODPECKER_SERVER}/api/repos/${REPO_OWNER}/${REPO_NAME}/pipelines/${pipeline.number}`, { headers: { Authorization: `Bearer ${WOODPECKER_TOKEN}` }, timeout: 5000, } ); if (stepsResponse.data?.workflows) { for (const workflow of stepsResponse.data.workflows) { if (workflow.children) { for (const step of workflow.children) { steps.push({ name: step.name, state: step.state, started: step.start_time, stopped: step.end_time, }); } } } } } catch (stepError) { // Steps fetch failed, continue without them } return { number: pipeline.number, status: pipeline.status, event: pipeline.event, branch: pipeline.branch, message: pipeline.message?.split('\n')[0] || '', commit: pipeline.commit?.slice(0, 8) || '', author: pipeline.author || 'unknown', created: pipeline.created_at, started: pipeline.started_at, finished: pipeline.finished_at, steps, }; } catch (error: any) { console.error('[DeployStatus] Failed to fetch pipeline:', error.message); } return null; } /** * Count commits between two SHAs */ async function countCommitsBetween(fromSha: string, toSha: string): Promise { if (!GITEA_TOKEN || !fromSha || !toSha) return 0; if (fromSha === toSha) return 0; try { const response = await axios.get( `${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/commits?sha=${toSha}&limit=50`, { headers: { Authorization: `token ${GITEA_TOKEN}` }, timeout: 5000, } ); if (response.data) { const commits = response.data; for (let i = 0; i < commits.length; i++) { if (commits[i].sha.startsWith(fromSha)) { return i; } } // If not found in first 50, assume more than 50 behind return commits.length; } } catch (error: any) { console.error('[DeployStatus] Failed to count commits:', error.message); } return 0; } /** * GET /api/admin/deploy-status * Returns deployment status with version comparison and CI info */ router.get('/', async (req: Request, res: Response) => { try { // Get running version from env vars (set during Docker build) const runningSha = process.env.APP_GIT_SHA || 'unknown'; const running = { sha: runningSha.slice(0, 8), sha_full: runningSha, build_time: process.env.APP_BUILD_TIME || new Date().toISOString(), image_tag: process.env.CONTAINER_IMAGE_TAG?.slice(0, 8) || 'local', }; // Fetch latest commit and pipeline in parallel const [latestCommit, pipeline] = await Promise.all([ getLatestCommit(), getLatestPipeline(), ]); // Build latest info const latest = latestCommit ? { sha: latestCommit.sha.slice(0, 8), sha_full: latestCommit.sha, message: latestCommit.message, author: latestCommit.author, timestamp: latestCommit.timestamp, } : null; // Determine if running latest const isLatest = latest ? runningSha.startsWith(latest.sha_full.slice(0, 8)) || latest.sha_full.startsWith(runningSha.slice(0, 8)) : true; // Count commits behind const commitsBehind = isLatest ? 0 : await countCommitsBetween(runningSha, latest?.sha_full || ''); const response: DeployStatusResponse = { running, latest, is_latest: isLatest, commits_behind: commitsBehind, pipeline, }; res.json(response); } catch (error: any) { console.error('[DeployStatus] Error:', error); res.status(500).json({ error: error.message, running: { sha: process.env.APP_GIT_SHA?.slice(0, 8) || 'unknown', sha_full: process.env.APP_GIT_SHA || 'unknown', build_time: process.env.APP_BUILD_TIME || 'unknown', image_tag: process.env.CONTAINER_IMAGE_TAG?.slice(0, 8) || 'local', }, latest: null, is_latest: true, commits_behind: 0, pipeline: null, }); } }); export default router;