Skip to content

Commit 0ac9e01

Browse files
turinglabsorgclaude
andcommitted
Add pre-job credit check, user tracking, admin tools, and enable login
- Agent: check credit balance before running jobs, fail with comment if user has no credits remaining - Webhook: attach userId from webhook registration to new jobs so credits can be tracked per-user - API: add POST /admin/grant-credits endpoint for admin credit grants - CLI: add grant-credits.ts script for command-line credit management - Frontend: add CSRF token to mutating API requests, enable GitHub login button (remove "coming soon" placeholder) - Shared: add getUserByLogin() to state manager Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ec417cc commit 0ac9e01

8 files changed

Lines changed: 140 additions & 4 deletions

File tree

agent/src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getBuffer,
1212
subscribe,
1313
pushLine,
14+
postComment,
1415
getInstallationToken,
1516
clearTokenCache,
1617
getAppInfo,
@@ -658,6 +659,22 @@ async function main() {
658659
running++;
659660
log.info(`Claimed job ${job.id} (running: ${running}/${config.maxConcurrentJobs})`);
660661

662+
// Pre-job credit check
663+
if (config.billingEnabled && job.userId) {
664+
const balance = await state.getCreditBalance(job.userId);
665+
if (!balance || balance.credits <= 0) {
666+
const now = new Date().toISOString();
667+
await state.upsertJob({ ...job, status: "failed", failureReason: "Insufficient credits", updatedAt: now });
668+
await postComment(
669+
job.owner, job.repo, job.issueNumber,
670+
"This job cannot run because you have no credits remaining. Please purchase credits to continue.",
671+
config
672+
);
673+
running--;
674+
return;
675+
}
676+
}
677+
661678
// Build the QueuedJob from the claimed JobState
662679
const queuedJob = await buildQueuedJob(job, config, state);
663680
if (!queuedJob) {

agent/src/webhook.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ async function handleIssueComment(
110110
return;
111111
}
112112

113+
const reg = await state.getWebhookByRepoId(`${owner}/${repo}`);
114+
113115
const jobId = `${owner}/${repo}#${issueNumber}`;
114116
const now = new Date().toISOString();
115117
const jobState: JobState = {
@@ -121,6 +123,7 @@ async function handleIssueComment(
121123
branch: `grog/issue-${issueNumber}`,
122124
issueTitle: payload.issue.title,
123125
triggerCommentId: payload.comment.id,
126+
userId: reg?.userId,
124127
startedAt: now,
125128
updatedAt: now,
126129
};
@@ -198,6 +201,8 @@ async function handleIssuesEvent(
198201

199202
log.info(`Auto-solving new issue ${owner}/${repo}#${payload.issue.number}`);
200203

204+
const reg = await state.getWebhookByRepoId(`${owner}/${repo}`);
205+
201206
const jobId = `${owner}/${repo}#${payload.issue.number}`;
202207
const now = new Date().toISOString();
203208
const jobState: JobState = {
@@ -209,6 +214,7 @@ async function handleIssuesEvent(
209214
branch: `grog/issue-${payload.issue.number}`,
210215
issueTitle: payload.issue.title,
211216
triggerCommentId: 0,
217+
userId: reg?.userId,
212218
startedAt: now,
213219
updatedAt: now,
214220
};

api/cli/grant-credits.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { loadConfig, StateManager } from "@grog/shared";
2+
3+
async function main() {
4+
const [target, amountStr] = process.argv.slice(2);
5+
6+
if (!target || !amountStr) {
7+
console.error("Usage: npx tsx cli/grant-credits.ts <github-login-or-id> <amount>");
8+
process.exit(1);
9+
}
10+
11+
const amount = parseInt(amountStr, 10);
12+
if (!Number.isFinite(amount) || amount <= 0) {
13+
console.error("Error: amount must be a positive integer");
14+
process.exit(1);
15+
}
16+
17+
const config = loadConfig();
18+
const state = await StateManager.connect(config.mongodbUri);
19+
20+
// Determine if target is a numeric GitHub ID or a login string
21+
const isNumeric = /^\d+$/.test(target);
22+
const user = isNumeric
23+
? await state.getUserByGithubId(parseInt(target, 10))
24+
: await state.getUserByLogin(target);
25+
26+
if (!user) {
27+
console.error(`Error: user not found: ${target}`);
28+
process.exit(1);
29+
}
30+
31+
const balance = await state.addCredits(user.githubId, amount);
32+
33+
await state.recordCreditTransaction({
34+
id: `grant-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
35+
userId: user.githubId,
36+
type: "grant",
37+
amount,
38+
balanceAfter: balance.credits,
39+
description: `CLI grant of ${amount} credits`,
40+
createdAt: new Date().toISOString(),
41+
});
42+
43+
console.log(`Granted ${amount} credits to ${user.login} (GitHub ID: ${user.githubId})`);
44+
console.log(`New balance: ${balance.credits} credits`);
45+
process.exit(0);
46+
}
47+
48+
main().catch((err) => {
49+
console.error(`Failed: ${err}`);
50+
process.exit(1);
51+
});

api/src/index.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,40 @@ async function main() {
4949
res.json(await state.getStats());
5050
});
5151

52+
// Admin grant credits
53+
app.post("/admin/grant-credits", requireAuth(config, state), requireAdmin(config), async (req, res) => {
54+
const { login, amount } = req.body as { login?: string; amount?: number };
55+
56+
if (!login || typeof login !== "string") {
57+
res.status(400).json({ error: "login is required" });
58+
return;
59+
}
60+
if (!amount || typeof amount !== "number" || amount <= 0 || !Number.isInteger(amount)) {
61+
res.status(400).json({ error: "amount must be a positive integer" });
62+
return;
63+
}
64+
65+
const user = await state.getUserByLogin(login);
66+
if (!user) {
67+
res.status(404).json({ error: `User not found: ${login}` });
68+
return;
69+
}
70+
71+
const balance = await state.addCredits(user.githubId, amount);
72+
await state.recordCreditTransaction({
73+
id: `grant-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
74+
userId: user.githubId,
75+
type: "grant",
76+
amount,
77+
balanceAfter: balance.credits,
78+
description: `Admin grant of ${amount} credits`,
79+
createdAt: new Date().toISOString(),
80+
});
81+
82+
log.info(`Admin granted ${amount} credits to ${login} (${user.githubId})`);
83+
res.json({ login, githubId: user.githubId, credits: balance.credits });
84+
});
85+
5286
// Health check
5387
app.get("/health", (_req, res) => {
5488
res.json({ status: "ok" });

app/src/api.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
11
const API_BASE = "";
22

3+
function getCsrfToken(): string | undefined {
4+
const match = document.cookie.match(/(?:^|;\s*)grog_csrf=([^;]*)/);
5+
return match ? match[1] : undefined;
6+
}
7+
38
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
9+
const method = init?.method?.toUpperCase() ?? "GET";
10+
const headers = new Headers(init?.headers);
11+
12+
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
13+
const csrf = getCsrfToken();
14+
if (csrf) {
15+
headers.set("X-CSRF-Token", csrf);
16+
}
17+
}
18+
419
const res = await fetch(`${API_BASE}${path}`, {
520
credentials: "include",
621
...init,
22+
headers,
723
});
824
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
925
return res.json() as Promise<T>;

app/src/components/Header.module.css

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,16 @@
8080
font-weight: 600;
8181
text-decoration: none;
8282
font-family: var(--font-mono);
83-
cursor: default;
83+
cursor: pointer;
8484
position: relative;
8585
transition: border-color 0.3s;
8686
}
8787

88+
.loginBtn:hover {
89+
border-color: var(--cyan);
90+
color: var(--text-primary);
91+
}
92+
8893
.comingSoon {
8994
font-size: 9px;
9095
text-transform: uppercase;

app/src/components/Header.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,12 @@ export function Header({ user }: Props) {
2929
</button>
3030
</>
3131
) : (
32-
<span className={styles.loginBtn}>
32+
<a href="/auth/github" className={styles.loginBtn}>
3333
<svg viewBox="0 0 16 16" width="18" height="18" fill="currentColor">
3434
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
3535
</svg>
3636
Login with GitHub
37-
<span className={styles.comingSoon}>coming soon</span>
38-
</span>
37+
</a>
3938
)}
4039
</div>
4140
</header>

shared/src/state.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,14 @@ export class StateManager {
221221
return doc ?? undefined;
222222
}
223223

224+
async getUserByLogin(login: string): Promise<GrogUser | undefined> {
225+
const doc = await this.usersCollection.findOne(
226+
{ login },
227+
{ projection: { _id: 0 } }
228+
);
229+
return doc ?? undefined;
230+
}
231+
224232
// --- Webhook Registrations ---
225233

226234
async upsertWebhookRegistration(reg: WebhookRegistration): Promise<void> {

0 commit comments

Comments
 (0)