Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a8749d7
feat(pr): add GitHub maintainer edit control
Bil0000 Oct 26, 2026
1ea7738
refactor(pr): move maintainer edits to menus
Bil0000 Oct 27, 2026
05ea166
fix(pr): hide maintainer edits on closed requests
Bil0000 Oct 27, 2026
ff02566
fix(mobile): use maintainer edit permission
Bil0000 Oct 27, 2026
d65b597
fix(pr): preserve pending maintainer edits on refresh
Bil0000 Oct 27, 2026
718327b
test(pull-requests): takeover tests for maintainer edits
juliusmarminge Oct 28, 2026
8ed48fd
refactor(pull-requests): maintainer edits follow the existing action …
juliusmarminge Oct 28, 2026
05d1a23
fix(pull-requests): a saved maintainer-edits choice no longer masks a…
juliusmarminge Oct 28, 2026
4e848d5
Merge remote-tracking branch 'origin/main' into pr-25588
juliusmarminge Oct 28, 2026
c7eaa64
fix(pull-requests): a failed maintainer-edits save restores the previ…
juliusmarminge Oct 28, 2026
375cc4c
Merge remote-tracking branch 'origin/main' into pr-25588
juliusmarminge Oct 28, 2026
03ee95a
refactor(pull-requests): one pending-choice rule for maintainer edits…
juliusmarminge Oct 28, 2026
238611f
Merge remote-tracking branch 'origin/main' into pr-25588
juliusmarminge Oct 28, 2026
affe7e2
test(pull-requests): cut the maintainer-edit takeover's tests to the …
juliusmarminge Oct 28, 2026
b4aaaee
fix(pull-requests): a saved maintainer-edits choice yields to the hos…
juliusmarminge Oct 28, 2026
651dacf
Merge remote-tracking branch 'origin/main' into t3/maintainer-edit-ac…
Bil0000 Oct 28, 2026
cf247b9
fix(web): align maintainer edits with PR menu actions
Bil0000 Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/mobile/src/components/ui/AndroidScreenHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface AndroidHeaderAction {
readonly disabled?: boolean;
readonly loading?: boolean;
readonly selected?: boolean;
readonly subtitle?: string;
readonly destructive?: boolean;
readonly overflowOnly?: boolean;
}
Expand Down Expand Up @@ -178,10 +179,11 @@ export function AndroidScreenHeader(props: {
actions={overflowActions.map((action, index) => ({
id: String(index),
title: action.accessibilityLabel,
subtitle: action.subtitle,
state: action.selected ? "on" : undefined,
attributes: {
disabled: Boolean(action.disabled),
destructive: action.destructive,
state: action.selected ? "on" : undefined,
},
}))}
onPressAction={({ nativeEvent }) =>
Expand Down
26 changes: 26 additions & 0 deletions apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ import {
PullRequestReadStatus,
} from "./PullRequestPresentation";
import { usePullRequestFilesSection } from "./PullRequestFiles";
import { usePullRequestMaintainerEdits } from "./usePullRequestMaintainerEdits";
import { PullRequestDescription } from "./PullRequestDescription";
import { PullRequestMediaVisibleContext } from "./PullRequestMedia";
import { PullRequestMarkdownRepository } from "./PullRequestMarkdown";
Expand Down Expand Up @@ -415,6 +416,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
const terminology = sourceControlClients.get(identity?.provider).changeRequest;
const permissions = usePullRequestPermissions(params, pr);
const action = usePullRequestAction(params, pr);
const maintainerEdits = usePullRequestMaintainerEdits(params, pr);
const showsDeleteBranch = pr !== null && canPerformPullRequestAction(pr, "delete-branch");
const confirmDeleteBranch = () =>
pr &&
Expand Down Expand Up @@ -516,6 +518,19 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
},
]
: []),
...(maintainerEdits.visible
? [
{
accessibilityLabel: "Allow edits from maintainers",
icon: "pencil" as const,
selected: maintainerEdits.value,
disabled: maintainerEdits.disabled,
subtitle: maintainerEdits.subtitle,
overflowOnly: true,
onPress: maintainerEdits.toggle,
},
]
: []),
...(identity
? [
{
Expand Down Expand Up @@ -596,6 +611,17 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
<Toolbar.Label>Edit description</Toolbar.Label>
</Toolbar.MenuAction>
) : null}
{maintainerEdits.visible ? (
<Toolbar.MenuAction
icon="pencil"
isOn={maintainerEdits.value}
disabled={maintainerEdits.disabled}
subtitle={maintainerEdits.subtitle}
onPress={maintainerEdits.toggle}
>
<Toolbar.Label>Allow edits from maintainers</Toolbar.Label>
</Toolbar.MenuAction>
) : null}
{identity ? (
<Toolbar.MenuAction
icon="arrow.up.right"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { RegistryContext } from "@effect/atom-react";
import { canEditPullRequestChangeRequest } from "@t3tools/client-runtime/pull-request-editing";
import {
EnvironmentId,
ProjectId,
SourceControlProviderKind,
type PullRequestDetail,
} from "@t3tools/contracts";
import { Atom, AtomRegistry } from "effect/reactivity";
import { act, useLayoutEffect } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vite-plus/test";

const mocks = vi.hoisted(() => ({
connected: true,
update: vi.fn(),
permission: undefined as unknown as Atom.Writable<boolean>,
detail: undefined as unknown as Atom.Writable<null>,
}));
vi.mock("../../state/pull-requests", () => {
const command = { permissionAtom: () => mocks.permission };
return {
composerPullRequests: {
runAction: command,
comment: command,
submitReview: command,
replyToThread: command,
setThreadResolution: command,
update: command,
updateComment: command,
setReaction: command,
detail: () => mocks.detail,
},
};
});
vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.update }));
vi.mock("../../components/ui/Dialog", () => ({ alert: vi.fn() }));
vi.mock("../../state/workspace", () => ({
useWorkspaceEnvironments: () => [
{
environmentId: "environment",
connectionState: mocks.connected ? "connected" : "offline",
},
],
}));

import { usePullRequestMaintainerEdits } from "./usePullRequestMaintainerEdits";

function setup() {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
mocks.connected = true;
mocks.update.mockReset().mockResolvedValue({ _tag: "Success" });
mocks.permission = Atom.make(true);
mocks.detail = Atom.make(null);
const registry = AtomRegistry.make();
let detail: PullRequestDetail = {
provider: SourceControlProviderKind.make("github"),
projectId: ProjectId.make("project"),
projectTitle: "Project",
workspaceRoot: "/repo",
repository: "owner/repo",
number: 7,
title: "A change",
body: "",
url: "https://github.com/owner/repo/pull/7",
author: { login: "author", name: null, avatarUrl: null },
state: "open",
isDraft: false,
mergeability: "unknown",
additions: 0,
deletions: 0,
changedFiles: 0,
headBranch: "feature",
baseBranch: "main",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
mergedAt: null,
closedAt: null,
reviewers: [],
labels: [],
checks: [],
mergeCapabilities: { merge: false, squash: false, rebase: false },
capabilities: {
diff: true,
comment: true,
search: true,
actions: [],
mergeMethods: [],
review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
reviewers: { request: false, listCandidates: false },
edit: { changeRequest: true, comment: true, maintainerEdits: true },
},
viewerPermissions: {
actions: [],
comment: true,
resolve: false,
verdicts: [],
requestReviewers: false,
maintainerEdits: true,
},
maintainerCanModify: false,
};
const root = createRoot(document.createElement("div"));
let current!: ReturnType<typeof usePullRequestMaintainerEdits>;
function Probe() {
const result = usePullRequestMaintainerEdits(
{
environmentId: "environment",
projectId: "project",
repository: "owner/repo",
number: 7,
},
detail,
);
useLayoutEffect(() => {
current = result;
});
return null;
}
const render = () =>
act(() =>
root.render(
<RegistryContext.Provider value={registry}>
<Probe />
</RegistryContext.Provider>,
),
);
render();
return {
get detail() {
return detail;
},
refresh(enabled: boolean) {
detail = { ...detail, maintainerCanModify: enabled };
render();
},
render,
registry,
get current() {
return current;
},
[Symbol.dispose]() {
act(() => root.unmount());
registry.dispose();
},
};
}

it("saves an explicit maintainer grant without a viewer login, while honoring local access", async () => {
using screen = setup();
expect(canEditPullRequestChangeRequest(screen.detail)).toBe(false);
expect(screen.current.visible).toBe(true);
expect(screen.current.disabled).toBe(false);
await act(async () => screen.current.toggle());
expect(screen.current.value).toBe(true);
expect(mocks.update).toHaveBeenCalledExactlyOnceWith({
environmentId: EnvironmentId.make("environment"),
input: {
projectId: ProjectId.make("project"),
repository: "owner/repo",
number: 7,
maintainerCanModify: true,
},
});
act(() => screen.registry.set(mocks.permission, false));
expect(screen.current.disabled).toBe(true);
await act(async () => screen.current.toggle());
act(() => screen.registry.set(mocks.permission, true));
mocks.connected = false;
screen.render();
expect(screen.current.disabled).toBe(true);
await act(async () => screen.current.toggle());
expect(mocks.update).toHaveBeenCalledTimes(1);
});

it("keeps a newer selection when an earlier save's refresh arrives", async () => {
using screen = setup();
await act(async () => screen.current.toggle());
expect(screen.current.value).toBe(true);
let settle!: (value: { _tag: "Success" }) => void;
mocks.update.mockReturnValueOnce(
new Promise<{ _tag: "Success" }>((resolve) => {
settle = resolve;
}),
);
await act(async () => screen.current.toggle());
expect(screen.current.value).toBe(false);
screen.refresh(true);
expect(screen.current.disabled).toBe(true);
expect(screen.current.value).toBe(false);
await act(async () => screen.current.toggle());
expect(mocks.update).toHaveBeenCalledTimes(2);
await act(async () => settle({ _tag: "Success" }));
expect(screen.current.value).toBe(false);
screen.refresh(false);
expect(screen.current.value).toBe(false);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
import { canEditPullRequestMaintainerEdits } from "@t3tools/client-runtime/pull-request-editing";
import type { PullRequestDetail } from "@t3tools/contracts";
import { AsyncResult } from "effect/reactivity";
import { useRef, useState } from "react";
import * as Dialog from "../../components/ui/Dialog";
import { composerPullRequests } from "../../state/pull-requests";
import { useAtomCommand } from "../../state/use-atom-command";
import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
import { usePullRequestConnected } from "./usePullRequestConnected";

export function usePullRequestMaintainerEdits(
params: PullRequestParams,
pr: PullRequestDetail | null,
) {
const target = pullRequestTarget(params);
const connected = usePullRequestConnected(target.environmentId);
const canUpdate = useAtomValue(composerPullRequests.update.permissionAtom(target.environmentId));
const allowed = connected && canUpdate;
const update = useAtomCommand(composerPullRequests.update, { reportFailure: false });
const refresh = useAtomRefresh(composerPullRequests.detail(target));
const key = `${pr?.url}:${pr?.maintainerCanModify}`;
const [saving, setSaving] = useState(false);
const [selection, setSelection] = useState({ key, value: pr?.maintainerCanModify ?? false });
const value =
selection.key === key || saving ? selection.value : (pr?.maintainerCanModify ?? false);
if (selection.key !== key) {
setSelection({ key, value });
}
const inFlight = useRef(false);
const visible = pr !== null && canEditPullRequestMaintainerEdits(pr);
const save = async (next: boolean) => {
if (!visible || !allowed || inFlight.current) return;
const previous = value;
inFlight.current = true;
setSaving(true);
setSelection({ key, value: next });
const result = await update({
...target,
input: { ...target.input, maintainerCanModify: next },
});
inFlight.current = false;
setSaving(false);
if (AsyncResult.isFailure(result)) {
setSelection({ key, value: previous });
Dialog.alert("Could not save maintainer access", "Try again.");
return;
}
refresh();
};

return {
visible,
value,
disabled: !allowed || saving,
subtitle: saving
? "Saving…"
: "Maintainers can push to this branch and access fork workflow secrets.",
toggle: () => void save(!value),
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
1 change: 1 addition & 0 deletions apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const reactTests = [
"src/features/diffs/nativeReviewDiffSurface.test.tsx",
"src/features/pull-requests/usePullRequestDiff.test.tsx",
"src/components/ProjectFavicon.test.tsx",
"src/features/pull-requests/usePullRequestMaintainerEdits.test.tsx",
"src/components/ui/Dialog.android.test.tsx",
"src/features/files/ZipArchiveView.test.tsx",
"src/features/pull-requests/guide/useReviewGuide.test.tsx",
Expand Down
Loading
Loading