@chisquare-tech/origine-ngx-apps-code 0.2.0 → 0.4.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.
@@ -115,6 +115,51 @@ class CodeApi {
115
115
  p['q'] = q;
116
116
  return this.api.get(`${this.repoPath(project, name)}/refs`, p).pipe(map((r) => data(r) ?? []));
117
117
  }
118
+ collaborators(project, name) {
119
+ return this.api.get(`${this.repoPath(project, name)}/collaborators`).pipe(map((r) => data(r) ?? []));
120
+ }
121
+ setCollaborator(project, name, userId, permission) {
122
+ return this.api.put(`${this.repoPath(project, name)}/collaborators/${enc(userId)}`, { permission }).pipe(map((r) => data(r)));
123
+ }
124
+ removeCollaborator(project, name, userId) {
125
+ return this.api.delete(`${this.repoPath(project, name)}/collaborators/${enc(userId)}`);
126
+ }
127
+ createTag(project, name, tag, target) {
128
+ return this.api.post(`${this.repoPath(project, name)}/tags`, { tag, target }).pipe(map((r) => data(r)));
129
+ }
130
+ deleteTag(project, name, tag) {
131
+ return this.api.delete(`${this.repoPath(project, name)}/tags/${enc(tag)}`);
132
+ }
133
+ releases(project, name) {
134
+ return this.api.get(`${this.repoPath(project, name)}/releases`).pipe(map((r) => data(r) ?? []));
135
+ }
136
+ createRelease(project, name, r) {
137
+ return this.api.post(`${this.repoPath(project, name)}/releases`, r).pipe(map((x) => data(x)));
138
+ }
139
+ updateRelease(project, name, id, r) {
140
+ return this.api.patch(`${this.repoPath(project, name)}/releases/${enc(id)}`, r).pipe(map((x) => data(x)));
141
+ }
142
+ deleteRelease(project, name, id, deleteTag) {
143
+ return this.api.delete(`${this.repoPath(project, name)}/releases/${enc(id)}${deleteTag ? '?deleteTag=true' : ''}`);
144
+ }
145
+ /** Notes drafted from the done issues and merged pull requests since the previous release. */
146
+ releaseNotes(project, name, target, tag = '') {
147
+ return this.api.post(`${this.repoPath(project, name)}/releases/notes`, { target, tag: tag || undefined }).pipe(map((r) => data(r)?.notes ?? ''));
148
+ }
149
+ addAsset(project, name, id, file) {
150
+ return this.api
151
+ .post(`${this.repoPath(project, name)}/releases/${enc(id)}/assets?filename=${encodeURIComponent(file.name)}`, null, {
152
+ body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' },
153
+ })
154
+ .pipe(map((r) => data(r)));
155
+ }
156
+ /** A release file's bytes (the download needs the session, so no plain link works). */
157
+ assetBlob(project, name, id, asset) {
158
+ return this.api.get(`${this.repoPath(project, name)}/releases/${enc(id)}/assets/${enc(asset)}`, null, { responseType: 'blob' }).pipe(map((b) => b));
159
+ }
160
+ deleteAsset(project, name, id, asset) {
161
+ return this.api.delete(`${this.repoPath(project, name)}/releases/${enc(id)}/assets/${enc(asset)}`);
162
+ }
118
163
  createBranch(project, name, branch, from) {
119
164
  return this.api.post(`${this.repoPath(project, name)}/branches`, { name: branch, from });
120
165
  }
@@ -307,6 +352,8 @@ class RepoPage {
307
352
  this.people = computed(() => new People(this.host.people()), ...(ngDevMode ? [{ debugName: "people" }] : /* istanbul ignore next */ []));
308
353
  this.canWrite = computed(() => !this.repo()?.archivedAt && (this.repo()?.capabilities ?? []).includes('code.write'), ...(ngDevMode ? [{ debugName: "canWrite" }] : /* istanbul ignore next */ []));
309
354
  this.canAdmin = computed(() => (this.repo()?.capabilities ?? []).includes('resources.manage'), ...(ngDevMode ? [{ debugName: "canAdmin" }] : /* istanbul ignore next */ []));
355
+ /** Repository settings short of a rename, the visibility and deletion: Maintainers, and maintain collaborators. */
356
+ this.canMaintain = computed(() => this.canAdmin() || (this.repo()?.capabilities ?? []).includes('repo.maintain'), ...(ngDevMode ? [{ debugName: "canMaintain" }] : /* istanbul ignore next */ []));
310
357
  this.canProtect = computed(() => (this.repo()?.capabilities ?? []).includes('protection.manage'), ...(ngDevMode ? [{ debugName: "canProtect" }] : /* istanbul ignore next */ []));
311
358
  this.route.paramMap.subscribe((m) => this.name.set(m.get('repo') ?? ''));
312
359
  effect(() => {
@@ -1352,7 +1399,7 @@ class RepoHomePage extends RepoPage {
1352
1399
  </div>
1353
1400
  <div class="o-col" style="gap:14px">
1354
1401
  <div class="o-card o-cb o-col"><b>About</b><span class="o-mut o-lh">{{ r.description || 'No description.' }}</span><span class="o-faint o-sm">Default branch <span class="o-mono">{{ r.defaultBranch }}</span></span></div>
1355
- <div class="o-card o-cb o-col"><div class="o-flex"><b>Releases</b><b class="o-ph-tag o-ga o-r">GA</b></div><span class="o-faint o-sm">Tags and release notes arrive at general availability.</span></div>
1402
+ <div class="o-card o-cb o-col"><b>Releases</b><a class="o-sm" [routerLink]="link('releases')" data-testid="repo-releases">Releases and tags</a></div>
1356
1403
  <div class="o-card o-cb o-col"><b>Deployments</b><span class="o-faint o-sm">Environments show here once Deploy is set up.</span></div>
1357
1404
  @if (langs().length) {
1358
1405
  <div class="o-card o-cb o-col">
@@ -1472,7 +1519,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1472
1519
  </div>
1473
1520
  <div class="o-col" style="gap:14px">
1474
1521
  <div class="o-card o-cb o-col"><b>About</b><span class="o-mut o-lh">{{ r.description || 'No description.' }}</span><span class="o-faint o-sm">Default branch <span class="o-mono">{{ r.defaultBranch }}</span></span></div>
1475
- <div class="o-card o-cb o-col"><div class="o-flex"><b>Releases</b><b class="o-ph-tag o-ga o-r">GA</b></div><span class="o-faint o-sm">Tags and release notes arrive at general availability.</span></div>
1522
+ <div class="o-card o-cb o-col"><b>Releases</b><a class="o-sm" [routerLink]="link('releases')" data-testid="repo-releases">Releases and tags</a></div>
1476
1523
  <div class="o-card o-cb o-col"><b>Deployments</b><span class="o-faint o-sm">Environments show here once Deploy is set up.</span></div>
1477
1524
  @if (langs().length) {
1478
1525
  <div class="o-card o-cb o-col">
@@ -2964,9 +3011,46 @@ class RepoSettingsPage extends RepoPage {
2964
3011
  { key: 'dismissStaleApprovals', label: 'Dismiss stale approvals', hint: 'A new commit dismisses approvals given on an older one.' },
2965
3012
  { key: 'requireUpToDate', label: 'Require branches to be up to date', hint: 'The branch must contain the target’s latest commit before it merges.' },
2966
3013
  ];
3014
+ this.collabs = signal([], ...(ngDevMode ? [{ debugName: "collabs" }] : /* istanbul ignore next */ []));
3015
+ this.perms = [{ id: 'read', label: 'Read' }, { id: 'write', label: 'Write' }, { id: 'maintain', label: 'Maintain' }];
3016
+ this.addable = computed(() => this.host.people().filter((p) => !this.collabs().some((c) => c.userId === p.id)), ...(ngDevMode ? [{ debugName: "addable" }] : /* istanbul ignore next */ []));
3017
+ this.newCollab = '';
3018
+ this.newPerm = 'write';
2967
3019
  this.route.queryParamMap.subscribe((q) => this.section.set(q.get('section') || 'general'));
2968
3020
  }
3021
+ roleLabel(r) {
3022
+ return r ? r[0].toUpperCase() + r.slice(1) : 'Not in the project';
3023
+ }
3024
+ loadCollabs(r) {
3025
+ if (!this.canAdmin())
3026
+ return;
3027
+ this.api.collaborators(this.projectId(), r.name).subscribe({ next: (l) => this.collabs.set(l), error: () => undefined });
3028
+ }
3029
+ setCollab(userId, permission, adding = false) {
3030
+ const r = this.repo();
3031
+ if (!r || !userId)
3032
+ return;
3033
+ this.busy.set(true);
3034
+ this.error.set('');
3035
+ this.api.setCollaborator(this.projectId(), r.name, userId, permission).subscribe({
3036
+ next: () => { this.busy.set(false); if (adding) {
3037
+ this.newCollab = '';
3038
+ this.note.set(`${this.people().name(userId)} now has ${permission} on ${r.name}.`);
3039
+ } this.loadCollabs(r); },
3040
+ error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not change the access.')); this.loadCollabs(r); },
3041
+ });
3042
+ }
3043
+ removeCollab(userId) {
3044
+ const r = this.repo();
3045
+ if (!r)
3046
+ return;
3047
+ this.api.removeCollaborator(this.projectId(), r.name, userId).subscribe({
3048
+ next: () => { this.note.set(`${this.people().name(userId)} is no longer a collaborator.`); this.loadCollabs(r); },
3049
+ error: (e) => this.error.set(apiMessage(e, 'Could not remove the collaborator.')),
3050
+ });
3051
+ }
2969
3052
  onRepo(r) {
3053
+ this.loadCollabs(r);
2970
3054
  this.g = { name: r.name, description: r.description, visibility: r.visibility, defaultBranch: r.defaultBranch, mergeMethods: [...(r.mergeMethods ?? [])], deleteBranchOnMerge: r.deleteBranchOnMerge };
2971
3055
  if (!r.empty)
2972
3056
  this.api.refs(this.projectId(), r.name, 'branch').subscribe({ next: (b) => this.branches.set(b), error: () => undefined });
@@ -3019,7 +3103,11 @@ class RepoSettingsPage extends RepoPage {
3019
3103
  return;
3020
3104
  this.busy.set(true);
3021
3105
  const renamed = this.g.name.trim() !== r.name;
3022
- this.api.update(this.projectId(), r.name, { name: this.g.name.trim(), description: this.g.description, visibility: this.g.visibility, defaultBranch: this.g.defaultBranch, mergeMethods: this.g.mergeMethods, deleteBranchOnMerge: this.g.deleteBranchOnMerge }).subscribe({
3106
+ // a maintain collaborator sends what they may change; the name and visibility stay a Maintainer's
3107
+ const body = this.canAdmin()
3108
+ ? { name: this.g.name.trim(), description: this.g.description, visibility: this.g.visibility, defaultBranch: this.g.defaultBranch, mergeMethods: this.g.mergeMethods, deleteBranchOnMerge: this.g.deleteBranchOnMerge }
3109
+ : { description: this.g.description, defaultBranch: this.g.defaultBranch, mergeMethods: this.g.mergeMethods, deleteBranchOnMerge: this.g.deleteBranchOnMerge };
3110
+ this.api.update(this.projectId(), r.name, body).subscribe({
3023
3111
  next: (n) => {
3024
3112
  this.busy.set(false);
3025
3113
  this.note.set('Saved.');
@@ -3084,7 +3172,7 @@ class RepoSettingsPage extends RepoPage {
3084
3172
  <span class="o-lbl" style="padding-top:0">{{ r.name }}</span>
3085
3173
  <button type="button" [class.o-on]="section() === 'general'" (click)="go('general')">General</button>
3086
3174
  <button type="button" [class.o-on]="section() === 'protect'" (click)="go('protect')">Branch protection</button>
3087
- <button type="button" [class.o-on]="section() === 'collab'" (click)="go('collab')">Collaborators<b class="o-ph-tag o-ga o-r">GA</b></button>
3175
+ <button type="button" [class.o-on]="section() === 'collab'" (click)="go('collab')">Collaborators</button>
3088
3176
  <button type="button" [class.o-on]="section() === 'danger'" (click)="go('danger')">Danger zone</button>
3089
3177
  </nav>
3090
3178
  <div class="o-col" style="gap:16px;min-width:0">
@@ -3096,9 +3184,9 @@ class RepoSettingsPage extends RepoPage {
3096
3184
  <form class="o-card o-cb o-form" (ngSubmit)="saveGeneral()">
3097
3185
  <div class="o-g2">
3098
3186
  <div class="o-fld"><label for="rs-name">Repository name</label><input id="rs-name" class="o-in o-mono" name="name" [(ngModel)]="g.name" [disabled]="!canAdmin()" /><span class="o-hint">Renaming keeps the old clone URL working as a redirect.</span></div>
3099
- <div class="o-fld"><label for="rs-def">Default branch</label><select id="rs-def" class="o-in" name="def" [(ngModel)]="g.defaultBranch" [disabled]="!canAdmin()">@for (b of branches(); track b.name) {<option [value]="b.name">{{ b.name }}</option>}@if (!branches().length) {<option [value]="r.defaultBranch">{{ r.defaultBranch }}</option>}</select></div>
3187
+ <div class="o-fld"><label for="rs-def">Default branch</label><select id="rs-def" class="o-in" name="def" [(ngModel)]="g.defaultBranch" [disabled]="!canMaintain()">@for (b of branches(); track b.name) {<option [value]="b.name">{{ b.name }}</option>}@if (!branches().length) {<option [value]="r.defaultBranch">{{ r.defaultBranch }}</option>}</select></div>
3100
3188
  </div>
3101
- <div class="o-fld"><label for="rs-desc">Description</label><input id="rs-desc" class="o-in" name="desc" [(ngModel)]="g.description" [disabled]="!canAdmin()" /></div>
3189
+ <div class="o-fld"><label for="rs-desc">Description</label><input id="rs-desc" class="o-in" name="desc" [(ngModel)]="g.description" [disabled]="!canMaintain()" /></div>
3102
3190
  <div class="o-fld"><span class="o-lbl" style="padding:0">Visibility</span>
3103
3191
  <div class="o-g2">
3104
3192
  <button type="button" class="o-opt" [class.o-on]="g.visibility === 'private'" (click)="g.visibility = 'private'" [disabled]="!canAdmin()"><span class="o-rad" [class.o-on]="g.visibility === 'private'"></span><span class="o-col" style="gap:2px"><b>Private</b><span class="o-faint o-sm">Project members only</span></span></button>
@@ -3106,11 +3194,11 @@ class RepoSettingsPage extends RepoPage {
3106
3194
  </div>
3107
3195
  </div>
3108
3196
  <div class="o-fld"><span class="o-lbl" style="padding:0">Allowed merge methods</span>
3109
- @for (m of methods; track m.id) {<label class="o-flex"><input type="checkbox" [checked]="g.mergeMethods.includes(m.id)" (change)="flipMethod(m.id)" [disabled]="!canAdmin()" />{{ m.label }}</label>}
3197
+ @for (m of methods; track m.id) {<label class="o-flex"><input type="checkbox" [checked]="g.mergeMethods.includes(m.id)" (change)="flipMethod(m.id)" [disabled]="!canMaintain()" />{{ m.label }}</label>}
3110
3198
  </div>
3111
- <div class="o-flex"><button type="button" class="o-tog" role="switch" aria-labelledby="rs-del" [class.o-on]="g.deleteBranchOnMerge" [attr.aria-checked]="g.deleteBranchOnMerge" (click)="g.deleteBranchOnMerge = !g.deleteBranchOnMerge" [disabled]="!canAdmin()"></button><span id="rs-del">Delete head branches after merge</span></div>
3199
+ <div class="o-flex"><button type="button" class="o-tog" role="switch" aria-labelledby="rs-del" [class.o-on]="g.deleteBranchOnMerge" [attr.aria-checked]="g.deleteBranchOnMerge" (click)="g.deleteBranchOnMerge = !g.deleteBranchOnMerge" [disabled]="!canMaintain()"></button><span id="rs-del">Delete head branches after merge</span></div>
3112
3200
  <div class="o-flex"><button type="button" class="o-tog o-dis" disabled aria-label="Allow auto-merge"></button>Allow auto-merge when all requirements pass<b class="o-ph-tag o-ga">GA</b></div>
3113
- @if (canAdmin()) {<div class="o-flex"><button type="submit" class="o-btn o-dark o-r" [disabled]="busy() || !g.mergeMethods.length">Save</button></div>}
3201
+ @if (canMaintain()) {<div class="o-flex">@if (!canAdmin()) {<span class="o-faint o-sm">As a maintain collaborator you change these settings; a project Maintainer renames the repository or changes its visibility.</span>}<button type="submit" class="o-btn o-dark o-r" [disabled]="busy() || !g.mergeMethods.length" data-testid="settings-save">Save</button></div>}
3114
3202
  </form>
3115
3203
  }
3116
3204
  @case ('protect') {
@@ -3147,7 +3235,34 @@ class RepoSettingsPage extends RepoPage {
3147
3235
  }
3148
3236
  @case ('collab') {
3149
3237
  <h1 class="o-t">Collaborators</h1>
3150
- <div class="o-card"><div class="o-empty">Access beyond the project role, for one repository, arrives at general availability. Until then, project roles decide who reads and pushes.</div></div>
3238
+ <span class="o-mut">Access beyond the project role, for this repository only.</span>
3239
+ @if (canAdmin()) {
3240
+ <div class="o-card" style="overflow-x:auto" data-testid="collaborators">
3241
+ <div class="o-tbl" role="table" aria-label="Collaborators" style="min-width:560px">
3242
+ <div class="o-thead" role="rowgroup"><div class="o-tr" role="row"><div class="o-th" role="columnheader">Person</div><div class="o-th" role="columnheader">Project role</div><div class="o-th" role="columnheader">Repository access</div><div class="o-th" role="columnheader"></div></div></div>
3243
+ <div class="o-tbody" role="rowgroup">
3244
+ @for (cl of collabs(); track cl.userId) {
3245
+ <div class="o-tr" role="row" [attr.data-testid]="'collab-' + cl.userId">
3246
+ <div class="o-td" role="cell"><span class="o-flex"><span class="o-av" aria-hidden="true">{{ people().initials(cl.userId) }}</span>{{ people().name(cl.userId) }}@if (cl.projectRole === 'guest') {<span class="o-chip">Guest</span>}</span></div>
3247
+ <div class="o-td o-mut" role="cell">{{ roleLabel(cl.projectRole) }}</div>
3248
+ <div class="o-td" role="cell"><select class="o-in o-sm" style="width:auto" [attr.aria-label]="'Repository access for ' + people().name(cl.userId)" [ngModel]="cl.permission" (ngModelChange)="setCollab(cl.userId, $event)">@for (p of perms; track p.id) {<option [value]="p.id">{{ p.label }}</option>}</select></div>
3249
+ <div class="o-td o-num" role="cell"><button type="button" class="o-btn o-xs o-ghost" (click)="removeCollab(cl.userId)" [attr.aria-label]="'Remove ' + people().name(cl.userId)">Remove</button></div>
3250
+ </div>
3251
+ } @empty {
3252
+ <div class="o-tr" role="row"><div class="o-td o-faint" role="cell" style="grid-column:1 / -1">No collaborators. Project roles decide who reads and pushes.</div></div>
3253
+ }
3254
+ </div>
3255
+ </div>
3256
+ <div class="o-cf">
3257
+ <select class="o-in o-sm" style="width:auto" aria-label="Person to add" [(ngModel)]="newCollab"><option value="">Add a person…</option>@for (p of addable(); track p.id) {<option [value]="p.id">{{ p.name || p.email }}</option>}</select>
3258
+ <select class="o-in o-sm" style="width:auto" aria-label="Access to give" [(ngModel)]="newPerm">@for (p of perms; track p.id) {<option [value]="p.id">{{ p.label }}</option>}</select>
3259
+ <button type="button" class="o-btn o-sm o-dark" [disabled]="!newCollab || busy()" (click)="setCollab(newCollab, newPerm, true)" data-testid="collab-add">Add</button>
3260
+ <span class="o-faint o-xs">They need to be in the project (any role, Guest included). Write pushes and opens pull requests here; maintain also changes this repository's settings.</span>
3261
+ </div>
3262
+ </div>
3263
+ } @else {
3264
+ <div class="o-card"><div class="o-empty">{{ repo()?.collaborator ? 'You have ' + repo()?.collaborator + ' on this repository as a collaborator. ' : '' }}Project Maintainers manage collaborators.</div></div>
3265
+ }
3151
3266
  }
3152
3267
  @case ('danger') {
3153
3268
  <h1 class="o-t">Danger zone</h1>
@@ -3182,7 +3297,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3182
3297
  <span class="o-lbl" style="padding-top:0">{{ r.name }}</span>
3183
3298
  <button type="button" [class.o-on]="section() === 'general'" (click)="go('general')">General</button>
3184
3299
  <button type="button" [class.o-on]="section() === 'protect'" (click)="go('protect')">Branch protection</button>
3185
- <button type="button" [class.o-on]="section() === 'collab'" (click)="go('collab')">Collaborators<b class="o-ph-tag o-ga o-r">GA</b></button>
3300
+ <button type="button" [class.o-on]="section() === 'collab'" (click)="go('collab')">Collaborators</button>
3186
3301
  <button type="button" [class.o-on]="section() === 'danger'" (click)="go('danger')">Danger zone</button>
3187
3302
  </nav>
3188
3303
  <div class="o-col" style="gap:16px;min-width:0">
@@ -3194,9 +3309,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3194
3309
  <form class="o-card o-cb o-form" (ngSubmit)="saveGeneral()">
3195
3310
  <div class="o-g2">
3196
3311
  <div class="o-fld"><label for="rs-name">Repository name</label><input id="rs-name" class="o-in o-mono" name="name" [(ngModel)]="g.name" [disabled]="!canAdmin()" /><span class="o-hint">Renaming keeps the old clone URL working as a redirect.</span></div>
3197
- <div class="o-fld"><label for="rs-def">Default branch</label><select id="rs-def" class="o-in" name="def" [(ngModel)]="g.defaultBranch" [disabled]="!canAdmin()">@for (b of branches(); track b.name) {<option [value]="b.name">{{ b.name }}</option>}@if (!branches().length) {<option [value]="r.defaultBranch">{{ r.defaultBranch }}</option>}</select></div>
3312
+ <div class="o-fld"><label for="rs-def">Default branch</label><select id="rs-def" class="o-in" name="def" [(ngModel)]="g.defaultBranch" [disabled]="!canMaintain()">@for (b of branches(); track b.name) {<option [value]="b.name">{{ b.name }}</option>}@if (!branches().length) {<option [value]="r.defaultBranch">{{ r.defaultBranch }}</option>}</select></div>
3198
3313
  </div>
3199
- <div class="o-fld"><label for="rs-desc">Description</label><input id="rs-desc" class="o-in" name="desc" [(ngModel)]="g.description" [disabled]="!canAdmin()" /></div>
3314
+ <div class="o-fld"><label for="rs-desc">Description</label><input id="rs-desc" class="o-in" name="desc" [(ngModel)]="g.description" [disabled]="!canMaintain()" /></div>
3200
3315
  <div class="o-fld"><span class="o-lbl" style="padding:0">Visibility</span>
3201
3316
  <div class="o-g2">
3202
3317
  <button type="button" class="o-opt" [class.o-on]="g.visibility === 'private'" (click)="g.visibility = 'private'" [disabled]="!canAdmin()"><span class="o-rad" [class.o-on]="g.visibility === 'private'"></span><span class="o-col" style="gap:2px"><b>Private</b><span class="o-faint o-sm">Project members only</span></span></button>
@@ -3204,11 +3319,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3204
3319
  </div>
3205
3320
  </div>
3206
3321
  <div class="o-fld"><span class="o-lbl" style="padding:0">Allowed merge methods</span>
3207
- @for (m of methods; track m.id) {<label class="o-flex"><input type="checkbox" [checked]="g.mergeMethods.includes(m.id)" (change)="flipMethod(m.id)" [disabled]="!canAdmin()" />{{ m.label }}</label>}
3322
+ @for (m of methods; track m.id) {<label class="o-flex"><input type="checkbox" [checked]="g.mergeMethods.includes(m.id)" (change)="flipMethod(m.id)" [disabled]="!canMaintain()" />{{ m.label }}</label>}
3208
3323
  </div>
3209
- <div class="o-flex"><button type="button" class="o-tog" role="switch" aria-labelledby="rs-del" [class.o-on]="g.deleteBranchOnMerge" [attr.aria-checked]="g.deleteBranchOnMerge" (click)="g.deleteBranchOnMerge = !g.deleteBranchOnMerge" [disabled]="!canAdmin()"></button><span id="rs-del">Delete head branches after merge</span></div>
3324
+ <div class="o-flex"><button type="button" class="o-tog" role="switch" aria-labelledby="rs-del" [class.o-on]="g.deleteBranchOnMerge" [attr.aria-checked]="g.deleteBranchOnMerge" (click)="g.deleteBranchOnMerge = !g.deleteBranchOnMerge" [disabled]="!canMaintain()"></button><span id="rs-del">Delete head branches after merge</span></div>
3210
3325
  <div class="o-flex"><button type="button" class="o-tog o-dis" disabled aria-label="Allow auto-merge"></button>Allow auto-merge when all requirements pass<b class="o-ph-tag o-ga">GA</b></div>
3211
- @if (canAdmin()) {<div class="o-flex"><button type="submit" class="o-btn o-dark o-r" [disabled]="busy() || !g.mergeMethods.length">Save</button></div>}
3326
+ @if (canMaintain()) {<div class="o-flex">@if (!canAdmin()) {<span class="o-faint o-sm">As a maintain collaborator you change these settings; a project Maintainer renames the repository or changes its visibility.</span>}<button type="submit" class="o-btn o-dark o-r" [disabled]="busy() || !g.mergeMethods.length" data-testid="settings-save">Save</button></div>}
3212
3327
  </form>
3213
3328
  }
3214
3329
  @case ('protect') {
@@ -3245,7 +3360,34 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3245
3360
  }
3246
3361
  @case ('collab') {
3247
3362
  <h1 class="o-t">Collaborators</h1>
3248
- <div class="o-card"><div class="o-empty">Access beyond the project role, for one repository, arrives at general availability. Until then, project roles decide who reads and pushes.</div></div>
3363
+ <span class="o-mut">Access beyond the project role, for this repository only.</span>
3364
+ @if (canAdmin()) {
3365
+ <div class="o-card" style="overflow-x:auto" data-testid="collaborators">
3366
+ <div class="o-tbl" role="table" aria-label="Collaborators" style="min-width:560px">
3367
+ <div class="o-thead" role="rowgroup"><div class="o-tr" role="row"><div class="o-th" role="columnheader">Person</div><div class="o-th" role="columnheader">Project role</div><div class="o-th" role="columnheader">Repository access</div><div class="o-th" role="columnheader"></div></div></div>
3368
+ <div class="o-tbody" role="rowgroup">
3369
+ @for (cl of collabs(); track cl.userId) {
3370
+ <div class="o-tr" role="row" [attr.data-testid]="'collab-' + cl.userId">
3371
+ <div class="o-td" role="cell"><span class="o-flex"><span class="o-av" aria-hidden="true">{{ people().initials(cl.userId) }}</span>{{ people().name(cl.userId) }}@if (cl.projectRole === 'guest') {<span class="o-chip">Guest</span>}</span></div>
3372
+ <div class="o-td o-mut" role="cell">{{ roleLabel(cl.projectRole) }}</div>
3373
+ <div class="o-td" role="cell"><select class="o-in o-sm" style="width:auto" [attr.aria-label]="'Repository access for ' + people().name(cl.userId)" [ngModel]="cl.permission" (ngModelChange)="setCollab(cl.userId, $event)">@for (p of perms; track p.id) {<option [value]="p.id">{{ p.label }}</option>}</select></div>
3374
+ <div class="o-td o-num" role="cell"><button type="button" class="o-btn o-xs o-ghost" (click)="removeCollab(cl.userId)" [attr.aria-label]="'Remove ' + people().name(cl.userId)">Remove</button></div>
3375
+ </div>
3376
+ } @empty {
3377
+ <div class="o-tr" role="row"><div class="o-td o-faint" role="cell" style="grid-column:1 / -1">No collaborators. Project roles decide who reads and pushes.</div></div>
3378
+ }
3379
+ </div>
3380
+ </div>
3381
+ <div class="o-cf">
3382
+ <select class="o-in o-sm" style="width:auto" aria-label="Person to add" [(ngModel)]="newCollab"><option value="">Add a person…</option>@for (p of addable(); track p.id) {<option [value]="p.id">{{ p.name || p.email }}</option>}</select>
3383
+ <select class="o-in o-sm" style="width:auto" aria-label="Access to give" [(ngModel)]="newPerm">@for (p of perms; track p.id) {<option [value]="p.id">{{ p.label }}</option>}</select>
3384
+ <button type="button" class="o-btn o-sm o-dark" [disabled]="!newCollab || busy()" (click)="setCollab(newCollab, newPerm, true)" data-testid="collab-add">Add</button>
3385
+ <span class="o-faint o-xs">They need to be in the project (any role, Guest included). Write pushes and opens pull requests here; maintain also changes this repository's settings.</span>
3386
+ </div>
3387
+ </div>
3388
+ } @else {
3389
+ <div class="o-card"><div class="o-empty">{{ repo()?.collaborator ? 'You have ' + repo()?.collaborator + ' on this repository as a collaborator. ' : '' }}Project Maintainers manage collaborators.</div></div>
3390
+ }
3249
3391
  }
3250
3392
  @case ('danger') {
3251
3393
  <h1 class="o-t">Danger zone</h1>
@@ -3268,6 +3410,386 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3268
3410
  }]
3269
3411
  }], ctorParameters: () => [] });
3270
3412
 
3413
+ // /{org}/{project}/code/{repo}/releases (prototype code_tags): releases newest first with their
3414
+ // notes, files and source archives, the latest marked; drafts for those who can push; a release
3415
+ // drafted on a new or existing tag with notes generated from the done issues since the previous
3416
+ // one; and the repository's tags.
3417
+ class ReleasesPage extends RepoPage {
3418
+ constructor() {
3419
+ super(...arguments);
3420
+ this.releases = signal([], ...(ngDevMode ? [{ debugName: "releases" }] : /* istanbul ignore next */ []));
3421
+ this.tags = signal([], ...(ngDevMode ? [{ debugName: "tags" }] : /* istanbul ignore next */ []));
3422
+ this.loaded = signal(false, ...(ngDevMode ? [{ debugName: "loaded" }] : /* istanbul ignore next */ []));
3423
+ this.tagging = signal(false, ...(ngDevMode ? [{ debugName: "tagging" }] : /* istanbul ignore next */ []));
3424
+ this.draft = signal(null, ...(ngDevMode ? [{ debugName: "draft" }] : /* istanbul ignore next */ []));
3425
+ this.busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
3426
+ this.confirming = signal('', ...(ngDevMode ? [{ debugName: "confirming" }] : /* istanbul ignore next */ []));
3427
+ this.freeTags = computed(() => this.tags().filter((t) => !this.used(t.name)), ...(ngDevMode ? [{ debugName: "freeTags" }] : /* istanbul ignore next */ []));
3428
+ this.ago = (s) => ago(s);
3429
+ this.tagName = '';
3430
+ this.tagFrom = '';
3431
+ this.alsoTag = false;
3432
+ }
3433
+ onRepo(r) {
3434
+ this.tagFrom = r.defaultBranch;
3435
+ this.load();
3436
+ }
3437
+ md(s) {
3438
+ return marked.parse(s || '', { async: false, gfm: true, breaks: true });
3439
+ }
3440
+ used(tag) {
3441
+ return this.releases().some((r) => r.tag === tag);
3442
+ }
3443
+ load() {
3444
+ const r = this.repo();
3445
+ if (!r)
3446
+ return;
3447
+ forkJoin({ rel: this.api.releases(this.projectId(), r.name), tags: this.api.refs(this.projectId(), r.name, 'tag') }).subscribe({
3448
+ next: ({ rel, tags }) => { this.releases.set(rel); this.tags.set(sortTags(tags)); this.loaded.set(true); },
3449
+ error: (e) => { this.loaded.set(true); this.error.set(apiMessage(e, 'Could not load the releases.')); },
3450
+ });
3451
+ }
3452
+ run(o, fallback, done) {
3453
+ this.busy.set(true);
3454
+ this.error.set('');
3455
+ this.note.set('');
3456
+ o.subscribe({
3457
+ next: () => { this.busy.set(false); done?.(); this.load(); },
3458
+ error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, fallback)); },
3459
+ });
3460
+ }
3461
+ createTag() {
3462
+ const r = this.repo();
3463
+ const t = this.tagName.trim();
3464
+ if (!r || !t)
3465
+ return;
3466
+ this.run(this.api.createTag(this.projectId(), r.name, t, this.tagFrom.trim() || r.defaultBranch), 'Could not create the tag.', () => {
3467
+ this.tagging.set(false);
3468
+ this.note.set(`Tag ${t} created.`);
3469
+ this.tagName = '';
3470
+ });
3471
+ }
3472
+ removeTag(t) {
3473
+ const r = this.repo();
3474
+ if (!r)
3475
+ return;
3476
+ this.run(this.api.deleteTag(this.projectId(), r.name, t.name), `Could not delete ${t.name}.`, () => this.note.set(`Tag ${t.name} deleted.`));
3477
+ }
3478
+ startRelease() {
3479
+ const r = this.repo();
3480
+ this.tagging.set(false);
3481
+ this.draft.set({ tag: '', newTag: true, target: r?.defaultBranch ?? 'main', title: '', notes: '', prerelease: false, draft: true });
3482
+ }
3483
+ setTag(d, tag) {
3484
+ d.tag = tag.trim();
3485
+ d.newTag = !this.tags().some((t) => t.name === d.tag);
3486
+ }
3487
+ generate(d) {
3488
+ const r = this.repo();
3489
+ if (!r)
3490
+ return;
3491
+ this.busy.set(true);
3492
+ const target = d.newTag ? d.target || r.defaultBranch : 'refs/tags/' + d.tag;
3493
+ this.api.releaseNotes(this.projectId(), r.name, target).subscribe({
3494
+ next: (n) => { this.busy.set(false); d.notes = n; this.draft.set({ ...d }); },
3495
+ error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not generate the notes.')); },
3496
+ });
3497
+ }
3498
+ edit(r) {
3499
+ this.tagging.set(false);
3500
+ this.draft.set({ id: r.id, version: r.version, tag: r.tag, newTag: false, target: '', title: r.title === r.tag ? '' : r.title, notes: r.notes, prerelease: r.prerelease, draft: r.draft });
3501
+ }
3502
+ save(d, asDraft) {
3503
+ const r = this.repo();
3504
+ if (!r)
3505
+ return;
3506
+ if (d.id) {
3507
+ this.run(this.api.updateRelease(this.projectId(), r.name, d.id, { title: d.title, notes: d.notes, prerelease: d.prerelease, draft: d.draft && asDraft, version: d.version }), 'Could not save the release.', () => { this.draft.set(null); this.note.set(asDraft ? 'Draft saved.' : `${d.tag} saved.`); });
3508
+ return;
3509
+ }
3510
+ this.run(this.api.createRelease(this.projectId(), r.name, { tag: d.tag, target: d.newTag ? d.target || r.defaultBranch : undefined, title: d.title, notes: d.notes, prerelease: d.prerelease, draft: asDraft }), 'Could not create the release.', () => { this.draft.set(null); this.note.set(asDraft ? `Draft ${d.tag} saved.` : `${d.tag} published.`); });
3511
+ }
3512
+ remove(rel) {
3513
+ const r = this.repo();
3514
+ if (!r)
3515
+ return;
3516
+ this.confirming.set('');
3517
+ this.run(this.api.deleteRelease(this.projectId(), r.name, rel.id, this.alsoTag), `Could not delete ${rel.tag}.`, () => this.note.set(`Release ${rel.tag} deleted${this.alsoTag ? ' with its tag' : ''}.`));
3518
+ }
3519
+ upload(rel, ev) {
3520
+ const r = this.repo();
3521
+ const input = ev.target;
3522
+ const file = input.files?.[0];
3523
+ if (!r || !file)
3524
+ return;
3525
+ if (file.size > 25 * 1024 * 1024) {
3526
+ this.error.set('A release file is at most 25 MB.');
3527
+ return;
3528
+ }
3529
+ this.run(this.api.addAsset(this.projectId(), r.name, rel.id, file), 'Could not add the file.', () => { input.value = ''; this.note.set(`${file.name} added to ${rel.tag}.`); });
3530
+ }
3531
+ removeAsset(rel, id) {
3532
+ const r = this.repo();
3533
+ if (!r)
3534
+ return;
3535
+ this.run(this.api.deleteAsset(this.projectId(), r.name, rel.id, id), 'Could not remove the file.');
3536
+ }
3537
+ download(rel, id, filename) {
3538
+ const r = this.repo();
3539
+ if (!r)
3540
+ return;
3541
+ this.api.assetBlob(this.projectId(), r.name, rel.id, id).subscribe({
3542
+ next: (b) => this.save_(b, filename),
3543
+ error: (e) => this.error.set(apiMessage(e, 'Could not download the file.')),
3544
+ });
3545
+ }
3546
+ archive(rel, format) {
3547
+ const r = this.repo();
3548
+ if (!r)
3549
+ return;
3550
+ this.api.archiveUrl(this.projectId(), r.name, 'refs/tags/' + rel.tag, format).subscribe({
3551
+ next: (url) => { if (url)
3552
+ window.location.assign(url); },
3553
+ error: (e) => this.error.set(apiMessage(e, 'Could not prepare the archive.')),
3554
+ });
3555
+ }
3556
+ save_(b, filename) {
3557
+ const url = URL.createObjectURL(b);
3558
+ const link = document.createElement('a');
3559
+ link.href = url;
3560
+ link.download = filename;
3561
+ link.click();
3562
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
3563
+ }
3564
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ReleasesPage, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
3565
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.12", type: ReleasesPage, isStandalone: true, selector: "o-code-releases", usesInheritance: true, ngImport: i0, template: `
3566
+ <div class="o-page o-narrow">
3567
+ <div class="o-ph">
3568
+ <div class="o-col"><span class="o-eyebrow">{{ name() }}</span><h1 class="o-t">Releases and tags</h1></div>
3569
+ @if (canWrite()) {
3570
+ <div class="o-acts">
3571
+ <button type="button" class="o-btn o-sm" (click)="tagging.set(!tagging()); draft.set(null)" data-testid="tag-new">New tag</button>
3572
+ <button type="button" class="o-btn o-sm o-dark" (click)="startRelease()" data-testid="release-new">Draft release</button>
3573
+ </div>
3574
+ }
3575
+ </div>
3576
+ @if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
3577
+ @if (note()) {<div class="o-note" role="status">{{ note() }}</div>}
3578
+
3579
+ @if (tagging()) {
3580
+ <form class="o-card" (ngSubmit)="createTag()" data-testid="tag-form">
3581
+ <div class="o-ch"><b>New tag</b></div>
3582
+ <div class="o-cb o-flex o-wrap" style="align-items:flex-end">
3583
+ <div class="o-fld"><label for="tg-name">Tag</label><input id="tg-name" class="o-in o-mono" name="tag" placeholder="v1.8.0" [(ngModel)]="tagName" /></div>
3584
+ <div class="o-fld"><label for="tg-from">At</label><input id="tg-from" class="o-in o-mono" name="from" [(ngModel)]="tagFrom" placeholder="main or a commit" /></div>
3585
+ <button type="button" class="o-btn o-sm o-r" (click)="tagging.set(false)">Cancel</button>
3586
+ <button type="submit" class="o-btn o-sm o-dark" [disabled]="busy() || !tagName.trim()">Create tag</button>
3587
+ </div>
3588
+ </form>
3589
+ }
3590
+
3591
+ @if (draft(); as d) {
3592
+ <div class="o-card" data-testid="release-form">
3593
+ <div class="o-ch"><b>{{ d.id ? 'Edit ' + d.tag : 'Draft a release' }}</b></div>
3594
+ <div class="o-cb o-col" style="gap:12px">
3595
+ @if (!d.id) {
3596
+ <div class="o-flex o-wrap" style="align-items:flex-end">
3597
+ <div class="o-fld"><label for="rl-tag">Tag</label>
3598
+ <input id="rl-tag" class="o-in o-mono" list="rl-tags" placeholder="v1.8.0" [ngModel]="d.tag" (ngModelChange)="setTag(d, $event)" data-testid="release-tag" />
3599
+ <datalist id="rl-tags">@for (t of freeTags(); track t.name) {<option [value]="t.name"></option>}</datalist>
3600
+ </div>
3601
+ @if (d.newTag) {<div class="o-fld"><label for="rl-target">New tag at</label><input id="rl-target" class="o-in o-mono" [(ngModel)]="d.target" placeholder="main" /></div>}
3602
+ <span class="o-hint" style="padding-bottom:8px">{{ d.tag ? (d.newTag ? 'A new tag, made when you save.' : 'An existing tag.') : '' }}</span>
3603
+ </div>
3604
+ }
3605
+ <div class="o-fld"><label for="rl-title">Title</label><input id="rl-title" class="o-in" maxlength="255" [(ngModel)]="d.title" placeholder="Defaults to the tag" data-testid="release-title" /></div>
3606
+ <div class="o-fld">
3607
+ <div class="o-flex"><label for="rl-notes">Notes</label>
3608
+ @if (!d.id) {<button type="button" class="o-lnk o-r o-sm" [disabled]="busy() || !d.tag" (click)="generate(d)" data-testid="release-generate">Generate from done issues</button>}
3609
+ </div>
3610
+ <textarea id="rl-notes" class="o-in" rows="7" style="height:auto;padding:8px 10px" [(ngModel)]="d.notes" placeholder="Markdown works." data-testid="release-notes"></textarea>
3611
+ <span class="o-hint">Generated notes list the done issues and merged pull requests since the previous release.</span>
3612
+ </div>
3613
+ <label class="o-flex o-sm"><input type="checkbox" [(ngModel)]="d.prerelease" /> Pre-release (never marked Latest)</label>
3614
+ </div>
3615
+ <div class="o-cf">
3616
+ <button type="button" class="o-btn o-sm o-r" (click)="draft.set(null)">Cancel</button>
3617
+ @if (!d.id || d.draft) {<button type="button" class="o-btn o-sm" [disabled]="busy() || !d.tag" (click)="save(d, true)" data-testid="release-save-draft">Save draft</button>}
3618
+ <button type="button" class="o-btn o-sm o-dark" [disabled]="busy() || !d.tag" (click)="save(d, false)" data-testid="release-publish">{{ d.id && !d.draft ? 'Save' : 'Publish release' }}</button>
3619
+ </div>
3620
+ </div>
3621
+ }
3622
+
3623
+ @for (r of releases(); track r.id) {
3624
+ <div class="o-card rel" [attr.data-testid]="'release-' + r.tag">
3625
+ <div class="o-ch">
3626
+ <b class="o-md">{{ r.tag }}</b>
3627
+ @if (r.latest) {<span class="o-chip o-grn">Latest</span>}
3628
+ @if (r.draft) {<span class="o-chip o-amb">Draft</span>}
3629
+ @if (r.prerelease) {<span class="o-chip">Pre-release</span>}
3630
+ <span class="o-r o-mono o-sm o-faint">{{ r.short }} · {{ ago(r.publishedAt || r.createdAt) }} · {{ people().name(r.createdBy) }}</span>
3631
+ </div>
3632
+ <div class="o-cb">
3633
+ @if (r.title && r.title !== r.tag) {<h4>{{ r.title }}</h4>}
3634
+ <div class="o-md" style="font-size:13px" [innerHTML]="r.notes ? md(r.notes) : '<p class=&quot;o-faint&quot;>No notes.</p>'"></div>
3635
+ </div>
3636
+ <div class="o-cf assets">
3637
+ <span>Assets</span>
3638
+ @for (a of r.assets; track a.id) {
3639
+ <button type="button" class="o-lnk o-mono" (click)="download(r, a.id, a.name)" [attr.aria-label]="'Download ' + a.name">{{ a.name }}</button>
3640
+ @if (canWrite()) {<button type="button" class="o-lnk" style="color:var(--o-mut)" (click)="removeAsset(r, a.id)" [attr.aria-label]="'Remove ' + a.name">×</button>}
3641
+ }
3642
+ <button type="button" class="o-lnk" (click)="archive(r, 'zip')">Source (zip)</button>
3643
+ <button type="button" class="o-lnk" (click)="archive(r, 'tar.gz')">Source (tar.gz)</button>
3644
+ @if (canWrite()) {
3645
+ <span class="o-r o-flex" style="gap:10px">
3646
+ <label class="o-lnk" style="cursor:pointer">Add file<input type="file" class="o-sr-only" (change)="upload(r, $event)" [attr.aria-label]="'Add a file to ' + r.tag" /></label>
3647
+ <button type="button" class="o-lnk" (click)="edit(r)" [attr.aria-label]="'Edit ' + r.tag">Edit</button>
3648
+ @if (confirming() === r.id) {
3649
+ <label class="o-flex o-xs"><input type="checkbox" [(ngModel)]="alsoTag" /> and the tag</label>
3650
+ <button type="button" class="o-lnk o-red-t" (click)="remove(r)">Delete</button><button type="button" class="o-lnk" (click)="confirming.set('')">Keep</button>
3651
+ } @else {
3652
+ <button type="button" class="o-lnk" style="color:var(--o-mut)" (click)="confirming.set(r.id); alsoTag = false" [attr.aria-label]="'Delete ' + r.tag">Delete</button>
3653
+ }
3654
+ </span>
3655
+ }
3656
+ </div>
3657
+ </div>
3658
+ } @empty {
3659
+ <div class="o-card"><div class="o-empty">{{ loaded() ? 'No releases yet.' + (canWrite() ? ' Draft one from a tag; its notes can come from the issues it finishes.' : '') : 'Loading…' }}</div></div>
3660
+ }
3661
+
3662
+ <div class="o-card" data-testid="tags">
3663
+ <div class="o-ch"><b>Tags</b><span class="o-r o-faint o-sm">{{ tags().length }}</span></div>
3664
+ @for (t of tags(); track t.name) {
3665
+ <div class="o-row" [attr.data-testid]="'tag-' + t.name">
3666
+ <span class="o-mono o-sm o-b5">{{ t.name }}</span><span class="o-faint o-sm o-mono">{{ t.commit?.short }}</span>
3667
+ <span class="o-r o-faint o-sm">{{ ago(t.commit?.committer?.when) }}</span>
3668
+ @if (canWrite() && !used(t.name)) {<button type="button" class="o-lnk" style="color:var(--o-mut)" (click)="removeTag(t)" [attr.aria-label]="'Delete tag ' + t.name">Delete</button>}
3669
+ </div>
3670
+ } @empty {<div class="o-empty o-sm">No tags yet.</div>}
3671
+ </div>
3672
+ </div>
3673
+ `, isInline: true, styles: [".rel h4{margin:0 0 6px;font-size:15px}.rel .o-md ul{margin:0;padding-left:20px}.assets{display:flex;gap:12px;flex-wrap:wrap;align-items:center}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.MaxLengthValidator, selector: "[maxlength][formControlName],[maxlength][formControl],[maxlength][ngModel]", inputs: ["maxlength"] }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1.NgForm, selector: "form:not([ngNoForm]):not([formGroup]):not([formArray]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
3674
+ }
3675
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: ReleasesPage, decorators: [{
3676
+ type: Component,
3677
+ args: [{ selector: 'o-code-releases', imports: [FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
3678
+ <div class="o-page o-narrow">
3679
+ <div class="o-ph">
3680
+ <div class="o-col"><span class="o-eyebrow">{{ name() }}</span><h1 class="o-t">Releases and tags</h1></div>
3681
+ @if (canWrite()) {
3682
+ <div class="o-acts">
3683
+ <button type="button" class="o-btn o-sm" (click)="tagging.set(!tagging()); draft.set(null)" data-testid="tag-new">New tag</button>
3684
+ <button type="button" class="o-btn o-sm o-dark" (click)="startRelease()" data-testid="release-new">Draft release</button>
3685
+ </div>
3686
+ }
3687
+ </div>
3688
+ @if (error()) {<div class="o-note o-red" role="alert">{{ error() }}</div>}
3689
+ @if (note()) {<div class="o-note" role="status">{{ note() }}</div>}
3690
+
3691
+ @if (tagging()) {
3692
+ <form class="o-card" (ngSubmit)="createTag()" data-testid="tag-form">
3693
+ <div class="o-ch"><b>New tag</b></div>
3694
+ <div class="o-cb o-flex o-wrap" style="align-items:flex-end">
3695
+ <div class="o-fld"><label for="tg-name">Tag</label><input id="tg-name" class="o-in o-mono" name="tag" placeholder="v1.8.0" [(ngModel)]="tagName" /></div>
3696
+ <div class="o-fld"><label for="tg-from">At</label><input id="tg-from" class="o-in o-mono" name="from" [(ngModel)]="tagFrom" placeholder="main or a commit" /></div>
3697
+ <button type="button" class="o-btn o-sm o-r" (click)="tagging.set(false)">Cancel</button>
3698
+ <button type="submit" class="o-btn o-sm o-dark" [disabled]="busy() || !tagName.trim()">Create tag</button>
3699
+ </div>
3700
+ </form>
3701
+ }
3702
+
3703
+ @if (draft(); as d) {
3704
+ <div class="o-card" data-testid="release-form">
3705
+ <div class="o-ch"><b>{{ d.id ? 'Edit ' + d.tag : 'Draft a release' }}</b></div>
3706
+ <div class="o-cb o-col" style="gap:12px">
3707
+ @if (!d.id) {
3708
+ <div class="o-flex o-wrap" style="align-items:flex-end">
3709
+ <div class="o-fld"><label for="rl-tag">Tag</label>
3710
+ <input id="rl-tag" class="o-in o-mono" list="rl-tags" placeholder="v1.8.0" [ngModel]="d.tag" (ngModelChange)="setTag(d, $event)" data-testid="release-tag" />
3711
+ <datalist id="rl-tags">@for (t of freeTags(); track t.name) {<option [value]="t.name"></option>}</datalist>
3712
+ </div>
3713
+ @if (d.newTag) {<div class="o-fld"><label for="rl-target">New tag at</label><input id="rl-target" class="o-in o-mono" [(ngModel)]="d.target" placeholder="main" /></div>}
3714
+ <span class="o-hint" style="padding-bottom:8px">{{ d.tag ? (d.newTag ? 'A new tag, made when you save.' : 'An existing tag.') : '' }}</span>
3715
+ </div>
3716
+ }
3717
+ <div class="o-fld"><label for="rl-title">Title</label><input id="rl-title" class="o-in" maxlength="255" [(ngModel)]="d.title" placeholder="Defaults to the tag" data-testid="release-title" /></div>
3718
+ <div class="o-fld">
3719
+ <div class="o-flex"><label for="rl-notes">Notes</label>
3720
+ @if (!d.id) {<button type="button" class="o-lnk o-r o-sm" [disabled]="busy() || !d.tag" (click)="generate(d)" data-testid="release-generate">Generate from done issues</button>}
3721
+ </div>
3722
+ <textarea id="rl-notes" class="o-in" rows="7" style="height:auto;padding:8px 10px" [(ngModel)]="d.notes" placeholder="Markdown works." data-testid="release-notes"></textarea>
3723
+ <span class="o-hint">Generated notes list the done issues and merged pull requests since the previous release.</span>
3724
+ </div>
3725
+ <label class="o-flex o-sm"><input type="checkbox" [(ngModel)]="d.prerelease" /> Pre-release (never marked Latest)</label>
3726
+ </div>
3727
+ <div class="o-cf">
3728
+ <button type="button" class="o-btn o-sm o-r" (click)="draft.set(null)">Cancel</button>
3729
+ @if (!d.id || d.draft) {<button type="button" class="o-btn o-sm" [disabled]="busy() || !d.tag" (click)="save(d, true)" data-testid="release-save-draft">Save draft</button>}
3730
+ <button type="button" class="o-btn o-sm o-dark" [disabled]="busy() || !d.tag" (click)="save(d, false)" data-testid="release-publish">{{ d.id && !d.draft ? 'Save' : 'Publish release' }}</button>
3731
+ </div>
3732
+ </div>
3733
+ }
3734
+
3735
+ @for (r of releases(); track r.id) {
3736
+ <div class="o-card rel" [attr.data-testid]="'release-' + r.tag">
3737
+ <div class="o-ch">
3738
+ <b class="o-md">{{ r.tag }}</b>
3739
+ @if (r.latest) {<span class="o-chip o-grn">Latest</span>}
3740
+ @if (r.draft) {<span class="o-chip o-amb">Draft</span>}
3741
+ @if (r.prerelease) {<span class="o-chip">Pre-release</span>}
3742
+ <span class="o-r o-mono o-sm o-faint">{{ r.short }} · {{ ago(r.publishedAt || r.createdAt) }} · {{ people().name(r.createdBy) }}</span>
3743
+ </div>
3744
+ <div class="o-cb">
3745
+ @if (r.title && r.title !== r.tag) {<h4>{{ r.title }}</h4>}
3746
+ <div class="o-md" style="font-size:13px" [innerHTML]="r.notes ? md(r.notes) : '<p class=&quot;o-faint&quot;>No notes.</p>'"></div>
3747
+ </div>
3748
+ <div class="o-cf assets">
3749
+ <span>Assets</span>
3750
+ @for (a of r.assets; track a.id) {
3751
+ <button type="button" class="o-lnk o-mono" (click)="download(r, a.id, a.name)" [attr.aria-label]="'Download ' + a.name">{{ a.name }}</button>
3752
+ @if (canWrite()) {<button type="button" class="o-lnk" style="color:var(--o-mut)" (click)="removeAsset(r, a.id)" [attr.aria-label]="'Remove ' + a.name">×</button>}
3753
+ }
3754
+ <button type="button" class="o-lnk" (click)="archive(r, 'zip')">Source (zip)</button>
3755
+ <button type="button" class="o-lnk" (click)="archive(r, 'tar.gz')">Source (tar.gz)</button>
3756
+ @if (canWrite()) {
3757
+ <span class="o-r o-flex" style="gap:10px">
3758
+ <label class="o-lnk" style="cursor:pointer">Add file<input type="file" class="o-sr-only" (change)="upload(r, $event)" [attr.aria-label]="'Add a file to ' + r.tag" /></label>
3759
+ <button type="button" class="o-lnk" (click)="edit(r)" [attr.aria-label]="'Edit ' + r.tag">Edit</button>
3760
+ @if (confirming() === r.id) {
3761
+ <label class="o-flex o-xs"><input type="checkbox" [(ngModel)]="alsoTag" /> and the tag</label>
3762
+ <button type="button" class="o-lnk o-red-t" (click)="remove(r)">Delete</button><button type="button" class="o-lnk" (click)="confirming.set('')">Keep</button>
3763
+ } @else {
3764
+ <button type="button" class="o-lnk" style="color:var(--o-mut)" (click)="confirming.set(r.id); alsoTag = false" [attr.aria-label]="'Delete ' + r.tag">Delete</button>
3765
+ }
3766
+ </span>
3767
+ }
3768
+ </div>
3769
+ </div>
3770
+ } @empty {
3771
+ <div class="o-card"><div class="o-empty">{{ loaded() ? 'No releases yet.' + (canWrite() ? ' Draft one from a tag; its notes can come from the issues it finishes.' : '') : 'Loading…' }}</div></div>
3772
+ }
3773
+
3774
+ <div class="o-card" data-testid="tags">
3775
+ <div class="o-ch"><b>Tags</b><span class="o-r o-faint o-sm">{{ tags().length }}</span></div>
3776
+ @for (t of tags(); track t.name) {
3777
+ <div class="o-row" [attr.data-testid]="'tag-' + t.name">
3778
+ <span class="o-mono o-sm o-b5">{{ t.name }}</span><span class="o-faint o-sm o-mono">{{ t.commit?.short }}</span>
3779
+ <span class="o-r o-faint o-sm">{{ ago(t.commit?.committer?.when) }}</span>
3780
+ @if (canWrite() && !used(t.name)) {<button type="button" class="o-lnk" style="color:var(--o-mut)" (click)="removeTag(t)" [attr.aria-label]="'Delete tag ' + t.name">Delete</button>}
3781
+ </div>
3782
+ } @empty {<div class="o-empty o-sm">No tags yet.</div>}
3783
+ </div>
3784
+ </div>
3785
+ `, styles: [".rel h4{margin:0 0 6px;font-size:15px}.rel .o-md ul{margin:0;padding-left:20px}.assets{display:flex;gap:12px;flex-wrap:wrap;align-items:center}\n"] }]
3786
+ }] });
3787
+ /** Newest commit first; tags on commits of the same second by version, highest first (v1.10 after v1.9). */
3788
+ function sortTags(tags) {
3789
+ const when = (t) => Date.parse(t.commit?.committer?.when ?? '') || 0;
3790
+ return [...tags].sort((a, b) => when(b) - when(a) || b.name.localeCompare(a.name, undefined, { numeric: true }));
3791
+ }
3792
+
3271
3793
  /*
3272
3794
  * @chisquare-tech/origine-ngx-apps-code: the Code module's screens (TRD §6.10). The host app provides
3273
3795
  * CodeHost and mounts the pages at /{org}/{project}/code, /code/new, /code/pulls and
@@ -3278,5 +3800,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3278
3800
  * Generated bundle index. Do not edit.
3279
3801
  */
3280
3802
 
3281
- export { BranchesPage, CodeApi, CodeHost, CommitPage, CommitsPage, CurrentRepo, CurrentRepoPage, DiffView, FilePage, NewRepoPage, People, PullPage, PullsPage, RefPicker, RepoHomePage, RepoPage, RepoSettingsPage, ReposPage, TOKEN_CSS, ago, apiMessage, checkDot, copy, dayLabel, initials, langColour, pair, shortDate, size };
3803
+ export { BranchesPage, CodeApi, CodeHost, CommitPage, CommitsPage, CurrentRepo, CurrentRepoPage, DiffView, FilePage, NewRepoPage, People, PullPage, PullsPage, RefPicker, ReleasesPage, RepoHomePage, RepoPage, RepoSettingsPage, ReposPage, TOKEN_CSS, ago, apiMessage, checkDot, copy, dayLabel, initials, langColour, pair, shortDate, size };
3282
3804
  //# sourceMappingURL=chisquare-tech-origine-ngx-apps-code.mjs.map