/**
* Cloudflare Worker: Slack Approval
*
* Slack 버튼 클릭 이벤트를 수신하여:
* - 승인: GitHub API로 develop → main 머지
* - 반려: .regenerate 마커 파일 생성 → 다음 스케줄에서 재생성
*/
export default {
async fetch(request, env, ctx) {
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
const url = new URL(request.url);
if (url.pathname === "/interact") {
return handleInteraction(request, env, ctx);
}
return new Response("Not Found", { status: 404 });
},
};
async function handleInteraction(request, env, ctx) {
const body = await request.text();
// Slack 서명 검증
const isValid = await verifySlackRequest(request, body, env.SLACK_SIGNING_SECRET);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
// Slack payload 파싱 (URL-encoded form: payload=JSON)
const params = new URLSearchParams(body);
const payload = JSON.parse(params.get("payload"));
const action = payload.actions[0];
const actionId = action.action_id;
const responseUrl = payload.response_url;
const user = payload.user.username;
let meta = {};
try { meta = JSON.parse(action.value || "{}"); } catch (e) {}
if (actionId === "approve_deploy") {
ctx.waitUntil(handleApprove(env, responseUrl, user, meta));
return jsonResponse({
replace_original: true,
text: "⏳ 승인 처리 중...",
});
}
if (actionId === "reject_deploy") {
ctx.waitUntil(handleReject(env, responseUrl, user, meta));
return jsonResponse({
replace_original: true,
text: "⏳ 반려 처리 중...",
});
}
return jsonResponse({ text: "Unknown action" });
}
async function handleApprove(env, responseUrl, user, meta) {
try {
const res = await fetch(
\`https://api.github.com/repos/\${env.GITHUB_REPO}/merges\`,
{
method: "POST",
headers: {
Authorization: \`Bearer \${env.GITHUB_TOKEN}\`,
"Content-Type": "application/json",
"User-Agent": "slack-approval-worker",
Accept: "application/vnd.github.v3+json",
},
body: JSON.stringify({
base: "main",
head: "develop",
commit_message: \`deploy: \${meta.slug || "content"} (approved by @\${user})\`,
}),
}
);
let message;
if (res.status === 201) {
message = "✅ 승인 완료! develop → main 머지 성공. 배포가 진행됩니다.";
} else if (res.status === 204) {
message = "ℹ️ 이미 최신 상태입니다.";
} else if (res.status === 409) {
message = "⚠️ 머지 충돌 발생. 수동으로 해결해주세요.";
} else {
const errBody = await res.text();
message = \`❌ 머지 실패 (\${res.status}): \${errBody}\`;
}
await updateSlackMessage(responseUrl, message);
} catch (e) {
await updateSlackMessage(responseUrl, \`❌ 오류: \${e.message}\`);
}
}
async function handleReject(env, responseUrl, user, meta) {
try {
const type = meta.type || "insight";
const slug = meta.slug || "unknown";
const markerPath = \`scripts/.regenerate-\${type}\`;
const content = btoa(JSON.stringify({
slug, requestedAt: new Date().toISOString(), requestedBy: user
}));
// 기존 마커 파일 SHA 확인
let sha = null;
const getRes = await fetch(
\`https://api.github.com/repos/\${env.GITHUB_REPO}/contents/\${markerPath}?ref=develop\`,
{
headers: {
Authorization: \`Bearer \${env.GITHUB_TOKEN}\`,
"User-Agent": "slack-approval-worker",
Accept: "application/vnd.github.v3+json",
},
}
);
if (getRes.ok) { sha = (await getRes.json()).sha; }
const putBody = { message: \`chore: request regeneration of \${slug}\`, content, branch: "develop" };
if (sha) putBody.sha = sha;
await fetch(
\`https://api.github.com/repos/\${env.GITHUB_REPO}/contents/\${markerPath}\`,
{
method: "PUT",
headers: {
Authorization: \`Bearer \${env.GITHUB_TOKEN}\`,
"Content-Type": "application/json",
"User-Agent": "slack-approval-worker",
Accept: "application/vnd.github.v3+json",
},
body: JSON.stringify(putBody),
}
);
await updateSlackMessage(responseUrl,
\`🔄 반려됨. \${slug} 재생성이 다음 스케줄에서 실행됩니다.\`);
} catch (e) {
await updateSlackMessage(responseUrl, \`❌ 반려 처리 오류: \${e.message}\`);
}
}
async function updateSlackMessage(responseUrl, text) {
await fetch(responseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ replace_original: true, text }),
});
}
async function verifySlackRequest(request, body, signingSecret) {
const timestamp = request.headers.get("X-Slack-Request-Timestamp");
if (!timestamp) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) return false;
const sigBasestring = \`v0:\${timestamp}:\${body}\`;
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(signingSecret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(sigBasestring));
const hexSig = "v0=" + [...new Uint8Array(sig)]
.map((b) => b.toString(16).padStart(2, "0")).join("");
return hexSig === request.headers.get("X-Slack-Signature");
}
function jsonResponse(data) {
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
}