From 63fb07be305fe8b8065eac61b648dab5825a249c Mon Sep 17 00:00:00 2001 From: someone <2+someone@noreply.localhost> Date: Fri, 24 Apr 2026 22:40:03 +0000 Subject: [PATCH] Sync src/GiteaSync.gs --- src/GiteaSync.gs | 386 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 src/GiteaSync.gs diff --git a/src/GiteaSync.gs b/src/GiteaSync.gs new file mode 100644 index 0000000..7847bb7 --- /dev/null +++ b/src/GiteaSync.gs @@ -0,0 +1,386 @@ +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'; + } + if (file.type === 'SERVER_JS') { + return 'src/' + file.name + '.gs'; + } + if (file.type === 'HTML') { + return 'src/' + file.name + '.html'; + } + 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.'); + 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; +}