From 45c3922094b79cf9184a7f7cfbe5f90d8a65e2ed Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:42:32 +0100 Subject: [PATCH 1/2] feat(gitlab): reviews can request changes Co-Authored-By: Claude Opus 5.5 --- .../src/server/GitLabPullRequestCli.test.ts | 85 ++++++++++++++++ .../src/server/GitLabPullRequestCli.ts | 98 +++++++++++++++++-- .../server/GitLabPullRequestProvider.test.ts | 4 +- .../src/server/GitLabPullRequestProvider.ts | 3 +- .../src/server/gitLabMergeRequestJson.test.ts | 15 +++ .../src/server/gitLabMergeRequestJson.ts | 74 ++++++++++++-- 6 files changed, 258 insertions(+), 21 deletions(-) diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts index c750c64652066..5b207024c5091 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts @@ -148,6 +148,48 @@ layer("GitLabPullRequestCli.layer", (it) => { }), ); + it.effect("keeps approvals from a server that has no reviewer review states", () => + Effect.gen(function* () { + mockedExecute.mockImplementation(({ cwd, stdin }) => + (stdin ?? "").includes("reviewState") + ? Effect.fail( + new GitLabCli.GitLabCliCommandError({ + operation: "execute", + command: "glab", + cwd, + cause: new Error("Field 'reviewState' doesn't exist"), + }), + ) + : Effect.succeed( + output( + JSON.stringify({ + data: { + metadata: { enterprise: false }, + project: { + mergeRequests: { + nodes: [ + { iid: "7", approved: true, approvedBy: { nodes: [{ username: "r" }] } }, + ], + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const decisions = yield* cli.getReviewDecisions({ + cwd: "/w", + repository: "acme/web", + numbers: [7], + }); + + expect([...decisions]).toEqual([[7, "approved"]]); + expect(mockedExecute).toHaveBeenCalledTimes(2); + }), + ); + it.effect("asks GitLab for one row more than the page, to probe for a next page", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1)))); @@ -944,6 +986,49 @@ layer("GitLabPullRequestCli.layer", (it) => { }), ); + it.effect("requests changes as a reviewer and reports what GitLab refused", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(JSON.stringify({ username: "bilal" })))) + .mockReturnValueOnce( + Effect.succeed( + output(JSON.stringify({ data: { mergeRequestSetReviewers: { errors: [] } } })), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + data: { mergeRequestRequestChanges: { errors: ["Merge request is locked"] } }, + }), + ), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.submitReview({ + cwd: "/w", + repository: "acme/web", + number: 7, + verdict: "request-changes", + body: "", + comments: [], + }), + ); + + const target = { projectPath: "acme/web", iid: "7" }; + const setReviewers = JSON.parse(callAt(1).stdin ?? ""); + expect(setReviewers.query).toContain("operationMode: APPEND"); + expect(setReviewers.variables).toEqual({ ...target, reviewers: ["bilal"] }); + const requestChanges = JSON.parse(callAt(2).stdin ?? ""); + expect(requestChanges.query).toContain("mergeRequestRequestChanges"); + expect(requestChanges.variables).toEqual(target); + assert.strictEqual(error._tag, "GitLabMutationRefusedError"); + expect(error.detail).toBe("GitLab refused it: Merge request is locked"); + }), + ); + it.effect("removes an award by listing them and deleting the reader's own id", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts index ba8710f82f535..0ec39615ecaca 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts @@ -41,6 +41,7 @@ import { decodeMergeRequestListJson, decodeMergeRequestFileStatsJson, decodeMergeRequestStatsJson, + decodeMutationErrorsJson, decodeNotesJson, decodeOwnAwardIdJson, decodeProjectMergeCapabilitiesJson, @@ -54,6 +55,7 @@ import { MERGE_REQUEST_FILE_STATS_GRAPHQL_QUERY, MERGE_REQUEST_STATS_GRAPHQL_QUERY, REPOSITORY_BLOBS_GRAPHQL_QUERY, + APPROVALS_GRAPHQL_QUERY, REVIEW_DECISIONS_GRAPHQL_QUERY, type GitLabDiffRefs, type GitLabMergeRequestDetail, @@ -185,6 +187,34 @@ export class GitLabDiffFileContentsUnavailableError extends Schema.TaggedError()( + "GitLabMutationRefusedError", + { + command: Schema.Literal("glab"), + cwd: Schema.String, + operation: Schema.String, + reasons: Schema.Array(Schema.String), + }, +) { + get detail(): string { + return `GitLab refused it: ${this.reasons.join("; ")}`; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +const SET_SELF_AS_REVIEWER_MUTATION = `mutation($projectPath: ID!, $iid: String!, $reviewers: [String!]!) { + mergeRequestSetReviewers( + input: { projectPath: $projectPath, iid: $iid, reviewerUsernames: $reviewers, operationMode: APPEND } + ) { errors } +}`; + +const REQUEST_CHANGES_MUTATION = `mutation($projectPath: ID!, $iid: String!) { + mergeRequestRequestChanges(input: { projectPath: $projectPath, iid: $iid }) { errors } +}`; + export type GitLabPullRequestCliError = | GitLabCli.GitLabCliError | GitLabMergeRequestReadError @@ -193,7 +223,8 @@ export type GitLabPullRequestCliError = | GitLabDiffCommitParentUnavailableError | GitLabDiffFileContentsUnavailableError | GitLabDiffRefsUnavailableError - | GitLabViewerUnavailableError; + | GitLabViewerUnavailableError + | GitLabMutationRefusedError; /** GitLab's `per_page` limit. */ const MAX_PAGE_SIZE = 100; @@ -1170,6 +1201,28 @@ export const make = Effect.gen(function* () { }), ); + const graphqlMutation = (input: { + readonly cwd: string; + readonly host?: string | undefined; + readonly operation: string; + readonly query: string; + readonly variables: Record; + }) => + graphqlRead({ ...input, decode: decodeMutationErrorsJson }).pipe( + Effect.flatMap((reasons) => + reasons.length === 0 + ? Effect.void + : Effect.fail( + new GitLabMutationRefusedError({ + command: "glab", + cwd: input.cwd, + operation: input.operation, + reasons, + }), + ), + ), + ); + const mergeRequestStats = (input: { readonly cwd: string; readonly host?: string | undefined; @@ -1316,15 +1369,20 @@ export const make = Effect.gen(function* () { }): Effect.Effect< ReadonlyMap, GitLabPullRequestCliError - > => - graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getReviewDecisions", - query: REVIEW_DECISIONS_GRAPHQL_QUERY, - variables: { fullPath: input.repository, iids: input.numbers.map(String) }, - decode: decodeReviewDecisionsJson, - }); + > => { + const read = (query: string) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getReviewDecisions", + query, + variables: { fullPath: input.repository, iids: input.numbers.map(String) }, + decode: decodeReviewDecisionsJson, + }); + return read(REVIEW_DECISIONS_GRAPHQL_QUERY).pipe( + Effect.catchTags({ GitLabCliCommandError: () => read(APPROVALS_GRAPHQL_QUERY) }), + ); + }; const viewerUsername = (input: { readonly cwd: string; readonly host?: string | undefined }) => api({ cwd: input.cwd, host: input.host, path: "user" }).pipe( @@ -1774,6 +1832,26 @@ export const make = Effect.gen(function* () { method: "POST", }); } + if (input.verdict === "request-changes") { + // GitLab keeps the verdict on the reviewer, so whoever requests changes joins the + // reviewers first, as on GitHub. Appending someone already there changes nothing. + const viewer = yield* viewerUsername(input); + const target = { projectPath: input.repository, iid: String(input.number) }; + yield* graphqlMutation({ + cwd: input.cwd, + host: input.host, + operation: "submitReview", + query: SET_SELF_AS_REVIEWER_MUTATION, + variables: { ...target, reviewers: [viewer] }, + }); + yield* graphqlMutation({ + cwd: input.cwd, + host: input.host, + operation: "submitReview", + query: REQUEST_CHANGES_MUTATION, + variables: target, + }); + } }), replyToDiscussion: Effect.fn("GitLabPullRequestCli.replyToDiscussion")(function (input) { diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts index dbf25b531a23a..106fa17ba8b0a 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts @@ -135,7 +135,7 @@ describe("gitLabViewerPermissions", () => { ], comment: true, resolve: true, - verdicts: ["comment", "approve"], + verdicts: ["comment", "approve", "request-changes"], requestReviewers: true, updateMethods: ["rebase"], }); @@ -146,7 +146,7 @@ describe("gitLabViewerPermissions", () => { actions: ["ready", "draft", "close", "reopen"], comment: true, resolve: true, - verdicts: ["comment", "approve"], + verdicts: ["comment", "approve", "request-changes"], requestReviewers: true, }); }); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index 9935bf17a4b37..45274ce48c621 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -35,8 +35,7 @@ const CAPABILITIES: PullRequestCapabilities = { inlineComment: true, reply: true, resolve: true, - // GitLab has no requested-changes verdict. - verdicts: ["comment", "approve"], + verdicts: ["comment", "approve", "request-changes"], }, reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts index 7b755d559c578..e4a46430edfce 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts @@ -715,6 +715,21 @@ describe("decodeReviewDecisionsJson", () => { approval: { approved: false, approvedBy: approvedBy() }, decision: null, }, + { + reading: "a reviewer requesting changes after another approved", + enterprise: false, + approval: { + approved: true, + approvedBy: approvedBy("julius"), + reviewers: { + nodes: [ + { mergeRequestInteraction: { reviewState: "APPROVED" } }, + { mergeRequestInteraction: { reviewState: "REQUESTED_CHANGES" } }, + ], + }, + }, + decision: "changes-requested", + }, ])("reads $reading as $decision", ({ enterprise, approval, decision }) => { expect([...decisionsOf(enterprise, [{ iid: "7", ...approval }])]).toEqual([[7, decision]]); }); diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts index c6fdafad94b2e..d174e704ca63e 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts @@ -999,15 +999,24 @@ export function decodeRepositoryBlobsJson( return Result.succeed(blobs); } -export const REVIEW_DECISIONS_GRAPHQL_QUERY = `query($fullPath: ID!, $iids: [String!]) { +const reviewDecisionsQuery = (reviewStates: boolean) => `query($fullPath: ID!, $iids: [String!]) { metadata { enterprise } project(fullPath: $fullPath) { mergeRequests(iids: $iids, first: 100) { - nodes { iid approved approvedBy(first: 1) { nodes { username } } } + nodes { + iid + approved + approvedBy(first: 1) { nodes { username } } + ${reviewStates ? "reviewers { nodes { mergeRequestInteraction { reviewState } } }" : ""} + } } } }`; +export const REVIEW_DECISIONS_GRAPHQL_QUERY = reviewDecisionsQuery(true); +/** For servers from before reviewers had a review state, which reject the whole query. */ +export const APPROVALS_GRAPHQL_QUERY = reviewDecisionsQuery(false); + const RawReviewDecisionsSchema = Schema.Struct({ data: Schema.Struct({ metadata: Schema.optional( @@ -1030,6 +1039,31 @@ const RawReviewDecisionsSchema = Schema.Struct({ }), ), ), + reviewers: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + mergeRequestInteraction: Schema.optional( + Schema.NullOr( + Schema.Struct({ + reviewState: Schema.optional( + Schema.NullOr(Schema.String), + ), + }), + ), + ), + }), + ), + ), + ), + ), + }), + ), + ), }), ), ), @@ -1047,6 +1081,7 @@ const decodeReviewDecisions = decodeJsonResult(RawReviewDecisionsSchema); * Only Enterprise Edition holds `approved` to the project's approval rules, so an unapproved * merge request there still needs review even when someone, such as a bot, approved it. * Community Edition reports `approved` once anyone approves and requires no review. + * A reviewer who requested changes outranks both until they review again. */ export function decodeReviewDecisionsJson( raw: string, @@ -1059,13 +1094,16 @@ export function decodeReviewDecisionsJson( const decisions = new Map(); for (const node of decoded.success.data.project?.mergeRequests?.nodes ?? []) { const number = Number(node?.iid); + if (node === null || !Number.isSafeInteger(number)) continue; if ( - node === null || - !Number.isSafeInteger(number) || - node.approved == null || - node.approvedBy?.nodes == null - ) + node.reviewers?.nodes?.some( + (reviewer) => reviewer?.mergeRequestInteraction?.reviewState === "REQUESTED_CHANGES", + ) + ) { + decisions.set(number, "changes-requested"); continue; + } + if (node.approved == null || node.approvedBy?.nodes == null) continue; const approvals = node.approvedBy.nodes.length; if (node.approved === false && approvals === 0 && enterprise == null) continue; decisions.set( @@ -1080,6 +1118,28 @@ export function decodeReviewDecisionsJson( return Result.succeed(decisions); } +const decodeMutationPayloads = decodeJsonResult( + Schema.Struct({ + data: Schema.Record( + Schema.String, + Schema.NullOr( + Schema.Struct({ errors: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))) }), + ), + ), + }), +); + +/** GitLab answers a refused mutation with HTTP 200 and the reasons in each payload's `errors`. */ +export function decodeMutationErrorsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeMutationPayloads(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + return Result.succeed( + Object.values(decoded.success.data).flatMap((payload) => payload?.errors ?? []), + ); +} + /** * Line counts for merge requests of one project. The REST listing carries no line counts, and * reading each diff to count them would cost a request per row, so GraphQL's per-merge-request From 5ef5f212030f436e148e05aefcda47495c719747 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:56:13 -0800 Subject: [PATCH 2/2] fix(gitlab): request changes before posting review comments On GitLab before 18.9 the request-changes mutation fails, which previously happened after the line comments and summary had already posted, so a retry duplicated them. Request changes now runs first; approval stays last. The review-state fallback in getReviewDecisions now retries only when glab exits non-zero, not on timeouts or I/O failures. Co-Authored-By: Claude Opus 5.5 --- .../src/server/GitLabPullRequestCli.test.ts | 108 ++++++++++++------ .../src/server/GitLabPullRequestCli.ts | 53 +++++---- 2 files changed, 98 insertions(+), 63 deletions(-) diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts index ca482714c88d4..ced4f5ce5166c 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts @@ -1,5 +1,5 @@ import { afterEach, assert, expect, it, vi } from "@effect/vitest"; -import { VcsProcessExitError } from "@t3tools/contracts"; +import { VcsProcessExitError, VcsProcessTimeoutError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { ChildProcessSpawner } from "effect/process"; @@ -171,46 +171,77 @@ layer("GitLabPullRequestCli.layer", (it) => { }), ); - it.effect("keeps approvals from a server that has no reviewer review states", () => - Effect.gen(function* () { - mockedExecute.mockImplementation(({ cwd, stdin }) => - (stdin ?? "").includes("reviewState") - ? Effect.fail( - new GitLabCli.GitLabCliCommandError({ - operation: "execute", - command: "glab", - cwd, - cause: new Error("Field 'reviewState' doesn't exist"), - }), - ) - : Effect.succeed( - output( - JSON.stringify({ - data: { - metadata: { enterprise: false }, - project: { - mergeRequests: { - nodes: [ - { iid: "7", approved: true, approvedBy: { nodes: [{ username: "r" }] } }, - ], + it.effect.each([ + { + reading: "a rejected query", + cause: new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab", + cwd: "/w", + exitCode: 1, + detail: "Field 'reviewState' doesn't exist on type 'MergeRequestReviewer'", + }), + calls: 2, + expected: [[7, "approved"]], + }, + { + reading: "a timeout", + cause: new VcsProcessTimeoutError({ + operation: "GitLabCli.execute", + command: "glab", + cwd: "/w", + timeoutMs: 1000, + }), + calls: 1, + expected: "GitLabCliCommandError", + }, + ])( + "retries review decisions without review states after $reading", + ({ cause, calls, expected }) => + Effect.gen(function* () { + mockedExecute.mockImplementation(({ cwd, stdin }) => + (stdin ?? "").includes("reviewState") + ? Effect.fail( + new GitLabCli.GitLabCliCommandError({ + operation: "execute", + command: "glab", + cwd, + cause, + }), + ) + : Effect.succeed( + output( + JSON.stringify({ + data: { + metadata: { enterprise: false }, + project: { + mergeRequests: { + nodes: [ + { + iid: "7", + approved: true, + approvedBy: { nodes: [{ username: "r" }] }, + }, + ], + }, }, }, - }, - }), + }), + ), ), - ), - ); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - const decisions = yield* cli.getReviewDecisions({ - cwd: "/w", - repository: "acme/web", - numbers: [7], - }); + const outcome = yield* cli + .getReviewDecisions({ cwd: "/w", repository: "acme/web", numbers: [7] }) + .pipe( + Effect.map((decisions) => [...decisions]), + Effect.catch((error) => Effect.succeed(error._tag)), + ); - expect([...decisions]).toEqual([[7, "approved"]]); - expect(mockedExecute).toHaveBeenCalledTimes(2); - }), + expect(outcome).toEqual(expected); + expect(mockedExecute).toHaveBeenCalledTimes(calls); + }), ); it.effect("asks GitLab for one row more than the page, to probe for a next page", () => @@ -1225,11 +1256,12 @@ layer("GitLabPullRequestCli.layer", (it) => { repository: "acme/web", number: 7, verdict: "request-changes", - body: "", - comments: [], + body: "Needs work", + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], }), ); + expect(mockedExecute).toHaveBeenCalledTimes(3); const target = { projectPath: "acme/web", iid: "7" }; const setReviewers = JSON.parse(callAt(1).stdin ?? ""); expect(setReviewers.query).toContain("operationMode: APPEND"); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts index 2b814d36cb95e..f527706a4045e 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts @@ -1615,8 +1615,12 @@ export const make = Effect.gen(function* () { variables: { fullPath: input.repository, iids: input.numbers.map(String) }, decode: decodeReviewDecisionsJson, }); + // glab exits non-zero when GitLab rejects the query; a timeout or I/O failure is not that. return read(REVIEW_DECISIONS_GRAPHQL_QUERY).pipe( - Effect.catchTags({ GitLabCliCommandError: () => read(APPROVALS_GRAPHQL_QUERY) }), + Effect.catchIf( + (error) => error._tag === "GitLabCliCommandError" && isVcsProcessExitError(error.cause), + () => read(APPROVALS_GRAPHQL_QUERY), + ), ); }; @@ -2133,10 +2137,29 @@ export const make = Effect.gen(function* () { submitReview: Effect.fn("GitLabPullRequestCli.submitReview")(function* (input) { const project = projectPath(input.repository); const mergeRequest = `projects/${project}/merge_requests/${input.number}`; - // GitLab has no pending review to attach comments to, so a review is replayed as the - // requests it is made of: the line comments, then the summary, then the verdict. A - // failure part-way therefore leaves what was already posted in place, which is why - // the verdict goes last — a half-sent review is never an approval. + // GitLab has no pending review, so a review is replayed as separate requests and a failure + // leaves earlier ones posted. Requesting changes (GitLab 18.9+) goes first so an older + // server fails before any comment lands; approval goes last so a half-sent review never + // approves. + if (input.verdict === "request-changes") { + // GitLab keeps the verdict on the reviewer, so the viewer joins the reviewers first. + const viewer = yield* viewerUsername(input); + const target = { projectPath: input.repository, iid: String(input.number) }; + yield* graphqlMutation({ + cwd: input.cwd, + host: input.host, + operation: "submitReview", + query: SET_SELF_AS_REVIEWER_MUTATION, + variables: { ...target, reviewers: [viewer] }, + }); + yield* graphqlMutation({ + cwd: input.cwd, + host: input.host, + operation: "submitReview", + query: REQUEST_CHANGES_MUTATION, + variables: target, + }); + } if (input.comments.length > 0) { const refs = yield* getDiffRefs(input); yield* Effect.forEach( @@ -2184,26 +2207,6 @@ export const make = Effect.gen(function* () { method: "POST", }); } - if (input.verdict === "request-changes") { - // GitLab keeps the verdict on the reviewer, so whoever requests changes joins the - // reviewers first, as on GitHub. Appending someone already there changes nothing. - const viewer = yield* viewerUsername(input); - const target = { projectPath: input.repository, iid: String(input.number) }; - yield* graphqlMutation({ - cwd: input.cwd, - host: input.host, - operation: "submitReview", - query: SET_SELF_AS_REVIEWER_MUTATION, - variables: { ...target, reviewers: [viewer] }, - }); - yield* graphqlMutation({ - cwd: input.cwd, - host: input.host, - operation: "submitReview", - query: REQUEST_CHANGES_MUTATION, - variables: target, - }); - } }), replyToDiscussion: Effect.fn("GitLabPullRequestCli.replyToDiscussion")(function (input) {