@taiga-ai/mcp-server 0.5.0-rc.389 → 0.6.0-dev.391
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/dist/index.js +306 -86
- package/package.json +1 -1
- package/src/client.test.ts +51 -10
- package/src/client.ts +39 -75
- package/src/index.ts +7 -3
- package/src/server.integration.test.ts +321 -1
- package/src/tools/annotations.ts +10 -0
- package/src/tools/backlogs.ts +78 -6
- package/src/tools/builds.ts +89 -0
- package/src/tools/documents.ts +3 -3
- package/src/tools/index.ts +2 -3
- package/src/tools/products.ts +106 -8
- package/src/tools/remote.test.ts +44 -0
- package/src/tools/remote.ts +107 -0
- package/src/tools/summarize.ts +18 -0
- package/taiga-ai-mcp-server-0.6.0-dev.391.tgz +0 -0
- package/taiga-ai-mcp-server-0.5.0-rc.389.tgz +0 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { TaigaClient } from '../client.js';
|
|
4
|
+
import { READ_ONLY } from './annotations.js';
|
|
5
|
+
import { runTool, toolResult } from './errors.js';
|
|
6
|
+
import { pickFields } from './summarize.js';
|
|
7
|
+
|
|
8
|
+
/** What a list row needs to say for the model to pick a run to read in full. */
|
|
9
|
+
const BUILD_SUMMARY_FIELDS = [
|
|
10
|
+
'id',
|
|
11
|
+
'runNumber',
|
|
12
|
+
'backlogItemId',
|
|
13
|
+
'targetTitle',
|
|
14
|
+
'planId',
|
|
15
|
+
'status',
|
|
16
|
+
'failedReason',
|
|
17
|
+
'runtime',
|
|
18
|
+
'repositoryFullName',
|
|
19
|
+
'branch',
|
|
20
|
+
'baseBranch',
|
|
21
|
+
'completedSteps',
|
|
22
|
+
'totalSteps',
|
|
23
|
+
'prNumber',
|
|
24
|
+
'prUrl',
|
|
25
|
+
'prState',
|
|
26
|
+
'prDraft',
|
|
27
|
+
'prMerged',
|
|
28
|
+
'createdAt',
|
|
29
|
+
'startedAt',
|
|
30
|
+
'completedAt',
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
34
|
+
if (!client.hasCapability('products:read')) return;
|
|
35
|
+
|
|
36
|
+
server.registerTool(
|
|
37
|
+
'taiga_list_builds',
|
|
38
|
+
{
|
|
39
|
+
title: 'List build runs',
|
|
40
|
+
description:
|
|
41
|
+
"List the coding agent's runs for a product, newest first: status, branch, pull request and step " +
|
|
42
|
+
'progress. Use this to see what Taiga already built or is building, then read one run with ' +
|
|
43
|
+
'taiga_get_build. Filter by backlog item to follow a single initiative.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
productId: z
|
|
46
|
+
.string()
|
|
47
|
+
.uuid()
|
|
48
|
+
.describe('Product ID -- get from taiga_resolve_product (from a git remote) or taiga_list_products'),
|
|
49
|
+
backlogItemId: z
|
|
50
|
+
.string()
|
|
51
|
+
.uuid()
|
|
52
|
+
.optional()
|
|
53
|
+
.describe("Only this backlog item's runs -- get from taiga_list_backlog_items"),
|
|
54
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
55
|
+
},
|
|
56
|
+
annotations: READ_ONLY,
|
|
57
|
+
},
|
|
58
|
+
(params) =>
|
|
59
|
+
runTool(async () => {
|
|
60
|
+
const result = await client.listBuildRuns(params.productId, params);
|
|
61
|
+
return toolResult(pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS));
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
server.registerTool(
|
|
66
|
+
'taiga_get_build',
|
|
67
|
+
{
|
|
68
|
+
title: 'Get build run',
|
|
69
|
+
description:
|
|
70
|
+
'Get one run in full with its steps: what each step was meant to change, what it did change ' +
|
|
71
|
+
'(commit, files), its status and any error. Use this to pick up where the coding agent stopped, ' +
|
|
72
|
+
'or to understand the pull request it opened.',
|
|
73
|
+
inputSchema: {
|
|
74
|
+
buildId: z.string().uuid().describe('Build run ID -- get from taiga_list_builds'),
|
|
75
|
+
},
|
|
76
|
+
annotations: READ_ONLY,
|
|
77
|
+
},
|
|
78
|
+
(params) =>
|
|
79
|
+
runTool(async () => {
|
|
80
|
+
// Two routes, one answer: fanned out here rather than in the model's
|
|
81
|
+
// context, like the six-tool pattern asks.
|
|
82
|
+
const [run, steps] = await Promise.all([
|
|
83
|
+
client.getBuildRun(params.buildId),
|
|
84
|
+
client.listBuildRunSteps(params.buildId),
|
|
85
|
+
]);
|
|
86
|
+
return toolResult({ ...(run.data as Record<string, unknown>), steps: steps.data });
|
|
87
|
+
})
|
|
88
|
+
);
|
|
89
|
+
}
|
package/src/tools/documents.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { TaigaApiError, type TaigaClient, type TaigaDocument } from '../client.js';
|
|
4
|
+
import { READ_ONLY } from './annotations.js';
|
|
4
5
|
import { runTool, toolError, toolResult } from './errors.js';
|
|
5
6
|
import { applySectionEdit, sectionContent, sectionsOf } from './sections.js';
|
|
6
7
|
import { documentType } from './inputs.js';
|
|
7
8
|
import { summarizeList } from './summarize.js';
|
|
8
9
|
|
|
9
|
-
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
|
|
10
10
|
const DRAFT_WRITE = {
|
|
11
11
|
readOnlyHint: false,
|
|
12
12
|
destructiveHint: false,
|
|
@@ -105,7 +105,7 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
|
|
|
105
105
|
'that apply across all factories and products. ' +
|
|
106
106
|
'Use taiga_get_document with the document type to read the full policy content.',
|
|
107
107
|
inputSchema: {
|
|
108
|
-
limit: z.number().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
108
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
109
109
|
},
|
|
110
110
|
annotations: READ_ONLY,
|
|
111
111
|
},
|
|
@@ -205,7 +205,7 @@ export function registerDocumentTools(server: McpServer, client: TaigaClient) {
|
|
|
205
205
|
'Use taiga_get_factory_document to read the full content.',
|
|
206
206
|
inputSchema: {
|
|
207
207
|
factoryId: z.string().uuid().describe('Factory ID -- get from taiga_list_factories'),
|
|
208
|
-
limit: z.number().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
208
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
209
209
|
},
|
|
210
210
|
annotations: READ_ONLY,
|
|
211
211
|
},
|
package/src/tools/index.ts
CHANGED
|
@@ -3,17 +3,16 @@ import type { TaigaClient } from '../client.js';
|
|
|
3
3
|
import { registerProductTools } from './products.js';
|
|
4
4
|
import { registerDocumentTools } from './documents.js';
|
|
5
5
|
import { registerBacklogTools } from './backlogs.js';
|
|
6
|
+
import { registerBuildTools } from './builds.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Register all MCP tools based on the API key's effective capabilities.
|
|
9
10
|
* Only tools matching the key's scopes are registered -- the AI assistant
|
|
10
11
|
* never sees tools it can't use.
|
|
11
|
-
*
|
|
12
|
-
* Beta scope: Knowledge base access (policies and factory instructions).
|
|
13
|
-
* Post-beta: Products, backlog items, environments, composite tools.
|
|
14
12
|
*/
|
|
15
13
|
export function registerAllTools(server: McpServer, client: TaigaClient): void {
|
|
16
14
|
registerProductTools(server, client);
|
|
17
15
|
registerDocumentTools(server, client);
|
|
18
16
|
registerBacklogTools(server, client);
|
|
17
|
+
registerBuildTools(server, client);
|
|
19
18
|
}
|
package/src/tools/products.ts
CHANGED
|
@@ -1,11 +1,41 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { TaigaClient } from '../client.js';
|
|
4
|
-
import {
|
|
4
|
+
import { READ_ONLY } from './annotations.js';
|
|
5
|
+
import { runTool, toolError, toolResult } from './errors.js';
|
|
5
6
|
import { documentType } from './inputs.js';
|
|
6
|
-
import {
|
|
7
|
+
import { parseRemote } from './remote.js';
|
|
8
|
+
import { pickFields, summarizeList } from './summarize.js';
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
/** What a product row needs to say for the model to pick one and read it in full. */
|
|
11
|
+
const PRODUCT_SUMMARY_FIELDS = ['id', 'name', 'slug', 'status', 'factoryId', 'kind', 'discoveryStep'] as const;
|
|
12
|
+
|
|
13
|
+
/** The API's page ceiling (paginationSchema in apps/api). */
|
|
14
|
+
const PAGE_SIZE = 100;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pages the resolver follows before it stops and says so. A repository is
|
|
18
|
+
* linked to one product, or a handful where sharing is allowed; a thousand
|
|
19
|
+
* matches is not a resolution the model can act on anyway.
|
|
20
|
+
*/
|
|
21
|
+
const RESOLVE_PAGE_LIMIT = 10;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Every product linked to the repository, page by page, until the API runs
|
|
25
|
+
* out or the cap is hit. A page that fails fails the call, like every other
|
|
26
|
+
* tool: a partial list marked truncated would read as a complete one.
|
|
27
|
+
*/
|
|
28
|
+
async function linkedProducts(client: TaigaClient, repository: string, repositoryHost: string) {
|
|
29
|
+
const products: Record<string, unknown>[] = [];
|
|
30
|
+
let cursor: string | undefined;
|
|
31
|
+
for (let page = 0; page < RESOLVE_PAGE_LIMIT; page += 1) {
|
|
32
|
+
const result = await client.listProducts({ repository, repositoryHost, limit: PAGE_SIZE, cursor });
|
|
33
|
+
products.push(...(result.data as Record<string, unknown>[]));
|
|
34
|
+
if (!result.nextCursor) return { products, truncated: false };
|
|
35
|
+
cursor = result.nextCursor;
|
|
36
|
+
}
|
|
37
|
+
return { products, truncated: true };
|
|
38
|
+
}
|
|
9
39
|
|
|
10
40
|
export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
11
41
|
if (client.hasCapability('factories:read')) {
|
|
@@ -19,7 +49,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
19
49
|
"Factory instructions carry the factory's standing guidance: domain context, tech preferences, " +
|
|
20
50
|
'and the conventions its products follow.',
|
|
21
51
|
inputSchema: {
|
|
22
|
-
limit: z.number().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
52
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
23
53
|
},
|
|
24
54
|
annotations: READ_ONLY,
|
|
25
55
|
},
|
|
@@ -37,8 +67,8 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
37
67
|
{
|
|
38
68
|
title: 'List products',
|
|
39
69
|
description:
|
|
40
|
-
'List products in the organization. Use this to find
|
|
41
|
-
'
|
|
70
|
+
'List products in the organization. Use this to find a product by name; inside a git checkout, ' +
|
|
71
|
+
'taiga_resolve_product finds it from the remote URL instead. ' +
|
|
42
72
|
'Can filter by factory; a status filter also exists but no longer distinguishes anything (see its description).',
|
|
43
73
|
inputSchema: {
|
|
44
74
|
factoryId: z
|
|
@@ -54,17 +84,85 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
54
84
|
'product is currently "draft" and filtering by "active" or "archived" returns nothing. ' +
|
|
55
85
|
'Omit this unless you are working with data set through the API directly.'
|
|
56
86
|
),
|
|
57
|
-
limit: z.number().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
87
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
58
88
|
},
|
|
59
89
|
annotations: READ_ONLY,
|
|
60
90
|
},
|
|
61
91
|
(params) =>
|
|
62
92
|
runTool(async () => {
|
|
63
93
|
const result = await client.listProducts(params);
|
|
94
|
+
return toolResult(pickFields(result.data as Record<string, unknown>[], PRODUCT_SUMMARY_FIELDS));
|
|
95
|
+
})
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
server.registerTool(
|
|
99
|
+
'taiga_get_product',
|
|
100
|
+
{
|
|
101
|
+
title: 'Get product',
|
|
102
|
+
description:
|
|
103
|
+
'Get one product in full: name, slug, factory, what Taiga owns for it (application, infrastructure, ' +
|
|
104
|
+
'deployment) and its settings; the list and resolve tools carry summaries. Use taiga_list_product_documents for its specification and design, ' +
|
|
105
|
+
'taiga_list_backlog_items for its work and taiga_list_builds for what the coding agent did.',
|
|
106
|
+
inputSchema: {
|
|
107
|
+
productId: z
|
|
108
|
+
.string()
|
|
109
|
+
.uuid()
|
|
110
|
+
.describe('Product ID -- get from taiga_resolve_product (from a git remote) or taiga_list_products'),
|
|
111
|
+
},
|
|
112
|
+
annotations: READ_ONLY,
|
|
113
|
+
},
|
|
114
|
+
(params) =>
|
|
115
|
+
runTool(async () => {
|
|
116
|
+
const result = await client.getProduct(params.productId);
|
|
64
117
|
return toolResult(result.data);
|
|
65
118
|
})
|
|
66
119
|
);
|
|
67
120
|
|
|
121
|
+
server.registerTool(
|
|
122
|
+
'taiga_resolve_product',
|
|
123
|
+
{
|
|
124
|
+
title: 'Resolve product from git remote',
|
|
125
|
+
description:
|
|
126
|
+
'Find the Taiga product a git checkout belongs to from its remote URL (the output of ' +
|
|
127
|
+
'`git remote get-url origin`). Call this first when working inside a repository, then use the ' +
|
|
128
|
+
'product ID with every other tool. Returns every product the repository is linked to (several ' +
|
|
129
|
+
`when the organization shares repositories; \`truncated\` is true past ${PAGE_SIZE * RESOLVE_PAGE_LIMIT}); ` +
|
|
130
|
+
'NOT_FOUND means nobody linked it, or the key cannot see the product.',
|
|
131
|
+
inputSchema: {
|
|
132
|
+
remoteUrl: z
|
|
133
|
+
.string()
|
|
134
|
+
.min(1)
|
|
135
|
+
.max(2048)
|
|
136
|
+
.describe('The remote URL: https://github.com/owner/repo.git, git@github.com:owner/repo.git or ssh://...'),
|
|
137
|
+
},
|
|
138
|
+
annotations: READ_ONLY,
|
|
139
|
+
},
|
|
140
|
+
(params) =>
|
|
141
|
+
runTool(async () => {
|
|
142
|
+
const remote = parseRemote(params.remoteUrl);
|
|
143
|
+
if (!remote) {
|
|
144
|
+
return toolError(
|
|
145
|
+
'INVALID_REMOTE',
|
|
146
|
+
`"${params.remoteUrl}" is not a remote of the form host/owner/repo. Pass the URL git remote get-url origin prints.`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const { products, truncated } = await linkedProducts(client, remote.repository, remote.host);
|
|
150
|
+
if (products.length === 0) {
|
|
151
|
+
return toolError(
|
|
152
|
+
'NOT_FOUND',
|
|
153
|
+
`No product you can see is linked to ${remote.repository} on ${remote.host}. ` +
|
|
154
|
+
'Link the repository to its product in Taiga, or find the product with taiga_list_products.'
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return toolResult({
|
|
158
|
+
repository: remote.repository,
|
|
159
|
+
host: remote.host,
|
|
160
|
+
products: pickFields(products, PRODUCT_SUMMARY_FIELDS),
|
|
161
|
+
truncated,
|
|
162
|
+
});
|
|
163
|
+
})
|
|
164
|
+
);
|
|
165
|
+
|
|
68
166
|
server.registerTool(
|
|
69
167
|
'taiga_list_product_documents',
|
|
70
168
|
{
|
|
@@ -77,7 +175,7 @@ export function registerProductTools(server: McpServer, client: TaigaClient) {
|
|
|
77
175
|
'Use taiga_get_product_document to read the full content.',
|
|
78
176
|
inputSchema: {
|
|
79
177
|
productId: z.string().uuid().describe('Product ID -- get from taiga_list_products'),
|
|
80
|
-
limit: z.number().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
178
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max results (default 25)'),
|
|
81
179
|
},
|
|
82
180
|
annotations: READ_ONLY,
|
|
83
181
|
},
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseRemote } from './remote.js';
|
|
3
|
+
|
|
4
|
+
describe('parseRemote', () => {
|
|
5
|
+
it.each([
|
|
6
|
+
['https://github.com/acme/web.git', 'https://github.com', 'acme/web'],
|
|
7
|
+
['https://github.com/acme/web', 'https://github.com', 'acme/web'],
|
|
8
|
+
['https://github.com/acme/web/', 'https://github.com', 'acme/web'],
|
|
9
|
+
['https://github.com/acme/web.git/', 'https://github.com', 'acme/web'],
|
|
10
|
+
['git@github.com:acme/web.git/', 'https://github.com', 'acme/web'],
|
|
11
|
+
['https://user@GitHub.com/Acme/Web.git', 'https://github.com', 'Acme/Web'],
|
|
12
|
+
['http://ghe.local:8443/acme/web.git', 'http://ghe.local:8443', 'acme/web'],
|
|
13
|
+
['ssh://git@github.com/acme/web.git', 'https://github.com', 'acme/web'],
|
|
14
|
+
['ssh://git@ghe.example.com:2222/acme/web.git', 'https://ghe.example.com', 'acme/web'],
|
|
15
|
+
['git+ssh://git@github.com/acme/web.git', 'https://github.com', 'acme/web'],
|
|
16
|
+
['git@github.com:acme/web.git', 'https://github.com', 'acme/web'],
|
|
17
|
+
['git@taiga.ghe.com:taiga/taiga-app.git', 'https://taiga.ghe.com', 'taiga/taiga-app'],
|
|
18
|
+
[' git@github.com:acme/web.git\n', 'https://github.com', 'acme/web'],
|
|
19
|
+
['https://g/acme/web', 'https://g', 'acme/web'],
|
|
20
|
+
])('reads %s as %s %s', (remote, host, repository) => {
|
|
21
|
+
expect(parseRemote(remote)).toEqual({ host, repository });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it.each([
|
|
25
|
+
[''],
|
|
26
|
+
['not a remote'],
|
|
27
|
+
['https://github.com/acme'],
|
|
28
|
+
['https://gitlab.com/group/subgroup/repo.git'],
|
|
29
|
+
['https://github.com/acme/../web'],
|
|
30
|
+
['https://github.com/acme/tmp/../web'],
|
|
31
|
+
['https://github.com/acme/tmp/%2e%2e/web'],
|
|
32
|
+
['https://github.com/acme/./web'],
|
|
33
|
+
['git@github.com:acme/tmp/../web.git'],
|
|
34
|
+
['ftp://github.com/acme/web'],
|
|
35
|
+
['/Users/dev/acme/web'],
|
|
36
|
+
['C:/acme/web'],
|
|
37
|
+
['git@ghe.local:8443:acme/web.git'],
|
|
38
|
+
['git@github[.]com:acme/web.git'],
|
|
39
|
+
['git@git hub.com:acme/web.git'],
|
|
40
|
+
['acme/web'],
|
|
41
|
+
])('refuses %s', (remote) => {
|
|
42
|
+
expect(parseRemote(remote)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a git remote says about where a repository lives, in the terms Taiga
|
|
3
|
+
* stores: the web origin of the host and the `owner/repo` name. A developer's
|
|
4
|
+
* checkout knows its remote, not its Taiga product; this is the bridge.
|
|
5
|
+
*
|
|
6
|
+
* Accepted shapes: `https://host/owner/repo(.git)`, `ssh://git@host/owner/repo`,
|
|
7
|
+
* `git+ssh://...` and the scp-like `git@host:owner/repo.git`. Anything else,
|
|
8
|
+
* and any path that is not exactly two segments, is null: Taiga links
|
|
9
|
+
* `owner/repo` names only, so a deeper path can never match a link.
|
|
10
|
+
*
|
|
11
|
+
* An SSH remote names no web origin, so it is read as `https://host` on the
|
|
12
|
+
* default port. A host whose web UI sits on another port or on plain http
|
|
13
|
+
* does not resolve from its SSH remote; the https remote does.
|
|
14
|
+
*/
|
|
15
|
+
export interface ParsedRemote {
|
|
16
|
+
/** The web origin the link's URL starts with, such as `https://github.com`. */
|
|
17
|
+
host: string;
|
|
18
|
+
/** `owner/repo`, in the caller's case; the API matches it case-insensitively. */
|
|
19
|
+
repository: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SCP_LIKE = /^(?:[^@\s/]+@)?([^:\s/]+):([^\s]+)$/;
|
|
23
|
+
|
|
24
|
+
export function parseRemote(remote: string): ParsedRemote | null {
|
|
25
|
+
const trimmed = remote.trim();
|
|
26
|
+
if (trimmed === '') return null;
|
|
27
|
+
|
|
28
|
+
let host: string;
|
|
29
|
+
let path: string;
|
|
30
|
+
let scheme = 'https';
|
|
31
|
+
|
|
32
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
|
33
|
+
// The URL parser folds `..` away before the path can be looked at, so
|
|
34
|
+
// the raw text after the authority is checked first: a dot segment in a
|
|
35
|
+
// remote is never what `git remote get-url` prints.
|
|
36
|
+
if (hasDotSegment(trimmed.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, ''))) return null;
|
|
37
|
+
let url: URL;
|
|
38
|
+
try {
|
|
39
|
+
url = new URL(trimmed);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const protocol = url.protocol.replace(/:$/, '').toLowerCase();
|
|
44
|
+
if (protocol === 'http' || protocol === 'https') {
|
|
45
|
+
scheme = protocol;
|
|
46
|
+
// The port is part of the web origin a link was stored with.
|
|
47
|
+
host = url.port ? `${url.hostname}:${url.port}` : url.hostname;
|
|
48
|
+
} else if (protocol === 'ssh' || protocol === 'git+ssh' || protocol === 'git') {
|
|
49
|
+
// An SSH port says nothing about the web origin.
|
|
50
|
+
host = url.hostname;
|
|
51
|
+
} else {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
path = url.pathname;
|
|
55
|
+
} else {
|
|
56
|
+
const match = SCP_LIKE.exec(trimmed);
|
|
57
|
+
if (!match) return null;
|
|
58
|
+
host = match[1]!;
|
|
59
|
+
path = match[2]!;
|
|
60
|
+
// Only this shape can misread a Windows drive (`C:/acme/web`) as a host;
|
|
61
|
+
// an explicit URL with a one-letter hostname is what it says it is.
|
|
62
|
+
if (host.length < 2) return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
host = host.toLowerCase();
|
|
66
|
+
if (host === '') return null;
|
|
67
|
+
|
|
68
|
+
const segments = path
|
|
69
|
+
.replace(/\/+$/, '')
|
|
70
|
+
.replace(/\.git$/i, '')
|
|
71
|
+
.split('/')
|
|
72
|
+
.filter((segment) => segment !== '');
|
|
73
|
+
if (segments.length !== 2) return null;
|
|
74
|
+
// `.`/`..` would collapse into another route; a ':' is a port that landed
|
|
75
|
+
// in the path (`git@host:8443:owner/repo`), which no link name carries.
|
|
76
|
+
if (hasDotSegment(path) || segments.some((segment) => segment.includes(':'))) return null;
|
|
77
|
+
|
|
78
|
+
// The scp-like shape takes any host-looking text; only an origin the URL
|
|
79
|
+
// parser accepts is sent on, so `git@github[.]com:acme/web` is refused
|
|
80
|
+
// here rather than by the API after a request.
|
|
81
|
+
const origin = webOrigin(`${scheme}://${host}`);
|
|
82
|
+
if (origin === null) return null;
|
|
83
|
+
|
|
84
|
+
return { host: origin, repository: `${segments[0]}/${segments[1]}` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether any segment of the path is `.` or `..`, percent-encoded or not. */
|
|
88
|
+
function hasDotSegment(path: string): boolean {
|
|
89
|
+
return path.split('/').some((segment) => {
|
|
90
|
+
let decoded = segment;
|
|
91
|
+
try {
|
|
92
|
+
decoded = decodeURIComponent(segment);
|
|
93
|
+
} catch {
|
|
94
|
+
// An undecodable segment is kept as is; it is not a dot segment.
|
|
95
|
+
}
|
|
96
|
+
return decoded === '.' || decoded === '..';
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function webOrigin(url: string): string | null {
|
|
101
|
+
try {
|
|
102
|
+
const origin = new URL(url).origin;
|
|
103
|
+
return origin === 'null' ? null : origin;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/tools/summarize.ts
CHANGED
|
@@ -28,3 +28,21 @@ export function summarizeList<T extends Record<string, unknown>>(
|
|
|
28
28
|
return summary;
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Keep only the named fields of each item. For lists whose rows carry prose
|
|
34
|
+
* (a backlog item's description, a run's metrics) that `summarizeList`'s
|
|
35
|
+
* fixed field names would not catch.
|
|
36
|
+
*/
|
|
37
|
+
export function pickFields<T extends Record<string, unknown>>(
|
|
38
|
+
items: T[],
|
|
39
|
+
fields: readonly string[]
|
|
40
|
+
): Record<string, unknown>[] {
|
|
41
|
+
return items.map((item) => {
|
|
42
|
+
const picked: Record<string, unknown> = {};
|
|
43
|
+
for (const field of fields) {
|
|
44
|
+
if (field in item) picked[field] = item[field];
|
|
45
|
+
}
|
|
46
|
+
return picked;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
Binary file
|
|
Binary file
|