|
|
|
|
@@ -4,24 +4,127 @@ import {
|
|
|
|
|
CallToolRequestSchema,
|
|
|
|
|
ListToolsRequestSchema,
|
|
|
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
|
|
|
import { execFileSync, execSync } from "child_process";
|
|
|
|
|
import { execFileSync } from "child_process";
|
|
|
|
|
import { readFileSync, writeFileSync } from "fs";
|
|
|
|
|
import path from "path";
|
|
|
|
|
import { fileURLToPath } from "url";
|
|
|
|
|
|
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
|
const PROJECT_ROOT = path.resolve(__dirname, "../..");
|
|
|
|
|
const REPO = "bipprojectbali/desa-plus";
|
|
|
|
|
const STACK_ENV = "stg";
|
|
|
|
|
const BASE_URL = process.env.BASE_URL ?? "";
|
|
|
|
|
const VERSION_PATH = process.env.VERSION_PATH ?? "/api/version-app";
|
|
|
|
|
const DEFAULT_STACK_NAME = process.env.STACK_NAME ?? "";
|
|
|
|
|
const GH_TOKEN = process.env.GH_TOKEN ?? "";
|
|
|
|
|
|
|
|
|
|
const GH = (args: string[]) =>
|
|
|
|
|
execFileSync("gh", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim();
|
|
|
|
|
const GH_URL_RAW = process.env.GH_URL ?? "";
|
|
|
|
|
// support both "owner/repo" and "https://github.com/owner/repo" formats
|
|
|
|
|
const REPO = GH_URL_RAW.startsWith("http")
|
|
|
|
|
? GH_URL_RAW.replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "")
|
|
|
|
|
: GH_URL_RAW;
|
|
|
|
|
|
|
|
|
|
const GIT = (args: string[]) =>
|
|
|
|
|
execFileSync("git", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim();
|
|
|
|
|
|
|
|
|
|
// --- GitHub API client (no gh CLI) ---
|
|
|
|
|
|
|
|
|
|
const GH_API = "https://api.github.com";
|
|
|
|
|
|
|
|
|
|
type WorkflowRun = {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
status: string;
|
|
|
|
|
conclusion: string | null;
|
|
|
|
|
html_url: string;
|
|
|
|
|
created_at: string;
|
|
|
|
|
run_started_at: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
async function ghFetch<T = unknown>(
|
|
|
|
|
pathname: string,
|
|
|
|
|
init: RequestInit = {}
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
if (!GH_TOKEN) throw new Error("GH_TOKEN tidak di-set.");
|
|
|
|
|
if (!REPO) throw new Error("GH_URL tidak di-set.");
|
|
|
|
|
|
|
|
|
|
const res = await fetch(`${GH_API}${pathname}`, {
|
|
|
|
|
...init,
|
|
|
|
|
headers: {
|
|
|
|
|
Accept: "application/vnd.github+json",
|
|
|
|
|
Authorization: `Bearer ${GH_TOKEN}`,
|
|
|
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
|
|
|
...(init.body ? { "Content-Type": "application/json" } : {}),
|
|
|
|
|
...(init.headers ?? {}),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const body = await res.text();
|
|
|
|
|
throw new Error(`GitHub API ${res.status} ${res.statusText}: ${body}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 204 No Content (e.g. workflow dispatch)
|
|
|
|
|
if (res.status === 204) return undefined as T;
|
|
|
|
|
return (await res.json()) as T;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function triggerWorkflow(
|
|
|
|
|
workflow: string,
|
|
|
|
|
ref: string,
|
|
|
|
|
inputs: Record<string, string>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
await ghFetch(`/repos/${REPO}/actions/workflows/${workflow}/dispatches`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({ ref, inputs }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getLatestRun(workflow: string): Promise<WorkflowRun> {
|
|
|
|
|
const data = await ghFetch<{ workflow_runs: WorkflowRun[] }>(
|
|
|
|
|
`/repos/${REPO}/actions/workflows/${workflow}/runs?per_page=1`
|
|
|
|
|
);
|
|
|
|
|
if (!data.workflow_runs?.length) {
|
|
|
|
|
throw new Error(`Tidak ada run untuk workflow ${workflow}.`);
|
|
|
|
|
}
|
|
|
|
|
return data.workflow_runs[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function listRuns(
|
|
|
|
|
workflow: string | "all",
|
|
|
|
|
limit: number
|
|
|
|
|
): Promise<WorkflowRun[]> {
|
|
|
|
|
const url =
|
|
|
|
|
workflow === "all"
|
|
|
|
|
? `/repos/${REPO}/actions/runs?per_page=${limit}`
|
|
|
|
|
: `/repos/${REPO}/actions/workflows/${workflow}/runs?per_page=${limit}`;
|
|
|
|
|
const data = await ghFetch<{ workflow_runs: WorkflowRun[] }>(url);
|
|
|
|
|
return data.workflow_runs ?? [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForRun(
|
|
|
|
|
runId: number,
|
|
|
|
|
timeoutMs = 30 * 60 * 1000
|
|
|
|
|
): Promise<WorkflowRun> {
|
|
|
|
|
const interval = 10_000;
|
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
|
|
const run = await ghFetch<WorkflowRun>(
|
|
|
|
|
`/repos/${REPO}/actions/runs/${runId}`
|
|
|
|
|
);
|
|
|
|
|
if (run.status === "completed") {
|
|
|
|
|
if (run.conclusion !== "success") {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Run ${runId} selesai dengan conclusion: ${run.conclusion}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return run;
|
|
|
|
|
}
|
|
|
|
|
await new Promise((r) => setTimeout(r, interval));
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`Timeout menunggu run ${runId}.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- version helpers ---
|
|
|
|
|
|
|
|
|
|
function bumpVersion(version: string, type: "patch" | "minor" | "major"): string {
|
|
|
|
|
@@ -48,7 +151,7 @@ function applyVersionBump(newVersion: string): void {
|
|
|
|
|
async function waitForDeployedVersion(expected: string, timeoutMs = 5 * 60 * 1000): Promise<string> {
|
|
|
|
|
if (!BASE_URL) return "BASE_URL tidak di-set, skip cek versi stg.";
|
|
|
|
|
|
|
|
|
|
const url = `${BASE_URL}/api/version-app`;
|
|
|
|
|
const url = `${BASE_URL}${VERSION_PATH}`;
|
|
|
|
|
const interval = 15_000;
|
|
|
|
|
const maxAttempts = Math.ceil(timeoutMs / interval);
|
|
|
|
|
let last = "";
|
|
|
|
|
@@ -199,54 +302,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
|
|
const currentBranch = GIT(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
|
|
|
GIT(["push", "build", `${currentBranch}:stg`, "--force"]);
|
|
|
|
|
|
|
|
|
|
// 4. Trigger publish
|
|
|
|
|
GH([
|
|
|
|
|
"workflow", "run", "publish.yml",
|
|
|
|
|
"--repo", REPO,
|
|
|
|
|
"--field", `stack_env=${STACK_ENV}`,
|
|
|
|
|
"--field", `tag=${newVersion}`,
|
|
|
|
|
]);
|
|
|
|
|
// 4. Trigger publish workflow
|
|
|
|
|
await triggerWorkflow("publish.yml", STACK_ENV, {
|
|
|
|
|
stack_env: STACK_ENV,
|
|
|
|
|
tag: newVersion,
|
|
|
|
|
});
|
|
|
|
|
await new Promise((r) => setTimeout(r, 4000));
|
|
|
|
|
|
|
|
|
|
const publishRunId = GH([
|
|
|
|
|
"run", "list", "--repo", REPO,
|
|
|
|
|
"--workflow", "publish.yml",
|
|
|
|
|
"--limit", "1",
|
|
|
|
|
"--json", "databaseId",
|
|
|
|
|
"--jq", ".[0].databaseId",
|
|
|
|
|
]);
|
|
|
|
|
const publishUrl = GH([
|
|
|
|
|
"run", "list", "--repo", REPO,
|
|
|
|
|
"--workflow", "publish.yml",
|
|
|
|
|
"--limit", "1",
|
|
|
|
|
"--json", "url",
|
|
|
|
|
"--jq", ".[0].url",
|
|
|
|
|
]);
|
|
|
|
|
const publishRun = await getLatestRun("publish.yml");
|
|
|
|
|
|
|
|
|
|
// 5. Wait for publish to finish
|
|
|
|
|
execSync(`gh run watch ${publishRunId} --repo ${REPO} --exit-status`, {
|
|
|
|
|
encoding: "utf-8",
|
|
|
|
|
cwd: PROJECT_ROOT,
|
|
|
|
|
timeout: 30 * 60 * 1000,
|
|
|
|
|
stdio: "pipe",
|
|
|
|
|
});
|
|
|
|
|
await waitForRun(publishRun.id);
|
|
|
|
|
|
|
|
|
|
// 6. Trigger repull
|
|
|
|
|
GH([
|
|
|
|
|
"workflow", "run", "re-pull.yml",
|
|
|
|
|
"--repo", REPO,
|
|
|
|
|
"--field", `stack_name=${stack_name}`,
|
|
|
|
|
"--field", `stack_env=${STACK_ENV}`,
|
|
|
|
|
]);
|
|
|
|
|
await triggerWorkflow("re-pull.yml", STACK_ENV, {
|
|
|
|
|
stack_name,
|
|
|
|
|
stack_env: STACK_ENV,
|
|
|
|
|
});
|
|
|
|
|
await new Promise((r) => setTimeout(r, 4000));
|
|
|
|
|
|
|
|
|
|
const repullUrl = GH([
|
|
|
|
|
"run", "list", "--repo", REPO,
|
|
|
|
|
"--workflow", "re-pull.yml",
|
|
|
|
|
"--limit", "1",
|
|
|
|
|
"--json", "url",
|
|
|
|
|
"--jq", ".[0].url",
|
|
|
|
|
]);
|
|
|
|
|
const repullRun = await getLatestRun("re-pull.yml");
|
|
|
|
|
|
|
|
|
|
// 7. Wait for repull, then verify version
|
|
|
|
|
await new Promise((r) => setTimeout(r, 30_000));
|
|
|
|
|
@@ -260,8 +335,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
|
|
type: "text",
|
|
|
|
|
text: [
|
|
|
|
|
`Deploy selesai: ${stack_name}-${STACK_ENV} @ ${newVersion} (dari ${oldVersion})`,
|
|
|
|
|
`Publish run : ${publishUrl}`,
|
|
|
|
|
`Repull run : ${repullUrl}`,
|
|
|
|
|
`Publish run : ${publishRun.html_url}`,
|
|
|
|
|
`Repull run : ${repullRun.html_url}`,
|
|
|
|
|
``,
|
|
|
|
|
`Versi lokal : ${localVer}`,
|
|
|
|
|
versionCheck,
|
|
|
|
|
@@ -275,24 +350,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
|
|
if (name === "publish") {
|
|
|
|
|
const tag = readPkgVersion();
|
|
|
|
|
|
|
|
|
|
GH([
|
|
|
|
|
"workflow", "run", "publish.yml",
|
|
|
|
|
"--repo", REPO,
|
|
|
|
|
"--field", `stack_env=${STACK_ENV}`,
|
|
|
|
|
"--field", `tag=${tag}`,
|
|
|
|
|
]);
|
|
|
|
|
await triggerWorkflow("publish.yml", STACK_ENV, {
|
|
|
|
|
stack_env: STACK_ENV,
|
|
|
|
|
tag,
|
|
|
|
|
});
|
|
|
|
|
await new Promise((r) => setTimeout(r, 3000));
|
|
|
|
|
|
|
|
|
|
const runUrl = GH([
|
|
|
|
|
"run", "list", "--repo", REPO,
|
|
|
|
|
"--workflow", "publish.yml",
|
|
|
|
|
"--limit", "1",
|
|
|
|
|
"--json", "url",
|
|
|
|
|
"--jq", ".[0].url",
|
|
|
|
|
]);
|
|
|
|
|
const run = await getLatestRun("publish.yml");
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
content: [{ type: "text", text: `Publish triggered: ${STACK_ENV}-${tag}\nRun: ${runUrl}` }],
|
|
|
|
|
content: [{ type: "text", text: `Publish triggered: ${STACK_ENV}-${tag}\nRun: ${run.html_url}` }],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -302,24 +369,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
|
|
const stack_name = _sn || DEFAULT_STACK_NAME;
|
|
|
|
|
if (!stack_name) throw new Error("stack_name tidak diisi dan env STACK_NAME kosong.");
|
|
|
|
|
|
|
|
|
|
GH([
|
|
|
|
|
"workflow", "run", "re-pull.yml",
|
|
|
|
|
"--repo", REPO,
|
|
|
|
|
"--field", `stack_name=${stack_name}`,
|
|
|
|
|
"--field", `stack_env=${STACK_ENV}`,
|
|
|
|
|
]);
|
|
|
|
|
await triggerWorkflow("re-pull.yml", STACK_ENV, {
|
|
|
|
|
stack_name,
|
|
|
|
|
stack_env: STACK_ENV,
|
|
|
|
|
});
|
|
|
|
|
await new Promise((r) => setTimeout(r, 3000));
|
|
|
|
|
|
|
|
|
|
const runUrl = GH([
|
|
|
|
|
"run", "list", "--repo", REPO,
|
|
|
|
|
"--workflow", "re-pull.yml",
|
|
|
|
|
"--limit", "1",
|
|
|
|
|
"--json", "url",
|
|
|
|
|
"--jq", ".[0].url",
|
|
|
|
|
]);
|
|
|
|
|
const run = await getLatestRun("re-pull.yml");
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
content: [{ type: "text", text: `Repull triggered: ${stack_name}-${STACK_ENV}\nRun: ${runUrl}` }],
|
|
|
|
|
content: [{ type: "text", text: `Repull triggered: ${stack_name}-${STACK_ENV}\nRun: ${run.html_url}` }],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -329,17 +388,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
|
|
workflow?: string;
|
|
|
|
|
limit?: number;
|
|
|
|
|
};
|
|
|
|
|
const workflowArgs = workflow === "all" ? [] : ["--workflow", workflow];
|
|
|
|
|
|
|
|
|
|
const output = GH([
|
|
|
|
|
"run", "list",
|
|
|
|
|
"--repo", REPO,
|
|
|
|
|
...workflowArgs,
|
|
|
|
|
"--limit", String(limit),
|
|
|
|
|
"--json", "workflowName,status,conclusion,startedAt,url,databaseId",
|
|
|
|
|
"--jq",
|
|
|
|
|
'.[] | "[\(.status)/\(.conclusion // "-")] \(.workflowName) — \(.startedAt)\n \(.url)"',
|
|
|
|
|
]);
|
|
|
|
|
const runs = await listRuns(workflow, limit);
|
|
|
|
|
const output = runs
|
|
|
|
|
.map(
|
|
|
|
|
(r) =>
|
|
|
|
|
`[${r.status}/${r.conclusion ?? "-"}] ${r.name} — ${r.run_started_at}\n ${r.html_url}`
|
|
|
|
|
)
|
|
|
|
|
.join("\n");
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
content: [{ type: "text", text: output || "Tidak ada run ditemukan." }],
|
|
|
|
|
@@ -353,7 +409,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
|
|
|
|
|
|
|
if (BASE_URL) {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${BASE_URL}/api/version-app`);
|
|
|
|
|
const res = await fetch(`${BASE_URL}${VERSION_PATH}`);
|
|
|
|
|
const data = (await res.json()) as { version?: string };
|
|
|
|
|
stgVersion = data.version ?? "?";
|
|
|
|
|
} catch (e) {
|
|
|
|
|
@@ -371,7 +427,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
|
|
type: "text",
|
|
|
|
|
text: [
|
|
|
|
|
`Lokal (package.json) : ${localVersion}`,
|
|
|
|
|
`Stg (/api/version-app): ${stgVersion}`,
|
|
|
|
|
`Stg (${VERSION_PATH}): ${stgVersion}`,
|
|
|
|
|
`Status : ${match}`,
|
|
|
|
|
].join("\n"),
|
|
|
|
|
},
|
|
|
|
|
|