Sync Feature: manual #2

Merged
someone merged 13 commits from sync/manual-20260425-0644 into main 2026-04-25 00:03:07 +00:00
Showing only changes of commit 7d8606daef - Show all commits
+402
View File
@@ -0,0 +1,402 @@
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;
}