Archify Diagram Viewer 0.1.0
VS Code extension that previews Archify diagrams from their JSON sources (live, as you type) and opens rendered Archify HTML in a viewer tab. Bundles the Archify 3.0.1 renderer and runs it on VS Code's Node runtime. Adds validation diagnostics, JSON schema help, source-link navigation, export saving, render-to-file and open-in-browser commands. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1388
File diff suppressed because it is too large
Load Diff
+1710
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { checkForUpdate } from './check-update.mjs';
|
||||
|
||||
// The parent can be busy in synchronous renderer work. Enforce its original
|
||||
// deadline here, in a separate event loop, so cache work cannot continue late.
|
||||
if (!/^\d{1,30}$/.test(process.argv[2] || '')) process.exit(1);
|
||||
const deadlineAt = BigInt(process.argv[2]);
|
||||
const remainingMs = Number(deadlineAt - process.hrtime.bigint()) / 1_000_000;
|
||||
if (remainingMs <= 0) process.exit(1);
|
||||
const deadlineTimer = setTimeout(() => process.kill(process.pid, 'SIGKILL'), remainingMs);
|
||||
deadlineTimer.unref();
|
||||
|
||||
const result = await checkForUpdate({
|
||||
...(process.env.ARCHIFY_UPDATE_RELEASE_PATH
|
||||
? { releasePath: process.env.ARCHIFY_UPDATE_RELEASE_PATH } : {}),
|
||||
...(process.env.ARCHIFY_UPDATE_CACHE_DIRECTORY
|
||||
? { cacheDirectory: process.env.ARCHIFY_UPDATE_CACHE_DIRECTORY } : {}),
|
||||
// Leave time to record a failed fetch, so a slow network backs off instead
|
||||
// of being killed before its result reaches the cache.
|
||||
timeoutMs: Math.max(1, Math.floor(remainingMs - 150)),
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as simpleIcons from 'simple-icons';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '..');
|
||||
const catalogPath = path.join(root, 'brand-marks', 'catalog.json');
|
||||
const outputPath = path.join(root, 'renderers', 'shared', 'generated-brand-marks.mjs');
|
||||
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
|
||||
const simpleIconsVersion = JSON.parse(fs.readFileSync(
|
||||
path.join(root, 'node_modules', 'simple-icons', 'package.json'),
|
||||
'utf8',
|
||||
)).version;
|
||||
const simpleBySlug = new Map(Object.values(simpleIcons)
|
||||
.filter((icon) => icon && typeof icon === 'object' && icon.slug && icon.path)
|
||||
.map((icon) => [icon.slug, icon]));
|
||||
|
||||
function normalizedList(value) {
|
||||
return [...new Set((Array.isArray(value) ? value : [])
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean))];
|
||||
}
|
||||
|
||||
function lookupForms(value) {
|
||||
const raw = String(value ?? '').trim().toLocaleLowerCase('en-US');
|
||||
if (!raw) return [];
|
||||
return [...new Set([
|
||||
raw,
|
||||
raw.replace(/[\s_]+/g, '-'),
|
||||
raw.replace(/[\s_.-]+/g, ''),
|
||||
])];
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`brand catalog: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (catalog.schemaVersion !== 1 || !Array.isArray(catalog.marks) || catalog.marks.length === 0) {
|
||||
fail('catalog.json must contain a non-empty schemaVersion 1 marks array');
|
||||
}
|
||||
|
||||
const ids = new Set();
|
||||
const lookupKeys = new Map();
|
||||
const domains = new Map();
|
||||
const generated = catalog.marks.map((entry, index) => {
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id || '')) fail(`marks[${index}] has an invalid id`);
|
||||
if (ids.has(entry.id)) fail(`duplicate id ${entry.id}`);
|
||||
ids.add(entry.id);
|
||||
|
||||
const aliases = normalizedList(entry.aliases);
|
||||
const entryDomains = normalizedList(entry.domains).map((domain) => domain.toLowerCase());
|
||||
for (const key of [entry.id, ...aliases]) {
|
||||
for (const form of lookupForms(key)) {
|
||||
if (lookupKeys.has(form) && lookupKeys.get(form) !== entry.id) {
|
||||
fail(`lookup key ${JSON.stringify(key)} is shared by ${lookupKeys.get(form)} and ${entry.id}`);
|
||||
}
|
||||
lookupKeys.set(form, entry.id);
|
||||
}
|
||||
}
|
||||
for (const domain of entryDomains) {
|
||||
if (domains.has(domain) && domains.get(domain) !== entry.id) {
|
||||
fail(`domain ${domain} is shared by ${domains.get(domain)} and ${entry.id}`);
|
||||
}
|
||||
domains.set(domain, entry.id);
|
||||
}
|
||||
|
||||
let mark;
|
||||
if (entry.simpleIcon) {
|
||||
const icon = simpleBySlug.get(entry.simpleIcon);
|
||||
if (!icon) fail(`${entry.id} references missing Simple Icons slug ${entry.simpleIcon}`);
|
||||
mark = {
|
||||
id: entry.id,
|
||||
title: entry.title || icon.title,
|
||||
category: entry.category,
|
||||
aliases,
|
||||
domains: entryDomains,
|
||||
viewBox: 24,
|
||||
hex: icon.hex,
|
||||
path: icon.path,
|
||||
provenance: {
|
||||
provider: 'Simple Icons',
|
||||
providerVersion: simpleIconsVersion,
|
||||
source: icon.source,
|
||||
...(icon.guidelines ? { guidelines: icon.guidelines } : {}),
|
||||
...(icon.license ? { license: icon.license } : {}),
|
||||
},
|
||||
};
|
||||
} else if (entry.custom) {
|
||||
const custom = entry.custom;
|
||||
if (!entry.title || !custom.path || !custom.source || !/^[0-9A-F]{6}$/i.test(custom.hex || '')) {
|
||||
fail(`${entry.id} custom mark requires title, path, source, and six-digit hex`);
|
||||
}
|
||||
mark = {
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
category: entry.category,
|
||||
aliases,
|
||||
domains: entryDomains,
|
||||
viewBox: custom.viewBox || 24,
|
||||
hex: custom.hex.toUpperCase(),
|
||||
path: custom.path,
|
||||
provenance: {
|
||||
provider: 'Official brand asset',
|
||||
source: custom.source,
|
||||
...(custom.guidelines ? { guidelines: custom.guidelines } : {}),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
fail(`${entry.id} must provide simpleIcon or custom`);
|
||||
}
|
||||
if (!mark.category || !mark.title) fail(`${entry.id} is missing category or title`);
|
||||
for (const form of lookupForms(mark.title)) {
|
||||
if (lookupKeys.has(form) && lookupKeys.get(form) !== entry.id) {
|
||||
fail(`title ${JSON.stringify(mark.title)} is shared by ${lookupKeys.get(form)} and ${entry.id}`);
|
||||
}
|
||||
lookupKeys.set(form, entry.id);
|
||||
}
|
||||
return mark;
|
||||
}).sort((left, right) => left.id.localeCompare(right.id));
|
||||
|
||||
const banner = `// Generated by scripts/generate-brand-marks.mjs from brand-marks/catalog.json.\n// Simple Icons ${simpleIconsVersion}. Do not edit by hand.\n`;
|
||||
const source = `${banner}export const BRAND_MARKS = Object.freeze(${JSON.stringify(generated, null, 2)});\n`;
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
const current = fs.existsSync(outputPath)
|
||||
? fs.readFileSync(outputPath, 'utf8').replace(/\r\n?/g, '\n')
|
||||
: '';
|
||||
if (current !== source) {
|
||||
console.error('generated brand marks are stale — run npm run generate:brand-marks');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const temporary = `${outputPath}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, source);
|
||||
fs.renameSync(temporary, outputPath);
|
||||
console.log(`generated ${path.relative(root, outputPath)} (${generated.length} marks)`);
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Ajv2020 from 'ajv/dist/2020.js';
|
||||
import standaloneCode from 'ajv/dist/standalone/index.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const schemasDir = path.join(root, 'schemas');
|
||||
const output = path.join(root, 'renderers/shared/generated-validators.mjs');
|
||||
const diagramTypes = ['workflow', 'sequence', 'dataflow', 'lifecycle', 'architecture'];
|
||||
|
||||
const ajv = new Ajv2020({
|
||||
allErrors: true,
|
||||
strict: true,
|
||||
code: { source: true, esm: true },
|
||||
});
|
||||
ajv.addSchema(JSON.parse(fs.readFileSync(path.join(schemasDir, 'common.schema.json'), 'utf8')));
|
||||
|
||||
const schemaIds = {};
|
||||
for (const type of diagramTypes) {
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(schemasDir, `${type}.schema.json`), 'utf8'));
|
||||
ajv.addSchema(schema);
|
||||
schemaIds[type] = schema.$id;
|
||||
}
|
||||
|
||||
const banner = '// Generated by scripts/generate-validators.mjs. Do not edit by hand.\n';
|
||||
const ajvUcs2Import = 'require("ajv/dist/runtime/ucs2length").default';
|
||||
const inlineUcs2Length = `function ucs2length(str) {
|
||||
const len = str.length;
|
||||
let length = 0;
|
||||
let pos = 0;
|
||||
while (pos < len) {
|
||||
length += 1;
|
||||
const value = str.charCodeAt(pos++);
|
||||
if (value >= 0xd800 && value <= 0xdbff && pos < len
|
||||
&& (str.charCodeAt(pos) & 0xfc00) === 0xdc00) pos += 1;
|
||||
}
|
||||
return length;
|
||||
}`;
|
||||
let validatorCode = standaloneCode(ajv, schemaIds);
|
||||
if (!validatorCode.includes(ajvUcs2Import)) {
|
||||
throw new Error('AJV standalone output no longer contains the expected ucs2length helper');
|
||||
}
|
||||
validatorCode = validatorCode.replaceAll(ajvUcs2Import, inlineUcs2Length);
|
||||
if (validatorCode.includes('require(')) {
|
||||
throw new Error('AJV standalone output contains an unexpected runtime dependency');
|
||||
}
|
||||
for (const type of diagramTypes) {
|
||||
const exportPattern = new RegExp(`export const ${type} = (validate\\d+);`);
|
||||
const match = validatorCode.match(exportPattern);
|
||||
if (!match) throw new Error(`AJV standalone output no longer exports the ${type} validator as expected`);
|
||||
validatorCode = validatorCode.replace(exportPattern, `const ${type}Schema = ${match[1]};`);
|
||||
}
|
||||
|
||||
const portableOutputWrappers = diagramTypes.map((type) => `export function ${type}(data, context = undefined) {
|
||||
if (!${type}Schema(data, context)) {
|
||||
${type}.errors = ${type}Schema.errors;
|
||||
return false;
|
||||
}
|
||||
const output = data?.meta?.output;
|
||||
if (typeof output === 'string') {
|
||||
try {
|
||||
validatePortablePath(output, { profile: 'output' });
|
||||
} catch (error) {
|
||||
${type}.errors = [{
|
||||
instancePath: \`${'${context?.instancePath || \'\'}'}/meta/output\`,
|
||||
schemaPath: 'common.schema.json#/$defs/portableOutputPath',
|
||||
keyword: 'portablePath',
|
||||
params: { reason: error?.reason || 'invalid' },
|
||||
message: 'must satisfy the portable output path contract',
|
||||
}];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
${type}.errors = null;
|
||||
return true;
|
||||
}
|
||||
${type}.evaluated = ${type}Schema.evaluated;`).join('\n');
|
||||
|
||||
const generated = `${banner}import { validatePortablePath } from './portable-path.mjs';\n${validatorCode}\n${portableOutputWrappers}\n`;
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
const current = fs.existsSync(output)
|
||||
? fs.readFileSync(output, 'utf8').replace(/\r\n?/g, '\n')
|
||||
: '';
|
||||
if (current !== generated) {
|
||||
console.error('generated validators are stale — run npm run generate:validators');
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const temporary = `${output}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, generated);
|
||||
fs.renameSync(temporary, output);
|
||||
console.log(`generated ${path.relative(root, output)}`);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// Re-render every bundled example from its JSON IR. Installed skills keep HTML
|
||||
// beside the JSON examples; the development script passes the golden directory.
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const outputRoot = path.resolve(process.argv[2] || path.join(skillRoot, 'examples'));
|
||||
|
||||
const TARGETS = [
|
||||
['workflow', 'agent-tool-call.workflow.json', 'workflow-agent-tool-call-rendered.html'],
|
||||
['sequence', 'cache-miss-request.sequence.json', 'sequence-cache-miss-request.html'],
|
||||
['dataflow', 'product-analytics.dataflow.json', 'dataflow-product-analytics.html'],
|
||||
['lifecycle', 'agent-run.lifecycle.json', 'lifecycle-agent-run.html'],
|
||||
['architecture', 'web-app.architecture.json', 'web-app-rendered.html'],
|
||||
];
|
||||
|
||||
for (const [mode, input, output] of TARGETS) {
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', input),
|
||||
path.join(outputRoot, output),
|
||||
], { stdio: 'inherit' });
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
export const SKILL_ID = 'archify';
|
||||
export const EXPECTED_REPOSITORY = 'https://github.com/tt-a1i/archify';
|
||||
export const DEFAULT_MANIFEST_URL = 'https://tt-a1i.github.io/archify/skill-updates/archify/stable.json';
|
||||
|
||||
const CONTROL_OR_BIDI = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
|
||||
const HEX_40 = /^[a-f0-9]{40}$/;
|
||||
const HEX_64 = /^[a-f0-9]{64}$/;
|
||||
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
||||
const UTC_SECONDS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
||||
|
||||
export class UpdateContractError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'UpdateContractError';
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasExactKeys(value, expected) {
|
||||
return isPlainObject(value)
|
||||
&& Object.keys(value).sort().join('\0') === [...expected].sort().join('\0');
|
||||
}
|
||||
|
||||
export function parseSemver(value) {
|
||||
if (typeof value !== 'string' || value.length > 128) {
|
||||
throw new UpdateContractError(`invalid SemVer: ${JSON.stringify(value)}`);
|
||||
}
|
||||
const match = SEMVER.exec(value);
|
||||
if (!match) throw new UpdateContractError(`invalid SemVer: ${JSON.stringify(value)}`);
|
||||
const prerelease = match[4]?.split('.') ?? null;
|
||||
if (prerelease?.some((identifier) => /^\d+$/.test(identifier)
|
||||
&& identifier.length > 1 && identifier.startsWith('0'))) {
|
||||
throw new UpdateContractError(`invalid SemVer: ${JSON.stringify(value)}`);
|
||||
}
|
||||
return {
|
||||
core: match.slice(1, 4),
|
||||
prerelease,
|
||||
build: match[5]?.split('.') ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function compareNumericIdentifiers(left, right) {
|
||||
if (left.length !== right.length) return left.length < right.length ? -1 : 1;
|
||||
if (left === right) return 0;
|
||||
return left < right ? -1 : 1;
|
||||
}
|
||||
|
||||
function comparePrerelease(left, right) {
|
||||
if (left === null && right === null) return 0;
|
||||
if (left === null) return 1;
|
||||
if (right === null) return -1;
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (left[index] === undefined) return -1;
|
||||
if (right[index] === undefined) return 1;
|
||||
if (left[index] === right[index]) continue;
|
||||
const leftNumeric = /^\d+$/.test(left[index]);
|
||||
const rightNumeric = /^\d+$/.test(right[index]);
|
||||
if (leftNumeric && rightNumeric) return compareNumericIdentifiers(left[index], right[index]);
|
||||
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
|
||||
return left[index] < right[index] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function compareSemver(leftValue, rightValue) {
|
||||
const left = parseSemver(leftValue);
|
||||
const right = parseSemver(rightValue);
|
||||
for (let index = 0; index < left.core.length; index += 1) {
|
||||
const comparison = compareNumericIdentifiers(left.core[index], right.core[index]);
|
||||
if (comparison !== 0) return comparison;
|
||||
}
|
||||
return comparePrerelease(left.prerelease, right.prerelease);
|
||||
}
|
||||
|
||||
export function releaseChannelForVersion(value) {
|
||||
return parseSemver(value).prerelease ? 'development' : 'stable';
|
||||
}
|
||||
|
||||
export function isStableCoreVersion(value) {
|
||||
try {
|
||||
const parsed = parseSemver(value);
|
||||
return parsed.prerelease === null && parsed.build === null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCanonicalUtcTimestamp(value) {
|
||||
if (typeof value !== 'string' || !UTC_SECONDS.test(value)) {
|
||||
throw new UpdateContractError('publication time must use YYYY-MM-DDTHH:mm:ssZ');
|
||||
}
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)
|
||||
|| new Date(timestamp).toISOString().replace('.000Z', 'Z') !== value) {
|
||||
throw new UpdateContractError('publication time is not a real UTC calendar instant');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function validateLocalRelease(value) {
|
||||
if (!hasExactKeys(value, [
|
||||
'schemaVersion', 'skillId', 'channel', 'version', 'source', 'updateManifestUrl',
|
||||
])
|
||||
|| value.schemaVersion !== 1
|
||||
|| value.skillId !== SKILL_ID
|
||||
|| !hasExactKeys(value.source, ['repository'])
|
||||
|| value.source.repository !== EXPECTED_REPOSITORY
|
||||
|| value.updateManifestUrl !== DEFAULT_MANIFEST_URL) {
|
||||
throw new UpdateContractError('invalid local release identity');
|
||||
}
|
||||
const expectedChannel = releaseChannelForVersion(value.version);
|
||||
if (value.channel !== expectedChannel) {
|
||||
throw new UpdateContractError('local release channel does not match its version');
|
||||
}
|
||||
return {
|
||||
schemaVersion: value.schemaVersion,
|
||||
skillId: value.skillId,
|
||||
channel: value.channel,
|
||||
version: value.version,
|
||||
source: { repository: value.source.repository },
|
||||
updateManifestUrl: value.updateManifestUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateReleaseNotesUrl(value, version) {
|
||||
if (!isStableCoreVersion(version)) {
|
||||
throw new UpdateContractError('release notes require a stable core version');
|
||||
}
|
||||
const expected = `https://github.com/tt-a1i/archify/releases/tag/v${version}`;
|
||||
if (value !== expected) {
|
||||
throw new UpdateContractError('release notes URL is outside the exact trusted release path');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function validateStableUpdateManifest(value) {
|
||||
if (!hasExactKeys(value, [
|
||||
'schemaVersion', 'skillId', 'channel', 'version', 'publishedAt', 'source',
|
||||
'artifact', 'summary', 'releaseNotes', 'severity',
|
||||
])
|
||||
|| value.schemaVersion !== 1
|
||||
|| value.skillId !== SKILL_ID
|
||||
|| value.channel !== 'stable'
|
||||
|| !isStableCoreVersion(value.version)
|
||||
|| !hasExactKeys(value.source, ['repository', 'ref', 'treeSha'])
|
||||
|| value.source.repository !== EXPECTED_REPOSITORY
|
||||
|| value.source.ref !== `v${value.version}`
|
||||
|| !HEX_40.test(value.source.treeSha)
|
||||
|| !hasExactKeys(value.artifact, ['sha256'])
|
||||
|| !HEX_64.test(value.artifact.sha256)) {
|
||||
throw new UpdateContractError('invalid immutable stable release identity');
|
||||
}
|
||||
validateCanonicalUtcTimestamp(value.publishedAt);
|
||||
if (typeof value.summary !== 'string' || value.summary.length < 1 || value.summary.length > 160
|
||||
|| CONTROL_OR_BIDI.test(value.summary)) {
|
||||
throw new UpdateContractError('invalid release summary');
|
||||
}
|
||||
validateReleaseNotesUrl(value.releaseNotes, value.version);
|
||||
if (!['normal', 'security'].includes(value.severity)) {
|
||||
throw new UpdateContractError('invalid update severity');
|
||||
}
|
||||
return {
|
||||
schemaVersion: value.schemaVersion,
|
||||
skillId: value.skillId,
|
||||
channel: value.channel,
|
||||
version: value.version,
|
||||
publishedAt: value.publishedAt,
|
||||
source: {
|
||||
repository: value.source.repository,
|
||||
ref: value.source.ref,
|
||||
treeSha: value.source.treeSha,
|
||||
},
|
||||
artifact: { sha256: value.artifact.sha256 },
|
||||
summary: value.summary,
|
||||
releaseNotes: value.releaseNotes,
|
||||
severity: value.severity,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user