fix: POST for file creation (Gitea 1.25+), add edit_issue tool, persist poll state

- files.ts: Use POST for new files, PUT for updates (Gitea 1.25 requires this)
- issues.ts: Add editIssue() for state/title/body changes
- write-tools.ts: Add gitea_edit_issue tool (open/close/edit issues)
- webhook/server.ts: Persist lastPollAt to disk to prevent duplicate
  events on reload; use followUp delivery to queue events during LLM turns
- index.ts: Use deliverAs:'followUp' for sendUserMessage
This commit is contained in:
2026-03-13 17:14:06 -07:00
parent 578e2f91cb
commit b868ad4df5
5 changed files with 105 additions and 9 deletions

View File

@@ -16,7 +16,11 @@ export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => { pi.on("session_start", async (_event, ctx) => {
console.log("[pi-gitea] Session started"); console.log("[pi-gitea] Session started");
const sendMessageFn = ctx.sendUserMessage || ((_msg: string) => Promise.resolve()); const sendMessageFn = (msg: string) => {
// Use followUp so events queue when the LLM is already processing
ctx.sendUserMessage(msg, { deliverAs: "followUp" });
return Promise.resolve();
};
setSendMessage(sendMessageFn); setSendMessage(sendMessageFn);
try { try {

View File

@@ -161,6 +161,32 @@ export default function (pi: ExtensionAPI) {
}, },
}); });
pi.registerTool({
name: "gitea_edit_issue",
label: "Gitea: Edit Issue",
description: "Edit an issue — change title, body, or state (open/closed). Use this to close issues when work is done.",
parameters: Type.Object({
owner: Type.Optional(Type.String({ description: `Repo owner (default: ${client.defaultOwner})` })),
repo: Type.Optional(Type.String({ description: `Repo name (default: ${client.defaultRepo})` })),
index: Type.Number({ description: "Issue number" }),
title: Type.Optional(Type.String({ description: "New title" })),
body: Type.Optional(Type.String({ description: "New body (Markdown)" })),
state: Type.Optional(Type.String({ description: "New state: 'open' or 'closed'" })),
}),
async execute(_id, params) {
const { owner, repo } = client.resolve(params.owner, params.repo);
const updated = await issues.editIssue(client, owner, repo, params.index, {
title: params.title,
body: params.body,
state: params.state as "open" | "closed" | undefined,
});
return {
content: [{ type: "text", text: `Issue #${updated.number} updated (state: ${updated.state}): ${updated.html_url}` }],
details: { issue: updated },
};
},
});
// ── Pull Requests ── // ── Pull Requests ──
pi.registerTool({ pi.registerTool({

View File

@@ -20,6 +20,8 @@ const POLL_INTERVAL = parseInt(process.env.PI_BOT_POLL_INTERVAL ?? "300", 10);
const EVENT_POLL_INTERVAL = parseInt(process.env.PI_EVENT_POLL_INTERVAL ?? "60", 10); const EVENT_POLL_INTERVAL = parseInt(process.env.PI_EVENT_POLL_INTERVAL ?? "60", 10);
const BOT_USER = process.env.PI_GIT_USER ?? ""; const BOT_USER = process.env.PI_GIT_USER ?? "";
const POLL_STATE_FILE = "/home/pibot/.pi/agent/gitea-poll-state.json";
let server: Server | null = null; let server: Server | null = null;
let processingQueue: Array<{ event: any; timestamp: number }> = []; let processingQueue: Array<{ event: any; timestamp: number }> = [];
let maxQueueDepth = 50; let maxQueueDepth = 50;
@@ -37,6 +39,31 @@ let pollOnlyRepos: Map<string, { addedAt: number; lastPollAt: string }> = new Ma
/** Track all known repos so we don't re-attempt webhook install every cycle */ /** Track all known repos so we don't re-attempt webhook install every cycle */
let knownRepos: Set<string> = new Set(); let knownRepos: Set<string> = new Set();
/** Load poll timestamps from disk (survives reloads) */
async function loadPollState(): Promise<Record<string, string>> {
try {
const fs = await import("node:fs/promises");
const data = await fs.readFile(POLL_STATE_FILE, "utf-8");
return JSON.parse(data);
} catch {
return {};
}
}
/** Save poll timestamps to disk */
async function savePollState(): Promise<void> {
try {
const fs = await import("node:fs/promises");
const state: Record<string, string> = {};
for (const [name, s] of pollOnlyRepos) {
state[name] = s.lastPollAt;
}
await fs.writeFile(POLL_STATE_FILE, JSON.stringify(state), "utf-8");
} catch (err) {
console.error("[gitea-polling] Error saving poll state:", err instanceof Error ? err.message : err);
}
}
export function setSendMessage(fn: (message: string) => Promise<void>) { export function setSendMessage(fn: (message: string) => Promise<void>) {
sendMessage = fn; sendMessage = fn;
} }
@@ -273,6 +300,7 @@ async function discoverRepos() {
try { try {
const client = new GiteaClient(); const client = new GiteaClient();
const repos = await client.get<any[]>("/user/repos?limit=100"); const repos = await client.get<any[]>("/user/repos?limit=100");
const savedState = await loadPollState();
let newWebhooks = 0; let newWebhooks = 0;
let newPollOnly = 0; let newPollOnly = 0;
@@ -303,11 +331,11 @@ async function discoverRepos() {
} catch (err) { } catch (err) {
if (err instanceof GiteaError && err.status === 403) { if (err instanceof GiteaError && err.status === 403) {
// No admin access — fall back to polling // No admin access — fall back to polling
// Set lastPollAt to 5 minutes ago so we catch recent events // Use persisted timestamp if available, otherwise 5 min ago
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString(); const lastPoll = savedState[name] ?? new Date(Date.now() - 5 * 60 * 1000).toISOString();
pollOnlyRepos.set(name, { pollOnlyRepos.set(name, {
addedAt: Date.now(), addedAt: Date.now(),
lastPollAt: fiveMinAgo, lastPollAt: lastPoll,
}); });
knownRepos.add(name); knownRepos.add(name);
newPollOnly++; newPollOnly++;
@@ -319,10 +347,10 @@ async function discoverRepos() {
} }
} else { } else {
// No webhook URL configured — all repos are poll-only // No webhook URL configured — all repos are poll-only
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString(); const lastPoll = savedState[name] ?? new Date(Date.now() - 5 * 60 * 1000).toISOString();
pollOnlyRepos.set(name, { pollOnlyRepos.set(name, {
addedAt: Date.now(), addedAt: Date.now(),
lastPollAt: fiveMinAgo, lastPollAt: lastPoll,
}); });
knownRepos.add(name); knownRepos.add(name);
newPollOnly++; newPollOnly++;
@@ -430,6 +458,9 @@ async function pollForEvents() {
console.error(`[gitea-poll-events] Error polling ${repoName}: ${err instanceof Error ? err.message : err}`); console.error(`[gitea-poll-events] Error polling ${repoName}: ${err instanceof Error ? err.message : err}`);
} }
} }
// Persist poll timestamps
await savePollState();
} }
// ── Public API ─────────────────────────────────────────────────────────────── // ── Public API ───────────────────────────────────────────────────────────────

View File

@@ -44,14 +44,38 @@ export async function updateFile(
filepath: string, filepath: string,
opts: { content: string; message: string; branch?: string; sha?: string }, opts: { content: string; message: string; branch?: string; sha?: string },
): Promise<FileCommitResponse> { ): Promise<FileCommitResponse> {
return client.put<FileCommitResponse>(`/repos/${owner}/${repo}/contents/${filepath}`, { const body = {
content: Buffer.from(opts.content).toString("base64"), content: Buffer.from(opts.content).toString("base64"),
message: opts.message, message: opts.message,
branch: opts.branch, branch: opts.branch,
};
if (opts.sha) {
// Explicit SHA → update existing file (PUT)
return client.put<FileCommitResponse>(`/repos/${owner}/${repo}/contents/${filepath}`, {
...body,
sha: opts.sha, sha: opts.sha,
}); });
} }
// No SHA → try POST (create). If 422 "SHA Required", the file already
// exists — fetch its SHA and retry with PUT.
try {
return await client.post<FileCommitResponse>(`/repos/${owner}/${repo}/contents/${filepath}`, body);
} catch (err: any) {
if (err?.status === 422 && /sha/i.test(err?.body ?? "")) {
const existing = await getFileContent(client, owner, repo, filepath, {
ref: opts.branch,
});
return client.put<FileCommitResponse>(`/repos/${owner}/${repo}/contents/${filepath}`, {
...body,
sha: existing.sha,
});
}
throw err;
}
}
/** Delete a file in a repo */ /** Delete a file in a repo */
export async function deleteFile( export async function deleteFile(
client: GiteaClient, client: GiteaClient,

View File

@@ -76,3 +76,14 @@ export async function createIssue(
labels: opts.labels ?? [], labels: opts.labels ?? [],
}); });
} }
/** Edit an existing issue (title, body, state) */
export async function editIssue(
client: GiteaClient,
owner: string,
repo: string,
index: number,
opts: { title?: string; body?: string; state?: "open" | "closed" },
): Promise<Issue> {
return client.patch<Issue>(`/repos/${owner}/${repo}/issues/${index}`, opts);
}