@taodigital/connectors 0.0.0-stage → 1.2.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/github.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * GitHub connector — credentials passed in, never read from env.
3
+ * Token is stored encrypted in Connector.config per project.
4
+ */
5
+
6
+ import { assertResolvesToPublicAddress } from '@taodigital/core';
7
+
8
+ export interface GitHubCredentials {
9
+ token: string;
10
+ repo: string; // "owner/repository"
11
+ defaultBranch?: string;
12
+ }
13
+
14
+ /** Test that the stored credentials actually work. */
15
+ export async function testConnection(creds: GitHubCredentials): Promise<{ ok: boolean; login?: string; error?: string }> {
16
+ try {
17
+ await assertResolvesToPublicAddress('https://api.github.com');
18
+ const res = await fetch('https://api.github.com/user', {
19
+ headers: { Authorization: `Bearer ${creds.token}`, 'User-Agent': 'zeta-ai' },
20
+ });
21
+ if (!res.ok) return { ok: false, error: `GitHub returned ${res.status}` };
22
+ const data = (await res.json()) as any;
23
+ return { ok: true, login: data.login };
24
+ } catch (err) {
25
+ return { ok: false, error: String(err) };
26
+ }
27
+ }
28
+
29
+ export async function listRecentCommits(creds: GitHubCredentials, branch?: string) {
30
+ await assertResolvesToPublicAddress('https://api.github.com');
31
+ const { getSystemConfig } = await import('@taodigital/core');
32
+ const ghCfg = await getSystemConfig('connectors_github');
33
+ const pageSize = parseInt(String(ghCfg?.pageSize ?? '10'), 10);
34
+ const ref = branch ?? creds.defaultBranch ?? 'main';
35
+ const res = await fetch(
36
+ `https://api.github.com/repos/${creds.repo}/commits?sha=${ref}&per_page=${pageSize}`,
37
+ { headers: { Authorization: `Bearer ${creds.token}`, 'User-Agent': 'zeta-ai' } }
38
+ );
39
+ if (!res.ok) throw new Error(`GitHub error: ${res.status}`);
40
+ const commits = (await res.json()) as any[];
41
+ return commits.map((c) => ({
42
+ sha: c.sha?.slice(0, 7),
43
+ message: c.commit?.message?.split('\n')[0],
44
+ author: c.commit?.author?.name,
45
+ date: c.commit?.author?.date,
46
+ }));
47
+ }
48
+
49
+ export async function createIssue(creds: GitHubCredentials, title: string, body: string, labels?: string[], opts?: { defaultLabels?: string[] }) {
50
+ await assertResolvesToPublicAddress('https://api.github.com');
51
+ const res = await fetch(
52
+ `https://api.github.com/repos/${creds.repo}/issues`,
53
+ {
54
+ method: 'POST',
55
+ headers: {
56
+ Authorization: `Bearer ${creds.token}`,
57
+ 'User-Agent': 'zeta-ai',
58
+ 'Content-Type': 'application/json',
59
+ },
60
+ body: JSON.stringify({ title, body, labels: labels ?? opts?.defaultLabels ?? [] }),
61
+ }
62
+ );
63
+ if (!res.ok) throw new Error(`GitHub error: ${res.status}`);
64
+ const issue = (await res.json()) as any;
65
+ return { number: issue.number, url: issue.html_url, title: issue.title };
66
+ }
67
+
68
+ export async function listPullRequests(creds: GitHubCredentials, state: 'open' | 'closed' | 'all' = 'open') {
69
+ await assertResolvesToPublicAddress('https://api.github.com');
70
+ const { getSystemConfig } = await import('@taodigital/core');
71
+ const ghCfg = await getSystemConfig('connectors_github');
72
+ const pageSize = parseInt(String(ghCfg?.pageSize ?? '10'), 10);
73
+ const res = await fetch(
74
+ `https://api.github.com/repos/${creds.repo}/pulls?state=${state}&per_page=${pageSize}`,
75
+ { headers: { Authorization: `Bearer ${creds.token}`, 'User-Agent': 'zeta-ai' } }
76
+ );
77
+ if (!res.ok) throw new Error(`GitHub error: ${res.status}`);
78
+ const prs = (await res.json()) as any[];
79
+ return prs.map((p) => ({
80
+ number: p.number,
81
+ title: p.title,
82
+ state: p.state,
83
+ branch: p.head?.ref,
84
+ url: p.html_url,
85
+ }));
86
+ }
87
+
88
+ /**
89
+ * Push (create or update) a single file in a GitHub repository.
90
+ * Uses the GitHub Contents API (PUT /repos/:owner/:repo/contents/:path).
91
+ */
92
+ export async function pushFile(
93
+ creds: GitHubCredentials,
94
+ filePath: string,
95
+ content: string,
96
+ commitMessage: string,
97
+ branch?: string,
98
+ ): Promise<{ url: string; sha: string }> {
99
+ await assertResolvesToPublicAddress('https://api.github.com');
100
+ const targetBranch = branch ?? creds.defaultBranch ?? 'main';
101
+ const apiBase = 'https://api.github.com';
102
+ const headers: Record<string, string> = {
103
+ Authorization: `Bearer ${creds.token}`,
104
+ Accept: 'application/vnd.github.v3+json',
105
+ 'Content-Type': 'application/json',
106
+ 'User-Agent': 'zeta-ai',
107
+ };
108
+ const encodedContent = Buffer.from(content, 'utf8').toString('base64');
109
+
110
+ // Fetch existing file SHA (required by GitHub API to update an existing file)
111
+ let sha: string | undefined;
112
+ const existing = await fetch(
113
+ `${apiBase}/repos/${creds.repo}/contents/${filePath}?ref=${targetBranch}`,
114
+ { headers },
115
+ ).then((r) => (r.ok ? r.json() : null)).catch(() => null) as any;
116
+ if (existing?.sha) sha = existing.sha;
117
+
118
+ const body: Record<string, unknown> = {
119
+ message: commitMessage,
120
+ content: encodedContent,
121
+ branch: targetBranch,
122
+ };
123
+ if (sha) body.sha = sha;
124
+
125
+ const res = await fetch(`${apiBase}/repos/${creds.repo}/contents/${filePath}`, {
126
+ method: 'PUT',
127
+ headers,
128
+ body: JSON.stringify(body),
129
+ });
130
+ if (!res.ok) {
131
+ const err = await res.json().catch(() => ({}) as any) as any;
132
+ throw new Error(`GitHub push failed: ${err.message ?? res.statusText}`);
133
+ }
134
+ const data = await res.json() as any;
135
+ return {
136
+ url: data.content?.html_url ?? `https://github.com/${creds.repo}/blob/${targetBranch}/${filePath}`,
137
+ sha: data.content?.sha ?? '',
138
+ };
139
+ }
package/src/gitlab.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * GitLab connector — credentials passed in, never read from env.
3
+ * Token (Personal Access Token or project token) stored encrypted
4
+ * in Connector.config per project. Supports gitlab.com and self-hosted.
5
+ */
6
+
7
+ import { assertResolvesToPublicAddress } from '@taodigital/core';
8
+
9
+ export interface GitLabCredentials {
10
+ token: string;
11
+ projectPath: string; // "namespace/project" or numeric project ID
12
+ baseUrl?: string; // defaults to https://gitlab.com
13
+ defaultBranch?: string;
14
+ }
15
+
16
+ function apiBase(creds: GitLabCredentials): string {
17
+ const host = (creds.baseUrl ?? 'https://gitlab.com').replace(/\/$/, '');
18
+ return `${host}/api/v4`;
19
+ }
20
+
21
+ function authHeaders(token: string): Record<string, string> {
22
+ return { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json' };
23
+ }
24
+
25
+ /** URL-encode a project path for use in GitLab API paths. */
26
+ function encodeProject(projectPath: string): string {
27
+ return encodeURIComponent(projectPath);
28
+ }
29
+
30
+ export async function testConnection(
31
+ creds: GitLabCredentials,
32
+ ): Promise<{ ok: boolean; login?: string; error?: string }> {
33
+ try {
34
+ const host = creds.baseUrl ?? 'https://gitlab.com';
35
+ await assertResolvesToPublicAddress(host);
36
+ const base = apiBase(creds);
37
+ const res = await fetch(`${base}/projects/${encodeProject(creds.projectPath)}`, {
38
+ headers: authHeaders(creds.token),
39
+ signal: AbortSignal.timeout(10_000),
40
+ });
41
+ if (res.status === 401) return { ok: false, error: 'Invalid GitLab token.' };
42
+ if (res.status === 404) return { ok: false, error: `Project "${creds.projectPath}" not found or token lacks access.` };
43
+ if (!res.ok) return { ok: false, error: `GitLab returned ${res.status}.` };
44
+ const project = (await res.json()) as any;
45
+ return { ok: true, login: project.path_with_namespace ?? creds.projectPath };
46
+ } catch (err: any) {
47
+ return { ok: false, error: err?.message ?? 'Could not reach GitLab.' };
48
+ }
49
+ }
50
+
51
+ export async function listRecentCommits(creds: GitLabCredentials, branch?: string) {
52
+ const host = creds.baseUrl ?? 'https://gitlab.com';
53
+ await assertResolvesToPublicAddress(host);
54
+ const { getSystemConfig } = await import('@taodigital/core');
55
+ const cfg = await getSystemConfig('connectors_gitlab');
56
+ const pageSize = parseInt(String(cfg?.pageSize ?? '10'), 10);
57
+ const ref = branch ?? creds.defaultBranch ?? 'main';
58
+ const base = apiBase(creds);
59
+ const res = await fetch(
60
+ `${base}/projects/${encodeProject(creds.projectPath)}/repository/commits?ref_name=${encodeURIComponent(ref)}&per_page=${pageSize}`,
61
+ { headers: authHeaders(creds.token), signal: AbortSignal.timeout(15_000) },
62
+ );
63
+ if (!res.ok) throw new Error(`GitLab error: ${res.status}`);
64
+ const commits = (await res.json()) as any[];
65
+ return commits.map((c) => ({
66
+ sha: c.id?.slice(0, 7),
67
+ message: c.title ?? c.message?.split('\n')[0],
68
+ author: c.author_name,
69
+ date: c.created_at,
70
+ }));
71
+ }
72
+
73
+ export async function createIssue(
74
+ creds: GitLabCredentials,
75
+ title: string,
76
+ body: string,
77
+ labels?: string[],
78
+ ): Promise<{ number: number; url: string; title: string }> {
79
+ const host = creds.baseUrl ?? 'https://gitlab.com';
80
+ await assertResolvesToPublicAddress(host);
81
+ const base = apiBase(creds);
82
+ const res = await fetch(`${base}/projects/${encodeProject(creds.projectPath)}/issues`, {
83
+ method: 'POST',
84
+ headers: authHeaders(creds.token),
85
+ body: JSON.stringify({ title, description: body, labels: labels?.join(',') }),
86
+ signal: AbortSignal.timeout(15_000),
87
+ });
88
+ if (!res.ok) throw new Error(`GitLab error: ${res.status}`);
89
+ const issue = (await res.json()) as any;
90
+ return { number: issue.iid, url: issue.web_url, title: issue.title };
91
+ }
92
+
93
+ export async function listMergeRequests(
94
+ creds: GitLabCredentials,
95
+ state: 'opened' | 'closed' | 'merged' | 'all' = 'opened',
96
+ ) {
97
+ const host = creds.baseUrl ?? 'https://gitlab.com';
98
+ await assertResolvesToPublicAddress(host);
99
+ const { getSystemConfig } = await import('@taodigital/core');
100
+ const cfg = await getSystemConfig('connectors_gitlab');
101
+ const pageSize = parseInt(String(cfg?.pageSize ?? '10'), 10);
102
+ const base = apiBase(creds);
103
+ const res = await fetch(
104
+ `${base}/projects/${encodeProject(creds.projectPath)}/merge_requests?state=${state}&per_page=${pageSize}`,
105
+ { headers: authHeaders(creds.token), signal: AbortSignal.timeout(15_000) },
106
+ );
107
+ if (!res.ok) throw new Error(`GitLab error: ${res.status}`);
108
+ const mrs = (await res.json()) as any[];
109
+ return mrs.map((m) => ({
110
+ number: m.iid,
111
+ title: m.title,
112
+ state: m.state,
113
+ branch: m.source_branch,
114
+ url: m.web_url,
115
+ }));
116
+ }
117
+
118
+ /**
119
+ * Push (create or update) a single file in a GitLab repository.
120
+ * Uses the GitLab Repository Files API.
121
+ */
122
+ export async function pushFile(
123
+ creds: GitLabCredentials,
124
+ filePath: string,
125
+ content: string,
126
+ commitMessage: string,
127
+ branch?: string,
128
+ ): Promise<{ url: string; sha: string }> {
129
+ const host = creds.baseUrl ?? 'https://gitlab.com';
130
+ await assertResolvesToPublicAddress(host);
131
+ const targetBranch = branch ?? creds.defaultBranch ?? 'main';
132
+ const base = apiBase(creds);
133
+ const encodedProject = encodeProject(creds.projectPath);
134
+ const encodedPath = encodeURIComponent(filePath);
135
+ const encodedContent = Buffer.from(content, 'utf8').toString('base64');
136
+
137
+ // Check if file exists to decide between POST (create) and PUT (update)
138
+ const existing = await fetch(
139
+ `${base}/projects/${encodedProject}/repository/files/${encodedPath}?ref=${encodeURIComponent(targetBranch)}`,
140
+ { headers: authHeaders(creds.token), signal: AbortSignal.timeout(10_000) },
141
+ ).catch(() => null);
142
+ const fileExists = existing?.ok ?? false;
143
+
144
+ const method = fileExists ? 'PUT' : 'POST';
145
+ const res = await fetch(`${base}/projects/${encodedProject}/repository/files/${encodedPath}`, {
146
+ method,
147
+ headers: authHeaders(creds.token),
148
+ body: JSON.stringify({
149
+ branch: targetBranch,
150
+ content: encodedContent,
151
+ encoding: 'base64',
152
+ commit_message: commitMessage,
153
+ }),
154
+ signal: AbortSignal.timeout(20_000),
155
+ });
156
+ if (!res.ok) {
157
+ const err = await res.json().catch(() => ({}) as any) as any;
158
+ throw new Error(`GitLab push failed: ${err.message ?? res.statusText}`);
159
+ }
160
+ const data = (await res.json()) as any;
161
+ const glBase = (creds.baseUrl ?? 'https://gitlab.com').replace(/\/$/, '');
162
+ return {
163
+ url: `${glBase}/${creds.projectPath}/-/blob/${targetBranch}/${filePath}`,
164
+ sha: data.file_path ?? filePath,
165
+ };
166
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * as github from './github.js';
2
+ export * as gitlab from './gitlab.js';
3
+ export * as azuredevops from './azuredevops.js';
4
+ export * as jira from './jira.js';
5
+ export * as figma from './figma.js';
6
+ export * as confluence from './confluence.js';
7
+ export * as pagerduty from './pagerduty.js';
8
+ export * as servicenow from './servicenow.js';
9
+ export * as salesforce from './salesforce.js';
package/src/jira.ts ADDED
@@ -0,0 +1,372 @@
1
+ /**
2
+ * JIRA connector — credentials passed in, never read from env.
3
+ * baseUrl and email stored as-is; apiToken stored encrypted.
4
+ */
5
+
6
+ import { assertResolvesToPublicAddress } from '@taodigital/core';
7
+
8
+ export interface JiraCredentials {
9
+ baseUrl: string; // e.g. https://yourcompany.atlassian.net or https://api.atlassian.com/ex/jira/{cloudId}
10
+ email: string; // Atlassian email (Cloud) or username (Server/Data Center)
11
+ apiToken: string; // Atlassian API token (Cloud)
12
+ password?: string; // Password (Server/Data Center) — takes priority over apiToken when set
13
+ projectKey?: string; // e.g. "APP" or "QA"
14
+ bearerToken?: string; // OAuth 2.0 access token — takes priority over Basic auth
15
+ proxyUrl?: string; // e.g. http://corporate-proxy:8080 — enables on-prem JIRA access, skips SSRF guard
16
+ }
17
+
18
+ function validateProxyUrl(proxyUrl: string): void {
19
+ let parsed: URL;
20
+ try { parsed = new URL(proxyUrl); } catch { throw new Error(`Invalid proxy URL: ${proxyUrl}`); }
21
+ const h = parsed.hostname.toLowerCase();
22
+ // Block loopback, link-local metadata
23
+ if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '169.254.169.254') {
24
+ throw new Error('Proxy URL must not target localhost or metadata addresses');
25
+ }
26
+ // Block RFC-1918 private ranges (10/8, 172.16-31/12, 192.168/16)
27
+ const parts = h.split('.').map(Number);
28
+ if (parts.length === 4 && !parts.some(isNaN)) {
29
+ const [a, b] = parts;
30
+ if (a === 10) throw new Error('Proxy URL must not target private network (10.0.0.0/8)');
31
+ if (a === 172 && b >= 16 && b <= 31) throw new Error('Proxy URL must not target private network (172.16.0.0/12)');
32
+ if (a === 192 && b === 168) throw new Error('Proxy URL must not target private network (192.168.0.0/16)');
33
+ }
34
+ }
35
+
36
+ async function makeFetch(creds: JiraCredentials): Promise<typeof fetch> {
37
+ const TIMEOUT_MS = 15_000;
38
+ if (creds.proxyUrl) {
39
+ validateProxyUrl(creds.proxyUrl);
40
+ const { ProxyAgent } = await import('undici');
41
+ const dispatcher = new ProxyAgent(creds.proxyUrl);
42
+ return (url, init) => fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS), ...(init ?? {}), dispatcher } as any);
43
+ }
44
+ // Wrap global fetch with a 15s timeout on every call
45
+ return (url, init) => fetch(url as any, { signal: AbortSignal.timeout(TIMEOUT_MS), ...(init ?? {}) });
46
+ }
47
+
48
+ function authHeader(creds: JiraCredentials): string {
49
+ if (creds.bearerToken) return `Bearer ${creds.bearerToken}`;
50
+ const secret = creds.password || creds.apiToken; // password = Server/DC, apiToken = Cloud
51
+ return 'Basic ' + Buffer.from(`${creds.email}:${secret}`).toString('base64');
52
+ }
53
+
54
+ export async function testConnection(creds: JiraCredentials): Promise<{ ok: boolean; displayName?: string; error?: string }> {
55
+ try {
56
+ const doFetch = await makeFetch(creds);
57
+ if (!creds.proxyUrl) await assertResolvesToPublicAddress(creds.baseUrl);
58
+ const res = await doFetch(`${creds.baseUrl}/rest/api/3/myself`, {
59
+ headers: { Authorization: authHeader(creds), Accept: 'application/json' },
60
+ });
61
+ if (!res.ok) return { ok: false, error: `JIRA returned ${res.status}` };
62
+ const data = (await res.json()) as any;
63
+ return { ok: true, displayName: data.displayName };
64
+ } catch (err) {
65
+ return { ok: false, error: String(err) };
66
+ }
67
+ }
68
+
69
+ export async function listProjects(creds: JiraCredentials) {
70
+ const doFetch = await makeFetch(creds);
71
+ if (!creds.proxyUrl) await assertResolvesToPublicAddress(creds.baseUrl);
72
+ const { getSystemConfig } = await import('@taodigital/core');
73
+ const jiraCfg = await getSystemConfig('connectors_jira');
74
+ const pageSize = parseInt(String(jiraCfg?.pageSize ?? '50'), 10);
75
+ const MAX_PAGES = 10;
76
+ const allProjects: any[] = [];
77
+ let startAt = 0;
78
+ let truncated = false;
79
+
80
+ for (let page = 0; page < MAX_PAGES; page++) {
81
+ const res = await doFetch(`${creds.baseUrl}/rest/api/3/project/search?maxResults=${pageSize}&startAt=${startAt}`, {
82
+ headers: { Authorization: authHeader(creds), Accept: 'application/json' },
83
+ });
84
+ if (!res.ok) throw new Error(`JIRA error: ${res.status}`);
85
+ const data = (await res.json()) as any;
86
+ const values: any[] = data.values ?? [];
87
+ allProjects.push(...values);
88
+ const total: number = data.total ?? 0;
89
+ if (values.length < pageSize || allProjects.length >= total) break;
90
+ startAt += values.length;
91
+ if (page === MAX_PAGES - 1) {
92
+ truncated = true;
93
+ console.warn(`listProjects: total=${total} exceeds page cap (${MAX_PAGES * pageSize}); results truncated`);
94
+ break;
95
+ }
96
+ }
97
+
98
+ return allProjects.map((p: any) => ({ key: p.key, name: p.name, id: p.id }));
99
+ }
100
+
101
+ export async function listRecentIssues(creds: JiraCredentials, maxResults?: number) {
102
+ const doFetch = await makeFetch(creds);
103
+ if (!creds.proxyUrl) await assertResolvesToPublicAddress(creds.baseUrl);
104
+ const { getSystemConfig } = await import('@taodigital/core');
105
+ const jiraCfg = await getSystemConfig('connectors_jira');
106
+ const resolvedMaxResults = maxResults ?? parseInt(String(jiraCfg?.recentPageSize ?? '20'), 10);
107
+ const jql = creds.projectKey
108
+ ? `project = "${(creds.projectKey ?? '').replace(/"/g, '\\"')}" ORDER BY updated DESC`
109
+ : 'ORDER BY updated DESC';
110
+
111
+ const res = await doFetch(
112
+ `${creds.baseUrl}/rest/api/3/search?jql=${encodeURIComponent(jql)}&maxResults=${resolvedMaxResults}&fields=summary,status,priority,assignee,updated`,
113
+ { headers: { Authorization: authHeader(creds), Accept: 'application/json' } }
114
+ );
115
+ if (!res.ok) throw new Error(`JIRA error: ${res.status}`);
116
+ const data = (await res.json()) as any;
117
+ return (data.issues ?? []).map((i: any) => ({
118
+ key: i.key,
119
+ summary: i.fields?.summary,
120
+ status: i.fields?.status?.name,
121
+ priority: i.fields?.priority?.name,
122
+ assignee: i.fields?.assignee?.displayName,
123
+ updated: i.fields?.updated,
124
+ }));
125
+ }
126
+
127
+ export interface GetIssueOptions {
128
+ acceptanceCriteriaFieldId?: string;
129
+ }
130
+
131
+ export async function getIssue(creds: JiraCredentials, issueKey: string, options: GetIssueOptions = {}) {
132
+ const doFetch = await makeFetch(creds);
133
+ if (!creds.proxyUrl) await assertResolvesToPublicAddress(creds.baseUrl);
134
+ const { getSystemConfig } = await import('@taodigital/core');
135
+ const jiraCfg = await getSystemConfig('connectors_jira');
136
+ const acField = options.acceptanceCriteriaFieldId ?? String(jiraCfg?.acFieldId ?? '');
137
+ const fieldsParam = `summary,description,status,priority,issuetype,labels,components,issuelinks,parent,subtasks${acField ? `,${acField}` : ''}`;
138
+ const res = await doFetch(`${creds.baseUrl}/rest/api/3/issue/${issueKey}?fields=${encodeURIComponent(fieldsParam)}`, {
139
+ headers: { Authorization: authHeader(creds), Accept: 'application/json' },
140
+ });
141
+ if (!res.ok) throw new Error(`JIRA error: ${res.status}`);
142
+ const i = (await res.json()) as any;
143
+ return {
144
+ key: i.key,
145
+ summary: i.fields?.summary,
146
+ description: i.fields?.description,
147
+ status: i.fields?.status?.name,
148
+ priority: i.fields?.priority?.name,
149
+ issueType: i.fields?.issuetype?.name,
150
+ acceptanceCriteria: acField ? (i.fields?.[acField] ?? null) : null,
151
+ labels: i.fields?.labels ?? [],
152
+ components: (i.fields?.components ?? []).map((c: any) => c.name),
153
+ epic: i.fields?.parent ? { key: i.fields.parent.key, summary: i.fields.parent.fields?.summary } : null,
154
+ links: (i.fields?.issuelinks ?? []).map((l: any) => ({
155
+ type: l.type?.name ?? l.type?.inward ?? l.type?.outward ?? 'relates',
156
+ key: l.inwardIssue?.key ?? l.outwardIssue?.key,
157
+ summary: l.inwardIssue?.fields?.summary ?? l.outwardIssue?.fields?.summary,
158
+ })).filter((x: any) => Boolean(x.key)),
159
+ subtasks: (i.fields?.subtasks ?? []).map((st: any) => ({ key: st.key, summary: st.fields?.summary })),
160
+ url: `${creds.baseUrl}/browse/${i.key}`,
161
+ };
162
+ }
163
+
164
+ export async function createBug(creds: JiraCredentials, opts: {
165
+ summary: string;
166
+ description: string;
167
+ priority?: 'Highest' | 'High' | 'Medium' | 'Low' | 'Lowest';
168
+ labels?: string[];
169
+ defaultBugPriority?: string;
170
+ defaultBugLabel?: string;
171
+ }) {
172
+ const doFetch = await makeFetch(creds);
173
+ if (!creds.proxyUrl) await assertResolvesToPublicAddress(creds.baseUrl);
174
+ if (!creds.projectKey) throw new Error('projectKey required to create JIRA issues.');
175
+ const res = await doFetch(`${creds.baseUrl}/rest/api/3/issue`, {
176
+ method: 'POST',
177
+ headers: {
178
+ Authorization: authHeader(creds),
179
+ Accept: 'application/json',
180
+ 'Content-Type': 'application/json',
181
+ },
182
+ body: JSON.stringify({
183
+ fields: {
184
+ project: { key: creds.projectKey },
185
+ summary: opts.summary,
186
+ description: {
187
+ type: 'doc',
188
+ version: 1,
189
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: opts.description }] }],
190
+ },
191
+ issuetype: { name: 'Bug' },
192
+ priority: { name: opts.priority ?? opts.defaultBugPriority ?? 'High' },
193
+ labels: opts.labels ?? (opts.defaultBugLabel ? [opts.defaultBugLabel] : []),
194
+ },
195
+ }),
196
+ });
197
+ if (!res.ok) throw new Error(`JIRA error: ${res.status} ${await res.text()}`);
198
+ const created = (await res.json()) as any;
199
+ return { key: created.key, url: `${creds.baseUrl}/browse/${created.key}` };
200
+ }
201
+
202
+ export interface ListUserStoriesOptions {
203
+ acceptanceCriteriaFieldId?: string;
204
+ }
205
+
206
+ export async function listUserStories(creds: JiraCredentials, sprintId?: string, maxResults?: number, options: ListUserStoriesOptions = {}) {
207
+ const doFetch = await makeFetch(creds);
208
+ if (!creds.proxyUrl) await assertResolvesToPublicAddress(creds.baseUrl);
209
+ const { getSystemConfig } = await import('@taodigital/core');
210
+ const jiraCfg = await getSystemConfig('connectors_jira');
211
+ const pageSize = Math.min(maxResults ?? parseInt(String(jiraCfg?.pageSize ?? '50'), 10), 100);
212
+ const acField = options.acceptanceCriteriaFieldId ?? String(jiraCfg?.acFieldId ?? '');
213
+ const jql = sprintId
214
+ ? `project = "${(creds.projectKey ?? '').replace(/"/g, '\\"')}" AND sprint = ${sprintId} AND issuetype = Story ORDER BY priority DESC`
215
+ : `project = "${(creds.projectKey ?? '').replace(/"/g, '\\"')}" AND issuetype = Story ORDER BY updated DESC`;
216
+
217
+ const fieldsParam = `summary,description,status,priority,issuetype,labels,components,issuelinks,parent,subtasks${acField ? `,${acField}` : ''}`;
218
+ const MAX_PAGES = 20;
219
+ const allIssues: any[] = [];
220
+ let startAt = 0;
221
+ let truncated = false;
222
+
223
+ for (let page = 0; page < MAX_PAGES; page++) {
224
+ const res = await doFetch(
225
+ `${creds.baseUrl}/rest/api/3/search?jql=${encodeURIComponent(jql)}&maxResults=${pageSize}&startAt=${startAt}&fields=${encodeURIComponent(fieldsParam)}`,
226
+ { headers: { Authorization: authHeader(creds), Accept: 'application/json' } }
227
+ );
228
+ if (!res.ok) throw new Error(`JIRA error: ${res.status}`);
229
+ const data = (await res.json()) as any;
230
+ const issues: any[] = data.issues ?? [];
231
+ allIssues.push(...issues);
232
+ const total: number = data.total ?? 0;
233
+ if (issues.length < pageSize || allIssues.length >= total) break;
234
+ startAt += issues.length;
235
+ if (page === MAX_PAGES - 1) { truncated = true; break; }
236
+ }
237
+
238
+ const mapped = allIssues.map((i: any) => ({
239
+ key: i.key,
240
+ summary: i.fields?.summary,
241
+ description: i.fields?.description,
242
+ acceptanceCriteria: acField ? (i.fields?.[acField] ?? null) : null,
243
+ status: i.fields?.status?.name,
244
+ priority: i.fields?.priority?.name,
245
+ issueType: i.fields?.issuetype?.name,
246
+ labels: i.fields?.labels ?? [],
247
+ components: (i.fields?.components ?? []).map((c: any) => c.name),
248
+ epic: i.fields?.parent
249
+ ? { key: i.fields.parent.key, summary: i.fields.parent.summary ?? i.fields.parent.fields?.summary ?? null }
250
+ : null,
251
+ links: (i.fields?.issuelinks ?? []).map((l: any) => ({
252
+ type: l.type?.name ?? l.type?.inward ?? l.type?.outward ?? 'relates',
253
+ key: l.inwardIssue?.key ?? l.outwardIssue?.key,
254
+ summary: l.inwardIssue?.fields?.summary ?? l.outwardIssue?.fields?.summary,
255
+ })).filter((x: any) => Boolean(x.key)),
256
+ subtasks: (i.fields?.subtasks ?? []).map((st: any) => ({ key: st.key, summary: st.fields?.summary })),
257
+ url: `${creds.baseUrl}/browse/${i.key}`,
258
+ }));
259
+ return { stories: mapped, truncated };
260
+ }
261
+
262
+ export async function createTestCase(creds: JiraCredentials, opts: {
263
+ summary: string;
264
+ stepsHtml: string;
265
+ priority?: 'Highest' | 'High' | 'Medium' | 'Low' | 'Lowest';
266
+ labels?: string[];
267
+ defaultIssueType?: string;
268
+ defaultTestCaseLabel?: string;
269
+ }): Promise<{ key: string; url: string }> {
270
+ const doFetch = await makeFetch(creds);
271
+ if (!creds.proxyUrl) await assertResolvesToPublicAddress(creds.baseUrl);
272
+ if (!creds.projectKey) throw new Error('projectKey required to create Jira test cases.');
273
+ const { stepsHtml } = opts;
274
+ const stepLines = (stepsHtml ?? '')
275
+ .split(/<br\s*\/?>|<\/li>/i)
276
+ .map((s: string) => s.replace(/<[^>]+>/g, '').trim())
277
+ .filter(Boolean);
278
+ const stepsNode = stepLines.length > 0
279
+ ? {
280
+ type: 'orderedList',
281
+ content: stepLines.map((t: string) => ({
282
+ type: 'listItem',
283
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: t }] }]
284
+ }))
285
+ }
286
+ : { type: 'paragraph', content: [{ type: 'text', text: stepsHtml ?? '' }] };
287
+ const res = await doFetch(`${creds.baseUrl}/rest/api/3/issue`, {
288
+ method: 'POST',
289
+ headers: {
290
+ Authorization: authHeader(creds),
291
+ Accept: 'application/json',
292
+ 'Content-Type': 'application/json',
293
+ },
294
+ body: JSON.stringify({
295
+ fields: {
296
+ project: { key: creds.projectKey },
297
+ summary: opts.summary,
298
+ description: {
299
+ type: 'doc',
300
+ version: 1,
301
+ content: [stepsNode],
302
+ },
303
+ // 'Test' issuetype requires Xray; falls back to 'Story' if not found (Jira returns 400)
304
+ issuetype: { name: opts.defaultIssueType ?? 'Test' },
305
+ priority: { name: opts.priority ?? 'High' },
306
+ labels: (opts.labels ?? []).map((l) => l.replace(/^@/, '')),
307
+ },
308
+ }),
309
+ });
310
+
311
+ if (!res.ok) {
312
+ const errText = await res.text().catch(() => '');
313
+ // Xray 'Test' issue type not available — retry as Story
314
+ if (res.status === 400 && errText.includes('issuetype')) {
315
+ const retry = await doFetch(`${creds.baseUrl}/rest/api/3/issue`, {
316
+ method: 'POST',
317
+ headers: {
318
+ Authorization: authHeader(creds),
319
+ Accept: 'application/json',
320
+ 'Content-Type': 'application/json',
321
+ },
322
+ body: JSON.stringify({
323
+ fields: {
324
+ project: { key: creds.projectKey },
325
+ summary: opts.summary,
326
+ description: {
327
+ type: 'doc',
328
+ version: 1,
329
+ content: [stepsNode],
330
+ },
331
+ issuetype: { name: 'Story' },
332
+ priority: { name: opts.priority ?? 'High' },
333
+ labels: [...(opts.defaultTestCaseLabel ? [opts.defaultTestCaseLabel] : []), ...(opts.labels ?? []).map((l) => l.replace(/^@/, ''))],
334
+ },
335
+ }),
336
+ });
337
+ if (!retry.ok) throw new Error(`Jira error: ${retry.status} ${await retry.text().catch(() => '')}`);
338
+ const d = (await retry.json()) as any;
339
+ return { key: d.key, url: `${creds.baseUrl}/browse/${d.key}` };
340
+ }
341
+ throw new Error(`Jira error: ${res.status} ${errText.slice(0, 200)}`);
342
+ }
343
+
344
+ const created = (await res.json()) as any;
345
+ return { key: created.key, url: `${creds.baseUrl}/browse/${created.key}` };
346
+ }
347
+
348
+ /** Free-text search — finds issues that mention a screen URL or name. */
349
+ export async function searchIssues(creds: JiraCredentials, text: string, maxResults?: number) {
350
+ const doFetch = await makeFetch(creds);
351
+ if (!creds.proxyUrl) await assertResolvesToPublicAddress(creds.baseUrl);
352
+ const { getSystemConfig } = await import('@taodigital/core');
353
+ const jiraCfg = await getSystemConfig('connectors_jira');
354
+ const resolvedMaxResults = maxResults ?? parseInt(String(jiraCfg?.searchPageSize ?? '10'), 10);
355
+ const safeText = text.replace(/["\\]/g, ' ').trim();
356
+ const projectClause = creds.projectKey ? `project = "${(creds.projectKey ?? '').replace(/"/g, '\\"')}" AND ` : '';
357
+ const jql = `${projectClause}text ~ "${safeText}" ORDER BY updated DESC`;
358
+ const res = await doFetch(
359
+ `${creds.baseUrl}/rest/api/3/search?jql=${encodeURIComponent(jql)}&maxResults=${resolvedMaxResults}&fields=summary,status,priority,updated`,
360
+ { headers: { Authorization: authHeader(creds), Accept: 'application/json' } }
361
+ );
362
+ if (!res.ok) throw new Error(`Jira search failed: ${res.status} ${await res.text().catch(() => '')}`);
363
+ const data = (await res.json()) as any;
364
+ return (data.issues ?? []).map((i: any) => ({
365
+ key: i.key,
366
+ summary: i.fields?.summary,
367
+ status: i.fields?.status?.name,
368
+ priority: i.fields?.priority?.name,
369
+ updated: i.fields?.updated,
370
+ url: `${creds.baseUrl}/browse/${i.key}`,
371
+ }));
372
+ }