From b868ad4df55271ba1aa6f5f854567bfb1c84256b Mon Sep 17 00:00:00 2001 From: pi-bot-01 Date: Fri, 13 Mar 2026 17:14:06 -0700 Subject: [PATCH] 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 --- pi-extension/index.ts | 6 ++++- pi-extension/tools/write-tools.ts | 26 ++++++++++++++++++++ pi-extension/webhook/server.ts | 41 +++++++++++++++++++++++++++---- src/files.ts | 30 +++++++++++++++++++--- src/issues.ts | 11 +++++++++ 5 files changed, 105 insertions(+), 9 deletions(-) diff --git a/pi-extension/index.ts b/pi-extension/index.ts index d87132a..9d3bff5 100644 --- a/pi-extension/index.ts +++ b/pi-extension/index.ts @@ -16,7 +16,11 @@ export default function (pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { 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); try { diff --git a/pi-extension/tools/write-tools.ts b/pi-extension/tools/write-tools.ts index 9a83aca..d4036e6 100644 --- a/pi-extension/tools/write-tools.ts +++ b/pi-extension/tools/write-tools.ts @@ -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 ── pi.registerTool({ diff --git a/pi-extension/webhook/server.ts b/pi-extension/webhook/server.ts index 04674b0..bc2dc30 100644 --- a/pi-extension/webhook/server.ts +++ b/pi-extension/webhook/server.ts @@ -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 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 processingQueue: Array<{ event: any; timestamp: number }> = []; let maxQueueDepth = 50; @@ -37,6 +39,31 @@ let pollOnlyRepos: Map = new Ma /** Track all known repos so we don't re-attempt webhook install every cycle */ let knownRepos: Set = new Set(); +/** Load poll timestamps from disk (survives reloads) */ +async function loadPollState(): Promise> { + 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 { + try { + const fs = await import("node:fs/promises"); + const state: Record = {}; + 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) { sendMessage = fn; } @@ -273,6 +300,7 @@ async function discoverRepos() { try { const client = new GiteaClient(); const repos = await client.get("/user/repos?limit=100"); + const savedState = await loadPollState(); let newWebhooks = 0; let newPollOnly = 0; @@ -303,11 +331,11 @@ async function discoverRepos() { } catch (err) { if (err instanceof GiteaError && err.status === 403) { // No admin access — fall back to polling - // Set lastPollAt to 5 minutes ago so we catch recent events - const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + // Use persisted timestamp if available, otherwise 5 min ago + const lastPoll = savedState[name] ?? new Date(Date.now() - 5 * 60 * 1000).toISOString(); pollOnlyRepos.set(name, { addedAt: Date.now(), - lastPollAt: fiveMinAgo, + lastPollAt: lastPoll, }); knownRepos.add(name); newPollOnly++; @@ -319,10 +347,10 @@ async function discoverRepos() { } } else { // 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, { addedAt: Date.now(), - lastPollAt: fiveMinAgo, + lastPollAt: lastPoll, }); knownRepos.add(name); newPollOnly++; @@ -430,6 +458,9 @@ async function pollForEvents() { console.error(`[gitea-poll-events] Error polling ${repoName}: ${err instanceof Error ? err.message : err}`); } } + + // Persist poll timestamps + await savePollState(); } // ── Public API ─────────────────────────────────────────────────────────────── diff --git a/src/files.ts b/src/files.ts index 61d7fe2..c11bdaf 100644 --- a/src/files.ts +++ b/src/files.ts @@ -44,12 +44,36 @@ export async function updateFile( filepath: string, opts: { content: string; message: string; branch?: string; sha?: string }, ): Promise { - return client.put(`/repos/${owner}/${repo}/contents/${filepath}`, { + const body = { content: Buffer.from(opts.content).toString("base64"), message: opts.message, branch: opts.branch, - sha: opts.sha, - }); + }; + + if (opts.sha) { + // Explicit SHA → update existing file (PUT) + return client.put(`/repos/${owner}/${repo}/contents/${filepath}`, { + ...body, + 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(`/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(`/repos/${owner}/${repo}/contents/${filepath}`, { + ...body, + sha: existing.sha, + }); + } + throw err; + } } /** Delete a file in a repo */ diff --git a/src/issues.ts b/src/issues.ts index d869af6..e733341 100644 --- a/src/issues.ts +++ b/src/issues.ts @@ -76,3 +76,14 @@ export async function createIssue( 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 { + return client.patch(`/repos/${owner}/${repo}/issues/${index}`, opts); +}