changeledger 0.4.0 → 0.6.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/AGENTS.md +13 -7
- package/README.md +35 -13
- package/bin/changeledger.mjs +39 -4
- package/package.json +1 -1
- package/src/commands/agent.mjs +1 -1
- package/src/commands/check.mjs +11 -1
- package/src/commands/context.mjs +65 -0
- package/src/commands/init.mjs +3 -6
- package/src/commands/new.mjs +1 -1
- package/src/commands/register.mjs +19 -7
- package/src/commands/view.mjs +4 -0
- package/src/config-migration.mjs +213 -0
- package/src/contract.mjs +86 -50
- package/src/paths.mjs +1 -1
- package/src/viewer/domain.mjs +280 -0
- package/src/viewer/public/api.js +28 -0
- package/src/viewer/public/app.js +561 -31
- package/src/viewer/public/index.html +2 -0
- package/src/viewer/public/styles.css +283 -0
- package/src/viewer/server/router.mjs +40 -14
- package/templates/config.yml +8 -3
- package/templates/contract/blocked.md +6 -0
- package/templates/contract/close.md +18 -0
- package/templates/contract/core.md +71 -0
- package/templates/contract/discarded.md +5 -0
- package/templates/contract/implement.md +52 -0
- package/templates/contract/readiness.md +19 -0
- package/templates/contract/release.md +25 -0
- package/templates/contract/review.md +19 -0
- package/templates/contract/spec.md +94 -0
- package/templates/contract/validation.md +9 -0
- package/templates/AGENTS.md +0 -540
package/src/viewer/public/app.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
+
getConfigMigrationPreview,
|
|
2
3
|
getGitRefs,
|
|
3
|
-
|
|
4
|
+
getProjectConfigStructured,
|
|
4
5
|
getProjects,
|
|
5
6
|
getRepo,
|
|
7
|
+
patchProjectConfigApi,
|
|
8
|
+
postConfigMigrationApply,
|
|
6
9
|
postProjectConfig,
|
|
7
10
|
postProjectPath,
|
|
8
11
|
postProjectRemove,
|
|
@@ -255,11 +258,11 @@ async function moveStatus(id, status, reason) {
|
|
|
255
258
|
const res = await postStatus(state.currentProject, id, status, reason);
|
|
256
259
|
const out = await res.json();
|
|
257
260
|
if (!res.ok) {
|
|
258
|
-
|
|
261
|
+
showToast(out.error || 'status change failed');
|
|
259
262
|
return;
|
|
260
263
|
}
|
|
261
264
|
} catch (e) {
|
|
262
|
-
|
|
265
|
+
showToast(e.message);
|
|
263
266
|
return;
|
|
264
267
|
}
|
|
265
268
|
invalidateCache();
|
|
@@ -485,7 +488,7 @@ export function createDiagramLightbox({ overlay, canvas, closeButton }) {
|
|
|
485
488
|
async function gotoChange(proj, changeId) {
|
|
486
489
|
const match = state.projectsList.find((p) => p.id === proj || p.name === proj);
|
|
487
490
|
if (!match?.alive) {
|
|
488
|
-
|
|
491
|
+
showToast(`Project "${proj}" is not registered or its path is gone.`);
|
|
489
492
|
return;
|
|
490
493
|
}
|
|
491
494
|
if (match.id !== state.currentProject) {
|
|
@@ -595,11 +598,41 @@ function openSpec(s) {
|
|
|
595
598
|
);
|
|
596
599
|
const overlay = $('#overlay');
|
|
597
600
|
overlay.classList.remove('hidden');
|
|
598
|
-
$('#detail')
|
|
601
|
+
const detail = $('#detail');
|
|
602
|
+
detail.querySelector('.close').onclick = closeDetail;
|
|
599
603
|
overlay.onclick = (e) => {
|
|
600
604
|
if (e.target === overlay) closeDetail();
|
|
601
605
|
};
|
|
602
|
-
|
|
606
|
+
detail.onclick = (e) => handleSpecBodyClick(e, (href) => openSpecByName(href, state, openSpec));
|
|
607
|
+
renderExpandableMermaid(detail);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Normalizes a spec href and opens the matching spec.
|
|
612
|
+
* Exported for testing: accepts `repoState` and `_openSpec` to avoid DOM coupling.
|
|
613
|
+
*/
|
|
614
|
+
export function openSpecByName(href, repoState = state, _openSpec = openSpec) {
|
|
615
|
+
const name = href.replace(/^\.\//, '').replace(/\.md$/, '');
|
|
616
|
+
const found = (repoState.repo?.specs ?? []).find((s) => s.name.replace(/\.md$/, '') === name);
|
|
617
|
+
if (found) _openSpec(found);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Click handler for the spec body container.
|
|
622
|
+
* Exported for testing: accepts `_openSpecByName` callback to avoid DOM coupling.
|
|
623
|
+
* Intercepts relative *.md links only; lets external links through unchanged.
|
|
624
|
+
*/
|
|
625
|
+
export function handleSpecBodyClick(event, _openSpecByName) {
|
|
626
|
+
const anchor = event.target.closest('a');
|
|
627
|
+
if (!anchor) return;
|
|
628
|
+
const href = anchor.getAttribute('href');
|
|
629
|
+
if (!href) return;
|
|
630
|
+
// Let external links (with scheme or absolute path) through unchanged.
|
|
631
|
+
if (/^[a-z][a-z\d+\-.]*:/i.test(href) || href.startsWith('/')) return;
|
|
632
|
+
// Only intercept relative *.md links.
|
|
633
|
+
if (!href.endsWith('.md')) return;
|
|
634
|
+
event.preventDefault();
|
|
635
|
+
_openSpecByName(href);
|
|
603
636
|
}
|
|
604
637
|
|
|
605
638
|
const VIEWS = ['board', 'table', 'graph', 'specs', 'metrics', 'projects'];
|
|
@@ -636,8 +669,316 @@ export function restoreInitialViewerShell(root = document, getStorage = () => wi
|
|
|
636
669
|
|
|
637
670
|
let managedProject = null;
|
|
638
671
|
let managedConfig = null;
|
|
672
|
+
let configMode = 'form'; // 'form' | 'raw'
|
|
673
|
+
let configDirty = false; // true when form/raw has unsaved edits
|
|
674
|
+
let migrationPreview = null; // null | { summary, changes, yaml } | { already_current }
|
|
675
|
+
|
|
676
|
+
const SUPPORTED_SCHEMA_VERSION = 1;
|
|
677
|
+
// Confirm dialog — uses native <dialog> for proper focus-trap, ESC and backdrop.
|
|
678
|
+
// _confirmImpl is replaceable in tests (JSDOM lacks showModal).
|
|
679
|
+
let _confirmImpl = null;
|
|
680
|
+
let dialogSequence = 0;
|
|
681
|
+
|
|
682
|
+
export function showToast(message, { type = 'error', duration = 4000 } = {}) {
|
|
683
|
+
const container = document.getElementById('toast-container');
|
|
684
|
+
if (!container) return;
|
|
685
|
+
const el = document.createElement('div');
|
|
686
|
+
el.className = `toast toast-${type}`;
|
|
687
|
+
el.textContent = message;
|
|
688
|
+
el.setAttribute('role', 'alert');
|
|
689
|
+
container.appendChild(el);
|
|
690
|
+
setTimeout(() => el.remove(), duration);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export function setConfirmImpl(impl) {
|
|
694
|
+
_confirmImpl = impl;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Prompt dialog — returns the entered string or null (cancel). Mockable via _promptImpl.
|
|
698
|
+
let _promptImpl = null;
|
|
699
|
+
export function setPromptImpl(impl) {
|
|
700
|
+
_promptImpl = impl;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export function showPrompt(message, { placeholder = '' } = {}) {
|
|
704
|
+
if (_promptImpl !== null) return Promise.resolve(_promptImpl(message));
|
|
705
|
+
return new Promise((resolve) => {
|
|
706
|
+
const id = `cl-prompt-${++dialogSequence}`;
|
|
707
|
+
const dialog = document.createElement('dialog');
|
|
708
|
+
dialog.className = 'cl-confirm-dialog';
|
|
709
|
+
dialog.setAttribute('aria-labelledby', `${id}-title`);
|
|
710
|
+
dialog.innerHTML = `
|
|
711
|
+
<p id="${id}-title" class="cl-confirm-message"></p>
|
|
712
|
+
<label for="${id}-input" class="cl-prompt-label">Confirmation value</label>
|
|
713
|
+
<input id="${id}-input" class="cl-prompt-input" type="text" autocomplete="off" />
|
|
714
|
+
<div class="cl-confirm-actions">
|
|
715
|
+
<button type="button" class="button cl-confirm-yes">Confirm</button>
|
|
716
|
+
<button type="button" class="button secondary cl-confirm-no">Cancel</button>
|
|
717
|
+
</div>`;
|
|
718
|
+
dialog.querySelector('.cl-confirm-message').textContent = message;
|
|
719
|
+
const input = dialog.querySelector('.cl-prompt-input');
|
|
720
|
+
if (placeholder) input.placeholder = placeholder;
|
|
721
|
+
document.body.appendChild(dialog);
|
|
722
|
+
const done = (result) => {
|
|
723
|
+
dialog.close();
|
|
724
|
+
dialog.remove();
|
|
725
|
+
resolve(result);
|
|
726
|
+
};
|
|
727
|
+
dialog.querySelector('.cl-confirm-yes').onclick = () => done(input.value);
|
|
728
|
+
dialog.querySelector('.cl-confirm-no').onclick = () => done(null);
|
|
729
|
+
dialog.addEventListener('cancel', () => done(null));
|
|
730
|
+
dialog.addEventListener('click', (e) => {
|
|
731
|
+
if (e.target === dialog) done(null);
|
|
732
|
+
});
|
|
733
|
+
input.addEventListener('keydown', (e) => {
|
|
734
|
+
if (e.key === 'Enter') done(input.value);
|
|
735
|
+
});
|
|
736
|
+
dialog.showModal();
|
|
737
|
+
input.focus();
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export function showConfirm(message) {
|
|
742
|
+
if (_confirmImpl) return Promise.resolve(_confirmImpl(message));
|
|
743
|
+
return new Promise((resolve) => {
|
|
744
|
+
const id = `cl-confirm-${++dialogSequence}`;
|
|
745
|
+
const dialog = document.createElement('dialog');
|
|
746
|
+
dialog.className = 'cl-confirm-dialog';
|
|
747
|
+
dialog.setAttribute('aria-labelledby', `${id}-title`);
|
|
748
|
+
dialog.innerHTML = `
|
|
749
|
+
<p id="${id}-title" class="cl-confirm-message"></p>
|
|
750
|
+
<div class="cl-confirm-actions">
|
|
751
|
+
<button type="button" class="button cl-confirm-yes">Confirm</button>
|
|
752
|
+
<button type="button" class="button secondary cl-confirm-no">Cancel</button>
|
|
753
|
+
</div>`;
|
|
754
|
+
dialog.querySelector('.cl-confirm-message').textContent = message;
|
|
755
|
+
document.body.appendChild(dialog);
|
|
756
|
+
const done = (result) => {
|
|
757
|
+
dialog.close();
|
|
758
|
+
dialog.remove();
|
|
759
|
+
resolve(result);
|
|
760
|
+
};
|
|
761
|
+
dialog.querySelector('.cl-confirm-yes').onclick = () => done(true);
|
|
762
|
+
dialog.querySelector('.cl-confirm-no').onclick = () => done(false);
|
|
763
|
+
dialog.addEventListener('cancel', () => done(false));
|
|
764
|
+
dialog.addEventListener('click', (e) => {
|
|
765
|
+
if (e.target === dialog) done(false);
|
|
766
|
+
});
|
|
767
|
+
dialog.showModal();
|
|
768
|
+
});
|
|
769
|
+
}
|
|
639
770
|
|
|
640
|
-
|
|
771
|
+
function configSectionTemplate(config, mode, preview) {
|
|
772
|
+
if (!config) return nothing;
|
|
773
|
+
if (config.error) {
|
|
774
|
+
return html`<div class="config-section">
|
|
775
|
+
<p class="project-error" role="alert" aria-live="assertive">${config.error}</p>
|
|
776
|
+
</div>`;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const schema = config.schemaVersion ?? 0;
|
|
780
|
+
const futureSch = schema > SUPPORTED_SCHEMA_VERSION;
|
|
781
|
+
const outdated = schema < SUPPORTED_SCHEMA_VERSION;
|
|
782
|
+
|
|
783
|
+
return html`<div class="config-section">
|
|
784
|
+
${
|
|
785
|
+
!futureSch
|
|
786
|
+
? html`<div class="config-tabs" role="tablist" aria-label="Config editor mode">
|
|
787
|
+
<button
|
|
788
|
+
type="button"
|
|
789
|
+
role="tab"
|
|
790
|
+
class=${`config-tab${mode === 'form' ? ' active' : ''}`}
|
|
791
|
+
aria-selected=${mode === 'form'}
|
|
792
|
+
data-config-mode="form"
|
|
793
|
+
>Form</button>
|
|
794
|
+
<button
|
|
795
|
+
type="button"
|
|
796
|
+
role="tab"
|
|
797
|
+
class=${`config-tab${mode === 'raw' ? ' active' : ''}`}
|
|
798
|
+
aria-selected=${mode === 'raw'}
|
|
799
|
+
data-config-mode="raw"
|
|
800
|
+
>Raw YAML</button>
|
|
801
|
+
</div>`
|
|
802
|
+
: nothing
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
${
|
|
806
|
+
futureSch
|
|
807
|
+
? html`<div class="config-future-schema">
|
|
808
|
+
<p class="config-schema-badge">Schema ${schema}</p>
|
|
809
|
+
<p>Update ChangeLedger to edit config schema ${schema}.</p>
|
|
810
|
+
</div>
|
|
811
|
+
${rawReadonlyTemplate(config)}`
|
|
812
|
+
: outdated
|
|
813
|
+
? html`<div class="config-migration-card">
|
|
814
|
+
<h3>Migration required</h3>
|
|
815
|
+
<p>Config schema ${schema} is outdated. Preview and apply the migration to schema ${SUPPORTED_SCHEMA_VERSION} to enable the Form editor.</p>
|
|
816
|
+
${
|
|
817
|
+
preview?.already_current
|
|
818
|
+
? html`<p class="config-migration-ok">Migration already applied.</p>`
|
|
819
|
+
: preview?.error
|
|
820
|
+
? html`<p class="project-error" role="alert" aria-live="assertive">${preview.error}</p>
|
|
821
|
+
<div class="project-actions">
|
|
822
|
+
<button class="button secondary" type="button" data-preview-migration>Retry preview</button>
|
|
823
|
+
</div>`
|
|
824
|
+
: preview
|
|
825
|
+
? html`<div class="config-migration-preview">
|
|
826
|
+
<p class="config-migration-summary">${preview.summary}</p>
|
|
827
|
+
<p><strong>Changes:</strong></p>
|
|
828
|
+
<ul>${preview.changes?.map((c) => html`<li>${c}</li>`)}</ul>
|
|
829
|
+
<pre class="config-migration-yaml">${preview.yaml}</pre>
|
|
830
|
+
<div class="project-actions">
|
|
831
|
+
<button class="button" type="button" data-apply-migration>Apply migration</button>
|
|
832
|
+
</div>
|
|
833
|
+
</div>`
|
|
834
|
+
: html`<div class="project-actions">
|
|
835
|
+
<button class="button secondary" type="button" data-preview-migration>Preview migration</button>
|
|
836
|
+
</div>`
|
|
837
|
+
}
|
|
838
|
+
<p class="config-section-note">You can still inspect the current config in Raw YAML.</p>
|
|
839
|
+
${rawEditorTemplate(config)}
|
|
840
|
+
</div>`
|
|
841
|
+
: mode === 'form'
|
|
842
|
+
? formEditorTemplate(config)
|
|
843
|
+
: rawEditorTemplate(config)
|
|
844
|
+
}
|
|
845
|
+
</div>`;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Raw editor with Save button (for editable schemas)
|
|
849
|
+
function rawEditorTemplate(config) {
|
|
850
|
+
return html`<form class="config-form">
|
|
851
|
+
<div class="config-label"><label for="project-config">.changeledger/config.yml</label><button type="button" class="text-button" data-reload-config>Reload</button></div>
|
|
852
|
+
<textarea id="project-config" spellcheck="false" .value=${config?.content ?? ''}></textarea>
|
|
853
|
+
<p class="project-error" role="alert" aria-live="assertive" ?hidden=${!config?.rawError}>${config?.rawError ?? ''}</p>
|
|
854
|
+
<div class="project-actions"><button class="button" type="submit">Save configuration</button></div>
|
|
855
|
+
</form>`;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Raw viewer without Save (future schema — strictly read-only)
|
|
859
|
+
function rawReadonlyTemplate(config) {
|
|
860
|
+
return html`<div class="config-form config-form-readonly">
|
|
861
|
+
<div class="config-label"><label for="project-config-ro">.changeledger/config.yml</label></div>
|
|
862
|
+
<textarea id="project-config-ro" spellcheck="false" readonly .value=${config?.content ?? ''}></textarea>
|
|
863
|
+
<p class="config-note">Editing disabled for schema ${config.schemaVersion ?? '?'}. Update ChangeLedger to enable editing.</p>
|
|
864
|
+
</div>`;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function formEditorTemplate(config) {
|
|
868
|
+
const cfg = config.config ?? {};
|
|
869
|
+
const types = cfg.types ?? {};
|
|
870
|
+
const impacts = cfg.release?.impacts ?? {};
|
|
871
|
+
const readiness = cfg.readiness ?? {};
|
|
872
|
+
const allStatuses = cfg.statuses ?? [];
|
|
873
|
+
const stages = cfg.stages ?? [];
|
|
874
|
+
|
|
875
|
+
return html`<form class="config-form config-form-structured" data-config-form>
|
|
876
|
+
<div class="config-label">
|
|
877
|
+
<label>.changeledger/config.yml (Form)</label>
|
|
878
|
+
<button type="button" class="text-button" data-reload-config>Reload</button>
|
|
879
|
+
</div>
|
|
880
|
+
|
|
881
|
+
<fieldset class="config-group">
|
|
882
|
+
<legend>General</legend>
|
|
883
|
+
<label>Project name
|
|
884
|
+
<input name="project_name" .value=${cfg.project_name ?? ''} />
|
|
885
|
+
</label>
|
|
886
|
+
<label>Language
|
|
887
|
+
<input name="language" .value=${cfg.language ?? 'en'} />
|
|
888
|
+
</label>
|
|
889
|
+
<label class="config-checkbox">
|
|
890
|
+
<input type="checkbox" name="tdd" ?checked=${cfg.tdd !== false} />
|
|
891
|
+
TDD mode (require test-grade criteria)
|
|
892
|
+
</label>
|
|
893
|
+
</fieldset>
|
|
894
|
+
|
|
895
|
+
<fieldset class="config-group">
|
|
896
|
+
<legend>Paths</legend>
|
|
897
|
+
<p class="config-note">Changing paths only updates the config — existing files are not moved.</p>
|
|
898
|
+
<label>Changes directory
|
|
899
|
+
<input name="changes_dir" .value=${cfg.changes_dir ?? '.changeledger/changes'} />
|
|
900
|
+
</label>
|
|
901
|
+
<label>Specs directory
|
|
902
|
+
<input name="specs_dir" .value=${cfg.specs_dir ?? '.changeledger/specs'} />
|
|
903
|
+
</label>
|
|
904
|
+
</fieldset>
|
|
905
|
+
|
|
906
|
+
<fieldset class="config-group">
|
|
907
|
+
<legend>Lifecycle statuses</legend>
|
|
908
|
+
<label>Status order (one per line)
|
|
909
|
+
<textarea name="statuses" rows="8">${allStatuses.join('\n')}</textarea>
|
|
910
|
+
</label>
|
|
911
|
+
<p class="config-note">Canonical statuses are required. Custom statuses may be added and reordered.</p>
|
|
912
|
+
</fieldset>
|
|
913
|
+
|
|
914
|
+
<fieldset class="config-group">
|
|
915
|
+
<legend>Lifecycle stages</legend>
|
|
916
|
+
<label>Canonical stage order (one per line)
|
|
917
|
+
<textarea name="stages" rows="6">${stages.join('\n')}</textarea>
|
|
918
|
+
</label>
|
|
919
|
+
<p class="config-note">Stages used by a change type cannot be removed until that type is updated.</p>
|
|
920
|
+
</fieldset>
|
|
921
|
+
|
|
922
|
+
<fieldset class="config-group">
|
|
923
|
+
<legend>Change types</legend>
|
|
924
|
+
${Object.entries(types).map(
|
|
925
|
+
([typeName, typeDef]) => html`
|
|
926
|
+
<fieldset class="config-type-row">
|
|
927
|
+
<legend>${typeName}</legend>
|
|
928
|
+
<label>Active stages (one per line)
|
|
929
|
+
<textarea name=${`stages_${typeName}`} rows="3">${(typeDef?.stages ?? []).join('\n')}</textarea>
|
|
930
|
+
</label>
|
|
931
|
+
<label>Review policy
|
|
932
|
+
<select name=${`review_required_${typeName}`}>
|
|
933
|
+
<option value="" ?selected=${!Object.hasOwn(typeDef ?? {}, 'review_required')}>Not configured</option>
|
|
934
|
+
<option value="true" ?selected=${typeDef?.review_required === true}>Required</option>
|
|
935
|
+
<option value="false" ?selected=${typeDef?.review_required === false}>Not required</option>
|
|
936
|
+
</select>
|
|
937
|
+
</label>
|
|
938
|
+
<label>SemVer impact
|
|
939
|
+
<select name=${`impact_${typeName}`}>
|
|
940
|
+
<option value="" ?selected=${!Object.hasOwn(impacts, typeName)}>Not configured</option>
|
|
941
|
+
${['none', 'patch', 'minor', 'major'].map(
|
|
942
|
+
(v) =>
|
|
943
|
+
html`<option value=${v} ?selected=${impacts[typeName] === v}>${v}</option>`,
|
|
944
|
+
)}
|
|
945
|
+
</select>
|
|
946
|
+
</label>
|
|
947
|
+
</fieldset>
|
|
948
|
+
`,
|
|
949
|
+
)}
|
|
950
|
+
</fieldset>
|
|
951
|
+
|
|
952
|
+
<fieldset class="config-group">
|
|
953
|
+
<legend>Definition of Ready</legend>
|
|
954
|
+
<label>Target patterns (one per line)
|
|
955
|
+
<textarea name="target_patterns" rows="3">${(readiness.target_patterns ?? []).join('\n')}</textarea>
|
|
956
|
+
</label>
|
|
957
|
+
<label>Verification patterns (one per line)
|
|
958
|
+
<textarea name="verification_patterns" rows="3">${(readiness.verification_patterns ?? []).join('\n')}</textarea>
|
|
959
|
+
</label>
|
|
960
|
+
</fieldset>
|
|
961
|
+
|
|
962
|
+
<fieldset class="config-group config-group-internal">
|
|
963
|
+
<legend>Internal</legend>
|
|
964
|
+
<p><span class="config-readonly-label">schema_version</span><span class="config-readonly-value">${cfg.schema_version ?? 0}</span></p>
|
|
965
|
+
<p><span class="config-readonly-label">project_id</span><span class="config-readonly-value mono">${cfg.project_id ?? ''}</span></p>
|
|
966
|
+
</fieldset>
|
|
967
|
+
|
|
968
|
+
<p class="project-error" role="alert" aria-live="assertive" ?hidden=${!config?.formError}>${config?.formError ?? ''}</p>
|
|
969
|
+
<div class="project-actions">
|
|
970
|
+
<button class="button" type="submit">Save configuration</button>
|
|
971
|
+
</div>
|
|
972
|
+
</form>`;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export function projectsViewTemplate(
|
|
976
|
+
projects,
|
|
977
|
+
selected,
|
|
978
|
+
config,
|
|
979
|
+
localOnly,
|
|
980
|
+
preview = migrationPreview,
|
|
981
|
+
) {
|
|
641
982
|
const project = projects.find((item) => item.id === selected);
|
|
642
983
|
return html`<div class="projects-shell">
|
|
643
984
|
<div class="projects-list">
|
|
@@ -680,12 +1021,7 @@ export function projectsViewTemplate(projects, selected, config, localOnly) {
|
|
|
680
1021
|
project.alive
|
|
681
1022
|
? config?.loading
|
|
682
1023
|
? html`<p class="empty">Loading configuration…</p>`
|
|
683
|
-
:
|
|
684
|
-
<div class="config-label"><label for="project-config">.changeledger/config.yml</label><button type="button" class="text-button" data-reload-config>Reload</button></div>
|
|
685
|
-
<textarea id="project-config" spellcheck="false" .value=${config?.content ?? ''}></textarea>
|
|
686
|
-
<p class="project-error" ?hidden=${!config?.error}>${config?.error ?? ''}</p>
|
|
687
|
-
<div class="project-actions"><button class="button" type="submit">Save configuration</button></div>
|
|
688
|
-
</form>`
|
|
1024
|
+
: configSectionTemplate(config, configMode, preview)
|
|
689
1025
|
: html`<div class="missing-config"><h3>Configuration unavailable</h3><p>Repair the registered path to edit this project.</p></div>`
|
|
690
1026
|
}
|
|
691
1027
|
${
|
|
@@ -700,9 +1036,11 @@ export function projectsViewTemplate(projects, selected, config, localOnly) {
|
|
|
700
1036
|
|
|
701
1037
|
async function openManagedProject(id, { reload = false } = {}) {
|
|
702
1038
|
managedProject = id;
|
|
1039
|
+
configDirty = false;
|
|
703
1040
|
const project = state.projectsList.find((item) => item.id === id);
|
|
704
1041
|
if (!project?.alive) {
|
|
705
1042
|
managedConfig = null;
|
|
1043
|
+
migrationPreview = null;
|
|
706
1044
|
renderProjects();
|
|
707
1045
|
return;
|
|
708
1046
|
}
|
|
@@ -711,9 +1049,17 @@ async function openManagedProject(id, { reload = false } = {}) {
|
|
|
711
1049
|
return;
|
|
712
1050
|
}
|
|
713
1051
|
managedConfig = { id, loading: true };
|
|
1052
|
+
migrationPreview = null;
|
|
714
1053
|
renderProjects();
|
|
715
1054
|
try {
|
|
716
|
-
|
|
1055
|
+
const structured = await getProjectConfigStructured(id);
|
|
1056
|
+
managedConfig = { id, ...structured };
|
|
1057
|
+
// Default to form for current schema, raw for future schema
|
|
1058
|
+
if (structured.schemaVersion > SUPPORTED_SCHEMA_VERSION) {
|
|
1059
|
+
configMode = 'raw';
|
|
1060
|
+
} else {
|
|
1061
|
+
configMode = 'form';
|
|
1062
|
+
}
|
|
717
1063
|
} catch (error) {
|
|
718
1064
|
managedConfig = { id, content: '', revision: '', error: error.message };
|
|
719
1065
|
}
|
|
@@ -740,15 +1086,20 @@ export async function projectMutation(root, request, onSuccess) {
|
|
|
740
1086
|
if (error) {
|
|
741
1087
|
error.textContent = failure.message;
|
|
742
1088
|
error.hidden = false;
|
|
743
|
-
} else
|
|
1089
|
+
} else showToast(failure.message);
|
|
744
1090
|
} finally {
|
|
745
1091
|
setProjectFormPending(root, false);
|
|
746
1092
|
}
|
|
747
1093
|
}
|
|
748
1094
|
|
|
749
|
-
export function requestUnregisterConfirmation(project, ask =
|
|
750
|
-
|
|
1095
|
+
export function requestUnregisterConfirmation(project, ask = null) {
|
|
1096
|
+
if (ask !== null)
|
|
1097
|
+
return ask(
|
|
1098
|
+
`Type "${project.name}" to unregister this project. No repository files will be deleted.`,
|
|
1099
|
+
);
|
|
1100
|
+
return showPrompt(
|
|
751
1101
|
`Type "${project.name}" to unregister this project. No repository files will be deleted.`,
|
|
1102
|
+
{ placeholder: project.name },
|
|
752
1103
|
);
|
|
753
1104
|
}
|
|
754
1105
|
|
|
@@ -768,6 +1119,92 @@ async function refreshProjectRegistry() {
|
|
|
768
1119
|
select.style.display = projects.length > 1 ? '' : 'none';
|
|
769
1120
|
}
|
|
770
1121
|
|
|
1122
|
+
const listFromControl = (control) =>
|
|
1123
|
+
(control?.value ?? '')
|
|
1124
|
+
.split('\n')
|
|
1125
|
+
.map((value) => value.trim())
|
|
1126
|
+
.filter(Boolean);
|
|
1127
|
+
|
|
1128
|
+
const sameList = (left = [], right = []) =>
|
|
1129
|
+
left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1130
|
+
|
|
1131
|
+
export function collectFormPatch(formEl, currentConfig) {
|
|
1132
|
+
const patch = {};
|
|
1133
|
+
const els = formEl.elements;
|
|
1134
|
+
|
|
1135
|
+
if (els.project_name && els.project_name.value !== (currentConfig.project_name ?? '')) {
|
|
1136
|
+
patch.project_name = els.project_name.value;
|
|
1137
|
+
}
|
|
1138
|
+
if (els.language && els.language.value !== (currentConfig.language ?? 'en')) {
|
|
1139
|
+
patch.language = els.language.value;
|
|
1140
|
+
}
|
|
1141
|
+
if (els.tdd && els.tdd.checked !== (currentConfig.tdd !== false)) patch.tdd = els.tdd.checked;
|
|
1142
|
+
if (
|
|
1143
|
+
els.changes_dir &&
|
|
1144
|
+
els.changes_dir.value !== (currentConfig.changes_dir ?? '.changeledger/changes')
|
|
1145
|
+
) {
|
|
1146
|
+
patch.changes_dir = els.changes_dir.value;
|
|
1147
|
+
}
|
|
1148
|
+
if (els.specs_dir && els.specs_dir.value !== (currentConfig.specs_dir ?? '.changeledger/specs')) {
|
|
1149
|
+
patch.specs_dir = els.specs_dir.value;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const statuses = listFromControl(els.statuses);
|
|
1153
|
+
if (els.statuses && !sameList(statuses, currentConfig.statuses ?? [])) patch.statuses = statuses;
|
|
1154
|
+
const stages = listFromControl(els.stages);
|
|
1155
|
+
if (els.stages && !sameList(stages, currentConfig.stages ?? [])) patch.stages = stages;
|
|
1156
|
+
|
|
1157
|
+
// Type fields are tri-state: an empty select means the key is not configured.
|
|
1158
|
+
const types = currentConfig.types ?? {};
|
|
1159
|
+
const existingImpacts = currentConfig.release?.impacts ?? {};
|
|
1160
|
+
const typePatch = {};
|
|
1161
|
+
const impacts = {};
|
|
1162
|
+
for (const typeName of Object.keys(types)) {
|
|
1163
|
+
const currentType = types[typeName] ?? {};
|
|
1164
|
+
const typeStages = listFromControl(els[`stages_${typeName}`]);
|
|
1165
|
+
if (els[`stages_${typeName}`] && !sameList(typeStages, currentType.stages ?? [])) {
|
|
1166
|
+
typePatch[typeName] = { ...(typePatch[typeName] ?? {}), stages: typeStages };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const rrEl = els[`review_required_${typeName}`];
|
|
1170
|
+
const currentReview = Object.hasOwn(currentType, 'review_required')
|
|
1171
|
+
? String(currentType.review_required)
|
|
1172
|
+
: '';
|
|
1173
|
+
if (rrEl && rrEl.value !== currentReview) {
|
|
1174
|
+
typePatch[typeName] = {
|
|
1175
|
+
...(typePatch[typeName] ?? {}),
|
|
1176
|
+
review_required: rrEl.value === '' ? null : rrEl.value === 'true',
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const impactEl = els[`impact_${typeName}`];
|
|
1181
|
+
const currentImpact = Object.hasOwn(existingImpacts, typeName) ? existingImpacts[typeName] : '';
|
|
1182
|
+
if (impactEl && impactEl.value !== currentImpact) {
|
|
1183
|
+
impacts[typeName] = impactEl.value === '' ? null : impactEl.value;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
if (Object.keys(typePatch).length) patch.types = typePatch;
|
|
1187
|
+
if (Object.keys(impacts).length) patch.release = { impacts };
|
|
1188
|
+
|
|
1189
|
+
// readiness patterns
|
|
1190
|
+
if (els.target_patterns !== undefined) {
|
|
1191
|
+
const targetPatterns = listFromControl(els.target_patterns);
|
|
1192
|
+
const verifyPatterns = listFromControl(els.verification_patterns);
|
|
1193
|
+
const currentReadiness = currentConfig.readiness ?? {};
|
|
1194
|
+
if (
|
|
1195
|
+
!sameList(targetPatterns, currentReadiness.target_patterns ?? []) ||
|
|
1196
|
+
!sameList(verifyPatterns, currentReadiness.verification_patterns ?? [])
|
|
1197
|
+
) {
|
|
1198
|
+
patch.readiness = {
|
|
1199
|
+
target_patterns: targetPatterns,
|
|
1200
|
+
verification_patterns: verifyPatterns,
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
return patch;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
771
1208
|
function renderProjects() {
|
|
772
1209
|
const root = $('#projects');
|
|
773
1210
|
litRender(
|
|
@@ -775,18 +1212,77 @@ function renderProjects() {
|
|
|
775
1212
|
root,
|
|
776
1213
|
);
|
|
777
1214
|
bindProjectViewActions(root, {
|
|
778
|
-
select: (id) =>
|
|
779
|
-
|
|
780
|
-
|
|
1215
|
+
select: async (id) => {
|
|
1216
|
+
if (configDirty) {
|
|
1217
|
+
const ok = await showConfirm('You have unsaved changes. Switch project and discard them?');
|
|
1218
|
+
if (!ok) return;
|
|
1219
|
+
}
|
|
1220
|
+
openManagedProject(id);
|
|
1221
|
+
},
|
|
1222
|
+
reload: async () => {
|
|
1223
|
+
if (configDirty) {
|
|
1224
|
+
const ok = await showConfirm('Reload will discard your unsaved changes. Continue?');
|
|
1225
|
+
if (!ok) return;
|
|
1226
|
+
}
|
|
1227
|
+
openManagedProject(managedProject, { reload: true });
|
|
1228
|
+
},
|
|
1229
|
+
markDirty: () => {
|
|
1230
|
+
configDirty = true;
|
|
1231
|
+
},
|
|
1232
|
+
switchMode: async (mode) => {
|
|
1233
|
+
if (configDirty && mode !== configMode) {
|
|
1234
|
+
const ok = await showConfirm('You have unsaved changes. Switch mode and discard them?');
|
|
1235
|
+
if (!ok) return;
|
|
1236
|
+
}
|
|
1237
|
+
configMode = mode;
|
|
1238
|
+
configDirty = false;
|
|
1239
|
+
renderProjects();
|
|
1240
|
+
},
|
|
1241
|
+
saveRaw: (content, configForm) =>
|
|
781
1242
|
projectMutation(
|
|
782
1243
|
configForm,
|
|
783
1244
|
() => postProjectConfig(managedProject, content, managedConfig.revision),
|
|
784
1245
|
async (body) => {
|
|
785
|
-
|
|
1246
|
+
configDirty = false;
|
|
1247
|
+
managedConfig = { ...managedConfig, content, revision: body.revision };
|
|
786
1248
|
await refreshProjectRegistry();
|
|
787
1249
|
renderProjects();
|
|
788
1250
|
},
|
|
789
1251
|
),
|
|
1252
|
+
saveForm: (formEl, configForm) =>
|
|
1253
|
+
projectMutation(
|
|
1254
|
+
configForm,
|
|
1255
|
+
() => {
|
|
1256
|
+
const patch = collectFormPatch(formEl, managedConfig.config ?? {});
|
|
1257
|
+
return patchProjectConfigApi(managedProject, patch, managedConfig.revision);
|
|
1258
|
+
},
|
|
1259
|
+
async (_body) => {
|
|
1260
|
+
configDirty = false;
|
|
1261
|
+
await openManagedProject(managedProject, { reload: true });
|
|
1262
|
+
},
|
|
1263
|
+
),
|
|
1264
|
+
previewMigration: async () => {
|
|
1265
|
+
try {
|
|
1266
|
+
const result = await getConfigMigrationPreview(managedProject, managedConfig.revision);
|
|
1267
|
+
migrationPreview = result;
|
|
1268
|
+
} catch (e) {
|
|
1269
|
+
migrationPreview = { error: e.message };
|
|
1270
|
+
}
|
|
1271
|
+
renderProjects();
|
|
1272
|
+
},
|
|
1273
|
+
applyMigration: async () => {
|
|
1274
|
+
const ok = await showConfirm(
|
|
1275
|
+
'Apply the config migration? This will update .changeledger/config.yml.',
|
|
1276
|
+
);
|
|
1277
|
+
if (!ok) return;
|
|
1278
|
+
try {
|
|
1279
|
+
await postConfigMigrationApply(managedProject, managedConfig.revision);
|
|
1280
|
+
await openManagedProject(managedProject, { reload: true });
|
|
1281
|
+
} catch (e) {
|
|
1282
|
+
migrationPreview = { error: e.message };
|
|
1283
|
+
renderProjects();
|
|
1284
|
+
}
|
|
1285
|
+
},
|
|
790
1286
|
repair: (projectPath, pathForm) =>
|
|
791
1287
|
projectMutation(
|
|
792
1288
|
pathForm,
|
|
@@ -796,13 +1292,13 @@ function renderProjects() {
|
|
|
796
1292
|
await openManagedProject(managedProject, { reload: true });
|
|
797
1293
|
},
|
|
798
1294
|
),
|
|
799
|
-
unregister: (editor) => {
|
|
1295
|
+
unregister: async (editor) => {
|
|
800
1296
|
const project = state.projectsList.find((item) => item.id === managedProject);
|
|
801
|
-
const
|
|
802
|
-
if (
|
|
1297
|
+
const answer = await requestUnregisterConfirmation(project);
|
|
1298
|
+
if (answer === null) return;
|
|
803
1299
|
projectMutation(
|
|
804
1300
|
editor,
|
|
805
|
-
() => postProjectRemove(managedProject,
|
|
1301
|
+
() => postProjectRemove(managedProject, answer),
|
|
806
1302
|
async () => {
|
|
807
1303
|
managedProject = null;
|
|
808
1304
|
managedConfig = null;
|
|
@@ -819,14 +1315,39 @@ export function bindProjectViewActions(root, handlers) {
|
|
|
819
1315
|
root.querySelectorAll('[data-manage-project]').forEach((button) => {
|
|
820
1316
|
button.onclick = () => handlers.select(button.dataset.manageProject);
|
|
821
1317
|
});
|
|
1318
|
+
|
|
1319
|
+
root.querySelectorAll('[data-config-mode]').forEach((button) => {
|
|
1320
|
+
button.onclick = () => handlers.switchMode(button.dataset.configMode);
|
|
1321
|
+
});
|
|
1322
|
+
|
|
822
1323
|
const reload = root.querySelector('[data-reload-config]');
|
|
823
1324
|
if (reload) reload.onclick = () => handlers.reload();
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1325
|
+
|
|
1326
|
+
const rawForm = root.querySelector('.config-form:not([data-config-form])');
|
|
1327
|
+
if (rawForm) {
|
|
1328
|
+
rawForm.onsubmit = (event) => {
|
|
827
1329
|
event.preventDefault();
|
|
828
|
-
handlers.
|
|
1330
|
+
handlers.saveRaw(rawForm.querySelector('textarea').value, rawForm);
|
|
829
1331
|
};
|
|
1332
|
+
rawForm.querySelector('textarea')?.addEventListener('input', () => handlers.markDirty?.());
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const formEditor = root.querySelector('[data-config-form]');
|
|
1336
|
+
if (formEditor) {
|
|
1337
|
+
formEditor.onsubmit = (event) => {
|
|
1338
|
+
event.preventDefault();
|
|
1339
|
+
handlers.saveForm(formEditor, formEditor);
|
|
1340
|
+
};
|
|
1341
|
+
formEditor.addEventListener('input', () => handlers.markDirty?.());
|
|
1342
|
+
formEditor.addEventListener('change', () => handlers.markDirty?.());
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const previewBtn = root.querySelector('[data-preview-migration]');
|
|
1346
|
+
if (previewBtn) previewBtn.onclick = () => handlers.previewMigration();
|
|
1347
|
+
|
|
1348
|
+
const applyBtn = root.querySelector('[data-apply-migration]');
|
|
1349
|
+
if (applyBtn) applyBtn.onclick = () => handlers.applyMigration();
|
|
1350
|
+
|
|
830
1351
|
const pathForm = root.querySelector('.project-path-form');
|
|
831
1352
|
if (pathForm)
|
|
832
1353
|
pathForm.onsubmit = (event) => {
|
|
@@ -950,8 +1471,17 @@ function bootstrap() {
|
|
|
950
1471
|
$('#view-specs').onclick = () => activateView('specs');
|
|
951
1472
|
$('#view-metrics').onclick = () => activateView('metrics');
|
|
952
1473
|
$('#view-projects').onclick = () => activateView('projects');
|
|
953
|
-
$('#project').onchange = (e) => {
|
|
954
|
-
|
|
1474
|
+
$('#project').onchange = async (e) => {
|
|
1475
|
+
const nextProject = e.target.value;
|
|
1476
|
+
if (state.currentView === 'projects' && configDirty && nextProject !== state.currentProject) {
|
|
1477
|
+
const ok = await showConfirm('You have unsaved changes. Switch project and discard them?');
|
|
1478
|
+
if (!ok) {
|
|
1479
|
+
e.target.value = state.currentProject;
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
configDirty = false;
|
|
1483
|
+
}
|
|
1484
|
+
selectProject(nextProject);
|
|
955
1485
|
load();
|
|
956
1486
|
};
|
|
957
1487
|
document.onkeydown = (e) => {
|