diff --git a/AGENTS.md b/AGENTS.md index 0e67b08..51f0294 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,19 +68,20 @@ test/ **Where to put new code:** -| Task | Location | Notes | -| -------------------- | ----------------------------------- | ------------------------------------------------------------------ | -| Add new provider | `src/providers/` | Copy github.ts as template. Extend the abstract `Provider` base | -| Add new resource | `src/types.ts` → provider files | Define interface in types.ts, implement in each provider | -| Change auth logic | `src/auth.ts` | `resolveToken()` chain — order matters | -| Change cache backend | `src/cache.ts` | `configureStorage()` swaps unstorage driver | -| Fix pagination | `src/pagination.ts` | `parseLinkHeader()` for GitHub/Gitea, `x-next-page` for GitLab | -| Fix error mapping | `src/errors.ts` | `normalizeError()` maps FetchError → ForgesError subtypes | -| Add sub-path export | `build.config.mjs` + `package.json` | Must update both: entries array + exports map | -| Add agent tool | `src/tool-operations.ts` | Executor first, then `src/mcp.ts` and both extensions | -| Change tool schema | `packages/shared/` | MCP and Pi share it; OMP rebuilds it from `pi.typebox` | -| Debug HTTP | `src/http.ts` | `rawFetch()` returns headers, `createHttpClient()` configures auth | -| Add tests | `test/` | Name must match `test/.test.ts` | +| Task | Location | Notes | +| ----------------------------- | ----------------------------------- | ------------------------------------------------------------------ | +| Add new provider | `src/providers/` | Copy github.ts as template. Extend the abstract `Provider` base | +| Add new resource | `src/types.ts` → provider files | Define interface in types.ts, implement in each provider | +| Change contribution templates | `src/provider.ts` + provider files | Keep lists metadata-only; `get` must resolve an exact listed key | +| Change auth logic | `src/auth.ts` | `resolveToken()` chain: order matters | +| Change cache backend | `src/cache.ts` | `configureStorage()` swaps unstorage driver | +| Fix pagination | `src/pagination.ts` | `parseLinkHeader()` for GitHub/Gitea, `x-next-page` for GitLab | +| Fix error mapping | `src/errors.ts` | `normalizeError()` maps FetchError → ForgesError subtypes | +| Add sub-path export | `build.config.mjs` + `package.json` | Must update both: entries array + exports map | +| Add agent tool | `src/tool-operations.ts` | Executor first, then `src/mcp.ts` and both extensions | +| Change tool schema | `packages/shared/` | MCP and Pi share it; OMP rebuilds it from `pi.typebox` | +| Debug HTTP | `src/http.ts` | `rawFetch()` returns headers, `createHttpClient()` configures auth | +| Add tests | `test/` | Name must match `test/.test.ts` | ## Code Conventions @@ -210,6 +211,9 @@ vi.mock("../src/cache.ts", () => ({ cachedFetch: mocks.cachedFetch })); - **GitBucket** works via GitHub provider with custom `baseURL` — no separate provider needed. - **GitLab `/users/:owner/projects`** returns 404 for groups — `listRepos` falls back to `/groups/:owner/projects` only on 404, re-throws other errors. - **GitHub `/issues` returns PRs** — filtered by absence of `pull_request` key. +- **GitHub template scope is explicit:** repository files and inherited owner `.github` defaults are different scopes; local overrides apply independently to issue and pull-request templates. - **GitLab uses `iid`** (project-scoped) not `id` (global) for issue/MR numbers. +- **GitLab template provenance can be hidden:** use the effective template API and leave inherited source fields unknown rather than guessing a group or instance source. - **Gitea uses `limit`** param, not `per_page`. +- **Gitea templates are repository-scoped:** do not claim GitHub-style owner inheritance. - **unstorage memory driver has no TTL** — that's why lru-cache driver is used. diff --git a/README.md b/README.md index f15fae8..354dddb 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ const gt = createProvider("gitea", { ## Agent tools -The same twenty-eight tools - repositories, code search, CI runs, commits, issues, pull requests and their changed files and checks, users, authentication reload, discussion comments, and review threads - are exposed over MCP and through the Pi and OMP extensions. Read tools use the normal token detection chain, then fall back to anonymous access when no credential exists. Writes, `forges_users_authenticated`, and `forges_auth_reload` still require a credential. For a trusted self-hosted endpoint, set the matching local environment variable to the full API base URL: +The same thirty tools - repositories, contribution templates, code search, CI runs, commits, issues, pull requests and their changed files and checks, users, authentication reload, discussion comments, and review threads - are exposed over MCP and through the Pi and OMP extensions. Read tools use the normal token detection chain, then fall back to anonymous access when no credential exists. Writes, `forges_users_authenticated`, and `forges_auth_reload` still require a credential. For a trusted self-hosted endpoint, set the matching local environment variable to the full API base URL: | Platform | Environment variable | | ------------------ | ------------------------ | @@ -120,7 +120,7 @@ Speaks MCP over stdio. Point a client at it: } ``` -An MCP client sees the text a tool returns and nothing else, so the text carries the whole answer as JSON. Issue and pull-request lists and searches drop bodies outright and name the tool that reads one in full, because one page of a busy repository is otherwise large enough to crowd out the conversation that asked for it. Pull-request search also leaves revision details to `forges_pull_requests_get`; GitHub and Gitea search responses do not carry them, and extra detail requests would make one search page unnecessarily expensive. `forges_threads_list` bounds each comment instead - twelve lines, four thousand characters - but keeps every comment of every thread on the page, so ask it for a small `perPage` on a heavily reviewed pull request. `forges_issues_comments` and `forges_pull_requests_comments` carry the same per-comment bound, and their `_get` variants read a single comment whole. +An MCP client sees the text a tool returns and nothing else, so the text carries the whole answer as JSON. Contribution-template lists, issue and pull-request lists, and searches drop bodies outright and name the tool that reads one in full, because one page of a busy repository is otherwise large enough to crowd out the conversation that asked for it. Pull-request search also leaves revision details to `forges_pull_requests_get`; GitHub and Gitea search responses do not carry them, and extra detail requests would make one search page unnecessarily expensive. `forges_threads_list` bounds each comment instead - twelve lines, four thousand characters - but keeps every comment of every thread on the page, so ask it for a small `perPage` on a heavily reviewed pull request. `forges_issues_comments` and `forges_pull_requests_comments` carry the same per-comment bound, and their `_get` variants read a single comment whole. A failure names the status and, on a rate limit, the retry window; it never repeats the endpoint the request went to, so a self-hosted `FORGES_*_BASE_URL` stays out of the model's context even when the platform answers with an error. @@ -138,10 +138,12 @@ The extensions add the details the harnesses render; MCP drops them and keeps th ## API -Every provider gives you eight resources with the same method shapes. Thread semantics still follow the platform: GitHub and GitLab return real multi-comment conversations, while Gitea has no parent id on review comments, so each one comes back as its own single-comment thread. +Every provider gives you nine resources with the same method shapes. Thread semantics still follow the platform: GitHub and GitLab return real multi-comment conversations, while Gitea has no parent id on review comments, so each one comes back as its own single-comment thread. **repos** - `list(owner, opts?)`, `get(owner, repo)` +**contributionTemplates** - `list(owner, repo, kind, opts?)`, `get(owner, repo, kind, key)` + **code** - `search(query, opts?)` **ciRuns** - `list(owner, repo, opts?)` @@ -156,6 +158,8 @@ Every provider gives you eight resources with the same method shapes. Thread sem **threads** - `list(owner, repo, number, opts?)`, `get(owner, repo, number, threadId)`, `reply(owner, repo, number, threadId, input)`, `resolve(owner, repo, number, threadId)`, `unresolve(owner, repo, number, threadId)` +Contribution-template lists accept `issue` or `pull_request` as `kind` and paginate metadata without returning template bodies. Each `ContributionTemplateSummary` carries an opaque `key`, `scope`, `inherited`, `sourceRepository`, `sourcePath`, and `sourceRef`; pass the returned kind and key unchanged to `get` for the complete source body. On GitHub, `scope: "repository"` means a local file under the target repository, such as `agntn/repo` plus `.github/ISSUE_TEMPLATE/bug.yml`. `scope: "owner"` means an inherited default from the `agntn/.github` repository. Local issue and pull-request overrides are resolved independently. GitHub-compatible hosts without GitHub Enterprise headers expose repository scope only instead of guessing at owner inheritance. Discovery follows each platform's recognized locations and precedence but does not lint template frontmatter or form schemas. GitLab uses its effective project template API, including group and instance inheritance; when that API hides the winning inherited source, `scope` is `unknown` and the three source fields are `null` instead of guesses. Gitea and Forgejo expose repository scope only. + Code search accepts `CodeSearchOptions`: `page`, `perPage`, and optional `owner` and `repo` scope. A repository scope requires its owner. GitHub supports global, owner, and repository search. GitLab uses global search, group search for owner-only scope, and project search for repository scope. GitLab requires authentication for every search API call; global and group code search also require Premium or Ultimate with advanced or exact code search enabled. Gitea, Forgejo, and GitHub-compatible hosts without a code-search endpoint return an explicit unsupported error. Results are `CodeSearchItem` rows with `repository`, `path`, and `url`, wrapped in `SearchPageResult` so callers can follow pagination and inspect `incomplete`. For GitHub, `incomplete` is true when the search times out, scope enforcement drops an unexpected row, or the match count exceeds the 1,000-result retrieval cap. CI-run lists accept `ListCiRunsOptions`: `page`, `perPage`, and an optional `branch` filter. They normalize GitHub Actions runs, GitLab pipelines, and Gitea Actions runs to branch, revision SHA, lifecycle status, terminal conclusion, and URL. Commit lists accept `ListCommitOptions`: `page`, `perPage`, `ref`, `path`, `since`, and `until`. They return metadata-only `CommitSummary` rows. Gitea rejects `path` because its API ignores pagination limits for that filter; the other filters remain supported. Commit reads return the SHA, message, author, committer, parent SHAs, URL, and changed-file rows without patches. GitHub pages are collected through its 3,000-file API cap; GitLab reads at most 10,000 rows per call. Gitea per-file counts and withheld GitLab diff counts are `null`. `filesComplete` is `true` when GitHub confirms a complete result and `null` when provider or safety limits make completeness unknowable. Pull-request file lists accept `ListPullRequestFilesOptions`: `page` and `perPage`. They normalize each changed file to path, status, additions, and deletions without returning patches; GitLab counts are `null` when its API withholds a collapsed or oversized diff. Pull-request check lists accept `ListPullRequestChecksOptions`: `page` and `perPage`. They read GitHub check runs for the head SHA, GitLab merge-request pipelines for the current head SHA, and Gitea commit statuses, normalized to name, lifecycle status, terminal conclusion, and URL. Issue and pull request lists accept `ListOptions`: `page`, `perPage`, and `state` (`'open' | 'closed' | 'all'`); repository lists use its pagination fields. Lists return `PageResult` with `items`, `hasNextPage`, `nextPage`, and an optional `totalCount`. Issue and pull-request searches accept the same options and return those fields plus `incomplete`, which is true when the result is known to be partial. Queries keep the selected platform's syntax: GitHub qualifiers work on GitHub, while GitLab and Gitea treat them as text. Pull-request search returns `PullRequestSearchItem`; call `get` for branches, revisions, and mergeability. @@ -217,7 +221,7 @@ const gitea = new GiteaProvider({ token: process.env.GITEA_TOKEN }); console.log(gitea instanceof Provider); // true ``` -`Provider` is the abstract base class for every implementation. It owns the eight resource accessors, while concrete classes implement the typed mappers and platform-specific API operations. Custom providers that do not implement code search, CI runs, or pull-request checks get an explicit unsupported-operation error. +`Provider` is the abstract base class for every implementation. It owns the nine resource accessors, while concrete classes implement the typed mappers and platform-specific API operations. Custom providers that do not implement contribution templates, code search, CI runs, or pull-request checks get an explicit unsupported-operation error. The runtime base class is also available from `@agntn/forges/provider`. The `@agntn/forges/types` subpath contains only TypeScript models and resource interfaces. diff --git a/packages/omp/extensions/forges.ts b/packages/omp/extensions/forges.ts index b6ac8ef..0767942 100644 --- a/packages/omp/extensions/forges.ts +++ b/packages/omp/extensions/forges.ts @@ -35,6 +35,14 @@ export default function forgesOmpExtension(pi: ExtensionAPI): void { ); const owner = Type.String({ description: "Repository owner or organization", minLength: 1 }); const repo = Type.String({ description: "Repository name", minLength: 1 }); + const contributionTemplateKind = Type.Union( + [Type.Literal("issue"), Type.Literal("pull_request")], + { description: "Contribution template kind" }, + ); + const contributionTemplateKey = Type.String({ + description: "Provider key returned by the contribution-template list operation", + minLength: 1, + }); const sha = Type.String({ description: "Commit SHA", minLength: 1 }); const branch = Type.Optional(Type.String({ description: "Filter by branch", minLength: 1 })); const ref = Type.Optional( @@ -68,6 +76,21 @@ export default function forgesOmpExtension(pi: ExtensionAPI): void { const listRepositoriesParameters = Type.Object({ platform, owner, page, perPage }); const repositoryParameters = Type.Object({ platform, owner, repo }); + const listContributionTemplatesParameters = Type.Object({ + platform, + owner, + repo, + kind: contributionTemplateKind, + page, + perPage, + }); + const contributionTemplateParameters = Type.Object({ + platform, + owner, + repo, + kind: contributionTemplateKind, + key: contributionTemplateKey, + }); const codeSearchParameters = Type.Object({ platform, query: Type.String({ @@ -191,6 +214,29 @@ export default function forgesOmpExtension(pi: ExtensionAPI): void { }, }); + pi.registerTool({ + name: "forges_contribution_templates_list", + label: "Forges Contribution Templates", + description: + "List paged metadata for effective issue or pull-request templates, including inheritance provenance", + parameters: listContributionTemplatesParameters, + approval: "read", + async execute(_toolCallId, params) { + return (await loadToolOperations()).listContributionTemplates(params); + }, + }); + + pi.registerTool({ + name: "forges_contribution_templates_get", + label: "Forges Contribution Template", + description: "Get one effective contribution template with its complete source body", + parameters: contributionTemplateParameters, + approval: "read", + async execute(_toolCallId, params) { + return (await loadToolOperations()).getContributionTemplate(params); + }, + }); + pi.registerTool({ name: "forges_code_search", label: "Search Forges Code", diff --git a/packages/pi/extensions/forges.ts b/packages/pi/extensions/forges.ts index cfd2887..78eb89f 100644 --- a/packages/pi/extensions/forges.ts +++ b/packages/pi/extensions/forges.ts @@ -10,11 +10,13 @@ import { codeSearchParameters, commentParameters, commitParameters, + contributionTemplateParameters, createIssueParameters, createPullRequestParameters, listCiRunsParameters, listCommentsParameters, listCommitsParameters, + listContributionTemplatesParameters, listPullRequestChecksParameters, listPullRequestFilesParameters, listRepositoriesParameters, @@ -112,6 +114,35 @@ export default function forgesExtension(pi: ExtensionAPI): void { }, }); + pi.registerTool({ + name: "forges_contribution_templates_list", + label: "Forges Contribution Templates", + description: + "List paged metadata for effective issue or pull-request templates, including inheritance provenance", + promptSnippet: "Discover the contribution templates that apply to a repository.", + promptGuidelines: [ + "Use forges_contribution_templates_list before drafting an issue or pull request; pass one returned kind and key to forges_contribution_templates_get when its full body is needed.", + ], + parameters: listContributionTemplatesParameters, + async execute(_toolCallId, params) { + return (await loadToolOperations()).listContributionTemplates(params); + }, + }); + + pi.registerTool({ + name: "forges_contribution_templates_get", + label: "Forges Contribution Template", + description: "Get one effective contribution template with its complete source body", + promptSnippet: "Read one issue or pull-request template in full.", + promptGuidelines: [ + "Use forges_contribution_templates_get only with the exact kind and key returned by forges_contribution_templates_list.", + ], + parameters: contributionTemplateParameters, + async execute(_toolCallId, params) { + return (await loadToolOperations()).getContributionTemplate(params); + }, + }); + pi.registerTool({ name: "forges_code_search", label: "Search Forges Code", diff --git a/packages/shared/forges-tool-schemas.ts b/packages/shared/forges-tool-schemas.ts index 4263200..a234117 100644 --- a/packages/shared/forges-tool-schemas.ts +++ b/packages/shared/forges-tool-schemas.ts @@ -27,6 +27,15 @@ const platform = Type.Unsafe({ }); const owner = Type.String({ description: "Repository owner or organization", minLength: 1 }); const repo = Type.String({ description: "Repository name", minLength: 1 }); +const contributionTemplateKind = Type.Unsafe<"issue" | "pull_request">({ + type: "string", + enum: ["issue", "pull_request"], + description: "Contribution template kind", +}); +const contributionTemplateKey = Type.String({ + description: "Provider key returned by the contribution-template list operation", + minLength: 1, +}); const sha = Type.String({ description: "Commit SHA", minLength: 1 }); const branch = Type.Optional(Type.String({ description: "Filter by branch", minLength: 1 })); const ref = Type.Optional( @@ -69,6 +78,21 @@ const assignees = Type.Optional( export const listRepositoriesParameters = Type.Object({ platform, owner, page, perPage }); export const repositoryParameters = Type.Object({ platform, owner, repo }); +export const listContributionTemplatesParameters = Type.Object({ + platform, + owner, + repo, + kind: contributionTemplateKind, + page, + perPage, +}); +export const contributionTemplateParameters = Type.Object({ + platform, + owner, + repo, + kind: contributionTemplateKind, + key: contributionTemplateKey, +}); export const codeSearchParameters = Type.Object({ platform, query: Type.String({ diff --git a/src/gitea.ts b/src/gitea.ts index 11c9ca9..682bde7 100644 --- a/src/gitea.ts +++ b/src/gitea.ts @@ -10,6 +10,10 @@ export type { RepositoryParent, RepositoryPermission, Repository, + ContributionTemplateKind, + ContributionTemplateScope, + ContributionTemplateSummary, + ContributionTemplate, IssueState, Issue, PullRequestSearchItem, @@ -22,12 +26,14 @@ export type { SearchPageResult, ListOptions, ListCommentOptions, + ListContributionTemplatesOptions, ListThreadOptions, CreateIssueInput, CreatePullRequestInput, ReplyThreadInput, ProviderConfig, RepositoryResource, + ContributionTemplateResource, IssueResource, PullRequestResource, UserResource, diff --git a/src/github.ts b/src/github.ts index 3feb923..76ff45a 100644 --- a/src/github.ts +++ b/src/github.ts @@ -10,6 +10,10 @@ export type { RepositoryParent, RepositoryPermission, Repository, + ContributionTemplateKind, + ContributionTemplateScope, + ContributionTemplateSummary, + ContributionTemplate, IssueState, Issue, PullRequestSearchItem, @@ -22,12 +26,14 @@ export type { SearchPageResult, ListOptions, ListCommentOptions, + ListContributionTemplatesOptions, ListThreadOptions, CreateIssueInput, CreatePullRequestInput, ReplyThreadInput, ProviderConfig, RepositoryResource, + ContributionTemplateResource, IssueResource, PullRequestResource, UserResource, diff --git a/src/gitlab.ts b/src/gitlab.ts index 51a811b..3868cea 100644 --- a/src/gitlab.ts +++ b/src/gitlab.ts @@ -10,6 +10,10 @@ export type { RepositoryParent, RepositoryPermission, Repository, + ContributionTemplateKind, + ContributionTemplateScope, + ContributionTemplateSummary, + ContributionTemplate, IssueState, Issue, PullRequestSearchItem, @@ -22,12 +26,14 @@ export type { SearchPageResult, ListOptions, ListCommentOptions, + ListContributionTemplatesOptions, ListThreadOptions, CreateIssueInput, CreatePullRequestInput, ReplyThreadInput, ProviderConfig, RepositoryResource, + ContributionTemplateResource, IssueResource, PullRequestResource, UserResource, diff --git a/src/index.ts b/src/index.ts index 13e6294..0aa425b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,10 @@ export type { RepositoryParent, RepositoryPermission, Repository, + ContributionTemplateKind, + ContributionTemplateScope, + ContributionTemplateSummary, + ContributionTemplate, CiRunStatus, CiRunConclusion, CiRun, @@ -44,6 +48,7 @@ export type { ListOptions, ListCiRunsOptions, ListCommentOptions, + ListContributionTemplatesOptions, ListCommitOptions, ListPullRequestFilesOptions, ListPullRequestChecksOptions, @@ -53,6 +58,7 @@ export type { ReplyThreadInput, ProviderConfig, RepositoryResource, + ContributionTemplateResource, CodeSearchResource, CiRunResource, CommitResource, diff --git a/src/mcp.ts b/src/mcp.ts index 2e077b1..ffca875 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -13,11 +13,13 @@ import { codeSearchParameters, commentParameters, commitParameters, + contributionTemplateParameters, createIssueParameters, createPullRequestParameters, listCiRunsParameters, listCommentsParameters, listCommitsParameters, + listContributionTemplatesParameters, listPullRequestChecksParameters, listPullRequestFilesParameters, listRepositoriesParameters, @@ -35,6 +37,7 @@ import { createPullRequest, getAuthenticatedUser, getCommit, + getContributionTemplate, getIssue, getIssueComment, getPullRequest, @@ -44,6 +47,7 @@ import { getUser, listCiRuns, listCommits, + listContributionTemplates, listIssueComments, listIssues, listPullRequestChecks, @@ -137,6 +141,24 @@ const tools: ToolDefinition[] = [ annotations: readAnnotations, execute: getRepository, }), + defineTool({ + name: "forges_contribution_templates_list", + title: "List Contribution Templates", + description: + "List paged metadata for the effective issue or pull-request templates of one repository. Results identify local versus inherited files and their source when the platform exposes it. Bodies are omitted; pass the returned kind and key unchanged to forges_contribution_templates_get.", + inputSchema: listContributionTemplatesParameters, + annotations: readAnnotations, + execute: listContributionTemplates, + }), + defineTool({ + name: "forges_contribution_templates_get", + title: "Get Contribution Template", + description: + "Get the full source body of one effective issue or pull-request template. Use the exact kind and provider key returned by forges_contribution_templates_list.", + inputSchema: contributionTemplateParameters, + annotations: readAnnotations, + execute: getContributionTemplate, + }), defineTool({ name: "forges_code_search", title: "Search Repository Code", diff --git a/src/provider.ts b/src/provider.ts index 1c05b86..5c4fd05 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -3,7 +3,7 @@ */ import { assertAssignees } from "./assignees.ts"; -import { ForgesError } from "./errors.ts"; +import { ForgesError, NotFoundError } from "./errors.ts"; import type { CiRun, CiRunResource, @@ -12,6 +12,10 @@ import type { CodeSearchResource, Comment, Commit, + ContributionTemplate, + ContributionTemplateKind, + ContributionTemplateResource, + ContributionTemplateSummary, CommitResource, CommitSummary, CreateIssueInput, @@ -21,6 +25,7 @@ import type { ListCiRunsOptions, ListCommentOptions, ListCommitOptions, + ListContributionTemplatesOptions, ListOptions, ListPullRequestChecksOptions, ListPullRequestFilesOptions, @@ -60,6 +65,54 @@ export interface ProviderRawTypes { comment: unknown; } +function contributionTemplatePageValue( + value: number | undefined, + fallback: number, + label: string, + maximum?: number, +): number { + const resolved = value ?? fallback; + if ( + !Number.isSafeInteger(resolved) || + resolved < 1 || + (maximum !== undefined && resolved > maximum) + ) { + const range = maximum === undefined ? "a positive integer" : `an integer from 1 to ${maximum}`; + throw new ForgesError(`${label} must be ${range}`, 400); + } + return resolved; +} + +interface ContributionTemplatePagination { + page: number; + perPage: number; +} + +function contributionTemplatePagination( + options?: ListContributionTemplatesOptions, +): ContributionTemplatePagination { + return { + page: contributionTemplatePageValue(options?.page, 1, "page"), + perPage: contributionTemplatePageValue(options?.perPage, 30, "perPage", 100), + }; +} + +function paginateContributionTemplates( + templates: readonly ContributionTemplateSummary[], + pagination: ContributionTemplatePagination, +): PageResult { + const { page, perPage } = pagination; + const start = (page - 1) * perPage; + const items = templates.slice(start, start + perPage); + const hasNextPage = start + items.length < templates.length; + return { + items, + totalCount: templates.length, + hasNextPage, + nextPage: hasNextPage ? page + 1 : undefined, + }; +} + /** * Abstract base for every git provider. * @@ -68,6 +121,7 @@ export interface ProviderRawTypes { */ export abstract class Provider { public readonly repos: RepositoryResource; + public readonly contributionTemplates: ContributionTemplateResource; public readonly code: CodeSearchResource; public readonly ciRuns: CiRunResource; public readonly commits: CommitResource; @@ -81,6 +135,25 @@ export abstract class Provider list: (owner, options) => this.listRepos(owner, options), get: (owner, repo) => this.getRepo(owner, repo), }; + this.contributionTemplates = { + list: async (owner, repo, kind, options) => { + const pagination = contributionTemplatePagination(options); + const templates = await this.listContributionTemplates(owner, repo, kind); + return paginateContributionTemplates(templates, pagination); + }, + get: async (owner, repo, kind, key) => { + if (key.length === 0) { + throw new ForgesError("Contribution template key must not be empty", 400); + } + const templates = await this.listContributionTemplates(owner, repo, kind); + const template = templates.find((candidate) => candidate.key === key); + if (!template) { + throw new NotFoundError(`Contribution template not found: ${kind}/${key}`); + } + const content = await this.readContributionTemplate(owner, repo, template); + return { ...template, content }; + }, + }; this.code = { search: async (query, options) => { if (query.trim() === "") { @@ -167,6 +240,24 @@ export abstract class Provider options?: ListOptions, ): Promise>; protected abstract getRepo(owner: string, repo: string): Promise; + protected listContributionTemplates( + _owner: string, + _repo: string, + _kind: ContributionTemplateKind, + ): Promise { + return Promise.reject( + new ForgesError("Contribution template discovery is not supported by this provider", 501), + ); + } + protected readContributionTemplate( + _owner: string, + _repo: string, + _template: ContributionTemplateSummary, + ): Promise { + return Promise.reject( + new ForgesError("Contribution template reads are not supported by this provider", 501), + ); + } protected searchCode( _query: string, _options?: CodeSearchOptions, diff --git a/src/providers/base-url.ts b/src/providers/base-url.ts index 20274db..943a944 100644 --- a/src/providers/base-url.ts +++ b/src/providers/base-url.ts @@ -24,6 +24,26 @@ export function encodePathSegment(value: string | number): string { return encodeURIComponent(segment); } +/** + * Encode a raw path segment returned by a forge API. A literal percent sign is + * allowed and encoded again rather than interpreted as an existing escape. + */ +export function encodeApiResponsePathSegment(value: string): string { + if ( + value.length === 0 || + value === "." || + value === ".." || + value.includes("/") || + value.includes("\\") || + /* oxlint-disable-next-line no-control-regex */ + /[\u0000-\u001F\u007F]/.test(value) + ) { + throw new TypeError("Invalid API response path segment"); + } + + return encodeURIComponent(value); +} + export function normalizeApiBaseURL( baseURL: string | undefined, fallbackBaseURL: string, diff --git a/src/providers/gitea.ts b/src/providers/gitea.ts index a9ff46a..1714ba6 100644 --- a/src/providers/gitea.ts +++ b/src/providers/gitea.ts @@ -6,10 +6,15 @@ * - Some fields may be null where GitHub returns empty strings */ +import { Buffer } from "node:buffer"; import { createHttpClient, rawFetch, type HttpClient } from "../http.ts"; import { parseLinkHeader } from "../pagination.ts"; import { ForgesError, normalizeError, NotFoundError } from "../errors.ts"; -import { encodePathSegment, normalizeApiBaseURL } from "./base-url.ts"; +import { + encodeApiResponsePathSegment, + encodePathSegment, + normalizeApiBaseURL, +} from "./base-url.ts"; import { Provider, type ProviderRawTypes } from "../provider.ts"; import { mapBooleanRepositoryPermission } from "../repository-access.ts"; import type { @@ -18,6 +23,8 @@ import type { CiRun, Commit, CommitSummary, + ContributionTemplateKind, + ContributionTemplateSummary, Issue, PullRequest, PullRequestCheck, @@ -46,6 +53,19 @@ import { normalizeChangedFileStatus } from "../changed-file.ts"; // -- Raw Gitea API response types -- +interface GiteaContent { + type: string; + name: string; + path: string; + content?: string; + encoding?: string; +} + +interface GiteaIssueTemplate { + name: string; + file_name: string; +} + interface GiteaCiRun { id: number; head_branch?: string | null; @@ -297,6 +317,26 @@ function buildListQuery(options?: ListOptions): Record { // -- Provider -- const PLATFORM = "gitea"; +const GITEA_PULL_REQUEST_TEMPLATE_CANDIDATES = [ + "PULL_REQUEST_TEMPLATE.md", + "PULL_REQUEST_TEMPLATE.yaml", + "PULL_REQUEST_TEMPLATE.yml", + "pull_request_template.md", + "pull_request_template.yaml", + "pull_request_template.yml", + ".gitea/PULL_REQUEST_TEMPLATE.md", + ".gitea/PULL_REQUEST_TEMPLATE.yaml", + ".gitea/PULL_REQUEST_TEMPLATE.yml", + ".gitea/pull_request_template.md", + ".gitea/pull_request_template.yaml", + ".gitea/pull_request_template.yml", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/PULL_REQUEST_TEMPLATE.yaml", + ".github/PULL_REQUEST_TEMPLATE.yml", + ".github/pull_request_template.md", + ".github/pull_request_template.yaml", + ".github/pull_request_template.yml", +] as const; /** * Gitea/Forgejo provider implementation. @@ -531,6 +571,111 @@ export class GiteaProvider extends Provider { } } + private contributionTemplateContentsRoute(owner: string, repo: string, path: string): string { + const encodedPath = path.split("/").map(encodeApiResponsePathSegment).join("/"); + return `/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}/contents/${encodedPath}`; + } + + private async tryContributionTemplateFile( + owner: string, + repo: string, + path: string, + ref: string, + ): Promise { + try { + const file = await this.client( + this.contributionTemplateContentsRoute(owner, repo, path), + { query: { ref } }, + ); + return file.type === "file" ? file : null; + } catch (error) { + const normalized = normalizeError(error, PLATFORM); + if (normalized.status === 404) return null; + throw normalized; + } + } + + private giteaTemplateSummary( + repository: GiteaRepository, + kind: ContributionTemplateKind, + key: string, + name: string, + ): ContributionTemplateSummary { + return { + kind, + key, + name, + scope: "repository", + inherited: false, + sourceRepository: repository.full_name, + sourcePath: key, + sourceRef: repository.default_branch ?? "main", + }; + } + + protected override async listContributionTemplates( + owner: string, + repo: string, + kind: ContributionTemplateKind, + ): Promise { + try { + const repository = await this.client( + `/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}`, + ); + if (kind === "issue") { + const rows = await this.client( + `/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}/issue_templates`, + ); + const seen = new Set(); + return rows.flatMap((row): ContributionTemplateSummary[] => { + if (seen.has(row.file_name)) return []; + seen.add(row.file_name); + const fallbackName = + row.file_name + .split("/") + .at(-1) + ?.replace(/\.[^.]+$/u, "") ?? ""; + return [ + this.giteaTemplateSummary(repository, kind, row.file_name, row.name || fallbackName), + ]; + }); + } + + const sourceRef = repository.default_branch ?? "main"; + for (const candidate of GITEA_PULL_REQUEST_TEMPLATE_CANDIDATES) { + const file = await this.tryContributionTemplateFile(owner, repo, candidate, sourceRef); + if (file === null) continue; + const name = file.name.replace(/\.[^.]+$/u, ""); + return [this.giteaTemplateSummary(repository, kind, file.path, name)]; + } + return []; + } catch (error) { + throw normalizeError(error, PLATFORM); + } + } + + protected override async readContributionTemplate( + owner: string, + repo: string, + template: ContributionTemplateSummary, + ): Promise { + try { + if (template.sourcePath === null || template.sourceRef === null) { + throw new ForgesError("Gitea template source metadata is incomplete", 502, PLATFORM); + } + const file = await this.client( + this.contributionTemplateContentsRoute(owner, repo, template.sourcePath), + { query: { ref: template.sourceRef } }, + ); + if (file.type !== "file" || file.encoding !== "base64" || file.content === undefined) { + throw new ForgesError("Gitea did not return decodable template content", 502, PLATFORM); + } + return Buffer.from(file.content.replaceAll("\n", ""), "base64").toString("utf8"); + } catch (error) { + throw normalizeError(error, PLATFORM); + } + } + protected override async listCiRuns( owner: string, repo: string, diff --git a/src/providers/github.ts b/src/providers/github.ts index 204968d..a40bc06 100644 --- a/src/providers/github.ts +++ b/src/providers/github.ts @@ -3,6 +3,7 @@ * Also serves GitBucket (GitHub API v3 compatible) via custom baseURL */ +import { Buffer } from "node:buffer"; import { Provider, type ProviderRawTypes } from "../provider.ts"; import type { ProviderConfig, @@ -12,6 +13,8 @@ import type { CiRun, Commit, CommitSummary, + ContributionTemplateKind, + ContributionTemplateSummary, Issue, PullRequest, PullRequestCheck, @@ -40,13 +43,15 @@ import { FetchError } from "ofetch"; import { ForgesError, NotFoundError, normalizeError } from "../errors.ts"; import { createHttpClient, rawFetch, type HttpClient, type RawFetchResult } from "../http.ts"; import { parseLinkHeader } from "../pagination.ts"; -import { encodePathSegment } from "./base-url.ts"; +import { encodeApiResponsePathSegment, encodePathSegment } from "./base-url.ts"; import { mapBooleanRepositoryPermission } from "../repository-access.ts"; import { normalizeCiRunState } from "../ci-run.ts"; import { normalizeChangedFileStatus } from "../changed-file.ts"; const MAX_COMMIT_FILE_PAGES = 30; const GITHUB_SEARCH_RESULT_LIMIT = 1000; +const GITHUB_ISSUE_TEMPLATE_DIRECTORY = ".github/ISSUE_TEMPLATE"; +const GITHUB_PULL_REQUEST_TEMPLATE_LOCATIONS = [".github", "", "docs"] as const; // --- GitHub API response types (snake_case) --- @@ -74,12 +79,21 @@ interface GitHubCodeSearchResponse { items: GitHubCodeSearchItem[]; } +interface GitHubContent { + type: string; + name: string; + path: string; + content?: string; + encoding?: string; +} + interface GitHubRepo { id: number; name: string; full_name: string; description: string | null; private: boolean; + visibility?: string; default_branch: string; html_url: string; clone_url: string; @@ -471,6 +485,227 @@ export class GitHubProvider extends Provider { }); } + private repositoryRoute(fullName: string): string { + const segments = fullName.split("/"); + const owner = segments[0]; + const repo = segments[1]; + if (segments.length !== 2 || owner === undefined || repo === undefined) { + throw new ForgesError("GitHub returned an invalid repository name", 502, "github"); + } + return `/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}`; + } + + private contentsRoute(fullName: string, path: string): string { + const encodedPath = + path === "" ? "" : path.split("/").map(encodeApiResponsePathSegment).join("/"); + const suffix = encodedPath === "" ? "" : `/${encodedPath}`; + return `${this.repositoryRoute(fullName)}/contents${suffix}`; + } + + private async tryRepository(owner: string, repo: string): Promise { + try { + return await this.client( + `/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}`, + ); + } catch (error) { + const normalized = normalizeError(error, "github"); + if (normalized.status === 404) return null; + throw normalized; + } + } + + private async supportsOwnerDefaults(owner: string, repo: string): Promise { + const hostname = new URL(this.restBaseURL).hostname; + if (hostname === "api.github.com" || hostname.endsWith(".ghe.com")) return true; + try { + const { headers } = await rawFetch( + this.client, + `/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}`, + ); + return headers.has("x-github-enterprise-version"); + } catch (error) { + throw normalizeError(error, "github"); + } + } + + private async tryListContents( + fullName: string, + path: string, + ref: string, + ): Promise { + try { + const contents = await this.client( + this.contentsRoute(fullName, path), + { query: { ref } }, + ); + return Array.isArray(contents) ? contents : []; + } catch (error) { + const normalized = normalizeError(error, "github"); + if (normalized.status === 404) return []; + throw normalized; + } + } + + private templateSummary( + kind: ContributionTemplateKind, + sourceRepository: string, + sourceRef: string, + file: GitHubContent, + inherited: boolean, + ): ContributionTemplateSummary { + const name = file.name.replace(/\.[^.]+$/u, ""); + return { + kind, + key: `${sourceRepository}:${file.path}`, + name, + scope: inherited ? "owner" : "repository", + inherited, + sourceRepository, + sourcePath: file.path, + sourceRef, + }; + } + + private async discoverIssueTemplates( + repository: GitHubRepo, + inherited: boolean, + ): Promise<{ templates: ContributionTemplateSummary[]; overrides: boolean }> { + const entries = await this.tryListContents( + repository.full_name, + GITHUB_ISSUE_TEMPLATE_DIRECTORY, + repository.default_branch, + ); + const files = entries.filter( + (entry) => + entry.type === "file" && + !/^config\.ya?ml$/iu.test(entry.name) && + /\.(?:md|ya?ml)$/iu.test(entry.name), + ); + const hasConfiguration = entries.some( + (entry) => entry.type === "file" && /^config\.ya?ml$/iu.test(entry.name), + ); + return { + templates: files.map((file) => + this.templateSummary( + "issue", + repository.full_name, + repository.default_branch, + file, + inherited, + ), + ), + overrides: files.length > 0 || hasConfiguration, + }; + } + + private async discoverPullRequestTemplates( + repository: GitHubRepo, + inherited: boolean, + ): Promise<{ templates: ContributionTemplateSummary[]; overrides: boolean }> { + const baseEntries = await Promise.all( + GITHUB_PULL_REQUEST_TEMPLATE_LOCATIONS.map((location) => + this.tryListContents(repository.full_name, location, repository.default_branch), + ), + ); + const templateDirectories = await Promise.all( + GITHUB_PULL_REQUEST_TEMPLATE_LOCATIONS.map((location) => { + const path = + location === "" ? "PULL_REQUEST_TEMPLATE" : `${location}/PULL_REQUEST_TEMPLATE`; + return this.tryListContents(repository.full_name, path, repository.default_branch); + }), + ); + const singular = baseEntries + .flatMap((entries) => + entries.filter( + (entry) => + entry.type === "file" && /^pull_request_template(?:\.[^/]+)?$/iu.test(entry.name), + ), + ) + .at(0); + const seenNames = new Set(); + const selectable = templateDirectories.flatMap((entries) => + entries.filter((entry) => { + if (entry.type !== "file") return false; + const name = entry.name.toLowerCase(); + if (seenNames.has(name)) return false; + seenNames.add(name); + return true; + }), + ); + const files = singular === undefined ? selectable : [singular, ...selectable]; + return { + templates: files.map((file) => + this.templateSummary( + "pull_request", + repository.full_name, + repository.default_branch, + file, + inherited, + ), + ), + overrides: + singular !== undefined || templateDirectories.some((entries) => entries.length > 0), + }; + } + + protected override async listContributionTemplates( + owner: string, + repo: string, + kind: ContributionTemplateKind, + ): Promise { + try { + const repository = await this.client( + `/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}`, + ); + const local = + kind === "issue" + ? await this.discoverIssueTemplates(repository, false) + : await this.discoverPullRequestTemplates(repository, false); + if (local.overrides || repository.name.toLowerCase() === ".github") { + return local.templates; + } + if (!(await this.supportsOwnerDefaults(owner, repo))) return local.templates; + + const defaults = await this.tryRepository(owner, ".github"); + const usableDefaults = + defaults !== null && (!defaults.private || defaults.visibility === "internal"); + if (!usableDefaults) return local.templates; + const inherited = + kind === "issue" + ? await this.discoverIssueTemplates(defaults, true) + : await this.discoverPullRequestTemplates(defaults, true); + return inherited.templates; + } catch (error) { + throw normalizeError(error, "github"); + } + } + + protected override async readContributionTemplate( + _owner: string, + _repo: string, + template: ContributionTemplateSummary, + ): Promise { + try { + if ( + template.sourceRepository === null || + template.sourcePath === null || + template.sourceRef === null + ) { + throw new ForgesError("GitHub template source metadata is incomplete", 502, "github"); + } + const file = await this.client( + this.contentsRoute(template.sourceRepository, template.sourcePath), + { query: { ref: template.sourceRef } }, + ); + if (file.type !== "file" || file.encoding !== "base64" || file.content === undefined) { + throw new ForgesError("GitHub did not return decodable template content", 502, "github"); + } + return Buffer.from(file.content.replaceAll("\n", ""), "base64").toString("utf8"); + } catch (error) { + throw normalizeError(error, "github"); + } + } + protected override mapOwner(raw: GitHubOwner): Owner { return { login: raw.login, diff --git a/src/providers/gitlab.ts b/src/providers/gitlab.ts index 6f55af8..43c43cb 100644 --- a/src/providers/gitlab.ts +++ b/src/providers/gitlab.ts @@ -21,6 +21,8 @@ import type { CiRun, Commit, CommitSummary, + ContributionTemplateKind, + ContributionTemplateSummary, Issue, PullRequest, PullRequestCheck, @@ -47,16 +49,33 @@ import type { } from "../types.ts"; import { createHttpClient, rawFetch, type HttpClient, type RawFetchResult } from "../http.ts"; import { cachedFetch, invalidateCache } from "../cache.ts"; -import { normalizeError, NotFoundError } from "../errors.ts"; -import { encodePathSegment, normalizeApiBaseURL } from "./base-url.ts"; +import { ForgesError, normalizeError, NotFoundError } from "../errors.ts"; +import { + encodeApiResponsePathSegment, + encodePathSegment, + normalizeApiBaseURL, +} from "./base-url.ts"; import { normalizeCiRunState } from "../ci-run.ts"; import { countDiffLines } from "../changed-file.ts"; const MAX_COMMIT_DIFF_PAGES = 100; const MAX_CODE_SEARCH_PROJECT_REQUESTS = 5; +const MAX_CONTRIBUTION_TEMPLATE_PAGES = 100; // GitLab API response types (internal) +interface GitLabContributionTemplate { + key: string; + name: string; + content?: string; +} + +interface GitLabTreeEntry { + name: string; + path: string; + type: string; +} + interface GitLabProjectParent { path_with_namespace: string; web_url: string; @@ -679,6 +698,135 @@ export class GitLabProvider extends Provider { } } + private contributionTemplateApiType(kind: ContributionTemplateKind): string { + return kind === "issue" ? "issues" : "merge_requests"; + } + + private contributionTemplateDirectory(kind: ContributionTemplateKind): string { + return kind === "issue" ? ".gitlab/issue_templates" : ".gitlab/merge_request_templates"; + } + + private nextContributionTemplatePage(headers: Headers, page: number): number | null { + const next = headers.get("x-next-page"); + if (next === null || next === "") return null; + const parsed = Number.parseInt(next, 10); + if (!Number.isInteger(parsed) || parsed <= page || parsed > MAX_CONTRIBUTION_TEMPLATE_PAGES) { + throw new ForgesError("GitLab returned unsafe template pagination", 502, "gitlab"); + } + return parsed; + } + + private async collectContributionTemplateRows( + projectId: number, + apiType: string, + ): Promise { + const rows: GitLabContributionTemplate[] = []; + let page = 1; + while (page <= MAX_CONTRIBUTION_TEMPLATE_PAGES) { + const { data, headers } = await rawFetch( + this.client, + `/projects/${projectId}/templates/${apiType}`, + { query: { page, per_page: 100 } }, + ); + rows.push(...(data ?? [])); + const next = this.nextContributionTemplatePage(headers, page); + if (next === null) break; + page = next; + } + return rows; + } + + private async collectLocalContributionTemplates( + projectId: number, + directory: string, + ref: string, + ): Promise> { + const local = new Map(); + let page = 1; + try { + while (page <= MAX_CONTRIBUTION_TEMPLATE_PAGES) { + const { data, headers } = await rawFetch( + this.client, + `/projects/${projectId}/repository/tree`, + { query: { path: directory, ref, page, per_page: 100 } }, + ); + for (const entry of data ?? []) { + if (entry.type !== "blob" || !entry.name.endsWith(".md")) continue; + local.set(entry.name.slice(0, -3), entry.path); + } + const next = this.nextContributionTemplatePage(headers, page); + if (next === null) break; + page = next; + } + return local; + } catch (error: unknown) { + const normalized = normalizeError(error, "gitlab"); + if (normalized.status === 404) return local; + throw normalized; + } + } + + protected override async listContributionTemplates( + owner: string, + repo: string, + kind: ContributionTemplateKind, + ): Promise { + try { + const project = await this.client( + `/projects/${encodeProjectPath(owner, repo)}`, + ); + this.setCachedProjectId(`${owner}/${repo}`, project.id); + const sourceRef = project.default_branch ?? "main"; + const apiType = this.contributionTemplateApiType(kind); + const directory = this.contributionTemplateDirectory(kind); + const [rows, local] = await Promise.all([ + this.collectContributionTemplateRows(project.id, apiType), + this.collectLocalContributionTemplates(project.id, directory, sourceRef), + ]); + const seen = new Set(); + return rows.flatMap((row): ContributionTemplateSummary[] => { + if (seen.has(row.key)) return []; + seen.add(row.key); + const sourcePath = local.get(row.key) ?? null; + const inherited = sourcePath === null; + return [ + { + kind, + key: row.key, + name: row.name, + scope: inherited ? "unknown" : "repository", + inherited, + sourceRepository: inherited ? null : project.path_with_namespace, + sourcePath, + sourceRef: inherited ? null : sourceRef, + }, + ]; + }); + } catch (error: unknown) { + throw normalizeError(error, "gitlab"); + } + } + + protected override async readContributionTemplate( + owner: string, + repo: string, + template: ContributionTemplateSummary, + ): Promise { + try { + const projectId = await this.resolveProjectId(owner, repo); + const apiType = this.contributionTemplateApiType(template.kind); + const row = await this.client( + `/projects/${projectId}/templates/${apiType}/${encodeApiResponsePathSegment(template.key)}`, + ); + if (row.content === undefined) { + throw new ForgesError("GitLab returned template metadata without content", 502, "gitlab"); + } + return row.content; + } catch (error: unknown) { + throw normalizeError(error, "gitlab"); + } + } + protected override async listCiRuns( owner: string, repo: string, diff --git a/src/tool-operations.ts b/src/tool-operations.ts index 16d0e99..37117a9 100644 --- a/src/tool-operations.ts +++ b/src/tool-operations.ts @@ -9,6 +9,9 @@ import type { CodeSearchOptions, Comment, Commit, + ContributionTemplate, + ContributionTemplateKind, + ContributionTemplateSummary, CommitSummary, CreateIssueInput, CreatePullRequestInput, @@ -17,6 +20,7 @@ import type { ListCiRunsOptions, ListCommentOptions, ListCommitOptions, + ListContributionTemplatesOptions, ListOptions, ListPullRequestChecksOptions, ListPullRequestFilesOptions, @@ -167,6 +171,17 @@ export interface ListRepositoriesParams extends OwnerParams { export type GetRepositoryParams = RepositoryParams; +export interface ListContributionTemplatesParams extends RepositoryParams { + readonly kind: ContributionTemplateKind; + readonly page?: number; + readonly perPage?: number; +} + +export interface GetContributionTemplateParams extends RepositoryParams { + readonly kind: ContributionTemplateKind; + readonly key: string; +} + export interface SearchCodeParams extends PlatformParams, CodeSearchOptions { query: string; } @@ -314,6 +329,38 @@ export async function getRepository( return result(params.platform, repository); } +export async function listContributionTemplates( + params: ListContributionTemplatesParams, +): Promise>> { + const options: ListContributionTemplatesOptions = { + page: params.page, + perPage: params.perPage, + }; + const templates = await readProvider(params.platform).contributionTemplates.list( + params.owner, + params.repo, + params.kind, + options, + ); + return result( + params.platform, + templates, + "Template bodies are omitted from list output; use forges_contribution_templates_get with the returned kind and key.", + ); +} + +export async function getContributionTemplate( + params: GetContributionTemplateParams, +): Promise> { + const template = await readProvider(params.platform).contributionTemplates.get( + params.owner, + params.repo, + params.kind, + params.key, + ); + return result(params.platform, template); +} + export async function searchCode( params: SearchCodeParams, ): Promise>> { diff --git a/src/types.ts b/src/types.ts index b66a85c..55c861d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -60,6 +60,45 @@ export interface Repository { owner: Owner; } +/** Contribution workflow that a repository template belongs to. */ +export type ContributionTemplateKind = "issue" | "pull_request"; + +/** + * Scope that supplied the effective template. Group and instance are available + * to providers that expose those origins; use unknown when the API hides them. + */ +export type ContributionTemplateScope = "repository" | "owner" | "group" | "instance" | "unknown"; + +/** + * One effective contribution template without its potentially large body. + * The provider-issued key is opaque and must be passed back unchanged to `get`. + */ +export interface ContributionTemplateSummary { + kind: ContributionTemplateKind; + key: string; + name: string; + /** Repository-local, owner-level, or another platform inheritance scope. */ + scope: ContributionTemplateScope; + inherited: boolean; + /** Repository holding the winning file, or null when the platform hides it. */ + sourceRepository: string | null; + /** Repository-relative path of the winning file, or null when the platform hides it. */ + sourcePath: string | null; + /** Ref used to read the winning file, or null when the platform hides it. */ + sourceRef: string | null; +} + +/** One effective contribution template with its complete source body. */ +export interface ContributionTemplate extends ContributionTemplateSummary { + content: string; +} + +/** Pagination for one contribution-template kind. */ +export interface ListContributionTemplatesOptions { + page?: number; + perPage?: number; +} + /** Provider-independent lifecycle state of a CI run. */ export type CiRunStatus = "queued" | "in_progress" | "waiting" | "completed"; @@ -361,6 +400,22 @@ export interface RepositoryResource { get(owner: string, repo: string): Promise; } +/** Resource accessor for effective repository contribution templates. */ +export interface ContributionTemplateResource { + list( + owner: string, + repo: string, + kind: ContributionTemplateKind, + options?: ListContributionTemplatesOptions, + ): Promise>; + get( + owner: string, + repo: string, + kind: ContributionTemplateKind, + key: string, + ): Promise; +} + /** Resource accessor for repository code search. */ export interface CodeSearchResource { search(query: string, options?: CodeSearchOptions): Promise>; diff --git a/test/base-url.test.ts b/test/base-url.test.ts index 07268e4..99df0de 100644 --- a/test/base-url.test.ts +++ b/test/base-url.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { encodePathSegment } from "../src/providers/base-url.ts"; +import { encodeApiResponsePathSegment, encodePathSegment } from "../src/providers/base-url.ts"; describe("encodePathSegment", () => { it.each([ @@ -24,3 +24,19 @@ describe("encodePathSegment", () => { }, ); }); + +describe("encodeApiResponsePathSegment", () => { + it("encodes literal percent signs returned in forge filenames", () => { + expect(encodeApiResponsePathSegment("100%.md")).toBe("100%25.md"); + expect(encodeApiResponsePathSegment("already%20literal.md")).toBe("already%2520literal.md"); + }); + + it.each(["", ".", "..", "owner/repo", "owner\\repo", "\0", "\n", "\x7f"])( + "rejects unsafe response segment %j", + (value) => { + expect(() => encodeApiResponsePathSegment(value)).toThrow( + "Invalid API response path segment", + ); + }, + ); +}); diff --git a/test/eval-packed-extensions.mjs b/test/eval-packed-extensions.mjs index 18694ce..fc0a27e 100644 --- a/test/eval-packed-extensions.mjs +++ b/test/eval-packed-extensions.mjs @@ -15,6 +15,8 @@ const originalEnvironment = environmentKeys.map((key) => [key, process.env[key]] const expectedToolNames = [ "forges_repos_list", "forges_repos_get", + "forges_contribution_templates_list", + "forges_contribution_templates_get", "forges_code_search", "forges_ci_runs_list", "forges_commits_list", diff --git a/test/extensions.test.ts b/test/extensions.test.ts index 0904927..ac75b07 100644 --- a/test/extensions.test.ts +++ b/test/extensions.test.ts @@ -18,6 +18,7 @@ import { resetPinnedProviders } from "../src/tool-operations.ts"; const mocks = vi.hoisted(() => { const repos = { list: vi.fn(), get: vi.fn() }; + const contributionTemplates = { list: vi.fn(), get: vi.fn() }; const code = { search: vi.fn() }; const ciRuns = { list: vi.fn() }; const commits = { list: vi.fn(), get: vi.fn() }; @@ -45,12 +46,23 @@ const mocks = vi.hoisted(() => { resolve: vi.fn(), unresolve: vi.fn(), }; - const provider = { repos, code, ciRuns, commits, issues, pullRequests, users, threads }; + const provider = { + repos, + contributionTemplates, + code, + ciRuns, + commits, + issues, + pullRequests, + users, + threads, + }; return { resolveToken: vi.fn(() => ({ token: "test-token", source: "env" as const })), createProvider: vi.fn(() => provider), repos, + contributionTemplates, code, ciRuns, commits, @@ -69,6 +81,8 @@ vi.mock("../src/index.ts", () => ({ const toolNames = [ "forges_repos_list", "forges_repos_get", + "forges_contribution_templates_list", + "forges_contribution_templates_get", "forges_code_search", "forges_ci_runs_list", "forges_commits_list", @@ -159,6 +173,11 @@ beforeEach(() => { vi.stubEnv("FORGES_GITLAB_BASE_URL", undefined); vi.stubEnv("FORGES_GITEA_BASE_URL", undefined); mocks.repos.list.mockResolvedValue({ items: [], hasNextPage: false }); + mocks.contributionTemplates.list.mockResolvedValue({ + items: [], + totalCount: 0, + hasNextPage: false, + }); mocks.code.search.mockResolvedValue({ items: [], incomplete: false, hasNextPage: false }); mocks.ciRuns.list.mockResolvedValue({ items: [], hasNextPage: false }); mocks.commits.list.mockResolvedValue({ items: [], hasNextPage: false }); @@ -246,6 +265,32 @@ describe("Forges Pi extension", () => { }); }); + it("executes contribution-template discovery through the shared provider operation", async () => { + const tool = requirePiTool(registerPiTools(), "forges_contribution_templates_list"); + const result = await tool.execute( + "test", + { + platform: "github", + owner: "agntn", + repo: "forges", + kind: "pull_request", + page: 2, + perPage: 10, + }, + undefined, + undefined, + unusedPiContext, + ); + + expect(mocks.contributionTemplates.list).toHaveBeenCalledWith( + "agntn", + "forges", + "pull_request", + { page: 2, perPage: 10 }, + ); + expect(result.content[0]?.text).toContain("forges_contribution_templates_get"); + }); + it("executes code search through the shared provider operation", async () => { const tool = requirePiTool(registerPiTools(), "forges_code_search"); const result = await tool.execute( diff --git a/test/gitea.test.ts b/test/gitea.test.ts index f95ed0b..575d263 100644 --- a/test/gitea.test.ts +++ b/test/gitea.test.ts @@ -177,6 +177,14 @@ function makeHeaders(extra: Record = {}): Headers { return new Headers(extra); } +function makeFetchError(status: number): FetchError { + const error = new FetchError(`HTTP ${status}`); + error.status = status; + error.statusCode = status; + error.response = Object.assign(new Response(null, { status }), { _data: undefined }); + return error; +} + function linkHeader( page: number, limit: number, @@ -359,6 +367,133 @@ describe("Gitea Provider", () => { }); }); + describe("contributionTemplates", () => { + function contentFile(path: string, content: string) { + const name = path.split("/").at(-1) ?? path; + return { + type: "file", + name, + path, + content: Buffer.from(content).toString("base64"), + encoding: "base64", + }; + } + + it("keeps same-name issue templates distinct by their provider paths", async () => { + const first = "---\nname: Bug\nabout: Root template\n---\nRoot body\n"; + mockClient.mockImplementation(async (url: string) => { + if (url === "/repos/testowner/test-repo") return giteaRepo(); + if (url === "/repos/testowner/test-repo/issue_templates") { + return [ + { + name: "Bug", + title: "", + about: "Root template", + labels: [], + assignees: [], + ref: "refs/heads/", + content: "Root body\n", + body: [], + file_name: "ISSUE_TEMPLATE/bug.md", + }, + { + name: "Bug", + title: "", + about: "Gitea template", + labels: [], + assignees: [], + ref: "refs/heads/", + content: "Gitea body\n", + body: [], + file_name: ".gitea/ISSUE_TEMPLATE/bug.md", + }, + ]; + } + if (url === "/repos/testowner/test-repo/contents/ISSUE_TEMPLATE/bug.md") { + return contentFile("ISSUE_TEMPLATE/bug.md", first); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const page = await provider.contributionTemplates.list("testowner", "test-repo", "issue"); + + expect(page.items.map(({ key }) => key)).toEqual([ + "ISSUE_TEMPLATE/bug.md", + ".gitea/ISSUE_TEMPLATE/bug.md", + ]); + expect(page.items.every(({ inherited }) => !inherited)).toBe(true); + + const template = await provider.contributionTemplates.get( + "testowner", + "test-repo", + "issue", + page.items[0]!.key, + ); + expect(template.content).toBe(first); + }); + + it("returns no pull-request template after the bounded candidate set misses", async () => { + mockClient.mockImplementation(async (url: string) => { + if (url === "/repos/testowner/test-repo") return giteaRepo(); + if (url.startsWith("/repos/testowner/test-repo/contents/")) { + throw makeFetchError(404); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const page = await provider.contributionTemplates.list( + "testowner", + "test-repo", + "pull_request", + ); + + expect(page.items).toEqual([]); + expect(mockClient).toHaveBeenCalledTimes(19); + }); + + it("stops at the first pull-request template candidate", async () => { + const body = "Describe the change.\n"; + mockClient.mockImplementation(async (url: string) => { + if (url === "/repos/testowner/test-repo") return giteaRepo(); + if ( + url === "/repos/testowner/test-repo/contents/PULL_REQUEST_TEMPLATE.md" || + url === "/repos/testowner/test-repo/contents/PULL_REQUEST_TEMPLATE.yaml" || + url === "/repos/testowner/test-repo/contents/PULL_REQUEST_TEMPLATE.yml" + ) { + throw makeFetchError(404); + } + if (url === "/repos/testowner/test-repo/contents/pull_request_template.md") { + return contentFile("pull_request_template.md", body); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const page = await provider.contributionTemplates.list( + "testowner", + "test-repo", + "pull_request", + ); + + expect(page.items).toEqual([ + { + kind: "pull_request", + key: "pull_request_template.md", + name: "pull_request_template", + scope: "repository", + inherited: false, + sourceRepository: "testowner/test-repo", + sourcePath: "pull_request_template.md", + sourceRef: "main", + }, + ]); + expect( + mockClient.mock.calls.some(([url]) => + String(url).includes(".gitea/PULL_REQUEST_TEMPLATE.md"), + ), + ).toBe(false); + }); + }); + describe("repos.get", () => { it("returns a mapped repository", async () => { mockClient.mockResolvedValueOnce(giteaRepo()); diff --git a/test/github.test.ts b/test/github.test.ts index a3e2ccd..6cff478 100644 --- a/test/github.test.ts +++ b/test/github.test.ts @@ -210,6 +210,248 @@ describe("GitHubProvider", () => { }); }); + describe("contributionTemplates", () => { + const defaultRepo = { + ...ghRepo, + id: 54321, + name: ".github", + full_name: "octocat/.github", + html_url: "https://github.com/octocat/.github", + clone_url: "https://github.com/octocat/.github.git", + }; + + function contentFile(name: string, path: string) { + return { + type: "file", + name, + path, + content: "", + encoding: "base64", + }; + } + + it("uses public owner defaults and returns the exact inherited source content", async () => { + const form = "name: Bug report\ndescription: Tell us what broke\nbody: []\n"; + mocks.client.mockImplementation(async (url: string) => { + if (url === "/repos/octocat/hello-world") return ghRepo; + if (url === "/repos/octocat/.github") return defaultRepo; + if (url === "/repos/octocat/hello-world/contents/.github/ISSUE_TEMPLATE") { + throw makeFetchError(404); + } + if (url === "/repos/octocat/.github/contents/.github/ISSUE_TEMPLATE") { + return [ + contentFile("config.yml", ".github/ISSUE_TEMPLATE/config.yml"), + contentFile("bug.yml", ".github/ISSUE_TEMPLATE/bug.yml"), + ]; + } + if (url === "/repos/octocat/.github/contents/.github/ISSUE_TEMPLATE/bug.yml") { + return { + ...contentFile("bug.yml", ".github/ISSUE_TEMPLATE/bug.yml"), + content: Buffer.from(form).toString("base64"), + }; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const page = await gh.contributionTemplates.list("octocat", "hello-world", "issue"); + + expect(page).toEqual({ + items: [ + { + kind: "issue", + key: "octocat/.github:.github/ISSUE_TEMPLATE/bug.yml", + name: "bug", + scope: "owner", + inherited: true, + sourceRepository: "octocat/.github", + sourcePath: ".github/ISSUE_TEMPLATE/bug.yml", + sourceRef: "main", + }, + ], + totalCount: 1, + hasNextPage: false, + }); + + const template = await gh.contributionTemplates.get( + "octocat", + "hello-world", + "issue", + page.items[0]!.key, + ); + expect(template).toEqual({ ...page.items[0], content: form }); + }); + + it("lets local issue-template configuration suppress owner defaults", async () => { + mocks.client.mockImplementation(async (url: string) => { + if (url === "/repos/octocat/hello-world") return ghRepo; + if (url === "/repos/octocat/hello-world/contents/.github/ISSUE_TEMPLATE") { + return [contentFile("config.yml", ".github/ISSUE_TEMPLATE/config.yml")]; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + await expect( + gh.contributionTemplates.list("octocat", "hello-world", "issue"), + ).resolves.toEqual({ items: [], totalCount: 0, hasNextPage: false }); + expect(mocks.client).not.toHaveBeenCalledWith("/repos/octocat/.github"); + }); + + it("ignores unrelated issue-template directory files when resolving owner defaults", async () => { + mocks.client.mockImplementation(async (url: string) => { + if (url === "/repos/octocat/hello-world") return ghRepo; + if (url === "/repos/octocat/hello-world/contents/.github/ISSUE_TEMPLATE") { + return [contentFile("notes.txt", ".github/ISSUE_TEMPLATE/notes.txt")]; + } + if (url === "/repos/octocat/.github") return defaultRepo; + if (url === "/repos/octocat/.github/contents/.github/ISSUE_TEMPLATE") { + return [contentFile("bug.md", ".github/ISSUE_TEMPLATE/bug.md")]; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const page = await gh.contributionTemplates.list("octocat", "hello-world", "issue"); + + expect(page.items[0]).toMatchObject({ + scope: "owner", + sourceRepository: "octocat/.github", + }); + }); + + it("honors pull-request path precedence and keeps independently selectable files", async () => { + mocks.client.mockImplementation(async (url: string) => { + if (url === "/repos/octocat/hello-world") return ghRepo; + if (url === "/repos/octocat/hello-world/contents/.github") { + return [contentFile("PULL_REQUEST_TEMPLATE.md", ".github/PULL_REQUEST_TEMPLATE.md")]; + } + if (url === "/repos/octocat/hello-world/contents") { + return [contentFile("pull_request_template.md", "pull_request_template.md")]; + } + if (url === "/repos/octocat/hello-world/contents/docs") return []; + if (url === "/repos/octocat/hello-world/contents/.github/PULL_REQUEST_TEMPLATE") { + return [contentFile("focused.md", ".github/PULL_REQUEST_TEMPLATE/focused.md")]; + } + if (url === "/repos/octocat/hello-world/contents/PULL_REQUEST_TEMPLATE") { + return [contentFile("focused.md", "PULL_REQUEST_TEMPLATE/focused.md")]; + } + if (url === "/repos/octocat/hello-world/contents/docs/PULL_REQUEST_TEMPLATE") return []; + throw new Error(`Unexpected URL: ${url}`); + }); + + const page = await gh.contributionTemplates.list("octocat", "hello-world", "pull_request"); + + expect(page.items.map(({ sourcePath }) => sourcePath)).toEqual([ + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/PULL_REQUEST_TEMPLATE/focused.md", + ]); + expect(page.items.every(({ scope, inherited }) => scope === "repository" && !inherited)).toBe( + true, + ); + expect(mocks.client).not.toHaveBeenCalledWith("/repos/octocat/.github"); + }); + + it("accepts a singular pull-request template without an extension", async () => { + mocks.client.mockImplementation(async (url: string) => { + if (url === "/repos/octocat/hello-world") return ghRepo; + if (url === "/repos/octocat/hello-world/contents/.github") { + return [contentFile("PULL_REQUEST_TEMPLATE", ".github/PULL_REQUEST_TEMPLATE")]; + } + if (url === "/repos/octocat/hello-world/contents") return []; + if (url === "/repos/octocat/hello-world/contents/docs") return []; + if (url === "/repos/octocat/hello-world/contents/.github/PULL_REQUEST_TEMPLATE") { + return contentFile("PULL_REQUEST_TEMPLATE", ".github/PULL_REQUEST_TEMPLATE"); + } + if (url === "/repos/octocat/hello-world/contents/PULL_REQUEST_TEMPLATE") return []; + if (url === "/repos/octocat/hello-world/contents/docs/PULL_REQUEST_TEMPLATE") return []; + throw new Error(`Unexpected URL: ${url}`); + }); + + const page = await gh.contributionTemplates.list("octocat", "hello-world", "pull_request"); + + expect(page.items).toHaveLength(1); + expect(page.items[0]?.sourcePath).toBe(".github/PULL_REQUEST_TEMPLATE"); + }); + + it("does not invent owner inheritance on GitHub-compatible hosts without GitHub headers", async () => { + const bucket = new GitHubProvider({ + baseURL: "https://gitbucket.example.com/api/v3", + token: "", + }); + mocks.client.mockImplementation(async (url: string) => { + if (url === "/repos/octocat/hello-world") return ghRepo; + if (url === "/repos/octocat/hello-world/contents/.github/ISSUE_TEMPLATE") { + throw makeFetchError(404); + } + throw new Error(`Unexpected URL: ${url}`); + }); + mocks.rawFetch.mockResolvedValue({ + data: ghRepo, + headers: new Headers(), + status: 200, + }); + + await expect( + bucket.contributionTemplates.list("octocat", "hello-world", "issue"), + ).resolves.toEqual({ items: [], totalCount: 0, hasNextPage: false }); + expect(mocks.client).not.toHaveBeenCalledWith("/repos/octocat/.github"); + }); + + it("uses internal owner defaults on GitHub Enterprise", async () => { + const enterprise = new GitHubProvider({ + baseURL: "https://github.example.com/api/v3", + token: "", + }); + const internalDefaults = { + ...defaultRepo, + private: true, + visibility: "internal", + }; + mocks.client.mockImplementation(async (url: string) => { + if (url === "/repos/octocat/hello-world") return ghRepo; + if (url === "/repos/octocat/hello-world/contents/.github/ISSUE_TEMPLATE") { + throw makeFetchError(404); + } + if (url === "/repos/octocat/.github") return internalDefaults; + if (url === "/repos/octocat/.github/contents/.github/ISSUE_TEMPLATE") { + return [contentFile("bug.md", ".github/ISSUE_TEMPLATE/bug.md")]; + } + throw new Error(`Unexpected URL: ${url}`); + }); + mocks.rawFetch.mockResolvedValue({ + data: ghRepo, + headers: new Headers({ "x-github-enterprise-version": "3.18.0" }), + status: 200, + }); + + const page = await enterprise.contributionTemplates.list("octocat", "hello-world", "issue"); + + expect(page.items[0]).toMatchObject({ + scope: "owner", + inherited: true, + sourceRepository: "octocat/.github", + }); + }); + + it("rejects invalid local pagination before making a request", async () => { + await expect( + gh.contributionTemplates.list("octocat", "hello-world", "issue", { page: 0 }), + ).rejects.toMatchObject({ status: 400 }); + expect(mocks.client).not.toHaveBeenCalled(); + }); + + it("rejects a stale key instead of treating it as a repository path", async () => { + mocks.client.mockImplementation(async (url: string) => { + if (url === "/repos/octocat/hello-world") return ghRepo; + if (url === "/repos/octocat/hello-world/contents/.github/ISSUE_TEMPLATE") return []; + if (url === "/repos/octocat/.github") throw makeFetchError(404); + throw new Error(`Unexpected URL: ${url}`); + }); + + await expect( + gh.contributionTemplates.get("octocat", "hello-world", "issue", "../../secrets"), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); + describe("constructor", () => { it("creates http client with GitHub auth config", () => { expect(mocks.createHttpClient).toHaveBeenCalledWith({ diff --git a/test/gitlab.test.ts b/test/gitlab.test.ts index 602851d..bf17157 100644 --- a/test/gitlab.test.ts +++ b/test/gitlab.test.ts @@ -382,6 +382,130 @@ describe("GitLabProvider", () => { }); }); + describe("contributionTemplates", () => { + const templateRows = [ + { key: "Bug", name: "Bug" }, + { key: "Bug", name: "Bug" }, + { key: "Security", name: "Security" }, + ]; + + function mockTemplateDiscovery(): void { + mocks.client.mockImplementation(async (url: string) => { + if (url === "/projects/gitlab-org%2Fgitlab-foss") return glProject; + if (url === "/projects/278964/templates/issues/Security") { + return { key: "Security", name: "Security", content: "Report privately.\n" }; + } + throw new Error(`Unexpected URL: ${url}`); + }); + mocks.rawFetch.mockImplementation(async (_client: unknown, url: string) => { + if (url === "/projects/278964/templates/issues") { + return { data: templateRows, headers: new Headers() }; + } + if (url === "/projects/278964/repository/tree") { + return { + data: [ + { + id: "abc123", + name: "Bug.md", + type: "blob", + path: ".gitlab/issue_templates/Bug.md", + }, + ], + headers: new Headers(), + }; + } + throw new Error(`Unexpected raw URL: ${url}`); + }); + } + + it("deduplicates effective names and distinguishes project-local from inherited templates", async () => { + mockTemplateDiscovery(); + + const page = await gl.contributionTemplates.list("gitlab-org", "gitlab-foss", "issue", { + perPage: 1, + }); + + expect(page).toEqual({ + items: [ + { + kind: "issue", + key: "Bug", + name: "Bug", + scope: "repository", + inherited: false, + sourceRepository: "gitlab-org/gitlab-foss", + sourcePath: ".gitlab/issue_templates/Bug.md", + sourceRef: "master", + }, + ], + totalCount: 2, + hasNextPage: true, + nextPage: 2, + }); + }); + + it("retrieves inherited content without inventing unavailable source provenance", async () => { + mockTemplateDiscovery(); + + const template = await gl.contributionTemplates.get( + "gitlab-org", + "gitlab-foss", + "issue", + "Security", + ); + + expect(template).toEqual({ + kind: "issue", + key: "Security", + name: "Security", + scope: "unknown", + inherited: true, + sourceRepository: null, + sourcePath: null, + sourceRef: null, + content: "Report privately.\n", + }); + }); + + it("rejects non-advancing effective-template pagination", async () => { + mocks.client.mockResolvedValueOnce(glProject); + mocks.rawFetch.mockImplementation(async (_client: unknown, url: string) => { + if (url === "/projects/278964/templates/issues") { + return { + data: [{ key: "Bug", name: "Bug" }], + headers: new Headers({ "x-next-page": "1" }), + }; + } + if (url === "/projects/278964/repository/tree") { + return { data: [], headers: new Headers() }; + } + throw new Error(`Unexpected raw URL: ${url}`); + }); + + await expect( + gl.contributionTemplates.list("gitlab-org", "gitlab-foss", "issue"), + ).rejects.toMatchObject({ status: 502 }); + }); + + it("keeps provenance unknown when the local template tree is unavailable", async () => { + mocks.client.mockResolvedValueOnce(glProject); + mocks.rawFetch.mockImplementation(async (_client: unknown, url: string) => { + if (url === "/projects/278964/templates/issues") { + return { + data: [{ key: "Security", name: "Security" }], + headers: new Headers(), + }; + } + if (url === "/projects/278964/repository/tree") throw makeFetchError(404); + throw new Error(`Unexpected raw URL: ${url}`); + }); + + const page = await gl.contributionTemplates.list("gitlab-org", "gitlab-foss", "issue"); + + expect(page.items[0]).toMatchObject({ scope: "unknown", inherited: true }); + }); + }); + describe("constructor", () => { it("creates http client with Private-Token auth", () => { expect(mocks.createHttpClient).toHaveBeenCalledWith({ diff --git a/test/integration.test.ts b/test/integration.test.ts index c7c96e2..cdb560d 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -51,6 +51,7 @@ import { AuthenticationError, ForgesError } from "../src/errors.ts"; import type { ProviderConfig, RepositoryResource, + ContributionTemplateResource, CodeSearchResource, CiRunResource, CommitResource, @@ -73,6 +74,7 @@ describe("createProvider factory", () => { expect(provider).toBeDefined(); expect(provider.repos).toBeDefined(); + expect(provider.contributionTemplates).toBeDefined(); expect(provider.code).toBeDefined(); expect(provider.issues).toBeDefined(); expect(provider.pullRequests).toBeDefined(); @@ -84,6 +86,7 @@ describe("createProvider factory", () => { expect(provider).toBeDefined(); expect(provider.repos).toBeDefined(); + expect(provider.contributionTemplates).toBeDefined(); expect(provider.code).toBeDefined(); expect(provider.issues).toBeDefined(); expect(provider.pullRequests).toBeDefined(); @@ -95,6 +98,7 @@ describe("createProvider factory", () => { expect(provider).toBeDefined(); expect(provider.repos).toBeDefined(); + expect(provider.contributionTemplates).toBeDefined(); expect(provider.code).toBeDefined(); expect(provider.issues).toBeDefined(); expect(provider.pullRequests).toBeDefined(); @@ -145,6 +149,11 @@ describe("createProvider factory", () => { }); it.each([ + [ + "listContributionTemplates", + "Contribution template discovery is not supported by this provider", + ], + ["readContributionTemplate", "Contribution template reads are not supported by this provider"], ["searchCode", "Code search is not supported by this provider"], ["listCiRuns", "CI-run listing is not supported by this provider"], ["listPullRequestChecks", "Pull request checks are not supported by this provider"], @@ -181,6 +190,15 @@ describe("cross-provider class consistency", () => { } }); + it("all providers have contribution-template resources", () => { + for (const platform of platforms) { + const p = providers[platform]; + expect(p.contributionTemplates).toBeDefined(); + expect(typeof p.contributionTemplates.list).toBe("function"); + expect(typeof p.contributionTemplates.get).toBe("function"); + } + }); + it("all providers have code search resource", () => { for (const platform of platforms) { const p = providers[platform]; @@ -262,6 +280,8 @@ describe("cross-provider class consistency", () => { expect(p.repos.list.length).toBeGreaterThanOrEqual(1); expect(p.repos.get.length).toBeGreaterThanOrEqual(2); + expect(p.contributionTemplates.list.length).toBeGreaterThanOrEqual(3); + expect(p.contributionTemplates.get.length).toBeGreaterThanOrEqual(4); expect(p.code.search.length).toBeGreaterThanOrEqual(1); expect(p.ciRuns.list.length).toBeGreaterThanOrEqual(2); expect(p.commits.list.length).toBeGreaterThanOrEqual(2); @@ -452,6 +472,7 @@ describe("type-level consistency", () => { // These should compile without errors const repos: RepositoryResource = provider.repos; + const contributionTemplates: ContributionTemplateResource = provider.contributionTemplates; const code: CodeSearchResource = provider.code; const ciRuns: CiRunResource = provider.ciRuns; const commits: CommitResource = provider.commits; @@ -461,6 +482,7 @@ describe("type-level consistency", () => { const threads: ThreadResource = provider.threads; expect(repos).toBeDefined(); + expect(contributionTemplates).toBeDefined(); expect(code).toBeDefined(); expect(ciRuns).toBeDefined(); expect(commits).toBeDefined(); diff --git a/test/mcp.test.ts b/test/mcp.test.ts index af6a756..9e560ff 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -8,6 +8,7 @@ import { resetPinnedProviders } from "../src/tool-operations.ts"; const mocks = vi.hoisted(() => { const repos = { list: vi.fn(), get: vi.fn() }; + const contributionTemplates = { list: vi.fn(), get: vi.fn() }; const code = { search: vi.fn() }; const ciRuns = { list: vi.fn() }; const commits = { list: vi.fn(), get: vi.fn() }; @@ -28,12 +29,23 @@ const mocks = vi.hoisted(() => { resolve: vi.fn(), unresolve: vi.fn(), }; - const provider = { repos, code, ciRuns, commits, issues, pullRequests, users, threads }; + const provider = { + repos, + contributionTemplates, + code, + ciRuns, + commits, + issues, + pullRequests, + users, + threads, + }; return { resolveToken: vi.fn(() => ({ token: "test-token", source: "env" as const })), createProvider: vi.fn(() => provider), repos, + contributionTemplates, code, ciRuns, commits, @@ -52,6 +64,8 @@ vi.mock("../src/index.ts", () => ({ const toolNames = [ "forges_repos_list", "forges_repos_get", + "forges_contribution_templates_list", + "forges_contribution_templates_get", "forges_code_search", "forges_ci_runs_list", "forges_commits_list", @@ -192,6 +206,61 @@ describe("forges MCP server", () => { expect(response.structuredContent).toBeUndefined(); }); + it("lists contribution-template metadata and reads one body by its returned key", async () => { + const summary = { + kind: "issue", + key: "agntn/.github:.github/ISSUE_TEMPLATE/bug.yml", + name: "bug", + scope: "owner", + inherited: true, + sourceRepository: "agntn/.github", + sourcePath: ".github/ISSUE_TEMPLATE/bug.yml", + sourceRef: "main", + }; + mocks.contributionTemplates.list.mockResolvedValue({ + items: [summary], + totalCount: 1, + hasNextPage: false, + }); + mocks.contributionTemplates.get.mockResolvedValue({ ...summary, content: "body: []\n" }); + const client = await connectTestClient(); + + const listed = await client.callTool({ + name: "forges_contribution_templates_list", + arguments: { + platform: "github", + owner: "agntn", + repo: "forges", + kind: "issue", + page: 1, + perPage: 10, + }, + }); + const read = await client.callTool({ + name: "forges_contribution_templates_get", + arguments: { + platform: "github", + owner: "agntn", + repo: "forges", + kind: "issue", + key: summary.key, + }, + }); + + expect(mocks.contributionTemplates.list).toHaveBeenCalledWith("agntn", "forges", "issue", { + page: 1, + perPage: 10, + }); + expect(mocks.contributionTemplates.get).toHaveBeenCalledWith( + "agntn", + "forges", + "issue", + summary.key, + ); + expect(text(listed.content)).not.toContain("body: []"); + expect(text(read.content)).toContain("body: []"); + }); + it("searches code through the shared operation", async () => { const search = { items: [ diff --git a/test/tool-operations.test.ts b/test/tool-operations.test.ts index 6054417..59caf22 100644 --- a/test/tool-operations.test.ts +++ b/test/tool-operations.test.ts @@ -4,7 +4,9 @@ import { AuthenticationError } from "../src/errors.ts"; import { createIssue, getAuthenticatedUser, + getContributionTemplate, getRepository, + listContributionTemplates, reloadAuthentication, searchCode, resetPinnedProviders, @@ -32,6 +34,36 @@ const mocks = vi.hoisted(() => { const login = anonymous ? "anonymous" : localLogin.current; return { + contributionTemplates: { + list: vi.fn(async (_owner: string, _repo: string, kind: string, options: unknown) => ({ + items: [ + { + kind, + key: "agntn/.github:.github/ISSUE_TEMPLATE/bug.yml", + name: "bug", + scope: "owner", + inherited: true, + sourceRepository: "agntn/.github", + sourcePath: ".github/ISSUE_TEMPLATE/bug.yml", + sourceRef: "main", + }, + ], + totalCount: 1, + hasNextPage: false, + options, + })), + get: vi.fn(async (_owner: string, _repo: string, kind: string, key: string) => ({ + kind, + key, + name: "bug", + scope: "owner", + inherited: true, + sourceRepository: "agntn/.github", + sourcePath: ".github/ISSUE_TEMPLATE/bug.yml", + sourceRef: "main", + content: "name: Bug report\nbody: []\n", + })), + }, code: { search: vi.fn(async (query: string, options: unknown) => ({ items: [ @@ -120,6 +152,29 @@ afterEach(() => { }); describe("configured provider", () => { + it("lists template metadata without bodies and reads one exact key in full", async () => { + const listed = await listContributionTemplates({ + platform: "github", + owner: "agntn", + repo: "forges", + kind: "issue", + page: 2, + perPage: 10, + }); + const summary = listed.details.result.items[0]!; + const read = await getContributionTemplate({ + platform: "github", + owner: "agntn", + repo: "forges", + kind: "issue", + key: summary.key, + }); + + expect(summary).not.toHaveProperty("content"); + expect(listed.content[0].text).toContain("use forges_contribution_templates_get"); + expect(read.details.result.content).toBe("name: Bug report\nbody: []\n"); + }); + it("passes optional scope and pagination to code search", async () => { const searched = await searchCode({ platform: "github",