@clawmem-ai/clawmem 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,11 +4,15 @@ import path from "node:path";
4
4
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
5
5
  import { AGENT_LABEL_PREFIX, DEFAULT_LABELS, SESSION_TITLE_PREFIX, extractLabelNames } from "./config.js";
6
6
  import type { GitHubIssueClient } from "./github-client.js";
7
+ import { parseCandidates } from "./memory.js";
7
8
  import { normalizeMessages, readTranscriptSnapshot } from "./transcript.js";
8
- import type { ClawMemPluginConfig, NormalizedMessage, SessionMirrorState, TranscriptSnapshot } from "./types.js";
9
+ import type { ClawMemPluginConfig, MemoryCandidate, MemorySchema, NormalizedMessage, SessionMirrorState, TranscriptSnapshot } from "./types.js";
9
10
  import { fmtTranscript, localDate, localDateTime, sha256, subKey } from "./utils.js";
10
11
  import { parseFlatYaml, stringifyFlatYaml } from "./yaml.js";
11
12
 
13
+ const FINALIZE_SCHEMA_KIND_LIMIT = 24;
14
+ const FINALIZE_SCHEMA_TOPIC_LIMIT = 80;
15
+
12
16
  export class ConversationMirror {
13
17
  constructor(private readonly client: GitHubIssueClient, private readonly api: OpenClawPluginApi, private readonly config: ClawMemPluginConfig) {}
14
18
 
@@ -16,12 +20,15 @@ export class ConversationMirror {
16
20
  if (sessionId.startsWith("slug-generator-")) return false;
17
21
  const first = messages.find((m) => m.role === "user")?.text ?? "";
18
22
  if (first.includes("generate a short 1-2 word filename slug") && first.includes("Reply with ONLY the slug")) return false;
19
- if (first.includes("Summarize the following conversation.") && first.includes('Return valid JSON only in the form {"summary":"..."}')) return false;
20
- if (first.includes("Extract durable memories from the conversation below.") && first.includes('Return JSON only in the form {"save":')) return false;
23
+ if (first.includes("Write the final issue summary and extract durable memory candidates from the conversation below.")) return false;
21
24
  return true;
22
25
  }
23
26
 
24
27
  async loadSnapshot(session: SessionMirrorState, fallback: unknown[]): Promise<TranscriptSnapshot> {
28
+ const normalizedFallback = normalizeMessages(fallback);
29
+ if (normalizedFallback.length > 0) {
30
+ return { sessionId: session.sessionId, messages: normalizedFallback };
31
+ }
25
32
  const filePath = await this.resolveTranscriptPath(session.sessionFile);
26
33
  if (filePath) {
27
34
  session.sessionFile = filePath;
@@ -32,7 +39,7 @@ export class ConversationMirror {
32
39
  this.api.logger.warn(`clawmem: transcript read failed for ${filePath}: ${String(error)}`);
33
40
  }
34
41
  }
35
- return { sessionId: session.sessionId, messages: normalizeMessages(fallback) };
42
+ return { sessionId: session.sessionId, messages: normalizedFallback };
36
43
  }
37
44
 
38
45
  async ensureIssue(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<void> {
@@ -90,174 +97,34 @@ export class ConversationMirror {
90
97
  return count;
91
98
  }
92
99
 
93
- async generateSummaryAndTitle(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<{ summary: string; title?: string }> {
94
- if (snapshot.messages.length === 0) throw new Error("no conversation messages to summarize");
100
+ async generateFinalArtifacts(
101
+ session: SessionMirrorState,
102
+ snapshot: TranscriptSnapshot,
103
+ schema?: MemorySchema,
104
+ ): Promise<{ summary: string; title?: string; candidates: MemoryCandidate[] }> {
105
+ if (snapshot.messages.length === 0) throw new Error("no conversation messages to finalize");
95
106
  const subagent = this.api.runtime.subagent;
96
- const sessionKey = subKey(session, "summary");
97
- const message = [
98
- "Summarize the following conversation and generate a short title.",
99
- 'Return valid JSON only in the form {"summary":"...","title":"..."}',
100
- "The summary should be concise, factual, and written in 2-4 sentences.",
101
- "Do not include markdown, bullet points, or analysis.",
102
- "",
103
- "Title rules:",
104
- "- Under 50 characters, accurately describe the main topic or task.",
105
- "- Should let someone immediately know what the conversation is about.",
106
- "- Must be in the same language as the majority of the conversation content.",
107
- "- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
108
- "", "<conversation>", fmtTranscript(snapshot.messages), "</conversation>",
109
- ].join("\n");
107
+ const sessionKey = subKey(session, "finalize");
108
+ const message = buildFinalizeArtifactsPrompt(snapshot, schema);
110
109
  try {
111
110
  const run = await subagent.run({
112
- sessionKey, message, deliver: false, lane: "clawmem-summary",
113
- idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:summary-v2`),
114
- extraSystemPrompt: "You summarize conversations and generate accurate, descriptive titles. Output JSON only with string fields summary and title.",
111
+ sessionKey,
112
+ message,
113
+ deliver: false,
114
+ lane: "clawmem-finalize",
115
+ idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:finalize-v2`),
116
+ extraSystemPrompt: "You finalize ClawMem conversations. Output JSON only with summary, title, and durable memory candidates. Reuse existing schema when it fits and keep human-readable memory text in the conversation language.",
115
117
  });
116
- const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: this.config.summaryWaitTimeoutMs });
117
- if (wait.status === "timeout") throw new Error("summary subagent timed out");
118
- if (wait.status === "error") throw new Error(wait.error || "summary subagent failed");
119
- const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
120
- const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
121
- if (!text) throw new Error("summary subagent returned no assistant text");
122
- return parseSummaryAndTitle(text);
123
- } finally { subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {}); }
124
- }
125
-
126
- /** If the title has not yet been generated by LLM, generate an accurate title from the full conversation and update the issue. */
127
- async syncTitle(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<void> {
128
- if (!session.issueNumber) return;
129
- if (session.titleSource === "llm") return;
130
- if (snapshot.messages.length < 2) return;
131
- try {
132
- const title = await this.generateTitle(session, snapshot);
133
- if (title) {
134
- await this.client.updateIssue(session.issueNumber, { title });
135
- session.issueTitle = title;
136
- session.titleSource = "llm";
137
- }
138
- } catch (e) {
139
- this.api.logger.warn(`clawmem: title sync failed: ${String(e)}`);
140
- }
141
- }
142
-
143
- /** Generate an accurate, descriptive title from the full conversation content via LLM. */
144
- async generateTitle(session: SessionMirrorState, snapshot: TranscriptSnapshot): Promise<string | undefined> {
145
- if (snapshot.messages.length === 0) return undefined;
146
- const subagent = this.api.runtime.subagent;
147
- const sessionKey = subKey(session, "title");
148
- const message = [
149
- "Generate a short, accurate title for the following conversation.",
150
- 'Return valid JSON only in the form {"title":"..."}',
151
- "",
152
- "Title rules:",
153
- "- Under 50 characters.",
154
- "- Accurately describe the main topic or task of the conversation.",
155
- "- Should let someone immediately know what the conversation is about.",
156
- "- Must be in the same language as the majority of the conversation content.",
157
- "- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
158
- "",
159
- "<conversation>",
160
- fmtTranscript(snapshot.messages),
161
- "</conversation>",
162
- ].join("\n");
163
- try {
164
- const run = await subagent.run({
165
- sessionKey, message, deliver: false, lane: "clawmem-title",
166
- idempotencyKey: sha256(`${session.sessionId}:${snapshot.messages.length}:title-v1`),
167
- extraSystemPrompt: "You generate accurate, descriptive titles for conversations. Output JSON only with a string field title.",
118
+ const wait = await subagent.waitForRun({
119
+ runId: run.runId,
120
+ timeoutMs: Math.max(this.config.summaryWaitTimeoutMs, this.config.memoryExtractWaitTimeoutMs),
168
121
  });
169
- const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: 30000 });
170
- if (wait.status === "timeout" || wait.status === "error") return undefined;
171
- const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 10 })).messages);
172
- const text = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
173
- if (!text) return undefined;
174
- return parseTitle(text);
175
- } catch (e) {
176
- this.api.logger.warn(`clawmem: title generation failed: ${String(e)}`);
177
- return undefined;
178
- } finally {
179
- subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
180
- }
181
- }
182
-
183
- /** Re-title all existing conversation issues. Uses summary when available, falls back to reading comments. */
184
- async retitleConversations(): Promise<{ updated: number; skipped: number; failed: number; retitledIssues: number[] }> {
185
- let updated = 0, skipped = 0, failed = 0;
186
- const retitledIssues: number[] = [];
187
- let page = 1;
188
- while (true) {
189
- const issues = await this.client.listIssues({ labels: ["type:conversation"], state: "all", page, perPage: 50 });
190
- if (issues.length === 0) break;
191
- for (const issue of issues) {
192
- try {
193
- const yaml = parseFlatYaml(issue.body || "");
194
- const summary = yaml.summary;
195
- let titleInput: string | undefined;
196
- if (summary && summary !== "pending" && !summary.startsWith("failed:")) {
197
- titleInput = summary;
198
- } else {
199
- // No usable summary — reconstruct conversation from issue comments.
200
- const comments = await this.client.listComments(issue.number, { perPage: 50 });
201
- const conversationText = comments
202
- .map((c) => c.body?.trim())
203
- .filter((b): b is string => Boolean(b))
204
- .join("\n\n");
205
- if (conversationText.length >= 20) {
206
- // Cap to avoid excessive token usage in LLM call.
207
- titleInput = conversationText.length > 4000 ? conversationText.slice(0, 4000) + "\n..." : conversationText;
208
- }
209
- }
210
- if (!titleInput) { skipped++; continue; }
211
- const title = await this.generateTitleFromText(titleInput, `retitle-${issue.number}`);
212
- if (!title) { skipped++; continue; }
213
- await this.client.updateIssue(issue.number, { title });
214
- this.api.logger.info?.(`clawmem: retitled issue #${issue.number} -> "${title}"`);
215
- retitledIssues.push(issue.number);
216
- updated++;
217
- } catch (e) {
218
- this.api.logger.warn(`clawmem: retitle failed for issue #${issue.number}: ${String(e)}`);
219
- failed++;
220
- }
221
- }
222
- if (issues.length < 50) break;
223
- page++;
224
- }
225
- return { updated, skipped, failed, retitledIssues };
226
- }
227
-
228
- private async generateTitleFromText(text: string, uniqueKey: string): Promise<string | undefined> {
229
- const subagent = this.api.runtime.subagent;
230
- const sessionKey = `clawmem-${uniqueKey}`;
231
- const message = [
232
- "Generate a short, accurate title based on the following conversation content.",
233
- 'Return valid JSON only in the form {"title":"..."}',
234
- "",
235
- "Title rules:",
236
- "- Under 50 characters.",
237
- "- Accurately describe the main topic or task.",
238
- "- Should let someone immediately know what the conversation was about.",
239
- "- Must be in the same language as the content.",
240
- "- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
241
- "",
242
- "<content>",
243
- text,
244
- "</content>",
245
- ].join("\n");
246
- try {
247
- const run = await subagent.run({
248
- sessionKey, message, deliver: false, lane: "clawmem-retitle",
249
- idempotencyKey: sha256(`retitle:${uniqueKey}:${text.slice(0, 200)}`),
250
- extraSystemPrompt: "You generate accurate, descriptive titles. Output JSON only with a string field title.",
251
- });
252
- const wait = await subagent.waitForRun({ runId: run.runId, timeoutMs: 30000 });
253
- if (wait.status === "timeout" || wait.status === "error") return undefined;
254
- const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 10 })).messages);
255
- const raw = [...msgs].reverse().find((e) => e.role === "assistant" && e.text.trim())?.text;
256
- if (!raw) return undefined;
257
- return parseTitle(raw);
258
- } catch (e) {
259
- this.api.logger.warn(`clawmem: title generation from text failed (${uniqueKey}): ${String(e)}`);
260
- return undefined;
122
+ if (wait.status === "timeout") throw new Error("finalize subagent timed out");
123
+ if (wait.status === "error") throw new Error(wait.error || "finalize subagent failed");
124
+ const msgs = normalizeMessages((await subagent.getSessionMessages({ sessionKey, limit: 50 })).messages);
125
+ const text = [...msgs].reverse().find((entry) => entry.role === "assistant" && entry.text.trim())?.text;
126
+ if (!text) throw new Error("finalize subagent returned no assistant text");
127
+ return parseFinalArtifacts(text);
261
128
  } finally {
262
129
  subagent.deleteSession({ sessionKey, deleteTranscript: true }).catch(() => {});
263
130
  }
@@ -328,6 +195,65 @@ export class ConversationMirror {
328
195
  }
329
196
  }
330
197
 
198
+ export function buildFinalizeArtifactsPrompt(snapshot: TranscriptSnapshot, schema?: MemorySchema): string {
199
+ return [
200
+ "Write the final issue summary and extract durable memory candidates from the conversation below.",
201
+ 'Return valid JSON only in the form {"summary":"...","title":"...","candidates":[{"title":"...","detail":"...","kind":"...","topics":["..."],"evidence":"..."}]}.',
202
+ "The summary should be concise, factual, and written in 2-4 sentences.",
203
+ "Do not include markdown, bullet points, or analysis.",
204
+ "",
205
+ "Title rules:",
206
+ "- Under 50 characters, accurately describe the main topic or task.",
207
+ "- Should let someone immediately know what the conversation is about.",
208
+ "- Must be in the same language as the majority of the conversation content.",
209
+ "- Good: precise, descriptive, specific. Bad: vague, overly creative, generic.",
210
+ "",
211
+ "Candidate rules:",
212
+ "- Extract only durable facts, preferences, decisions, constraints, workflows, and ongoing context worth remembering later.",
213
+ "- Each candidate must represent one durable fact. Split independent facts into separate candidates.",
214
+ "- Prefer a concise explicit title for each candidate whenever the fact can be named clearly.",
215
+ "- Candidate titles and details must be in the same language as the majority of the conversation content.",
216
+ "- Do not extract temporary requests, tool chatter, startup boilerplate, or summaries about internal helper sessions.",
217
+ "- Reuse existing schema labels when one already fits.",
218
+ "- If no existing kind or topic fits, create one short stable machine-readable label instead of a translated or near-duplicate variant.",
219
+ "- Keep kind and topic labels short, reusable, low-cardinality, and machine-readable.",
220
+ "- Evidence is optional. If present, keep it short and quote-free.",
221
+ "- Prefer an empty candidates array when nothing durable was learned.",
222
+ "",
223
+ ...buildFinalizeSchemaSection(schema),
224
+ "<conversation>",
225
+ fmtTranscript(snapshot.messages),
226
+ "</conversation>",
227
+ ].join("\n");
228
+ }
229
+
230
+ function buildFinalizeSchemaSection(schema?: MemorySchema): string[] {
231
+ if (!schema) return [];
232
+
233
+ const kinds = schema.kinds.map((kind) => kind.trim()).filter(Boolean);
234
+ const topics = schema.topics.map((topic) => topic.trim()).filter(Boolean);
235
+ if (kinds.length === 0 && topics.length === 0) return [];
236
+
237
+ const kindLines = kinds.slice(0, FINALIZE_SCHEMA_KIND_LIMIT).map((kind) => `- kind:${kind}`);
238
+ const topicLines = topics.slice(0, FINALIZE_SCHEMA_TOPIC_LIMIT).map((topic) => `- topic:${topic}`);
239
+ const kindOverflow = kinds.length > kindLines.length ? [`- ...and ${kinds.length - kindLines.length} more kinds`] : [];
240
+ const topicOverflow = topics.length > topicLines.length ? [`- ...and ${topics.length - topicLines.length} more topics`] : [];
241
+
242
+ return [
243
+ "Current schema to reuse first:",
244
+ "<current-schema>",
245
+ "Kinds:",
246
+ ...(kindLines.length > 0 ? kindLines : ["- None"]),
247
+ ...kindOverflow,
248
+ "Topics:",
249
+ ...(topicLines.length > 0 ? topicLines : ["- None"]),
250
+ ...topicOverflow,
251
+ "</current-schema>",
252
+ "Prefer these existing labels whenever they fit. Only create a new label when none of the current labels matches the fact you are storing.",
253
+ "",
254
+ ];
255
+ }
256
+
331
257
  async function fexists(p: string): Promise<boolean> { try { return (await fs.promises.stat(p)).isFile(); } catch { return false; } }
332
258
  function isNotFoundError(error: unknown): boolean {
333
259
  const text = String(error);
@@ -367,26 +293,12 @@ function parseSummaryAndTitle(raw: string): { summary: string; title?: string }
367
293
  return { summary: t };
368
294
  }
369
295
 
370
- function parseTitle(raw: string): string | undefined {
371
- const tryParse = (s: string): string | undefined => {
372
- try {
373
- const p = JSON.parse(s) as { title?: unknown };
374
- return typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
375
- } catch {
376
- const i = s.indexOf("{"), j = s.lastIndexOf("}");
377
- if (i >= 0 && j > i) {
378
- try {
379
- const p = JSON.parse(s.slice(i, j + 1)) as { title?: unknown };
380
- return typeof p?.title === "string" && p.title.trim() ? p.title.trim() : undefined;
381
- } catch { return undefined; }
382
- }
383
- return undefined;
384
- }
296
+ function parseFinalArtifacts(raw: string): { summary: string; title?: string; candidates: MemoryCandidate[] } {
297
+ const parsedSummary = parseSummaryAndTitle(raw);
298
+ const candidates = parseCandidates(raw);
299
+ return {
300
+ summary: parsedSummary.summary,
301
+ ...(parsedSummary.title ? { title: parsedSummary.title } : {}),
302
+ candidates,
385
303
  };
386
- const t = raw.trim();
387
- const direct = tryParse(t);
388
- if (direct) return direct;
389
- const f = /^```(?:json)?\s*([\s\S]*?)```$/i.exec(t);
390
- if (f?.[1]) return tryParse(f[1].trim());
391
- return undefined;
392
304
  }
@@ -0,0 +1,101 @@
1
+ import { GitHubIssueClient } from "./github-client.js";
2
+ import type { ClawMemResolvedRoute } from "./types.js";
3
+
4
+ function assert(condition: unknown, message: string): void {
5
+ if (!condition) throw new Error(message);
6
+ }
7
+
8
+ type FetchCall = { url: string; init: RequestInit };
9
+
10
+ function createClientRecorder(): {
11
+ client: GitHubIssueClient;
12
+ calls: FetchCall[];
13
+ restore(): void;
14
+ } {
15
+ const calls: FetchCall[] = [];
16
+ const originalFetch = globalThis.fetch;
17
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
18
+ calls.push({ url: String(input), init: init ?? {} });
19
+ const method = init?.method ?? "GET";
20
+ if (method === "DELETE" || method === "PATCH") return new Response(null, { status: 204 });
21
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } });
22
+ }) as typeof fetch;
23
+
24
+ const route: ClawMemResolvedRoute = {
25
+ agentId: "main",
26
+ baseUrl: "https://git.clawmem.ai/api/v3",
27
+ defaultRepo: "alice/memory",
28
+ repo: "alice/memory",
29
+ token: "token-123",
30
+ authScheme: "token",
31
+ };
32
+
33
+ return {
34
+ client: new GitHubIssueClient(route, {}),
35
+ calls,
36
+ restore() {
37
+ globalThis.fetch = originalFetch;
38
+ },
39
+ };
40
+ }
41
+
42
+ async function testOrgGovernanceRoutes(): Promise<void> {
43
+ const { client, calls, restore } = createClientRecorder();
44
+ try {
45
+ await client.listOrgMembers("acme", "admin");
46
+ await client.getOrgMembership("acme", "alice");
47
+ await client.removeOrgMember("acme", "alice");
48
+ await client.removeOrgMembership("acme", "alice");
49
+ await client.revokeOrgInvitation("acme", 12);
50
+
51
+ assert(calls[0]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/members?role=admin", "expected org member list route");
52
+ assert(calls[1]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/memberships/alice", "expected org membership route");
53
+ assert(calls[2]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/members/alice", "expected org member delete route");
54
+ assert(calls[2]?.init.method === "DELETE", "expected DELETE for org member removal");
55
+ assert(calls[3]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/memberships/alice", "expected org membership delete route");
56
+ assert(calls[3]?.init.method === "DELETE", "expected DELETE for org membership removal");
57
+ assert(calls[4]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/invitations/12", "expected org invitation revoke route");
58
+ assert(calls[4]?.init.method === "DELETE", "expected DELETE for org invitation revoke");
59
+ } finally {
60
+ restore();
61
+ }
62
+ }
63
+
64
+ async function testTeamGovernanceRoutes(): Promise<void> {
65
+ const { client, calls, restore } = createClientRecorder();
66
+ try {
67
+ await client.getTeam("acme", "platform");
68
+ await client.updateTeam("acme", "platform", { name: "Platform Eng", description: "Core platform", privacy: "closed" });
69
+ await client.deleteTeam("acme", "platform");
70
+ await client.listTeamMembers("acme", "platform");
71
+
72
+ assert(calls[0]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/teams/platform", "expected team get route");
73
+ assert(calls[1]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/teams/platform", "expected team update route");
74
+ assert(calls[1]?.init.method === "PATCH", "expected PATCH for team update");
75
+ assert(String(calls[1]?.init.body).includes("\"name\":\"Platform Eng\""), "expected team update payload to include name");
76
+ assert(calls[2]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/teams/platform", "expected team delete route");
77
+ assert(calls[2]?.init.method === "DELETE", "expected DELETE for team delete");
78
+ assert(calls[3]?.url === "https://git.clawmem.ai/api/v3/orgs/acme/teams/platform/members", "expected team members route");
79
+ } finally {
80
+ restore();
81
+ }
82
+ }
83
+
84
+ async function testRepoTransferRoute(): Promise<void> {
85
+ const { client, calls, restore } = createClientRecorder();
86
+ try {
87
+ await client.transferRepo("alice", "memory", "acme");
88
+ assert(calls.length === 1, "expected one repo transfer request");
89
+ assert(calls[0]?.url === "https://git.clawmem.ai/api/v3/repos/alice/memory/transfer", "expected repo transfer route");
90
+ assert(calls[0]?.init.method === "POST", "expected POST for repo transfer");
91
+ assert(String(calls[0]?.init.body) === "{\"new_owner\":\"acme\"}", "expected repo transfer payload");
92
+ } finally {
93
+ restore();
94
+ }
95
+ }
96
+
97
+ await testOrgGovernanceRoutes();
98
+ await testTeamGovernanceRoutes();
99
+ await testRepoTransferRoute();
100
+
101
+ console.log("github client tests passed");
@@ -52,6 +52,12 @@ type RepositoryInvitationResponse = {
52
52
  inviter?: { login?: string; name?: string };
53
53
  };
54
54
  type TeamMembershipResponse = { state?: string; role?: string };
55
+ type OrganizationMembershipResponse = {
56
+ state?: string;
57
+ role?: string;
58
+ organization?: OrgResponse;
59
+ user?: CollaboratorResponse;
60
+ };
55
61
  type InvitationResponse = {
56
62
  id?: number;
57
63
  role?: string;
@@ -145,9 +151,30 @@ export class GitHubIssueClient {
145
151
  async getOrg(org: string): Promise<OrgResponse> {
146
152
  return this.req<OrgResponse>(`orgs/${encodeURIComponent(org)}`, { method: "GET" });
147
153
  }
154
+ async listOrgMembers(org: string, role?: "admin"): Promise<CollaboratorResponse[]> {
155
+ const q = new URLSearchParams();
156
+ if (role) q.set("role", role);
157
+ const suffix = q.toString();
158
+ return this.req<CollaboratorResponse[]>(`orgs/${encodeURIComponent(org)}/members${suffix ? `?${suffix}` : ""}`, { method: "GET" });
159
+ }
160
+ async getOrgMembership(org: string, username: string): Promise<OrganizationMembershipResponse> {
161
+ return this.req<OrganizationMembershipResponse>(
162
+ `orgs/${encodeURIComponent(org)}/memberships/${encodeURIComponent(username)}`,
163
+ { method: "GET" },
164
+ );
165
+ }
166
+ async removeOrgMember(org: string, username: string): Promise<void> {
167
+ await this.req(`orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(username)}`, { method: "DELETE" });
168
+ }
169
+ async removeOrgMembership(org: string, username: string): Promise<void> {
170
+ await this.req(`orgs/${encodeURIComponent(org)}/memberships/${encodeURIComponent(username)}`, { method: "DELETE" });
171
+ }
148
172
  async listOrgTeams(org: string): Promise<TeamResponse[]> {
149
173
  return this.req<TeamResponse[]>(`orgs/${encodeURIComponent(org)}/teams`, { method: "GET" });
150
174
  }
175
+ async getTeam(org: string, teamSlug: string): Promise<TeamResponse> {
176
+ return this.req<TeamResponse>(`orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}`, { method: "GET" });
177
+ }
151
178
  async createOrgTeam(org: string, params: { name: string; description?: string; privacy?: "closed" | "secret" }): Promise<TeamResponse> {
152
179
  return this.req<TeamResponse>(`orgs/${encodeURIComponent(org)}/teams`, {
153
180
  method: "POST",
@@ -158,6 +185,29 @@ export class GitHubIssueClient {
158
185
  }),
159
186
  });
160
187
  }
188
+ async updateTeam(
189
+ org: string,
190
+ teamSlug: string,
191
+ params: { name?: string; description?: string; privacy?: "closed" | "secret" },
192
+ ): Promise<TeamResponse> {
193
+ return this.req<TeamResponse>(`orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}`, {
194
+ method: "PATCH",
195
+ body: JSON.stringify({
196
+ ...(params.name ? { name: params.name } : {}),
197
+ ...(params.description ? { description: params.description } : {}),
198
+ ...(params.privacy ? { privacy: params.privacy } : {}),
199
+ }),
200
+ });
201
+ }
202
+ async deleteTeam(org: string, teamSlug: string): Promise<void> {
203
+ await this.req(`orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}`, { method: "DELETE" });
204
+ }
205
+ async listTeamMembers(org: string, teamSlug: string): Promise<CollaboratorResponse[]> {
206
+ return this.req<CollaboratorResponse[]>(
207
+ `orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/members`,
208
+ { method: "GET" },
209
+ );
210
+ }
161
211
  async setTeamMembership(org: string, teamSlug: string, username: string, role: "member" | "maintainer"): Promise<TeamMembershipResponse> {
162
212
  return this.req<TeamMembershipResponse>(
163
213
  `orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/memberships/${encodeURIComponent(username)}`,
@@ -238,6 +288,9 @@ export class GitHubIssueClient {
238
288
  }),
239
289
  });
240
290
  }
291
+ async revokeOrgInvitation(org: string, invitationId: number): Promise<void> {
292
+ await this.req(`orgs/${encodeURIComponent(org)}/invitations/${invitationId}`, { method: "DELETE" });
293
+ }
241
294
  async listOrgOutsideCollaborators(org: string): Promise<CollaboratorResponse[]> {
242
295
  return this.req<CollaboratorResponse[]>(`orgs/${encodeURIComponent(org)}/outside_collaborators`, { method: "GET" });
243
296
  }
@@ -250,6 +303,12 @@ export class GitHubIssueClient {
250
303
  async declineUserOrgInvitation(invitationId: number): Promise<void> {
251
304
  await this.req(`user/organization_invitations/${invitationId}`, { method: "DELETE" });
252
305
  }
306
+ async transferRepo(owner: string, repo: string, newOwner: string): Promise<RepoResponse> {
307
+ return this.req<RepoResponse>(`repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/transfer`, {
308
+ method: "POST",
309
+ body: JSON.stringify({ new_owner: newOwner }),
310
+ });
311
+ }
253
312
  async ensureLabels(labels: string[]): Promise<void> {
254
313
  for (const label of labels) {
255
314
  if (!label.trim()) continue;