Sync src/GiteaSync.gs
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
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 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++;
|
||||
}
|
||||
|
||||
console.log("Sync Summary: " + results.created + " created, " + results.updated + " updated, " + results.errors + " errors.");
|
||||
|
||||
return { ok: true, branch: payload.syncBranch, summary: results };
|
||||
}
|
||||
|
||||
function runManualSyncToGitea() {
|
||||
const res = syncAppsScriptToGiteaBranch('manual');
|
||||
if (res.ok) {
|
||||
console.log('Sync successful to branch: ' + res.branch);
|
||||
console.log(JSON.stringify(res.summary, null, 2));
|
||||
} else {
|
||||
console.error('Sync failed: ' + res.message);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
Reference in New Issue
Block a user