convoreader / chat-git.js
luguog's picture
Serialize live chat commits and harden branch controls
a0e54db verified
Raw
History Blame Contribute Delete
8.54 kB
(function (global) {
'use strict';
const STATE_KEY = 'convoreader_chat_git_v1';
const TOKEN_KEY = 'convoreader_github_token';
let syncQueue = Promise.resolve();
let commitQueue = Promise.resolve();
function initialState() {
return {
schemaVersion: 1,
activeBranch: 'main',
branches: { main: null },
commits: [],
config: { owner: '', repo: '', githubBranch: 'main', autoSync: false },
sync: { status: 'disconnected', lastCommit: null, error: null }
};
}
function read() {
try {
const state = { ...initialState(), ...JSON.parse(localStorage.getItem(STATE_KEY) || '{}') };
state.branches = state.branches || { main: null };
state.commits = Array.isArray(state.commits) ? state.commits : [];
state.config = { ...initialState().config, ...(state.config || {}) };
return state;
} catch { return initialState(); }
}
function write(state) {
localStorage.setItem(STATE_KEY, JSON.stringify(state));
global.dispatchEvent(new CustomEvent('chatgit:change', { detail: state }));
return state;
}
async function hash(value) {
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(value)));
return [...new Uint8Array(bytes)].map(v => v.toString(16).padStart(2, '0')).join('');
}
function token() { return sessionStorage.getItem(TOKEN_KEY) || ''; }
function safePart(value) { return String(value || 'stream').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 100); }
function utf8Base64(value) {
const bytes = new TextEncoder().encode(value);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
async function github(path, options = {}) {
const state = read();
if (!token()) throw new Error('GitHub is not connected for this browser tab');
if (!state.config.owner || !state.config.repo) throw new Error('GitHub owner and repository are required');
const response = await fetch(`https://api.github.com/repos/${encodeURIComponent(state.config.owner)}/${encodeURIComponent(state.config.repo)}${path}`, {
...options,
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token()}`,
'X-GitHub-Api-Version': '2022-11-28',
...(options.headers || {})
}
});
const data = response.status === 204 ? {} : await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.message || `GitHub HTTP ${response.status}`);
return data;
}
async function connect({ owner, repo, githubBranch = 'main', accessToken, autoSync = true }) {
if (!/^[A-Za-z0-9_.-]+$/.test(owner || '') || !/^[A-Za-z0-9_.-]+$/.test(repo || '')) throw new Error('Invalid repository identifier');
if (!accessToken) throw new Error('A fine-grained GitHub token is required');
sessionStorage.setItem(TOKEN_KEY, accessToken);
const state = read();
state.config = { owner, repo, githubBranch, autoSync };
state.sync = { status: 'connecting', lastCommit: state.sync?.lastCommit || null, error: null };
write(state);
try {
const repository = await github('');
const next = read();
next.sync.status = 'connected';
next.sync.error = null;
write(next);
return { fullName: repository.full_name, defaultBranch: repository.default_branch, private: repository.private };
} catch (error) {
sessionStorage.removeItem(TOKEN_KEY);
const next = read();
next.sync = { status: 'error', lastCommit: next.sync?.lastCommit || null, error: error.message };
write(next);
throw error;
}
}
function disconnect() {
sessionStorage.removeItem(TOKEN_KEY);
const state = read();
state.sync.status = 'disconnected';
state.sync.error = null;
return write(state);
}
async function commitNow({ streamId = 'workspace', type = 'chat', message, snapshot, codemap = null, parents = null }) {
const state = read();
const branch = state.activeBranch;
const body = {
schemaVersion: 1,
streamId,
branch,
type,
message: String(message || type).slice(0, 240),
parents: parents || (state.branches[branch] ? [state.branches[branch]] : []),
snapshot,
codemap,
createdAt: new Date().toISOString()
};
body.id = await hash(body);
state.commits.push(body);
if (state.commits.length > 2000) state.commits = state.commits.slice(-2000);
state.branches[branch] = body.id;
write(state);
if (state.config.autoSync && token()) queueSync(streamId);
return body;
}
function commit(input) {
commitQueue = commitQueue.then(() => commitNow(input));
return commitQueue;
}
function createBranch(name, from = null) {
const clean = safePart(name);
const state = read();
if (state.branches[clean] !== undefined) throw new Error('Branch already exists');
state.branches[clean] = from || state.branches[state.activeBranch] || null;
state.activeBranch = clean;
return write(state);
}
function switchBranch(name) {
const state = read();
if (state.branches[name] === undefined) throw new Error('Unknown branch');
state.activeBranch = name;
return write(state);
}
async function merge(source, target = null, snapshot = null) {
const state = read();
const destination = target || state.activeBranch;
if (state.branches[source] === undefined || state.branches[destination] === undefined) throw new Error('Unknown branch');
const prior = state.activeBranch;
state.activeBranch = destination;
write(state);
const result = await commit({
streamId: 'branch-merge', type: 'merge', message: `Merge ${source} into ${destination}`,
snapshot: snapshot || { source, destination },
parents: [state.branches[destination], state.branches[source]].filter(Boolean)
});
if (prior !== destination) switchBranch(prior);
return result;
}
async function syncStream(streamId) {
const state = read();
const streamCommits = state.commits.filter(c => c.streamId === streamId || c.type === 'merge');
const payload = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
activeBranch: state.activeBranch,
branches: state.branches,
streamId,
commits: streamCommits
};
const filePath = `.convoreader/streams/${safePart(streamId)}.json`;
const branch = state.config.githubBranch || 'main';
let existing = null;
try { existing = await github(`/contents/${filePath}?ref=${encodeURIComponent(branch)}`); }
catch (error) { if (!/not found/i.test(error.message)) throw error; }
const latest = streamCommits.at(-1);
const body = {
message: `chat(${safePart(streamId)}): ${latest?.message || 'sync version graph'}`,
content: utf8Base64(JSON.stringify(payload, null, 2) + '\n'),
branch,
...(existing?.sha ? { sha: existing.sha } : {})
};
const result = await github(`/contents/${filePath}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const next = read();
next.sync = { status: 'connected', lastCommit: result.commit?.sha || null, error: null };
write(next);
return result;
}
function queueSync(streamId) {
syncQueue = syncQueue.then(() => syncStream(streamId)).catch(error => {
const state = read();
state.sync = { status: 'error', lastCommit: state.sync?.lastCommit || null, error: error.message };
write(state);
});
return syncQueue;
}
async function searchRepositories(query) {
const response = await fetch(`https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&sort=stars&order=desc&per_page=5`, {
headers: {
Accept: 'application/vnd.github+json',
...(token() ? { Authorization: `Bearer ${token()}` } : {}),
'X-GitHub-Api-Version': '2022-11-28'
}
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.message || `GitHub search HTTP ${response.status}`);
return { total: data.total_count || 0, items: (data.items || []).map(item => ({ name: item.full_name, url: item.html_url, stars: item.stargazers_count, description: item.description })) };
}
global.ChatGit = { read, commit, connect, disconnect, createBranch, switchBranch, merge, syncStream, queueSync, searchRepositories, hasSessionToken: () => Boolean(token()) };
})(window);