feat(core): add scout agent for repo research (#24149)

Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
Shoubhit Dash
2026-05-09 01:50:08 +05:30
committed by GitHub
parent 6e47ae769e
commit 40d5ea1cf1
44 changed files with 1622 additions and 66 deletions

View File

@@ -2,11 +2,11 @@ import { afterEach, test, expect } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Agent } from "../../src/agent/agent"
import { Permission } from "../../src/permission"
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Permission } from "../../src/permission"
// Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
@@ -18,25 +18,38 @@ function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
}
async function withExperimentalScout(enabled: boolean, fn: () => Promise<void>) {
const original = Flag.OPENCODE_EXPERIMENTAL_SCOUT
Flag.OPENCODE_EXPERIMENTAL_SCOUT = enabled
try {
await fn()
} finally {
Flag.OPENCODE_EXPERIMENTAL_SCOUT = original
}
}
afterEach(async () => {
await disposeAllInstances()
})
test("returns default native agents when no config", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const agents = await load(tmp.path, (svc) => svc.list())
const names = agents.map((a) => a.name)
expect(names).toContain("build")
expect(names).toContain("plan")
expect(names).toContain("general")
expect(names).toContain("explore")
expect(names).toContain("compaction")
expect(names).toContain("title")
expect(names).toContain("summary")
},
await withExperimentalScout(false, async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const agents = await load(tmp.path, (svc) => svc.list())
const names = agents.map((a) => a.name)
expect(names).toContain("build")
expect(names).toContain("plan")
expect(names).toContain("general")
expect(names).toContain("explore")
expect(names).not.toContain("scout")
expect(names).toContain("compaction")
expect(names).toContain("title")
expect(names).toContain("summary")
},
})
})
})
@@ -51,6 +64,8 @@ test("build agent has correct default properties", async () => {
expect(build?.native).toBe(true)
expect(evalPerm(build, "edit")).toBe("allow")
expect(evalPerm(build, "bash")).toBe("allow")
expect(evalPerm(build, "repo_clone")).toBe("deny")
expect(evalPerm(build, "repo_overview")).toBe("deny")
},
})
})
@@ -102,6 +117,85 @@ test("explore agent asks for external directories and allows whitelisted externa
})
})
test("scout agent allows repo cloning and repo cache reads", async () => {
await withExperimentalScout(true, async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const scout = await load(tmp.path, (svc) => svc.get("scout"))
expect(scout).toBeDefined()
expect(scout?.mode).toBe("subagent")
expect(evalPerm(scout, "repo_clone")).toBe("allow")
expect(evalPerm(scout, "repo_overview")).toBe("allow")
expect(evalPerm(scout, "edit")).toBe("deny")
expect(
Permission.evaluate(
"external_directory",
path.join(Global.Path.repos, "github.com", "owner", "repo", "README.md"),
scout!.permission,
).action,
).toBe("allow")
},
})
})
})
test("reference config creates scout-backed subagents", async () => {
await withExperimentalScout(true, async () => {
await using tmp = await tmpdir({
config: {
reference: {
effect: "github.com/effect/effect-smol",
effectFull: {
repository: "Effect-TS/effect",
branch: "main",
},
localdocs: "../docs",
localdocsFull: {
path: "../local-docs",
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const effect = await load(tmp.path, (svc) => svc.get("effect"))
const effectFull = await load(tmp.path, (svc) => svc.get("effectFull"))
const local = await load(tmp.path, (svc) => svc.get("localdocs"))
const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull"))
expect(effect).toBeDefined()
expect(effect?.mode).toBe("subagent")
expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol")
expect(evalPerm(effect, "repo_clone")).toBe("allow")
expect(effectFull).toBeDefined()
expect(effectFull?.mode).toBe("subagent")
expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect")
expect(effectFull?.prompt).toContain("Branch/ref: main")
expect(evalPerm(effectFull, "repo_clone")).toBe("allow")
expect(local).toBeDefined()
expect(local?.mode).toBe("subagent")
expect(local?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../docs")}`)
expect(
Permission.evaluate(
"external_directory",
path.join(path.resolve(tmp.path, "../docs"), "README.md"),
local!.permission,
).action,
).toBe("allow")
expect(localFull).toBeDefined()
expect(localFull?.mode).toBe("subagent")
expect(localFull?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../local-docs")}`)
},
})
})
})
test("general agent denies todo tools", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({

View File

@@ -25,6 +25,16 @@ test("parses ssh:// URL without .git suffix", () => {
expect(parseGitHubRemote("ssh://git@github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
})
test("parses git protocol URLs from package metadata", () => {
expect(parseGitHubRemote("git://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" })
expect(parseGitHubRemote("git+https://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" })
expect(parseGitHubRemote("git+ssh://git@github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" })
})
test("parses npm-style github shorthand", () => {
expect(parseGitHubRemote("github:facebook/react")).toBeNull()
})
test("parses http URL", () => {
expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" })
})

View File

@@ -15,6 +15,7 @@ import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "@/provider/provider"
import { Env } from "../../src/env"
import { Git } from "../../src/git"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Question } from "../../src/question"
import { Todo } from "../../src/session/todo"
@@ -178,6 +179,7 @@ function makeHttp() {
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo),

View File

@@ -30,6 +30,7 @@ import { TestLLMServer } from "../lib/llm-server"
// Same layer setup as prompt-effect.test.ts
import { NodeFileSystem } from "@effect/platform-node"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { Git } from "../../src/git"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Config } from "@/config/config"
@@ -128,6 +129,7 @@ function makeHttp() {
Layer.provide(Skill.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo),

View File

@@ -4,6 +4,7 @@ import fs from "fs/promises"
import { Effect, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ToolRegistry } from "@/tool/registry"
import { Flag } from "@opencode-ai/core/flag/flag"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestConfig } from "../fixture/config"
@@ -15,6 +16,7 @@ import { Skill } from "@/skill"
import { Agent } from "@/agent/agent"
import { Session } from "@/session/session"
import { Provider } from "@/provider/provider"
import { Git } from "@/git"
import { LSP } from "@/lsp/lsp"
import { Instruction } from "@/session/instruction"
import { Bus } from "@/bus"
@@ -25,6 +27,7 @@ import * as Truncate from "@/tool/truncate"
import { InstanceState } from "@/effect/instance-state"
const node = CrossSpawnSpawner.defaultLayer
const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT
const configLayer = TestConfig.layer({
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
})
@@ -38,6 +41,7 @@ const registryLayer = ToolRegistry.layer.pipe(
Layer.provide(Agent.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
@@ -52,10 +56,35 @@ const registryLayer = ToolRegistry.layer.pipe(
const it = testEffect(Layer.mergeAll(registryLayer, node))
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout
await disposeAllInstances()
})
describe("tool.registry", () => {
it.instance("hides repo research tools unless experimental", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_SCOUT = false
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).not.toContain("codesearch")
expect(ids).not.toContain("repo_clone")
expect(ids).not.toContain("repo_overview")
}),
)
it.instance("shows repo research tools when experimental scout is enabled", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_SCOUT = true
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("codesearch")
expect(ids).toContain("repo_clone")
expect(ids).toContain("repo_overview")
}),
)
it.instance("loads tools from .opencode/tool (singular)", () =>
Effect.gen(function* () {
const test = yield* TestInstance

View File

@@ -0,0 +1,226 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import { pathToFileURL } from "node:url"
import { Cause, Effect, Exit, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Agent } from "../../src/agent/agent"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Git } from "../../src/git"
import { Global } from "@opencode-ai/core/global"
import { MessageID, SessionID } from "../../src/session/schema"
import { Truncate } from "../../src/tool/truncate"
import { RepoCloneTool } from "../../src/tool/repo_clone"
import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
})
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "scout",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Git.defaultLayer,
Truncate.defaultLayer,
),
)
const init = Effect.fn("RepoCloneToolTest.init")(function* () {
const info = yield* RepoCloneTool
return yield* info.init()
})
const git = Effect.fn("RepoCloneToolTest.git")(function* (cwd: string, args: string[]) {
return yield* Effect.promise(async () => {
const proc = Bun.spawn(["git", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (code !== 0) {
throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
}
return stdout.trim()
})
})
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
return previous
}),
() => self,
(previous) =>
Effect.sync(() => {
if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
}),
)
describe("tool.repo_clone", () => {
it.live("clones a repo into the managed cache and reuses it on subsequent calls", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const source = yield* tmpdirScoped({ git: true })
const remoteRoot = yield* tmpdirScoped()
const remoteDir = path.join(remoteRoot, "owner")
const remoteRepo = path.join(remoteDir, "repo.git")
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add readme"])
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
const tool = yield* init()
const cloned = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx))
const cached = yield* githubBase(
`file://${remoteRoot}/`,
tool.execute({ repository: "https://github.com/owner/repo.git" }, ctx),
)
expect(cloned.metadata.status).toBe("cloned")
expect(cloned.metadata.localPath).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo"))
expect(cached.metadata.status).toBe("cached")
expect(yield* fs.readFileString(path.join(cloned.metadata.localPath, "README.md"))).toBe("v1\n")
}),
),
)
it.live("refresh updates an existing cached clone", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const source = yield* tmpdirScoped({ git: true })
const remoteRoot = yield* tmpdirScoped()
const remoteDir = path.join(remoteRoot, "owner")
const remoteRepo = path.join(remoteDir, "repo.git")
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add readme"])
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
const branch = yield* git(source, ["branch", "--show-current"])
yield* git(source, ["remote", "add", "origin", remoteRepo])
yield* git(source, ["push", "-u", "origin", `${branch}:${branch}`])
const tool = yield* init()
const first = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx))
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n"))
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "update readme"])
yield* git(source, ["push", "origin", `${branch}:${branch}`])
const refreshed = yield* githubBase(
`file://${remoteRoot}/`,
tool.execute({ repository: "owner/repo", refresh: true }, ctx),
)
expect(first.metadata.status).toBe("cloned")
expect(refreshed.metadata.status).toBe("refreshed")
expect(yield* fs.readFileString(path.join(first.metadata.localPath, "README.md"))).toBe("v2\n")
}),
),
)
it.live("clones a configured branch", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const source = yield* tmpdirScoped({ git: true })
const remoteRoot = yield* tmpdirScoped()
const remoteDir = path.join(remoteRoot, "owner")
const remoteRepo = path.join(remoteDir, "repo.git")
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "main\n"))
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add readme"])
yield* git(source, ["checkout", "-b", "docs"])
yield* Effect.promise(() => Bun.write(path.join(source, "DOCS.md"), "docs\n"))
yield* git(source, ["add", "."])
yield* git(source, ["commit", "-m", "add docs"])
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
const tool = yield* init()
const result = yield* githubBase(
`file://${remoteRoot}/`,
tool.execute({ repository: "owner/repo", branch: "docs" }, ctx),
)
expect(result.metadata.status).toBe("cloned")
expect(result.metadata.branch).toBe("docs")
expect(yield* fs.readFileString(path.join(result.metadata.localPath, "DOCS.md"))).toBe("docs\n")
}),
),
)
it.live("rejects invalid repository inputs", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const tool = yield* init()
const inputs = [
{ repository: "not-a-repo", message: "git URL" },
{ repository: "git@github.com:../../../etc/passwd", message: "git URL" },
{ repository: "-u:foo/bar", message: "git URL" },
{ repository: pathToFileURL(path.join(_dir, "local.git")).href, message: "Local file" },
]
yield* Effect.forEach(
inputs,
(input) =>
Effect.gen(function* () {
const result = yield* tool.execute({ repository: input.repository }, ctx).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
if (Exit.isFailure(result)) {
const error = Cause.squash(result.cause)
expect(error instanceof Error ? error.message : String(error)).toContain(input.message)
}
}),
{ discard: true },
)
}),
),
)
it.live("rejects local file repository URLs", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const source = yield* tmpdirScoped({ git: true })
const tool = yield* init()
const result = yield* tool.execute({ repository: pathToFileURL(source).href }, ctx).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
if (Exit.isFailure(result)) {
const error = Cause.squash(result.cause)
expect(error instanceof Error ? error.message : String(error)).toContain("Local file")
}
}),
),
)
})

View File

@@ -0,0 +1,150 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import { Cause, Effect, Exit, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Agent } from "../../src/agent/agent"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Git } from "../../src/git"
import { Global } from "@opencode-ai/core/global"
import { MessageID, SessionID } from "../../src/session/schema"
import { Truncate } from "../../src/tool/truncate"
import { RepoOverviewTool } from "../../src/tool/repo_overview"
import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
})
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "scout",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Git.defaultLayer,
Truncate.defaultLayer,
),
)
const init = Effect.fn("RepoOverviewToolTest.init")(function* () {
const info = yield* RepoOverviewTool
return yield* info.init()
})
describe("tool.repo_overview", () => {
it.live("summarizes a local repository path", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const repo = yield* tmpdirScoped({ git: true })
const fs = yield* AppFileSystem.Service
yield* fs.writeWithDirs(
path.join(repo, "package.json"),
JSON.stringify(
{
name: "example-repo",
main: "dist/index.js",
module: "dist/index.mjs",
types: "dist/index.d.ts",
exports: {
".": "./dist/index.js",
"./server": "./dist/server.js",
},
bin: {
example: "./bin/example.js",
},
},
null,
2,
),
)
yield* fs.writeWithDirs(path.join(repo, "bun.lock"), "")
yield* fs.writeWithDirs(path.join(repo, "README.md"), "# Example\n")
yield* fs.writeWithDirs(path.join(repo, "src", "index.ts"), "export const value = 1\n")
const tool = yield* init()
const result = yield* tool.execute({ path: repo }, ctx)
expect(result.metadata.path).toBe(repo)
expect(result.metadata.ecosystems).toContain("Node.js")
expect(result.metadata.package_manager).toBe("bun")
expect(result.metadata.dependency_files).toEqual(expect.arrayContaining(["package.json", "bun.lock"]))
expect(result.metadata.entrypoints).toEqual(
expect.arrayContaining([
"main: dist/index.js",
"module: dist/index.mjs",
"types: dist/index.d.ts",
"exports: .",
"exports: ./server",
"bin: example",
"file: src/index.ts",
]),
)
expect(result.output).toContain("Top-level structure:")
expect(result.output).toContain("src/")
expect(result.output).toContain("README.md")
}),
),
)
it.live("resolves a cached repository from repository shorthand", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const cached = path.join(Global.Path.repos, "github.com", "owner", "repo")
yield* fs.writeWithDirs(path.join(cached, "package.json"), JSON.stringify({ name: "cached-repo" }, null, 2))
yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n")
const tool = yield* init()
const result = yield* tool.execute({ repository: "owner/repo" }, ctx)
expect(result.metadata.path).toBe(cached)
expect(result.metadata.repository).toBe("owner/repo")
expect(result.output).toContain("Repository: owner/repo")
expect(result.output).toContain(`Path: ${cached}`)
}),
),
)
it.live("fails clearly when a repository is not cloned", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const tool = yield* init()
const result = yield* tool.execute({ repository: "missing/repo" }, ctx).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
if (Exit.isFailure(result)) {
const error = Cause.squash(result.cause)
expect(error instanceof Error ? error.message : String(error)).toContain("Use repo_clone first")
}
}),
),
)
it.live("resolves cached repositories from host/path references", () =>
provideTmpdirInstance((_dir) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const cached = path.join(Global.Path.repos, "gitlab.com", "group", "repo")
yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n")
const tool = yield* init()
const result = yield* tool.execute({ repository: "gitlab.com/group/repo" }, ctx)
expect(result.metadata.path).toBe(cached)
expect(result.metadata.repository).toBe("gitlab.com/group/repo")
expect(result.output).toContain("Repository: gitlab.com/group/repo")
}),
),
)
})