Files
PaperClipAI/server/src/__tests__/git-credentials.test.ts
T
Devin Foleyanddevinfoley cfed739f81 fix(external-objects): stop reporting GitHub rate limits as unreachable (#17792)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/11/changes/paperclip/11
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip is the open source app that people use to manage AI agents
for work.
> - Paperclip shows the status of external objects, such as GitHub pull
requests and issues.
> - A background sweep refreshes these objects and resolves a GitHub
credential.
> - The sweep read a credential only from a company secret with a
well-known name.
> - An instance without that secret made anonymous GitHub API calls.
Anonymous calls are limited to 60 per hour.
> - When the limit was reached, the sweep showed "unreachable" for
objects that it could not refresh.
> - A server environment token is instance-wide, so it must not expose a
private repository that a company cannot see.
> - This pull request reads a server token for public repositories,
reports a rate limit as its own state, and shows a warning on the pill.
> - The benefit is an accurate status, fewer misleading failures, and no
cross-company private data.

## Linked Issues or Issue Description

No public GitHub issue exists. The sections below describe the bug.

**What happened?**

The board showed "unreachable" for many GitHub links, even after a
related fix was deployed.

**Expected behavior**

The board should show "unreachable" only for real network failures. It
should show a rate limit as its own state and retry later.

**Steps to reproduce**

1. Use an instance with no GitHub token company secret and no server
token.
2. Let the external-object sweep make more than 60 anonymous GitHub
requests in one hour.
3. Open a task with GitHub links.

**Paperclip version or commit**

master (commit a67a18c at report time).

**Deployment mode**

Self-hosted server.

## What Changed

- Add `resolveGitHubApiCredential` in
`server/src/services/git-credentials.ts`. It reads a company secret with
a well-known name, then falls back to the server environment token
(`GITHUB_TOKEN`/`GH_TOKEN`), and it reports the credential `source`.
- Use the source in the GitHub external-object provider and in
`github-commit-details.ts`. A `server_env` token is limited to public
repositories. A private repository resolves only with a company
credential; otherwise the object reports `auth_required` and does not
fetch the private object.
- Add a `rate_limited` liveness state in
`packages/shared/src/constants.ts`.
- Classify a 403 with `x-ratelimit-remaining: 0`, a 429 response, and a
rate-limited repo probe as `rate_limited` instead of `unreachable`.
- Show `rate_limited` in the UI filter, icon, label, tone, overlay, and
badge maps.
- Make `ExternalObjectPill` and `ExternalObjectStatusIcon` prefer a
liveness warning icon and tone, so a rate-limited object shows a warning
even when the last status was healthy.
- Add tests for the credential source, the public-only server token, the
company-scoped private repository, and the rate-limited pill tone.

## Verification

- `vitest run server/src/__tests__/external-objects-service.test.ts
server/src/__tests__/git-credentials.test.ts
server/src/__tests__/github-commit-details.test.ts
server/src/__tests__/work-products.test.ts` — pass, with the new scoping
and credential-source tests.
- `vitest run ui/src/lib/external-objects.test.ts
ui/src/lib/issue-filters.test.ts
ui/src/components/ExternalObjectPill.test.tsx
ui/src/components/MarkdownExternalLink.test.tsx` — pass, with the new
rate-limited pill test.
- `pnpm --filter @paperclipai/shared typecheck` — pass. The full
monorepo `tsc` (server and ui) is killed by the 4GB memory cap of this
sandbox; the authoritative CI Typecheck gate runs it instead.
- One test in `git-credentials.test.ts` fails in this sandbox only. The
test "credential helper execution (real git, no network) ... env-carried
token" reads a global GitHub credential helper in the sandbox. The test
fails the same way on the base without this change.

## Risks

- Low risk. The change adds a liveness state and a credential source. It
does not change existing terminal verdicts.
- A server-environment token cannot resolve a private repository. This
is intentional. An operator who needs private repositories must add a
GitHub token as a company secret. The object then reports
`auth_required` with that instruction.
- `rate_limited` is a new value in `EXTERNAL_OBJECT_LIVENESS_STATES`. An
old client ignores an unknown value.

## Model Used

- Provider: DeepSeek (OpenRouter).
- Exact model ID: `openrouter/deepseek/deepseek-v4.1-flash`.
- Capabilities: tool use and code execution in the Paperclip runner.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for similar open and closed PRs and
confirmed this is not a duplicate
- [x] I have either (a) linked existing issues with `Fixes:` / `Closes`
/ `Refs:` OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
2026-10-26 16:13:04 -07:00

446 lines
19 KiB
TypeScript

import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import {
DEFAULT_GITHUB_TOKEN_SECRET_NAMES,
GIT_CREDENTIAL_TOKEN_ENV_KEY,
buildGitAuthInvocation,
createGitRemoteAuthProvider,
createIssueGitRemoteAuthProvider,
describeGitAuthFailure,
isGitHubHttpsRemoteUrl,
resolveGitHubApiCredential,
scrubGitCredentialText,
} from "../services/git-credentials.ts";
const fakeDb = null as unknown as Db;
function buildSecretsFake(byName: Record<string, string | Error>) {
const getByName = vi.fn(async (_companyId: string, name: string) => {
if (!(name in byName)) return null;
return { id: `secret-${name}` };
});
const resolveSecretValue = vi.fn(async (_companyId: string, secretId: string) => {
const name = secretId.replace(/^secret-/, "");
const value = byName[name];
if (value instanceof Error) throw value;
return value ?? "";
});
return { getByName, resolveSecretValue };
}
describe("isGitHubHttpsRemoteUrl", () => {
it("accepts https github.com and www.github.com URLs", () => {
expect(isGitHubHttpsRemoteUrl("https://github.com/example/repo.git")).toBe(true);
expect(isGitHubHttpsRemoteUrl("https://www.github.com/example/repo.git")).toBe(true);
});
it("rejects ssh, http, enterprise hosts, other providers, userinfo URLs, and non-URLs", () => {
expect(isGitHubHttpsRemoteUrl("git@github.com:example/repo.git")).toBe(false);
expect(isGitHubHttpsRemoteUrl("ssh://git@github.com/example/repo.git")).toBe(false);
expect(isGitHubHttpsRemoteUrl("http://github.com/example/repo.git")).toBe(false);
expect(isGitHubHttpsRemoteUrl("https://github.enterprise.example/org/repo.git")).toBe(false);
expect(isGitHubHttpsRemoteUrl("https://gitlab.com/example/repo.git")).toBe(false);
expect(isGitHubHttpsRemoteUrl("https://alice:token@github.com/example/repo.git")).toBe(false);
expect(isGitHubHttpsRemoteUrl("/local/path/repo.git")).toBe(false);
});
});
describe("resolveGitHubApiCredential", () => {
it("prefers a company secret over the server environment and marks it company-scoped", async () => {
const secrets = buildSecretsFake({ GH_TOKEN: "gh-token" });
const credential = await resolveGitHubApiCredential(fakeDb, "company-1", {
secrets,
env: { GITHUB_TOKEN: "env-token" },
});
expect(credential).toEqual({ token: "gh-token", source: "company_secret", secretName: "GH_TOKEN" });
});
it("falls back to the server environment and marks it server_env so callers can restrict it", async () => {
const secrets = buildSecretsFake({});
const credential = await resolveGitHubApiCredential(fakeDb, "company-1", {
secrets,
env: { GITHUB_TOKEN: "env-token" },
});
expect(credential).toEqual({ token: "env-token", source: "server_env", secretName: null });
});
it("returns null when neither a secret nor the environment provides a token", async () => {
const secrets = buildSecretsFake({});
const credential = await resolveGitHubApiCredential(fakeDb, "company-1", { secrets, env: {} });
expect(credential).toBeNull();
});
});
describe("createGitRemoteAuthProvider", () => {
const githubUrl = "https://github.com/example/repo.git";
it("prefers company secrets in declared order", async () => {
const secrets = buildSecretsFake({ GH_TOKEN: "gh-token", PAPERCLIP_GITHUB_TOKEN: "pc-token" });
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
secrets,
env: { GITHUB_TOKEN: "env-token" },
});
const invocation = await provider(githubUrl);
expect(invocation?.env[GIT_CREDENTIAL_TOKEN_ENV_KEY]).toBe("gh-token");
expect(invocation?.source).toBe("company_secret");
expect(invocation?.secretName).toBe("GH_TOKEN");
// GITHUB_TOKEN is probed first even though only GH_TOKEN exists.
expect(secrets.getByName.mock.calls.map((call) => call[1])).toEqual(["GITHUB_TOKEN", "GH_TOKEN"]);
});
it("falls back to the server env, GITHUB_TOKEN before GH_TOKEN", async () => {
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
secrets: buildSecretsFake({}),
env: { GITHUB_TOKEN: "env-github", GH_TOKEN: "env-gh" },
});
const invocation = await provider(githubUrl);
expect(invocation?.env[GIT_CREDENTIAL_TOKEN_ENV_KEY]).toBe("env-github");
expect(invocation?.source).toBe("server_env");
expect(invocation?.secretName).toBeNull();
});
it("returns null when no token is available anywhere", async () => {
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
secrets: buildSecretsFake({}),
env: {},
});
await expect(provider(githubUrl)).resolves.toBeNull();
});
it("accepts GitHub SSH remotes for process-scoped HTTPS rewriting", async () => {
const secrets = buildSecretsFake({ GITHUB_TOKEN: "token" });
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
secrets,
env: {},
});
const invocation = await provider("git@github.com:example/repo.git");
expect(invocation?.env.GIT_CONFIG_VALUE_3).toBe("git@github.com:");
expect(invocation?.env.GIT_CONFIG_KEY_3).toBe("url.https://github.com/.insteadOf");
});
it("returns null for non-GitHub URLs without touching the secret store", async () => {
const secrets = buildSecretsFake({ GITHUB_TOKEN: "token" });
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
secrets,
env: {},
});
await expect(provider("https://gitlab.com/example/repo.git")).resolves.toBeNull();
expect(secrets.getByName).not.toHaveBeenCalled();
});
it("memoizes the credential lookup across calls", async () => {
const secrets = buildSecretsFake({ GITHUB_TOKEN: "token" });
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
secrets,
env: {},
});
await provider(githubUrl);
await provider(githubUrl);
await provider("https://github.com/example/another.git");
expect(secrets.getByName).toHaveBeenCalledTimes(1);
expect(secrets.resolveSecretValue).toHaveBeenCalledTimes(1);
});
it("passes a system access context so resolution is audited", async () => {
const secrets = buildSecretsFake({ GITHUB_TOKEN: "token" });
const provider = createGitRemoteAuthProvider(
fakeDb,
"company-1",
{ issueId: "issue-1", heartbeatRunId: "run-1" },
{ secrets, env: {} },
);
await provider(githubUrl);
expect(secrets.resolveSecretValue).toHaveBeenCalledWith("company-1", "secret-GITHUB_TOKEN", "latest", {
accessContext: expect.objectContaining({
consumerType: "system",
consumerId: "workspace-git-credential",
actorType: "system",
issueId: "issue-1",
heartbeatRunId: "run-1",
}),
});
});
it("continues down the chain when one secret fails to resolve", async () => {
const secrets = buildSecretsFake({
GITHUB_TOKEN: new Error("provider outage"),
GH_TOKEN: "gh-token",
});
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
secrets,
env: {},
});
const invocation = await provider(githubUrl);
expect(invocation?.secretName).toBe("GH_TOKEN");
});
it("ignores a managed connection installed only for another agent", async () => {
const query = (rows: unknown[]) => ({
from: () => ({ where: async () => rows }),
});
const db = {
select: vi.fn()
.mockReturnValueOnce(query([{
id: "github-connection",
companyId: "company-1",
enabled: true,
status: "active",
config: { sourceTemplateKey: "github" },
}]))
.mockReturnValueOnce(query([{
connectionId: "github-connection",
companyId: "company-1",
targetType: "agent",
targetId: "agent-a",
}])),
} as unknown as Db;
const secrets = buildSecretsFake({ GH_TOKEN: "agent-b-legacy-token" });
const provider = createGitRemoteAuthProvider(db, "company-1", { agentId: "agent-b" }, {
secrets,
env: {},
});
const invocation = await provider(githubUrl);
expect(invocation?.source).toBe("company_secret");
expect(invocation?.secretName).toBe("GH_TOKEN");
expect(invocation?.env[GIT_CREDENTIAL_TOKEN_ENV_KEY]).toBe("agent-b-legacy-token");
expect(db.select).toHaveBeenCalledTimes(2);
});
});
describe("buildGitAuthInvocation", () => {
it("keeps the token out of argv and installs the helper URL-scoped to github.com", () => {
const invocation = buildGitAuthInvocation({
token: "super-secret-token",
source: "company_secret",
secretName: "GITHUB_TOKEN",
});
expect(invocation.configArgs.join(" ")).not.toContain("super-secret-token");
expect(invocation.configArgs[0]).toBe("-c");
expect(invocation.configArgs[1]).toBe("credential.helper=");
expect(invocation.configArgs[3]).toContain("credential.https://github.com.helper=");
expect(invocation.configArgs[3]).toContain("x-access-token");
expect(invocation.configArgs[5]).toContain("credential.https://www.github.com.helper=");
expect(invocation.env[GIT_CREDENTIAL_TOKEN_ENV_KEY]).toBe("super-secret-token");
expect(invocation.env.GH_TOKEN).toBe("super-secret-token");
expect(invocation.env.GITHUB_TOKEN).toBe("super-secret-token");
expect(invocation.env.GIT_TERMINAL_PROMPT).toBe("0");
expect(invocation.env).not.toHaveProperty("HOME");
});
it("sets GitHub's stable noreply commit identity without exposing the token in config", () => {
const invocation = buildGitAuthInvocation({
token: "super-secret-token",
source: "managed_connection",
secretName: null,
githubIdentity: { userId: "12345", login: "octocat" },
});
expect(invocation.env.GIT_CONFIG_KEY_7).toBe("user.name");
expect(invocation.env.GIT_CONFIG_VALUE_7).toBe("octocat");
expect(invocation.env.GIT_CONFIG_KEY_8).toBe("user.email");
expect(invocation.env.GIT_CONFIG_VALUE_8).toBe("12345+octocat@users.noreply.github.com");
expect(invocation.env.GIT_AUTHOR_NAME).toBe("octocat");
expect(invocation.env.GIT_AUTHOR_EMAIL).toBe("12345+octocat@users.noreply.github.com");
expect(invocation.env.GIT_COMMITTER_NAME).toBe("octocat");
expect(invocation.env.GIT_COMMITTER_EMAIL).toBe("12345+octocat@users.noreply.github.com");
expect(Object.values(invocation.env).filter((value) => value.includes("super-secret-token"))).toHaveLength(3);
});
});
describe("credential helper execution (real git, no network)", () => {
async function runCredentialFill(description: string) {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-cred-fill-"));
try {
const invocation = buildGitAuthInvocation({
token: "abc123",
source: "company_secret",
secretName: "GITHUB_TOKEN",
});
return await new Promise<{ code: number | null; stdout: string; stderr: string }>(
(resolve, reject) => {
const child = spawn("git", [...invocation.configArgs, "credential", "fill"], {
cwd,
env: { ...process.env, ...invocation.env },
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
child.on("error", reject);
child.on("close", (code) => resolve({ code, stdout, stderr }));
child.stdin.write(description);
child.stdin.end();
},
);
} finally {
await fs.rm(cwd, { recursive: true, force: true });
}
}
it("answers a github.com https request with the env-carried token", async () => {
const result = await runCredentialFill("protocol=https\nhost=github.com\n\n");
expect(result.code).toBe(0);
expect(result.stdout).toContain("username=x-access-token");
expect(result.stdout).toContain("password=abc123");
});
it("never hands the token to another host, even if git asks", async () => {
// Simulates a request whose effective host changed after our pre-invocation URL check
// (for example a repository-local url.<base>.insteadOf rewrite): the URL-scoped helper
// config keeps git from consulting the helper, prompts are disabled, so the fill fails
// and the token is never emitted.
const result = await runCredentialFill("protocol=https\nhost=evil.example\n\n");
expect(result.code).not.toBe(0);
expect(result.stdout).not.toContain("abc123");
});
it("never answers plain-http requests for github.com", async () => {
const result = await runCredentialFill("protocol=http\nhost=github.com\n\n");
expect(result.code).not.toBe(0);
expect(result.stdout).not.toContain("abc123");
});
});
describe("scrubGitCredentialText", () => {
it("masks URL userinfo", () => {
expect(scrubGitCredentialText("https://x-access-token:ghp_secret@github.com/a/b.git")).toBe(
"https://***@github.com/a/b.git",
);
expect(scrubGitCredentialText(
'fatal: unable to access "https://x-access-token:ghp_abc123@github.com/example/repo.git"',
)).toBe('fatal: unable to access "https://***@github.com/example/repo.git"');
expect(scrubGitCredentialText(
"clone https://alice@github.com/a.git then http://token@internal.example/b.git",
)).toBe("clone https://***@github.com/a.git then http://***@internal.example/b.git");
});
it("masks userinfo on non-HTTP schemes, leaving scp-style remotes alone", () => {
expect(scrubGitCredentialText("ssh://deploy:hunter2@internal.example/repo.git")).toBe(
"ssh://***@internal.example/repo.git",
);
expect(scrubGitCredentialText("git@github.com:example/repo.git")).toBe(
"git@github.com:example/repo.git",
);
expect(scrubGitCredentialText(
"fatal: cannot clone ssh://deploy:hunter2@internal.example/repo.git or git+ssh://bob@host/x.git",
)).toBe("fatal: cannot clone ssh://***@internal.example/repo.git or git+ssh://***@host/x.git");
expect(scrubGitCredentialText("fetch from git@github.com:example/repo.git failed")).toBe(
"fetch from git@github.com:example/repo.git failed",
);
});
it("masks entire URL query strings regardless of parameter names", () => {
expect(scrubGitCredentialText("https://github.com/a/b.git?access_token=ghs_secret&ref=main")).toBe(
"https://github.com/a/b.git?***",
);
expect(scrubGitCredentialText("https://host.example/r.git?obscure_cred_name=secret")).toBe(
"https://host.example/r.git?***",
);
expect(scrubGitCredentialText(
"fatal: unable to access 'https://github.com/example/repo.git?access_token=ghs_secret&ref=main'",
)).toBe("fatal: unable to access 'https://github.com/example/repo.git?***'");
expect(scrubGitCredentialText(
"clone https://gitlab.example/repo.git?anything=glpat-123 failed",
)).toBe("clone https://gitlab.example/repo.git?*** failed");
});
it("leaves credential-free text unchanged", () => {
expect(scrubGitCredentialText("fatal: repository not found")).toBe("fatal: repository not found");
const text = 'Failed to clone "https://github.com/example/repo.git": exit code 128';
expect(scrubGitCredentialText(text)).toBe(text);
const plain = "git clone failed with exit code=128 at key step ref=main";
expect(scrubGitCredentialText(plain)).toBe(plain);
});
});
describe("describeGitAuthFailure", () => {
it("names the company secret when a stored credential was used", () => {
expect(describeGitAuthFailure({
error: "fatal: Authentication failed",
used: { source: "company_secret", secretName: "GH_TOKEN" },
})).toContain("the GH_TOKEN company-secret GitHub credential");
});
it("names the server environment when an env credential was used", () => {
expect(describeGitAuthFailure({
error: "fatal: Authentication failed",
used: { source: "server_env", secretName: null },
})).toContain("server-environment GitHub credential");
});
it("points at Settings → Secrets for auth-looking failures without a credential", () => {
expect(describeGitAuthFailure({
error: "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
used: null,
})).toContain("add a GITHUB_TOKEN or GH_TOKEN company secret");
});
it("stays silent for non-auth failures without a credential", () => {
expect(describeGitAuthFailure({
error: "fatal: unable to resolve host example.invalid",
used: null,
})).toBeNull();
});
it("stays silent for non-auth failures even when a credential was used", () => {
// A credential present during an unrelated failure (network outage, target-path
// collision) must not be blamed for it.
expect(describeGitAuthFailure({
error: "fatal: destination path '/x/y' already exists and is not an empty directory.",
used: { source: "company_secret", secretName: "GH_TOKEN" },
})).toBeNull();
});
});
describe("DEFAULT_GITHUB_TOKEN_SECRET_NAMES", () => {
it("keeps the shared name order stable", () => {
expect([...DEFAULT_GITHUB_TOKEN_SECRET_NAMES]).toEqual([
"GITHUB_TOKEN",
"GH_TOKEN",
"PAPERCLIP_GITHUB_TOKEN",
]);
});
});
describe("createIssueGitRemoteAuthProvider", () => {
const githubUrl = "https://github.com/paperclipai/private-repo.git";
function issueDb(rows: unknown[]) {
const where = vi.fn(async () => rows);
return { db: { select: vi.fn(() => ({ from: () => ({ where }) })) } as unknown as Db, where };
}
it("resolves credentials with the issue's responsible user and assigned agent", async () => {
const { db } = issueDb([{ responsibleUserId: "user-1", assigneeAgentId: "agent-1" }]);
const inner = vi.fn(async () => ({ env: {}, configArgs: [], source: "managed_connection" }));
const create = vi.fn(() => inner) as unknown as typeof createGitRemoteAuthProvider;
const provider = createIssueGitRemoteAuthProvider(db, "company-1", "issue-1", create);
await expect(provider(githubUrl)).resolves.toMatchObject({ source: "managed_connection" });
expect(create).toHaveBeenCalledWith(db, "company-1", { issueId: "issue-1", responsibleUserId: "user-1", agentId: "agent-1" });
expect(inner).toHaveBeenCalledWith(githubUrl);
});
it("looks the issue up once across calls", async () => {
const { db } = issueDb([{ responsibleUserId: "user-1", assigneeAgentId: null }]);
const inner = vi.fn(async () => null);
const create = vi.fn(() => inner) as unknown as typeof createGitRemoteAuthProvider;
const provider = createIssueGitRemoteAuthProvider(db, "company-1", "issue-1", create);
await provider(githubUrl); await provider(githubUrl);
expect(db.select).toHaveBeenCalledTimes(1);
expect(create).toHaveBeenCalledTimes(1);
expect(inner).toHaveBeenCalledTimes(2);
});
it("falls back to the issue-only context when the issue is missing", async () => {
const { db } = issueDb([]);
const create = vi.fn(() => vi.fn(async () => null)) as unknown as typeof createGitRemoteAuthProvider;
await createIssueGitRemoteAuthProvider(db, "company-1", "issue-1", create)(githubUrl);
expect(create).toHaveBeenCalledWith(db, "company-1", { issueId: "issue-1", responsibleUserId: null, agentId: null });
});
});