apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts

import * as Effect from "effect/Effect";
import * as Semaphore from "effect/Semaphore";
import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";

import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
import {
  azureDevOpsFilePatch,
  azureDevOpsUnreadableFilePatch,
  formatAzureDevOpsDiffCursor,
  parseAzureDevOpsDiffCursor,
  MAX_DIFF_SLICE_BYTES,
  MAX_DIFF_SLICE_EDITS,
  MAX_DIFF_SLICE_FILES,
  byteLength,
  MAX_FILE_DIFF_EDITS,
  type AzureDevOpsFileTexts,
} from "./azureDevOpsDiff.ts";
import {
  PullRequestProviderError,
  type PullRequestProviderFailure,
  type ProviderChangeRequest,
  type ProviderChangeRequestActivity,
  type ProviderChangeRequestDetail,
  type ProviderChangeRequestSummary,
  type ProviderDiffSlice,
  type PullRequestProviderApi,
} from "./PullRequestProvider.ts";
import type { AzureDevOpsIterationChanges } from "./AzureDevOpsPullRequestCli.ts";
import type {
  AzureDevOpsChangeEntry,
  AzureDevOpsItemContent,
  AzureDevOpsIteration,
  AzureDevOpsPullRequest,
  AzureDevOpsRepositoryLocation,
} from "./azureDevOpsPullRequestJson.ts";

/**
 * How many of a slice's files are read at once. Every file is two `az` invocations, each paying a
 * Python interpreter's start-up, so reading them one after another is most of what the Code tab
 * waits for. Four files means eight processes at once: the fan-out the GitHub CLI reads its
 * per-file stats with here, and low enough not to swamp the host's throttling or the machine.
 */
const DIFF_FILE_CONCURRENCY = 4;

/**
 * How many `az` processes this build will have out at once, counted across every reader rather
 * than per request. The fan-out above bounds one Code tab, so two people opening two Azure reviews
 * had sixteen Python interpreters starting at once and nothing above them. Held at what one
 * request at full width spends, so a second reader waits behind the first instead of adding to it.
 */
export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY;

/** How many pull requests' repository locations one provider remembers at once. */
export const LOCATION_CACHE_CAPACITY = 128;

const CAPABILITIES: PullRequestCapabilities = {
  // Azure serves no patch of its own, so the one the Code tab reads is built here out of the
  // files an iteration changed and both sides of each of them.
  diff: true,
  // Reading a conversation is a plain REST read, but posting one is not something this can
  // claim without having run it, so the composer stays hidden.
  comment: false,
  actions: [
    "merge",
    "ready",
    "draft",
    "close",
    "reopen",
    "enable-auto-merge",
    "disable-auto-merge",
  ],
  // Azure squashes as a completion option; it has no rebase strategy of its own.
  mergeMethods: ["merge", "squash"],
  // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all.
  search: false,
  reactions: false,
  // The patch has lines to write against, but writing a remark at all is what Azure is not
  // offered for here, so nothing in a review is either.
  review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
  // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos`
  // lists the ones this repository could name — that lives behind the identity and graph APIs, a
  // different service with its own permissions. So the page takes a name here rather than being
  // handed a menu built out of a guess.
  reviewers: { request: true, listCandidates: false },
  // A new title and description travel on the same `az repos pr update` that moves a pull request.
  // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on
  // Azure DevOps at all, so there is nothing here it could rewrite either.
  edit: { changeRequest: true, comment: false },
  // Azure does keep a viewed record of its own, but only behind the undocumented contribution
  // endpoint its web UI talks to, keyed on an iteration so a push would drop every mark anyway.
  // So they are kept here instead, and the client says whose they are rather than implying the
  // Azure DevOps page will show them.
  viewedFiles: "environment",
};

/**
 * Everything this host offers, granted to whoever is signed in. Azure DevOps states no permission
 * anywhere `az repos pr show` or `az repos pr list` reach: the answer lives in the security
 * namespaces, behind identity descriptors and token paths that would be several calls per pull
 * request to resolve.
 *
 * So the actions stay live and a viewer who may not take one is told so by Azure, at the moment
 * they try. That is the safer half of an unknown: hiding a control from someone entitled to it
 * leaves them no way through and no reason given.
 */
const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = {
  actions: CAPABILITIES.actions,
  comment: CAPABILITIES.comment,
  resolve: CAPABILITIES.review.resolve,
  verdicts: CAPABILITIES.review.verdicts,
  requestReviewers: CAPABILITIES.reviewers.request,
};

/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */
export function azureDevOpsProviderFailure(
  error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError,
): PullRequestProviderFailure {
  if (error._tag === "AzureDevOpsCliUnavailableError") return { reason: "missing-tool" };
  if (error._tag === "AzureDevOpsCliAuthenticationError") return { reason: "unauthenticated" };
  if (error._tag === "AzureDevOpsCliRateLimitError") return { reason: "rate-limited" };
  return { reason: "failed" };
}

function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeRequest {
  return {
    number: pullRequest.number,
    title: pullRequest.title,
    url: pullRequest.url,
    author: pullRequest.author,
    headBranch: pullRequest.headBranch,
    baseBranch: pullRequest.baseBranch,
    state: pullRequest.state,
    isDraft: pullRequest.isDraft,
    mergeability: pullRequest.mergeability,
    // Azure counts a pull request's files but never its lines, and counting them here would mean
    // reading every file on both sides of every row of a listing.
    additions: 0,
    deletions: 0,
    createdAt: pullRequest.createdAt,
    closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
    mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
    updatedAt: pullRequest.updatedAt,
    reviewRequestLogins: pullRequest.reviewRequestLogins,
    // Azure keeps labels on work items rather than on the pull request.
    labels: [],
  };
}

export const make = Effect.gen(function* () {
  const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
  // Made once with the provider, which the registry builds once, so this is the whole build's
  // allowance rather than one request's.
  const diffSpawns = yield* Semaphore.make(MAX_DIFF_SPAWNS);
  const readItemContent = (input: Parameters<typeof cli.readItemContent>[0]) =>
    diffSpawns.withPermits(1)(cli.readItemContent(input));

  const fail =
    (operation: string) => (error: AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCliError) =>
      new PullRequestProviderError({
        provider: "azure-devops",
        operation,
        ...azureDevOpsProviderFailure(error),
        detail: error.detail,
        cause: error,
      });

  /** Refuses what the capabilities already say this host cannot do. */
  const unsupported = (operation: string) =>
    Effect.fail(
      new PullRequestProviderError({
        provider: "azure-devops",
        operation,
        reason: "failed",
        detail: "Azure DevOps reviews cannot be written from here yet.",
      }),
    );

  /** A pull request Azure could not place has no diff to read, which reads as an empty one. */
  const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null };

  /**
   * Where a pull request's repository lives, which is the route every other read of it needs and
   * the one thing only the pull request itself states. A pull request cannot move between
   * repositories, so it is remembered rather than re-read: the marks alone would otherwise pay for
   * a whole pull request read every time they checked whether a file had been pushed to. Least
   * recently used, so a listing walking cold pull requests cannot evict the one being read.
   */
  const locations = new Map<string, AzureDevOpsRepositoryLocation>();

  const locationOf = (input: { readonly cwd: string; readonly number: number }) => {
    const key = `${input.cwd} ${input.number}`;
    const held = locations.get(key);
    if (held !== undefined) {
      locations.delete(key);
      locations.set(key, held);
      return Effect.succeed(held);
    }
    return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
      Effect.map((pullRequest) => {
        const location = pullRequest.location;
        if (location === null) return null;
        if (locations.size >= LOCATION_CACHE_CAPACITY) {
          const oldest = locations.keys().next().value;
          if (oldest !== undefined) locations.delete(oldest);
        }
        locations.set(key, location);
        return location;
      }),
    );
  };

  /**
   * Everything a diff read needs before it can ask for a file: where the repository lives, and
   * which pushes the pull request has had. A client names neither, and the iterations are read
   * afresh every time because the newest one is what a push adds.
   */
  const diffScope = (input: { readonly cwd: string; readonly number: number }) =>
    Effect.gen(function* () {
      const location = yield* locationOf(input);
      if (location === null) return null;
      const iterations = yield* cli.listIterations({
        cwd: input.cwd,
        location,
        number: input.number,
      });
      return { location, iterations };
    });

  const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false };

  /**
   * Both sides of one changed file, read at once because neither answer depends on the other and
   * `az` pays a Python interpreter's start-up for each. Only the sides a change actually has are
   * asked for: Azure answers for a file that is not at a commit with a failure rather than with
   * nothing.
   */
  const readTexts = (input: {
    readonly cwd: string;
    readonly location: AzureDevOpsRepositoryLocation;
    readonly iteration: AzureDevOpsIteration;
    readonly change: Pick<AzureDevOpsChangeEntry, "changeKind" | "path" | "oldPath">;
  }) =>
    Effect.all(
      [
        input.change.changeKind === "new"
          ? Effect.succeed(EMPTY_ITEM)
          : readItemContent({
              cwd: input.cwd,
              location: input.location,
              path: input.change.oldPath,
              commit: input.iteration.mergeBaseCommit,
            }),
        input.change.changeKind === "deleted"
          ? Effect.succeed(EMPTY_ITEM)
          : readItemContent({
              cwd: input.cwd,
              location: input.location,
              path: input.change.path,
              commit: input.iteration.headCommit,
            }),
      ],
      { concurrency: 2 },
    ).pipe(
      Effect.map(([oldItem, newItem]): AzureDevOpsFileTexts => ({
        oldContents: oldItem.contents,
        newContents: newItem.contents,
        // Azure hands a file it calls binary over in an encoding of its own, so its own word on
        // that is taken rather than looked for in bytes it may never have sent verbatim.
        binary: oldItem.isBinary || newItem.isBinary,
      })),
    );

  /**
   * What the whole pull request changed, taken from its latest push. An iteration's changes are
   * reported against the merge base rather than against the push before it, so the newest one is
   * the whole of the change rather than the last slice of it.
   */
  const listLatestChanges = (input: {
    readonly cwd: string;
    readonly location: AzureDevOpsRepositoryLocation;
    readonly number: number;
    readonly iterations: ReadonlyArray<AzureDevOpsIteration>;
  }) => {
    const latest = input.iterations.at(-1);
    return latest === undefined
      ? Effect.succeed({ changes: [], truncated: false } as AzureDevOpsIterationChanges)
      : cli.listIterationChanges({
          cwd: input.cwd,
          location: input.location,
          number: input.number,
          iterationId: latest.id,
        });
  };

  const provider: PullRequestProviderApi = {
    kind: "azure-devops",
    capabilities: CAPABILITIES,

    getViewer: (input) =>
      cli.getViewer({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))),

    // `input.query` is deliberately dropped: `az repos pr list` filters by status, creator,
    // reviewer and branch, and has nothing that matches text. Sending it as one of those would
    // narrow by the wrong thing, so the page comes back unnarrowed and the caller filters it.
    listChangeRequests: (input) =>
      cli
        .listPullRequests({
          cwd: input.cwd,
          repository: input.repository,
          state: input.state,
          involvement: input.involvement,
          viewer: input.viewer,
          limit: input.limit,
          cursor: input.cursor,
        })
        .pipe(
          Effect.mapError(fail("listChangeRequests")),
          Effect.map((batch) => ({
            items: batch.items.map(toChangeRequest),
            truncated: batch.truncated,
            cursorAdvance: batch.cursorAdvance,
            // Azure answers in one order whether or not it is being carried on from, so a slice
            // can always be stepped past — by counting, which is all Azure offers.
            continues: true,
          })),
        ),

    // The polled path a linked thread's row stays live on: one `az` read, no iterations or
    // changes behind it, since the file count that would cost is not shown here.
    getChangeRequestSummary: (input) =>
      cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
        Effect.mapError(fail("getChangeRequestSummary")),
        Effect.map((pullRequest): ProviderChangeRequestSummary => ({
          number: pullRequest.number,
          title: pullRequest.title,
          url: pullRequest.url,
          author: pullRequest.author,
          headBranch: pullRequest.headBranch,
          baseBranch: pullRequest.baseBranch,
          state: pullRequest.state,
          isDraft: pullRequest.isDraft,
          mergeability: pullRequest.mergeability,
          closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
          mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
          updatedAt: pullRequest.updatedAt,
        })),
      ),

    getChangeRequest: (input) =>
      Effect.gen(function* () {
        const pullRequest = yield* cli.getPullRequest({ cwd: input.cwd, number: input.number });
        const location = pullRequest.location;
        // The file count is two reads past the pull request itself, and it is the only thing
        // riding on them, so a failure leaves it unknown rather than losing the whole detail.
        const changedFiles =
          location === null
            ? 0
            : yield* cli.listIterations({ cwd: input.cwd, location, number: input.number }).pipe(
                Effect.flatMap((iterations) =>
                  listLatestChanges({
                    cwd: input.cwd,
                    location,
                    number: input.number,
                    iterations,
                  }),
                ),
                Effect.map((listed) => listed.changes.length),
                Effect.orElseSucceed(() => 0),
              );
        const detail: ProviderChangeRequestDetail = {
          ...toChangeRequest(pullRequest),
          body: pullRequest.body,
          changedFiles,
          mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
          closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
          reviewers: pullRequest.reviewers,
          checks: [],
          mergeCapabilities: { merge: true, squash: true, rebase: false },
          viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS,
          autoMergeEnabled: pullRequest.autoMergeEnabled,
          ...(pullRequest.autoMergeMethod === undefined
            ? {}
            : { autoMergeMethod: pullRequest.autoMergeMethod }),
        };
        return detail;
      }).pipe(Effect.mapError(fail("getChangeRequest"))),

    getChangeRequestActivity: (input) =>
      cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe(
        Effect.mapError(fail("getChangeRequestActivity")),
        Effect.flatMap((pullRequest) =>
          (pullRequest.location === null
            ? Effect.succeed({ comments: [], truncated: true })
            : cli
                .listThreads({
                  cwd: input.cwd,
                  location: pullRequest.location,
                  number: input.number,
                })
                .pipe(
                  Effect.map((comments) => ({ comments, truncated: false })),
                  Effect.orElseSucceed(() => ({ comments: [], truncated: true })),
                )
          ).pipe(
            Effect.map((conversation): ProviderChangeRequestActivity => ({
              comments: conversation.comments,
              commentCount: conversation.comments.length,
              commentsTruncated: conversation.truncated,
              reviewThreads: [],
              commits: [],
            })),
          ),
        ),
      ),

    // No request at all: Azure has nothing to say about the viewer that a pull request read can
    // reach, so the answer is the same constant the detail carries.
    getViewerPermissions: () => Effect.succeed(AZURE_DEVOPS_VIEWER_PERMISSIONS),

    // `input.commit` is deliberately dropped: Azure states no commit list on a pull request, so
    // the Code tab has nothing to scope itself to and always asks for the whole change.
    getDiff: (input) =>
      Effect.gen(function* () {
        const scope = yield* diffScope(input);
        if (scope === null) return EMPTY_DIFF_SLICE;
        const cursor = parseAzureDevOpsDiffCursor(input.cursor);
        // Reading on stays with the push the first slice was taken against. A push landing
        // mid-read would otherwise renumber the files and hand the reader one twice, or none.
        const iteration =
          cursor === null
            ? scope.iterations.at(-1)
            : scope.iterations.find((candidate) => candidate.id === cursor.iterationId);
        if (iteration === undefined) return EMPTY_DIFF_SLICE;
        const listed = yield* cli.listIterationChanges({
          cwd: input.cwd,
          location: scope.location,
          number: input.number,
          iterationId: iteration.id,
        });
        const changes = listed.changes;

        const sections: string[] = [];
        let truncated = listed.truncated;
        let bytes = 0;
        let edits = 0;
        let index = cursor?.fileIndex ?? 0;
        let full = false;
        // How many files to read at once. Every file costs a request a side, and everything read
        // past the point the slice fills is thrown away and read again by the next slice, so the
        // batch narrows as the budgets do: what is left of each, over what a file has spent of it
        // on average so far, which is the only estimate there is before a file is read. At least
        // one, so a file heavier than the whole budget still moves the cursor.
        const batchWidth = () => {
          if (sections.length === 0) return DIFF_FILE_CONCURRENCY;
          const admits = (left: number, spent: number) =>
            Math.ceil(left / Math.max(1, spent / sections.length));
          return Math.max(
            1,
            Math.min(
              DIFF_FILE_CONCURRENCY,
              // The file budget needs no estimate: a file spends exactly one of it.
              MAX_DIFF_SLICE_FILES - sections.length,
              admits(MAX_DIFF_SLICE_BYTES - bytes, bytes),
              admits(MAX_DIFF_SLICE_EDITS - MAX_FILE_DIFF_EDITS - edits, edits),
            ),
          );
        };
        while (!full && index < changes.length) {
          const batch = changes.slice(index, index + batchWidth());
          const read = yield* Effect.forEach(
            batch,
            (change) =>
              // A pair Azure refuses is one file rather than the whole slice: an oversize blob or
              // a path `az` will not carry through leaves that file listed without its hunks, and
              // everything around it still renders.
              readTexts({ cwd: input.cwd, location: scope.location, iteration, change }).pipe(
                // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az`
                // at all is the read failing rather than the file, and belongs to the caller,
                // which pauses the host rather than showing every file as unreadable.
                Effect.catchTags({
                  AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null),
                  AzureDevOpsCommandFailedError: () => Effect.succeed(null),
                  AzureDevOpsPullRequestReadError: () => Effect.succeed(null),
                }),
                Effect.map((texts) => ({ change, texts })),
              ),
            { concurrency: DIFF_FILE_CONCURRENCY },
          );
          for (const { change, texts } of read) {
            // The diff is synchronous and the reads no longer stand between one file and the next
            // to let anything else on the server run, so the thread is handed back here.
            yield* Effect.yieldNow;
            const file =
              texts === null
                ? azureDevOpsUnreadableFilePatch(change)
                : azureDevOpsFilePatch({ change, texts });
            sections.push(file.section);
            bytes += byteLength(file.section);
            edits += file.edits;
            truncated = truncated || file.truncated;
            index += 1;
            // A file whose diff was given up on spent the whole of what one file is allowed and
            // has only a header to show for it, and a file the diff never ran on at all weighs
            // almost nothing in either budget and still costs its two reads. So the file count
            // bounds the request alongside the bytes.
            // Checked after the file is added rather than before it, so every slice carries at
            // least one: a section heavier than the whole budget would otherwise never be added,
            // and the read would answer the same slice forever without moving the cursor.
            if (
              bytes >= MAX_DIFF_SLICE_BYTES ||
              edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS ||
              sections.length >= MAX_DIFF_SLICE_FILES ||
              file.abandoned
            ) {
              full = true;
              break;
            }
          }
        }

        const slice: ProviderDiffSlice = {
          patch: sections.join(""),
          truncated,
          nextCursor:
            index >= changes.length
              ? null
              : formatAzureDevOpsDiffCursor({ iterationId: iteration.id, fileIndex: index }),
        };
        return slice;
      }).pipe(Effect.mapError(fail("getDiff"))),

    // The patch is built from whole files, so opening the lines around a hunk is the same two
    // reads over again rather than a wider request. Read against the latest iteration: nothing in
    // the request says which push the reader is looking at, and expansion is stale after a
    // mid-review push on every host here anyway.
    getDiffFileContents: (input) =>
      Effect.gen(function* () {
        const scope = yield* diffScope(input);
        const iteration = scope?.iterations.at(-1);
        if (scope === null || iteration === undefined) return { oldContents: "", newContents: "" };
        return yield* readTexts({
          cwd: input.cwd,
          location: scope.location,
          iteration,
          change: {
            changeKind: input.changeType,
            path: input.newPath,
            oldPath: input.oldPath,
          },
        });
      }).pipe(Effect.mapError(fail("getDiffFileContents"))),

    /**
     * What the head has of each marked file, which is the blob Azure already names on the change
     * it reports. One read covers every path, since the latest iteration lists the whole change.
     *
     * A path the change does not carry is at the empty revision, which is where a file the pull
     * request deletes sits. When the change was too long to follow to its end, those paths are
     * left out instead: reporting them as deleted would clear a file nobody has read.
     */
    getFileRevisions: (input) =>
      Effect.gen(function* () {
        const revisions = new Map<string, string>();
        if (input.paths.length === 0) return { revisions };
        const scope = yield* diffScope(input);
        if (scope === null) return { revisions };
        const listed = yield* listLatestChanges({
          ...scope,
          cwd: input.cwd,
          number: input.number,
        });
        const marked = new Set(input.paths);
        for (const change of listed.changes) {
          if (!marked.has(change.path) || change.objectId === null) continue;
          revisions.set(change.path, change.objectId);
        }
        if (!listed.truncated) {
          for (const path of input.paths) {
            if (!revisions.has(path)) revisions.set(path, "");
          }
        }
        return { revisions };
      }).pipe(Effect.mapError(fail("getFileRevisions"))),

    runAction: (input) =>
      cli
        .runPullRequestAction({
          cwd: input.cwd,
          number: input.number,
          action: input.action,
          ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
        })
        .pipe(Effect.mapError(fail("runAction"))),

    updateChangeRequest: (input) =>
      cli
        .updatePullRequest({
          cwd: input.cwd,
          number: input.number,
          title: input.title,
          body: input.body,
        })
        .pipe(Effect.mapError(fail("updateChangeRequest"))),

    // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the
    // list without it.
    listReviewerCandidates: () =>
      Effect.fail(
        new PullRequestProviderError({
          provider: "azure-devops",
          operation: "listReviewerCandidates",
          reason: "failed",
          detail: "Azure DevOps cannot say who may review a pull request.",
        }),
      ),

    setReviewerRequest: (input) =>
      cli
        .setPullRequestReviewers({
          cwd: input.cwd,
          number: input.number,
          // Azure names an identity by an email address or a guid, and has no team to ask, so a
          // candidate's id is the whole of what it takes.
          reviewers: input.reviewers.map((reviewer) => reviewer.id),
          requested: input.requested,
        })
        .pipe(Effect.mapError(fail("setReviewerRequest"))),

    // Never called: `capabilities.comment` is false, and the service refuses a comment without it.
    comment: () => unsupported("comment"),

    // Declared unsupported above, so the service refuses these before a provider is reached.
    // They exist because every provider answers the whole port.
    submitReview: () => unsupported("submitReview"),

    replyToThread: () => unsupported("replyToThread"),

    setThreadResolution: () => unsupported("setThreadResolution"),

    setReaction: () => unsupported("setReaction"),
  };

  return provider;
});