golem-cc 1.0.2 → 2.1.0

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.
package/src/cli/index.ts DELETED
@@ -1,370 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * golem-api CLI
4
- *
5
- * TypeScript helper for API operations, called by the main golem bash script.
6
- * Handles JSON parsing and API communication cleanly.
7
- */
8
-
9
- import { program } from 'commander';
10
- import chalk from 'chalk';
11
- import { createSyncContext, createLinkedTicket, importFreshTicket, updateTicketStatus, loadTicketState, recordCommit } from '../sync/ticket-sync.js';
12
- import { createWorktree, removeWorktree, getRepoRoot, listWorktrees, squashCommits, pushBranch } from '../worktree/manager.js';
13
- import { createFreshworksClient } from '../api/freshworks.js';
14
- import { createGiteaClient } from '../api/gitea.js';
15
- import type { CommitType, TicketStatus } from '../types.js';
16
- import { readFile } from 'node:fs/promises';
17
- import { join } from 'node:path';
18
-
19
- // Load .env from project root or home
20
- import { config } from 'dotenv';
21
- config({ path: join(process.cwd(), '.env') });
22
- config({ path: join(process.env.HOME || '', '.golem/.env') });
23
-
24
- const golemDir = join(process.cwd(), '.golem');
25
- const ticketsDir = join(golemDir, 'tickets');
26
-
27
- program
28
- .name('golem-api')
29
- .description('Golem API helper for Freshworks/Gitea integration')
30
- .version('0.1.0');
31
-
32
- // ============ Ticket Commands ============
33
-
34
- program
35
- .command('ticket:new')
36
- .description('Create a new linked ticket in Fresh + Gitea')
37
- .requiredOption('-s, --subject <subject>', 'Ticket subject')
38
- .requiredOption('-d, --description <description>', 'Ticket description')
39
- .requiredOption('-t, --type <type>', 'Commit type (feat|fix|refactor|docs|test|chore)')
40
- .requiredOption('--slug <slug>', 'Human-readable slug for branch name')
41
- .option('-p, --priority <priority>', 'Priority (1-4)', '3')
42
- .option('-r, --repo <repo>', 'Gitea repo (default from GITEA_REPO env)')
43
- .action(async (opts) => {
44
- try {
45
- const repo = opts.repo || process.env.GITEA_REPO;
46
- if (!repo) {
47
- console.error(chalk.red('Error: --repo required or set GITEA_REPO'));
48
- process.exit(1);
49
- }
50
-
51
- const ctx = createSyncContext(ticketsDir, repo);
52
- const ticket = await createLinkedTicket(ctx, {
53
- subject: opts.subject,
54
- description: opts.description,
55
- type: opts.type as CommitType,
56
- slug: opts.slug,
57
- priority: parseInt(opts.priority) as 1 | 2 | 3 | 4,
58
- });
59
-
60
- console.log(chalk.green(`✓ Created ticket ${ticket.id}`));
61
- console.log(chalk.dim(` Fresh: ${ticket.fresh?.url}`));
62
- console.log(chalk.dim(` Gitea: ${ticket.gitea?.url}`));
63
- console.log(chalk.dim(` Branch: ${ticket.git.branch}`));
64
-
65
- // Output JSON for bash script to parse
66
- console.log(JSON.stringify(ticket));
67
- } catch (e) {
68
- console.error(chalk.red(`Error: ${e}`));
69
- process.exit(1);
70
- }
71
- });
72
-
73
- program
74
- .command('ticket:import <freshId>')
75
- .description('Import existing Fresh ticket and create linked Gitea issue')
76
- .requiredOption('-t, --type <type>', 'Commit type (feat|fix|refactor|docs|test|chore)')
77
- .requiredOption('--slug <slug>', 'Human-readable slug for branch name')
78
- .option('-r, --repo <repo>', 'Gitea repo (default from GITEA_REPO env)')
79
- .action(async (freshId, opts) => {
80
- try {
81
- const repo = opts.repo || process.env.GITEA_REPO;
82
- if (!repo) {
83
- console.error(chalk.red('Error: --repo required or set GITEA_REPO'));
84
- process.exit(1);
85
- }
86
-
87
- const ctx = createSyncContext(ticketsDir, repo);
88
- const ticket = await importFreshTicket(ctx, freshId, {
89
- type: opts.type as CommitType,
90
- slug: opts.slug,
91
- });
92
-
93
- console.log(chalk.green(`✓ Imported ticket ${ticket.id}`));
94
- console.log(chalk.dim(` Fresh: ${ticket.fresh?.url}`));
95
- console.log(chalk.dim(` Gitea: ${ticket.gitea?.url}`));
96
- console.log(chalk.dim(` Branch: ${ticket.git.branch}`));
97
-
98
- console.log(JSON.stringify(ticket));
99
- } catch (e) {
100
- console.error(chalk.red(`Error: ${e}`));
101
- process.exit(1);
102
- }
103
- });
104
-
105
- program
106
- .command('ticket:status <ticketId> <status>')
107
- .description('Update ticket status and sync to Fresh/Gitea')
108
- .option('-n, --note <note>', 'Status note')
109
- .option('-r, --repo <repo>', 'Gitea repo')
110
- .action(async (ticketId, status, opts) => {
111
- try {
112
- const repo = opts.repo || process.env.GITEA_REPO;
113
- if (!repo) {
114
- console.error(chalk.red('Error: --repo required or set GITEA_REPO'));
115
- process.exit(1);
116
- }
117
-
118
- const ctx = createSyncContext(ticketsDir, repo);
119
- const result = await updateTicketStatus(ctx, ticketId, status as TicketStatus, opts.note);
120
-
121
- if (result.success) {
122
- console.log(chalk.green(`✓ Updated ${ticketId} to ${status}`));
123
- if (result.freshUpdated) console.log(chalk.dim(' Fresh updated'));
124
- if (result.giteaUpdated) console.log(chalk.dim(' Gitea updated'));
125
- } else {
126
- console.error(chalk.red(`Error: ${result.error}`));
127
- process.exit(1);
128
- }
129
- } catch (e) {
130
- console.error(chalk.red(`Error: ${e}`));
131
- process.exit(1);
132
- }
133
- });
134
-
135
- program
136
- .command('ticket:get <ticketId>')
137
- .description('Get ticket state as JSON')
138
- .action(async (ticketId) => {
139
- try {
140
- const ticket = await loadTicketState(ticketsDir, ticketId);
141
- if (!ticket) {
142
- console.error(chalk.red(`Ticket ${ticketId} not found`));
143
- process.exit(1);
144
- }
145
- console.log(JSON.stringify(ticket, null, 2));
146
- } catch (e) {
147
- console.error(chalk.red(`Error: ${e}`));
148
- process.exit(1);
149
- }
150
- });
151
-
152
- program
153
- .command('ticket:list')
154
- .description('List all tickets')
155
- .option('--status <status>', 'Filter by status')
156
- .option('--json', 'Output as JSON')
157
- .action(async (opts) => {
158
- try {
159
- const { readdir } = await import('node:fs/promises');
160
- const files = await readdir(ticketsDir).catch(() => []);
161
- const tickets = [];
162
-
163
- for (const file of files) {
164
- if (!file.endsWith('.yaml')) continue;
165
- const ticketId = file.replace('.yaml', '');
166
- const ticket = await loadTicketState(ticketsDir, ticketId);
167
- if (ticket && (!opts.status || ticket.status === opts.status)) {
168
- tickets.push(ticket);
169
- }
170
- }
171
-
172
- if (opts.json) {
173
- console.log(JSON.stringify(tickets, null, 2));
174
- } else {
175
- if (tickets.length === 0) {
176
- console.log(chalk.dim('No tickets found'));
177
- } else {
178
- for (const t of tickets) {
179
- const statusColor = t.status === 'done' ? chalk.green
180
- : t.status === 'in-progress' ? chalk.yellow
181
- : t.status === 'blocked' ? chalk.red
182
- : chalk.dim;
183
- console.log(`${chalk.bold(t.id)} ${statusColor(`[${t.status}]`)} ${t.fresh?.subject || t.slug}`);
184
- console.log(chalk.dim(` ${t.git.branch}`));
185
- }
186
- }
187
- }
188
- } catch (e) {
189
- console.error(chalk.red(`Error: ${e}`));
190
- process.exit(1);
191
- }
192
- });
193
-
194
- // ============ Worktree Commands ============
195
-
196
- program
197
- .command('worktree:create <ticketId>')
198
- .description('Create git worktree for a ticket')
199
- .option('-b, --base <branch>', 'Base branch (default: main)')
200
- .action(async (ticketId, opts) => {
201
- try {
202
- const ticket = await loadTicketState(ticketsDir, ticketId);
203
- if (!ticket) {
204
- console.error(chalk.red(`Ticket ${ticketId} not found`));
205
- process.exit(1);
206
- }
207
-
208
- const path = await createWorktree(ticket, { baseBranch: opts.base });
209
- console.log(chalk.green(`✓ Created worktree at ${path}`));
210
- console.log(path); // For bash to capture
211
- } catch (e) {
212
- console.error(chalk.red(`Error: ${e}`));
213
- process.exit(1);
214
- }
215
- });
216
-
217
- program
218
- .command('worktree:list')
219
- .description('List all worktrees')
220
- .option('--json', 'Output as JSON')
221
- .action(async (opts) => {
222
- try {
223
- const worktrees = listWorktrees();
224
- if (opts.json) {
225
- console.log(JSON.stringify(worktrees, null, 2));
226
- } else {
227
- for (const wt of worktrees) {
228
- console.log(`${chalk.bold(wt.branch)}`);
229
- console.log(chalk.dim(` ${wt.path}`));
230
- }
231
- }
232
- } catch (e) {
233
- console.error(chalk.red(`Error: ${e}`));
234
- process.exit(1);
235
- }
236
- });
237
-
238
- program
239
- .command('worktree:remove <path>')
240
- .description('Remove a worktree')
241
- .action(async (path) => {
242
- try {
243
- await removeWorktree(path);
244
- console.log(chalk.green(`✓ Removed worktree`));
245
- } catch (e) {
246
- console.error(chalk.red(`Error: ${e}`));
247
- process.exit(1);
248
- }
249
- });
250
-
251
- // ============ Git Commands ============
252
-
253
- program
254
- .command('git:squash <ticketId>')
255
- .description('Squash all commits for a ticket into one')
256
- .requiredOption('-m, --message <message>', 'Commit message')
257
- .option('-b, --base <branch>', 'Base branch')
258
- .action(async (ticketId, opts) => {
259
- try {
260
- const ticket = await loadTicketState(ticketsDir, ticketId);
261
- if (!ticket) {
262
- console.error(chalk.red(`Ticket ${ticketId} not found`));
263
- process.exit(1);
264
- }
265
-
266
- const repoRoot = getRepoRoot();
267
- const worktreePath = join(repoRoot, ticket.git.worktree);
268
-
269
- const sha = await squashCommits(worktreePath, opts.message, opts.base);
270
- console.log(chalk.green(`✓ Squashed to ${sha.slice(0, 8)}`));
271
- console.log(sha);
272
- } catch (e) {
273
- console.error(chalk.red(`Error: ${e}`));
274
- process.exit(1);
275
- }
276
- });
277
-
278
- program
279
- .command('git:push <ticketId>')
280
- .description('Push ticket branch to remote')
281
- .option('-f, --force', 'Force push (with lease)')
282
- .action(async (ticketId, opts) => {
283
- try {
284
- const ticket = await loadTicketState(ticketsDir, ticketId);
285
- if (!ticket) {
286
- console.error(chalk.red(`Ticket ${ticketId} not found`));
287
- process.exit(1);
288
- }
289
-
290
- const repoRoot = getRepoRoot();
291
- const worktreePath = join(repoRoot, ticket.git.worktree);
292
-
293
- await pushBranch(worktreePath, opts.force);
294
- console.log(chalk.green(`✓ Pushed ${ticket.git.branch}`));
295
- } catch (e) {
296
- console.error(chalk.red(`Error: ${e}`));
297
- process.exit(1);
298
- }
299
- });
300
-
301
- program
302
- .command('git:commit <ticketId> <sha>')
303
- .description('Record a commit against a ticket')
304
- .action(async (ticketId, sha) => {
305
- try {
306
- await recordCommit(ticketsDir, ticketId, sha);
307
- console.log(chalk.green(`✓ Recorded commit ${sha.slice(0, 8)}`));
308
- } catch (e) {
309
- console.error(chalk.red(`Error: ${e}`));
310
- process.exit(1);
311
- }
312
- });
313
-
314
- // ============ API Test Commands ============
315
-
316
- program
317
- .command('fresh:test')
318
- .description('Test Freshworks API connection')
319
- .action(async () => {
320
- try {
321
- const client = createFreshworksClient();
322
- const tickets = await client.getMyTickets();
323
- console.log(chalk.green(`✓ Connected to Freshworks`));
324
- console.log(chalk.dim(` Found ${tickets.length} tickets assigned to you`));
325
- } catch (e) {
326
- console.error(chalk.red(`Error: ${e}`));
327
- process.exit(1);
328
- }
329
- });
330
-
331
- program
332
- .command('fresh:list')
333
- .description('List your Freshworks tickets')
334
- .option('--json', 'Output as JSON')
335
- .action(async (opts) => {
336
- try {
337
- const client = createFreshworksClient();
338
- const tickets = await client.getMyTickets();
339
-
340
- if (opts.json) {
341
- console.log(JSON.stringify(tickets, null, 2));
342
- } else {
343
- for (const t of tickets) {
344
- const id = client.constructor.prototype.constructor.formatTicketId(t.id, t.ticket_type);
345
- console.log(`${chalk.bold(id)} ${t.subject}`);
346
- console.log(chalk.dim(` Priority: ${t.priority} | Status: ${t.status}`));
347
- }
348
- }
349
- } catch (e) {
350
- console.error(chalk.red(`Error: ${e}`));
351
- process.exit(1);
352
- }
353
- });
354
-
355
- program
356
- .command('gitea:test')
357
- .description('Test Gitea API connection')
358
- .action(async () => {
359
- try {
360
- const client = createGiteaClient();
361
- const repos = await client.listOrgRepos();
362
- console.log(chalk.green(`✓ Connected to Gitea`));
363
- console.log(chalk.dim(` Found ${repos.length} repos in org`));
364
- } catch (e) {
365
- console.error(chalk.red(`Error: ${e}`));
366
- process.exit(1);
367
- }
368
- });
369
-
370
- program.parse();
@@ -1,303 +0,0 @@
1
- /**
2
- * Bi-directional sync between Freshworks, Gitea, and local state
3
- *
4
- * Local state (.golem/tickets/*.yaml) is the source of truth.
5
- * This module handles pushing updates to Fresh and Gitea.
6
- */
7
-
8
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
9
- import { join, dirname } from 'node:path';
10
- import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
11
- import type { TicketState, SyncResult, TicketStatus, CommitType } from '../types.js';
12
- import { FreshworksClient, createFreshworksClient } from '../api/freshworks.js';
13
- import { GiteaClient, createGiteaClient } from '../api/gitea.js';
14
-
15
- export interface SyncContext {
16
- fresh: FreshworksClient;
17
- gitea: GiteaClient;
18
- ticketsDir: string;
19
- repo: string;
20
- }
21
-
22
- /**
23
- * Load ticket state from local YAML file
24
- */
25
- export async function loadTicketState(
26
- ticketsDir: string,
27
- ticketId: string
28
- ): Promise<TicketState | null> {
29
- const filePath = join(ticketsDir, `${ticketId}.yaml`);
30
- try {
31
- const content = await readFile(filePath, 'utf-8');
32
- return parseYaml(content) as TicketState;
33
- } catch {
34
- return null;
35
- }
36
- }
37
-
38
- /**
39
- * Save ticket state to local YAML file
40
- */
41
- export async function saveTicketState(
42
- ticketsDir: string,
43
- state: TicketState
44
- ): Promise<void> {
45
- const filePath = join(ticketsDir, `${state.id}.yaml`);
46
- await mkdir(dirname(filePath), { recursive: true });
47
- state.updated = new Date().toISOString();
48
- await writeFile(filePath, stringifyYaml(state), 'utf-8');
49
- }
50
-
51
- /**
52
- * Create a new ticket in both Fresh and Gitea, linked together
53
- */
54
- export async function createLinkedTicket(
55
- ctx: SyncContext,
56
- params: {
57
- subject: string;
58
- description: string;
59
- type: CommitType;
60
- slug: string;
61
- priority?: 1 | 2 | 3 | 4;
62
- }
63
- ): Promise<TicketState> {
64
- const { fresh, gitea, ticketsDir, repo } = ctx;
65
-
66
- // 1. Create Fresh ticket first (gets the ID)
67
- // Required fields from Freshservice: status, group_id, category, email/requester_id, source
68
- const freshTicket = await fresh.createTicket({
69
- subject: params.subject,
70
- description: params.description,
71
- priority: params.priority || 3,
72
- status: 2, // Open
73
- source: parseInt(process.env.FRESH_SOURCE_ID || '1002'), // ACE (API)
74
- group_id: parseInt(process.env.FRESH_DEFAULT_GROUP_ID || '38000120203'),
75
- category: process.env.FRESH_DEFAULT_CATEGORY || 'Applications',
76
- email: process.env.FRESH_DEFAULT_EMAIL || 'ace-bot@pearlriverresort.com',
77
- });
78
-
79
- const ticketId = FreshworksClient.formatTicketId(freshTicket.id);
80
- const freshUrl = `https://${process.env.FRESH_DOMAIN}/a/tickets/${freshTicket.id}`;
81
-
82
- // 2. Create Gitea issue with link back to Fresh ticket
83
- const giteaIssue = await gitea.createIssue(repo, {
84
- title: `[${ticketId}] ${params.subject}`,
85
- body: `${params.description}\n\n---\n**Freshservice:** [${ticketId}](${freshUrl})\n**Type:** ${params.type}`,
86
- });
87
-
88
- // 3. Update Fresh ticket with Gitea link
89
- await fresh.addNote(
90
- freshTicket.id,
91
- `🔗 Gitea Issue: ${giteaIssue.html_url}`,
92
- true // private note
93
- );
94
-
95
- // 4. Create local state
96
- const branch = `${params.type}/${ticketId}-${params.slug}`;
97
- const state: TicketState = {
98
- id: ticketId,
99
- slug: params.slug,
100
- fresh: {
101
- id: ticketId,
102
- url: `https://${process.env.FRESH_DOMAIN}/helpdesk/tickets/${freshTicket.id}`,
103
- subject: freshTicket.subject,
104
- description: freshTicket.description_text,
105
- priority: freshTicket.priority,
106
- status: freshTicket.status,
107
- },
108
- gitea: {
109
- repo,
110
- issueNumber: giteaIssue.number,
111
- url: giteaIssue.html_url,
112
- },
113
- git: {
114
- worktree: join('.golem/worktrees', branch),
115
- branch,
116
- commits: [],
117
- },
118
- status: 'new',
119
- type: params.type,
120
- created: new Date().toISOString(),
121
- updated: new Date().toISOString(),
122
- };
123
-
124
- await saveTicketState(ticketsDir, state);
125
- return state;
126
- }
127
-
128
- /**
129
- * Import an existing Fresh ticket and create linked Gitea issue
130
- */
131
- export async function importFreshTicket(
132
- ctx: SyncContext,
133
- freshTicketId: number | string,
134
- params: {
135
- type: CommitType;
136
- slug: string;
137
- }
138
- ): Promise<TicketState> {
139
- const { fresh, gitea, ticketsDir, repo } = ctx;
140
-
141
- // Parse ticket ID if string
142
- const numericId = typeof freshTicketId === 'string'
143
- ? FreshworksClient.parseTicketId(freshTicketId)
144
- : freshTicketId;
145
-
146
- // 1. Fetch existing Fresh ticket
147
- const freshTicket = await fresh.getTicket(numericId);
148
- const ticketId = FreshworksClient.formatTicketId(freshTicket.id, freshTicket.ticket_type);
149
-
150
- // 2. Check if local state already exists
151
- const existingState = await loadTicketState(ticketsDir, ticketId);
152
- if (existingState) {
153
- console.log(`Ticket ${ticketId} already imported, returning existing state`);
154
- return existingState;
155
- }
156
-
157
- // 3. Check if Gitea issue already exists for this ticket
158
- let giteaIssue = await gitea.findIssueByTicketId(repo, ticketId);
159
- const freshUrl = `https://${process.env.FRESH_DOMAIN}/a/tickets/${freshTicket.id}`;
160
-
161
- if (giteaIssue) {
162
- console.log(`Found existing Gitea issue #${giteaIssue.number} for ${ticketId}`);
163
- } else {
164
- // 4. Create Gitea issue with link back to Fresh
165
- giteaIssue = await gitea.createIssue(repo, {
166
- title: `[${ticketId}] ${freshTicket.subject}`,
167
- body: `${freshTicket.description_text}\n\n---\n**Freshservice:** [${ticketId}](${freshUrl})\n**Type:** ${params.type}`,
168
- });
169
-
170
- // 5. Update Fresh ticket with Gitea link
171
- await fresh.addNote(
172
- freshTicket.id,
173
- `🔗 Gitea Issue: ${giteaIssue.html_url}`,
174
- true
175
- );
176
- }
177
-
178
- // 6. Create local state
179
- const branch = `${params.type}/${ticketId}-${params.slug}`;
180
- const state: TicketState = {
181
- id: ticketId,
182
- slug: params.slug,
183
- fresh: {
184
- id: ticketId,
185
- url: `https://${process.env.FRESH_DOMAIN}/helpdesk/tickets/${freshTicket.id}`,
186
- subject: freshTicket.subject,
187
- description: freshTicket.description_text,
188
- priority: freshTicket.priority,
189
- status: freshTicket.status,
190
- },
191
- gitea: {
192
- repo,
193
- issueNumber: giteaIssue.number,
194
- url: giteaIssue.html_url,
195
- },
196
- git: {
197
- worktree: join('.golem/worktrees', branch),
198
- branch,
199
- commits: [],
200
- },
201
- status: 'new',
202
- type: params.type,
203
- created: new Date().toISOString(),
204
- updated: new Date().toISOString(),
205
- };
206
-
207
- await saveTicketState(ticketsDir, state);
208
- return state;
209
- }
210
-
211
- /**
212
- * Update ticket status and sync to Fresh/Gitea
213
- */
214
- export async function updateTicketStatus(
215
- ctx: SyncContext,
216
- ticketId: string,
217
- newStatus: TicketStatus,
218
- note?: string
219
- ): Promise<SyncResult> {
220
- const { fresh, gitea, ticketsDir } = ctx;
221
-
222
- const state = await loadTicketState(ticketsDir, ticketId);
223
- if (!state) {
224
- return { success: false, freshUpdated: false, giteaUpdated: false, localUpdated: false, error: 'Ticket not found' };
225
- }
226
-
227
- const oldStatus = state.status;
228
- state.status = newStatus;
229
-
230
- let freshUpdated = false;
231
- let giteaUpdated = false;
232
-
233
- // Build status message
234
- const statusMessage = note || `Status: ${oldStatus} → ${newStatus}`;
235
-
236
- // Update Fresh
237
- if (state.fresh) {
238
- try {
239
- const freshId = FreshworksClient.parseTicketId(state.fresh.id);
240
- await fresh.addNote(freshId, `🤖 Golem: ${statusMessage}`, true);
241
-
242
- // Close ticket if done
243
- if (newStatus === 'done') {
244
- await fresh.closeTicket(freshId, statusMessage);
245
- }
246
- freshUpdated = true;
247
- } catch (e) {
248
- console.error('Failed to update Fresh:', e);
249
- }
250
- }
251
-
252
- // Update Gitea
253
- if (state.gitea) {
254
- try {
255
- await gitea.addIssueComment(state.gitea.repo, state.gitea.issueNumber, `🤖 Golem: ${statusMessage}`);
256
-
257
- // Close issue if done
258
- if (newStatus === 'done') {
259
- await gitea.closeIssue(state.gitea.repo, state.gitea.issueNumber);
260
- }
261
- giteaUpdated = true;
262
- } catch (e) {
263
- console.error('Failed to update Gitea:', e);
264
- }
265
- }
266
-
267
- // Save local state
268
- await saveTicketState(ticketsDir, state);
269
-
270
- return { success: true, freshUpdated, giteaUpdated, localUpdated: true };
271
- }
272
-
273
- /**
274
- * Record a commit against a ticket
275
- */
276
- export async function recordCommit(
277
- ticketsDir: string,
278
- ticketId: string,
279
- commitSha: string
280
- ): Promise<void> {
281
- const state = await loadTicketState(ticketsDir, ticketId);
282
- if (!state) {
283
- throw new Error(`Ticket ${ticketId} not found`);
284
- }
285
-
286
- state.git.commits.push(commitSha);
287
- await saveTicketState(ticketsDir, state);
288
- }
289
-
290
- /**
291
- * Create sync context from environment
292
- */
293
- export function createSyncContext(
294
- ticketsDir: string,
295
- repo: string
296
- ): SyncContext {
297
- return {
298
- fresh: createFreshworksClient(),
299
- gitea: createGiteaClient(),
300
- ticketsDir,
301
- repo,
302
- };
303
- }