Sync Feature: manual #2
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user