mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
fix: enable guest shared PR publication in chat (#162988)
Let session creators with session-scoped write access use shared GitHub publication from chat while keeping personal publication restricted. Refs #162916.
This commit is contained in:
parent
63a514d927
commit
df8c0ee207
12 changed files with 416 additions and 27 deletions
|
|
@ -304,7 +304,9 @@ Older unfinished shared requests without this requester binding require a new au
|
|||
|
||||
Pending session deletion blocks publication actions without discarding the original request. A failed deletion restores its retry. Confirmed deletion retires the attempt. The page clears this memory on reload or connection changes. Profile, session access, and workspace changes also retire affected browser state; they never retarget an existing Gateway request.
|
||||
|
||||
Publication requires `operator.write` and current access to change the session. Connecting your account alone does not grant either permission.
|
||||
Shared publication also supports `operator.sessions.write` for the session creator. When the Gateway identifies a supported GitHub target in an owned session's managed worktree or repository workspace, session-only callers can use **Publish PR** without access to the broader PR list. A plain conversation or project folder alone does not make publication available. The Gateway rechecks the workspace, unpublished work, current access, and workflow restrictions before publishing. Shared results remain visible after refresh or reconnect.
|
||||
|
||||
Personal publication and confirmation require `operator.write` and current access to change the session. Connecting your account alone does not grant either permission.
|
||||
|
||||
Personal GitHub is a Gateway-brokered publication connection, not a session-wide shell identity. Ordinary agent `git`/`gh` commands, model-initiated publication, and repository previews and discovery keep their existing credential behavior. OpenClaw cloud workers use the shared execution identity, never your personal connection. For a repository-only session, finish the current turn and wait for its accepted Git-normalized checkpoint. Personal publication is available while the worker is idle or after Stop, without a Gateway checkout. Remote sessions sourced from a Gateway worktree still require **Stop cloud worker…** before personal publication. See [`tools.github`](/gateway/config-tools#tools-github) for shared agent execution.
|
||||
|
||||
|
|
|
|||
217
ui/src/e2e/chat-github-publication-guest.e2e.test.ts
Normal file
217
ui/src/e2e/chat-github-publication-guest.e2e.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "playwright";
|
||||
import { expect, it } from "vitest";
|
||||
import type {
|
||||
SessionGitHubPublicationResult,
|
||||
SessionGitHubStatusResult,
|
||||
} from "../../../packages/gateway-protocol/src/schema/session-github-publication.ts";
|
||||
import { SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD } from "../lib/session-pull-requests.ts";
|
||||
import type { GitHubPublicationOptions } from "../lib/sessions/github-publication-controller.ts";
|
||||
import { takeControlUiViewportScreenshot } from "../test-helpers/control-ui-e2e-screenshot.ts";
|
||||
import {
|
||||
controlUiSessionUrl,
|
||||
installMockGateway,
|
||||
reconnectMockGateway,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createControlUiSessionRow } from "../test-helpers/control-ui-session-fixtures.ts";
|
||||
import { publicationMethods } from "./chat-github-publication.test-support.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({ name: "Control UI guest shared GitHub publication" });
|
||||
const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
|
||||
const viewport = { width: 1180, height: 800 };
|
||||
const sessionKey = "agent:main:guest-publication";
|
||||
const publisher = { source: "system-configured", accountId: 1, login: "roboclaw-bot" } as const;
|
||||
const options = {
|
||||
personal: null,
|
||||
shared: publisher,
|
||||
pendingPersonal: null,
|
||||
latestShared: null,
|
||||
} satisfies GitHubPublicationOptions;
|
||||
const historyText = "The visitor's change is ready for publication.";
|
||||
|
||||
function contextOptions(record = false): Parameters<typeof suite.newBrowserContext>[0] {
|
||||
return {
|
||||
colorScheme: "light",
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport,
|
||||
...(record && captureUiProof
|
||||
? { recordVideo: { dir: suite.artifactDir, size: viewport } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function installGuestGateway(
|
||||
page: Page,
|
||||
hasWorkspace = true,
|
||||
shared: GitHubPublicationOptions["shared"] = publisher,
|
||||
) {
|
||||
return await installMockGateway(page, {
|
||||
assistantName: "Publication QA",
|
||||
workspace: "/synthetic/visitor-publication",
|
||||
communityInvite: false,
|
||||
operatorScopes: ["operator.sessions.write"],
|
||||
// Guests do not receive the broad PR watcher; publication must work without its branch event.
|
||||
featureMethods: publicationMethods.filter(
|
||||
(method) => method !== SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD,
|
||||
),
|
||||
sessionKey,
|
||||
mainSessionKey: "agent:main:main",
|
||||
sessions: [
|
||||
createControlUiSessionRow(sessionKey, "Visitor change", 1, {
|
||||
visibility: "shared",
|
||||
sharingRole: "owner",
|
||||
...(hasWorkspace
|
||||
? {
|
||||
worktree: {
|
||||
id: "visitor-worktree",
|
||||
branch: "visitor/documentation",
|
||||
repoRoot: "/synthetic/visitor-demo",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
],
|
||||
presenceUsers: [
|
||||
{
|
||||
self: true,
|
||||
id: "synthetic-visitor",
|
||||
identity: { type: "profile", id: "synthetic-visitor" },
|
||||
name: "Visitor",
|
||||
},
|
||||
],
|
||||
historyMessages: [
|
||||
{ role: "user", content: [{ type: "text", text: "Prepare a small documentation change." }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: historyText }] },
|
||||
],
|
||||
methodResponses: { "sessions.github.options": { ...options, shared } },
|
||||
});
|
||||
}
|
||||
|
||||
async function screenshot(page: Page, filename: string) {
|
||||
if (captureUiProof) {
|
||||
await writeFile(
|
||||
path.join(suite.artifactDir, filename),
|
||||
await takeControlUiViewportScreenshot(page, page.locator(".shell"), [
|
||||
page.getByText(historyText, { exact: true }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectNoPersonalActions(page: Page) {
|
||||
expect(await page.getByRole("button", { name: "Publication account", exact: true }).count()).toBe(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
await page.getByRole("button", { name: "Confirm original publication", exact: true }).count(),
|
||||
).toBe(0);
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it("publishes an owned guest session through the shared account and recovers its receipt", async () => {
|
||||
await suite.withPage(contextOptions(true), async ({ page }) => {
|
||||
const gateway = await installGuestGateway(page);
|
||||
const requestId = "8c698e8a-bdc7-4927-a0f2-73a842c2d7b7";
|
||||
const requested = {
|
||||
requestId,
|
||||
status: "requested",
|
||||
publisher,
|
||||
message: "The shared publisher is preparing the pull request.",
|
||||
} satisfies SessionGitHubPublicationResult;
|
||||
const receipt = {
|
||||
result: {
|
||||
requestId,
|
||||
status: "published",
|
||||
publisher,
|
||||
url: "https://github.com/synthetic/visitor-demo/pull/42",
|
||||
repository: "synthetic/visitor-demo",
|
||||
branch: "visitor/documentation",
|
||||
headCommit: "a".repeat(40),
|
||||
},
|
||||
confirmation: null,
|
||||
} satisfies SessionGitHubStatusResult;
|
||||
await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey));
|
||||
await page.getByText(historyText, { exact: true }).waitFor();
|
||||
const discovered = await gateway.waitForRequest("sessions.github.options");
|
||||
expect(discovered.params).toEqual({ sessionKey, agentId: "main" });
|
||||
const publish = page.getByRole("button", { name: "Publish PR", exact: true });
|
||||
try {
|
||||
await expect.poll(() => publish.count()).toBe(1);
|
||||
await expect.poll(() => publish.isEnabled()).toBe(true);
|
||||
} finally {
|
||||
// This same capture records the missing action when run on the pre-fix source.
|
||||
await screenshot(page, "01-guest-ready.png");
|
||||
}
|
||||
await expectNoPersonalActions(page);
|
||||
expect(await gateway.getRequests("sessions.github.publish")).toHaveLength(0);
|
||||
await gateway.deferNext("sessions.github.publish");
|
||||
await publish.click();
|
||||
const publication = await gateway.waitForRequest("sessions.github.publish");
|
||||
expect(publication.params).toEqual({
|
||||
sessionKey,
|
||||
agentId: "main",
|
||||
idempotencyKey: expect.any(String),
|
||||
selection: { source: "shared", expected: publisher },
|
||||
});
|
||||
await gateway.resolveDeferred("sessions.github.publish", requested);
|
||||
await page.getByText(requested.message, { exact: true }).waitFor();
|
||||
await screenshot(page, "02-guest-requested.png");
|
||||
await gateway.setMethodResponse("sessions.github.status", receipt);
|
||||
await gateway.setMethodResponse("sessions.github.options", {
|
||||
...options,
|
||||
latestShared: receipt,
|
||||
});
|
||||
await page.getByRole("button", { name: "Refresh publication", exact: true }).click();
|
||||
const status = await gateway.waitForRequest("sessions.github.status");
|
||||
expect(status.params).toEqual({ sessionKey, agentId: "main", requestId });
|
||||
const openPr = page.getByRole("link", { name: "Open PR", exact: true });
|
||||
await expect.poll(() => openPr.getAttribute("href")).toBe(receipt.result.url);
|
||||
await screenshot(page, "03-guest-published.png");
|
||||
|
||||
const previousOptions = (await gateway.getRequests("sessions.github.options")).length;
|
||||
await reconnectMockGateway(page, gateway);
|
||||
await gateway.waitForRequest("sessions.github.options", { after: previousOptions });
|
||||
await expect.poll(() => openPr.getAttribute("href")).toBe(receipt.result.url);
|
||||
await expectNoPersonalActions(page);
|
||||
expect(await gateway.getRequests("sessions.github.publish")).toHaveLength(1);
|
||||
expect(await gateway.getRequests("sessions.github.confirm")).toHaveLength(0);
|
||||
expect(await gateway.getRequests(SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD)).toHaveLength(0);
|
||||
await page.reload();
|
||||
await gateway.waitForRequest("sessions.github.options");
|
||||
await expect.poll(() => openPr.getAttribute("href")).toBe(receipt.result.url);
|
||||
await screenshot(page, "04-guest-recovered.png");
|
||||
expect(await gateway.getRequests("sessions.github.publish")).toHaveLength(0);
|
||||
expect(await gateway.getRequests("sessions.github.confirm")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"waits for an available publication target (managed workspace: %s)",
|
||||
async (hasWorkspace) => {
|
||||
await suite.withPage(contextOptions(), async ({ page }) => {
|
||||
const gateway = await installGuestGateway(page, hasWorkspace, null);
|
||||
await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey));
|
||||
await page.getByText(historyText, { exact: true }).waitFor();
|
||||
await gateway.waitForRequest("sessions.github.options");
|
||||
expect(await page.getByRole("button", { name: "Publish PR", exact: true }).count()).toBe(0);
|
||||
await expectNoPersonalActions(page);
|
||||
expect(await gateway.getRequests("sessions.github.publish")).toHaveLength(0);
|
||||
expect(await gateway.getRequests("sessions.github.confirm")).toHaveLength(0);
|
||||
if (hasWorkspace) {
|
||||
// The Gateway has qualified the supported worktree rebind before rediscovery.
|
||||
await gateway.setMethodResponse("sessions.github.options", options);
|
||||
const previousOptions = (await gateway.getRequests("sessions.github.options")).length;
|
||||
await reconnectMockGateway(page, gateway);
|
||||
await gateway.waitForRequest("sessions.github.options", { after: previousOptions });
|
||||
await expect
|
||||
.poll(() => page.getByRole("button", { name: "Publish PR", exact: true }).count())
|
||||
.toBe(1);
|
||||
expect(await gateway.getRequests("sessions.github.publish")).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -14,7 +14,8 @@ import { generateUUID } from "../uuid.ts";
|
|||
|
||||
export type GitHubPublicationOptions = Static<typeof SessionGitHubOptionsResultSchema>;
|
||||
type GitHubPublicationPresentation = {
|
||||
canWrite: boolean;
|
||||
canPublishShared: boolean;
|
||||
canPublishPersonal: boolean;
|
||||
personalReady: boolean;
|
||||
isPresented: () => boolean;
|
||||
isCurrent: () => boolean;
|
||||
|
|
@ -42,7 +43,8 @@ type GitHubPublicationActivity = "read" | "publish" | "confirm";
|
|||
|
||||
export type GitHubPublicationView = {
|
||||
activity: GitHubPublicationActivity | null;
|
||||
canWrite: boolean;
|
||||
canPublishShared: boolean;
|
||||
canPublishPersonal: boolean;
|
||||
locked: boolean;
|
||||
options: GitHubPublicationOptions | null;
|
||||
selection: GitHubPublicationSelection | null;
|
||||
|
|
@ -220,6 +222,13 @@ export class GitHubPublicationController {
|
|||
return this.attempt !== null || (this.result !== null && !terminal(this.result));
|
||||
}
|
||||
|
||||
private canPublish(presentation: Presentation, source: "shared" | "personal"): boolean {
|
||||
return source === "personal"
|
||||
? presentation.scope?.canPublishPersonal === true
|
||||
: presentation.scope?.canPublishShared === true &&
|
||||
(this.attempt?.selection.source === "shared" || Boolean(this.options?.shared));
|
||||
}
|
||||
|
||||
private choose(presentation: Presentation, source: "shared" | "personal"): void {
|
||||
const options = this.options;
|
||||
if (
|
||||
|
|
@ -227,7 +236,7 @@ export class GitHubPublicationController {
|
|||
this.locked ||
|
||||
this.busy ||
|
||||
!this.presented(presentation) ||
|
||||
!presentation.scope?.canWrite
|
||||
!this.canPublish(presentation, source)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -383,10 +392,10 @@ export class GitHubPublicationController {
|
|||
}
|
||||
const selection = this.attempt?.selection ?? this.selection;
|
||||
if (
|
||||
!presentation.scope?.canWrite ||
|
||||
!selection ||
|
||||
!this.canPublish(presentation, selection.source) ||
|
||||
terminal(this.result) ||
|
||||
(selection.source === "personal" && !presentation.scope.personalReady) ||
|
||||
(selection.source === "personal" && !presentation.scope?.personalReady) ||
|
||||
(this.locked && !this.attempt)
|
||||
) {
|
||||
return;
|
||||
|
|
@ -433,7 +442,7 @@ export class GitHubPublicationController {
|
|||
if (
|
||||
!confirmation ||
|
||||
!requestId ||
|
||||
!presentation.scope?.canWrite ||
|
||||
!presentation.scope?.canPublishPersonal ||
|
||||
!presentation.scope.personalReady
|
||||
) {
|
||||
return;
|
||||
|
|
@ -464,6 +473,11 @@ export class GitHubPublicationController {
|
|||
return undefined;
|
||||
}
|
||||
const version = this.version;
|
||||
const selection = this.attempt?.selection ?? this.selection;
|
||||
const canPublish = this.canPublish(
|
||||
presentation,
|
||||
selection?.source ?? (this.options?.shared ? "shared" : "personal"),
|
||||
);
|
||||
// Each callback belongs to the displayed operation state, not whichever
|
||||
// publication or confirmation happens to occupy this session later.
|
||||
const invoke = (action: () => void) => {
|
||||
|
|
@ -473,24 +487,25 @@ export class GitHubPublicationController {
|
|||
};
|
||||
return {
|
||||
activity: this.activity,
|
||||
canWrite: scope.canWrite,
|
||||
canPublishShared: scope.canPublishShared,
|
||||
canPublishPersonal: scope.canPublishPersonal,
|
||||
locked: this.locked,
|
||||
options: this.options,
|
||||
selection: this.attempt?.selection ?? this.selection,
|
||||
selection,
|
||||
result: this.result,
|
||||
confirmation: this.confirmation,
|
||||
error: this.error,
|
||||
personalReady: scope.personalReady,
|
||||
onSelect:
|
||||
scope.canWrite && !this.result && !this.locked
|
||||
scope.canPublishPersonal && !this.result && !this.locked
|
||||
? (source) => invoke(() => this.choose(presentation, source))
|
||||
: undefined,
|
||||
onPublish:
|
||||
scope.canWrite && (!this.locked || this.attempt !== null) && !terminal(this.result)
|
||||
canPublish && (!this.locked || this.attempt !== null) && !terminal(this.result)
|
||||
? () => invoke(() => void this.publish(presentation))
|
||||
: undefined,
|
||||
onConfirm:
|
||||
scope.canWrite && this.confirmation
|
||||
scope.canPublishPersonal && this.confirmation
|
||||
? () => invoke(() => void this.confirm(presentation))
|
||||
: undefined,
|
||||
onRefresh: () => invoke(() => void this.refresh(presentation)),
|
||||
|
|
|
|||
|
|
@ -190,7 +190,8 @@ function publicationHarness() {
|
|||
)!;
|
||||
publicationChanges.set(binding, () => changed.promise);
|
||||
binding.sync({
|
||||
canWrite: true,
|
||||
canPublishShared: true,
|
||||
canPublishPersonal: true,
|
||||
personalReady: true,
|
||||
isPresented: () => true,
|
||||
isCurrent: () => binding.matches(session),
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ export function setup(initialOptions = options) {
|
|||
client: { request },
|
||||
key: "gateway:alice:session:1",
|
||||
target: { sessionKey: "agent:main:one", agentId: "main" },
|
||||
canWrite: true,
|
||||
canPublishShared: true,
|
||||
canPublishPersonal: true,
|
||||
personalReady: true,
|
||||
isPresented: () => true,
|
||||
isCurrent: () => true,
|
||||
|
|
|
|||
|
|
@ -458,10 +458,40 @@ describe("explicit GitHub publication", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("does not confirm a personal receipt with shared-only publication access", async () => {
|
||||
const { controller, request, scope } = setup({ ...options, pendingPersonal: interrupted });
|
||||
const staff = await settled(controller);
|
||||
expect(staff.onConfirm).toBeTypeOf("function");
|
||||
controller.sync({ ...scope, canPublishPersonal: false });
|
||||
expect(controller.view()?.onConfirm).toBeUndefined();
|
||||
staff.onConfirm?.();
|
||||
expect(request.mock.calls.some(([method]) => method === "sessions.github.confirm")).toBe(false);
|
||||
});
|
||||
|
||||
it("retires a new shared publication action when refreshed options withdraw the target", async () => {
|
||||
const { controller, request, scope } = setup();
|
||||
await settled(controller);
|
||||
controller.sync({ ...scope, canPublishPersonal: false });
|
||||
const response = createDeferred<GitHubPublicationOptions>();
|
||||
request.mockReturnValueOnce(response.promise);
|
||||
controller.view()?.onRefresh();
|
||||
const refreshing = controller.view()!;
|
||||
expect(refreshing.onPublish).toBeTypeOf("function");
|
||||
response.resolve({ ...options, shared: null, personal: null });
|
||||
expect((await settled(controller)).onPublish).toBeUndefined();
|
||||
refreshing.onPublish?.();
|
||||
expect(request.mock.calls.some(([method]) => method === "sessions.github.publish")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps readers nonmutating and personal publication unavailable on busy or remote workspaces", async () => {
|
||||
const { controller, request, scope } = setup();
|
||||
await settled(controller);
|
||||
controller.sync({ ...scope, key: "reader", canWrite: false });
|
||||
controller.sync({
|
||||
...scope,
|
||||
key: "reader",
|
||||
canPublishShared: false,
|
||||
canPublishPersonal: false,
|
||||
});
|
||||
const reader = await settled(controller);
|
||||
expect(reader.onSelect).toBeUndefined();
|
||||
expect(reader.onPublish).toBeUndefined();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD,
|
||||
sessionPullRequestsForGateway,
|
||||
} from "../../lib/session-pull-requests.ts";
|
||||
import type { GitHubPublicationOptions } from "../../lib/sessions/github-publication-controller.ts";
|
||||
import { createSessionCapability, type SessionCapability } from "../../lib/sessions/index.ts";
|
||||
import { gatewayHelloForMethods } from "../../test-helpers/gateway-methods.ts";
|
||||
import { resetChatHistoryProjection } from "./chat-history-state.ts";
|
||||
|
|
@ -81,15 +82,29 @@ function emitSnapshot(
|
|||
});
|
||||
}
|
||||
|
||||
function createPublicationPane(scope?: "global" | "per-sender") {
|
||||
function createPublicationPane(
|
||||
scope?: "global" | "per-sender",
|
||||
operatorScopes = ["operator.read", "operator.write"],
|
||||
) {
|
||||
const agentId = scope ? "research" : "main";
|
||||
const sessionKey = scope ? "global" : "agent:main:publication";
|
||||
const shared = { source: "system-configured", accountId: 1, login: "system-bot" };
|
||||
const shared: NonNullable<GitHubPublicationOptions["shared"]> = {
|
||||
source: "system-configured",
|
||||
accountId: 1,
|
||||
login: "system-bot",
|
||||
};
|
||||
const account = { accountId: 2, login: "alice-tools" };
|
||||
const generation = "bdca439a-e787-4f9f-b5f3-a878c662cc76";
|
||||
const options = {
|
||||
const options: GitHubPublicationOptions = {
|
||||
shared,
|
||||
personal: { state: "connected", generation, account },
|
||||
personal: {
|
||||
state: "connected",
|
||||
generation,
|
||||
account,
|
||||
accessExpiresAtMs: null,
|
||||
refreshState: "available",
|
||||
pending: null,
|
||||
},
|
||||
pendingPersonal: null,
|
||||
latestShared: null,
|
||||
};
|
||||
|
|
@ -113,7 +128,7 @@ function createPublicationPane(scope?: "global" | "per-sender") {
|
|||
const eventListeners = new Set<GatewayEventListener>();
|
||||
const hello = gatewayHelloForMethods(
|
||||
["sessions.github.publish", SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD, "projects.list"],
|
||||
["operator.read", "operator.write"],
|
||||
operatorScopes,
|
||||
);
|
||||
if (scope) {
|
||||
hello.snapshot = {
|
||||
|
|
@ -179,6 +194,11 @@ function createPublicationPane(scope?: "global" | "per-sender") {
|
|||
{
|
||||
key: sessionKey,
|
||||
sessionId: "publication",
|
||||
worktree: {
|
||||
id: "worktree-publication",
|
||||
branch: "feature/publication",
|
||||
repoRoot: "/synthetic/repository",
|
||||
},
|
||||
kind: scope ? "global" : "direct",
|
||||
updatedAt: 1,
|
||||
},
|
||||
|
|
@ -426,12 +446,12 @@ describe("chat pane pushed pull request state", () => {
|
|||
it.each(["shared", "personal"] as const)(
|
||||
"retains an unknown %s publication across a retained-pane navigation",
|
||||
async (source) => {
|
||||
const { pane, state, request, shared, account, generation, settled } =
|
||||
const { pane, state, request, options, shared, account, generation, settled } =
|
||||
createPublicationPane();
|
||||
(await settled()).onSelect?.(source);
|
||||
pane.render();
|
||||
pane.chatProps!.githubPublication!.onPublish?.();
|
||||
const unknown = await settled();
|
||||
let unknown = await settled();
|
||||
expect(unknown.locked).toBe(true);
|
||||
const first = request.mock.calls.find(([method]) => method === "sessions.github.publish");
|
||||
expect(first?.[1]).toEqual({
|
||||
|
|
@ -441,6 +461,12 @@ describe("chat pane pushed pull request state", () => {
|
|||
selection:
|
||||
source === "shared" ? { source, expected: shared } : { source, generation, account },
|
||||
});
|
||||
if (source === "shared") {
|
||||
options.shared = null;
|
||||
unknown.onRefresh();
|
||||
unknown = await settled();
|
||||
expect(unknown.onPublish).toBeTypeOf("function");
|
||||
}
|
||||
|
||||
pane.presented = false;
|
||||
pane.render();
|
||||
|
|
@ -666,6 +692,76 @@ describe("chat pane pushed pull request state", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each(["worktree", "repository"] as const)(
|
||||
"derives guest shared publication for an owned %s workspace",
|
||||
async (workspace) => {
|
||||
const { pane, state, context, request, shared, settled } = createPublicationPane(undefined, [
|
||||
"operator.sessions.write",
|
||||
]);
|
||||
const row = state.sessionsResult!.sessions[0]!;
|
||||
row.sharingRole = "owner";
|
||||
if (workspace === "repository") {
|
||||
delete row.worktree;
|
||||
row.repositoryWorkspaceId = "repository-publication";
|
||||
row.repository = {
|
||||
url: "https://github.com/synthetic/visitor-demo",
|
||||
branch: "feature/publication",
|
||||
};
|
||||
}
|
||||
const guest = await settled();
|
||||
expect(guest.onPublish).toBeTypeOf("function");
|
||||
expect(guest.onSelect).toBeUndefined();
|
||||
expect(guest.onConfirm).toBeUndefined();
|
||||
guest.onPublish?.();
|
||||
await settled();
|
||||
expect(request).toHaveBeenLastCalledWith("sessions.github.publish", {
|
||||
sessionKey: state.sessionKey,
|
||||
agentId: "main",
|
||||
idempotencyKey: expect.any(String),
|
||||
selection: { source: "shared", expected: shared },
|
||||
});
|
||||
const previous = pane.chatProps!.githubPublication!;
|
||||
context.gateway.snapshot.hello = gatewayHelloForMethods(
|
||||
["sessions.github.publish", "sessions.github.options"],
|
||||
["operator.sessions.read"],
|
||||
);
|
||||
previous.onPublish?.();
|
||||
expect(
|
||||
request.mock.calls.filter(([method]) => method === "sessions.github.publish"),
|
||||
).toHaveLength(1);
|
||||
expect((await settled()).onPublish).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not fall back to a personal account for a session-only publisher", async () => {
|
||||
const { state, options, request, settled } = createPublicationPane(undefined, [
|
||||
"operator.sessions.write",
|
||||
]);
|
||||
state.sessionsResult!.sessions[0]!.sharingRole = "owner";
|
||||
options.shared = null;
|
||||
const view = await settled();
|
||||
expect(view.onPublish).toBeUndefined();
|
||||
expect(view.onSelect).toBeUndefined();
|
||||
expect(request.mock.calls.some(([method]) => method === "sessions.github.publish")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["viewer", "member", "unknown", "archived"] as const)(
|
||||
"keeps narrow publication nonmutating for a %s session",
|
||||
async (access) => {
|
||||
const { state, request, settled } = createPublicationPane(undefined, [
|
||||
"operator.sessions.write",
|
||||
]);
|
||||
const row = state.sessionsResult!.sessions[0]!;
|
||||
row.sharingRole = access === "archived" ? "owner" : access === "unknown" ? undefined : access;
|
||||
row.archived = access === "archived";
|
||||
const view = await settled();
|
||||
expect(view.onPublish).toBeUndefined();
|
||||
expect(view.onSelect).toBeUndefined();
|
||||
expect(view.onConfirm).toBeUndefined();
|
||||
expect(request.mock.calls.every(([method]) => method !== "sessions.github.publish")).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["incarnation", "sharing", "archive-projection"] as const)(
|
||||
"rejects a stale idle publication before render: %s",
|
||||
async (change) => {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
createChatPaneSessionActionCallbacks,
|
||||
readChatPaneComposerAccess,
|
||||
readChatPaneMutationAccess,
|
||||
readChatPublicationAccess,
|
||||
renderChatPaneComposerControls,
|
||||
} from "./chat-pane-session-controls.ts";
|
||||
import {
|
||||
|
|
@ -299,7 +300,7 @@ export class ChatPane extends ChatPaneLayoutRender {
|
|||
}
|
||||
const publication = this.githubPublication;
|
||||
publication?.sync({
|
||||
canWrite: !selectedSessionArchived && !sessionParticipationBlocked && hasWriteScope,
|
||||
...readChatPublicationAccess(gatewaySnapshot, publicationRow, sessionParticipationBlocked),
|
||||
personalReady:
|
||||
!hasAbortableSessionRun(state) &&
|
||||
(!isCloudWorkerPlacementState(placement?.state) ||
|
||||
|
|
|
|||
|
|
@ -111,6 +111,24 @@ export function readChatPaneMutationAccess(
|
|||
};
|
||||
}
|
||||
|
||||
export function readChatPublicationAccess(
|
||||
snapshot: ApplicationGatewaySnapshot,
|
||||
session: GatewaySessionRow,
|
||||
participationBlocked: boolean,
|
||||
) {
|
||||
const canMutate = !session.archived && !participationBlocked;
|
||||
return {
|
||||
canPublishShared:
|
||||
canMutate &&
|
||||
readSessionMethodAccess(snapshot, {
|
||||
method: "sessions.github.publish",
|
||||
requiredScope: "operator.sessions.write",
|
||||
session,
|
||||
}).allowed,
|
||||
canPublishPersonal: canMutate && hasOperatorWriteAccess(snapshot.hello?.auth ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderChatPaneComposerControls(params: {
|
||||
state: ChatPageHost;
|
||||
selectedSession: GatewaySessionRow | undefined;
|
||||
|
|
|
|||
|
|
@ -115,7 +115,11 @@ function renderPublicationButton(publication: GitHubPublicationView) {
|
|||
if (result?.status === "failed") {
|
||||
action = {
|
||||
click: publication.onNewAction,
|
||||
label: t(publication.canWrite ? "githubPublication.newAction" : "common.dismiss"),
|
||||
label: t(
|
||||
publication.canPublishShared || publication.canPublishPersonal
|
||||
? "githubPublication.newAction"
|
||||
: "common.dismiss",
|
||||
),
|
||||
disabled: busy,
|
||||
};
|
||||
} else if (result?.status === "needs_confirmation") {
|
||||
|
|
@ -190,7 +194,7 @@ export function renderGitHubPublicationDetails(publication: GitHubPublicationVie
|
|||
!result &&
|
||||
!locked &&
|
||||
!busy &&
|
||||
publication.canWrite;
|
||||
(publication.canPublishShared || publication.canPublishPersonal);
|
||||
if (!result && !confirmation && !error && !locked && !personalUnavailable && !noAccount) {
|
||||
return nothing;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import type { GitHubPublicationView } from "../../../lib/sessions/github-publica
|
|||
export function publication(overrides: Partial<GitHubPublicationView> = {}): GitHubPublicationView {
|
||||
return {
|
||||
activity: null,
|
||||
canWrite: true,
|
||||
canPublishShared: true,
|
||||
canPublishPersonal: true,
|
||||
locked: false,
|
||||
options: null,
|
||||
selection: {
|
||||
|
|
|
|||
|
|
@ -309,9 +309,12 @@ export function renderChatPullRequests(props: {
|
|||
const { publication } = props;
|
||||
const published = publication?.result?.status === "published" ? publication.result : undefined;
|
||||
const retainedPublication = publication?.result || publication?.locked || publication?.error;
|
||||
// Session-only publishers cannot read the broader PR subscription's branch facts.
|
||||
const sharedAction =
|
||||
publication?.canPublishShared && !publication.canPublishPersonal && publication.options?.shared;
|
||||
// Gateway branch facts describe unpublished work, including changes after a merge.
|
||||
// PR metadata takes precedence over retained publication history.
|
||||
if (props.branch || (props.pullRequests.length === 0 && retainedPublication)) {
|
||||
if (props.branch || (props.pullRequests.length === 0 && (retainedPublication || sharedAction))) {
|
||||
return html`<div class="chat-prs" aria-live="polite">
|
||||
${renderWorkRow(props.branch, props.status, props.onOpenSessionDiff, publication)}
|
||||
</div>`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue