Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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 @@ -111,6 +111,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 @@ -416,6 +417,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 @@ -517,6 +519,19 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
},
]
: []),
...(maintainerEdits.visible
? [
{
accessibilityLabel: "Allow edits from maintainers",
icon: "pencil" as const,
selected: maintainerEdits.value,
disabled: maintainerEdits.saving,
subtitle: maintainerEdits.subtitle,
overflowOnly: true,
onPress: maintainerEdits.toggle,
},
]
: []),
...(identity
? [
{
Expand Down Expand Up @@ -597,6 +612,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.saving}
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,65 @@
import { useAtomRefresh } from "@effect/atom-react";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
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 { usePullRequestPermissions } from "./usePullRequestPermissions";

export function usePullRequestMaintainerEdits(
params: PullRequestParams,
pr: PullRequestDetail | null,
) {
const target = pullRequestTarget(params);
const permissions = usePullRequestPermissions(params, pr);
const update = useAtomCommand(composerPullRequests.update, { reportFailure: false });
const refreshDetail = useAtomRefresh(composerPullRequests.detail(target));
const pending = useRef(false);
const [saving, setSaving] = useState(false);
const [choice, setChoice] = useState<{
readonly url: string;
readonly observed: boolean | undefined;
readonly value: boolean;
} | null>(null);
const observed = pr?.maintainerCanModify;
// A choice ends at the first host value that differs from the one it was made against, so a
// later change made elsewhere is never masked by it.
if (choice !== null && !saving && (choice.url !== pr?.url || choice.observed !== observed)) {
setChoice(null);
}
const value = choice !== null && choice.url === pr?.url ? choice.value : observed === true;
const save = async (next: boolean) => {
if (pending.current || pr === null || !permissions.canEditMaintainerEdits) return;
pending.current = true;
setSaving(true);
setChoice({ url: pr.url, observed, value: next });
const response = await update({
...target,
input: { ...target.input, maintainerCanModify: next },
});
pending.current = false;
setSaving(false);
if (AsyncResult.isFailure(response)) {
setChoice(null);
const cause = squashAtomCommandFailure(response);
Dialog.alert(
"Could not save maintainer access",
cause instanceof Error ? cause.message : "Try again.",
);
return;
}
refreshDetail();
};
return {
visible: permissions.canEditMaintainerEdits,
value,
saving,
subtitle: saving
? "Saving…"
: "Maintainers can push to this branch and may get access to fork workflow secrets.",
toggle: () => void save(!value),
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
import {
canEditPullRequestChangeRequest,
canEditPullRequestComment,
canEditPullRequestMaintainerEdits,
} from "@t3tools/client-runtime/pull-request-editing";
import type { PullRequestComment, PullRequestDetail } from "@t3tools/contracts";
import { usePullRequestConnected } from "./usePullRequestConnected";
Expand Down Expand Up @@ -49,6 +50,8 @@ export function usePullRequestPermissions(params: PullRequestParams, pr: PullReq
canWrite: connected && canWrite,
canEditChangeRequest:
connected && canUpdate && pr !== null && canEditPullRequestChangeRequest(pr),
canEditMaintainerEdits:
connected && canUpdate && pr !== null && canEditPullRequestMaintainerEdits(pr),
canEditComment: (comment: Pick<PullRequestComment, "author" | "canEdit" | "kind">) =>
connected && canUpdateComment && pr !== null && canEditPullRequestComment(pr, comment),
canReact: connected && canSetReaction && pr?.capabilities.reactions === true,
Expand Down
59 changes: 59 additions & 0 deletions apps/server/src/pullRequest/PullRequestService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,65 @@ function hostedChangeRequest(body: string, additions = 1) {
};
}

it.effect("reads maintainer edits and changes them through the update command", () =>
Effect.gen(function* () {
let enabled = true;
const provider = fakePullRequestProvider(SourceControlProviderKind.make("github"), {
getChangeRequest: () =>
Effect.succeed({ ...hostedChangeRequest("Body"), maintainerCanModify: enabled }),
updateChangeRequest: (input) =>
Effect.sync(() => {
assert.strictEqual(input.title, undefined);
assert.strictEqual(input.body, undefined);
enabled = input.maintainerCanModify!;
}),
});
const service = yield* makeService({
projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
providers: [
{
...provider,
capabilities: {
...provider.capabilities,
edit: { changeRequest: true, comment: true, maintainerEdits: true },
},
},
],
});
const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
assert.strictEqual((yield* service.detail(ref)).maintainerCanModify, true);
yield* service.update({ ...ref, maintainerCanModify: false });
assert.strictEqual((yield* service.detail(ref)).maintainerCanModify, false);
}),
);

it.effect("refuses maintainer edits on a host without the capability", () =>
Effect.gen(function* () {
let writes = 0;
const service = yield* makeService({
projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })],
providers: [
fakePullRequestProvider(SourceControlProviderKind.make("github"), {
updateChangeRequest: () =>
Effect.sync(() => {
writes += 1;
}),
}),
],
});
const result = yield* service
.update({
projectId: "p1" as ProjectId,
repository: "acme/web",
number: 1,
maintainerCanModify: false,
})
.pipe(Effect.flip);
assert.strictEqual(result._tag, "PullRequestOperationError");
assert.strictEqual(writes, 0);
}),
);

it.effect("does not wait for an in-flight detail read to display a preview", () =>
Effect.gen(function* () {
const detailStarted = yield* Deferred.make<void>();
Expand Down
23 changes: 22 additions & 1 deletion apps/server/src/pullRequest/PullRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1753,6 +1753,9 @@ export const make = Effect.gen(function* () {
...(changeRequest.autoMergeMethod === undefined
? {}
: { autoMergeMethod: changeRequest.autoMergeMethod }),
...(changeRequest.maintainerCanModify === undefined
? {}
: { maintainerCanModify: changeRequest.maintainerCanModify }),
...(changeRequest.workflowApprovalsRequired === undefined
? {}
: { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }),
Expand Down Expand Up @@ -2138,7 +2141,22 @@ export const make = Effect.gen(function* () {
}),
);
}
if (input.title === undefined && input.body === undefined) {
if (
input.maintainerCanModify !== undefined &&
project.api.capabilities.edit?.maintainerEdits !== true
) {
return Effect.fail(
new PullRequestOperationError({
operation: "update",
detail: "This host does not support maintainer edits.",
}),
);
}
if (
input.title === undefined &&
input.body === undefined &&
input.maintainerCanModify === undefined
) {
return Effect.fail(
new PullRequestOperationError({
operation: "update",
Expand All @@ -2151,6 +2169,9 @@ export const make = Effect.gen(function* () {
repository: project.repository,
host: project.host,
number: input.number,
...(input.maintainerCanModify === undefined
? {}
: { maintainerCanModify: input.maintainerCanModify }),
...(input.title === undefined ? {} : { title: input.title }),
...(input.body === undefined ? {} : { body: input.body }),
}).pipe(Effect.mapError(toPullRequestError("update")));
Expand Down
Loading
Loading