@peter.prib/renderengine 0.4.0 → 0.5.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/REFERENCE.md +15 -0
- package/dist/renderengine-workspace.umd.js +333 -2
- package/dist/renderengine.umd.js +333 -2
- package/package.json +1 -1
- package/src/charting/requirements/AzureDevOpsApi.js +55 -0
- package/src/charting/requirements/GitLabApi.js +43 -0
- package/src/charting/requirements/JiraApi.js +69 -0
- package/src/charting/requirements/TrackerApi.js +142 -0
- package/src/charting/requirements/TrackerExport.js +3 -2
- package/src/charting/requirements/index.js +5 -0
- package/src/charting/requirements/trackerApis.js +21 -0
- package/src/index.js +2 -1
package/REFERENCE.md
CHANGED
|
@@ -2614,6 +2614,16 @@ open model:
|
|
|
2614
2614
|
or title. Ids are the tracker's keys, so a newer export brought in replaces what it had. Where each tracker keeps
|
|
2615
2615
|
each field is data (`TRACKERS`); a team's own column is given with `new TrackerExport({ columns: { criteria:
|
|
2616
2616
|
['Done when'] } })` (in Jira's JSON, a custom field's key).
|
|
2617
|
+
- **A tracker, live** (`TrackerApi`; `JiraApi`, `AzureDevOpsApi`, `GitLabApi`): the issues a connection names, read
|
|
2618
|
+
through the tracker's API page by page — Jira by JQL (a project's by default; Cloud with an email and API token,
|
|
2619
|
+
Server / Data Center with a personal access token), Azure DevOps by WIQL (a project's work items but its tasks,
|
|
2620
|
+
200 at a time, each linked to its page in Boards), GitLab a project's issues (open and closed, filtered as its
|
|
2621
|
+
issues API filters). `read()` is the JSON `TrackerExport` reads, `model()` the model's spec, named as the
|
|
2622
|
+
connection is; at most `maxIssues` (5000) are read, `truncated` saying when there were more. `fetch` is given,
|
|
2623
|
+
since a tracker's token belongs where the request is made (a server); an error says what the tracker answered,
|
|
2624
|
+
never the token. What a connection asks for is each class's `fields` (`trackerConnectionFields()`: per tracker,
|
|
2625
|
+
`{key, label, type?, choices?, required?, secret?, placeholder?, hint?}`), for a host to build its form from;
|
|
2626
|
+
`problems()` what is missing before the tracker is asked.
|
|
2617
2627
|
- **A storyboard** (`StoryboardFileFormat`, `Storyboard`): Markdown — a `#` title names it, `##` are epics (or any
|
|
2618
2628
|
heading saying *Epic*), `###` stories (or *Story*); "As a … I want …" list items under an epic are stories; a
|
|
2619
2629
|
picture is kept by its caption.
|
|
@@ -2623,6 +2633,11 @@ const { RequirementsModel, TrackerExport } = window.RenderEngine;
|
|
|
2623
2633
|
const model = new RequirementsModel(new TrackerExport().read(jiraCsvText));
|
|
2624
2634
|
model.storyMap().columns.map(c => c.name); // the epics, across
|
|
2625
2635
|
model.coverage().criteriaShare; // how many stories say when they are done
|
|
2636
|
+
|
|
2637
|
+
// The same, live — on a server, where the token is kept:
|
|
2638
|
+
const api = trackerApi({ tracker: 'jira', name: 'Shop backlog', baseUrl: 'https://shop.atlassian.net',
|
|
2639
|
+
user: 'me@example.com', token: process.env.JIRA_TOKEN, project: 'SHOP' }, { fetch });
|
|
2640
|
+
const live = new RequirementsModel(await api.model());
|
|
2626
2641
|
```
|
|
2627
2642
|
|
|
2628
2643
|
`RequirementsPart` (type `requirements`, in a `ModelsPart` with `editorType: 'requirements'`) shows the model as
|
|
@@ -49300,15 +49300,16 @@
|
|
|
49300
49300
|
return found?.[0] ?? null;
|
|
49301
49301
|
}
|
|
49302
49302
|
|
|
49303
|
-
/** @private */
|
|
49303
|
+
/** @private — an API's JSON, given as text or already parsed. */
|
|
49304
49304
|
static _json(text) {
|
|
49305
|
+
if (text && typeof text === 'object') return text;
|
|
49305
49306
|
const s = String(text ?? '').trim();
|
|
49306
49307
|
if (!s.startsWith('{') && !s.startsWith('[')) return null;
|
|
49307
49308
|
try { return JSON.parse(s); } catch { return null; }
|
|
49308
49309
|
}
|
|
49309
49310
|
|
|
49310
49311
|
/**
|
|
49311
|
-
* The export as a requirements model's spec.
|
|
49312
|
+
* The export (its text; an API's JSON as text or parsed — TrackerApi) as a requirements model's spec.
|
|
49312
49313
|
* @returns {{epics: Array, releases: Array, stories: Array, tracker: string|null}}
|
|
49313
49314
|
*/
|
|
49314
49315
|
read(text) {
|
|
@@ -49542,6 +49543,329 @@
|
|
|
49542
49543
|
}
|
|
49543
49544
|
}
|
|
49544
49545
|
|
|
49546
|
+
/** Text as base64, whatever its characters (btoa takes only Latin-1). */
|
|
49547
|
+
function base64(text) {
|
|
49548
|
+
const bytes = new TextEncoder().encode(String(text));
|
|
49549
|
+
let binary = '';
|
|
49550
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
49551
|
+
return btoa(binary);
|
|
49552
|
+
}
|
|
49553
|
+
|
|
49554
|
+
/**
|
|
49555
|
+
* TrackerApi — a tracker read through its API rather than from an export: the
|
|
49556
|
+
* issues a connection names, page by page, as the JSON `TrackerExport` reads
|
|
49557
|
+
* (so an API's answer and a file saved from it make the same model). A
|
|
49558
|
+
* subclass per tracker (`JiraApi`, `AzureDevOpsApi`, `GitLabApi`) says where
|
|
49559
|
+
* its issues are, how it pages and how it is signed in to; what a connection
|
|
49560
|
+
* asks for is `fields`, which a host builds its form from.
|
|
49561
|
+
*
|
|
49562
|
+
* `fetch` is given (a server's, or a test's): a tracker's token belongs where
|
|
49563
|
+
* the request is made, which is seldom a browser. Nothing here keeps or logs
|
|
49564
|
+
* the token; an error says what the tracker answered, not what was sent.
|
|
49565
|
+
*/
|
|
49566
|
+
class TrackerApi {
|
|
49567
|
+
/** The tracker's key in TRACKERS ('jira'). */
|
|
49568
|
+
static tracker = null;
|
|
49569
|
+
|
|
49570
|
+
/** Most issues read from one connection: a connection naming a whole site is stopped there (`truncated`). */
|
|
49571
|
+
static MAX_ISSUES = 5000;
|
|
49572
|
+
|
|
49573
|
+
/**
|
|
49574
|
+
* What a connection asks for, in order: `{key, label, type?, choices?,
|
|
49575
|
+
* required?, secret?, placeholder?, hint?}` — `secret` written, never
|
|
49576
|
+
* shown back. The tracker's own between its address and its token.
|
|
49577
|
+
*/
|
|
49578
|
+
static get fields() {
|
|
49579
|
+
return [
|
|
49580
|
+
{ key: 'name', label: 'Name', required: true, placeholder: 'What people pick it by, e.g. "Shop backlog"' },
|
|
49581
|
+
{ key: 'baseUrl', label: 'Address', required: true, placeholder: this.baseUrlPlaceholder },
|
|
49582
|
+
...this.ownFields,
|
|
49583
|
+
{ key: 'token', label: 'Token', type: 'password', required: true, secret: true, hint: this.tokenHint },
|
|
49584
|
+
];
|
|
49585
|
+
}
|
|
49586
|
+
|
|
49587
|
+
/** @protected */
|
|
49588
|
+
static get ownFields() { return []; }
|
|
49589
|
+
|
|
49590
|
+
/** @protected */
|
|
49591
|
+
static baseUrlPlaceholder = 'https://…';
|
|
49592
|
+
|
|
49593
|
+
/** @protected */
|
|
49594
|
+
static tokenHint = 'An access token that may read the issues';
|
|
49595
|
+
|
|
49596
|
+
/**
|
|
49597
|
+
* @param {Object} connection - Its `fields`' values (and `columns`, a TrackerExport's own columns)
|
|
49598
|
+
* @param {Object} [options]
|
|
49599
|
+
* @param {Function} [options.fetch] - The fetch to read with (default: the global one)
|
|
49600
|
+
* @param {number} [options.maxIssues] - Most issues read (default MAX_ISSUES)
|
|
49601
|
+
*/
|
|
49602
|
+
constructor(connection, { fetch = globalThis.fetch, maxIssues = new.target.MAX_ISSUES } = {}) {
|
|
49603
|
+
this.connection = connection ?? {};
|
|
49604
|
+
this.fetch = fetch;
|
|
49605
|
+
this.maxIssues = maxIssues;
|
|
49606
|
+
/** Whether the last read stopped at maxIssues. */
|
|
49607
|
+
this.truncated = false;
|
|
49608
|
+
}
|
|
49609
|
+
|
|
49610
|
+
get tracker() { return this.constructor.tracker; }
|
|
49611
|
+
|
|
49612
|
+
/** What the tracker is called ('Jira'). */
|
|
49613
|
+
get label() { return TrackerExport.label(this.tracker); }
|
|
49614
|
+
|
|
49615
|
+
/** What is wrong with the connection, before anything is asked of the tracker: messages, none when it will do. */
|
|
49616
|
+
problems() {
|
|
49617
|
+
const c = this.connection;
|
|
49618
|
+
const out = this.constructor.fields.filter(f => f.required && !String(c[f.key] ?? '').trim()).map(f => `${f.label} is needed.`);
|
|
49619
|
+
if (c.baseUrl && !/^https?:\/\/[^\s/]+/i.test(String(c.baseUrl).trim())) out.push(`Address must start http:// or https:// ("${c.baseUrl}").`);
|
|
49620
|
+
return out;
|
|
49621
|
+
}
|
|
49622
|
+
|
|
49623
|
+
/** The issues, as the tracker's API gives them (the JSON TrackerExport reads). */
|
|
49624
|
+
async read() {
|
|
49625
|
+
const problems = this.problems();
|
|
49626
|
+
if (problems.length) throw new Error(`${this.label} connection "${this.connection.name ?? ''}": ${problems.join(' ')}`);
|
|
49627
|
+
this.truncated = false;
|
|
49628
|
+
return this._read();
|
|
49629
|
+
}
|
|
49630
|
+
|
|
49631
|
+
/** The issues as a requirements model's spec, named as the connection is. */
|
|
49632
|
+
async model() {
|
|
49633
|
+
const json = await this.read();
|
|
49634
|
+
const { tracker, ...spec } = new TrackerExport({ tracker: this.tracker, columns: this.connection.columns ?? {} }).read(json);
|
|
49635
|
+
return { name: this.connection.name || `${this.label} issues`, ...spec };
|
|
49636
|
+
}
|
|
49637
|
+
|
|
49638
|
+
/** @protected @abstract */
|
|
49639
|
+
async _read() {
|
|
49640
|
+
throw new Error(`${this.constructor.name} does not say how to read ${this.label}`);
|
|
49641
|
+
}
|
|
49642
|
+
|
|
49643
|
+
/** The headers that sign in. @protected @abstract */
|
|
49644
|
+
headers() {
|
|
49645
|
+
return {};
|
|
49646
|
+
}
|
|
49647
|
+
|
|
49648
|
+
/** A path on the tracker's address. @protected */
|
|
49649
|
+
url(path) {
|
|
49650
|
+
return `${String(this.connection.baseUrl).trim().replace(/\/+$/, '')}${path}`;
|
|
49651
|
+
}
|
|
49652
|
+
|
|
49653
|
+
/**
|
|
49654
|
+
* A request to the tracker, its answer's JSON and headers; an answer
|
|
49655
|
+
* other than OK an error saying what the tracker said. @protected
|
|
49656
|
+
*/
|
|
49657
|
+
async request(url, { method = 'GET', body } = {}) {
|
|
49658
|
+
if (typeof this.fetch !== 'function') throw new Error('No fetch to read the tracker with');
|
|
49659
|
+
const res = await this.fetch(url, {
|
|
49660
|
+
method,
|
|
49661
|
+
headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...this.headers() },
|
|
49662
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
49663
|
+
});
|
|
49664
|
+
if (!res.ok) {
|
|
49665
|
+
const text = await res.text().catch(() => '');
|
|
49666
|
+
throw new Error(`${this.label} answered ${res.status}${res.statusText ? ` ${res.statusText}` : ''}${text ? `: ${text.slice(0, 300)}` : ''}`);
|
|
49667
|
+
}
|
|
49668
|
+
return { json: await res.json(), headers: res.headers };
|
|
49669
|
+
}
|
|
49670
|
+
|
|
49671
|
+
/** Issues kept up to maxIssues; true once it is reached. @protected */
|
|
49672
|
+
keep(into, more) {
|
|
49673
|
+
for (const issue of more) {
|
|
49674
|
+
if (into.length >= this.maxIssues) { this.truncated = true; return true; }
|
|
49675
|
+
into.push(issue);
|
|
49676
|
+
}
|
|
49677
|
+
return into.length >= this.maxIssues;
|
|
49678
|
+
}
|
|
49679
|
+
|
|
49680
|
+
/** @protected */
|
|
49681
|
+
static base64 = base64;
|
|
49682
|
+
|
|
49683
|
+
/** @protected — the tracker's issue types not read (TRACKERS: its sub-tasks). */
|
|
49684
|
+
get skipTypes() { return TRACKERS[this.tracker]?.skipTypes ?? []; }
|
|
49685
|
+
}
|
|
49686
|
+
|
|
49687
|
+
/**
|
|
49688
|
+
* Jira through its REST API: the issues a JQL query finds (a project's, by
|
|
49689
|
+
* default), every field (custom ones too: story points, acceptance criteria).
|
|
49690
|
+
* Jira Cloud signs in with an email and an API token and pages by token
|
|
49691
|
+
* (`/rest/api/3/search/jql`); Jira Server and Data Center with a personal
|
|
49692
|
+
* access token (no email), paging by offset (`/rest/api/2/search`).
|
|
49693
|
+
*/
|
|
49694
|
+
class JiraApi extends TrackerApi {
|
|
49695
|
+
static tracker = 'jira';
|
|
49696
|
+
|
|
49697
|
+
static PAGE = 100;
|
|
49698
|
+
|
|
49699
|
+
static baseUrlPlaceholder = 'https://your-site.atlassian.net';
|
|
49700
|
+
|
|
49701
|
+
static tokenHint = 'Jira Cloud: an API token (with the email above). Server / Data Center: a personal access token (no email).';
|
|
49702
|
+
|
|
49703
|
+
static get ownFields() {
|
|
49704
|
+
return [
|
|
49705
|
+
{ key: 'edition', label: 'Jira', type: 'select', choices: [['cloud', 'Cloud'], ['server', 'Server / Data Center']], value: 'cloud' },
|
|
49706
|
+
{ key: 'user', label: 'Email', placeholder: 'The account the API token is for (Cloud)' },
|
|
49707
|
+
{ key: 'project', label: 'Project key', placeholder: 'SHOP — or leave it and give a JQL query' },
|
|
49708
|
+
{ key: 'query', label: 'JQL', placeholder: 'project = SHOP AND issuetype != Sub-task (instead of the project)' },
|
|
49709
|
+
];
|
|
49710
|
+
}
|
|
49711
|
+
|
|
49712
|
+
problems() {
|
|
49713
|
+
const out = super.problems();
|
|
49714
|
+
if (!String(this.connection.project ?? '').trim() && !String(this.connection.query ?? '').trim()) out.push('A project key or a JQL query is needed.');
|
|
49715
|
+
return out;
|
|
49716
|
+
}
|
|
49717
|
+
|
|
49718
|
+
/** The JQL: the one given, else the project's issues in key order. */
|
|
49719
|
+
get jql() {
|
|
49720
|
+
const { query, project } = this.connection;
|
|
49721
|
+
if (String(query ?? '').trim()) return String(query).trim();
|
|
49722
|
+
return `project = "${String(project).trim().replace(/"/g, '\\"')}" ORDER BY key`;
|
|
49723
|
+
}
|
|
49724
|
+
|
|
49725
|
+
headers() {
|
|
49726
|
+
const { user, token } = this.connection;
|
|
49727
|
+
return { Authorization: String(user ?? '').trim() ? `Basic ${TrackerApi.base64(`${String(user).trim()}:${token}`)}` : `Bearer ${token}` };
|
|
49728
|
+
}
|
|
49729
|
+
|
|
49730
|
+
async _read() {
|
|
49731
|
+
const issues = [];
|
|
49732
|
+
const page = this.constructor.PAGE;
|
|
49733
|
+
const query = (extra) => new URLSearchParams({ jql: this.jql, fields: '*all', maxResults: String(page), ...extra }).toString();
|
|
49734
|
+
if (this.connection.edition === 'server') {
|
|
49735
|
+
for (let startAt = 0; ; startAt += page) {
|
|
49736
|
+
const { json } = await this.request(this.url(`/rest/api/2/search?${query({ startAt: String(startAt) })}`));
|
|
49737
|
+
const got = json.issues ?? [];
|
|
49738
|
+
const more = got.length > 0 && startAt + got.length < (json.total ?? 0);
|
|
49739
|
+
if (this.keep(issues, got)) { this.truncated ||= more; break; }
|
|
49740
|
+
if (!more) break;
|
|
49741
|
+
}
|
|
49742
|
+
} else {
|
|
49743
|
+
for (let token = null; ;) {
|
|
49744
|
+
const { json } = await this.request(this.url(`/rest/api/3/search/jql?${query(token ? { nextPageToken: token } : {})}`));
|
|
49745
|
+
const got = json.issues ?? [];
|
|
49746
|
+
token = json.isLast ? null : json.nextPageToken ?? null;
|
|
49747
|
+
if (this.keep(issues, got)) { this.truncated ||= !!token; break; }
|
|
49748
|
+
if (!token || !got.length) break;
|
|
49749
|
+
}
|
|
49750
|
+
}
|
|
49751
|
+
return { issues };
|
|
49752
|
+
}
|
|
49753
|
+
}
|
|
49754
|
+
|
|
49755
|
+
/**
|
|
49756
|
+
* Azure DevOps (Boards) through its REST API: a WIQL query for a project's
|
|
49757
|
+
* work items (but its tasks — TRACKERS), then the items, 200 at a time. Signs
|
|
49758
|
+
* in with a personal access token. Each item's link is its page in Boards.
|
|
49759
|
+
*/
|
|
49760
|
+
class AzureDevOpsApi extends TrackerApi {
|
|
49761
|
+
static tracker = 'azure';
|
|
49762
|
+
|
|
49763
|
+
/** Most ids one work-items request takes. */
|
|
49764
|
+
static BATCH = 200;
|
|
49765
|
+
|
|
49766
|
+
static API_VERSION = '7.1';
|
|
49767
|
+
|
|
49768
|
+
static baseUrlPlaceholder = 'https://dev.azure.com/your-organisation';
|
|
49769
|
+
|
|
49770
|
+
static tokenHint = 'A personal access token with Work Items (Read)';
|
|
49771
|
+
|
|
49772
|
+
static get ownFields() {
|
|
49773
|
+
return [
|
|
49774
|
+
{ key: 'project', label: 'Project', required: true, placeholder: 'The project, e.g. Shop' },
|
|
49775
|
+
{ key: 'query', label: 'WIQL condition', placeholder: "[System.AreaPath] UNDER 'Shop\\Checkout' (and the project's)" },
|
|
49776
|
+
];
|
|
49777
|
+
}
|
|
49778
|
+
|
|
49779
|
+
headers() {
|
|
49780
|
+
return { Authorization: `Basic ${TrackerApi.base64(`:${this.connection.token}`)}` };
|
|
49781
|
+
}
|
|
49782
|
+
|
|
49783
|
+
/** The WIQL: the project's work items, not its tasks, and the condition given. */
|
|
49784
|
+
get wiql() {
|
|
49785
|
+
const types = this.skipTypes.map(t => `'${t.replace(/'/g, "''")}'`).join(', ');
|
|
49786
|
+
const condition = String(this.connection.query ?? '').trim();
|
|
49787
|
+
return `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project`
|
|
49788
|
+
+ (types ? ` AND [System.WorkItemType] NOT IN (${types})` : '')
|
|
49789
|
+
+ (condition ? ` AND (${condition})` : '')
|
|
49790
|
+
+ ' ORDER BY [System.Id]';
|
|
49791
|
+
}
|
|
49792
|
+
|
|
49793
|
+
async _read() {
|
|
49794
|
+
const project = encodeURIComponent(String(this.connection.project).trim());
|
|
49795
|
+
const version = `api-version=${this.constructor.API_VERSION}`;
|
|
49796
|
+
const { json } = await this.request(this.url(`/${project}/_apis/wit/wiql?${version}`), { method: 'POST', body: { query: this.wiql } });
|
|
49797
|
+
const ids = (json.workItems ?? []).map(w => w.id);
|
|
49798
|
+
const value = [];
|
|
49799
|
+
for (let i = 0; i < ids.length; i += this.constructor.BATCH) {
|
|
49800
|
+
const batch = ids.slice(i, i + this.constructor.BATCH);
|
|
49801
|
+
const { json: items } = await this.request(this.url(`/${project}/_apis/wit/workitems?ids=${batch.join(',')}&${version}`));
|
|
49802
|
+
const got = (items.value ?? []).map(w => ({ ...w, url: this.url(`/${project}/_workitems/edit/${w.id}`) }));
|
|
49803
|
+
if (this.keep(value, got)) { this.truncated ||= i + batch.length < ids.length; break; }
|
|
49804
|
+
}
|
|
49805
|
+
return { value };
|
|
49806
|
+
}
|
|
49807
|
+
}
|
|
49808
|
+
|
|
49809
|
+
/**
|
|
49810
|
+
* GitLab through its REST API: a project's issues, open and closed, 100 a
|
|
49811
|
+
* page; filtered as its issues API filters (`labels=…&milestone=…`). Signs
|
|
49812
|
+
* in with a personal, project or group access token. An issue's epic
|
|
49813
|
+
* (GitLab Premium) is its epic.
|
|
49814
|
+
*/
|
|
49815
|
+
class GitLabApi extends TrackerApi {
|
|
49816
|
+
static tracker = 'gitlab';
|
|
49817
|
+
|
|
49818
|
+
static PAGE = 100;
|
|
49819
|
+
|
|
49820
|
+
static baseUrlPlaceholder = 'https://gitlab.com';
|
|
49821
|
+
|
|
49822
|
+
static tokenHint = 'An access token with read_api';
|
|
49823
|
+
|
|
49824
|
+
static get ownFields() {
|
|
49825
|
+
return [
|
|
49826
|
+
{ key: 'project', label: 'Project', required: true, placeholder: 'group/project, or its id' },
|
|
49827
|
+
{ key: 'query', label: 'Filter', placeholder: 'labels=requirements&milestone=Release 2 (as the issues API takes)' },
|
|
49828
|
+
];
|
|
49829
|
+
}
|
|
49830
|
+
|
|
49831
|
+
headers() {
|
|
49832
|
+
return { 'PRIVATE-TOKEN': String(this.connection.token) };
|
|
49833
|
+
}
|
|
49834
|
+
|
|
49835
|
+
async _read() {
|
|
49836
|
+
const project = encodeURIComponent(String(this.connection.project).trim());
|
|
49837
|
+
const issues = [];
|
|
49838
|
+
for (let page = 1; page;) {
|
|
49839
|
+
const params = new URLSearchParams(String(this.connection.query ?? '').trim());
|
|
49840
|
+
for (const [k, v] of Object.entries({ state: params.get('state') ?? 'all', per_page: String(this.constructor.PAGE), page: String(page) })) params.set(k, v);
|
|
49841
|
+
const { json, headers } = await this.request(this.url(`/api/v4/projects/${project}/issues?${params}`));
|
|
49842
|
+
const got = Array.isArray(json) ? json : [];
|
|
49843
|
+
const next = Number(headers?.get?.('x-next-page')) || (got.length === this.constructor.PAGE && !headers?.has?.('x-next-page') ? page + 1 : 0);
|
|
49844
|
+
if (this.keep(issues, got)) { this.truncated ||= !!next; break; }
|
|
49845
|
+
page = got.length ? next : 0;
|
|
49846
|
+
}
|
|
49847
|
+
return issues;
|
|
49848
|
+
}
|
|
49849
|
+
}
|
|
49850
|
+
|
|
49851
|
+
/** Each tracker's API, by its key in TRACKERS. */
|
|
49852
|
+
const TRACKER_APIS = Object.freeze({ jira: JiraApi, azure: AzureDevOpsApi, gitlab: GitLabApi });
|
|
49853
|
+
|
|
49854
|
+
/**
|
|
49855
|
+
* The API reader for a connection (`connection.tracker`: 'jira', 'azure',
|
|
49856
|
+
* 'gitlab'); `options` as TrackerApi takes them.
|
|
49857
|
+
*/
|
|
49858
|
+
function trackerApi(connection, options) {
|
|
49859
|
+
const Api = TRACKER_APIS[connection?.tracker];
|
|
49860
|
+
if (!Api) throw new Error(`No API reader for tracker "${connection?.tracker ?? ''}" (${Object.keys(TRACKER_APIS).join(', ')})`);
|
|
49861
|
+
return new Api(connection, options);
|
|
49862
|
+
}
|
|
49863
|
+
|
|
49864
|
+
/** What each tracker's connection asks for: `[{tracker, label, fields}]`, for a host's form. */
|
|
49865
|
+
function trackerConnectionFields() {
|
|
49866
|
+
return Object.entries(TRACKER_APIS).map(([tracker, Api]) => ({ tracker, label: new Api({}).label, fields: Api.fields }));
|
|
49867
|
+
}
|
|
49868
|
+
|
|
49545
49869
|
/**
|
|
49546
49870
|
* propertiesDialog — the "Show/Edit Properties" dialog, as a shipped feature.
|
|
49547
49871
|
*
|
|
@@ -55801,6 +56125,7 @@ th { color: #555; font-weight: 500; }
|
|
|
55801
56125
|
exports.ArrowMarker = ArrowMarker;
|
|
55802
56126
|
exports.AssociationKind = AssociationKind;
|
|
55803
56127
|
exports.AttributedLevel = AttributedLevel;
|
|
56128
|
+
exports.AzureDevOpsApi = AzureDevOpsApi;
|
|
55804
56129
|
exports.BUILTIN_PARTS = BUILTIN_PARTS;
|
|
55805
56130
|
exports.BUILTIN_THEMES = BUILTIN_THEMES;
|
|
55806
56131
|
exports.BackgroundCommand = BackgroundCommand;
|
|
@@ -55909,6 +56234,7 @@ th { color: #555; font-weight: 500; }
|
|
|
55909
56234
|
exports.GRID_COLS = GRID_COLS;
|
|
55910
56235
|
exports.GRID_GAP_PX = GRID_GAP_PX;
|
|
55911
56236
|
exports.GeneralizationKind = GeneralizationKind;
|
|
56237
|
+
exports.GitLabApi = GitLabApi;
|
|
55912
56238
|
exports.Glyph = Glyph;
|
|
55913
56239
|
exports.GlyphTemplate = GlyphTemplate;
|
|
55914
56240
|
exports.GraphDataWindow = GraphDataWindow;
|
|
@@ -55925,6 +56251,7 @@ th { color: #555; font-weight: 500; }
|
|
|
55925
56251
|
exports.ITIconNotation = ITIconNotation;
|
|
55926
56252
|
exports.ImageSelector = ImageSelector;
|
|
55927
56253
|
exports.ItemGroup = ItemGroup;
|
|
56254
|
+
exports.JiraApi = JiraApi;
|
|
55928
56255
|
exports.KIND_PREFERENCE = KIND_PREFERENCE;
|
|
55929
56256
|
exports.LAYER_CHOICES = LAYER_CHOICES;
|
|
55930
56257
|
exports.LIGHT_THEME = LIGHT_THEME;
|
|
@@ -56019,6 +56346,7 @@ th { color: #555; font-weight: 500; }
|
|
|
56019
56346
|
exports.SwimlaneTemplate = SwimlaneTemplate;
|
|
56020
56347
|
exports.THEME_VARS = THEME_VARS;
|
|
56021
56348
|
exports.TRACKERS = TRACKERS;
|
|
56349
|
+
exports.TRACKER_APIS = TRACKER_APIS;
|
|
56022
56350
|
exports.TREE_LAYERS = TREE_LAYERS;
|
|
56023
56351
|
exports.TREE_MODE_CATALOG = TREE_MODE_CATALOG;
|
|
56024
56352
|
exports.TabsNode = TabsNode;
|
|
@@ -56033,6 +56361,7 @@ th { color: #555; font-weight: 500; }
|
|
|
56033
56361
|
exports.ToolbarSelect = ToolbarSelect;
|
|
56034
56362
|
exports.ToolbarSeparator = ToolbarSeparator;
|
|
56035
56363
|
exports.ToolbarText = ToolbarText;
|
|
56364
|
+
exports.TrackerApi = TrackerApi;
|
|
56036
56365
|
exports.TrackerExport = TrackerExport;
|
|
56037
56366
|
exports.TrackerExportFileFormat = TrackerExportFileFormat;
|
|
56038
56367
|
exports.TrapezoidTemplate = TrapezoidTemplate;
|
|
@@ -56137,6 +56466,8 @@ th { color: #555; font-weight: 500; }
|
|
|
56137
56466
|
exports.toArchimateModel = toArchimateModel;
|
|
56138
56467
|
exports.toExchangeXML = toExchangeXML;
|
|
56139
56468
|
exports.toPngBlob = toPngBlob;
|
|
56469
|
+
exports.trackerApi = trackerApi;
|
|
56470
|
+
exports.trackerConnectionFields = trackerConnectionFields;
|
|
56140
56471
|
exports.treeLayerOf = treeLayerOf;
|
|
56141
56472
|
exports.treeModeRegistry = treeModeRegistry;
|
|
56142
56473
|
exports.viewItemChanges = viewItemChanges;
|
package/dist/renderengine.umd.js
CHANGED
|
@@ -45535,15 +45535,16 @@
|
|
|
45535
45535
|
return found?.[0] ?? null;
|
|
45536
45536
|
}
|
|
45537
45537
|
|
|
45538
|
-
/** @private */
|
|
45538
|
+
/** @private — an API's JSON, given as text or already parsed. */
|
|
45539
45539
|
static _json(text) {
|
|
45540
|
+
if (text && typeof text === 'object') return text;
|
|
45540
45541
|
const s = String(text ?? '').trim();
|
|
45541
45542
|
if (!s.startsWith('{') && !s.startsWith('[')) return null;
|
|
45542
45543
|
try { return JSON.parse(s); } catch { return null; }
|
|
45543
45544
|
}
|
|
45544
45545
|
|
|
45545
45546
|
/**
|
|
45546
|
-
* The export as a requirements model's spec.
|
|
45547
|
+
* The export (its text; an API's JSON as text or parsed — TrackerApi) as a requirements model's spec.
|
|
45547
45548
|
* @returns {{epics: Array, releases: Array, stories: Array, tracker: string|null}}
|
|
45548
45549
|
*/
|
|
45549
45550
|
read(text) {
|
|
@@ -45777,6 +45778,329 @@
|
|
|
45777
45778
|
}
|
|
45778
45779
|
}
|
|
45779
45780
|
|
|
45781
|
+
/** Text as base64, whatever its characters (btoa takes only Latin-1). */
|
|
45782
|
+
function base64(text) {
|
|
45783
|
+
const bytes = new TextEncoder().encode(String(text));
|
|
45784
|
+
let binary = '';
|
|
45785
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
45786
|
+
return btoa(binary);
|
|
45787
|
+
}
|
|
45788
|
+
|
|
45789
|
+
/**
|
|
45790
|
+
* TrackerApi — a tracker read through its API rather than from an export: the
|
|
45791
|
+
* issues a connection names, page by page, as the JSON `TrackerExport` reads
|
|
45792
|
+
* (so an API's answer and a file saved from it make the same model). A
|
|
45793
|
+
* subclass per tracker (`JiraApi`, `AzureDevOpsApi`, `GitLabApi`) says where
|
|
45794
|
+
* its issues are, how it pages and how it is signed in to; what a connection
|
|
45795
|
+
* asks for is `fields`, which a host builds its form from.
|
|
45796
|
+
*
|
|
45797
|
+
* `fetch` is given (a server's, or a test's): a tracker's token belongs where
|
|
45798
|
+
* the request is made, which is seldom a browser. Nothing here keeps or logs
|
|
45799
|
+
* the token; an error says what the tracker answered, not what was sent.
|
|
45800
|
+
*/
|
|
45801
|
+
class TrackerApi {
|
|
45802
|
+
/** The tracker's key in TRACKERS ('jira'). */
|
|
45803
|
+
static tracker = null;
|
|
45804
|
+
|
|
45805
|
+
/** Most issues read from one connection: a connection naming a whole site is stopped there (`truncated`). */
|
|
45806
|
+
static MAX_ISSUES = 5000;
|
|
45807
|
+
|
|
45808
|
+
/**
|
|
45809
|
+
* What a connection asks for, in order: `{key, label, type?, choices?,
|
|
45810
|
+
* required?, secret?, placeholder?, hint?}` — `secret` written, never
|
|
45811
|
+
* shown back. The tracker's own between its address and its token.
|
|
45812
|
+
*/
|
|
45813
|
+
static get fields() {
|
|
45814
|
+
return [
|
|
45815
|
+
{ key: 'name', label: 'Name', required: true, placeholder: 'What people pick it by, e.g. "Shop backlog"' },
|
|
45816
|
+
{ key: 'baseUrl', label: 'Address', required: true, placeholder: this.baseUrlPlaceholder },
|
|
45817
|
+
...this.ownFields,
|
|
45818
|
+
{ key: 'token', label: 'Token', type: 'password', required: true, secret: true, hint: this.tokenHint },
|
|
45819
|
+
];
|
|
45820
|
+
}
|
|
45821
|
+
|
|
45822
|
+
/** @protected */
|
|
45823
|
+
static get ownFields() { return []; }
|
|
45824
|
+
|
|
45825
|
+
/** @protected */
|
|
45826
|
+
static baseUrlPlaceholder = 'https://…';
|
|
45827
|
+
|
|
45828
|
+
/** @protected */
|
|
45829
|
+
static tokenHint = 'An access token that may read the issues';
|
|
45830
|
+
|
|
45831
|
+
/**
|
|
45832
|
+
* @param {Object} connection - Its `fields`' values (and `columns`, a TrackerExport's own columns)
|
|
45833
|
+
* @param {Object} [options]
|
|
45834
|
+
* @param {Function} [options.fetch] - The fetch to read with (default: the global one)
|
|
45835
|
+
* @param {number} [options.maxIssues] - Most issues read (default MAX_ISSUES)
|
|
45836
|
+
*/
|
|
45837
|
+
constructor(connection, { fetch = globalThis.fetch, maxIssues = new.target.MAX_ISSUES } = {}) {
|
|
45838
|
+
this.connection = connection ?? {};
|
|
45839
|
+
this.fetch = fetch;
|
|
45840
|
+
this.maxIssues = maxIssues;
|
|
45841
|
+
/** Whether the last read stopped at maxIssues. */
|
|
45842
|
+
this.truncated = false;
|
|
45843
|
+
}
|
|
45844
|
+
|
|
45845
|
+
get tracker() { return this.constructor.tracker; }
|
|
45846
|
+
|
|
45847
|
+
/** What the tracker is called ('Jira'). */
|
|
45848
|
+
get label() { return TrackerExport.label(this.tracker); }
|
|
45849
|
+
|
|
45850
|
+
/** What is wrong with the connection, before anything is asked of the tracker: messages, none when it will do. */
|
|
45851
|
+
problems() {
|
|
45852
|
+
const c = this.connection;
|
|
45853
|
+
const out = this.constructor.fields.filter(f => f.required && !String(c[f.key] ?? '').trim()).map(f => `${f.label} is needed.`);
|
|
45854
|
+
if (c.baseUrl && !/^https?:\/\/[^\s/]+/i.test(String(c.baseUrl).trim())) out.push(`Address must start http:// or https:// ("${c.baseUrl}").`);
|
|
45855
|
+
return out;
|
|
45856
|
+
}
|
|
45857
|
+
|
|
45858
|
+
/** The issues, as the tracker's API gives them (the JSON TrackerExport reads). */
|
|
45859
|
+
async read() {
|
|
45860
|
+
const problems = this.problems();
|
|
45861
|
+
if (problems.length) throw new Error(`${this.label} connection "${this.connection.name ?? ''}": ${problems.join(' ')}`);
|
|
45862
|
+
this.truncated = false;
|
|
45863
|
+
return this._read();
|
|
45864
|
+
}
|
|
45865
|
+
|
|
45866
|
+
/** The issues as a requirements model's spec, named as the connection is. */
|
|
45867
|
+
async model() {
|
|
45868
|
+
const json = await this.read();
|
|
45869
|
+
const { tracker, ...spec } = new TrackerExport({ tracker: this.tracker, columns: this.connection.columns ?? {} }).read(json);
|
|
45870
|
+
return { name: this.connection.name || `${this.label} issues`, ...spec };
|
|
45871
|
+
}
|
|
45872
|
+
|
|
45873
|
+
/** @protected @abstract */
|
|
45874
|
+
async _read() {
|
|
45875
|
+
throw new Error(`${this.constructor.name} does not say how to read ${this.label}`);
|
|
45876
|
+
}
|
|
45877
|
+
|
|
45878
|
+
/** The headers that sign in. @protected @abstract */
|
|
45879
|
+
headers() {
|
|
45880
|
+
return {};
|
|
45881
|
+
}
|
|
45882
|
+
|
|
45883
|
+
/** A path on the tracker's address. @protected */
|
|
45884
|
+
url(path) {
|
|
45885
|
+
return `${String(this.connection.baseUrl).trim().replace(/\/+$/, '')}${path}`;
|
|
45886
|
+
}
|
|
45887
|
+
|
|
45888
|
+
/**
|
|
45889
|
+
* A request to the tracker, its answer's JSON and headers; an answer
|
|
45890
|
+
* other than OK an error saying what the tracker said. @protected
|
|
45891
|
+
*/
|
|
45892
|
+
async request(url, { method = 'GET', body } = {}) {
|
|
45893
|
+
if (typeof this.fetch !== 'function') throw new Error('No fetch to read the tracker with');
|
|
45894
|
+
const res = await this.fetch(url, {
|
|
45895
|
+
method,
|
|
45896
|
+
headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...this.headers() },
|
|
45897
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
45898
|
+
});
|
|
45899
|
+
if (!res.ok) {
|
|
45900
|
+
const text = await res.text().catch(() => '');
|
|
45901
|
+
throw new Error(`${this.label} answered ${res.status}${res.statusText ? ` ${res.statusText}` : ''}${text ? `: ${text.slice(0, 300)}` : ''}`);
|
|
45902
|
+
}
|
|
45903
|
+
return { json: await res.json(), headers: res.headers };
|
|
45904
|
+
}
|
|
45905
|
+
|
|
45906
|
+
/** Issues kept up to maxIssues; true once it is reached. @protected */
|
|
45907
|
+
keep(into, more) {
|
|
45908
|
+
for (const issue of more) {
|
|
45909
|
+
if (into.length >= this.maxIssues) { this.truncated = true; return true; }
|
|
45910
|
+
into.push(issue);
|
|
45911
|
+
}
|
|
45912
|
+
return into.length >= this.maxIssues;
|
|
45913
|
+
}
|
|
45914
|
+
|
|
45915
|
+
/** @protected */
|
|
45916
|
+
static base64 = base64;
|
|
45917
|
+
|
|
45918
|
+
/** @protected — the tracker's issue types not read (TRACKERS: its sub-tasks). */
|
|
45919
|
+
get skipTypes() { return TRACKERS[this.tracker]?.skipTypes ?? []; }
|
|
45920
|
+
}
|
|
45921
|
+
|
|
45922
|
+
/**
|
|
45923
|
+
* Jira through its REST API: the issues a JQL query finds (a project's, by
|
|
45924
|
+
* default), every field (custom ones too: story points, acceptance criteria).
|
|
45925
|
+
* Jira Cloud signs in with an email and an API token and pages by token
|
|
45926
|
+
* (`/rest/api/3/search/jql`); Jira Server and Data Center with a personal
|
|
45927
|
+
* access token (no email), paging by offset (`/rest/api/2/search`).
|
|
45928
|
+
*/
|
|
45929
|
+
class JiraApi extends TrackerApi {
|
|
45930
|
+
static tracker = 'jira';
|
|
45931
|
+
|
|
45932
|
+
static PAGE = 100;
|
|
45933
|
+
|
|
45934
|
+
static baseUrlPlaceholder = 'https://your-site.atlassian.net';
|
|
45935
|
+
|
|
45936
|
+
static tokenHint = 'Jira Cloud: an API token (with the email above). Server / Data Center: a personal access token (no email).';
|
|
45937
|
+
|
|
45938
|
+
static get ownFields() {
|
|
45939
|
+
return [
|
|
45940
|
+
{ key: 'edition', label: 'Jira', type: 'select', choices: [['cloud', 'Cloud'], ['server', 'Server / Data Center']], value: 'cloud' },
|
|
45941
|
+
{ key: 'user', label: 'Email', placeholder: 'The account the API token is for (Cloud)' },
|
|
45942
|
+
{ key: 'project', label: 'Project key', placeholder: 'SHOP — or leave it and give a JQL query' },
|
|
45943
|
+
{ key: 'query', label: 'JQL', placeholder: 'project = SHOP AND issuetype != Sub-task (instead of the project)' },
|
|
45944
|
+
];
|
|
45945
|
+
}
|
|
45946
|
+
|
|
45947
|
+
problems() {
|
|
45948
|
+
const out = super.problems();
|
|
45949
|
+
if (!String(this.connection.project ?? '').trim() && !String(this.connection.query ?? '').trim()) out.push('A project key or a JQL query is needed.');
|
|
45950
|
+
return out;
|
|
45951
|
+
}
|
|
45952
|
+
|
|
45953
|
+
/** The JQL: the one given, else the project's issues in key order. */
|
|
45954
|
+
get jql() {
|
|
45955
|
+
const { query, project } = this.connection;
|
|
45956
|
+
if (String(query ?? '').trim()) return String(query).trim();
|
|
45957
|
+
return `project = "${String(project).trim().replace(/"/g, '\\"')}" ORDER BY key`;
|
|
45958
|
+
}
|
|
45959
|
+
|
|
45960
|
+
headers() {
|
|
45961
|
+
const { user, token } = this.connection;
|
|
45962
|
+
return { Authorization: String(user ?? '').trim() ? `Basic ${TrackerApi.base64(`${String(user).trim()}:${token}`)}` : `Bearer ${token}` };
|
|
45963
|
+
}
|
|
45964
|
+
|
|
45965
|
+
async _read() {
|
|
45966
|
+
const issues = [];
|
|
45967
|
+
const page = this.constructor.PAGE;
|
|
45968
|
+
const query = (extra) => new URLSearchParams({ jql: this.jql, fields: '*all', maxResults: String(page), ...extra }).toString();
|
|
45969
|
+
if (this.connection.edition === 'server') {
|
|
45970
|
+
for (let startAt = 0; ; startAt += page) {
|
|
45971
|
+
const { json } = await this.request(this.url(`/rest/api/2/search?${query({ startAt: String(startAt) })}`));
|
|
45972
|
+
const got = json.issues ?? [];
|
|
45973
|
+
const more = got.length > 0 && startAt + got.length < (json.total ?? 0);
|
|
45974
|
+
if (this.keep(issues, got)) { this.truncated ||= more; break; }
|
|
45975
|
+
if (!more) break;
|
|
45976
|
+
}
|
|
45977
|
+
} else {
|
|
45978
|
+
for (let token = null; ;) {
|
|
45979
|
+
const { json } = await this.request(this.url(`/rest/api/3/search/jql?${query(token ? { nextPageToken: token } : {})}`));
|
|
45980
|
+
const got = json.issues ?? [];
|
|
45981
|
+
token = json.isLast ? null : json.nextPageToken ?? null;
|
|
45982
|
+
if (this.keep(issues, got)) { this.truncated ||= !!token; break; }
|
|
45983
|
+
if (!token || !got.length) break;
|
|
45984
|
+
}
|
|
45985
|
+
}
|
|
45986
|
+
return { issues };
|
|
45987
|
+
}
|
|
45988
|
+
}
|
|
45989
|
+
|
|
45990
|
+
/**
|
|
45991
|
+
* Azure DevOps (Boards) through its REST API: a WIQL query for a project's
|
|
45992
|
+
* work items (but its tasks — TRACKERS), then the items, 200 at a time. Signs
|
|
45993
|
+
* in with a personal access token. Each item's link is its page in Boards.
|
|
45994
|
+
*/
|
|
45995
|
+
class AzureDevOpsApi extends TrackerApi {
|
|
45996
|
+
static tracker = 'azure';
|
|
45997
|
+
|
|
45998
|
+
/** Most ids one work-items request takes. */
|
|
45999
|
+
static BATCH = 200;
|
|
46000
|
+
|
|
46001
|
+
static API_VERSION = '7.1';
|
|
46002
|
+
|
|
46003
|
+
static baseUrlPlaceholder = 'https://dev.azure.com/your-organisation';
|
|
46004
|
+
|
|
46005
|
+
static tokenHint = 'A personal access token with Work Items (Read)';
|
|
46006
|
+
|
|
46007
|
+
static get ownFields() {
|
|
46008
|
+
return [
|
|
46009
|
+
{ key: 'project', label: 'Project', required: true, placeholder: 'The project, e.g. Shop' },
|
|
46010
|
+
{ key: 'query', label: 'WIQL condition', placeholder: "[System.AreaPath] UNDER 'Shop\\Checkout' (and the project's)" },
|
|
46011
|
+
];
|
|
46012
|
+
}
|
|
46013
|
+
|
|
46014
|
+
headers() {
|
|
46015
|
+
return { Authorization: `Basic ${TrackerApi.base64(`:${this.connection.token}`)}` };
|
|
46016
|
+
}
|
|
46017
|
+
|
|
46018
|
+
/** The WIQL: the project's work items, not its tasks, and the condition given. */
|
|
46019
|
+
get wiql() {
|
|
46020
|
+
const types = this.skipTypes.map(t => `'${t.replace(/'/g, "''")}'`).join(', ');
|
|
46021
|
+
const condition = String(this.connection.query ?? '').trim();
|
|
46022
|
+
return `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project`
|
|
46023
|
+
+ (types ? ` AND [System.WorkItemType] NOT IN (${types})` : '')
|
|
46024
|
+
+ (condition ? ` AND (${condition})` : '')
|
|
46025
|
+
+ ' ORDER BY [System.Id]';
|
|
46026
|
+
}
|
|
46027
|
+
|
|
46028
|
+
async _read() {
|
|
46029
|
+
const project = encodeURIComponent(String(this.connection.project).trim());
|
|
46030
|
+
const version = `api-version=${this.constructor.API_VERSION}`;
|
|
46031
|
+
const { json } = await this.request(this.url(`/${project}/_apis/wit/wiql?${version}`), { method: 'POST', body: { query: this.wiql } });
|
|
46032
|
+
const ids = (json.workItems ?? []).map(w => w.id);
|
|
46033
|
+
const value = [];
|
|
46034
|
+
for (let i = 0; i < ids.length; i += this.constructor.BATCH) {
|
|
46035
|
+
const batch = ids.slice(i, i + this.constructor.BATCH);
|
|
46036
|
+
const { json: items } = await this.request(this.url(`/${project}/_apis/wit/workitems?ids=${batch.join(',')}&${version}`));
|
|
46037
|
+
const got = (items.value ?? []).map(w => ({ ...w, url: this.url(`/${project}/_workitems/edit/${w.id}`) }));
|
|
46038
|
+
if (this.keep(value, got)) { this.truncated ||= i + batch.length < ids.length; break; }
|
|
46039
|
+
}
|
|
46040
|
+
return { value };
|
|
46041
|
+
}
|
|
46042
|
+
}
|
|
46043
|
+
|
|
46044
|
+
/**
|
|
46045
|
+
* GitLab through its REST API: a project's issues, open and closed, 100 a
|
|
46046
|
+
* page; filtered as its issues API filters (`labels=…&milestone=…`). Signs
|
|
46047
|
+
* in with a personal, project or group access token. An issue's epic
|
|
46048
|
+
* (GitLab Premium) is its epic.
|
|
46049
|
+
*/
|
|
46050
|
+
class GitLabApi extends TrackerApi {
|
|
46051
|
+
static tracker = 'gitlab';
|
|
46052
|
+
|
|
46053
|
+
static PAGE = 100;
|
|
46054
|
+
|
|
46055
|
+
static baseUrlPlaceholder = 'https://gitlab.com';
|
|
46056
|
+
|
|
46057
|
+
static tokenHint = 'An access token with read_api';
|
|
46058
|
+
|
|
46059
|
+
static get ownFields() {
|
|
46060
|
+
return [
|
|
46061
|
+
{ key: 'project', label: 'Project', required: true, placeholder: 'group/project, or its id' },
|
|
46062
|
+
{ key: 'query', label: 'Filter', placeholder: 'labels=requirements&milestone=Release 2 (as the issues API takes)' },
|
|
46063
|
+
];
|
|
46064
|
+
}
|
|
46065
|
+
|
|
46066
|
+
headers() {
|
|
46067
|
+
return { 'PRIVATE-TOKEN': String(this.connection.token) };
|
|
46068
|
+
}
|
|
46069
|
+
|
|
46070
|
+
async _read() {
|
|
46071
|
+
const project = encodeURIComponent(String(this.connection.project).trim());
|
|
46072
|
+
const issues = [];
|
|
46073
|
+
for (let page = 1; page;) {
|
|
46074
|
+
const params = new URLSearchParams(String(this.connection.query ?? '').trim());
|
|
46075
|
+
for (const [k, v] of Object.entries({ state: params.get('state') ?? 'all', per_page: String(this.constructor.PAGE), page: String(page) })) params.set(k, v);
|
|
46076
|
+
const { json, headers } = await this.request(this.url(`/api/v4/projects/${project}/issues?${params}`));
|
|
46077
|
+
const got = Array.isArray(json) ? json : [];
|
|
46078
|
+
const next = Number(headers?.get?.('x-next-page')) || (got.length === this.constructor.PAGE && !headers?.has?.('x-next-page') ? page + 1 : 0);
|
|
46079
|
+
if (this.keep(issues, got)) { this.truncated ||= !!next; break; }
|
|
46080
|
+
page = got.length ? next : 0;
|
|
46081
|
+
}
|
|
46082
|
+
return issues;
|
|
46083
|
+
}
|
|
46084
|
+
}
|
|
46085
|
+
|
|
46086
|
+
/** Each tracker's API, by its key in TRACKERS. */
|
|
46087
|
+
const TRACKER_APIS = Object.freeze({ jira: JiraApi, azure: AzureDevOpsApi, gitlab: GitLabApi });
|
|
46088
|
+
|
|
46089
|
+
/**
|
|
46090
|
+
* The API reader for a connection (`connection.tracker`: 'jira', 'azure',
|
|
46091
|
+
* 'gitlab'); `options` as TrackerApi takes them.
|
|
46092
|
+
*/
|
|
46093
|
+
function trackerApi(connection, options) {
|
|
46094
|
+
const Api = TRACKER_APIS[connection?.tracker];
|
|
46095
|
+
if (!Api) throw new Error(`No API reader for tracker "${connection?.tracker ?? ''}" (${Object.keys(TRACKER_APIS).join(', ')})`);
|
|
46096
|
+
return new Api(connection, options);
|
|
46097
|
+
}
|
|
46098
|
+
|
|
46099
|
+
/** What each tracker's connection asks for: `[{tracker, label, fields}]`, for a host's form. */
|
|
46100
|
+
function trackerConnectionFields() {
|
|
46101
|
+
return Object.entries(TRACKER_APIS).map(([tracker, Api]) => ({ tracker, label: new Api({}).label, fields: Api.fields }));
|
|
46102
|
+
}
|
|
46103
|
+
|
|
45780
46104
|
/**
|
|
45781
46105
|
* propertiesDialog — the "Show/Edit Properties" dialog, as a shipped feature.
|
|
45782
46106
|
*
|
|
@@ -48686,6 +49010,7 @@
|
|
|
48686
49010
|
exports.ArrowMarker = ArrowMarker;
|
|
48687
49011
|
exports.AssociationKind = AssociationKind;
|
|
48688
49012
|
exports.AttributedLevel = AttributedLevel;
|
|
49013
|
+
exports.AzureDevOpsApi = AzureDevOpsApi;
|
|
48689
49014
|
exports.BackgroundCommand = BackgroundCommand;
|
|
48690
49015
|
exports.BackgroundGroup = BackgroundGroup;
|
|
48691
49016
|
exports.Benchmark = Benchmark;
|
|
@@ -48780,6 +49105,7 @@
|
|
|
48780
49105
|
exports.FormDialog = FormDialog;
|
|
48781
49106
|
exports.GLYPHS = GLYPHS;
|
|
48782
49107
|
exports.GeneralizationKind = GeneralizationKind;
|
|
49108
|
+
exports.GitLabApi = GitLabApi;
|
|
48783
49109
|
exports.Glyph = Glyph;
|
|
48784
49110
|
exports.GlyphTemplate = GlyphTemplate;
|
|
48785
49111
|
exports.GraphDataWindow = GraphDataWindow;
|
|
@@ -48794,6 +49120,7 @@
|
|
|
48794
49120
|
exports.ITIconNotation = ITIconNotation;
|
|
48795
49121
|
exports.ImageSelector = ImageSelector;
|
|
48796
49122
|
exports.ItemGroup = ItemGroup;
|
|
49123
|
+
exports.JiraApi = JiraApi;
|
|
48797
49124
|
exports.KIND_PREFERENCE = KIND_PREFERENCE;
|
|
48798
49125
|
exports.LAYER_CHOICES = LAYER_CHOICES;
|
|
48799
49126
|
exports.LINE_ONLY_PROPERTIES = LINE_ONLY_PROPERTIES;
|
|
@@ -48870,6 +49197,7 @@
|
|
|
48870
49197
|
exports.StructuredType = StructuredType;
|
|
48871
49198
|
exports.SwimlaneTemplate = SwimlaneTemplate;
|
|
48872
49199
|
exports.TRACKERS = TRACKERS;
|
|
49200
|
+
exports.TRACKER_APIS = TRACKER_APIS;
|
|
48873
49201
|
exports.TREE_LAYERS = TREE_LAYERS;
|
|
48874
49202
|
exports.TREE_MODE_CATALOG = TREE_MODE_CATALOG;
|
|
48875
49203
|
exports.Template = Template;
|
|
@@ -48883,6 +49211,7 @@
|
|
|
48883
49211
|
exports.ToolbarSelect = ToolbarSelect;
|
|
48884
49212
|
exports.ToolbarSeparator = ToolbarSeparator;
|
|
48885
49213
|
exports.ToolbarText = ToolbarText;
|
|
49214
|
+
exports.TrackerApi = TrackerApi;
|
|
48886
49215
|
exports.TrackerExport = TrackerExport;
|
|
48887
49216
|
exports.TrackerExportFileFormat = TrackerExportFileFormat;
|
|
48888
49217
|
exports.TrapezoidTemplate = TrapezoidTemplate;
|
|
@@ -48967,6 +49296,8 @@
|
|
|
48967
49296
|
exports.toArchimateModel = toArchimateModel;
|
|
48968
49297
|
exports.toExchangeXML = toExchangeXML;
|
|
48969
49298
|
exports.toPngBlob = toPngBlob;
|
|
49299
|
+
exports.trackerApi = trackerApi;
|
|
49300
|
+
exports.trackerConnectionFields = trackerConnectionFields;
|
|
48970
49301
|
exports.treeLayerOf = treeLayerOf;
|
|
48971
49302
|
exports.treeModeRegistry = treeModeRegistry;
|
|
48972
49303
|
exports.viewItemChanges = viewItemChanges;
|
package/package.json
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { TrackerApi } from './TrackerApi.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Azure DevOps (Boards) through its REST API: a WIQL query for a project's
|
|
5
|
+
* work items (but its tasks — TRACKERS), then the items, 200 at a time. Signs
|
|
6
|
+
* in with a personal access token. Each item's link is its page in Boards.
|
|
7
|
+
*/
|
|
8
|
+
export class AzureDevOpsApi extends TrackerApi {
|
|
9
|
+
static tracker = 'azure';
|
|
10
|
+
|
|
11
|
+
/** Most ids one work-items request takes. */
|
|
12
|
+
static BATCH = 200;
|
|
13
|
+
|
|
14
|
+
static API_VERSION = '7.1';
|
|
15
|
+
|
|
16
|
+
static baseUrlPlaceholder = 'https://dev.azure.com/your-organisation';
|
|
17
|
+
|
|
18
|
+
static tokenHint = 'A personal access token with Work Items (Read)';
|
|
19
|
+
|
|
20
|
+
static get ownFields() {
|
|
21
|
+
return [
|
|
22
|
+
{ key: 'project', label: 'Project', required: true, placeholder: 'The project, e.g. Shop' },
|
|
23
|
+
{ key: 'query', label: 'WIQL condition', placeholder: "[System.AreaPath] UNDER 'Shop\\Checkout' (and the project's)" },
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
headers() {
|
|
28
|
+
return { Authorization: `Basic ${TrackerApi.base64(`:${this.connection.token}`)}` };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The WIQL: the project's work items, not its tasks, and the condition given. */
|
|
32
|
+
get wiql() {
|
|
33
|
+
const types = this.skipTypes.map(t => `'${t.replace(/'/g, "''")}'`).join(', ');
|
|
34
|
+
const condition = String(this.connection.query ?? '').trim();
|
|
35
|
+
return `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project`
|
|
36
|
+
+ (types ? ` AND [System.WorkItemType] NOT IN (${types})` : '')
|
|
37
|
+
+ (condition ? ` AND (${condition})` : '')
|
|
38
|
+
+ ' ORDER BY [System.Id]';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async _read() {
|
|
42
|
+
const project = encodeURIComponent(String(this.connection.project).trim());
|
|
43
|
+
const version = `api-version=${this.constructor.API_VERSION}`;
|
|
44
|
+
const { json } = await this.request(this.url(`/${project}/_apis/wit/wiql?${version}`), { method: 'POST', body: { query: this.wiql } });
|
|
45
|
+
const ids = (json.workItems ?? []).map(w => w.id);
|
|
46
|
+
const value = [];
|
|
47
|
+
for (let i = 0; i < ids.length; i += this.constructor.BATCH) {
|
|
48
|
+
const batch = ids.slice(i, i + this.constructor.BATCH);
|
|
49
|
+
const { json: items } = await this.request(this.url(`/${project}/_apis/wit/workitems?ids=${batch.join(',')}&${version}`));
|
|
50
|
+
const got = (items.value ?? []).map(w => ({ ...w, url: this.url(`/${project}/_workitems/edit/${w.id}`) }));
|
|
51
|
+
if (this.keep(value, got)) { this.truncated ||= i + batch.length < ids.length; break; }
|
|
52
|
+
}
|
|
53
|
+
return { value };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { TrackerApi } from './TrackerApi.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitLab through its REST API: a project's issues, open and closed, 100 a
|
|
5
|
+
* page; filtered as its issues API filters (`labels=…&milestone=…`). Signs
|
|
6
|
+
* in with a personal, project or group access token. An issue's epic
|
|
7
|
+
* (GitLab Premium) is its epic.
|
|
8
|
+
*/
|
|
9
|
+
export class GitLabApi extends TrackerApi {
|
|
10
|
+
static tracker = 'gitlab';
|
|
11
|
+
|
|
12
|
+
static PAGE = 100;
|
|
13
|
+
|
|
14
|
+
static baseUrlPlaceholder = 'https://gitlab.com';
|
|
15
|
+
|
|
16
|
+
static tokenHint = 'An access token with read_api';
|
|
17
|
+
|
|
18
|
+
static get ownFields() {
|
|
19
|
+
return [
|
|
20
|
+
{ key: 'project', label: 'Project', required: true, placeholder: 'group/project, or its id' },
|
|
21
|
+
{ key: 'query', label: 'Filter', placeholder: 'labels=requirements&milestone=Release 2 (as the issues API takes)' },
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
headers() {
|
|
26
|
+
return { 'PRIVATE-TOKEN': String(this.connection.token) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async _read() {
|
|
30
|
+
const project = encodeURIComponent(String(this.connection.project).trim());
|
|
31
|
+
const issues = [];
|
|
32
|
+
for (let page = 1; page;) {
|
|
33
|
+
const params = new URLSearchParams(String(this.connection.query ?? '').trim());
|
|
34
|
+
for (const [k, v] of Object.entries({ state: params.get('state') ?? 'all', per_page: String(this.constructor.PAGE), page: String(page) })) params.set(k, v);
|
|
35
|
+
const { json, headers } = await this.request(this.url(`/api/v4/projects/${project}/issues?${params}`));
|
|
36
|
+
const got = Array.isArray(json) ? json : [];
|
|
37
|
+
const next = Number(headers?.get?.('x-next-page')) || (got.length === this.constructor.PAGE && !headers?.has?.('x-next-page') ? page + 1 : 0);
|
|
38
|
+
if (this.keep(issues, got)) { this.truncated ||= !!next; break; }
|
|
39
|
+
page = got.length ? next : 0;
|
|
40
|
+
}
|
|
41
|
+
return issues;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { TrackerApi } from './TrackerApi.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Jira through its REST API: the issues a JQL query finds (a project's, by
|
|
5
|
+
* default), every field (custom ones too: story points, acceptance criteria).
|
|
6
|
+
* Jira Cloud signs in with an email and an API token and pages by token
|
|
7
|
+
* (`/rest/api/3/search/jql`); Jira Server and Data Center with a personal
|
|
8
|
+
* access token (no email), paging by offset (`/rest/api/2/search`).
|
|
9
|
+
*/
|
|
10
|
+
export class JiraApi extends TrackerApi {
|
|
11
|
+
static tracker = 'jira';
|
|
12
|
+
|
|
13
|
+
static PAGE = 100;
|
|
14
|
+
|
|
15
|
+
static baseUrlPlaceholder = 'https://your-site.atlassian.net';
|
|
16
|
+
|
|
17
|
+
static tokenHint = 'Jira Cloud: an API token (with the email above). Server / Data Center: a personal access token (no email).';
|
|
18
|
+
|
|
19
|
+
static get ownFields() {
|
|
20
|
+
return [
|
|
21
|
+
{ key: 'edition', label: 'Jira', type: 'select', choices: [['cloud', 'Cloud'], ['server', 'Server / Data Center']], value: 'cloud' },
|
|
22
|
+
{ key: 'user', label: 'Email', placeholder: 'The account the API token is for (Cloud)' },
|
|
23
|
+
{ key: 'project', label: 'Project key', placeholder: 'SHOP — or leave it and give a JQL query' },
|
|
24
|
+
{ key: 'query', label: 'JQL', placeholder: 'project = SHOP AND issuetype != Sub-task (instead of the project)' },
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
problems() {
|
|
29
|
+
const out = super.problems();
|
|
30
|
+
if (!String(this.connection.project ?? '').trim() && !String(this.connection.query ?? '').trim()) out.push('A project key or a JQL query is needed.');
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The JQL: the one given, else the project's issues in key order. */
|
|
35
|
+
get jql() {
|
|
36
|
+
const { query, project } = this.connection;
|
|
37
|
+
if (String(query ?? '').trim()) return String(query).trim();
|
|
38
|
+
return `project = "${String(project).trim().replace(/"/g, '\\"')}" ORDER BY key`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
headers() {
|
|
42
|
+
const { user, token } = this.connection;
|
|
43
|
+
return { Authorization: String(user ?? '').trim() ? `Basic ${TrackerApi.base64(`${String(user).trim()}:${token}`)}` : `Bearer ${token}` };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async _read() {
|
|
47
|
+
const issues = [];
|
|
48
|
+
const page = this.constructor.PAGE;
|
|
49
|
+
const query = (extra) => new URLSearchParams({ jql: this.jql, fields: '*all', maxResults: String(page), ...extra }).toString();
|
|
50
|
+
if (this.connection.edition === 'server') {
|
|
51
|
+
for (let startAt = 0; ; startAt += page) {
|
|
52
|
+
const { json } = await this.request(this.url(`/rest/api/2/search?${query({ startAt: String(startAt) })}`));
|
|
53
|
+
const got = json.issues ?? [];
|
|
54
|
+
const more = got.length > 0 && startAt + got.length < (json.total ?? 0);
|
|
55
|
+
if (this.keep(issues, got)) { this.truncated ||= more; break; }
|
|
56
|
+
if (!more) break;
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
for (let token = null; ;) {
|
|
60
|
+
const { json } = await this.request(this.url(`/rest/api/3/search/jql?${query(token ? { nextPageToken: token } : {})}`));
|
|
61
|
+
const got = json.issues ?? [];
|
|
62
|
+
token = json.isLast ? null : json.nextPageToken ?? null;
|
|
63
|
+
if (this.keep(issues, got)) { this.truncated ||= !!token; break; }
|
|
64
|
+
if (!token || !got.length) break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { issues };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { TrackerExport, TRACKERS } from './TrackerExport.js';
|
|
2
|
+
|
|
3
|
+
/** Text as base64, whatever its characters (btoa takes only Latin-1). */
|
|
4
|
+
function base64(text) {
|
|
5
|
+
const bytes = new TextEncoder().encode(String(text));
|
|
6
|
+
let binary = '';
|
|
7
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
8
|
+
return btoa(binary);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* TrackerApi — a tracker read through its API rather than from an export: the
|
|
13
|
+
* issues a connection names, page by page, as the JSON `TrackerExport` reads
|
|
14
|
+
* (so an API's answer and a file saved from it make the same model). A
|
|
15
|
+
* subclass per tracker (`JiraApi`, `AzureDevOpsApi`, `GitLabApi`) says where
|
|
16
|
+
* its issues are, how it pages and how it is signed in to; what a connection
|
|
17
|
+
* asks for is `fields`, which a host builds its form from.
|
|
18
|
+
*
|
|
19
|
+
* `fetch` is given (a server's, or a test's): a tracker's token belongs where
|
|
20
|
+
* the request is made, which is seldom a browser. Nothing here keeps or logs
|
|
21
|
+
* the token; an error says what the tracker answered, not what was sent.
|
|
22
|
+
*/
|
|
23
|
+
export class TrackerApi {
|
|
24
|
+
/** The tracker's key in TRACKERS ('jira'). */
|
|
25
|
+
static tracker = null;
|
|
26
|
+
|
|
27
|
+
/** Most issues read from one connection: a connection naming a whole site is stopped there (`truncated`). */
|
|
28
|
+
static MAX_ISSUES = 5000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What a connection asks for, in order: `{key, label, type?, choices?,
|
|
32
|
+
* required?, secret?, placeholder?, hint?}` — `secret` written, never
|
|
33
|
+
* shown back. The tracker's own between its address and its token.
|
|
34
|
+
*/
|
|
35
|
+
static get fields() {
|
|
36
|
+
return [
|
|
37
|
+
{ key: 'name', label: 'Name', required: true, placeholder: 'What people pick it by, e.g. "Shop backlog"' },
|
|
38
|
+
{ key: 'baseUrl', label: 'Address', required: true, placeholder: this.baseUrlPlaceholder },
|
|
39
|
+
...this.ownFields,
|
|
40
|
+
{ key: 'token', label: 'Token', type: 'password', required: true, secret: true, hint: this.tokenHint },
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @protected */
|
|
45
|
+
static get ownFields() { return []; }
|
|
46
|
+
|
|
47
|
+
/** @protected */
|
|
48
|
+
static baseUrlPlaceholder = 'https://…';
|
|
49
|
+
|
|
50
|
+
/** @protected */
|
|
51
|
+
static tokenHint = 'An access token that may read the issues';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {Object} connection - Its `fields`' values (and `columns`, a TrackerExport's own columns)
|
|
55
|
+
* @param {Object} [options]
|
|
56
|
+
* @param {Function} [options.fetch] - The fetch to read with (default: the global one)
|
|
57
|
+
* @param {number} [options.maxIssues] - Most issues read (default MAX_ISSUES)
|
|
58
|
+
*/
|
|
59
|
+
constructor(connection, { fetch = globalThis.fetch, maxIssues = new.target.MAX_ISSUES } = {}) {
|
|
60
|
+
this.connection = connection ?? {};
|
|
61
|
+
this.fetch = fetch;
|
|
62
|
+
this.maxIssues = maxIssues;
|
|
63
|
+
/** Whether the last read stopped at maxIssues. */
|
|
64
|
+
this.truncated = false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get tracker() { return this.constructor.tracker; }
|
|
68
|
+
|
|
69
|
+
/** What the tracker is called ('Jira'). */
|
|
70
|
+
get label() { return TrackerExport.label(this.tracker); }
|
|
71
|
+
|
|
72
|
+
/** What is wrong with the connection, before anything is asked of the tracker: messages, none when it will do. */
|
|
73
|
+
problems() {
|
|
74
|
+
const c = this.connection;
|
|
75
|
+
const out = this.constructor.fields.filter(f => f.required && !String(c[f.key] ?? '').trim()).map(f => `${f.label} is needed.`);
|
|
76
|
+
if (c.baseUrl && !/^https?:\/\/[^\s/]+/i.test(String(c.baseUrl).trim())) out.push(`Address must start http:// or https:// ("${c.baseUrl}").`);
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The issues, as the tracker's API gives them (the JSON TrackerExport reads). */
|
|
81
|
+
async read() {
|
|
82
|
+
const problems = this.problems();
|
|
83
|
+
if (problems.length) throw new Error(`${this.label} connection "${this.connection.name ?? ''}": ${problems.join(' ')}`);
|
|
84
|
+
this.truncated = false;
|
|
85
|
+
return this._read();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The issues as a requirements model's spec, named as the connection is. */
|
|
89
|
+
async model() {
|
|
90
|
+
const json = await this.read();
|
|
91
|
+
const { tracker, ...spec } = new TrackerExport({ tracker: this.tracker, columns: this.connection.columns ?? {} }).read(json);
|
|
92
|
+
return { name: this.connection.name || `${this.label} issues`, ...spec };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @protected @abstract */
|
|
96
|
+
async _read() {
|
|
97
|
+
throw new Error(`${this.constructor.name} does not say how to read ${this.label}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The headers that sign in. @protected @abstract */
|
|
101
|
+
headers() {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A path on the tracker's address. @protected */
|
|
106
|
+
url(path) {
|
|
107
|
+
return `${String(this.connection.baseUrl).trim().replace(/\/+$/, '')}${path}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A request to the tracker, its answer's JSON and headers; an answer
|
|
112
|
+
* other than OK an error saying what the tracker said. @protected
|
|
113
|
+
*/
|
|
114
|
+
async request(url, { method = 'GET', body } = {}) {
|
|
115
|
+
if (typeof this.fetch !== 'function') throw new Error('No fetch to read the tracker with');
|
|
116
|
+
const res = await this.fetch(url, {
|
|
117
|
+
method,
|
|
118
|
+
headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...this.headers() },
|
|
119
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
const text = await res.text().catch(() => '');
|
|
123
|
+
throw new Error(`${this.label} answered ${res.status}${res.statusText ? ` ${res.statusText}` : ''}${text ? `: ${text.slice(0, 300)}` : ''}`);
|
|
124
|
+
}
|
|
125
|
+
return { json: await res.json(), headers: res.headers };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Issues kept up to maxIssues; true once it is reached. @protected */
|
|
129
|
+
keep(into, more) {
|
|
130
|
+
for (const issue of more) {
|
|
131
|
+
if (into.length >= this.maxIssues) { this.truncated = true; return true; }
|
|
132
|
+
into.push(issue);
|
|
133
|
+
}
|
|
134
|
+
return into.length >= this.maxIssues;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @protected */
|
|
138
|
+
static base64 = base64;
|
|
139
|
+
|
|
140
|
+
/** @protected — the tracker's issue types not read (TRACKERS: its sub-tasks). */
|
|
141
|
+
get skipTypes() { return TRACKERS[this.tracker]?.skipTypes ?? []; }
|
|
142
|
+
}
|
|
@@ -167,15 +167,16 @@ export class TrackerExport {
|
|
|
167
167
|
return found?.[0] ?? null;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
/** @private */
|
|
170
|
+
/** @private — an API's JSON, given as text or already parsed. */
|
|
171
171
|
static _json(text) {
|
|
172
|
+
if (text && typeof text === 'object') return text;
|
|
172
173
|
const s = String(text ?? '').trim();
|
|
173
174
|
if (!s.startsWith('{') && !s.startsWith('[')) return null;
|
|
174
175
|
try { return JSON.parse(s); } catch { return null; }
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
/**
|
|
178
|
-
* The export as a requirements model's spec.
|
|
179
|
+
* The export (its text; an API's JSON as text or parsed — TrackerApi) as a requirements model's spec.
|
|
179
180
|
* @returns {{epics: Array, releases: Array, stories: Array, tracker: string|null}}
|
|
180
181
|
*/
|
|
181
182
|
read(text) {
|
|
@@ -5,3 +5,8 @@ export { RequirementsFileFormat } from './RequirementsFileFormat.js';
|
|
|
5
5
|
export { TrackerExportFileFormat, StoryboardFileFormat } from './RequirementsImportFormats.js';
|
|
6
6
|
export { TrackerExport, TRACKERS, criteriaFrom, narrativeFrom, withoutNarrative } from './TrackerExport.js';
|
|
7
7
|
export { Storyboard } from './Storyboard.js';
|
|
8
|
+
export { TrackerApi } from './TrackerApi.js';
|
|
9
|
+
export { JiraApi } from './JiraApi.js';
|
|
10
|
+
export { AzureDevOpsApi } from './AzureDevOpsApi.js';
|
|
11
|
+
export { GitLabApi } from './GitLabApi.js';
|
|
12
|
+
export { TRACKER_APIS, trackerApi, trackerConnectionFields } from './trackerApis.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { JiraApi } from './JiraApi.js';
|
|
2
|
+
import { AzureDevOpsApi } from './AzureDevOpsApi.js';
|
|
3
|
+
import { GitLabApi } from './GitLabApi.js';
|
|
4
|
+
|
|
5
|
+
/** Each tracker's API, by its key in TRACKERS. */
|
|
6
|
+
export const TRACKER_APIS = Object.freeze({ jira: JiraApi, azure: AzureDevOpsApi, gitlab: GitLabApi });
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The API reader for a connection (`connection.tracker`: 'jira', 'azure',
|
|
10
|
+
* 'gitlab'); `options` as TrackerApi takes them.
|
|
11
|
+
*/
|
|
12
|
+
export function trackerApi(connection, options) {
|
|
13
|
+
const Api = TRACKER_APIS[connection?.tracker];
|
|
14
|
+
if (!Api) throw new Error(`No API reader for tracker "${connection?.tracker ?? ''}" (${Object.keys(TRACKER_APIS).join(', ')})`);
|
|
15
|
+
return new Api(connection, options);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What each tracker's connection asks for: `[{tracker, label, fields}]`, for a host's form. */
|
|
19
|
+
export function trackerConnectionFields() {
|
|
20
|
+
return Object.entries(TRACKER_APIS).map(([tracker, Api]) => ({ tracker, label: new Api({}).label, fields: Api.fields }));
|
|
21
|
+
}
|
package/src/index.js
CHANGED
|
@@ -166,7 +166,7 @@ import { buildTypeCategoryTree, buildPropertyTree } from './charting/modelTree/b
|
|
|
166
166
|
import { DiagramTabs } from './charting/modelTree/DiagramTabs.js';
|
|
167
167
|
import { DataModelNotation, DataModelLevel, AttributedLevel, ConceptualLevel, LogicalLevel, PhysicalLevel, NotationStyle, RelationshipKind, AssociationKind, GeneralizationKind, EntityRole, FactRole, DimensionRole } from './datamodel.js';
|
|
168
168
|
import { DataModel, DataModelPerspective, DataModelContext, LevelSchema, LevelMapping, ServiceDomain, DataModelFileFormat, ServiceLandscape, BianLandscapeReader, BianSemanticApiReader, DataType, EnumerationType, StructuredType, NamingStandard, NameStyle, ClassificationScheme, ClassificationLens, SqlDdl, SqlDdlFileFormat, ModelScorecard, ScorecardCategory, MeasuredCategory } from './charting/datamodel/index.js';
|
|
169
|
-
import { RequirementsModel, RequirementsChecker, RequirementsFileFormat, TrackerExportFileFormat, StoryboardFileFormat, TrackerExport, TRACKERS, Storyboard } from './charting/requirements/index.js';
|
|
169
|
+
import { RequirementsModel, RequirementsChecker, RequirementsFileFormat, TrackerExportFileFormat, StoryboardFileFormat, TrackerExport, TRACKERS, Storyboard, TrackerApi, JiraApi, AzureDevOpsApi, GitLabApi, TRACKER_APIS, trackerApi, trackerConnectionFields } from './charting/requirements/index.js';
|
|
170
170
|
import { EditableModel } from './charting/model/EditableModel.js';
|
|
171
171
|
import { ModelEditCommand } from './charting/model/ModelEditCommand.js';
|
|
172
172
|
import { Benchmark } from './charting/model/Benchmark.js';
|
|
@@ -229,6 +229,7 @@ export {
|
|
|
229
229
|
DataModelNotation, DataModelLevel, AttributedLevel, ConceptualLevel, LogicalLevel, PhysicalLevel, NotationStyle, RelationshipKind, AssociationKind, GeneralizationKind, EntityRole, FactRole, DimensionRole,
|
|
230
230
|
DataModel, DataModelPerspective, DataModelContext, LevelSchema, LevelMapping, ServiceDomain, DataModelFileFormat, ServiceLandscape, BianLandscapeReader, BianSemanticApiReader, DataType, EnumerationType, StructuredType, NamingStandard, NameStyle, ClassificationScheme, ClassificationLens, SqlDdl, SqlDdlFileFormat, ModelScorecard, ScorecardCategory, MeasuredCategory,
|
|
231
231
|
RequirementsModel, RequirementsChecker, RequirementsFileFormat, TrackerExportFileFormat, StoryboardFileFormat, TrackerExport, TRACKERS, Storyboard,
|
|
232
|
+
TrackerApi, JiraApi, AzureDevOpsApi, GitLabApi, TRACKER_APIS, trackerApi, trackerConnectionFields,
|
|
232
233
|
EditableModel, ModelEditCommand, Benchmark,
|
|
233
234
|
BianNotation, BianFunctionalPattern, BianActionTerm,
|
|
234
235
|
exportElementsCSV, exportRelationshipsCSV, exportModelCSV,
|