@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 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;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peter.prib/renderengine",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "ArchiMate modeller, diagram editor and chart library for the browser, with no runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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,