@nonameolsson/nightjar 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +405 -2
- package/dist/account.d.ts +12 -0
- package/dist/account.js +57 -0
- package/dist/account.js.map +1 -0
- package/dist/adapter-module.d.ts +18 -0
- package/dist/adapter-module.js +83 -0
- package/dist/adapter-module.js.map +1 -0
- package/dist/adapters.d.ts +15 -0
- package/dist/adapters.js +160 -0
- package/dist/adapters.js.map +1 -0
- package/dist/args.d.ts +13 -0
- package/dist/args.js +84 -0
- package/dist/args.js.map +1 -0
- package/dist/ask.d.ts +6 -0
- package/dist/ask.js +27 -0
- package/dist/ask.js.map +1 -0
- package/dist/azure-devops/api.d.ts +34 -0
- package/dist/azure-devops/api.js +98 -0
- package/dist/azure-devops/api.js.map +1 -0
- package/dist/azure-devops/code-host.d.ts +36 -0
- package/dist/azure-devops/code-host.js +185 -0
- package/dist/azure-devops/code-host.js.map +1 -0
- package/dist/azure-devops/fake-azure-devops.d.ts +90 -0
- package/dist/azure-devops/fake-azure-devops.js +728 -0
- package/dist/azure-devops/fake-azure-devops.js.map +1 -0
- package/dist/azure-devops/index.d.ts +8 -0
- package/dist/azure-devops/index.js +11 -0
- package/dist/azure-devops/index.js.map +1 -0
- package/dist/azure-devops/sign-in.d.ts +11 -0
- package/dist/azure-devops/sign-in.js +68 -0
- package/dist/azure-devops/sign-in.js.map +1 -0
- package/dist/azure-devops/text.d.ts +1 -0
- package/dist/azure-devops/text.js +41 -0
- package/dist/azure-devops/text.js.map +1 -0
- package/dist/azure-devops/tracker.d.ts +44 -0
- package/dist/azure-devops/tracker.js +333 -0
- package/dist/azure-devops/tracker.js.map +1 -0
- package/dist/build.d.ts +75 -0
- package/dist/build.js +698 -0
- package/dist/build.js.map +1 -0
- package/dist/check.d.ts +8 -0
- package/dist/check.js +25 -0
- package/dist/check.js.map +1 -0
- package/dist/checks.d.ts +45 -0
- package/dist/checks.js +243 -0
- package/dist/checks.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +11 -0
- package/dist/cli.js.map +1 -0
- package/dist/code-host.d.ts +19 -0
- package/dist/code-host.js +2 -0
- package/dist/code-host.js.map +1 -0
- package/dist/config.d.ts +86 -0
- package/dist/config.js +294 -0
- package/dist/config.js.map +1 -0
- package/dist/contract/case.d.ts +4 -0
- package/dist/contract/case.js +2 -0
- package/dist/contract/case.js.map +1 -0
- package/dist/contract/code-host.d.ts +24 -0
- package/dist/contract/code-host.js +108 -0
- package/dist/contract/code-host.js.map +1 -0
- package/dist/contract/command.d.ts +2 -0
- package/dist/contract/command.js +36 -0
- package/dist/contract/command.js.map +1 -0
- package/dist/contract/feature-writer.d.ts +3 -0
- package/dist/contract/feature-writer.js +101 -0
- package/dist/contract/feature-writer.js.map +1 -0
- package/dist/contract/run.d.ts +18 -0
- package/dist/contract/run.js +89 -0
- package/dist/contract/run.js.map +1 -0
- package/dist/contract/tracker.d.ts +30 -0
- package/dist/contract/tracker.js +250 -0
- package/dist/contract/tracker.js.map +1 -0
- package/dist/create.d.ts +9 -0
- package/dist/create.js +112 -0
- package/dist/create.js.map +1 -0
- package/dist/doctor.d.ts +4 -0
- package/dist/doctor.js +42 -0
- package/dist/doctor.js.map +1 -0
- package/dist/fakes/code-host.d.ts +21 -0
- package/dist/fakes/code-host.js +42 -0
- package/dist/fakes/code-host.js.map +1 -0
- package/dist/fakes/harness.d.ts +7 -0
- package/dist/fakes/harness.js +68 -0
- package/dist/fakes/harness.js.map +1 -0
- package/dist/fakes/machine.d.ts +11 -0
- package/dist/fakes/machine.js +101 -0
- package/dist/fakes/machine.js.map +1 -0
- package/dist/fakes/tracker.d.ts +40 -0
- package/dist/fakes/tracker.js +116 -0
- package/dist/fakes/tracker.js.map +1 -0
- package/dist/feature-file.d.ts +12 -0
- package/dist/feature-file.js +84 -0
- package/dist/feature-file.js.map +1 -0
- package/dist/git.d.ts +9 -0
- package/dist/git.js +12 -0
- package/dist/git.js.map +1 -0
- package/dist/github/api.d.ts +37 -0
- package/dist/github/api.js +66 -0
- package/dist/github/api.js.map +1 -0
- package/dist/github/code-host.d.ts +19 -0
- package/dist/github/code-host.js +105 -0
- package/dist/github/code-host.js.map +1 -0
- package/dist/github/fake-gh.d.ts +17 -0
- package/dist/github/fake-gh.js +51 -0
- package/dist/github/fake-gh.js.map +1 -0
- package/dist/github/fake-github.d.ts +58 -0
- package/dist/github/fake-github.js +285 -0
- package/dist/github/fake-github.js.map +1 -0
- package/dist/github/labels.d.ts +3 -0
- package/dist/github/labels.js +44 -0
- package/dist/github/labels.js.map +1 -0
- package/dist/github/tracker.d.ts +28 -0
- package/dist/github/tracker.js +208 -0
- package/dist/github/tracker.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +19 -0
- package/dist/init.js +225 -0
- package/dist/init.js.map +1 -0
- package/dist/ledger.d.ts +13 -0
- package/dist/ledger.js +49 -0
- package/dist/ledger.js.map +1 -0
- package/dist/main.d.ts +11 -0
- package/dist/main.js +215 -0
- package/dist/main.js.map +1 -0
- package/dist/person-config.d.ts +9 -0
- package/dist/person-config.js +46 -0
- package/dist/person-config.js.map +1 -0
- package/dist/plan.d.ts +9 -0
- package/dist/plan.js +27 -0
- package/dist/plan.js.map +1 -0
- package/dist/ports.d.ts +16 -0
- package/dist/ports.js +104 -0
- package/dist/ports.js.map +1 -0
- package/dist/prompt.d.ts +166 -0
- package/dist/prompt.js +217 -0
- package/dist/prompt.js.map +1 -0
- package/dist/queue.d.ts +20 -0
- package/dist/queue.js +94 -0
- package/dist/queue.js.map +1 -0
- package/dist/say.d.ts +2 -0
- package/dist/say.js +4 -0
- package/dist/say.js.map +1 -0
- package/dist/serve.d.ts +10 -0
- package/dist/serve.js +85 -0
- package/dist/serve.js.map +1 -0
- package/dist/session.d.ts +39 -0
- package/dist/session.js +144 -0
- package/dist/session.js.map +1 -0
- package/dist/settings.d.ts +10 -0
- package/dist/settings.js +92 -0
- package/dist/settings.js.map +1 -0
- package/dist/signals.d.ts +5 -0
- package/dist/signals.js +13 -0
- package/dist/signals.js.map +1 -0
- package/dist/skills.d.ts +4 -0
- package/dist/skills.js +51 -0
- package/dist/skills.js.map +1 -0
- package/dist/status-shape.d.ts +53 -0
- package/dist/status-shape.js +3 -0
- package/dist/status-shape.js.map +1 -0
- package/dist/status.d.ts +23 -0
- package/dist/status.js +56 -0
- package/dist/status.js.map +1 -0
- package/dist/tracker.d.ts +56 -0
- package/dist/tracker.js +12 -0
- package/dist/tracker.js.map +1 -0
- package/dist/workflow.d.ts +33 -0
- package/dist/workflow.js +53 -0
- package/dist/workflow.js.map +1 -0
- package/dist/worktree.d.ts +44 -0
- package/dist/worktree.js +193 -0
- package/dist/worktree.js.map +1 -0
- package/docs/ticket-contract.md +95 -0
- package/package.json +74 -5
- package/plugin/nightjar-status/.claude-plugin/plugin.json +5 -0
- package/plugin/nightjar-status/hooks/format.ts +48 -0
- package/plugin/nightjar-status/hooks/hooks.json +1 -0
- package/plugin/nightjar-status/hooks/register.ts +51 -0
- package/schema/factory.schema.json +437 -0
- package/skills/nightjar-feature/SKILL.md +73 -0
- package/src/status-shape.ts +44 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
const TOKEN = 'Bearer fake-azure-devops';
|
|
2
|
+
const API = '7.1';
|
|
3
|
+
const COMMENTS_API = '7.1-preview.4';
|
|
4
|
+
const STATE = /System\.State/i;
|
|
5
|
+
const CHILD = 'System.LinkTypes.Hierarchy-Forward';
|
|
6
|
+
const PARENT = 'System.LinkTypes.Hierarchy-Reverse';
|
|
7
|
+
const SUCCESSOR = 'System.LinkTypes.Dependency-Forward';
|
|
8
|
+
const PREDECESSOR = 'System.LinkTypes.Dependency-Reverse';
|
|
9
|
+
const json = (status, body) => ({
|
|
10
|
+
status,
|
|
11
|
+
text: body === undefined ? '' : JSON.stringify(body)
|
|
12
|
+
});
|
|
13
|
+
const refusal = (status, message) => json(status, { message, typeKey: 'Fault' });
|
|
14
|
+
// What Azure DevOps keeps of Markdown: a bare < or > becomes its entity.
|
|
15
|
+
const storedMarkdown = (text) => text.replace(/</g, '<').replace(/>/g, '>');
|
|
16
|
+
const fault = (message) => new Error(`the fake Azure DevOps: ${message}`);
|
|
17
|
+
const guid = (n) => `00000000-0000-0000-0000-${String(n).padStart(12, '0')}`;
|
|
18
|
+
export class FakeAzureDevOps {
|
|
19
|
+
organization;
|
|
20
|
+
project;
|
|
21
|
+
person;
|
|
22
|
+
items = new Map();
|
|
23
|
+
people = new Map();
|
|
24
|
+
discussion = new Map();
|
|
25
|
+
clock = Date.UTC(2026, 9, 30, 9, 0);
|
|
26
|
+
// every call it answered, in order
|
|
27
|
+
asked = [];
|
|
28
|
+
faults = [];
|
|
29
|
+
repositories = new Map();
|
|
30
|
+
pulls = [];
|
|
31
|
+
// the artifact links on each work item, such as one to a pull request
|
|
32
|
+
artifacts = new Map();
|
|
33
|
+
constructor(organization, project,
|
|
34
|
+
// the sign-in name the token is for
|
|
35
|
+
person) {
|
|
36
|
+
this.organization = organization;
|
|
37
|
+
this.project = project;
|
|
38
|
+
this.person = person;
|
|
39
|
+
this.join(person, { contributor: true });
|
|
40
|
+
}
|
|
41
|
+
// Adds a person to the organization; a Contributor is in the project's Contributors group by way of its team.
|
|
42
|
+
join(uniqueName, { contributor }) {
|
|
43
|
+
const known = this.people.get(uniqueName.toLowerCase());
|
|
44
|
+
const id = known?.id ?? guid(this.people.size + 1);
|
|
45
|
+
this.people.set(uniqueName.toLowerCase(), { id, uniqueName, contributor });
|
|
46
|
+
}
|
|
47
|
+
// Makes a work item and gives back its id.
|
|
48
|
+
workItem(item) {
|
|
49
|
+
const id = this.items.size + 1;
|
|
50
|
+
const fields = {
|
|
51
|
+
'System.Id': id,
|
|
52
|
+
'System.TeamProject': this.project,
|
|
53
|
+
'System.WorkItemType': item.type,
|
|
54
|
+
'System.Title': item.title,
|
|
55
|
+
'System.State': item.state ?? 'New',
|
|
56
|
+
'System.CreatedDate': this.tick()
|
|
57
|
+
};
|
|
58
|
+
if (item.description !== undefined) {
|
|
59
|
+
fields['System.Description'] = item.html
|
|
60
|
+
? item.description
|
|
61
|
+
: storedMarkdown(item.description);
|
|
62
|
+
}
|
|
63
|
+
if (item.assignedTo !== undefined)
|
|
64
|
+
fields['System.AssignedTo'] = this.identity(item.assignedTo);
|
|
65
|
+
if (item.tags !== undefined && item.tags.length > 0)
|
|
66
|
+
fields['System.Tags'] = item.tags.join('; ');
|
|
67
|
+
if (item.closed === true)
|
|
68
|
+
fields['Microsoft.VSTS.Common.ClosedDate'] = this.tick();
|
|
69
|
+
if (item.rank !== undefined)
|
|
70
|
+
fields[item.rank.field] = item.rank.value;
|
|
71
|
+
this.items.set(id, { id, rev: 1, fields, markdown: item.html !== true, links: [] });
|
|
72
|
+
return id;
|
|
73
|
+
}
|
|
74
|
+
// Links `child` under `parent`, last among its children.
|
|
75
|
+
addChild(parent, child) {
|
|
76
|
+
this.link(parent, CHILD, child);
|
|
77
|
+
this.link(child, PARENT, parent);
|
|
78
|
+
}
|
|
79
|
+
// Links `successor` after `predecessor`: the predecessor blocks it.
|
|
80
|
+
precede(predecessor, successor) {
|
|
81
|
+
this.link(predecessor, SUCCESSOR, successor);
|
|
82
|
+
this.link(successor, PREDECESSOR, predecessor);
|
|
83
|
+
}
|
|
84
|
+
say(id, author, text, { html = false } = {}) {
|
|
85
|
+
this.get(id);
|
|
86
|
+
const list = this.discussion.get(id) ?? [];
|
|
87
|
+
list.push({
|
|
88
|
+
id: list.length + 1,
|
|
89
|
+
text: html ? text : storedMarkdown(text),
|
|
90
|
+
markdown: !html,
|
|
91
|
+
author: this.who(author),
|
|
92
|
+
createdDate: this.tick()
|
|
93
|
+
});
|
|
94
|
+
this.discussion.set(id, list);
|
|
95
|
+
}
|
|
96
|
+
// The tags on a work item, as Azure DevOps lists them.
|
|
97
|
+
tagsOf(id) {
|
|
98
|
+
return tagList(this.get(id).fields['System.Tags']);
|
|
99
|
+
}
|
|
100
|
+
// Makes a Git repo in the project, on Azure Repos.
|
|
101
|
+
repository(name) {
|
|
102
|
+
const id = guid(5000 + this.repositories.size + 1);
|
|
103
|
+
this.repositories.set(name.toLowerCase(), { id, name });
|
|
104
|
+
}
|
|
105
|
+
// A pull request as the fake keeps it.
|
|
106
|
+
pullRequestAt(id) {
|
|
107
|
+
const found = this.pulls.find((p) => p.pullRequestId === id);
|
|
108
|
+
if (found === undefined)
|
|
109
|
+
throw fault(`no pull request ${String(id)}`);
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
// The work items linked to a pull request, by id.
|
|
113
|
+
linkedWorkItems(pullId) {
|
|
114
|
+
const link = this.artifactOf(this.pullRequestAt(pullId));
|
|
115
|
+
return [...this.artifacts.entries()]
|
|
116
|
+
.filter(([, links]) => links.some((l) => l.toLowerCase() === link.toLowerCase()))
|
|
117
|
+
.map(([id]) => id)
|
|
118
|
+
.sort((a, b) => a - b);
|
|
119
|
+
}
|
|
120
|
+
// The next call whose address `match` finds answers `status` and changes nothing.
|
|
121
|
+
failNext(match, status) {
|
|
122
|
+
this.faults.push({ match, status });
|
|
123
|
+
}
|
|
124
|
+
// A sign-in whose token this fake takes.
|
|
125
|
+
signIn() {
|
|
126
|
+
return {
|
|
127
|
+
authorization: () => Promise.resolve(TOKEN),
|
|
128
|
+
via: () => Promise.resolve('the fake sign-in'),
|
|
129
|
+
refresh: () => Promise.resolve()
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
request = (url, call) => {
|
|
133
|
+
const body = call.body === undefined ? undefined : JSON.parse(call.body);
|
|
134
|
+
if (STATE.test(url) || STATE.test(call.body ?? '')) {
|
|
135
|
+
throw fault(`Nightjar never reads or sets a work item's state (docs/adr/0002): ${url}`);
|
|
136
|
+
}
|
|
137
|
+
this.asked.push({ method: call.method, url, body });
|
|
138
|
+
if (call.headers.Authorization !== TOKEN)
|
|
139
|
+
return Promise.resolve(json(401));
|
|
140
|
+
const scripted = this.faults.find((f) => f.match.test(url));
|
|
141
|
+
if (scripted !== undefined) {
|
|
142
|
+
this.faults.splice(this.faults.indexOf(scripted), 1);
|
|
143
|
+
return Promise.resolve(refusal(scripted.status, 'the fake Azure DevOps: a scripted fault'));
|
|
144
|
+
}
|
|
145
|
+
return Promise.resolve(this.answer(new URL(url), call, body));
|
|
146
|
+
};
|
|
147
|
+
answer(url, call, body) {
|
|
148
|
+
const org = `/${encodeURIComponent(this.organization)}`;
|
|
149
|
+
const project = `${org}/${encodeURIComponent(this.project)}`;
|
|
150
|
+
const path = url.pathname;
|
|
151
|
+
const route = `${call.method} ${url.host}`;
|
|
152
|
+
if (route === 'GET vssps.dev.azure.com' && path === `${org}/_apis/identities`) {
|
|
153
|
+
return this.identities(url.searchParams);
|
|
154
|
+
}
|
|
155
|
+
if (url.host !== 'dev.azure.com')
|
|
156
|
+
throw fault(`no host ${url.host}`);
|
|
157
|
+
if (call.method === 'GET' && path === `${org}/_apis/connectionData`) {
|
|
158
|
+
const me = this.who(this.person);
|
|
159
|
+
return json(200, {
|
|
160
|
+
authenticatedUser: {
|
|
161
|
+
id: me.id,
|
|
162
|
+
providerDisplayName: me.uniqueName,
|
|
163
|
+
properties: { Account: { $type: 'System.String', $value: me.uniqueName } }
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (call.method === 'POST' && path === `${project}/_apis/wit/wiql`) {
|
|
168
|
+
this.query(url, { 'api-version': API });
|
|
169
|
+
return this.wiql(body);
|
|
170
|
+
}
|
|
171
|
+
if (call.method === 'GET' && path === `${org}/_apis/wit/workitems`) {
|
|
172
|
+
return this.batch(url.searchParams);
|
|
173
|
+
}
|
|
174
|
+
const made = new RegExp(`^${project}/_apis/wit/workitems/(?:\\$|%24)(.+)$`).exec(path);
|
|
175
|
+
if (call.method === 'POST' && made) {
|
|
176
|
+
this.query(url, { 'api-version': API });
|
|
177
|
+
if (call.headers['Content-Type'] !== 'application/json-patch+json') {
|
|
178
|
+
throw fault('a work item is made with application/json-patch+json');
|
|
179
|
+
}
|
|
180
|
+
return this.create(decodeURIComponent(made[1] ?? ''), body);
|
|
181
|
+
}
|
|
182
|
+
const one = new RegExp(`^${project}/_apis/wit/workitems/(\\d+)$`).exec(path);
|
|
183
|
+
if (call.method === 'PATCH' && one) {
|
|
184
|
+
this.query(url, { 'api-version': API });
|
|
185
|
+
if (call.headers['Content-Type'] !== 'application/json-patch+json') {
|
|
186
|
+
throw fault('a work item is patched with application/json-patch+json');
|
|
187
|
+
}
|
|
188
|
+
return this.patch(Number(one[1]), body);
|
|
189
|
+
}
|
|
190
|
+
const thread = new RegExp(`^${project}/_apis/wit/workItems/(\\d+)/comments$`).exec(path);
|
|
191
|
+
if (thread) {
|
|
192
|
+
const id = Number(thread[1]);
|
|
193
|
+
if (!this.items.has(id))
|
|
194
|
+
return refusal(404, `TF401232: Work item ${String(id)} does not exist.`);
|
|
195
|
+
if (call.method === 'GET')
|
|
196
|
+
return this.comments(id, url.searchParams);
|
|
197
|
+
if (call.method === 'POST')
|
|
198
|
+
return this.addComment(id, url.searchParams, body);
|
|
199
|
+
}
|
|
200
|
+
const repos = this.reposAnswer(url, call, body, project);
|
|
201
|
+
if (repos !== null)
|
|
202
|
+
return repos;
|
|
203
|
+
const linking = new RegExp(`^${org}/_apis/wit/workitems/(\\d+)$`, 'i').exec(path);
|
|
204
|
+
if (call.method === 'PATCH' && linking) {
|
|
205
|
+
this.query(url, { 'api-version': API });
|
|
206
|
+
if (call.headers['Content-Type'] !== 'application/json-patch+json') {
|
|
207
|
+
throw fault('a work item is patched with application/json-patch+json');
|
|
208
|
+
}
|
|
209
|
+
return this.linkArtifact(Number(linking[1]), body);
|
|
210
|
+
}
|
|
211
|
+
throw fault(`no route answers ${call.method} ${url.href}`);
|
|
212
|
+
}
|
|
213
|
+
// Azure Repos: a repo, its pull requests, their threads and linked work items. Null for another route.
|
|
214
|
+
reposAnswer(url, call, body, project) {
|
|
215
|
+
const shape = new RegExp(`^${project}/_apis/git/repositories/([^/]+)(?:/(pullrequests)(?:/(\\d+)(?:/(workitems|threads))?)?)?$`, 'i').exec(url.pathname);
|
|
216
|
+
if (!shape)
|
|
217
|
+
return null;
|
|
218
|
+
this.query(url, { 'api-version': API }, [
|
|
219
|
+
'searchCriteria.sourceRefName',
|
|
220
|
+
'searchCriteria.status'
|
|
221
|
+
]);
|
|
222
|
+
const repo = this.repositories.get(decodeURIComponent(shape[1] ?? '').toLowerCase());
|
|
223
|
+
if (repo === undefined) {
|
|
224
|
+
return refusal(404, `TF401019: The Git repository with name or identifier ${shape[1] ?? ''} does not exist.`);
|
|
225
|
+
}
|
|
226
|
+
const [, , pulls, idText, part] = shape;
|
|
227
|
+
if (pulls === undefined) {
|
|
228
|
+
if (call.method !== 'GET')
|
|
229
|
+
throw fault(`${call.method} on a repo`);
|
|
230
|
+
return json(200, this.renderRepository(repo));
|
|
231
|
+
}
|
|
232
|
+
if (idText === undefined) {
|
|
233
|
+
if (call.method === 'GET')
|
|
234
|
+
return this.listPulls(repo, url.searchParams);
|
|
235
|
+
if (call.method === 'POST')
|
|
236
|
+
return this.createPull(repo, body);
|
|
237
|
+
throw fault(`${call.method} on the pull requests`);
|
|
238
|
+
}
|
|
239
|
+
const pull = this.pulls.find((p) => p.pullRequestId === Number(idText) && p.repository === repo.id);
|
|
240
|
+
if (pull === undefined) {
|
|
241
|
+
return refusal(404, `TF401180: The requested pull request was not found.`);
|
|
242
|
+
}
|
|
243
|
+
if (part === undefined) {
|
|
244
|
+
if (call.method === 'GET')
|
|
245
|
+
return json(200, this.renderPull(pull));
|
|
246
|
+
if (call.method === 'PATCH')
|
|
247
|
+
return this.updatePull(pull, body);
|
|
248
|
+
}
|
|
249
|
+
else if (part.toLowerCase() === 'workitems' && call.method === 'GET') {
|
|
250
|
+
const value = this.linkedWorkItems(pull.pullRequestId).map((id) => ({
|
|
251
|
+
id: String(id),
|
|
252
|
+
url: this.itemUrl(id)
|
|
253
|
+
}));
|
|
254
|
+
return json(200, { count: value.length, value });
|
|
255
|
+
}
|
|
256
|
+
else if (part.toLowerCase() === 'threads' && call.method === 'POST') {
|
|
257
|
+
const content = body?.comments?.[0]
|
|
258
|
+
?.content;
|
|
259
|
+
if (typeof content !== 'string')
|
|
260
|
+
throw fault('a thread carries { comments: [{ content }] }');
|
|
261
|
+
this.replacePull({ ...pull, comments: [...pull.comments, content] });
|
|
262
|
+
return json(200, { id: pull.comments.length + 1, comments: [{ id: 1, content }] });
|
|
263
|
+
}
|
|
264
|
+
throw fault(`no route answers ${call.method} ${url.href}`);
|
|
265
|
+
}
|
|
266
|
+
listPulls(repo, search) {
|
|
267
|
+
const source = search.get('searchCriteria.sourceRefName');
|
|
268
|
+
const status = search.get('searchCriteria.status') ?? 'active';
|
|
269
|
+
if (status !== 'active')
|
|
270
|
+
throw fault(`searchCriteria.status=${status}`);
|
|
271
|
+
const value = this.pulls
|
|
272
|
+
.filter((p) => p.repository === repo.id && (source === null || p.sourceRefName === source))
|
|
273
|
+
.map((p) => this.renderPull(p));
|
|
274
|
+
return json(200, { count: value.length, value });
|
|
275
|
+
}
|
|
276
|
+
createPull(repo, body) {
|
|
277
|
+
const got = (body ?? {});
|
|
278
|
+
const { sourceRefName, targetRefName, title, description, isDraft } = got;
|
|
279
|
+
for (const name of Object.keys(got)) {
|
|
280
|
+
if (!['sourceRefName', 'targetRefName', 'title', 'description', 'isDraft'].includes(name))
|
|
281
|
+
throw fault(`a new pull request Nightjar may not send with ${name}`);
|
|
282
|
+
}
|
|
283
|
+
if (typeof sourceRefName !== 'string' || typeof targetRefName !== 'string')
|
|
284
|
+
throw fault('a pull request names its sourceRefName and targetRefName');
|
|
285
|
+
if (typeof title !== 'string' || typeof description !== 'string')
|
|
286
|
+
throw fault('a pull request carries a title and a description');
|
|
287
|
+
if (description.length > DESCRIPTION_LIMIT) {
|
|
288
|
+
return refusal(400, 'The pull request description is too long; it must be 4000 characters or less.');
|
|
289
|
+
}
|
|
290
|
+
if (this.pulls.some((p) => p.repository === repo.id &&
|
|
291
|
+
p.sourceRefName === sourceRefName &&
|
|
292
|
+
p.targetRefName === targetRefName)) {
|
|
293
|
+
return refusal(409, 'TF401179: An active pull request for the source and target branch already exists.');
|
|
294
|
+
}
|
|
295
|
+
const made = {
|
|
296
|
+
pullRequestId: 100 + this.pulls.length + 1,
|
|
297
|
+
repository: repo.id,
|
|
298
|
+
sourceRefName,
|
|
299
|
+
targetRefName,
|
|
300
|
+
title,
|
|
301
|
+
description,
|
|
302
|
+
isDraft: isDraft === true,
|
|
303
|
+
status: 'active',
|
|
304
|
+
comments: []
|
|
305
|
+
};
|
|
306
|
+
this.pulls.push(made);
|
|
307
|
+
return json(201, this.renderPull(made));
|
|
308
|
+
}
|
|
309
|
+
// Only the title and description are Nightjar's to change: a pull request is never completed, abandoned or published.
|
|
310
|
+
updatePull(pull, body) {
|
|
311
|
+
const got = (body ?? {});
|
|
312
|
+
for (const name of Object.keys(got)) {
|
|
313
|
+
if (!['title', 'description'].includes(name))
|
|
314
|
+
throw fault(`a pull request update Nightjar may not send: ${name}`);
|
|
315
|
+
}
|
|
316
|
+
const { description, title } = got;
|
|
317
|
+
if (typeof description === 'string' && description.length > DESCRIPTION_LIMIT) {
|
|
318
|
+
return refusal(400, 'The pull request description is too long; it must be 4000 characters or less.');
|
|
319
|
+
}
|
|
320
|
+
const changed = {
|
|
321
|
+
...pull,
|
|
322
|
+
...(typeof description === 'string' ? { description } : {}),
|
|
323
|
+
...(typeof title === 'string' ? { title } : {})
|
|
324
|
+
};
|
|
325
|
+
this.replacePull(changed);
|
|
326
|
+
return json(200, this.renderPull(changed));
|
|
327
|
+
}
|
|
328
|
+
// Only a link to a pull request is Nightjar's to add this way: any other patch is a fault.
|
|
329
|
+
linkArtifact(id, body) {
|
|
330
|
+
const item = this.items.get(id);
|
|
331
|
+
if (item === undefined)
|
|
332
|
+
return refusal(404, `TF401232: Work item ${String(id)} does not exist.`);
|
|
333
|
+
if (!Array.isArray(body))
|
|
334
|
+
throw fault('a patch is a list of operations');
|
|
335
|
+
const links = [...(this.artifacts.get(id) ?? [])];
|
|
336
|
+
for (const op of body) {
|
|
337
|
+
const value = op.value;
|
|
338
|
+
const url = value?.url;
|
|
339
|
+
if (op.op !== 'add' ||
|
|
340
|
+
op.path !== '/relations/-' ||
|
|
341
|
+
value?.rel !== 'ArtifactLink' ||
|
|
342
|
+
typeof url !== 'string' ||
|
|
343
|
+
!url.startsWith('vstfs:///Git/PullRequestId/')) {
|
|
344
|
+
throw fault(`a patch Nightjar may not send: ${JSON.stringify(op)}`);
|
|
345
|
+
}
|
|
346
|
+
if (links.some((l) => l.toLowerCase() === url.toLowerCase())) {
|
|
347
|
+
return refusal(400, `TF201036: You cannot add a link of this type: the link already exists.`);
|
|
348
|
+
}
|
|
349
|
+
links.push(url);
|
|
350
|
+
}
|
|
351
|
+
this.artifacts.set(id, links);
|
|
352
|
+
const changed = { ...item, rev: item.rev + 1 };
|
|
353
|
+
this.items.set(id, changed);
|
|
354
|
+
return json(200, this.render(changed, undefined, false));
|
|
355
|
+
}
|
|
356
|
+
replacePull(pull) {
|
|
357
|
+
const at = this.pulls.findIndex((p) => p.pullRequestId === pull.pullRequestId);
|
|
358
|
+
this.pulls[at] = pull;
|
|
359
|
+
}
|
|
360
|
+
artifactOf(pull) {
|
|
361
|
+
return `vstfs:///Git/PullRequestId/${PROJECT_ID}%2F${pull.repository}%2F${String(pull.pullRequestId)}`;
|
|
362
|
+
}
|
|
363
|
+
renderRepository(repo) {
|
|
364
|
+
const web = `https://dev.azure.com/${encodeURIComponent(this.organization)}/${encodeURIComponent(this.project)}/_git/${encodeURIComponent(repo.name)}`;
|
|
365
|
+
return {
|
|
366
|
+
id: repo.id,
|
|
367
|
+
name: repo.name,
|
|
368
|
+
project: { id: PROJECT_ID, name: this.project },
|
|
369
|
+
remoteUrl: web,
|
|
370
|
+
webUrl: web
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
renderPull(pull) {
|
|
374
|
+
const repo = [...this.repositories.values()].find((r) => r.id === pull.repository);
|
|
375
|
+
if (repo === undefined)
|
|
376
|
+
throw fault('a pull request without its repo');
|
|
377
|
+
return {
|
|
378
|
+
pullRequestId: pull.pullRequestId,
|
|
379
|
+
repository: this.renderRepository(repo),
|
|
380
|
+
status: pull.status,
|
|
381
|
+
title: pull.title,
|
|
382
|
+
description: pull.description,
|
|
383
|
+
sourceRefName: pull.sourceRefName,
|
|
384
|
+
targetRefName: pull.targetRefName,
|
|
385
|
+
isDraft: pull.isDraft,
|
|
386
|
+
artifactId: this.artifactOf(pull),
|
|
387
|
+
url: `https://dev.azure.com/${encodeURIComponent(this.organization)}/_apis/git/repositories/${repo.id}/pullRequests/${String(pull.pullRequestId)}`
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
// The query a call carries must be exactly `wanted`, besides the `optional` names.
|
|
391
|
+
query(url, wanted, optional = []) {
|
|
392
|
+
for (const [name, value] of Object.entries(wanted)) {
|
|
393
|
+
if (url.searchParams.get(name) !== value)
|
|
394
|
+
throw fault(`${url.href} needs ${name}=${value}`);
|
|
395
|
+
}
|
|
396
|
+
for (const name of url.searchParams.keys()) {
|
|
397
|
+
if (!(name in wanted) && !optional.includes(name))
|
|
398
|
+
throw fault(`${url.href} takes no ${name}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// The clauses Nightjar writes, joined by AND; any other WIQL is a fault, not an empty answer.
|
|
402
|
+
wiql(body) {
|
|
403
|
+
const query = body?.query;
|
|
404
|
+
if (typeof query !== 'string')
|
|
405
|
+
throw fault('a WIQL call carries { query }');
|
|
406
|
+
const shape = /^SELECT \[System\.Id\] FROM WorkItems WHERE (.+) ORDER BY \[System\.Id\]$/s.exec(query.trim());
|
|
407
|
+
if (!shape)
|
|
408
|
+
throw fault(`WIQL of a shape not known: ${query}`);
|
|
409
|
+
const clause = /\s*(?:AND\s+)?\[([\w.]+)\]\s+(=|CONTAINS)\s+('(?:[^']|'')*'|@project|@Me)\s*/y;
|
|
410
|
+
const tests = [];
|
|
411
|
+
const where = shape[1] ?? '';
|
|
412
|
+
while (clause.lastIndex < where.length) {
|
|
413
|
+
const at = clause.lastIndex;
|
|
414
|
+
const found = clause.exec(where);
|
|
415
|
+
if (!found)
|
|
416
|
+
throw fault(`WIQL clause not known at "${where.slice(at)}"`);
|
|
417
|
+
tests.push(this.clause(found[1] ?? '', found[2] ?? '', found[3] ?? ''));
|
|
418
|
+
}
|
|
419
|
+
const ids = [...this.items.values()].filter((i) => tests.every((t) => t(i))).map((i) => i.id);
|
|
420
|
+
return json(200, {
|
|
421
|
+
queryType: 'flat',
|
|
422
|
+
workItems: ids.sort((a, b) => a - b).map((id) => ({ id, url: this.itemUrl(id) }))
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
clause(field, op, raw) {
|
|
426
|
+
if (raw === '@project') {
|
|
427
|
+
if (field !== 'System.TeamProject' || op !== '=')
|
|
428
|
+
throw fault(`@project with ${field}`);
|
|
429
|
+
return () => true;
|
|
430
|
+
}
|
|
431
|
+
if (raw === '@Me') {
|
|
432
|
+
if (field !== 'System.AssignedTo' || op !== '=')
|
|
433
|
+
throw fault(`@Me with ${field}`);
|
|
434
|
+
return this.clause(field, op, `'${this.person.replace(/'/g, "''")}'`);
|
|
435
|
+
}
|
|
436
|
+
const value = raw.slice(1, -1).replace(/''/g, "'").toLowerCase();
|
|
437
|
+
const text = (item) => {
|
|
438
|
+
const got = item.fields[field];
|
|
439
|
+
const shown = typeof got === 'object' && got !== null ? got.uniqueName : got;
|
|
440
|
+
return typeof shown === 'string' ? shown.toLowerCase() : '';
|
|
441
|
+
};
|
|
442
|
+
if (op === 'CONTAINS' && field === 'System.Tags') {
|
|
443
|
+
return (item) => tagList(item.fields['System.Tags']).some((t) => t.toLowerCase() === value);
|
|
444
|
+
}
|
|
445
|
+
if (op === '=' && ['System.WorkItemType', 'System.AssignedTo'].includes(field)) {
|
|
446
|
+
return (item) => text(item) === value;
|
|
447
|
+
}
|
|
448
|
+
throw fault(`WIQL clause not known: [${field}] ${op} ${raw}`);
|
|
449
|
+
}
|
|
450
|
+
// Azure DevOps refuses $expand beside a field list; errorPolicy=omit puts a null for an id it cannot show.
|
|
451
|
+
batch(search) {
|
|
452
|
+
const url = new URL(`https://dev.azure.com/?${search.toString()}`);
|
|
453
|
+
this.query(url, { 'api-version': API }, ['ids', '$expand', 'fields', 'errorPolicy']);
|
|
454
|
+
const expand = search.get('$expand');
|
|
455
|
+
const fields = search.get('fields')?.split(',');
|
|
456
|
+
if (expand !== null && fields !== undefined) {
|
|
457
|
+
return refusal(400, 'VS403062: The expand parameter can not be used with the fields parameter.');
|
|
458
|
+
}
|
|
459
|
+
if (expand !== null && expand !== 'relations')
|
|
460
|
+
throw fault(`$expand=${expand}`);
|
|
461
|
+
const omit = search.get('errorPolicy');
|
|
462
|
+
if (omit !== null && omit !== 'omit')
|
|
463
|
+
throw fault(`errorPolicy=${omit}`);
|
|
464
|
+
const ids = (search.get('ids') ?? '')
|
|
465
|
+
.split(',')
|
|
466
|
+
.filter((s) => s !== '')
|
|
467
|
+
.map(Number);
|
|
468
|
+
if (ids.length === 0 || ids.length > 200)
|
|
469
|
+
throw fault(`ids takes 1 to 200 ids, not ${String(ids.length)}`);
|
|
470
|
+
const value = [];
|
|
471
|
+
for (const id of ids) {
|
|
472
|
+
const item = this.items.get(id);
|
|
473
|
+
if (item === undefined && omit === null) {
|
|
474
|
+
return refusal(404, `TF401232: Work item ${String(id)} does not exist, or you do not have permissions to read it.`);
|
|
475
|
+
}
|
|
476
|
+
value.push(item === undefined ? null : this.render(item, fields, expand !== null));
|
|
477
|
+
}
|
|
478
|
+
return json(200, { count: value.length, value });
|
|
479
|
+
}
|
|
480
|
+
// Only the tags are Nightjar's to write: any other patch is a fault.
|
|
481
|
+
patch(id, body) {
|
|
482
|
+
const item = this.items.get(id);
|
|
483
|
+
if (item === undefined)
|
|
484
|
+
return refusal(404, `TF401232: Work item ${String(id)} does not exist.`);
|
|
485
|
+
if (!Array.isArray(body))
|
|
486
|
+
throw fault('a patch is a list of operations');
|
|
487
|
+
const fields = { ...item.fields };
|
|
488
|
+
for (const op of body) {
|
|
489
|
+
const dependency = op.op === 'add' && op.path === '/relations/-' ? relation(op.value) : null;
|
|
490
|
+
if (dependency?.rel === PREDECESSOR) {
|
|
491
|
+
const blocker = this.items.get(dependency.id);
|
|
492
|
+
if (blocker === undefined) {
|
|
493
|
+
return refusal(404, `TF401232: Work item ${String(dependency.id)} does not exist.`);
|
|
494
|
+
}
|
|
495
|
+
this.precede(blocker.id, id);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if ((op.op === 'add' || op.op === 'replace') && op.path === '/fields/System.Tags') {
|
|
499
|
+
const tags = [];
|
|
500
|
+
for (const tag of tagList(op.value)) {
|
|
501
|
+
if (!tags.some((t) => t.toLowerCase() === tag.toLowerCase()))
|
|
502
|
+
tags.push(tag);
|
|
503
|
+
}
|
|
504
|
+
if (tags.length === 0)
|
|
505
|
+
delete fields['System.Tags'];
|
|
506
|
+
else
|
|
507
|
+
fields['System.Tags'] = tags.join('; ');
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
throw fault(`a patch Nightjar may not send: ${JSON.stringify(op)}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const changed = { ...this.get(id), rev: item.rev + 1, fields };
|
|
514
|
+
this.items.set(id, changed);
|
|
515
|
+
return json(200, this.render(changed, undefined, false));
|
|
516
|
+
}
|
|
517
|
+
// A work item made with fields and a parent link, as Nightjar makes a Feature or Ticket; any other field is a fault.
|
|
518
|
+
create(type, body) {
|
|
519
|
+
if (!Array.isArray(body))
|
|
520
|
+
throw fault('a work item is made from a list of operations');
|
|
521
|
+
let title;
|
|
522
|
+
let description;
|
|
523
|
+
let markdown = false;
|
|
524
|
+
let parent;
|
|
525
|
+
for (const op of body) {
|
|
526
|
+
if (op.op !== 'add')
|
|
527
|
+
throw fault(`a work item is made with add, not ${JSON.stringify(op)}`);
|
|
528
|
+
const value = op.value;
|
|
529
|
+
if (op.path === '/fields/System.Title' && typeof value === 'string')
|
|
530
|
+
title = value;
|
|
531
|
+
else if (op.path === '/fields/System.Description' && typeof value === 'string') {
|
|
532
|
+
description = value;
|
|
533
|
+
}
|
|
534
|
+
else if (op.path === '/multilineFieldsFormat/System.Description') {
|
|
535
|
+
markdown = typeof value === 'string' && value.toLowerCase() === 'markdown';
|
|
536
|
+
}
|
|
537
|
+
else if (op.path === '/relations/-' && relation(value)?.rel === PARENT) {
|
|
538
|
+
parent = relation(value)?.id;
|
|
539
|
+
if (parent === undefined || !this.items.has(parent)) {
|
|
540
|
+
return refusal(404, `TF401232: Work item ${String(parent)} does not exist.`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
else
|
|
544
|
+
throw fault(`a field Nightjar may not make: ${JSON.stringify(op)}`);
|
|
545
|
+
}
|
|
546
|
+
if (title === undefined || title.trim() === '') {
|
|
547
|
+
return refusal(400, 'TF401320: Rule Error for field Title. Error code: Required.');
|
|
548
|
+
}
|
|
549
|
+
const id = this.workItem({
|
|
550
|
+
type,
|
|
551
|
+
title,
|
|
552
|
+
...(description === undefined ? {} : { description, html: !markdown })
|
|
553
|
+
});
|
|
554
|
+
if (parent !== undefined)
|
|
555
|
+
this.addChild(parent, id);
|
|
556
|
+
return json(200, this.render(this.get(id), undefined, true));
|
|
557
|
+
}
|
|
558
|
+
// Azure DevOps answers newest first unless asked otherwise, a page at a time.
|
|
559
|
+
comments(id, search) {
|
|
560
|
+
const url = new URL(`https://dev.azure.com/?${search.toString()}`);
|
|
561
|
+
this.query(url, { 'api-version': COMMENTS_API }, ['$top', 'order', 'continuationToken']);
|
|
562
|
+
const all = [...(this.discussion.get(id) ?? [])];
|
|
563
|
+
if (search.get('order') !== 'asc')
|
|
564
|
+
all.reverse();
|
|
565
|
+
const top = Number(search.get('$top') ?? '200');
|
|
566
|
+
const from = Number(search.get('continuationToken') ?? '0');
|
|
567
|
+
const page = all.slice(from, from + top);
|
|
568
|
+
return json(200, {
|
|
569
|
+
totalCount: all.length,
|
|
570
|
+
count: page.length,
|
|
571
|
+
comments: page.map((c) => this.renderComment(id, c)),
|
|
572
|
+
...(from + top < all.length ? { continuationToken: String(from + top) } : {})
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
addComment(id, search, body) {
|
|
576
|
+
const url = new URL(`https://dev.azure.com/?${search.toString()}`);
|
|
577
|
+
this.query(url, { 'api-version': COMMENTS_API }, ['format']);
|
|
578
|
+
const text = body?.text;
|
|
579
|
+
if (typeof text !== 'string')
|
|
580
|
+
throw fault('a comment carries { text }');
|
|
581
|
+
const html = search.get('format') !== 'markdown';
|
|
582
|
+
this.say(id, this.person, text, { html });
|
|
583
|
+
const list = this.discussion.get(id) ?? [];
|
|
584
|
+
const made = list[list.length - 1];
|
|
585
|
+
if (made === undefined)
|
|
586
|
+
throw fault('the comment was not kept');
|
|
587
|
+
return json(200, this.renderComment(id, made));
|
|
588
|
+
}
|
|
589
|
+
// Two reads of the identity host: the project's Contributors group by name, and a person by id with every group above them.
|
|
590
|
+
identities(search) {
|
|
591
|
+
const url = new URL(`https://vssps.dev.azure.com/?${search.toString()}`);
|
|
592
|
+
const contributors = `[${this.project}]\\Contributors`;
|
|
593
|
+
if (search.has('searchFilter')) {
|
|
594
|
+
this.query(url, {
|
|
595
|
+
'api-version': API,
|
|
596
|
+
searchFilter: 'General',
|
|
597
|
+
filterValue: contributors,
|
|
598
|
+
queryMembership: 'None'
|
|
599
|
+
});
|
|
600
|
+
return json(200, {
|
|
601
|
+
count: 1,
|
|
602
|
+
value: [
|
|
603
|
+
{
|
|
604
|
+
id: guid(9001),
|
|
605
|
+
descriptor: CONTRIBUTORS,
|
|
606
|
+
providerDisplayName: contributors,
|
|
607
|
+
isContainer: true
|
|
608
|
+
}
|
|
609
|
+
]
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
this.query(url, { 'api-version': API, identityIds: search.get('identityIds') ?? '' }, [
|
|
613
|
+
'queryMembership'
|
|
614
|
+
]);
|
|
615
|
+
const ids = (search.get('identityIds') ?? '').split(',');
|
|
616
|
+
const expanded = search.get('queryMembership') === 'ExpandedUp';
|
|
617
|
+
const value = ids.map((id) => {
|
|
618
|
+
const person = [...this.people.values()].find((p) => p.id === id);
|
|
619
|
+
if (person === undefined)
|
|
620
|
+
return null;
|
|
621
|
+
// a person reaches Contributors through their team's group, so only an expanded read shows it
|
|
622
|
+
const groups = person.contributor ? [TEAM, ...(expanded ? [CONTRIBUTORS] : [])] : [READERS];
|
|
623
|
+
return {
|
|
624
|
+
id: person.id,
|
|
625
|
+
descriptor: `Microsoft.IdentityModel.Claims.ClaimsIdentity;contoso\\${person.uniqueName}`,
|
|
626
|
+
providerDisplayName: person.uniqueName,
|
|
627
|
+
isContainer: false,
|
|
628
|
+
memberOf: groups
|
|
629
|
+
};
|
|
630
|
+
});
|
|
631
|
+
return json(200, { count: value.length, value });
|
|
632
|
+
}
|
|
633
|
+
render(item, fields, relations) {
|
|
634
|
+
const shown = fields
|
|
635
|
+
? Object.fromEntries(Object.entries(item.fields).filter(([name]) => fields.includes(name)))
|
|
636
|
+
: item.fields;
|
|
637
|
+
return {
|
|
638
|
+
id: item.id,
|
|
639
|
+
rev: item.rev,
|
|
640
|
+
fields: shown,
|
|
641
|
+
...(item.markdown && 'System.Description' in shown
|
|
642
|
+
? { multilineFieldsFormat: { 'System.Description': 'markdown' } }
|
|
643
|
+
: {}),
|
|
644
|
+
...(relations && item.links.length > 0
|
|
645
|
+
? {
|
|
646
|
+
relations: item.links.map((l) => ({
|
|
647
|
+
rel: l.rel,
|
|
648
|
+
url: this.itemUrl(l.id),
|
|
649
|
+
attributes: { isLocked: false, name: l.rel === CHILD ? 'Child' : 'Other' }
|
|
650
|
+
}))
|
|
651
|
+
}
|
|
652
|
+
: {}),
|
|
653
|
+
url: this.itemUrl(item.id)
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
renderComment(workItemId, c) {
|
|
657
|
+
const by = {
|
|
658
|
+
id: c.author.id,
|
|
659
|
+
displayName: c.author.uniqueName,
|
|
660
|
+
uniqueName: c.author.uniqueName
|
|
661
|
+
};
|
|
662
|
+
return {
|
|
663
|
+
workItemId,
|
|
664
|
+
id: c.id,
|
|
665
|
+
version: 1,
|
|
666
|
+
text: c.text,
|
|
667
|
+
format: c.markdown ? 'markdown' : 'html',
|
|
668
|
+
createdBy: by,
|
|
669
|
+
createdDate: c.createdDate,
|
|
670
|
+
modifiedBy: by,
|
|
671
|
+
modifiedDate: c.createdDate,
|
|
672
|
+
isDeleted: false
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
identity(uniqueName) {
|
|
676
|
+
const p = this.who(uniqueName);
|
|
677
|
+
return { displayName: p.uniqueName, uniqueName: p.uniqueName, id: p.id };
|
|
678
|
+
}
|
|
679
|
+
// A person the organization knows; one it does not yet is added without Contributors.
|
|
680
|
+
who(uniqueName) {
|
|
681
|
+
const found = this.people.get(uniqueName.toLowerCase());
|
|
682
|
+
if (found)
|
|
683
|
+
return found;
|
|
684
|
+
this.join(uniqueName, { contributor: false });
|
|
685
|
+
return this.who(uniqueName);
|
|
686
|
+
}
|
|
687
|
+
link(from, rel, to) {
|
|
688
|
+
const item = this.get(from);
|
|
689
|
+
this.get(to);
|
|
690
|
+
this.items.set(from, { ...item, links: [...item.links, { rel, id: to }] });
|
|
691
|
+
}
|
|
692
|
+
itemUrl(id) {
|
|
693
|
+
return `https://dev.azure.com/${encodeURIComponent(this.organization)}/_apis/wit/workItems/${String(id)}`;
|
|
694
|
+
}
|
|
695
|
+
get(id) {
|
|
696
|
+
const item = this.items.get(id);
|
|
697
|
+
if (item === undefined)
|
|
698
|
+
throw fault(`no work item ${String(id)}`);
|
|
699
|
+
return item;
|
|
700
|
+
}
|
|
701
|
+
tick() {
|
|
702
|
+
this.clock += 60_000;
|
|
703
|
+
return new Date(this.clock).toISOString();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const CONTRIBUTORS = 'Microsoft.TeamFoundation.Identity;S-1-9-1551374245-1-contributors';
|
|
707
|
+
const TEAM = 'Microsoft.TeamFoundation.Identity;S-1-9-1551374245-2-team';
|
|
708
|
+
const READERS = 'Microsoft.TeamFoundation.Identity;S-1-9-1551374245-3-readers';
|
|
709
|
+
const PROJECT_ID = guid(4000);
|
|
710
|
+
// the most characters Azure Repos keeps in a pull request's description
|
|
711
|
+
const DESCRIPTION_LIMIT = 4000;
|
|
712
|
+
function tagList(value) {
|
|
713
|
+
if (typeof value !== 'string')
|
|
714
|
+
return [];
|
|
715
|
+
return value
|
|
716
|
+
.split(';')
|
|
717
|
+
.map((t) => t.trim())
|
|
718
|
+
.filter((t) => t !== '');
|
|
719
|
+
}
|
|
720
|
+
// The link a patch adds: its type and the id its address names.
|
|
721
|
+
function relation(value) {
|
|
722
|
+
const link = value;
|
|
723
|
+
if (typeof link?.rel !== 'string' || typeof link.url !== 'string')
|
|
724
|
+
return null;
|
|
725
|
+
const id = /\/workItems\/(\d+)$/i.exec(link.url)?.[1];
|
|
726
|
+
return id === undefined ? null : { rel: link.rel, id: Number(id) };
|
|
727
|
+
}
|
|
728
|
+
//# sourceMappingURL=fake-azure-devops.js.map
|