@plosson/agentio 0.1.5 → 0.1.8
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/README.md +18 -0
- package/package.json +1 -1
- package/src/auth/jira-oauth.ts +249 -0
- package/src/commands/jira.ts +319 -0
- package/src/commands/slack.ts +265 -0
- package/src/config/credentials.ts +9 -0
- package/src/index.ts +4 -0
- package/src/services/jira/client.ts +355 -0
- package/src/services/slack/client.ts +76 -0
- package/src/types/config.ts +3 -1
- package/src/types/jira.ts +87 -0
- package/src/types/slack.ts +17 -0
- package/src/utils/output.ts +88 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { createInterface } from 'readline';
|
|
3
|
+
import { readFile } from 'fs/promises';
|
|
4
|
+
import { setCredentials, removeCredentials, getCredentials } from '../auth/token-store';
|
|
5
|
+
import { setProfile, removeProfile, listProfiles, getProfile } from '../config/config-manager';
|
|
6
|
+
import { SlackClient } from '../services/slack/client';
|
|
7
|
+
import { CliError, handleError } from '../utils/errors';
|
|
8
|
+
import { readStdin } from '../utils/stdin';
|
|
9
|
+
import { printSlackSendResult } from '../utils/output';
|
|
10
|
+
import type { SlackCredentials, SlackWebhookCredentials } from '../types/slack';
|
|
11
|
+
|
|
12
|
+
function prompt(question: string): Promise<string> {
|
|
13
|
+
const rl = createInterface({
|
|
14
|
+
input: process.stdin,
|
|
15
|
+
output: process.stderr,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
rl.question(question, (answer) => {
|
|
20
|
+
rl.close();
|
|
21
|
+
resolve(answer.trim());
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function getSlackClient(profileName?: string): Promise<{ client: SlackClient; profile: string }> {
|
|
27
|
+
const profile = await getProfile('slack', profileName);
|
|
28
|
+
|
|
29
|
+
if (!profile) {
|
|
30
|
+
throw new CliError(
|
|
31
|
+
'PROFILE_NOT_FOUND',
|
|
32
|
+
profileName
|
|
33
|
+
? `Profile "${profileName}" not found for slack`
|
|
34
|
+
: 'No default profile configured for slack',
|
|
35
|
+
'Run: agentio slack profile add'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const credentials = await getCredentials<SlackCredentials>('slack', profile);
|
|
40
|
+
|
|
41
|
+
if (!credentials) {
|
|
42
|
+
throw new CliError(
|
|
43
|
+
'AUTH_FAILED',
|
|
44
|
+
`No credentials found for slack profile "${profile}"`,
|
|
45
|
+
`Run: agentio slack profile add --profile ${profile}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
client: new SlackClient(credentials),
|
|
51
|
+
profile,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function registerSlackCommands(program: Command): void {
|
|
56
|
+
const slack = program
|
|
57
|
+
.command('slack')
|
|
58
|
+
.description('Slack operations');
|
|
59
|
+
|
|
60
|
+
slack
|
|
61
|
+
.command('send')
|
|
62
|
+
.description('Send a message to Slack')
|
|
63
|
+
.option('--profile <name>', 'Profile name')
|
|
64
|
+
.option('--json [file]', 'Send Block Kit message from JSON file (or stdin if no file specified)')
|
|
65
|
+
.argument('[message]', 'Message text (or pipe via stdin)')
|
|
66
|
+
.action(async (message: string | undefined, options) => {
|
|
67
|
+
try {
|
|
68
|
+
let text: string | undefined = message;
|
|
69
|
+
let payload: Record<string, unknown> | undefined;
|
|
70
|
+
|
|
71
|
+
// Handle --json option
|
|
72
|
+
if (options.json !== undefined) {
|
|
73
|
+
// Check mutual exclusivity
|
|
74
|
+
if (message) {
|
|
75
|
+
throw new CliError(
|
|
76
|
+
'INVALID_PARAMS',
|
|
77
|
+
'Cannot use both text message and --json option',
|
|
78
|
+
'Use either: agentio slack send "text" OR agentio slack send --json file.json'
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let jsonContent: string;
|
|
83
|
+
|
|
84
|
+
if (typeof options.json === 'string') {
|
|
85
|
+
// Read from file
|
|
86
|
+
try {
|
|
87
|
+
jsonContent = await readFile(options.json, 'utf-8');
|
|
88
|
+
} catch (err) {
|
|
89
|
+
throw new CliError(
|
|
90
|
+
'INVALID_PARAMS',
|
|
91
|
+
`Failed to read JSON file: ${options.json}`,
|
|
92
|
+
'Check that the file exists and is readable'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
// Read from stdin
|
|
97
|
+
const stdinContent = await readStdin();
|
|
98
|
+
if (!stdinContent) {
|
|
99
|
+
throw new CliError(
|
|
100
|
+
'INVALID_PARAMS',
|
|
101
|
+
'No JSON provided via stdin',
|
|
102
|
+
'Pipe JSON content: cat message.json | agentio slack send --json'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
jsonContent = stdinContent;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Parse JSON
|
|
109
|
+
try {
|
|
110
|
+
payload = JSON.parse(jsonContent) as Record<string, unknown>;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
throw new CliError(
|
|
113
|
+
'INVALID_PARAMS',
|
|
114
|
+
`Invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
115
|
+
'Check that the JSON is valid'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
// Text message mode
|
|
120
|
+
if (!text) {
|
|
121
|
+
text = await readStdin() || undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!text) {
|
|
125
|
+
throw new CliError('INVALID_PARAMS', 'Message is required. Provide as argument or pipe via stdin.');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const { client } = await getSlackClient(options.profile);
|
|
130
|
+
const result = await client.send({
|
|
131
|
+
text,
|
|
132
|
+
payload,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
printSlackSendResult(result);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
handleError(error);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Profile management
|
|
142
|
+
const profile = slack
|
|
143
|
+
.command('profile')
|
|
144
|
+
.description('Manage Slack profiles');
|
|
145
|
+
|
|
146
|
+
profile
|
|
147
|
+
.command('add')
|
|
148
|
+
.description('Add a new Slack profile (webhook)')
|
|
149
|
+
.option('--profile <name>', 'Profile name', 'default')
|
|
150
|
+
.action(async (options) => {
|
|
151
|
+
try {
|
|
152
|
+
const profileName = options.profile;
|
|
153
|
+
await setupWebhookProfile(profileName);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
handleError(error);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
profile
|
|
160
|
+
.command('list')
|
|
161
|
+
.description('List Slack profiles')
|
|
162
|
+
.action(async () => {
|
|
163
|
+
try {
|
|
164
|
+
const result = await listProfiles('slack');
|
|
165
|
+
const { profiles, default: defaultProfile } = result[0];
|
|
166
|
+
|
|
167
|
+
if (profiles.length === 0) {
|
|
168
|
+
console.log('No profiles configured');
|
|
169
|
+
} else {
|
|
170
|
+
for (const name of profiles) {
|
|
171
|
+
const marker = name === defaultProfile ? ' (default)' : '';
|
|
172
|
+
const credentials = await getCredentials<SlackCredentials>('slack', name);
|
|
173
|
+
const channelInfo = credentials?.channelName ? ` - #${credentials.channelName}` : ' - webhook';
|
|
174
|
+
console.log(`${name}${marker}${channelInfo}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
handleError(error);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
profile
|
|
183
|
+
.command('remove')
|
|
184
|
+
.description('Remove a Slack profile')
|
|
185
|
+
.requiredOption('--profile <name>', 'Profile name')
|
|
186
|
+
.action(async (options) => {
|
|
187
|
+
try {
|
|
188
|
+
const profileName = options.profile;
|
|
189
|
+
|
|
190
|
+
const removed = await removeProfile('slack', profileName);
|
|
191
|
+
await removeCredentials('slack', profileName);
|
|
192
|
+
|
|
193
|
+
if (removed) {
|
|
194
|
+
console.log(`Removed profile "${profileName}"`);
|
|
195
|
+
} else {
|
|
196
|
+
console.error(`Profile "${profileName}" not found`);
|
|
197
|
+
}
|
|
198
|
+
} catch (error) {
|
|
199
|
+
handleError(error);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function setupWebhookProfile(profileName: string): Promise<void> {
|
|
205
|
+
console.error('\nSlack Webhook Setup\n');
|
|
206
|
+
console.error('1. Go to https://api.slack.com/apps and create a new app (or use existing)');
|
|
207
|
+
console.error('2. Enable "Incoming Webhooks" in Features');
|
|
208
|
+
console.error('3. Click "Add New Webhook to Workspace" and select a channel');
|
|
209
|
+
console.error('4. Copy the Webhook URL\n');
|
|
210
|
+
|
|
211
|
+
const webhookUrl = await prompt('? Paste your webhook URL: ');
|
|
212
|
+
|
|
213
|
+
if (!webhookUrl) {
|
|
214
|
+
throw new CliError('INVALID_PARAMS', 'Webhook URL is required');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!webhookUrl.startsWith('https://hooks.slack.com/')) {
|
|
218
|
+
throw new CliError(
|
|
219
|
+
'INVALID_PARAMS',
|
|
220
|
+
'Invalid Slack webhook URL',
|
|
221
|
+
'URL should start with https://hooks.slack.com/'
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Validate webhook with a test request
|
|
226
|
+
try {
|
|
227
|
+
const response = await fetch(webhookUrl, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: {
|
|
230
|
+
'Content-Type': 'application/json',
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify({ text: 'Test message from agentio setup' }),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
const error = await response.text();
|
|
237
|
+
throw new CliError(
|
|
238
|
+
'API_ERROR',
|
|
239
|
+
`Webhook validation failed: ${response.status} ${error}`,
|
|
240
|
+
'Check the webhook URL and try again'
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (err instanceof CliError) throw err;
|
|
245
|
+
throw new CliError(
|
|
246
|
+
'API_ERROR',
|
|
247
|
+
`Failed to validate webhook: ${err instanceof Error ? err.message : String(err)}`,
|
|
248
|
+
'Check that the URL is correct and accessible'
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const channelName = await prompt('? Channel name (optional, for display): ');
|
|
253
|
+
|
|
254
|
+
const credentials: SlackWebhookCredentials = {
|
|
255
|
+
type: 'webhook',
|
|
256
|
+
webhookUrl: webhookUrl,
|
|
257
|
+
channelName: channelName || undefined,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
await setProfile('slack', profileName);
|
|
261
|
+
await setCredentials('slack', profileName, credentials);
|
|
262
|
+
|
|
263
|
+
console.log(`\nSuccess! Webhook profile "${profileName}" configured.`);
|
|
264
|
+
console.log(` Test with: agentio slack send --profile ${profileName} "Hello from agentio"`);
|
|
265
|
+
}
|
|
@@ -11,3 +11,12 @@ export const GOOGLE_OAUTH_CONFIG = {
|
|
|
11
11
|
clientId: CLIENT_ID,
|
|
12
12
|
clientSecret: reveal(CLIENT_SECRET_ENC),
|
|
13
13
|
};
|
|
14
|
+
|
|
15
|
+
// JIRA/Atlassian OAuth credentials
|
|
16
|
+
const JIRA_CLIENT_ID = '7408S0MZKdYnsiz0KXlUT15Lb69k7y0e';
|
|
17
|
+
const JIRA_CLIENT_SECRET_ENC = 'C0J5Cyvhfl_UHCvmY9BfvKa_gzqdaa5mb3FwzRmq-YApJdAoBAKmeCl3xmcYLRCFdMvQLWVmifTkryoB0bmhVWX6eRTzoj1q4iyyTxZzT0yff0pYvLguHzp9Y4U';
|
|
18
|
+
|
|
19
|
+
export const JIRA_OAUTH_CONFIG = {
|
|
20
|
+
clientId: JIRA_CLIENT_ID,
|
|
21
|
+
clientSecret: reveal(JIRA_CLIENT_SECRET_ENC),
|
|
22
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { Command } from 'commander';
|
|
|
3
3
|
import { registerGmailCommands } from './commands/gmail';
|
|
4
4
|
import { registerTelegramCommands } from './commands/telegram';
|
|
5
5
|
import { registerGChatCommands } from './commands/gchat';
|
|
6
|
+
import { registerJiraCommands } from './commands/jira';
|
|
7
|
+
import { registerSlackCommands } from './commands/slack';
|
|
6
8
|
|
|
7
9
|
const program = new Command();
|
|
8
10
|
|
|
@@ -14,5 +16,7 @@ program
|
|
|
14
16
|
registerGmailCommands(program);
|
|
15
17
|
registerTelegramCommands(program);
|
|
16
18
|
registerGChatCommands(program);
|
|
19
|
+
registerJiraCommands(program);
|
|
20
|
+
registerSlackCommands(program);
|
|
17
21
|
|
|
18
22
|
program.parse();
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { CliError, type ErrorCode } from '../../utils/errors';
|
|
2
|
+
import type {
|
|
3
|
+
JiraCredentials,
|
|
4
|
+
JiraProject,
|
|
5
|
+
JiraIssue,
|
|
6
|
+
JiraTransition,
|
|
7
|
+
JiraComment,
|
|
8
|
+
JiraSearchOptions,
|
|
9
|
+
JiraProjectListOptions,
|
|
10
|
+
JiraCommentResult,
|
|
11
|
+
JiraTransitionResult,
|
|
12
|
+
} from '../../types/jira';
|
|
13
|
+
|
|
14
|
+
export class JiraClient {
|
|
15
|
+
private credentials: JiraCredentials;
|
|
16
|
+
private baseUrl: string;
|
|
17
|
+
|
|
18
|
+
constructor(credentials: JiraCredentials) {
|
|
19
|
+
this.credentials = credentials;
|
|
20
|
+
this.baseUrl = `https://api.atlassian.com/ex/jira/${credentials.cloudId}/rest/api/3`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private async request<T>(
|
|
24
|
+
method: string,
|
|
25
|
+
path: string,
|
|
26
|
+
body?: unknown
|
|
27
|
+
): Promise<T> {
|
|
28
|
+
const url = `${this.baseUrl}${path}`;
|
|
29
|
+
const headers: Record<string, string> = {
|
|
30
|
+
Authorization: `Bearer ${this.credentials.accessToken}`,
|
|
31
|
+
Accept: 'application/json',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (body) {
|
|
35
|
+
headers['Content-Type'] = 'application/json';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const response = await fetch(url, {
|
|
39
|
+
method,
|
|
40
|
+
headers,
|
|
41
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
const errorText = await response.text();
|
|
46
|
+
const code = this.getErrorCode(response.status);
|
|
47
|
+
throw new CliError(code, `JIRA API error: ${errorText}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Some endpoints return 204 No Content
|
|
51
|
+
if (response.status === 204) {
|
|
52
|
+
return {} as T;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return response.json();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private getErrorCode(status: number): ErrorCode {
|
|
59
|
+
if (status === 401) return 'AUTH_FAILED';
|
|
60
|
+
if (status === 403) return 'PERMISSION_DENIED';
|
|
61
|
+
if (status === 404) return 'NOT_FOUND';
|
|
62
|
+
if (status === 429) return 'RATE_LIMITED';
|
|
63
|
+
return 'API_ERROR';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async listProjects(options: JiraProjectListOptions = {}): Promise<JiraProject[]> {
|
|
67
|
+
const params = new URLSearchParams();
|
|
68
|
+
if (options.maxResults) {
|
|
69
|
+
params.set('maxResults', String(options.maxResults));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const queryString = params.toString();
|
|
73
|
+
const path = `/project/search${queryString ? `?${queryString}` : ''}`;
|
|
74
|
+
|
|
75
|
+
interface ProjectSearchResponse {
|
|
76
|
+
values: Array<{
|
|
77
|
+
id: string;
|
|
78
|
+
key: string;
|
|
79
|
+
name: string;
|
|
80
|
+
projectTypeKey: string;
|
|
81
|
+
simplified: boolean;
|
|
82
|
+
style: string;
|
|
83
|
+
isPrivate: boolean;
|
|
84
|
+
avatarUrls?: Record<string, string>;
|
|
85
|
+
}>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const response = await this.request<ProjectSearchResponse>('GET', path);
|
|
89
|
+
|
|
90
|
+
return response.values.map((p) => ({
|
|
91
|
+
id: p.id,
|
|
92
|
+
key: p.key,
|
|
93
|
+
name: p.name,
|
|
94
|
+
projectTypeKey: p.projectTypeKey,
|
|
95
|
+
simplified: p.simplified,
|
|
96
|
+
style: p.style,
|
|
97
|
+
isPrivate: p.isPrivate,
|
|
98
|
+
avatarUrls: p.avatarUrls,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async searchIssues(options: JiraSearchOptions = {}): Promise<JiraIssue[]> {
|
|
103
|
+
// Build JQL query
|
|
104
|
+
const jqlParts: string[] = [];
|
|
105
|
+
|
|
106
|
+
if (options.jql) {
|
|
107
|
+
jqlParts.push(options.jql);
|
|
108
|
+
}
|
|
109
|
+
if (options.project) {
|
|
110
|
+
jqlParts.push(`project = "${options.project}"`);
|
|
111
|
+
}
|
|
112
|
+
if (options.status) {
|
|
113
|
+
jqlParts.push(`status = "${options.status}"`);
|
|
114
|
+
}
|
|
115
|
+
if (options.assignee) {
|
|
116
|
+
jqlParts.push(`assignee = "${options.assignee}"`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const jql = jqlParts.join(' AND ') || 'ORDER BY created DESC';
|
|
120
|
+
const maxResults = options.maxResults || 50;
|
|
121
|
+
|
|
122
|
+
const params = new URLSearchParams();
|
|
123
|
+
params.set('jql', jql);
|
|
124
|
+
params.set('maxResults', String(maxResults));
|
|
125
|
+
params.set('fields', 'summary,status,priority,assignee,reporter,created,updated,project,issuetype,description');
|
|
126
|
+
|
|
127
|
+
interface SearchResponse {
|
|
128
|
+
issues: Array<{
|
|
129
|
+
id: string;
|
|
130
|
+
key: string;
|
|
131
|
+
fields: {
|
|
132
|
+
summary: string;
|
|
133
|
+
status: {
|
|
134
|
+
name: string;
|
|
135
|
+
statusCategory: { key: string };
|
|
136
|
+
};
|
|
137
|
+
priority?: { name: string };
|
|
138
|
+
assignee?: { displayName: string };
|
|
139
|
+
reporter?: { displayName: string };
|
|
140
|
+
created: string;
|
|
141
|
+
updated: string;
|
|
142
|
+
project: { key: string };
|
|
143
|
+
issuetype: { name: string };
|
|
144
|
+
description?: unknown;
|
|
145
|
+
};
|
|
146
|
+
}>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const response = await this.request<SearchResponse>('GET', `/search/jql?${params.toString()}`);
|
|
150
|
+
|
|
151
|
+
return response.issues.map((issue) => ({
|
|
152
|
+
id: issue.id,
|
|
153
|
+
key: issue.key,
|
|
154
|
+
summary: issue.fields.summary,
|
|
155
|
+
status: issue.fields.status.name,
|
|
156
|
+
statusCategoryKey: issue.fields.status.statusCategory.key,
|
|
157
|
+
priority: issue.fields.priority?.name,
|
|
158
|
+
assignee: issue.fields.assignee?.displayName,
|
|
159
|
+
reporter: issue.fields.reporter?.displayName,
|
|
160
|
+
created: issue.fields.created,
|
|
161
|
+
updated: issue.fields.updated,
|
|
162
|
+
projectKey: issue.fields.project.key,
|
|
163
|
+
issueType: issue.fields.issuetype.name,
|
|
164
|
+
description: this.extractTextFromAdf(issue.fields.description),
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async getIssue(issueKey: string): Promise<JiraIssue> {
|
|
169
|
+
interface IssueResponse {
|
|
170
|
+
id: string;
|
|
171
|
+
key: string;
|
|
172
|
+
fields: {
|
|
173
|
+
summary: string;
|
|
174
|
+
status: {
|
|
175
|
+
name: string;
|
|
176
|
+
statusCategory: { key: string };
|
|
177
|
+
};
|
|
178
|
+
priority?: { name: string };
|
|
179
|
+
assignee?: { displayName: string };
|
|
180
|
+
reporter?: { displayName: string };
|
|
181
|
+
created: string;
|
|
182
|
+
updated: string;
|
|
183
|
+
project: { key: string };
|
|
184
|
+
issuetype: { name: string };
|
|
185
|
+
description?: unknown;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const response = await this.request<IssueResponse>('GET', `/issue/${issueKey}`);
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
id: response.id,
|
|
193
|
+
key: response.key,
|
|
194
|
+
summary: response.fields.summary,
|
|
195
|
+
status: response.fields.status.name,
|
|
196
|
+
statusCategoryKey: response.fields.status.statusCategory.key,
|
|
197
|
+
priority: response.fields.priority?.name,
|
|
198
|
+
assignee: response.fields.assignee?.displayName,
|
|
199
|
+
reporter: response.fields.reporter?.displayName,
|
|
200
|
+
created: response.fields.created,
|
|
201
|
+
updated: response.fields.updated,
|
|
202
|
+
projectKey: response.fields.project.key,
|
|
203
|
+
issueType: response.fields.issuetype.name,
|
|
204
|
+
description: this.extractTextFromAdf(response.fields.description),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async getTransitions(issueKey: string): Promise<JiraTransition[]> {
|
|
209
|
+
interface TransitionsResponse {
|
|
210
|
+
transitions: Array<{
|
|
211
|
+
id: string;
|
|
212
|
+
name: string;
|
|
213
|
+
to: {
|
|
214
|
+
id: string;
|
|
215
|
+
name: string;
|
|
216
|
+
statusCategory: {
|
|
217
|
+
key: string;
|
|
218
|
+
name: string;
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
}>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const response = await this.request<TransitionsResponse>('GET', `/issue/${issueKey}/transitions`);
|
|
225
|
+
|
|
226
|
+
return response.transitions.map((t) => ({
|
|
227
|
+
id: t.id,
|
|
228
|
+
name: t.name,
|
|
229
|
+
to: {
|
|
230
|
+
id: t.to.id,
|
|
231
|
+
name: t.to.name,
|
|
232
|
+
statusCategory: {
|
|
233
|
+
key: t.to.statusCategory.key,
|
|
234
|
+
name: t.to.statusCategory.name,
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async transitionIssue(issueKey: string, transitionId: string): Promise<JiraTransitionResult> {
|
|
241
|
+
// Get transitions first to find the name
|
|
242
|
+
const transitions = await this.getTransitions(issueKey);
|
|
243
|
+
const transition = transitions.find((t) => t.id === transitionId);
|
|
244
|
+
|
|
245
|
+
if (!transition) {
|
|
246
|
+
throw new CliError('NOT_FOUND', `Transition "${transitionId}" not found or not available for this issue`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await this.request<void>('POST', `/issue/${issueKey}/transitions`, {
|
|
250
|
+
transition: { id: transitionId },
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
issueKey,
|
|
255
|
+
transitionName: transition.name,
|
|
256
|
+
newStatus: transition.to.name,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async addComment(issueKey: string, body: string): Promise<JiraCommentResult> {
|
|
261
|
+
// JIRA v3 API requires Atlassian Document Format (ADF)
|
|
262
|
+
const adfBody = this.textToAdf(body);
|
|
263
|
+
|
|
264
|
+
interface CommentResponse {
|
|
265
|
+
id: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const response = await this.request<CommentResponse>('POST', `/issue/${issueKey}/comment`, {
|
|
269
|
+
body: adfBody,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
id: response.id,
|
|
274
|
+
issueKey,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async getComments(issueKey: string): Promise<JiraComment[]> {
|
|
279
|
+
interface CommentsResponse {
|
|
280
|
+
comments: Array<{
|
|
281
|
+
id: string;
|
|
282
|
+
author: { displayName: string };
|
|
283
|
+
body: unknown;
|
|
284
|
+
created: string;
|
|
285
|
+
updated: string;
|
|
286
|
+
}>;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const response = await this.request<CommentsResponse>('GET', `/issue/${issueKey}/comment`);
|
|
290
|
+
|
|
291
|
+
return response.comments.map((c) => ({
|
|
292
|
+
id: c.id,
|
|
293
|
+
author: c.author.displayName,
|
|
294
|
+
body: this.extractTextFromAdf(c.body),
|
|
295
|
+
created: c.created,
|
|
296
|
+
updated: c.updated,
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Convert plain text to Atlassian Document Format (ADF)
|
|
301
|
+
private textToAdf(text: string): object {
|
|
302
|
+
const paragraphs = text.split('\n\n');
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
version: 1,
|
|
306
|
+
type: 'doc',
|
|
307
|
+
content: paragraphs.map((paragraph) => ({
|
|
308
|
+
type: 'paragraph',
|
|
309
|
+
content: paragraph.split('\n').flatMap((line, index, array) => {
|
|
310
|
+
const parts: object[] = [{ type: 'text', text: line }];
|
|
311
|
+
if (index < array.length - 1) {
|
|
312
|
+
parts.push({ type: 'hardBreak' });
|
|
313
|
+
}
|
|
314
|
+
return parts;
|
|
315
|
+
}),
|
|
316
|
+
})),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Extract plain text from Atlassian Document Format (ADF)
|
|
321
|
+
private extractTextFromAdf(adf: unknown): string {
|
|
322
|
+
if (!adf || typeof adf !== 'object') {
|
|
323
|
+
return '';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const doc = adf as { content?: Array<{ type: string; content?: Array<{ text?: string; type?: string }> }> };
|
|
327
|
+
if (!doc.content) {
|
|
328
|
+
return '';
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const extractText = (node: unknown): string => {
|
|
332
|
+
if (!node || typeof node !== 'object') return '';
|
|
333
|
+
|
|
334
|
+
const n = node as { type?: string; text?: string; content?: unknown[] };
|
|
335
|
+
|
|
336
|
+
if (n.type === 'text' && n.text) {
|
|
337
|
+
return n.text;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (n.type === 'hardBreak') {
|
|
341
|
+
return '\n';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (Array.isArray(n.content)) {
|
|
345
|
+
return n.content.map(extractText).join('');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return '';
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
return doc.content
|
|
352
|
+
.map((block) => extractText(block))
|
|
353
|
+
.join('\n\n');
|
|
354
|
+
}
|
|
355
|
+
}
|