function getSyncConfig() { const p = PropertiesService.getScriptProperties().getProperties(); return { baseUrl: p.GITEA_BASE_URL || '', owner: p.GITEA_OWNER || '', repo: p.GITEA_REPO || '', token: p.GITEA_TOKEN || '', defaultBranch: p.GITEA_DEFAULT_BRANCH || 'main', scriptId: p.SCRIPT_ID || ScriptApp.getScriptId() }; } function formatSyncTimestamp() { return Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyyMMdd-HHmm"); } function encodeBase64Utf8(text) { return Utilities.base64Encode(text || '', Utilities.Charset.UTF_8); } function buildSyncBranchName(featureCode) { var safeFeature = String(featureCode || "manual") .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return "sync/" + (safeFeature || "manual") + "-" + formatSyncTimestamp(); } function mapAppsScriptFileToRepoPath(file) { if (!file || !file.name || !file.type) return null; if (file.name === 'appsscript' && file.type === 'JSON') { return 'appsscript.json'; } const appFiles = ['Code', 'Index', 'styles', 'scripts']; const docsFiles = ['Readme']; let prefix = 'workflow/'; if (appFiles.includes(file.name)) { prefix = 'app/'; } else if (docsFiles.includes(file.name)) { prefix = 'docs/'; } if (file.type === 'SERVER_JS') { return prefix + file.name + '.gs'; } if (file.type === 'HTML') { return prefix + file.name + '.html'; } Logger.log('Unknown file type for mapping: ' + file.name + ' (' + file.type + ')'); return null; } function prepareGitSyncPayload(featureCode, appScriptFiles) { const config = getSyncConfig(); const syncBranch = buildSyncBranchName(featureCode); const mappedFiles = []; if (appScriptFiles && Array.isArray(appScriptFiles)) { appScriptFiles.forEach(function(file) { const repoPath = mapAppsScriptFileToRepoPath(file); if (repoPath) { mappedFiles.push({ repoPath: repoPath, content: file.source || '', originalName: file.name, type: file.type }); } }); } return { baseBranch: config.defaultBranch, syncBranch: syncBranch, files: mappedFiles }; } function getGiteaSyncApiUrl(path) { const config = getSyncConfig(); let baseUrl = config.baseUrl.replace(/\/$/, ''); if (!baseUrl.endsWith('/api/v1')) { baseUrl += '/api/v1'; } const normalizedPath = path.startsWith('/') ? path : '/' + path; return baseUrl + normalizedPath; } function getGiteaSyncRequestOptions(method, payload) { const config = getSyncConfig(); const options = { method: method, headers: { 'Authorization': 'token ' + config.token }, contentType: 'application/json', muteHttpExceptions: true }; if (payload) { options.payload = JSON.stringify(payload); } return options; } function giteaSyncRequest(method, path, payload) { const url = getGiteaSyncApiUrl(path); const options = getGiteaSyncRequestOptions(method, payload); try { const response = UrlFetchApp.fetch(url, options); const code = response.getResponseCode(); const rawContent = response.getContentText(); let json = null; try { json = JSON.parse(rawContent); } catch (e) {} if (code >= 200 && code < 300) { return { ok: true, data: json || rawContent, code: code }; } return { ok: false, message: json && json.message ? json.message : 'API Error ' + code, code: code, rawBody: rawContent }; } catch (e) { return { ok: false, message: 'Connection Error: ' + e.message, code: 500 }; } } function createGiteaSyncBranch(baseBranch, newBranch) { const config = getSyncConfig(); const path = '/repos/' + config.owner + '/' + config.repo + '/branches'; const payload = { new_branch_name: newBranch, old_ref_name: baseBranch || config.defaultBranch }; return giteaSyncRequest('POST', path, payload); } function getGiteaFileMetadata(repoPath, branch) { const config = getSyncConfig(); const path = '/repos/' + config.owner + '/' + config.repo + '/contents/' + repoPath + '?ref=' + encodeURIComponent(branch); return giteaSyncRequest('GET', path, null); } function upsertGiteaSyncFile(repoPath, content, branch, message) { const config = getSyncConfig(); const path = '/repos/' + config.owner + '/' + config.repo + '/contents/' + repoPath; const metaRes = getGiteaFileMetadata(repoPath, branch); let sha = null; if (metaRes.ok && metaRes.data && metaRes.data.sha) { sha = metaRes.data.sha; } else if (!metaRes.ok && metaRes.code !== 404) { return metaRes; } const payload = { branch: branch, content: encodeBase64Utf8(content), message: message || 'Update ' + repoPath }; if (sha) { payload.sha = sha; } return giteaSyncRequest(sha ? 'PUT' : 'POST', path, payload); } function getAppsScriptProjectContent() { const config = getSyncConfig(); const currentScriptId = ScriptApp.getScriptId(); const targetScriptId = config.scriptId || currentScriptId; console.log("--- Diagnostic: getAppsScriptProjectContent ---"); console.log("Configured Script ID : " + (config.scriptId || "(empty)")); console.log("Current Script ID : " + currentScriptId); if (config.scriptId && config.scriptId !== currentScriptId) { console.warn("MISMATCH: Configured SCRIPT_ID is different from current ScriptApp.getScriptId(). This may cause a 403 error."); } else { console.log("MATCH: Script IDs match (or using default fallback)."); } const url = "https://script.googleapis.com/v1/projects/" + targetScriptId + "/content"; const token = ScriptApp.getOAuthToken(); const options = { method: "get", headers: { Authorization: "Bearer " + token }, muteHttpExceptions: true }; const response = UrlFetchApp.fetch(url, options); const code = response.getResponseCode(); const rawContent = response.getContentText(); if (code === 200) { const data = JSON.parse(rawContent); const files = data.files || []; console.log("Response Code : " + code); console.log("Files Retrieved : " + files.length); console.log("File Names : " + files.map(function(f){ return f.name; }).join(", ")); console.log("-----------------------------------------------"); return { ok: true, data: data }; } console.log("Response Code : " + code); console.log("Raw Response Body : " + rawContent); console.log("-----------------------------------------------"); let errorMsg = "Failed to fetch project content. Code: " + code; if (code === 403) { errorMsg = "HTTP 403 Forbidden. Ensure Google Apps Script API is enabled in user settings (https://script.google.com/home/usersettings) and OAuth scopes are correctly configured."; } return { ok: false, message: errorMsg, code: code, rawBody: rawContent }; } function normalizeAppsScriptFiles(files) { return files.map(function(file) { return { name: file.name, type: file.type, source: file.source || '' }; }); } function exportAppsScriptProject() { const res = getAppsScriptProjectContent(); if (!res.ok) return res; return { ok: true, files: normalizeAppsScriptFiles(res.data.files || []) }; } function buildSyncManifest(featureCode, mappedFiles) { const manifest = { featureCode: featureCode, syncDate: new Date().toISOString(), filesSynced: mappedFiles.length }; return JSON.stringify(manifest, null, 2); } function checkGiteaPullRequestExists(baseBranch, headBranch) { const config = getSyncConfig(); const path = '/repos/' + config.owner + '/' + config.repo + '/pulls?state=open'; const res = giteaSyncRequest('GET', path, null); if (res.ok && res.data && Array.isArray(res.data)) { const existing = res.data.find(function(pr) { return pr.base && pr.head && pr.base.ref === baseBranch && pr.head.ref === headBranch; }); if (existing) { return { ok: true, existed: true, data: existing }; } return { ok: true, existed: false }; } return { ok: false, existed: false, message: res.message || 'Failed to check existing PRs' }; } function createGiteaPullRequest(title, body, baseBranch, headBranch) { const config = getSyncConfig(); const checkRes = checkGiteaPullRequestExists(baseBranch, headBranch); if (checkRes.existed) { return { ok: true, existed: true, data: checkRes.data, message: 'Pull request already exists' }; } if (!checkRes.ok) { return checkRes; } const path = '/repos/' + config.owner + '/' + config.repo + '/pulls'; const payload = { title: title, body: body, base: baseBranch, head: headBranch }; const res = giteaSyncRequest('POST', path, payload); if (res.ok) { res.existed = false; } return res; } function ensureGiteaBranch(baseBranch, newBranch) { const config = getSyncConfig(); const checkRes = giteaSyncRequest('GET', '/repos/' + config.owner + '/' + config.repo + '/branches/' + newBranch, null); if (checkRes.ok) { return { ok: true, existed: true }; } const createRes = createGiteaSyncBranch(baseBranch, newBranch); if (!createRes.ok && createRes.message && createRes.message.toLowerCase().indexOf('empty') !== -1) { console.log('Git Repository is empty. Attempting to bootstrap with initial commit...'); const bootstrapRes = giteaSyncRequest('POST', '/repos/' + config.owner + '/' + config.repo + '/contents/README.md', { branch: baseBranch || config.defaultBranch, content: encodeBase64Utf8('# SchoolHub Sync\nRepository initialized automatically.'), message: 'Initial commit' }); if (bootstrapRes.ok) { console.log('Bootstrap successful. Retrying branch creation...'); return createGiteaSyncBranch(baseBranch, newBranch); } return { ok: false, message: 'Git Repository is empty and bootstrap failed: ' + bootstrapRes.message + '. Harap inisialisasi repositori secara manual (misal: centang "Initialize Repository" saat membuat repo).' }; } return createRes; } function syncAppsScriptToGiteaBranch(featureCode) { console.log("Starting Gitea sync for feature: " + featureCode); const projectRes = exportAppsScriptProject(); if (!projectRes.ok) return projectRes; const payload = prepareGitSyncPayload(featureCode, projectRes.files); if (!payload.files || payload.files.length === 0) { return { ok: false, message: 'No valid files to sync.' }; } const branchRes = ensureGiteaBranch(payload.baseBranch, payload.syncBranch); if (!branchRes.ok) return { ok: false, message: 'Branch creation failed: ' + branchRes.message }; console.log("Branch ready: " + payload.syncBranch + " (Existed: " + !!branchRes.existed + ")"); const results = { created: 0, updated: 0, errors: 0, details: [] }; payload.files.forEach(function(file) { const res = upsertGiteaSyncFile(file.repoPath, file.content, payload.syncBranch, 'Sync ' + file.repoPath); results.details.push({ file: file.repoPath, ok: res.ok, message: res.message }); if (res.ok) { if (res.code === 201) results.created++; else results.updated++; } else { results.errors++; } }); const manifestContent = buildSyncManifest(featureCode, payload.files); const manifestRes = upsertGiteaSyncFile('sync-manifest.json', manifestContent, payload.syncBranch, 'Update sync manifest'); if (manifestRes.ok) { if (manifestRes.code === 201) results.created++; else results.updated++; } else { results.errors++; } const prTitle = 'Sync Feature: ' + featureCode; const prBody = 'Automated sync from Google Apps Script for feature: ' + featureCode + '.\n\nFiles synced:\n' + payload.files.map(function(f) { return '- ' + f.repoPath; }).join('\n'); const prRes = createGiteaPullRequest(prTitle, prBody, payload.baseBranch, payload.syncBranch); let prInfo = null; if (prRes.ok) { prInfo = { url: prRes.data.html_url || '', existed: prRes.existed, number: prRes.data.number }; } console.log("Sync Summary: " + results.created + " created, " + results.updated + " updated, " + results.errors + " errors."); if (prInfo) { console.log("Pull Request: " + prInfo.url + (prInfo.existed ? " (Existed)" : " (Created)")); } else { console.log("Pull Request Failed: " + prRes.message); } return { ok: true, branch: payload.syncBranch, summary: results, pullRequest: prInfo }; } function runManualSyncToGitea() { const res = syncAppsScriptToGiteaBranch('manual'); if (res.ok) { console.log('=== Sync Summary ==='); console.log('Branch Sync: ' + res.branch); console.log('Files: ' + res.summary.created + ' created, ' + res.summary.updated + ' updated, ' + res.summary.errors + ' errors.'); console.log('Catatan: File baru akan menggunakan path app/, docs/, dan workflow/. File lama dengan folder src/ mungkin masih tertinggal di repositori (Gitea) dan harus dihapus secara manual.'); if (res.pullRequest) { console.log('Pull Request Status: ' + (res.pullRequest.existed ? 'Existing' : 'Created')); console.log('Pull Request URL: ' + res.pullRequest.url); } else { console.log('Pull Request Status: Not available'); } } else { console.error('Sync failed: ' + res.message); } return res; }