feat(ui): forge write operations — comments, replies, close/reopen, edit, reviews, draft, metadata
- server: write routes for all three providers (issue/PR comments, inline review-comment replies, issue/MR updates w/ labels-assignees-milestone, review submit, draft toggle) - ui: ForgeProvider gains six write ops; shared action components (composer, thread reply, state/review/draft/metadata/edit) wired into ForgeEntityDetailView and GitHub PR Overview
This commit is contained in:
@@ -33,7 +33,7 @@
|
||||
|
||||
### Client (`client.js`)
|
||||
|
||||
- `createGiteaClient({ token, baseUrl })`: raw-fetch REST v1 client with `request(path, { method, query, body, signal, raw })` plus convenience methods `user()`, `repo(owner, repo)`, `issues(owner, repo, params)`, `issue(owner, repo, number)`, `issueComments(owner, repo, number, params)`, `pullRequests(owner, repo, params)`, `pullRequest(owner, repo, number)`, `pullRequestDiff(owner, repo, number)` (raw `.diff` text via the `raw` option), `pullRequestFiles(owner, repo, number, params)`, `pullRequestCommits(owner, repo, number, params)`, `pullRequestReviews(owner, repo, number, params)`, `commitStatuses(owner, repo, sha, params)`, `createPullRequest(owner, repo, body)`, `updatePullRequest(owner, repo, number, body)` (PATCH), `mergePullRequest(owner, repo, number, body)` (POST), `branches(owner, repo, params)`.
|
||||
- `createGiteaClient({ token, baseUrl })`: raw-fetch REST v1 client with `request(path, { method, query, body, signal, raw })` plus convenience methods `user()`, `repo(owner, repo)`, `issues(owner, repo, params)`, `issue(owner, repo, number)`, `issueComments(owner, repo, number, params)`, `createIssueComment(owner, repo, number, body)`, `updateIssue(owner, repo, number, params)` (PATCH), `milestones(owner, repo, params)`, `repoLabels(owner, repo, params)`, `pullRequests(owner, repo, params)`, `pullRequest(owner, repo, number)`, `pullRequestDiff(owner, repo, number)` (raw `.diff` text via the `raw` option), `pullRequestFiles(owner, repo, number, params)`, `pullRequestCommits(owner, repo, number, params)`, `pullRequestReviews(owner, repo, number, params)`, `createPullReview(owner, repo, number, params)` (POST), `commitStatuses(owner, repo, sha, params)`, `createPullRequest(owner, repo, body)`, `updatePullRequest(owner, repo, number, body)` (PATCH), `mergePullRequest(owner, repo, number, body)` (POST), `branches(owner, repo, params)`.
|
||||
- `getGiteaClientOrNull()`: client for the current account, or `null`.
|
||||
- `isGiteaRateLimited()` / `noteGiteaRateLimit(error)`: own module-level rate-limit cooldown (not shared with the GitHub/GitLab modules).
|
||||
|
||||
@@ -76,8 +76,13 @@
|
||||
- PR reviews: `GET /repos/{owner}/{repo}/pulls/{number}/reviews?limit=100` (mapped to `{ id, state, author, submittedAt, body, commitSha }`; `state` passes through, e.g. `APPROVED`/`REQUEST_CHANGES`).
|
||||
- Commit statuses: `GET /repos/{owner}/{repo}/commits/{sha}/statuses?limit=100` (the `prs/statuses` route resolves the PR `head.sha` first, then maps statuses to `{ state, name, description, url, createdAt }` with `state` lowercased).
|
||||
- PR create: `POST /repos/{owner}/{repo}/pulls` with `{ title, head, base, body? }` (body omitted when absent).
|
||||
- PR update: `PATCH /repos/{owner}/{repo}/pulls/{number}` with `{ title?, body? }` (undefined fields omitted).
|
||||
- PR update: `PATCH /repos/{owner}/{repo}/pulls/{number}` with `{ title?, body?, state? }` (undefined fields omitted; the PR number IS the issue index, so the edit-issue `state` transition applies directly).
|
||||
- PR merge: `POST /repos/{owner}/{repo}/pulls/{number}/merge` with `{ Do: true, MergeMethod: 'merge' | 'squash' | 'rebase' }` (`method` defaults to `'merge'`).
|
||||
- Issue comment write: `POST /repos/{owner}/{repo}/issues/{number}/comments` with `{ body }` (PRs are issues at the API level, so `prs/comment` uses the same endpoint with the PR number as the index).
|
||||
- Issue update: `PATCH /repos/{owner}/{repo}/issues/{number}` with `{ title?, body?, state?, labels?, assignees?, milestone?, unset_milestone? }` (labels are label **names**, assignees are logins; `milestone` is resolved from a title to a milestone id and `null` sets `unset_milestone: true`).
|
||||
- Pull review write: `POST /repos/{owner}/{repo}/pulls/{number}/reviews` with `{ event, body? }` (`event` is `APPROVED`/`REQUEST_CHANGES`/`COMMENT`).
|
||||
- Milestones: `GET /repos/{owner}/{repo}/milestones?state=all&limit=50` (first page) for title-to-id resolution on issue updates.
|
||||
- Repo labels: `GET /repos/{owner}/{repo}/labels?limit=100` (first page) so metadata editors can offer existing labels.
|
||||
- Branches: `GET /repos/{owner}/{repo}/branches?limit=50&page=N` mapped to names, plus `GET /repos/{owner}/{repo}` for `default_branch` (Gitea branch objects carry no default flag).
|
||||
- There is **no ready-for-review endpoint** in this module (Gitea has no GitLab-style ready_for_review action).
|
||||
|
||||
@@ -99,8 +104,13 @@
|
||||
| GET | `/api/gitea/prs/reviews` | `?directory&number&owner&repo` -> `{ connected, repo?, reviews[] }` |
|
||||
| GET | `/api/gitea/prs/statuses` | `?directory&number&owner&repo` -> `{ connected, repo?, statuses[] }` (resolves the PR `head.sha` first, then lists commit statuses for that SHA) |
|
||||
| POST | `/api/gitea/pr/create` | body `{ directory, title, sourceBranch, targetBranch, description? }` -> `{ connected, repo?, pr }`; `400` for missing fields or an unresolvable repo |
|
||||
| PATCH | `/api/gitea/pr/update` | body `{ directory, number, title?, description? }` -> `{ connected, repo?, pr }`; `404` when the PR does not exist |
|
||||
| PATCH | `/api/gitea/pr/update` | body `{ directory, number, title?, description?, state? }` -> `{ connected, repo?, pr }`; `404` when the PR does not exist |
|
||||
| POST | `/api/gitea/pr/merge` | body `{ directory, number, method? }` -> `{ connected, merged: true }` on success; non-mergeable PRs -> the Gitea status (`405`/`409`/`422`) with `{ connected, merged: false, message }` |
|
||||
| POST | `/api/gitea/issues/comment` | body `{ directory, number, body, owner?, repo? }` -> `{ connected, repo?, comment }` |
|
||||
| PATCH | `/api/gitea/issues/update` | body `{ directory, number, title?, body?, state?, labels?, assignees?, milestone?, owner?, repo? }` -> `{ connected, repo?, issue }`; `400 'Milestone not found'` when a milestone title does not match |
|
||||
| POST | `/api/gitea/prs/comment` | body `{ directory, number, body, owner?, repo? }` -> `{ connected, repo?, comment }` (PRs are issues at the API level, so the PR number is the issue index) |
|
||||
| POST | `/api/gitea/prs/review` | body `{ directory, number, event, body?, owner?, repo? }` -> `{ connected, repo?, review }`; `400` when `event` is not `APPROVED`/`REQUEST_CHANGES`/`COMMENT` |
|
||||
| GET | `/api/gitea/repo/labels` | `?directory&owner&repo` -> `{ connected, repo?, labels[] }` |
|
||||
| GET | `/api/gitea/repo/branches` | `?owner&repo` -> `{ branches[], defaultBranch? }` (`defaultBranch` is `null` when Gitea is disconnected or the repo has no default) |
|
||||
|
||||
Conventions mirror `github/routes.js` and `gitlab/routes.js`:
|
||||
@@ -110,8 +120,8 @@ Conventions mirror `github/routes.js` and `gitlab/routes.js`:
|
||||
- Hard failures -> `4xx`/`5xx` with `{ error }`.
|
||||
- A Gitea `429` -> `503 { error: 'Gitea rate limited' }`.
|
||||
- Lazy-import pattern: route handlers import `./index.js` on first use, so the module never loads unless Gitea endpoints are hit.
|
||||
- Composite routes run under a 15 s route-level budget on top of the client's 8 s per-request timeout.
|
||||
- Repo targeting: `owner`/`repo` query params override the directory-local git remote.
|
||||
- Composite routes run under a 15 s route-level budget on top of the client's 8 s per-request timeout. Write routes deliberately skip the route-level timeout (a timeout can orphan a write); the client's per-request timeout still bounds them.
|
||||
- Repo targeting: `owner`/`repo` query params override the directory-local git remote; write routes also accept them in the JSON body.
|
||||
|
||||
## Consumers
|
||||
|
||||
@@ -124,6 +134,7 @@ Conventions mirror `github/routes.js` and `gitlab/routes.js`:
|
||||
- A repo that does not resolve from the local git remote yields `repo: null` with empty lists, matching GitHub/GitLab behavior. Write routes reject an unresolvable repo with `400 { error: 'Unable to resolve Gitea repo from directory' }`.
|
||||
- Invalid/expired tokens are cleared on `401`/`403` and reported as disconnected.
|
||||
- Gitea `403` on write routes means the token lacks repository write scope; they respond `400 { error: 'Your Gitea token needs write:repository scope to ...' }`.
|
||||
- Milestone titles on issue updates are resolved against `GET /repos/{owner}/{repo}/milestones`; an unmatched title yields `400 { error: 'Milestone not found' }` and `null` sets `unset_milestone: true`.
|
||||
- PR merge rejections (`405`/`409`/`422` from Gitea) are surfaced as `{ connected, merged: false, message }` with the Gitea status so clients can show the message without treating it as a transport error (mirrors `github/pr/merge`).
|
||||
- The pull-files endpoint returning `404` (older Gitea) yields `files: []` instead of failing the whole PR context; a missing `.diff` falls back to concatenated patches.
|
||||
- Rate-limit and timeout failures surface explicit `503` responses so clients keep last-known state rather than clearing UI.
|
||||
@@ -134,4 +145,4 @@ Conventions mirror `github/routes.js` and `gitlab/routes.js`:
|
||||
- Never log tokens. Error messages must not include the access token.
|
||||
- The ETag cache and rate-limit cooldown are module-level and per-instance — they are NOT shared with the GitHub or GitLab modules.
|
||||
- Gitea `GET /user` returns `login`/`full_name`/`html_url`; the route mappers accept the GitHub-style `username`/`name`/`web_url` variants too, so Forgejo versions that differ still map.
|
||||
- To add further Gitea write operations (comment, assign, issue writes), add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the existing PR write routes and the GitHub PR write routes.
|
||||
- To add further Gitea write operations, add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the existing issue/PR write routes and the GitHub PR write routes.
|
||||
|
||||
@@ -263,6 +263,14 @@ export function createGiteaClient({ token, baseUrl }) {
|
||||
request(`/repos/${owner}/${repo}/issues/${number}`),
|
||||
issueComments: (owner, repo, number, params = {}) =>
|
||||
request(`/repos/${owner}/${repo}/issues/${number}/comments`, { query: params }),
|
||||
createIssueComment: (owner, repo, number, body) =>
|
||||
request(`/repos/${owner}/${repo}/issues/${number}/comments`, { method: 'POST', body: { body } }),
|
||||
updateIssue: (owner, repo, number, params) =>
|
||||
request(`/repos/${owner}/${repo}/issues/${number}`, { method: 'PATCH', body: params }),
|
||||
milestones: (owner, repo, params = {}) =>
|
||||
request(`/repos/${owner}/${repo}/milestones`, { query: params }),
|
||||
repoLabels: (owner, repo, params = {}) =>
|
||||
request(`/repos/${owner}/${repo}/labels`, { query: params }),
|
||||
pullRequests: (owner, repo, params = {}) =>
|
||||
request(`/repos/${owner}/${repo}/pulls`, { query: params }),
|
||||
pullRequest: (owner, repo, number) =>
|
||||
@@ -275,6 +283,8 @@ export function createGiteaClient({ token, baseUrl }) {
|
||||
request(`/repos/${owner}/${repo}/pulls/${number}/commits`, { query: params }),
|
||||
pullRequestReviews: (owner, repo, number, params = {}) =>
|
||||
request(`/repos/${owner}/${repo}/pulls/${number}/reviews`, { query: params }),
|
||||
createPullReview: (owner, repo, number, params) =>
|
||||
request(`/repos/${owner}/${repo}/pulls/${number}/reviews`, { method: 'POST', body: params }),
|
||||
commitStatuses: (owner, repo, sha, params = {}) =>
|
||||
request(`/repos/${owner}/${repo}/commits/${sha}/statuses`, { query: params }),
|
||||
createPullRequest: (owner, repo, body) =>
|
||||
|
||||
@@ -266,6 +266,70 @@ describe('pull request write methods', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue, review, and repo write methods', () => {
|
||||
test('createIssueComment POSTs a body to the issue comments endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ id: 5, body: 'hi' }, { status: 201 }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
const result = await client.createIssueComment('owner', 'repo', 7, 'Nice catch');
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/issues/7/comments');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'Nice catch' });
|
||||
expect(result.status).toBe(201);
|
||||
});
|
||||
|
||||
test('updateIssue PATCHes params to the issue endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ number: 7, title: 'Updated' }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.updateIssue('owner', 'repo', 7, { state: 'closed', labels: ['bug'], milestone: 33 });
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/issues/7');
|
||||
expect(options.method).toBe('PATCH');
|
||||
expect(JSON.parse(options.body)).toEqual({ state: 'closed', labels: ['bug'], milestone: 33 });
|
||||
});
|
||||
|
||||
test('createPullReview POSTs event/body to the reviews endpoint', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ id: 101, state: 'APPROVED' }, { status: 201 }));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.createPullReview('owner', 'repo', 12, { event: 'APPROVED', body: 'LGTM' });
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/pulls/12/reviews');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ event: 'APPROVED', body: 'LGTM' });
|
||||
});
|
||||
|
||||
test('milestones GETs the repo milestones list', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse([{ id: 33, title: 'v1.0' }]));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.milestones('owner', 'repo', { state: 'all', limit: 50 });
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/milestones?state=all&limit=50');
|
||||
});
|
||||
|
||||
test('repoLabels GETs the repo labels list', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse([{ id: 1, name: 'bug', color: 'd73a4a' }]));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const client = createGiteaClient({ token: 't', baseUrl: 'https://gitea.example.com' });
|
||||
await client.repoLabels('owner', 'repo', { limit: 100 });
|
||||
|
||||
const [url] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://gitea.example.com/api/v1/repos/owner/repo/labels?limit=100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limiting', () => {
|
||||
// NOTE: these tests run last in this file. The rate-limit cooldown is
|
||||
// module-level and has no reset export, so earlier tests must not set one.
|
||||
|
||||
@@ -23,9 +23,12 @@ function withTimeout(promise, timeoutMs, label) {
|
||||
|
||||
const asString = (value) => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
// Resolve the requested repo from the query (read routes) or the JSON body
|
||||
// (write routes). `owner`/`repo` override the directory-local git remote for
|
||||
// repos checked out from non-Gitea remotes.
|
||||
const getRequestedRepo = (req) => {
|
||||
const owner = asString(req.query?.owner);
|
||||
const repo = asString(req.query?.repo);
|
||||
const owner = asString(req.query?.owner) || asString(req.body?.owner);
|
||||
const repo = asString(req.query?.repo) || asString(req.body?.repo);
|
||||
return owner && repo ? { owner, repo } : null;
|
||||
};
|
||||
|
||||
@@ -130,6 +133,13 @@ const mapGiteaComment = (comment) => ({
|
||||
createdAt: typeof comment.created_at === 'string' ? comment.created_at : undefined,
|
||||
});
|
||||
|
||||
const mapGiteaIssue = (item) => ({
|
||||
...mapGiteaIssueSummary(item),
|
||||
body: typeof item.body === 'string' ? item.body : '',
|
||||
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
|
||||
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
|
||||
});
|
||||
|
||||
// Gitea's pull-files endpoint returns capitalized JSON fields
|
||||
// (Filename/Status/Additions/Deletions/Patch); tolerate the lowercase GitHub
|
||||
// style too for Forgejo versions that match GitHub output.
|
||||
@@ -156,6 +166,24 @@ const giteaErrorMessage = (data) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Gitea issue/PR update endpoints take `milestone` (numeric), not the title.
|
||||
// Resolve a title via the repo milestones list (first page is enough for
|
||||
// title-based lookups); unmatched titles yield `milestoneId: null` so routes
|
||||
// can surface `400 { error: 'Milestone not found' }`.
|
||||
const resolveMilestoneId = async (client, owner, repo, title) => {
|
||||
const resp = await client.milestones(owner, repo, { state: 'all', limit: 50 });
|
||||
if (resp.status === 429) {
|
||||
return { milestoneId: null, rateLimited: true };
|
||||
}
|
||||
if (resp.status !== 200 || !Array.isArray(resp.data)) {
|
||||
return { milestoneId: null, rateLimited: false };
|
||||
}
|
||||
const match = resp.data.find(
|
||||
(item) => typeof item?.title === 'string' && item.title.toLowerCase() === title.toLowerCase(),
|
||||
);
|
||||
return { milestoneId: typeof match?.id === 'number' ? match.id : null, rateLimited: false };
|
||||
};
|
||||
|
||||
const repoRefFromOwnerRepo = (owner, repo, baseUrl) => {
|
||||
let host = null;
|
||||
let normalizedBaseUrl = null;
|
||||
@@ -442,12 +470,7 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
}
|
||||
|
||||
const item = resp.data;
|
||||
const issue = {
|
||||
...mapGiteaIssueSummary(item),
|
||||
body: typeof item.body === 'string' ? item.body : '',
|
||||
createdAt: typeof item.created_at === 'string' ? item.created_at : undefined,
|
||||
updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined,
|
||||
};
|
||||
const issue = mapGiteaIssue(item);
|
||||
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), issue });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Gitea issue:', error);
|
||||
@@ -497,6 +520,135 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/gitea/issues/comment', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
||||
if (!directory || !number || !body) {
|
||||
return res.status(400).json({ error: 'directory, number, body are required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
||||
if (!owner || !repo) {
|
||||
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
||||
}
|
||||
|
||||
const resp = await client.createIssueComment(owner, repo, number, body);
|
||||
if (resp.status === 429) {
|
||||
return res.status(503).json({ error: 'Gitea rate limited' });
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to comment on issues' });
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
return res.status(404).json({ error: 'Issue not found' });
|
||||
}
|
||||
if (resp.status !== 200 && resp.status !== 201) {
|
||||
const status = resp.status >= 500 ? 500 : 400;
|
||||
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while creating the comment' });
|
||||
}
|
||||
if (!resp.data) {
|
||||
return res.status(500).json({ error: 'Gitea returned an empty response while creating the comment' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
||||
comment: mapGiteaComment(resp.data),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create Gitea issue comment:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to create Gitea issue comment' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/gitea/issues/update', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
if (!directory || !number) {
|
||||
return res.status(400).json({ error: 'directory and number are required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
||||
if (!owner || !repo) {
|
||||
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
||||
}
|
||||
|
||||
const body = {};
|
||||
if (typeof req.body?.title === 'string') {
|
||||
body.title = req.body.title.trim();
|
||||
}
|
||||
if (typeof req.body?.body === 'string') {
|
||||
body.body = req.body.body;
|
||||
}
|
||||
if (req.body?.state === 'open' || req.body?.state === 'closed') {
|
||||
body.state = req.body.state;
|
||||
}
|
||||
// Gitea accepts label names (not ids) in the edit-issue payload.
|
||||
if (Array.isArray(req.body?.labels)) {
|
||||
body.labels = req.body.labels.filter((label) => typeof label === 'string');
|
||||
}
|
||||
if (Array.isArray(req.body?.assignees)) {
|
||||
body.assignees = req.body.assignees.filter((login) => typeof login === 'string');
|
||||
}
|
||||
if (req.body?.milestone !== undefined) {
|
||||
if (req.body.milestone === null) {
|
||||
body.unset_milestone = true;
|
||||
} else if (typeof req.body.milestone === 'string' && req.body.milestone.trim()) {
|
||||
const { milestoneId, rateLimited } = await resolveMilestoneId(client, owner, repo, req.body.milestone.trim());
|
||||
if (rateLimited) {
|
||||
return res.status(503).json({ error: 'Gitea rate limited' });
|
||||
}
|
||||
if (milestoneId === null) {
|
||||
return res.status(400).json({ error: 'Milestone not found' });
|
||||
}
|
||||
body.milestone = milestoneId;
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await client.updateIssue(owner, repo, number, body);
|
||||
if (resp.status === 429) {
|
||||
return res.status(503).json({ error: 'Gitea rate limited' });
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to update issues' });
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
return res.status(404).json({ error: 'Issue not found' });
|
||||
}
|
||||
if (resp.status !== 200 && resp.status !== 201) {
|
||||
const status = resp.status >= 500 ? 500 : 400;
|
||||
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while updating the issue' });
|
||||
}
|
||||
if (!resp.data) {
|
||||
return res.status(500).json({ error: 'Gitea returned an empty response while updating the issue' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
||||
issue: mapGiteaIssue(resp.data),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update Gitea issue:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to update Gitea issue' });
|
||||
}
|
||||
});
|
||||
|
||||
// ================= Gitea Pull Request APIs =================
|
||||
|
||||
app.get('/api/gitea/prs/list', async (req, res) => {
|
||||
@@ -963,6 +1115,11 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
if (description !== undefined) {
|
||||
body.body = description;
|
||||
}
|
||||
// PRs are issues at the API level in Gitea (the PR number IS the issue
|
||||
// index), so the edit-issue `state` transition applies directly.
|
||||
if (req.body?.state === 'open' || req.body?.state === 'closed') {
|
||||
body.state = req.body.state;
|
||||
}
|
||||
|
||||
const resp = await withTimeout(client.updatePullRequest(owner, repo, number, body), ROUTE_TIMEOUT_MS, 'gitea pr update');
|
||||
if (resp.status === 429) {
|
||||
@@ -1047,6 +1204,122 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// PRs are issues at the API level in Gitea, so a PR comment is an issue
|
||||
// comment addressed by the PR number (which IS the issue index).
|
||||
app.post('/api/gitea/prs/comment', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
||||
if (!directory || !number || !body) {
|
||||
return res.status(400).json({ error: 'directory, number, body are required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
||||
if (!owner || !repo) {
|
||||
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
||||
}
|
||||
|
||||
const resp = await client.createIssueComment(owner, repo, number, body);
|
||||
if (resp.status === 429) {
|
||||
return res.status(503).json({ error: 'Gitea rate limited' });
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to comment on pull requests' });
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
return res.status(404).json({ error: 'Pull request not found' });
|
||||
}
|
||||
if (resp.status !== 200 && resp.status !== 201) {
|
||||
const status = resp.status >= 500 ? 500 : 400;
|
||||
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while creating the comment' });
|
||||
}
|
||||
if (!resp.data) {
|
||||
return res.status(500).json({ error: 'Gitea returned an empty response while creating the comment' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
||||
comment: mapGiteaComment(resp.data),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create Gitea pull request comment:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to create Gitea pull request comment' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/gitea/prs/review', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.body?.directory);
|
||||
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
||||
const event = typeof req.body?.event === 'string' ? req.body.event : '';
|
||||
if (!directory || !number || !event) {
|
||||
return res.status(400).json({ error: 'directory, number, event are required' });
|
||||
}
|
||||
if (event !== 'APPROVED' && event !== 'REQUEST_CHANGES' && event !== 'COMMENT') {
|
||||
return res.status(400).json({ error: 'event must be APPROVED, REQUEST_CHANGES, or COMMENT' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
return res.json({ connected: false });
|
||||
}
|
||||
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
||||
if (!owner || !repo) {
|
||||
return res.status(400).json({ error: 'Unable to resolve Gitea repo from directory' });
|
||||
}
|
||||
|
||||
const params = { event };
|
||||
if (typeof req.body?.body === 'string' && req.body.body) {
|
||||
params.body = req.body.body;
|
||||
}
|
||||
|
||||
const resp = await client.createPullReview(owner, repo, number, params);
|
||||
if (resp.status === 429) {
|
||||
return res.status(503).json({ error: 'Gitea rate limited' });
|
||||
}
|
||||
if (resp.status === 403) {
|
||||
return res.status(400).json({ error: 'Your Gitea token needs write:repository scope to review pull requests' });
|
||||
}
|
||||
if (resp.status === 404) {
|
||||
return res.status(404).json({ error: 'Pull request not found' });
|
||||
}
|
||||
if (resp.status !== 200 && resp.status !== 201) {
|
||||
const status = resp.status >= 500 ? 500 : 400;
|
||||
return res.status(status).json({ error: giteaErrorMessage(resp.data) || 'Gitea returned an error while submitting the review' });
|
||||
}
|
||||
if (!resp.data) {
|
||||
return res.status(500).json({ error: 'Gitea returned an empty response while submitting the review' });
|
||||
}
|
||||
|
||||
const review = resp.data;
|
||||
return res.json({
|
||||
connected: true,
|
||||
repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl),
|
||||
review: {
|
||||
id: String(review.id),
|
||||
state: typeof review.state === 'string' ? review.state : event,
|
||||
author: mapGiteaAuthor(review.user) || null,
|
||||
...(typeof review.submitted_at === 'string' ? { submittedAt: review.submitted_at } : {}),
|
||||
body: typeof review.body === 'string' ? review.body : null,
|
||||
...(typeof review.commit_id === 'string' ? { commitSha: review.commit_id } : null),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to submit Gitea pull request review:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to submit Gitea pull request review' });
|
||||
}
|
||||
});
|
||||
|
||||
// ================= Gitea Repo APIs =================
|
||||
|
||||
app.get('/api/gitea/repo/branches', async (req, res) => {
|
||||
@@ -1101,4 +1374,48 @@ export function registerGiteaRoutes(app, options = {}) {
|
||||
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea repo branches' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/gitea/repo/labels', async (req, res) => {
|
||||
try {
|
||||
const directory = asString(req.query?.directory);
|
||||
const requestedRepo = getRequestedRepo(req);
|
||||
if (!directory && !requestedRepo) {
|
||||
return res.status(400).json({ error: 'directory or owner/repo is required' });
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
return res.json({ connected: false, labels: [] });
|
||||
}
|
||||
|
||||
const { owner, repo, repoRef } = await resolveRepoForRequest(directory, requestedRepo);
|
||||
if (!owner || !repo) {
|
||||
return res.json({ connected: true, repo: null, labels: [] });
|
||||
}
|
||||
|
||||
const resp = await withTimeout(
|
||||
client.repoLabels(owner, repo, { limit: 100 }),
|
||||
ROUTE_TIMEOUT_MS,
|
||||
'gitea repo labels',
|
||||
);
|
||||
if (resp.status === 429) {
|
||||
return res.status(503).json({ error: 'Gitea rate limited' });
|
||||
}
|
||||
if (resp.status !== 200) {
|
||||
return res.status(502).json({ error: 'Gitea returned an error while fetching repo labels' });
|
||||
}
|
||||
|
||||
const labels = (Array.isArray(resp.data) ? resp.data : []).map((label) => ({
|
||||
...(typeof label.id === 'number' ? { id: label.id } : {}),
|
||||
name: typeof label.name === 'string' ? label.name : '',
|
||||
...(typeof label.color === 'string' ? { color: label.color } : {}),
|
||||
...(typeof label.description === 'string' ? { description: label.description } : {}),
|
||||
}));
|
||||
|
||||
return res.json({ connected: true, repo: repoRef || repoRefFromOwnerRepo(owner, repo, client.baseUrl), labels });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Gitea repo labels:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to fetch Gitea repo labels' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1046,6 +1046,301 @@ describe('Gitea data routes', () => {
|
||||
expect(statuses.body).toMatchObject({ connected: false, statuses: [] });
|
||||
});
|
||||
|
||||
test('issues/comment POSTs a comment and maps it', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7\/comments$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({
|
||||
id: 5,
|
||||
body: 'Nice catch',
|
||||
html_url: 'https://gitea.example.com/owner/repo/issues/7#issuecomment-5',
|
||||
user: { id: 42, login: 'alice', full_name: 'Alice Example' },
|
||||
created_at: '2026-01-02T11:00:00Z',
|
||||
}, { status: 201 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitea/issues/comment')
|
||||
.send({ directory: '/tmp/work', number: 7, body: 'Nice catch' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com' },
|
||||
comment: {
|
||||
id: 5,
|
||||
body: 'Nice catch',
|
||||
url: 'https://gitea.example.com/owner/repo/issues/7#issuecomment-5',
|
||||
author: { username: 'alice', id: 42 },
|
||||
createdAt: '2026-01-02T11:00:00Z',
|
||||
},
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'Nice catch' });
|
||||
});
|
||||
|
||||
test('issues/comment reports connected:false when not authenticated', async () => {
|
||||
clearGiteaAuth();
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitea/issues/comment')
|
||||
.send({ directory: '/tmp/work', number: 7, body: 'hello' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ connected: false });
|
||||
});
|
||||
|
||||
test('issues/update maps labels, assignees, state, and resolves the milestone title', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url) => (matches(/\/milestones\?/)(url)
|
||||
? jsonResponse([{ id: 33, title: 'v1.0', state: 'open' }])
|
||||
: null),
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7$/)(url) && options.method === 'PATCH') {
|
||||
return jsonResponse({
|
||||
number: 7,
|
||||
title: 'Updated issue',
|
||||
html_url: 'https://gitea.example.com/owner/repo/issues/7',
|
||||
state: 'closed',
|
||||
body: 'New body',
|
||||
user: { id: 42, login: 'alice' },
|
||||
labels: [{ id: 1, name: 'bug' }],
|
||||
assignees: [{ id: 43, login: 'bob' }],
|
||||
milestone: { id: 33, title: 'v1.0', state: 'open' },
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.patch('/api/gitea/issues/update')
|
||||
.send({
|
||||
directory: '/tmp/work',
|
||||
number: 7,
|
||||
title: 'Updated issue',
|
||||
body: 'New body',
|
||||
state: 'closed',
|
||||
labels: ['bug'],
|
||||
assignees: ['bob'],
|
||||
milestone: 'v1.0',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
issue: {
|
||||
number: 7,
|
||||
title: 'Updated issue',
|
||||
state: 'closed',
|
||||
body: 'New body',
|
||||
labels: ['bug'],
|
||||
assignees: [{ username: 'bob', id: 43 }],
|
||||
milestone: { title: 'v1.0', state: 'open' },
|
||||
},
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[1];
|
||||
expect(options.method).toBe('PATCH');
|
||||
expect(JSON.parse(options.body)).toEqual({
|
||||
title: 'Updated issue',
|
||||
body: 'New body',
|
||||
state: 'closed',
|
||||
labels: ['bug'],
|
||||
assignees: ['bob'],
|
||||
milestone: 33,
|
||||
});
|
||||
});
|
||||
|
||||
test('issues/update clears the milestone with unset_milestone', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/7$/)(url) && options.method === 'PATCH') {
|
||||
return jsonResponse({ number: 7, title: 'T', html_url: 'u', state: 'open', user: { login: 'alice' } });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
await request(app)
|
||||
.patch('/api/gitea/issues/update')
|
||||
.send({ directory: '/tmp/work', number: 7, milestone: null });
|
||||
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(options.body)).toEqual({ unset_milestone: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('issues/update returns 400 when the milestone title does not match', async () => {
|
||||
scriptedFetch([
|
||||
(url) => (matches(/\/milestones\?/)(url)
|
||||
? jsonResponse([{ id: 33, title: 'v1.0' }])
|
||||
: null),
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.patch('/api/gitea/issues/update')
|
||||
.send({ directory: '/tmp/work', number: 7, milestone: 'v2.0' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Milestone not found' });
|
||||
});
|
||||
|
||||
test('prs/comment POSTs a comment on the PR index and maps it', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/issues\/12\/comments$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({
|
||||
id: 8,
|
||||
body: 'LGTM',
|
||||
html_url: 'https://gitea.example.com/owner/repo/pulls/12#issuecomment-8',
|
||||
user: { id: 43, login: 'bob' },
|
||||
}, { status: 201 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitea/prs/comment')
|
||||
.send({ directory: '/tmp/work', number: 12, body: 'LGTM' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
comment: {
|
||||
id: 8,
|
||||
body: 'LGTM',
|
||||
url: 'https://gitea.example.com/owner/repo/pulls/12#issuecomment-8',
|
||||
author: { username: 'bob', id: 43 },
|
||||
},
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ body: 'LGTM' });
|
||||
});
|
||||
|
||||
test('prs/review POSTs event/body and maps the review', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/pulls\/12\/reviews$/)(url) && options.method === 'POST') {
|
||||
return jsonResponse({
|
||||
id: 101,
|
||||
state: 'APPROVED',
|
||||
user: { id: 42, login: 'alice', full_name: 'Alice Example' },
|
||||
submitted_at: '2026-01-02T11:00:00Z',
|
||||
body: 'LGTM',
|
||||
commit_id: 'abc123def4567890',
|
||||
}, { status: 201 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitea/prs/review')
|
||||
.send({ directory: '/tmp/work', number: 12, event: 'APPROVED', body: 'LGTM' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
review: {
|
||||
id: '101',
|
||||
state: 'APPROVED',
|
||||
author: { username: 'alice', id: 42 },
|
||||
submittedAt: '2026-01-02T11:00:00Z',
|
||||
body: 'LGTM',
|
||||
commitSha: 'abc123def4567890',
|
||||
},
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ event: 'APPROVED', body: 'LGTM' });
|
||||
});
|
||||
|
||||
test('prs/review rejects an unsupported event with 400', async () => {
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.post('/api/gitea/prs/review')
|
||||
.send({ directory: '/tmp/work', number: 12, event: 'PENDING' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'event must be APPROVED, REQUEST_CHANGES, or COMMENT' });
|
||||
});
|
||||
|
||||
test('repo/labels returns mapped labels', async () => {
|
||||
scriptedFetch([
|
||||
(url) => (matches(/\/labels\?/)(url)
|
||||
? jsonResponse([
|
||||
{ id: 1, name: 'bug', color: 'd73a4a', description: 'A bug' },
|
||||
{ id: 2, name: 'enhancement', color: 'a2eeef' },
|
||||
])
|
||||
: null),
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app).get('/api/gitea/repo/labels?directory=%2Ftmp%2Fwork');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com' },
|
||||
labels: [
|
||||
{ id: 1, name: 'bug', color: 'd73a4a', description: 'A bug' },
|
||||
{ id: 2, name: 'enhancement', color: 'a2eeef' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('repo/labels requires directory or owner/repo', async () => {
|
||||
const app = createApp();
|
||||
const response = await request(app).get('/api/gitea/repo/labels');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'directory or owner/repo is required' });
|
||||
});
|
||||
|
||||
test('pr/update passes state through to the PATCH payload', async () => {
|
||||
const fetchMock = scriptedFetch([
|
||||
(url, options) => {
|
||||
if (matches(/\/pulls\/12$/)(url) && options.method === 'PATCH') {
|
||||
return jsonResponse({
|
||||
number: 12,
|
||||
title: 'T',
|
||||
html_url: 'u',
|
||||
state: 'closed',
|
||||
merged: false,
|
||||
draft: false,
|
||||
user: { login: 'alice' },
|
||||
head: { ref: 'feat/add' },
|
||||
base: { ref: 'main' },
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
]);
|
||||
|
||||
const app = createApp();
|
||||
const response = await request(app)
|
||||
.patch('/api/gitea/pr/update')
|
||||
.send({ directory: '/tmp/work', number: 12, state: 'closed' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
connected: true,
|
||||
pr: { number: 12, state: 'closed' },
|
||||
});
|
||||
const [, options] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(options.body)).toEqual({ state: 'closed' });
|
||||
});
|
||||
|
||||
// NOTE: keep this test last in the file. The rate-limit cooldown is
|
||||
// module-level and has no reset export, so tests after it would short-circuit.
|
||||
test('data routes surface a 503 when Gitea rate limits', async () => {
|
||||
scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse({}, { status: 429, headers: { 'retry-after': '30' } }) : null)]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user