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:
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, { addedAt: number; lastPollAt: string }> = new Ma
|
||||
/** Track all known repos so we don't re-attempt webhook install every cycle */
|
||||
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>) {
|
||||
sendMessage = fn;
|
||||
}
|
||||
@@ -273,6 +300,7 @@ async function discoverRepos() {
|
||||
try {
|
||||
const client = new GiteaClient();
|
||||
const repos = await client.get<any[]>("/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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
30
src/files.ts
30
src/files.ts
@@ -44,12 +44,36 @@ export async function updateFile(
|
||||
filepath: string,
|
||||
opts: { content: string; message: string; branch?: string; sha?: string },
|
||||
): Promise<FileCommitResponse> {
|
||||
return client.put<FileCommitResponse>(`/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<FileCommitResponse>(`/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<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 */
|
||||
|
||||
@@ -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<Issue> {
|
||||
return client.patch<Issue>(`/repos/${owner}/${repo}/issues/${index}`, opts);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user