Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ TestPlanIt is a comprehensive test management platform designed to help teams pl
### LLM Integration

- **Test case generation** - Generate test cases from requirements using AI
- **QuickScript AI generation** - Convert manual test cases into automation scripts with AI, optionally informed by your code repository
- **QuickScript AI generation** - Convert manual test cases into automation scripts with AI, optionally informed by your code repository (GitHub, GitLab, Bitbucket, Azure DevOps, Gitea/Forgejo/Gogs)
- **Enhance Writing** - Get AI recommendations to improve writing for any rich text field
- **Magic Select** - AI-assisted test case selection for quickly building test runs
- **Auto Tag** - Automatically suggest and apply tags to test cases, test runs, and sessions using AI analysis
Expand Down
10 changes: 9 additions & 1 deletion docs/docs/user-guide/llm-quickscript.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ Connecting a code repository gives the AI access to your actual source files, en

### 1. Add a Code Repository

Navigate to **Administration > Code Repositories** and add a connection to your repository (GitHub, GitLab, Bitbucket, or Azure DevOps). You'll need to provide credentials with read access to the repository.
Navigate to **Administration > Code Repositories** and add a connection to your repository. Supported providers:

- **GitHub** — github.com or GitHub Enterprise
- **GitLab** — gitlab.com or self-hosted
- **Bitbucket Cloud** — bitbucket.org
- **Azure DevOps** — dev.azure.com
- **Gitea / Forgejo / Gogs** — self-hosted Git servers with a compatible `/api/v1/` REST API

You'll need to provide credentials with read access to the repository.

### 2. Configure QuickScript Settings

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const providerLabel: Record<string, string> = {
GITLAB: "GitLab",
BITBUCKET: "Bitbucket",
AZURE_DEVOPS: "Azure DevOps",
GITEA: "Gitea / Forgejo / Gogs",
};

interface ColumnActions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
} from "@/components/ui/form";
import { HelpPopover } from "@/components/ui/help-popover";
import { Input } from "@/components/ui/input";
import { ShieldAlert } from "lucide-react";
import { useTranslations } from "next-intl";
import { UseFormReturn } from "react-hook-form";

interface FieldConfig {
Expand All @@ -16,6 +18,7 @@ interface FieldConfig {
type?: string; // "password" for sensitive fields
isCredential: boolean; // true = goes in credentials object; false = goes in settings
helpKey?: string;
isUrl?: boolean; // true = show HTTP plaintext warning when value starts with http://
}

// Field definitions per provider -- mirrors IntegrationConfigForm.tsx providerFields pattern
Expand Down Expand Up @@ -66,6 +69,7 @@ const providerFields: Record<string, FieldConfig[]> = {
placeholder: "https://gitlab.com",
isCredential: false,
helpKey: "codeRepository.baseUrl",
isUrl: true,
},
],
BITBUCKET: [
Expand Down Expand Up @@ -114,6 +118,7 @@ const providerFields: Record<string, FieldConfig[]> = {
placeholder: "https://dev.azure.com/myorg",
isCredential: false,
helpKey: "codeRepository.organizationUrl",
isUrl: true,
},
{
name: "project",
Expand All @@ -130,6 +135,38 @@ const providerFields: Record<string, FieldConfig[]> = {
helpKey: "codeRepository.azureRepoId",
},
],
GITEA: [
{
name: "personalAccessToken",
label: "Personal Access Token",
type: "password",
isCredential: true,
placeholder: "...",
helpKey: "codeRepository.giteaToken",
},
{
name: "baseUrl",
label: "Server URL",
placeholder: "https://gitea.example.com",
isCredential: false,
helpKey: "codeRepository.giteaBaseUrl",
isUrl: true,
},
{
name: "owner",
label: "Owner",
placeholder: "myorg",
isCredential: false,
helpKey: "codeRepository.giteaOwner",
},
{
name: "repo",
label: "Repository",
placeholder: "my-repo",
isCredential: false,
helpKey: "codeRepository.giteaRepo",
},
],
};

interface CodeRepositoryConfigFormProps {
Expand All @@ -142,6 +179,7 @@ export function CodeRepositoryConfigForm({
form,
}: CodeRepositoryConfigFormProps) {
const fields = providerFields[provider] ?? [];
const t = useTranslations("admin.codeRepositories");

return (
<div className="space-y-4">
Expand All @@ -155,26 +193,39 @@ export function CodeRepositoryConfigForm({
key={field.name}
control={form.control}
name={formFieldName}
render={({ field: formField }) => (
<FormItem>
<FormLabel className="flex items-center">
{field.label}
<HelpPopover helpKey={field.helpKey ?? ""} />
</FormLabel>
<FormControl>
<Input
{...formField}
value={formField.value ?? ""}
type={field.type ?? "text"}
placeholder={field.placeholder}
autoComplete={
field.type === "password" ? "new-password" : undefined
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
render={({ field: formField }) => {
const showHttpWarning =
field.isUrl &&
typeof formField.value === "string" &&
formField.value.startsWith("http://");

return (
<FormItem>
<FormLabel className="flex items-center">
{field.label}
<HelpPopover helpKey={field.helpKey ?? ""} />
</FormLabel>
<FormControl>
<Input
{...formField}
value={formField.value ?? ""}
type={field.type ?? "text"}
placeholder={field.placeholder}
autoComplete={
field.type === "password" ? "new-password" : undefined
}
/>
</FormControl>
{showHttpWarning && (
<p className="flex items-center gap-1.5 text-sm text-warning">
<ShieldAlert className="h-4 w-4 shrink-0" />
{t("httpWarning")}
</p>
)}
<FormMessage />
</FormItem>
);
}}
/>
);
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ const PROVIDERS = [
{ value: "GITLAB", label: "GitLab" },
{ value: "BITBUCKET", label: "Bitbucket Cloud" },
{ value: "AZURE_DEVOPS", label: "Azure DevOps" },
{ value: "GITEA", label: "Gitea / Forgejo / Gogs" },
] as const;

const formSchema = z.object({
name: z.string().min(1, "Name is required"),
provider: z.enum(["GITHUB", "GITLAB", "BITBUCKET", "AZURE_DEVOPS"]),
provider: z.enum(["GITHUB", "GITLAB", "BITBUCKET", "AZURE_DEVOPS", "GITEA"]),
credentials: z.record(z.string(), z.string()).optional(),
settings: z.record(z.string(), z.string()).optional(),
isActive: z.boolean().optional(),
Expand Down
113 changes: 111 additions & 2 deletions testplanit/lib/integrations/adapters/GitRepoAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AzureDevOpsRepoAdapter } from "./AzureDevOpsRepoAdapter";
import { BitbucketRepoAdapter } from "./BitbucketRepoAdapter";
import { GiteaRepoAdapter } from "./GiteaRepoAdapter";
import { GitHubRepoAdapter } from "./GitHubRepoAdapter";
import { GitLabRepoAdapter } from "./GitLabRepoAdapter";

// Stub fetch so adapters don't error
vi.stubGlobal("fetch", vi.fn());
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

// Mock DNS resolution to avoid real lookups in tests
vi.mock("~/utils/ssrf", async (importOriginal) => {
const actual = await importOriginal<typeof import("~/utils/ssrf")>();
return {
...actual,
assertSsrfSafeResolved: vi.fn().mockResolvedValue(undefined),
};
});

function makeResponse(
data: any,
status = 200,
headers: Record<string, string> = {}
) {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? "OK" : "Error",
headers: new Headers(headers),
json: () => Promise.resolve(data),
text: () => Promise.resolve(typeof data === "string" ? data : JSON.stringify(data)),
url: "https://api.github.com/repos/test/test",
};
}

// The factory uses require() which fails in Vitest's ESM context.
// Instead of testing the factory directly, test that each adapter can be
Expand Down Expand Up @@ -53,6 +80,19 @@ describe("GitRepoAdapter adapter instantiation", () => {
expect((adapter as any).project).toBe("myproject");
});

it("instantiates GiteaRepoAdapter with credentials and settings", () => {
const adapter = new GiteaRepoAdapter(creds, {
baseUrl: "https://gitea.example.com",
owner: "testorg",
repo: "testrepo",
});
expect(adapter).toBeInstanceOf(GiteaRepoAdapter);
expect((adapter as any).personalAccessToken).toBe("test-token");
expect((adapter as any).baseUrl).toBe("https://gitea.example.com");
expect((adapter as any).owner).toBe("testorg");
expect((adapter as any).repo).toBe("testrepo");
});

it("handles null settings gracefully", () => {
const adapter = new GitHubRepoAdapter(creds, null);
expect(adapter).toBeInstanceOf(GitHubRepoAdapter);
Expand All @@ -73,6 +113,7 @@ describe("GitRepoAdapter adapter instantiation", () => {
new GitLabRepoAdapter(creds, {}),
new BitbucketRepoAdapter({ username: "u", appPassword: "p" }, {}),
new AzureDevOpsRepoAdapter(creds, {}),
new GiteaRepoAdapter(creds, { baseUrl: "https://gitea.example.com" }),
];

for (const adapter of adapters) {
Expand All @@ -83,3 +124,71 @@ describe("GitRepoAdapter adapter instantiation", () => {
}
});
});

describe("GitRepoAdapter redirect protection", () => {
let adapter: GitHubRepoAdapter;

beforeEach(() => {
vi.clearAllMocks();
adapter = new GitHubRepoAdapter(
{ personalAccessToken: "test-token" },
{ owner: "testorg", repo: "testrepo" }
);
(adapter as any).rateLimitDelay = 0;
(adapter as any).lastRequestTime = 0;
});

it("follows safe redirects with SSRF validation", async () => {
// First response: redirect
mockFetch.mockResolvedValueOnce({
ok: false,
status: 301,
statusText: "Moved",
headers: new Headers({ Location: "https://api.github.com/repos/testorg/testrepo-new" }),
url: "https://api.github.com/repos/testorg/testrepo",
json: () => Promise.resolve({}),
text: () => Promise.resolve(""),
});
// Second response: actual data after redirect
mockFetch.mockResolvedValueOnce(makeResponse({ default_branch: "main" }));

const branch = await adapter.getDefaultBranch();

expect(branch).toBe("main");
expect(mockFetch).toHaveBeenCalledTimes(2);
// Second fetch should use redirect: "error" to prevent chains
expect(mockFetch.mock.calls[1][1]).toEqual(
expect.objectContaining({ redirect: "error" })
);
});

it("rejects redirect with no Location header", async () => {
// Disable retries so the redirect error propagates directly
(adapter as any).maxRetries = 0;

mockFetch.mockResolvedValue({
ok: false,
status: 302,
statusText: "Found",
headers: new Headers({}),
url: "https://api.github.com/repos/testorg/testrepo",
json: () => Promise.resolve({}),
text: () => Promise.resolve(""),
});

await expect(adapter.getDefaultBranch()).rejects.toThrow(
"Redirect (302) with no Location header"
);
});

it("uses redirect: manual to prevent automatic redirect following", async () => {
mockFetch.mockResolvedValueOnce(makeResponse({ default_branch: "main" }));

await adapter.getDefaultBranch();

expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ redirect: "manual" })
);
});
});
Loading
Loading