lib/issues-service.js
Rama main · réplica de lectura del repositorio original.
// Authorization and capability bindings for the Phase 1 Issues backend.
var issuesStore = require("./issues-store");
var schema = require("./issues-schema");
var provenance = require("./session-provenance");
var commitVerification = require("./issues-commit-verification");
var accessScope = require("./project-logs-access-scope").attachProjectLogsAccessScope;
var PUBLIC_FIELDS = { title: true, summary: true, body: true, resolutionSummary: true,
type: true, priority: true, status: true, closeReason: true };
function clone(value) { return value == null ? value : JSON.parse(JSON.stringify(value)); }
function attachIssuesService(ctx) {
var context = ctx || {};
var projects = context.getProjects;
var multiUser = context.isMultiUser || function () { return false; };
var findUserById = context.findUserById || function () { return null; };
var stores = new Map();
var githubLocks = new Map();
var githubAssessments = new Map();
var verify = commitVerification.injectedVerifier(context.verifyCommit);
var scope;
function projectFor(slug) {
var list = typeof projects === "function" ? projects() : null;
return typeof slug === "string" && list && typeof list.get === "function" ? list.get(slug) || null : null;
}
function statusFor(project) {
var status;
if (!project || typeof project.getStatus !== "function") return null;
try { status = project.getStatus(); } catch (e) { return null; }
if (!status || typeof status !== "object" || Array.isArray(status)) return null;
if (typeof status.slug !== "string" || !status.slug || typeof status.path !== "string" || !status.path) return null;
if (typeof status.projectKnowledgeId !== "string" || !status.projectKnowledgeId) return null;
if (status.projectOwnerId != null && typeof status.projectOwnerId !== "string") return null;
if (status.isWorktree === true && (typeof status.changeSetId !== "string" || !status.changeSetId || typeof status.parentSlug !== "string" || !status.parentSlug)) return null;
return status;
}
function governingStatus(status) {
if (!status || status.isWorktree !== true) return status;
return statusFor(projectFor(status.parentSlug));
}
function ownerKnown(status) {
// Legacy ownerless projects still require explicit project access below.
// An assigned owner that no longer exists remains invalid.
return !multiUser() || status.projectOwnerId == null || !!findUserById(status.projectOwnerId);
}
function openStore(status) {
var governing = governingStatus(status);
if (!governing || !governing.path || !governing.projectKnowledgeId) throw new Error("Issues are unavailable for this project.");
var key = governing.projectKnowledgeId;
if (!stores.has(key)) stores.set(key, issuesStore.createIssuesStore({ projectKnowledgeId: key, baseDir: context.baseDir }));
return stores.get(key);
}
function issueContext(status) {
if (!status || status.isWorktree !== true || !status.changeSetId) return null;
return { kind: "worktree", changeSetId: status.changeSetId, branch: status.branch || null,
baseCommit: status.baseCommit || null, headCommit: status.headCommit || null };
}
function author(user, type, session) {
return { type: type, userId: user ? user.id || null : null,
displayName: user && (user.displayName || user.username) || null,
sessionKey: session ? session.cliSessionId || "local:" + session.localId : null,
sessionOriginId: session ? session.sessionOriginId || null : null,
vendor: session ? session.vendor || null : null, at: Date.now() };
}
function cleanPublic(input, controls, allowCloseReason) {
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Issue arguments are required.");
var keys = Object.keys(input);
var result = {};
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
if (Object.prototype.hasOwnProperty.call(controls, key)) continue;
if (!Object.prototype.hasOwnProperty.call(PUBLIC_FIELDS, key) || (key === "closeReason" && !allowCloseReason)) throw new Error("Unknown or server-owned issue field: " + key + ".");
result[key] = input[key];
}
return result;
}
function osUsersEnabled() {
return typeof context.osUsersEnabled === "function" ? context.osUsersEnabled() === true : context.osUsersEnabled === true;
}
function osIdentity(state) {
if (!osUsersEnabled()) return null;
var identity = typeof context.getOsUserInfoForActor === "function" ? context.getOsUserInfoForActor(state.user) : null;
if (!identity || typeof identity !== "object") throw new Error("An OS identity is required for commit verification.");
return clone(identity);
}
scope = accessScope({ projectFor: projectFor, isMultiUser: multiUser,
canAccessProject: context.canAccessProject, hasFullProjectAccess: context.hasFullProjectAccess,
openStore: function () { return null; } });
function bindingState(project, session, userId, originId) {
var status = statusFor(project);
if (!status || status.isMate === true) return null;
var governing = governingStatus(status);
if (!governing || !ownerKnown(governing)) return null;
var user = null;
if (multiUser()) {
if (!userId || !(user = findUserById(userId))) return null;
} else {
user = userId ? findUserById(userId) : null;
if (!user) user = { id: userId || status.projectOwnerId || null };
}
if (session) {
var manager = project.getSessionManager ? project.getSessionManager() : project.sm;
if (!manager || !manager.sessions || manager.sessions.get(session.localId) !== session) return null;
if ((session.ownerId || null) !== (userId || null) || (session.sessionOriginId || null) !== originId) return null;
if (multiUser() && !session.ownerId) return null;
if (provenance.isWorker(session) || session.hidden === true || session.delegated === true || session._pairDelegation || session._delegatedBy) return null;
}
var principal = multiUser() ? { userId: user.id, singleUserOwnerId: null }
: { userId: null, singleUserOwnerId: user.id || status.projectOwnerId || null };
var authorization;
try { authorization = scope.projectAuthorization(principal, status); } catch (e) { return null; }
if (!authorization.allowed || (authorization.worktreeOnly && !status.changeSetId)) return null;
principal.isProjectOwner = scope.ownsProject(principal, governing);
return { project: project, status: status, session: session, user: user,
principal: principal, worktreeOnly: authorization.worktreeOnly };
}
function bind(source, isSession, canonicalClay) {
if (!source || typeof source.projectSlug !== "string") return null;
var initialSlug = source.projectSlug;
var initialProject = projectFor(initialSlug);
var initialSession = isSession ? source.session : null;
var clayInitial;
if (canonicalClay) { try { clayInitial = require("./canonical-clay-record-authority").resolve(source.authority, initialSlug); } catch (error) { return null; } }
if (!initialProject || (isSession && !initialSession)) return null;
if (initialSession) provenance.ensureOrigin(initialSession);
var initialUserId = initialSession ? initialSession.ownerId || null : canonicalClay ? clayInitial.principal.userId || clayInitial.principal.singleUserOwnerId : source.userId || null;
var initialOrigin = initialSession ? initialSession.sessionOriginId || null : null;
var initial = bindingState(initialProject, initialSession, initialUserId, initialOrigin);
if (!initial) return null;
function current() {
if (projectFor(initialSlug) !== initialProject) throw new Error("This Issues binding is no longer valid.");
if (canonicalClay) {
var clayLive = require("./canonical-clay-record-authority").resolve(source.authority, initialSlug);
if (clayLive.project !== initialProject || clayLive.status.path !== clayInitial.status.path || clayLive.status.projectKnowledgeId !== clayInitial.status.projectKnowledgeId || clayLive.status.changeSetId !== clayInitial.status.changeSetId) throw new Error("Selected Issues context changed.");
}
var live = bindingState(initialProject, initialSession, initialUserId, initialOrigin);
if (!live) throw new Error("This Issues binding is no longer valid.");
return live;
}
function expected(entry, args) { if (canonicalClay && (!Number.isInteger(args.expectedRevision) || entry.revision !== args.expectedRevision)) throw new Error("Issue revision conflict."); }
function actor(state) { return canonicalClay ? require("./canonical-clay-record-authority").resolve(source.authority, initialSlug).actor : author(state.user, state.session ? "session" : "user", state.session); }
function visibleIssue(store, ref, state) {
var result = store.read(ref);
if (state.worktreeOnly && (!result.context || result.context.changeSetId !== state.status.changeSetId)) throw new Error("Issue not found.");
return result;
}
function scopedIssue(store, ref, state) {
var result = store.read(ref, true);
if (state.worktreeOnly && (!result.context || result.context.changeSetId !== state.status.changeSetId)) throw new Error("Issue not found.");
return result;
}
function scopedOptions(state, args) {
var result = Object.assign({}, args || {});
if (state.worktreeOnly) {
if (!state.status.changeSetId) throw new Error("Issues are unavailable without a change set.");
result.changeSetId = state.status.changeSetId;
}
return result;
}
function operation(state, needsIdentity) {
var governing = governingStatus(state.status);
return { project: state.project, session: state.session, userId: state.user.id || null,
path: state.status.path, knowledgeId: governing.projectKnowledgeId,
changeSetId: state.status.changeSetId || null, worktreeOnly: state.worktreeOnly,
recordContext: clone(issueContext(state.status)),
actor: clone(actor(state)),
osIdentity: needsIdentity ? osIdentity(state) : null };
}
function recheck(before, needsIdentity) {
var fresh = current();
var governing = governingStatus(fresh.status);
var liveIdentity = needsIdentity ? osIdentity(fresh) : null;
if (fresh.project !== before.project || fresh.session !== before.session || (fresh.user.id || null) !== before.userId ||
fresh.status.path !== before.path || !governing || governing.projectKnowledgeId !== before.knowledgeId ||
(fresh.status.changeSetId || null) !== before.changeSetId || fresh.worktreeOnly !== before.worktreeOnly || JSON.stringify(issueContext(fresh.status)) !== JSON.stringify(before.recordContext) ||
JSON.stringify(liveIdentity) !== JSON.stringify(before.osIdentity)) throw new Error("Issue authorization changed during the operation.");
return fresh;
}
function evidence(before, sha) {
return verify(before.path, sha, before.knowledgeId, before.osIdentity).then(function (proof) {
var normalized = schema.normalizeVerifiedCommit(proof);
if (normalized.repository !== before.knowledgeId) throw new Error("Verified commit evidence belongs to a different repository.");
return normalized;
});
}
var github = require('./issues-github').attachIssueGithub({
locks: githubLocks, assessments: githubAssessments, run: context.runGithub, analyze: source.analyzeGithub || context.analyzeGithub,
begin: function () {
if (isSession || canonicalClay) throw new Error('GitHub filing requires an explicit action in the Issues panel.');
return operation(current(), true);
},
read: function (before, ref) {
var state = recheck(before, true);
return visibleIssue(openStore(state.status), ref, state);
},
save: function (before, ref, data, revision) {
var state = recheck(before, true);
var target = openStore(state.status);
visibleIssue(target, ref, state);
return target.update(ref, data, revision, before.actor);
},
});
return {
searchGithubIssue: github.search, linkGithubIssue: github.link, createGithubIssue: github.create,
linkWorkSession: function (args, session) {
if (canonicalClay) throw new Error("Use reviewed project assignment or follow-up for Driver execution.");
var state = current();
var manager = state.project.getSessionManager();
if (!args || !Number.isInteger(args.expectedRevision)) throw new Error("expectedRevision is required.");
if (!session || manager.sessions.get(session.localId) !== session || provenance.isWorker(session) || session.hidden === true ||
session.delegated === true || session._pairDelegation || session._delegatedBy ||
(session.ownerId || null) !== (state.user.id || null)) throw new Error("Invalid work session.");
provenance.ensureOrigin(session);
var target = openStore(state.status);
var entry = visibleIssue(target, args.ref, state);
if (entry.revision !== args.expectedRevision) throw new Error("Issue revision conflict.");
if (entry.status === "resolved" || entry.status === "closed") throw new Error("Reopen this issue before starting work.");
var links = entry.linkedWorkSessions || [];
if (links.some(function (link) { return link.sessionId === session.sessionOriginId; })) return entry;
var link = { sessionId: session.sessionOriginId, role: "driver", issueRevision: args.expectedRevision };
if (args.requestId !== undefined) link.requestId = schema.text(args.requestId, "work request id", 200, true);
return target.update(args.ref, { linkedWorkSessions: links.concat([link]), status: "in_progress" }, args.expectedRevision, canonicalClay ? actor(state) : author(state.user, "user", null));
},
resolveWorkSession: function (args) {
if (canonicalClay) throw new Error("Work sessions require the existing project Driver workflow.");
var state = current();
var entry = visibleIssue(openStore(state.status), args.ref, state);
if (!(entry.linkedWorkSessions || []).some(function (link) { return link.sessionId === args.sessionOriginId; })) throw new Error("Work session unavailable.");
var found = null;
state.project.getSessionManager().sessions.forEach(function (session) {
if (session.sessionOriginId === args.sessionOriginId && (session.ownerId || null) === (state.user.id || null) &&
!provenance.isWorker(session) && session.hidden !== true && session.delegated !== true && !session._pairDelegation && !session._delegatedBy) found = session;
});
if (!found) throw new Error("Work session unavailable.");
return found;
},
scopeId: "project/" + governingStatus(initial.status).projectKnowledgeId + "/issues",
canComment: true,
canDelete: initial.principal.isProjectOwner === true,
listIssues: function (args) { var state = current(); return openStore(state.status).list(scopedOptions(state, args)); },
searchIssues: function (args) { var state = current(); return openStore(state.status).search(scopedOptions(state, args)); },
readIssue: function (args) { var state = current(); return visibleIssue(openStore(state.status), args && args.ref, state); },
readIssueTombstone: function (args) { var state = current(); return scopedIssue(openStore(state.status), args && args.ref, state); },
issueHistory: function (args) { var state = current(); var store = openStore(state.status); var history = store.history(args && args.ref); if (state.worktreeOnly) { var historical = scopedIssue(store, args && args.ref, state); if (!historical.context || historical.context.changeSetId !== state.status.changeSetId) throw new Error("Issue not found."); } return history; },
readIssueRevision: function (args) { var state = current(); var store = openStore(state.status); visibleIssue(store, args && args.ref, state); return store.readRevision(args.ref, args.revision); },
commentIssue: function (args) { var state = current(); var target = openStore(state.status); expected(visibleIssue(target, args && args.ref, state), args); return target.comment(args.ref, args.body, canonicalClay ? actor(state) : author(state.user, "user", null)); },
listIssueFeedback: function (args) {
if (!isSession && !canonicalClay) throw new Error("Only the Project Driver can review issue comments.");
var state = current(); var target = openStore(state.status); var out = []; var feedbackLimit = args && args.limit === undefined ? 200 : Number(args.limit);
if (!Number.isInteger(feedbackLimit) || feedbackLimit < 1 || feedbackLimit > 200) throw new Error("Feedback limit must be between 1 and 200.");
var position = { issueCursor: null, issueIndex: 0, commentOffset: 0 };
if (args && args.cursor) {
try {
if (/^\d+$/.test(String(args.cursor))) position.issueCursor = String(args.cursor);
else position = JSON.parse(Buffer.from(String(args.cursor), "base64url").toString("utf8"));
} catch (e) { throw new Error("Invalid issue feedback cursor."); }
if (!position || (position.issueCursor !== null && typeof position.issueCursor !== "string") || !Number.isInteger(position.issueIndex) || position.issueIndex < 0 || !Number.isInteger(position.commentOffset) || position.commentOffset < 0) throw new Error("Invalid issue feedback cursor.");
}
var nextCursor = null; var scanned = 0;
while (out.length < feedbackLimit && scanned < 500) {
var result = target.list(scopedOptions(state, { limit: 50, cursor: position.issueCursor }));
if (!result.issues.length) break;
scanned += result.issues.length;
var firstIssueIndex = position.issueIndex;
if (firstIssueIndex >= result.issues.length) throw new Error("Invalid issue feedback cursor.");
for (var i = firstIssueIndex; i < result.issues.length && out.length < feedbackLimit; i++) {
var entry = visibleIssue(target, result.issues[i].ref, state);
var waiting = (entry.comments || []).filter(function (item) { return item.status === "pending"; });
var start = i === position.issueIndex ? position.commentOffset : 0;
var j = start;
for (; j < waiting.length && out.length < feedbackLimit; j++) out.push({ ref: entry.ref, title: entry.title, commentId: waiting[j].id, body: waiting[j].body, author: waiting[j].author, at: waiting[j].at });
if (out.length >= feedbackLimit) {
if (j < waiting.length) nextCursor = Buffer.from(JSON.stringify({ issueCursor: position.issueCursor, issueIndex: i, commentOffset: j })).toString("base64url");
else if (i + 1 < result.issues.length) nextCursor = Buffer.from(JSON.stringify({ issueCursor: position.issueCursor, issueIndex: i + 1, commentOffset: 0 })).toString("base64url");
else if (result.nextCursor) nextCursor = Buffer.from(JSON.stringify({ issueCursor: result.nextCursor, issueIndex: 0, commentOffset: 0 })).toString("base64url");
}
}
if (nextCursor || !result.nextCursor) break;
if (scanned >= 500) {
nextCursor = Buffer.from(JSON.stringify({ issueCursor: result.nextCursor, issueIndex: 0, commentOffset: 0 })).toString("base64url");
break;
}
position.issueCursor = result.nextCursor; position.issueIndex = 0; position.commentOffset = 0;
}
return { feedback: out, total: out.length, nextCursor: nextCursor, truncated: !!nextCursor };
},
reviewIssueComment: function (args) {
if (!isSession && !canonicalClay) throw new Error("Only the Project Driver can review issue comments.");
var state = current(); var target = openStore(state.status); var entry = visibleIssue(target, args && args.ref, state);
expected(entry, args);
if (args.action === "incorporate") {
if (!Number.isInteger(args.expectedRevision)) throw new Error("expectedRevision is required to incorporate a comment.");
var data = cleanPublic(args, { ref: true, expectedRevision: true, commentId: true, action: true, response: true, commitSha: true }, true);
if (data.status === "resolved") throw new Error("Use update_issue with verified commit evidence before resolving this Issue.");
return target.incorporate(entry.ref, args.commentId, data, args.expectedRevision, args.response, actor(state));
}
return target.review(entry.ref, args.commentId, args.action, args.response, actor(state));
},
removeIssue: function (args) {
var state = current();
if (!state.principal.isProjectOwner) throw new Error("Only the project owner can delete Issues.");
var target = openStore(state.status); var entry = visibleIssue(target, args && args.ref, state);
if (!Number.isInteger(args && args.expectedRevision) || entry.revision !== args.expectedRevision) throw new Error("Issue revision conflict.");
return target.remove(entry.ref, canonicalClay ? actor(state) : author(state.user, "user", null), args.expectedRevision);
},
createIssue: function (args) {
if (!isSession && !canonicalClay) throw new Error("People cannot create issues directly. Ask the Project Driver to create one.");
var data = cleanPublic(args, { commitSha: true }, true);
var status = data.status === undefined ? "open" : data.status;
if (schema.STATUSES.indexOf(status) === -1) throw new Error("Invalid status.");
var state = current();
if (status !== "resolved") return Promise.resolve(openStore(state.status).create(data,
actor(state), issueContext(state.status)));
if (typeof data.resolutionSummary !== "string" || !data.resolutionSummary.trim()) throw new Error("resolutionSummary is required when resolving an issue.");
var before = operation(state, true);
return evidence(before, args.commitSha).then(function (proof) {
var after = recheck(before, true);
return openStore(after.status).create(data, before.actor, before.recordContext, proof);
});
},
updateIssue: function (args) {
var data = cleanPublic(args, { ref: true, expectedRevision: true, commitSha: true }, true);
if (!Number.isInteger(args.expectedRevision)) throw new Error("expectedRevision is required.");
if (data.status !== undefined && schema.STATUSES.indexOf(data.status) === -1) throw new Error("Invalid status.");
var ref = args.ref;
var expectedRevision = args.expectedRevision;
var state = current();
var target = openStore(state.status);
var existing = visibleIssue(target, ref, state);
if (existing.revision !== expectedRevision) throw new Error("Issue revision conflict.");
if (data.status !== "resolved") return Promise.resolve(target.update(ref, data, expectedRevision,
actor(state)));
if (typeof data.resolutionSummary !== "string" || !data.resolutionSummary.trim()) throw new Error("resolutionSummary is required when resolving an issue.");
var before = operation(state, true);
return evidence(before, args.commitSha).then(function (proof) {
var after = recheck(before, true);
var afterStore = openStore(after.status);
visibleIssue(afterStore, ref, after);
return afterStore.update(ref, data, expectedRevision, before.actor, null, proof);
});
},
};
}
return { bindUser: function (source) { return bind(source, false); },
bindCanonicalClay: function (source) { return bind(source, false, true); },
bindProjectSession: function (source) { return bind(source, true); },
issueScopeIdForKnowledgeId: issuesStore.issueScopeIdForKnowledgeId };
}
module.exports = { attachIssuesService: attachIssuesService };