Skip to content

Commit 880b1c2

Browse files
authored
Merge pull request #15 from odefun/ode_1770277628.637719
feat: add per-user GitHub tokens
2 parents e75abe5 + 11153d6 commit 880b1c2

File tree

10 files changed

+276
-270
lines changed

10 files changed

+276
-270
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ode",
3-
"version": "0.0.25",
3+
"version": "0.0.26",
44
"description": "Ode - OpenCode chat controller for Slack",
55
"module": "packages/core/index.ts",
66
"type": "module",

packages/agents/shared.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export function buildSlackSystemPrompt(slack?: SlackContext): string {
2929
lines.push(`- Channel: ${slack.channelId}`);
3030
lines.push(`- Thread: ${slack.threadId}`);
3131
lines.push(`- User: <@${slack.userId}>`);
32+
if (slack.hasGitHubToken !== undefined) {
33+
lines.push(`- GitHub token available: ${slack.hasGitHubToken ? "yes" : "no"}`);
34+
}
3235
}
3336

3437
lines.push("");

packages/agents/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface SlackContext {
1919
threadHistory?: string;
2020
hasCustomSlackTool?: boolean;
2121
odeSlackApiUrl?: string;
22+
hasGitHubToken?: boolean;
2223
}
2324

2425
export interface OpenCodeMessageContext {

packages/config/dashboard-config.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ export type DashboardConfig = {
44
email: string;
55
initials?: string;
66
avatar?: string;
7-
githubToken: string;
87
defaultMessageFrequency: "aggressive" | "medium" | "minimum";
98
};
109
devServers: {
@@ -37,7 +36,6 @@ export const defaultDashboardConfig: DashboardConfig = {
3736
user: {
3837
name: "",
3938
email: "",
40-
githubToken: "",
4139
defaultMessageFrequency: "medium",
4240
},
4341
devServers: [],
@@ -140,7 +138,6 @@ export const sanitizeDashboardConfig = (config: unknown): DashboardConfig => {
140138
email: asString(user.email),
141139
initials: asString(user.initials, "") || undefined,
142140
avatar: asString(user.avatar, "") || undefined,
143-
githubToken: asString(user.githubToken),
144141
defaultMessageFrequency: asFrequency(user.defaultMessageFrequency),
145142
},
146143
devServers,

packages/config/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@ export {
1515
getSlackTargetChannels,
1616
getDefaultCwd,
1717
getDefaultOpenCodeServerUrl,
18+
getGitHubInfoForUser,
1819
resolveChannelCwd,
1920
setChannelCwd,
2021
setChannelWorkingDirectory,
22+
setGitHubInfoForUser,
23+
clearGitHubInfoForUser,
2124
getChannelOpenCodeServerUrl,
2225
setChannelModel,
2326
setChannelDevServerId,

packages/config/local/ode.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ const userSchema = z.object({
1313
email: z.string().optional().default(""),
1414
initials: z.string().optional().default(""),
1515
avatar: z.string().optional().default(""),
16-
githubToken: z.string().optional().default(""),
1716
defaultMessageFrequency: z.enum([
1817
"minimum",
1918
"medium",
@@ -61,6 +60,17 @@ const workspaceSchema = z.object({
6160

6261
const odeConfigSchema = z.object({
6362
user: userSchema,
63+
githubInfos: z
64+
.record(
65+
z.string(),
66+
z.object({
67+
token: z.string().optional().default(""),
68+
gitName: z.string().optional().default(""),
69+
gitEmail: z.string().optional().default(""),
70+
})
71+
)
72+
.optional()
73+
.default({}),
6474
devServers: z.array(devServerSchema),
6575
workspaces: z.array(workspaceSchema),
6676
updates: updateSchema.optional().default({
@@ -84,9 +94,9 @@ const EMPTY_TEMPLATE: OdeConfig = {
8494
email: "",
8595
initials: "",
8696
avatar: "",
87-
githubToken: "",
8897
defaultMessageFrequency: "medium",
8998
},
99+
githubInfos: {},
90100
devServers: [],
91101
workspaces: [],
92102
updates: {
@@ -231,6 +241,46 @@ export function getChannelDetails(channelId: string): ChannelDetail | null {
231241
return null;
232242
}
233243

244+
export type GitHubInfo = {
245+
token: string;
246+
gitName?: string;
247+
gitEmail?: string;
248+
};
249+
250+
export function getGitHubInfoForUser(userId: string): GitHubInfo | null {
251+
const info = loadOdeConfig().githubInfos?.[userId];
252+
if (!info) return null;
253+
const token = info.token?.trim();
254+
if (!token) return null;
255+
const gitName = info.gitName?.trim() || undefined;
256+
const gitEmail = info.gitEmail?.trim() || undefined;
257+
return { token, gitName, gitEmail };
258+
}
259+
260+
export function setGitHubInfoForUser(userId: string, info: GitHubInfo): void {
261+
const config = loadOdeConfig();
262+
const githubInfos = { ...(config.githubInfos ?? {}) };
263+
const token = info.token.trim();
264+
if (token.length === 0) {
265+
delete githubInfos[userId];
266+
} else {
267+
githubInfos[userId] = {
268+
token,
269+
gitName: info.gitName?.trim() || "",
270+
gitEmail: info.gitEmail?.trim() || "",
271+
};
272+
}
273+
saveOdeConfig({ ...config, githubInfos });
274+
}
275+
276+
export function clearGitHubInfoForUser(userId: string): void {
277+
const config = loadOdeConfig();
278+
const githubInfos = { ...(config.githubInfos ?? {}) };
279+
if (!(userId in githubInfos)) return;
280+
delete githubInfos[userId];
281+
saveOdeConfig({ ...config, githubInfos });
282+
}
283+
234284
export type ChannelCwdInfo = {
235285
cwd: string;
236286
workingDirectory: string | null;

packages/config/local/settings.ts

Lines changed: 1 addition & 221 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
2-
import { join, dirname } from "path";
2+
import { join } from "path";
33
import { homedir } from "os";
44
import {
55
setChannelCwd as setChannelCwdInConfig,
@@ -9,10 +9,6 @@ import { loadSession } from "./sessions";
99
const ODE_CONFIG_DIR = join(homedir(), ".config", "ode");
1010
const SETTINGS_FILE = join(ODE_CONFIG_DIR, "settings.json");
1111
const AGENTS_DIR = join(ODE_CONFIG_DIR, "agents");
12-
const GH_CONFIG_DIR = join(homedir(), ".config", "gh");
13-
const GH_HOSTS_FILENAME = "hosts.yml";
14-
const GH_HOSTS_FILE = join(GH_CONFIG_DIR, GH_HOSTS_FILENAME);
15-
const GH_USERS_DIR = join(ODE_CONFIG_DIR, "gh-users");
1612

1713
export interface ChannelSettings {
1814
threadSessions: Record<string, string>; // threadId -> sessionId
@@ -320,219 +316,3 @@ export function clearOAuthState(): void {
320316
delete settings.oauthState;
321317
saveSettings(settings);
322318
}
323-
324-
export interface GitHubAuthInfo {
325-
host: string;
326-
user?: string;
327-
gitProtocol?: string;
328-
hasToken: boolean;
329-
}
330-
331-
export interface GitHubAuthRecord {
332-
host: string;
333-
user?: string;
334-
token: string;
335-
gitProtocol?: string;
336-
}
337-
338-
export interface GitIdentity {
339-
name: string;
340-
email: string;
341-
}
342-
343-
export function normalizeGitHubHost(input: string): string | null {
344-
const trimmed = input.trim();
345-
if (!trimmed) return null;
346-
const noProtocol = trimmed.replace(/^https?:\/\//, "");
347-
const host = noProtocol.split(/[/?#]/)[0];
348-
return host || null;
349-
}
350-
351-
function ensureGitHubConfigDir(): void {
352-
if (!existsSync(GH_CONFIG_DIR)) {
353-
mkdirSync(GH_CONFIG_DIR, { recursive: true });
354-
}
355-
}
356-
357-
function ensureGitHubUserDir(userId: string): void {
358-
const userDir = join(GH_USERS_DIR, userId);
359-
if (!existsSync(userDir)) {
360-
mkdirSync(userDir, { recursive: true });
361-
}
362-
}
363-
364-
export function getGitHubUserConfigDir(userId: string): string {
365-
return join(GH_USERS_DIR, userId);
366-
}
367-
368-
function getGitHubUserHostsFile(userId: string): string {
369-
return join(GH_USERS_DIR, userId, GH_HOSTS_FILENAME);
370-
}
371-
372-
function parseGitHubHosts(contents: string): Record<string, Record<string, string>> {
373-
const hosts: Record<string, Record<string, string>> = {};
374-
let currentHost: string | null = null;
375-
376-
for (const line of contents.split(/\r?\n/)) {
377-
const trimmed = line.trim();
378-
if (!trimmed || trimmed.startsWith("#")) continue;
379-
380-
if (!line.startsWith(" ") && trimmed.endsWith(":")) {
381-
currentHost = trimmed.slice(0, -1).trim();
382-
if (currentHost) {
383-
hosts[currentHost] = hosts[currentHost] || {};
384-
}
385-
continue;
386-
}
387-
388-
if (!currentHost) continue;
389-
const hostKey = currentHost;
390-
const match = trimmed.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
391-
if (!match) continue;
392-
const field = match[1];
393-
if (!field) continue;
394-
hosts[hostKey] = hosts[hostKey] || {};
395-
hosts[hostKey][field] = match[2] ?? "";
396-
}
397-
398-
return hosts;
399-
}
400-
401-
function serializeGitHubHosts(hosts: Record<string, Record<string, string>>): string {
402-
const lines: string[] = [];
403-
for (const [host, entries] of Object.entries(hosts)) {
404-
lines.push(`${host}:`);
405-
for (const [key, value] of Object.entries(entries)) {
406-
lines.push(` ${key}: ${value}`);
407-
}
408-
}
409-
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
410-
}
411-
412-
function readGitHubHosts(filePath: string): Record<string, Record<string, string>> {
413-
if (!existsSync(filePath)) return {};
414-
const contents = readFileSync(filePath, "utf-8");
415-
return parseGitHubHosts(contents);
416-
}
417-
418-
function writeGitHubHosts(filePath: string, hosts: Record<string, Record<string, string>>): void {
419-
const dirPath = dirname(filePath);
420-
if (!existsSync(dirPath)) {
421-
mkdirSync(dirPath, { recursive: true });
422-
}
423-
writeFileSync(filePath, serializeGitHubHosts(hosts));
424-
}
425-
426-
function getGitHubAuthFromFile(filePath: string, host: string): GitHubAuthInfo | null {
427-
if (!existsSync(filePath)) return null;
428-
const hosts = readGitHubHosts(filePath);
429-
const entry = hosts[host];
430-
if (!entry) return null;
431-
return {
432-
host,
433-
user: entry.user,
434-
gitProtocol: entry.git_protocol,
435-
hasToken: Boolean(entry.oauth_token),
436-
};
437-
}
438-
439-
function getGitHubAuthRecordFromFile(filePath: string, host: string): GitHubAuthRecord | null {
440-
if (!existsSync(filePath)) return null;
441-
const hosts = readGitHubHosts(filePath);
442-
const entry = hosts[host];
443-
if (!entry?.oauth_token) return null;
444-
return {
445-
host,
446-
user: entry.user,
447-
token: entry.oauth_token,
448-
gitProtocol: entry.git_protocol,
449-
};
450-
}
451-
452-
function saveGitHubAuthToFile(
453-
filePath: string,
454-
params: {
455-
host: string;
456-
user?: string;
457-
token: string;
458-
gitProtocol?: string;
459-
}
460-
): void {
461-
const existing = readGitHubHosts(filePath);
462-
const entry = { ...(existing[params.host] || {}) };
463-
if (params.user) entry.user = params.user;
464-
entry.oauth_token = params.token;
465-
entry.git_protocol = params.gitProtocol || entry.git_protocol || "https";
466-
existing[params.host] = entry;
467-
writeGitHubHosts(filePath, existing);
468-
}
469-
470-
export function getGitHubAuth(host = "github.com"): GitHubAuthInfo | null {
471-
return getGitHubAuthFromFile(GH_HOSTS_FILE, host);
472-
}
473-
474-
export function getGitHubAuthForUser(
475-
userId: string,
476-
host = "github.com"
477-
): GitHubAuthInfo | null {
478-
return getGitHubAuthFromFile(getGitHubUserHostsFile(userId), host);
479-
}
480-
481-
export function getGitHubAuthRecord(host = "github.com"): GitHubAuthRecord | null {
482-
return getGitHubAuthRecordFromFile(GH_HOSTS_FILE, host);
483-
}
484-
485-
export function getGitHubAuthRecordForUser(
486-
userId: string,
487-
host = "github.com"
488-
): GitHubAuthRecord | null {
489-
return getGitHubAuthRecordFromFile(getGitHubUserHostsFile(userId), host);
490-
}
491-
492-
export function activateGitHubAuthForUser(
493-
userId: string,
494-
host = "github.com"
495-
): GitHubAuthInfo | null {
496-
const record = getGitHubAuthRecordForUser(userId, host) ?? getGitHubAuthRecord(host);
497-
if (!record) return null;
498-
saveGitHubAuth(record);
499-
return {
500-
host: record.host,
501-
user: record.user,
502-
gitProtocol: record.gitProtocol,
503-
hasToken: true,
504-
};
505-
}
506-
507-
export function getGitIdentityForUser(
508-
userId: string,
509-
host = "github.com"
510-
): GitIdentity | null {
511-
const record = getGitHubAuthRecordForUser(userId, host) ?? getGitHubAuthRecord(host);
512-
if (!record?.user) return null;
513-
return {
514-
name: record.user,
515-
email: `${record.user}@users.noreply.github.com`,
516-
};
517-
}
518-
519-
export function saveGitHubAuth(params: {
520-
host: string;
521-
user?: string;
522-
token: string;
523-
gitProtocol?: string;
524-
}): void {
525-
ensureGitHubConfigDir();
526-
saveGitHubAuthToFile(GH_HOSTS_FILE, params);
527-
}
528-
529-
export function saveGitHubAuthForUser(params: {
530-
userId: string;
531-
host: string;
532-
user?: string;
533-
token: string;
534-
gitProtocol?: string;
535-
}): void {
536-
ensureGitHubUserDir(params.userId);
537-
saveGitHubAuthToFile(getGitHubUserHostsFile(params.userId), params);
538-
}

0 commit comments

Comments
 (0)