TypeScript/Playwright MCP server exposing 9 Gitea tools (list_repos, view_repo, view_file, list/create/view/comment/merge/close PR). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
256 lines
8.2 KiB
TypeScript
256 lines
8.2 KiB
TypeScript
import { GITEA_URL, USERNAME, type Session } from './auth';
|
|
|
|
export interface Repo {
|
|
name: string;
|
|
url: string;
|
|
repo: string;
|
|
}
|
|
|
|
export interface RepoFile {
|
|
type: 'file' | 'dir';
|
|
name: string;
|
|
message: string | undefined;
|
|
}
|
|
|
|
export interface RepoView {
|
|
branch: string;
|
|
files: RepoFile[];
|
|
readme: string;
|
|
cloneUrl: string;
|
|
}
|
|
|
|
export interface PR {
|
|
number: string | undefined;
|
|
title: string | undefined;
|
|
url: string | undefined;
|
|
labels: string[];
|
|
}
|
|
|
|
export interface PRDetails {
|
|
title: string | undefined;
|
|
state: string | undefined;
|
|
body: string | undefined;
|
|
headBranch: string | undefined;
|
|
baseBranch: string | undefined;
|
|
comments: Array<{ author: string | undefined; body: string | undefined }>;
|
|
canMerge: boolean;
|
|
isClosed: boolean;
|
|
isMerged: boolean;
|
|
}
|
|
|
|
// List repos visible to the logged-in user
|
|
export async function listRepos({ page }: Session): Promise<Repo[]> {
|
|
await page.goto(`${GITEA_URL}/${USERNAME}`, { waitUntil: 'load' });
|
|
|
|
const reposTab = page.locator('a[href*="?tab=repos"]');
|
|
if (await reposTab.count() > 0) {
|
|
await reposTab.click();
|
|
await page.waitForLoadState('load');
|
|
}
|
|
|
|
return page.evaluate((username: string) => {
|
|
const seen = new Set<string>();
|
|
return [...document.querySelectorAll<HTMLAnchorElement>(`a[href^="/${username}/"]`)]
|
|
.filter(a => {
|
|
const parts = (a.getAttribute('href') ?? '').split('/').filter(Boolean);
|
|
return parts.length === 2;
|
|
})
|
|
.map(a => ({
|
|
name: a.textContent?.trim() ?? '',
|
|
url: a.href,
|
|
repo: (a.getAttribute('href') ?? '').split('/')[2] ?? '',
|
|
}))
|
|
.filter(r => r.name && !seen.has(r.url) && !!seen.add(r.url));
|
|
}, USERNAME);
|
|
}
|
|
|
|
// View a repo's root: file list, default branch, README excerpt
|
|
export async function viewRepo({ page }: Session, owner: string, repo: string): Promise<RepoView> {
|
|
await page.goto(`${GITEA_URL}/${owner}/${repo}`, { waitUntil: 'load' });
|
|
|
|
return page.evaluate((): RepoView => {
|
|
const files = [...document.querySelectorAll('table.files tr, .repository.file.list table tr')]
|
|
.slice(1)
|
|
.map(row => {
|
|
const icon = row.querySelector('svg, .octicon');
|
|
const type: 'file' | 'dir' =
|
|
icon?.classList.contains('octicon-file-directory') ||
|
|
icon?.getAttribute('aria-label')?.includes('dir')
|
|
? 'dir' : 'file';
|
|
const nameEl = row.querySelector('td.name a, td a.muted');
|
|
const msgEl = row.querySelector('td.message a');
|
|
return nameEl
|
|
? { type, name: nameEl.textContent?.trim() ?? '', message: msgEl?.textContent?.trim() }
|
|
: null;
|
|
})
|
|
.filter((f): f is RepoFile => f !== null);
|
|
|
|
const branch =
|
|
document.querySelector('.branch-dropdown-button, [data-clipboard-text]')?.textContent?.trim() ||
|
|
(document.querySelector('input[name="ref"]') as HTMLInputElement | null)?.value ||
|
|
'';
|
|
|
|
const readme =
|
|
document.querySelector('#readme .markdown-body, #readme article, .plain-text pre')
|
|
?.textContent?.slice(0, 3000) ?? '';
|
|
|
|
const cloneUrl =
|
|
(document.querySelector('#repo-clone-https, input#clone-url') as HTMLInputElement | null)?.value ?? '';
|
|
|
|
return { branch, files, readme, cloneUrl };
|
|
});
|
|
}
|
|
|
|
// Get raw file content
|
|
export async function viewFile(
|
|
{ page }: Session,
|
|
owner: string,
|
|
repo: string,
|
|
branch: string,
|
|
filepath: string,
|
|
): Promise<string> {
|
|
const url = `${GITEA_URL}/${owner}/${repo}/raw/branch/${encodeURIComponent(branch)}/${filepath}`;
|
|
const response = await page.goto(url, { waitUntil: 'load' });
|
|
if (!response) throw new Error('No response received');
|
|
return response.text();
|
|
}
|
|
|
|
// List pull requests for a repo
|
|
export async function listPRs(
|
|
{ page }: Session,
|
|
owner: string,
|
|
repo: string,
|
|
state: 'open' | 'closed' | 'all' = 'open',
|
|
): Promise<PR[]> {
|
|
await page.goto(`${GITEA_URL}/${owner}/${repo}/pulls?state=${state}&type=pullrequests`, { waitUntil: 'load' });
|
|
|
|
return page.evaluate((): PR[] => {
|
|
return [...document.querySelectorAll('.issue.list .item')]
|
|
.map(el => {
|
|
const titleEl = el.querySelector<HTMLAnchorElement>('.title, a.title');
|
|
const numberEl = el.querySelector('.index');
|
|
const labels = [...el.querySelectorAll('.label')].map(l => l.textContent?.trim() ?? '');
|
|
return {
|
|
number: numberEl?.textContent?.replace('#', '').trim(),
|
|
title: titleEl?.textContent?.trim(),
|
|
url: titleEl?.href ?? el.querySelector('a')?.href,
|
|
labels,
|
|
};
|
|
})
|
|
.filter(pr => pr.title);
|
|
});
|
|
}
|
|
|
|
// Create a new pull request
|
|
export async function createPR(
|
|
{ page }: Session,
|
|
owner: string,
|
|
repo: string,
|
|
head: string,
|
|
base: string,
|
|
title: string,
|
|
body = '',
|
|
): Promise<{ url: string; number: string | undefined }> {
|
|
await page.goto(`${GITEA_URL}/${owner}/${repo}/compare/${base}...${head}`, { waitUntil: 'load' });
|
|
|
|
await page.fill('input[name="title"]', title);
|
|
if (body) {
|
|
await page.locator('textarea[name="content"], #content').fill(body);
|
|
}
|
|
|
|
await Promise.all([
|
|
page.waitForNavigation({ waitUntil: 'load' }),
|
|
page.locator('button[type="submit"]:has-text("Create"), input[type="submit"]').first().click(),
|
|
]);
|
|
|
|
return {
|
|
url: page.url(),
|
|
number: page.url().match(/\/pulls\/(\d+)/)?.[1],
|
|
};
|
|
}
|
|
|
|
// View a specific pull request: details + comments
|
|
export async function viewPR(
|
|
{ page }: Session,
|
|
owner: string,
|
|
repo: string,
|
|
number: number,
|
|
): Promise<PRDetails> {
|
|
await page.goto(`${GITEA_URL}/${owner}/${repo}/pulls/${number}`, { waitUntil: 'load' });
|
|
|
|
return page.evaluate((): PRDetails => {
|
|
const comments = [...document.querySelectorAll('.comment:not(.first)')]
|
|
.map(c => ({
|
|
author: c.querySelector('.author')?.textContent?.trim(),
|
|
body: c.querySelector('.render-content')?.textContent?.trim(),
|
|
}))
|
|
.filter(c => c.body);
|
|
|
|
return {
|
|
title: document.querySelector('.issue-title, h1.issue-title')?.textContent?.trim(),
|
|
state: document.querySelector('.issue-state-label, .label.green, .label.red, .label.purple')?.textContent?.trim(),
|
|
body: document.querySelector('.comment.first .render-content, .post-content')?.textContent?.trim(),
|
|
headBranch: document.querySelector('.head-branch, .compare-branch')?.textContent?.trim(),
|
|
baseBranch: document.querySelector('.base-branch')?.textContent?.trim(),
|
|
comments,
|
|
canMerge: !!document.querySelector('button.merge-button, .merge-section button[type="submit"]'),
|
|
isClosed: !!document.querySelector('.issue-state-label')?.textContent?.toLowerCase().includes('closed'),
|
|
isMerged: !!document.querySelector('.merged-section, .label.purple'),
|
|
};
|
|
});
|
|
}
|
|
|
|
// Comment on a pull request
|
|
export async function commentPR(
|
|
{ page }: Session,
|
|
owner: string,
|
|
repo: string,
|
|
number: number,
|
|
body: string,
|
|
): Promise<{ url: string }> {
|
|
await page.goto(`${GITEA_URL}/${owner}/${repo}/pulls/${number}`, { waitUntil: 'load' });
|
|
|
|
await page.locator('textarea[name="content"]').last().fill(body);
|
|
|
|
await Promise.all([
|
|
page.waitForNavigation({ waitUntil: 'load' }),
|
|
page.locator('button[type="submit"]:has-text("Comment")').last().click(),
|
|
]);
|
|
|
|
return { url: page.url() };
|
|
}
|
|
|
|
// Merge a pull request
|
|
export async function mergePR(
|
|
{ page }: Session,
|
|
owner: string,
|
|
repo: string,
|
|
number: number,
|
|
): Promise<{ url: string; merged: true }> {
|
|
await page.goto(`${GITEA_URL}/${owner}/${repo}/pulls/${number}`, { waitUntil: 'load' });
|
|
|
|
const mergeBtn = page.locator('button.merge-button, .merge-section button[type="submit"]').first();
|
|
await mergeBtn.waitFor({ state: 'visible' });
|
|
await mergeBtn.click();
|
|
await page.waitForLoadState('load');
|
|
|
|
return { url: page.url(), merged: true };
|
|
}
|
|
|
|
// Close a pull request without merging
|
|
export async function closePR(
|
|
{ page }: Session,
|
|
owner: string,
|
|
repo: string,
|
|
number: number,
|
|
): Promise<{ url: string; closed: true }> {
|
|
await page.goto(`${GITEA_URL}/${owner}/${repo}/pulls/${number}`, { waitUntil: 'load' });
|
|
|
|
const closeBtn = page.locator('button:has-text("Close"), a:has-text("Close Pull Request")').first();
|
|
await closeBtn.waitFor({ state: 'visible' });
|
|
await closeBtn.click();
|
|
await page.waitForLoadState('load');
|
|
|
|
return { url: page.url(), closed: true };
|
|
}
|