@chisquare-tech/origine-ngx-apps-code 0.11.3 → 0.13.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.
@@ -1,12 +1,13 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { inject, Injectable, signal, computed, effect, untracked, Directive, ElementRef, input, output, HostListener, ChangeDetectionStrategy, Component, DestroyRef } from '@angular/core';
3
3
  import { ApiService } from '@thescaffold/ngx-core';
4
- import { map, forkJoin, of } from 'rxjs';
4
+ import { map, catchError, of, forkJoin } from 'rxjs';
5
5
  import * as i1 from '@angular/forms';
6
6
  import { FormsModule } from '@angular/forms';
7
7
  import { ActivatedRoute, RouterLink, Router } from '@angular/router';
8
8
  import { NgTemplateOutlet } from '@angular/common';
9
- import { map as map$1, catchError } from 'rxjs/operators';
9
+ import { DomSanitizer } from '@angular/platform-browser';
10
+ import { map as map$1, catchError as catchError$1 } from 'rxjs/operators';
10
11
  import { marked } from 'marked';
11
12
 
12
13
  /**
@@ -29,6 +30,17 @@ class CodeApi {
29
30
  repoPath(project, name) {
30
31
  return `${C}/project/${enc(project)}/repo/${enc(name)}`;
31
32
  }
33
+ /**
34
+ * Whether this installation signs the commits it makes (DEV-31, D-67): GET /signing-key answers.
35
+ * A web edit is then signed, so a rule that only asks for signed commits takes it directly.
36
+ */
37
+ platformSigns() {
38
+ return this.api.get(`${C}/signing-key`).pipe(map((r) => !!data(r)), catchError(() => of(false)));
39
+ }
40
+ /** The organization's custom roles (V4-01), for the bypass list of a branch rule. */
41
+ customRoles() {
42
+ return this.api.get('v1/projects/roles').pipe(map((r) => (data(r)?.custom ?? []).map((c) => ({ id: c.id, name: c.name }))));
43
+ }
32
44
  repos(project) {
33
45
  return this.api.get(`${C}/project/${enc(project)}/repo`).pipe(map((r) => data(r) ?? []));
34
46
  }
@@ -38,6 +50,21 @@ class CodeApi {
38
50
  import(project, body) {
39
51
  return this.api.post(`${C}/project/${enc(project)}/repo/import`, body).pipe(map((r) => data(r)));
40
52
  }
53
+ /** A repository's pull mirror (V6-01). */
54
+ mirror(project, name) {
55
+ return this.api.get(`${this.repoPath(project, name)}/mirror`).pipe(map((r) => data(r)));
56
+ }
57
+ /** Changes the mirror's source, credentials or interval; the saver becomes whom it syncs as. */
58
+ updateMirror(project, name, body) {
59
+ return this.api.patch(`${this.repoPath(project, name)}/mirror`, body).pipe(map((r) => data(r)));
60
+ }
61
+ syncMirror(project, name) {
62
+ return this.api.post(`${this.repoPath(project, name)}/mirror/sync`, {}).pipe(map((r) => data(r)));
63
+ }
64
+ /** Stops mirroring: an ordinary repository that takes pushes. */
65
+ stopMirror(project, name) {
66
+ return this.api.delete(`${this.repoPath(project, name)}/mirror`).pipe(map((r) => data(r)));
67
+ }
41
68
  repo(project, name) {
42
69
  return this.api.get(this.repoPath(project, name)).pipe(map((r) => data(r)));
43
70
  }
@@ -211,6 +238,14 @@ class CodeApi {
211
238
  merge(project, name, n, body) {
212
239
  return this.api.post(`${this.repoPath(project, name)}/pulls/${n}/merge`, body).pipe(map((r) => data(r)));
213
240
  }
241
+ /** A pull request's conflicts with its target, for the resolver (V6-03). */
242
+ conflicts(project, name, n) {
243
+ return this.api.get(`${this.repoPath(project, name)}/pulls/${n}/conflicts`).pipe(map((r) => data(r)));
244
+ }
245
+ /** Commits a resolution of the conflicts (V6-03). */
246
+ resolveConflicts(project, name, n, body) {
247
+ return this.api.post(`${this.repoPath(project, name)}/pulls/${n}/resolve`, body).pipe(map((r) => data(r)));
248
+ }
214
249
  conversation(project, name, n) {
215
250
  return this.api.get(`${this.repoPath(project, name)}/pulls/${n}/conversation`).pipe(map((r) => data(r) ?? { comments: [], reviews: [], threads: [] }));
216
251
  }
@@ -356,6 +391,18 @@ const TOKEN_CSS = `
356
391
  :host ::ng-deep .tk-nb, :host ::ng-deep .tk-nt, :host ::ng-deep .tk-bp{color:var(--o-tk-p)}
357
392
  :host ::ng-deep .tk-gi{color:var(--o-add)} :host ::ng-deep .tk-gd{color:var(--o-bad-tx)}
358
393
  `;
394
+ /** A commit's verification badge (DEV-31, V6-04): commits Origine made read "Verified · Origine". */
395
+ function verifyLabel(v) {
396
+ if (!v.verified)
397
+ return 'Unverified';
398
+ return v.signer === 'origine' ? 'Verified · Origine' : 'Verified';
399
+ }
400
+ /** The badge's hover text: whose key signed it, or why it is not verified. */
401
+ function verifyTitle(v, name) {
402
+ if (!v.verified)
403
+ return verifyWhy(v.reason);
404
+ return v.signer === 'origine' ? 'Made and signed by Origine, with the signing key of its region' : 'Signed with a key of ' + name(v.signer ?? '');
405
+ }
359
406
  /** Why a signed commit is not verified (DEV-31). */
360
407
  function verifyWhy(reason) {
361
408
  return { unknown_key: 'Signed with a key that is not the committer’s signing key', bad_signature: 'The signature does not match the commit' }[reason] ?? 'Not verified';
@@ -367,6 +414,13 @@ function verifyWhy(reason) {
367
414
  * their own data in onRepo().
368
415
  */
369
416
  class RepoPage {
417
+ verifyTitle(v) {
418
+ return verifyTitle(v, (id) => this.people().name(id));
419
+ }
420
+ /** Whether the caller holds code capability id on this repository (V4-01): its caps, a collaborator row included. */
421
+ can(id) {
422
+ return (this.repo()?.caps ?? []).includes(id);
423
+ }
370
424
  constructor() {
371
425
  this.host = inject(CodeHost);
372
426
  this.api = inject(CodeApi);
@@ -378,11 +432,20 @@ class RepoPage {
378
432
  this.note = signal('', ...(ngDevMode ? [{ debugName: "note" }] : /* istanbul ignore next */ []));
379
433
  this.people = computed(() => new People(this.host.people()), ...(ngDevMode ? [{ debugName: "people" }] : /* istanbul ignore next */ []));
380
434
  this.verifyWhy = verifyWhy;
381
- this.canWrite = computed(() => !this.repo()?.archivedAt && (this.repo()?.capabilities ?? []).includes('code.write'), ...(ngDevMode ? [{ debugName: "canWrite" }] : /* istanbul ignore next */ []));
382
- this.canAdmin = computed(() => (this.repo()?.capabilities ?? []).includes('resources.manage'), ...(ngDevMode ? [{ debugName: "canAdmin" }] : /* istanbul ignore next */ []));
383
- /** Repository settings short of a rename, the visibility and deletion: Maintainers, and maintain collaborators. */
384
- this.canMaintain = computed(() => this.canAdmin() || (this.repo()?.capabilities ?? []).includes('repo.maintain'), ...(ngDevMode ? [{ debugName: "canMaintain" }] : /* istanbul ignore next */ []));
385
- this.canProtect = computed(() => (this.repo()?.capabilities ?? []).includes('protection.manage'), ...(ngDevMode ? [{ debugName: "canProtect" }] : /* istanbul ignore next */ []));
435
+ this.verifyLabel = verifyLabel;
436
+ /** Takes writes: not archived, and not a pull mirror (V6-01). */
437
+ this.live = computed(() => !!this.repo() && !this.repo()?.archivedAt && !this.repo()?.mirror, ...(ngDevMode ? [{ debugName: "live" }] : /* istanbul ignore next */ []));
438
+ this.canWrite = computed(() => this.live() && this.can('code.push'), ...(ngDevMode ? [{ debugName: "canWrite" }] : /* istanbul ignore next */ []));
439
+ /** Every repository setting, a rename and the visibility included. */
440
+ this.canSettings = computed(() => this.can('code.repo.settings'), ...(ngDevMode ? [{ debugName: "canSettings" }] : /* istanbul ignore next */ []));
441
+ /** Repository settings short of a rename and the visibility: the above, and maintain collaborators. */
442
+ this.canMaintain = computed(() => this.canSettings() || this.repo()?.collaborator === 'maintain', ...(ngDevMode ? [{ debugName: "canMaintain" }] : /* istanbul ignore next */ []));
443
+ this.canDelete = computed(() => this.can('code.repo.delete'), ...(ngDevMode ? [{ debugName: "canDelete" }] : /* istanbul ignore next */ []));
444
+ this.canCollaborators = computed(() => this.can('code.collaborators.manage'), ...(ngDevMode ? [{ debugName: "canCollaborators" }] : /* istanbul ignore next */ []));
445
+ this.canProtect = computed(() => this.can('code.branch.protect'), ...(ngDevMode ? [{ debugName: "canProtect" }] : /* istanbul ignore next */ []));
446
+ this.canRelease = computed(() => this.live() && this.can('code.release.manage'), ...(ngDevMode ? [{ debugName: "canRelease" }] : /* istanbul ignore next */ []));
447
+ this.canOpenPull = computed(() => this.live() && this.can('code.pr.create'), ...(ngDevMode ? [{ debugName: "canOpenPull" }] : /* istanbul ignore next */ []));
448
+ this.canMerge = computed(() => this.live() && this.can('code.pr.merge'), ...(ngDevMode ? [{ debugName: "canMerge" }] : /* istanbul ignore next */ []));
386
449
  this.route.paramMap.subscribe((m) => this.name.set(m.get('repo') ?? ''));
387
450
  effect(() => {
388
451
  const name = this.name();
@@ -960,7 +1023,7 @@ class ReposPage {
960
1023
  this.ago = (s) => ago(s);
961
1024
  this.canWrite = computed(() => {
962
1025
  const p = this.host.project();
963
- return !!p && !p.archivedAt && p.capabilities.includes('code.write');
1026
+ return !!p && !p.archivedAt && this.host.can('code.repo.create');
964
1027
  }, ...(ngDevMode ? [{ debugName: "canWrite" }] : /* istanbul ignore next */ []));
965
1028
  effect(() => {
966
1029
  const p = this.host.project();
@@ -1072,6 +1135,8 @@ class NewRepoPage {
1072
1135
  this.url_ = '';
1073
1136
  this.user = '';
1074
1137
  this.token = '';
1138
+ this.mirror = false;
1139
+ this.interval = 60;
1075
1140
  this.route.queryParamMap.subscribe((q) => this.mode.set(q.get('import') ? 'import' : 'create'));
1076
1141
  }
1077
1142
  nameFromUrl() {
@@ -1101,7 +1166,7 @@ class NewRepoPage {
1101
1166
  return;
1102
1167
  this.busy.set(true);
1103
1168
  this.error.set('');
1104
- this.api.import(p.id, { name: this.repoName.trim(), url: this.url_.trim(), username: this.user.trim() || undefined, token: this.token || undefined, visibility: this.vis }).subscribe({
1169
+ this.api.import(p.id, { name: this.repoName.trim(), url: this.url_.trim(), username: this.user.trim() || undefined, token: this.token || undefined, visibility: this.vis, mirror: this.mirror || undefined, intervalMinutes: this.mirror ? Number(this.interval) || undefined : undefined }).subscribe({
1105
1170
  next: (r) => { this.busy.set(false); this.token = ''; this.made.set(r); },
1106
1171
  error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not start the import.')); },
1107
1172
  });
@@ -1159,11 +1224,15 @@ class NewRepoPage {
1159
1224
  <div class="o-fld"><label for="ir-user">Username <span class="o-faint">(private repos)</span></label><input id="ir-user" class="o-in" name="user" [(ngModel)]="user" autocomplete="off" /></div>
1160
1225
  <div class="o-fld"><label for="ir-tok">Access token</label><input id="ir-tok" class="o-in" type="password" name="token" [(ngModel)]="token" autocomplete="off" /></div>
1161
1226
  </div>
1162
- <div class="o-flex"><span class="o-faint o-sm">Imports run in the background; you'll get an inbox message when done.</span><button type="submit" class="o-btn o-dark o-r" [disabled]="busy() || !url_.trim() || !repoName.trim()">Import</button></div>
1227
+ <div class="o-g2">
1228
+ <div class="o-fld"><span class="o-lbl" style="padding:0" id="ir-mirror">Keep in sync with the source</span><button type="button" class="o-tog" role="switch" aria-labelledby="ir-mirror" [class.o-on]="mirror" [attr.aria-checked]="mirror" (click)="mirror = !mirror" data-testid="import-mirror"></button><span class="o-hint">A mirror fetches new commits on a schedule and refuses pushes. The token is kept encrypted for its syncs.</span></div>
1229
+ @if (mirror) {<div class="o-fld"><label for="ir-int">Sync every (minutes)</label><input id="ir-int" class="o-in" type="number" min="1" name="interval" [(ngModel)]="interval" data-testid="import-interval" /></div>}
1230
+ </div>
1231
+ <div class="o-flex"><span class="o-faint o-sm">Imports run in the background; you'll get an inbox message when done.</span><button type="submit" class="o-btn o-dark o-r" [disabled]="busy() || !url_.trim() || !repoName.trim()" data-testid="import-submit">Import</button></div>
1163
1232
  </form>
1164
1233
  }
1165
1234
  </div>
1166
- `, isInline: true, 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.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { 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.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"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1235
+ `, isInline: true, 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.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { 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.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { 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"] }, { kind: "directive", type: RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
1167
1236
  }
1168
1237
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: NewRepoPage, decorators: [{
1169
1238
  type: Component,
@@ -1223,7 +1292,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1223
1292
  <div class="o-fld"><label for="ir-user">Username <span class="o-faint">(private repos)</span></label><input id="ir-user" class="o-in" name="user" [(ngModel)]="user" autocomplete="off" /></div>
1224
1293
  <div class="o-fld"><label for="ir-tok">Access token</label><input id="ir-tok" class="o-in" type="password" name="token" [(ngModel)]="token" autocomplete="off" /></div>
1225
1294
  </div>
1226
- <div class="o-flex"><span class="o-faint o-sm">Imports run in the background; you'll get an inbox message when done.</span><button type="submit" class="o-btn o-dark o-r" [disabled]="busy() || !url_.trim() || !repoName.trim()">Import</button></div>
1295
+ <div class="o-g2">
1296
+ <div class="o-fld"><span class="o-lbl" style="padding:0" id="ir-mirror">Keep in sync with the source</span><button type="button" class="o-tog" role="switch" aria-labelledby="ir-mirror" [class.o-on]="mirror" [attr.aria-checked]="mirror" (click)="mirror = !mirror" data-testid="import-mirror"></button><span class="o-hint">A mirror fetches new commits on a schedule and refuses pushes. The token is kept encrypted for its syncs.</span></div>
1297
+ @if (mirror) {<div class="o-fld"><label for="ir-int">Sync every (minutes)</label><input id="ir-int" class="o-in" type="number" min="1" name="interval" [(ngModel)]="interval" data-testid="import-interval" /></div>}
1298
+ </div>
1299
+ <div class="o-flex"><span class="o-faint o-sm">Imports run in the background; you'll get an inbox message when done.</span><button type="submit" class="o-btn o-dark o-r" [disabled]="busy() || !url_.trim() || !repoName.trim()" data-testid="import-submit">Import</button></div>
1227
1300
  </form>
1228
1301
  }
1229
1302
  </div>
@@ -1408,8 +1481,9 @@ class RepoHomePage extends RepoPage {
1408
1481
  <div class="o-ph">
1409
1482
  <div class="o-col">
1410
1483
  <span class="o-eyebrow">{{ r.projectKey }}</span>
1411
- <div class="o-flex"><h1 class="o-t o-mono" style="font-size:19px">{{ r.name }}</h1><span class="o-chip">{{ r.visibility === 'private' ? 'Private' : 'Org-internal' }}</span>@if (r.archivedAt) {<span class="o-chip o-amb">Archived</span>}</div>
1484
+ <div class="o-flex"><h1 class="o-t o-mono" style="font-size:19px">{{ r.name }}</h1><span class="o-chip">{{ r.visibility === 'private' ? 'Private' : 'Org-internal' }}</span>@if (r.archivedAt) {<span class="o-chip o-amb">Archived</span>}@if (r.mirror) {<span class="o-chip o-blu" data-testid="repo-mirror">Mirror</span>}</div>
1412
1485
  @if (r.description) {<span class="o-mut">{{ r.description }}</span>}
1486
+ @if (r.mirror) {<span class="o-faint o-sm">Mirror of <span class="o-mono">{{ r.importSource }}</span>, read-only.</span>}
1413
1487
  </div>
1414
1488
  @if (!r.empty) {<div class="o-acts"><button type="button" class="o-btn o-sm" (click)="archive('zip')">Download ZIP</button></div>}
1415
1489
  </div>
@@ -1540,8 +1614,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1540
1614
  <div class="o-ph">
1541
1615
  <div class="o-col">
1542
1616
  <span class="o-eyebrow">{{ r.projectKey }}</span>
1543
- <div class="o-flex"><h1 class="o-t o-mono" style="font-size:19px">{{ r.name }}</h1><span class="o-chip">{{ r.visibility === 'private' ? 'Private' : 'Org-internal' }}</span>@if (r.archivedAt) {<span class="o-chip o-amb">Archived</span>}</div>
1617
+ <div class="o-flex"><h1 class="o-t o-mono" style="font-size:19px">{{ r.name }}</h1><span class="o-chip">{{ r.visibility === 'private' ? 'Private' : 'Org-internal' }}</span>@if (r.archivedAt) {<span class="o-chip o-amb">Archived</span>}@if (r.mirror) {<span class="o-chip o-blu" data-testid="repo-mirror">Mirror</span>}</div>
1544
1618
  @if (r.description) {<span class="o-mut">{{ r.description }}</span>}
1619
+ @if (r.mirror) {<span class="o-faint o-sm">Mirror of <span class="o-mono">{{ r.importSource }}</span>, read-only.</span>}
1545
1620
  </div>
1546
1621
  @if (!r.empty) {<div class="o-acts"><button type="button" class="o-btn o-sm" (click)="archive('zip')">Download ZIP</button></div>}
1547
1622
  </div>
@@ -1690,9 +1765,11 @@ function matchesPattern(pattern, branch) {
1690
1765
  }
1691
1766
  // The web editor's commit box (prototype code_file "Commit and open PR", DEV-13): a message, and
1692
1767
  // either the branch itself or a new branch with a pull request. A branch whose rules block direct
1693
- // pushes only offers the second. The rules load asynchronously: until they have, nothing can be
1694
- // committed (a quick commit straight to a protected branch was refused by the server); when they
1695
- // cannot be loaded, the change goes to a new branch with a pull request, which any branch takes.
1768
+ // pushes only offers the second, and so does one that requires signed commits when the platform
1769
+ // does not sign what it commits (D-67: when it does, the web edit is signed and goes straight in).
1770
+ // The rules load asynchronously: until they have, nothing can be committed (a quick commit straight
1771
+ // to a protected branch was refused by the server); when they cannot be loaded, the change goes to
1772
+ // a new branch with a pull request, which any branch takes.
1696
1773
  class CommitBox {
1697
1774
  constructor() {
1698
1775
  this.api = inject(CodeApi);
@@ -1707,13 +1784,17 @@ class CommitBox {
1707
1784
  this.toNew = signal(false, ...(ngDevMode ? [{ debugName: "toNew" }] : /* istanbul ignore next */ []));
1708
1785
  this.newBranch = signal('', ...(ngDevMode ? [{ debugName: "newBranch" }] : /* istanbul ignore next */ []));
1709
1786
  this.rules = signal([], ...(ngDevMode ? [{ debugName: "rules" }] : /* istanbul ignore next */ []));
1787
+ /** Whether the platform signs web commits (GET /signing-key answers). */
1788
+ this.signs = signal(false, ...(ngDevMode ? [{ debugName: "signs" }] : /* istanbul ignore next */ []));
1710
1789
  /** Whether the branch's rules are known yet. */
1711
1790
  this.rulesState = signal('loading', ...(ngDevMode ? [{ debugName: "rulesState" }] : /* istanbul ignore next */ []));
1712
1791
  /**
1713
1792
  * Direct commits are refused (or may be): the rules block direct pushes, or require signed
1714
- * commits (a browser commit is unsigned), or they could not be loaded.
1793
+ * commits while the platform signs nothing (a browser commit is then unsigned), or they could not
1794
+ * be loaded.
1715
1795
  */
1716
- this.blocked = computed(() => this.rulesState() === 'failed' || this.rules().some((r) => (r.blockDirectPush || r.requireSignedCommits) && matchesPattern(r.pattern, this.branch())), ...(ngDevMode ? [{ debugName: "blocked" }] : /* istanbul ignore next */ []));
1796
+ this.blocked = computed(() => this.rulesState() === 'failed' ||
1797
+ this.rules().some((r) => (r.blockDirectPush || (r.requireSignedCommits && !this.signs())) && matchesPattern(r.pattern, this.branch())), ...(ngDevMode ? [{ debugName: "blocked" }] : /* istanbul ignore next */ []));
1717
1798
  this.rulesFor = null;
1718
1799
  effect(() => {
1719
1800
  const m = this.defaultMessage();
@@ -1737,11 +1818,12 @@ class CommitBox {
1737
1818
  if (!at)
1738
1819
  return;
1739
1820
  this.rulesState.set('loading');
1740
- this.api.protections(at.project, at.repo).subscribe({
1741
- next: (r) => {
1821
+ forkJoin({ rules: this.api.protections(at.project, at.repo), signs: this.api.platformSigns() }).subscribe({
1822
+ next: ({ rules, signs }) => {
1742
1823
  if (this.rulesFor !== at)
1743
1824
  return;
1744
- this.rules.set(r);
1825
+ this.rules.set(rules);
1826
+ this.signs.set(signs);
1745
1827
  this.rulesState.set('loaded');
1746
1828
  },
1747
1829
  error: () => {
@@ -1810,7 +1892,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1810
1892
  }], ctorParameters: () => [], propDecorators: { repo: [{ type: i0.Input, args: [{ isSignal: true, alias: "repo", required: true }] }], branch: [{ type: i0.Input, args: [{ isSignal: true, alias: "branch", required: true }] }], defaultMessage: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultMessage", required: false }] }], busy: [{ type: i0.Input, args: [{ isSignal: true, alias: "busy", required: false }] }], commit: [{ type: i0.Output, args: ["commit"] }], cancel: [{ type: i0.Output, args: ["cancel"] }] } });
1811
1893
 
1812
1894
  // /{org}/{project}/code/{repo}/blob?ref=&path= (prototype "code_file"): one file, highlighted, with
1813
- // Markdown preview, blame, raw, copy and download; a line number links to that line (#L12).
1895
+ // Markdown preview, blame, raw, copy and download; a line number links to that line (#L12). A CSV
1896
+ // or TSV previews as a table of its first rows, and a PDF in the browser's own viewer (V0-21).
1814
1897
  class FilePage extends RepoPage {
1815
1898
  constructor() {
1816
1899
  super();
@@ -1822,6 +1905,12 @@ class FilePage extends RepoPage {
1822
1905
  this.path = signal('', ...(ngDevMode ? [{ debugName: "path" }] : /* istanbul ignore next */ []));
1823
1906
  this.line = signal(0, ...(ngDevMode ? [{ debugName: "line" }] : /* istanbul ignore next */ []));
1824
1907
  this.view = signal('preview', ...(ngDevMode ? [{ debugName: "view" }] : /* istanbul ignore next */ []));
1908
+ this.sanitizer = inject(DomSanitizer);
1909
+ /** The PDF's signed raw link, trusted as a frame source: the cookieless raw host serves it inline. */
1910
+ this.pdfUrl = computed(() => {
1911
+ const b = this.blob();
1912
+ return b?.preview === 'pdf' && b.rawUrl ? this.sanitizer.bypassSecurityTrustResourceUrl(b.rawUrl) : null;
1913
+ }, ...(ngDevMode ? [{ debugName: "pdfUrl" }] : /* istanbul ignore next */ []));
1825
1914
  this.copied = signal(false, ...(ngDevMode ? [{ debugName: "copied" }] : /* istanbul ignore next */ []));
1826
1915
  this.ago = (s) => ago(s);
1827
1916
  this.initials = initials;
@@ -1949,7 +2038,7 @@ class FilePage extends RepoPage {
1949
2038
  this.blame.set([]);
1950
2039
  this.blaming.set(false);
1951
2040
  this.api.blob(this.projectId(), r.name, this.ref() || r.defaultBranch, this.path()).subscribe({
1952
- next: (b) => { this.blob.set(b); this.view.set(b.markdown ? 'preview' : 'code'); if (this.line())
2041
+ next: (b) => { this.blob.set(b); this.view.set(b.markdown || b.preview === 'csv' ? 'preview' : 'code'); if (this.line())
1953
2042
  setTimeout(() => document.getElementById('L' + this.line())?.scrollIntoView({ block: 'center' })); },
1954
2043
  error: (e) => this.error.set(apiMessage(e, `Could not open ${this.path()}.`)),
1955
2044
  });
@@ -1982,7 +2071,7 @@ class FilePage extends RepoPage {
1982
2071
  <div class="o-ch">
1983
2072
  <span class="o-faint o-sm">{{ meta() }}</span>
1984
2073
  <span class="o-r o-flex" style="gap:6px">
1985
- @if (b.markdown) {
2074
+ @if (b.markdown || b.preview === 'csv') {
1986
2075
  <div class="o-seg" role="group" aria-label="View"><button type="button" [class.o-on]="view() === 'preview'" (click)="view.set('preview')">Preview</button><button type="button" [class.o-on]="view() === 'code'" (click)="view.set('code')">Code</button></div>
1987
2076
  }
1988
2077
  @if (!b.binary && !b.truncated && !b.lfs) {<button type="button" class="o-btn o-xs" [class.o-on]="blaming()" [attr.aria-pressed]="blaming()" (click)="toggleBlame()">Blame</button>}
@@ -2011,6 +2100,21 @@ class FilePage extends RepoPage {
2011
2100
  }
2012
2101
  @if (b.image && b.rawUrl) {
2013
2102
  <div class="o-cb" style="text-align:center"><img [src]="b.rawUrl" [alt]="b.name" style="max-width:100%" /></div>
2103
+ } @else if (b.preview === 'pdf' && pdfUrl(); as src) {
2104
+ <iframe [src]="src" [title]="b.name" style="display:block;width:100%;height:78vh;border:0" data-testid="pdf-preview"></iframe>
2105
+ } @else if (b.preview === 'csv' && b.table && view() === 'preview') {
2106
+ @let t = b.table;
2107
+ <div style="overflow:auto;max-height:78vh" data-testid="csv-preview">
2108
+ <div class="o-tbl" role="table" [attr.aria-label]="b.path">
2109
+ <div class="o-thead" role="rowgroup"><div class="o-tr" role="row"><div class="o-th o-num" role="columnheader" aria-label="Row"></div>@for (h of t.header; track $index) {<div class="o-th" role="columnheader">{{ h }}</div>}</div></div>
2110
+ <div class="o-tbody" role="rowgroup">
2111
+ @for (row of t.rows; track $index; let i = $index) {
2112
+ <div class="o-tr" role="row"><div class="o-td o-num o-faint" role="rowheader">{{ i + 1 }}</div>@for (c of row; track $index) {<div class="o-td" role="cell">{{ c }}</div>}</div>
2113
+ }
2114
+ </div>
2115
+ </div>
2116
+ </div>
2117
+ @if (t.truncated) {<div class="o-cf o-faint o-sm" data-testid="csv-more">Showing the first {{ t.rows.length }} rows. @if (b.rawUrl) {<a [href]="b.rawUrl" target="_blank" rel="noopener">View raw</a> for the rest.}</div>}
2014
2118
  } @else if (b.lfs && !b.lfs.stored) {
2015
2119
  <div class="o-empty" data-testid="lfs-missing">This file is a Git LFS pointer to an object that was never uploaded (<span class="o-mono">{{ b.lfs.oid.slice(0, 12) }}</span>, {{ size(b.lfs.size) }}).</div>
2016
2120
  } @else if (b.binary || b.truncated) {
@@ -2068,7 +2172,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2068
2172
  <div class="o-ch">
2069
2173
  <span class="o-faint o-sm">{{ meta() }}</span>
2070
2174
  <span class="o-r o-flex" style="gap:6px">
2071
- @if (b.markdown) {
2175
+ @if (b.markdown || b.preview === 'csv') {
2072
2176
  <div class="o-seg" role="group" aria-label="View"><button type="button" [class.o-on]="view() === 'preview'" (click)="view.set('preview')">Preview</button><button type="button" [class.o-on]="view() === 'code'" (click)="view.set('code')">Code</button></div>
2073
2177
  }
2074
2178
  @if (!b.binary && !b.truncated && !b.lfs) {<button type="button" class="o-btn o-xs" [class.o-on]="blaming()" [attr.aria-pressed]="blaming()" (click)="toggleBlame()">Blame</button>}
@@ -2097,6 +2201,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2097
2201
  }
2098
2202
  @if (b.image && b.rawUrl) {
2099
2203
  <div class="o-cb" style="text-align:center"><img [src]="b.rawUrl" [alt]="b.name" style="max-width:100%" /></div>
2204
+ } @else if (b.preview === 'pdf' && pdfUrl(); as src) {
2205
+ <iframe [src]="src" [title]="b.name" style="display:block;width:100%;height:78vh;border:0" data-testid="pdf-preview"></iframe>
2206
+ } @else if (b.preview === 'csv' && b.table && view() === 'preview') {
2207
+ @let t = b.table;
2208
+ <div style="overflow:auto;max-height:78vh" data-testid="csv-preview">
2209
+ <div class="o-tbl" role="table" [attr.aria-label]="b.path">
2210
+ <div class="o-thead" role="rowgroup"><div class="o-tr" role="row"><div class="o-th o-num" role="columnheader" aria-label="Row"></div>@for (h of t.header; track $index) {<div class="o-th" role="columnheader">{{ h }}</div>}</div></div>
2211
+ <div class="o-tbody" role="rowgroup">
2212
+ @for (row of t.rows; track $index; let i = $index) {
2213
+ <div class="o-tr" role="row"><div class="o-td o-num o-faint" role="rowheader">{{ i + 1 }}</div>@for (c of row; track $index) {<div class="o-td" role="cell">{{ c }}</div>}</div>
2214
+ }
2215
+ </div>
2216
+ </div>
2217
+ </div>
2218
+ @if (t.truncated) {<div class="o-cf o-faint o-sm" data-testid="csv-more">Showing the first {{ t.rows.length }} rows. @if (b.rawUrl) {<a [href]="b.rawUrl" target="_blank" rel="noopener">View raw</a> for the rest.}</div>}
2100
2219
  } @else if (b.lfs && !b.lfs.stored) {
2101
2220
  <div class="o-empty" data-testid="lfs-missing">This file is a Git LFS pointer to an object that was never uploaded (<span class="o-mono">{{ b.lfs.oid.slice(0, 12) }}</span>, {{ size(b.lfs.size) }}).</div>
2102
2221
  } @else if (b.binary || b.truncated) {
@@ -2200,7 +2319,7 @@ class CommitsPage extends RepoPage {
2200
2319
  @for (k of c.keys; track k) {<a class="o-chip o-blu" [routerLink]="host.link('issues/' + k)">{{ k }}</a>}
2201
2320
  </span>
2202
2321
  </div>
2203
- @if (c.verification; as v) {<span class="o-chip" [class.o-grn]="v.verified" [attr.title]="v.verified ? 'Signed with a key of ' + people().name(v.signer) : verifyWhy(v.reason)" [attr.data-testid]="'verify-' + c.short">{{ v.verified ? 'Verified' : 'Unverified' }}</span>}
2322
+ @if (c.verification; as v) {<span class="o-chip" [class.o-grn]="v.verified" [attr.title]="verifyTitle(v)" [attr.data-testid]="'verify-' + c.short">{{ verifyLabel(v) }}</span>}
2204
2323
  <a class="o-btn o-sm o-mono" [routerLink]="link('commit/' + c.oid)">{{ c.short }}</a>
2205
2324
  <a class="o-btn o-sm" [routerLink]="link()" [queryParams]="{ ref: c.oid }" title="Browse files at this commit" [attr.aria-label]="'Browse files at ' + c.short">‹›</a>
2206
2325
  </div>
@@ -2241,7 +2360,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2241
2360
  @for (k of c.keys; track k) {<a class="o-chip o-blu" [routerLink]="host.link('issues/' + k)">{{ k }}</a>}
2242
2361
  </span>
2243
2362
  </div>
2244
- @if (c.verification; as v) {<span class="o-chip" [class.o-grn]="v.verified" [attr.title]="v.verified ? 'Signed with a key of ' + people().name(v.signer) : verifyWhy(v.reason)" [attr.data-testid]="'verify-' + c.short">{{ v.verified ? 'Verified' : 'Unverified' }}</span>}
2363
+ @if (c.verification; as v) {<span class="o-chip" [class.o-grn]="v.verified" [attr.title]="verifyTitle(v)" [attr.data-testid]="'verify-' + c.short">{{ verifyLabel(v) }}</span>}
2245
2364
  <a class="o-btn o-sm o-mono" [routerLink]="link('commit/' + c.oid)">{{ c.short }}</a>
2246
2365
  <a class="o-btn o-sm" [routerLink]="link()" [queryParams]="{ ref: c.oid }" title="Browse files at this commit" [attr.aria-label]="'Browse files at ' + c.short">‹›</a>
2247
2366
  </div>
@@ -2291,7 +2410,7 @@ class CommitPage extends RepoPage {
2291
2410
  @if (c.body) {<span class="o-mut o-lh" style="white-space:pre-wrap">{{ c.body }}</span>}
2292
2411
  <div class="o-flex o-wrap o-sm">
2293
2412
  <span class="o-av" aria-hidden="true">{{ initials(c.author.name) }}</span><b class="o-b5">{{ c.author.name }}</b><span class="o-faint">committed {{ ago(c.committer.when) }}</span>
2294
- @if (c.verification; as v) {<span class="o-chip" [class.o-grn]="v.verified" [attr.title]="v.verified ? 'Signed with a key of ' + people().name(v.signer) : verifyWhy(v.reason)" data-testid="commit-verified">{{ v.verified ? 'Verified' : 'Unverified' }}</span>}
2413
+ @if (c.verification; as v) {<span class="o-chip" [class.o-grn]="v.verified" [attr.title]="verifyTitle(v)" data-testid="commit-verified">{{ verifyLabel(v) }}</span>}
2295
2414
  @for (k of c.keys; track k) {<a class="o-chip o-blu" [routerLink]="host.link('issues/' + k)">{{ k }}</a>}
2296
2415
  <span class="o-r o-mono o-faint">commit {{ c.oid.slice(0, 10) }}@for (p of c.parents; track p) { · parent <a [routerLink]="link('commit/' + p)">{{ p.slice(0, 7) }}</a>}</span>
2297
2416
  </div>
@@ -2323,7 +2442,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2323
2442
  @if (c.body) {<span class="o-mut o-lh" style="white-space:pre-wrap">{{ c.body }}</span>}
2324
2443
  <div class="o-flex o-wrap o-sm">
2325
2444
  <span class="o-av" aria-hidden="true">{{ initials(c.author.name) }}</span><b class="o-b5">{{ c.author.name }}</b><span class="o-faint">committed {{ ago(c.committer.when) }}</span>
2326
- @if (c.verification; as v) {<span class="o-chip" [class.o-grn]="v.verified" [attr.title]="v.verified ? 'Signed with a key of ' + people().name(v.signer) : verifyWhy(v.reason)" data-testid="commit-verified">{{ v.verified ? 'Verified' : 'Unverified' }}</span>}
2445
+ @if (c.verification; as v) {<span class="o-chip" [class.o-grn]="v.verified" [attr.title]="verifyTitle(v)" data-testid="commit-verified">{{ verifyLabel(v) }}</span>}
2327
2446
  @for (k of c.keys; track k) {<a class="o-chip o-blu" [routerLink]="host.link('issues/' + k)">{{ k }}</a>}
2328
2447
  <span class="o-r o-mono o-faint">commit {{ c.oid.slice(0, 10) }}@for (p of c.parents; track p) { · parent <a [routerLink]="link('commit/' + p)">{{ p.slice(0, 7) }}</a>}</span>
2329
2448
  </div>
@@ -2444,7 +2563,7 @@ class BranchesPage extends RepoPage {
2444
2563
  </div>
2445
2564
  <div class="o-td" role="cell">
2446
2565
  @if (pullFor(b.name); as pr) {<a [class]="pr.state === 'merged' ? 'o-chip o-vio' : pr.state === 'closed' ? 'o-chip' : 'o-chip o-blu'" [routerLink]="link('pull/' + pr.number)">#{{ pr.number }} {{ pr.state }}</a>}
2447
- @else if (!b.default && b.ahead > 0 && canWrite()) {<a class="o-btn o-xs" [routerLink]="link('pulls')" [queryParams]="{ new: 1, source: b.name }">New PR</a>}
2566
+ @else if (!b.default && b.ahead > 0 && canOpenPull()) {<a class="o-btn o-xs" [routerLink]="link('pulls')" [queryParams]="{ new: 1, source: b.name }">New PR</a>}
2448
2567
  </div>
2449
2568
  <div class="o-td o-num" role="cell">@if (b.canDelete && canWrite()) {<button type="button" class="o-btn o-xs o-ghost" (click)="remove(b)" [attr.aria-label]="'Delete ' + b.name">Delete</button>}</div>
2450
2569
  </div>
@@ -2503,7 +2622,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2503
2622
  </div>
2504
2623
  <div class="o-td" role="cell">
2505
2624
  @if (pullFor(b.name); as pr) {<a [class]="pr.state === 'merged' ? 'o-chip o-vio' : pr.state === 'closed' ? 'o-chip' : 'o-chip o-blu'" [routerLink]="link('pull/' + pr.number)">#{{ pr.number }} {{ pr.state }}</a>}
2506
- @else if (!b.default && b.ahead > 0 && canWrite()) {<a class="o-btn o-xs" [routerLink]="link('pulls')" [queryParams]="{ new: 1, source: b.name }">New PR</a>}
2625
+ @else if (!b.default && b.ahead > 0 && canOpenPull()) {<a class="o-btn o-xs" [routerLink]="link('pulls')" [queryParams]="{ new: 1, source: b.name }">New PR</a>}
2507
2626
  </div>
2508
2627
  <div class="o-td o-num" role="cell">@if (b.canDelete && canWrite()) {<button type="button" class="o-btn o-xs o-ghost" (click)="remove(b)" [attr.aria-label]="'Delete ' + b.name">Delete</button>}</div>
2509
2628
  </div>
@@ -2558,7 +2677,7 @@ class PullsPage {
2558
2677
  this.review = (r) => REVIEW[r] ?? r.replace(/_/g, ' ');
2559
2678
  this.canWrite = computed(() => {
2560
2679
  const p = this.host.project();
2561
- return !!p && !p.archivedAt && p.capabilities.includes('code.write');
2680
+ return !!p && !p.archivedAt && this.host.can('code.pr.create');
2562
2681
  }, ...(ngDevMode ? [{ debugName: "canWrite" }] : /* istanbul ignore next */ []));
2563
2682
  this.route.paramMap.subscribe((m) => this.repoName.set(m.get('repo') ?? ''));
2564
2683
  this.route.queryParamMap.subscribe((q) => { if (q.get('new'))
@@ -2592,7 +2711,7 @@ class PullsPage {
2592
2711
  return;
2593
2712
  }
2594
2713
  this.loading.set(true);
2595
- forkJoin(repos.map((r) => this.api.pulls(p.id, r.name, q).pipe(map$1((ps) => ps.map((x) => ({ ...x, repo: r.name }))), catchError(() => of([]))))).subscribe({
2714
+ forkJoin(repos.map((r) => this.api.pulls(p.id, r.name, q).pipe(map$1((ps) => ps.map((x) => ({ ...x, repo: r.name }))), catchError$1(() => of([]))))).subscribe({
2596
2715
  next: (lists) => {
2597
2716
  this.loading.set(false);
2598
2717
  this.rows.set(lists.flat().sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')));
@@ -2792,6 +2911,13 @@ class PullPage extends RepoPage {
2792
2911
  this.method = signal('merge', ...(ngDevMode ? [{ debugName: "method" }] : /* istanbul ignore next */ []));
2793
2912
  this.delBranch = signal(false, ...(ngDevMode ? [{ debugName: "delBranch" }] : /* istanbul ignore next */ []));
2794
2913
  this.number = 0;
2914
+ /** The conflict resolver (V6-03): the conflicts, and each file's resolution as edited. */
2915
+ this.resolver = signal(null, ...(ngDevMode ? [{ debugName: "resolver" }] : /* istanbul ignore next */ []));
2916
+ this.resolved = signal({}, ...(ngDevMode ? [{ debugName: "resolved" }] : /* istanbul ignore next */ []));
2917
+ this.resolutionReady = computed(() => {
2918
+ const cf = this.resolver();
2919
+ return !!cf && cf.canResolve && cf.files.every((f) => f.resolvable && !this.hasMarkers(this.resolved()[f.path]));
2920
+ }, ...(ngDevMode ? [{ debugName: "resolutionReady" }] : /* istanbul ignore next */ []));
2795
2921
  this.ago = (s) => ago(s);
2796
2922
  this.initials = initials;
2797
2923
  this.dot = checkDot;
@@ -2961,6 +3087,47 @@ class PullPage extends RepoPage {
2961
3087
  return;
2962
3088
  this.run(this.api.merge(this.projectId(), this.name(), this.number, { method: this.method(), deleteBranch: this.delBranch(), expectHead: p.headSha }), 'Could not merge.', () => this.note.set(`Merged into ${p.target}.`));
2963
3089
  }
3090
+ openResolver() {
3091
+ this.busy.set(true);
3092
+ this.error.set('');
3093
+ this.api.conflicts(this.projectId(), this.name(), this.number).subscribe({
3094
+ next: (cf) => {
3095
+ this.busy.set(false);
3096
+ const start = {};
3097
+ for (const f of cf.files)
3098
+ if (f.resolvable)
3099
+ start[f.path] = f.merged ?? '';
3100
+ this.resolved.set(start);
3101
+ this.resolver.set(cf);
3102
+ },
3103
+ error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not load the conflicts.')); },
3104
+ });
3105
+ }
3106
+ take(path, text) {
3107
+ this.resolved.set({ ...this.resolved(), [path]: text });
3108
+ }
3109
+ hasMarkers(text) {
3110
+ return /^(<{7}|>{7})( |$)|^={7}$/m.test(text ?? '');
3111
+ }
3112
+ conflictKind(kind) {
3113
+ return { binary: 'Binary file', 'modify/delete': 'Changed and deleted', rename: 'Renamed', large: 'Over 1 MB', lfs: 'Git LFS file' }[kind] ?? 'Command line only';
3114
+ }
3115
+ commitResolution() {
3116
+ const cf = this.resolver();
3117
+ if (!cf)
3118
+ return;
3119
+ this.busy.set(true);
3120
+ this.error.set('');
3121
+ this.api.resolveConflicts(this.projectId(), this.name(), this.number, { files: this.resolved(), expectHead: cf.headSha, expectBase: cf.baseSha }).subscribe({
3122
+ next: (r) => {
3123
+ this.busy.set(false);
3124
+ this.resolver.set(null);
3125
+ this.note.set(r.pull ? `Committed the merge to ${r.branch} and opened pull request #${r.pull} into ${cf.source}.` : `Committed the merge to ${cf.source}.`);
3126
+ this.load(false);
3127
+ },
3128
+ error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not commit the resolution.')); },
3129
+ });
3130
+ }
2964
3131
  close(reopen) {
2965
3132
  this.run(this.api.closePull(this.projectId(), this.name(), this.number, reopen), reopen ? 'Could not reopen.' : 'Could not close.');
2966
3133
  }
@@ -3089,8 +3256,8 @@ class PullPage extends RepoPage {
3089
3256
  @if (p.canEdit) {<button type="button" class="o-btn o-ghost" (click)="close(false)" [disabled]="busy()">Close</button>}
3090
3257
  @if (p.autoMergeBy) {
3091
3258
  <span class="o-chip o-blu" data-testid="auto-merge-on">Auto-merge on · {{ methodLabel(p.autoMergeMethod || 'merge') }} · by {{ people().name(p.autoMergeBy) }}</span>
3092
- @if (canWrite()) {<button type="button" class="o-btn o-sm o-ghost" (click)="setAutoMerge(null)" [disabled]="busy()" data-testid="auto-merge-off">Disable auto-merge</button>}
3093
- } @else if (repo()?.allowAutoMerge && canWrite() && !p.gate.mergeable) {
3259
+ @if (canMerge()) {<button type="button" class="o-btn o-sm o-ghost" (click)="setAutoMerge(null)" [disabled]="busy()" data-testid="auto-merge-off">Disable auto-merge</button>}
3260
+ } @else if (repo()?.allowAutoMerge && canMerge() && !p.gate.mergeable) {
3094
3261
  <button type="button" class="o-btn o-sm" (click)="setAutoMerge(method())" [disabled]="busy()" data-testid="auto-merge-enable" title="Merges as you once every requirement passes">Enable auto-merge</button>
3095
3262
  }
3096
3263
  @if (p.canMerge) {
@@ -3106,6 +3273,9 @@ class PullPage extends RepoPage {
3106
3273
  </div>
3107
3274
  }
3108
3275
  </div>
3276
+ @if (p.gate.conflicts?.length && canWrite() && !resolver()) {
3277
+ <div class="o-flex" style="padding:10px 14px;border-top:1px solid var(--o-line-soft)"><span class="o-mut o-sm">Text conflicts can be resolved here; the result is a merge of {{ p.target }} into {{ p.source }}.</span><button type="button" class="o-btn o-sm o-r" (click)="openResolver()" [disabled]="busy()" data-testid="resolve-open">Resolve conflicts</button></div>
3278
+ }
3109
3279
  } @else if (p.state === 'closed' && p.canEdit) {
3110
3280
  <div class="o-flex" style="padding:12px 14px;background:var(--o-well)"><button type="button" class="o-btn o-r" (click)="close(true)" [disabled]="busy()">Reopen</button></div>
3111
3281
  } @else if (p.state === 'merged') {
@@ -3113,6 +3283,42 @@ class PullPage extends RepoPage {
3113
3283
  }
3114
3284
  </div>
3115
3285
 
3286
+ @if (resolver(); as cf) {
3287
+ <div class="o-card" data-testid="resolver" aria-label="Resolve conflicts">
3288
+ <div class="o-ch"><b>Resolve conflicts</b><span class="o-faint o-sm">{{ cf.source }} with {{ cf.target }}</span><button type="button" class="o-btn o-xs o-ghost o-r" (click)="resolver.set(null)">Cancel</button></div>
3289
+ <div class="o-cb o-col" style="gap:16px">
3290
+ @if (!cf.resolvable) {
3291
+ <div class="o-note" role="status">Some conflicts can only be resolved on the command line:
3292
+ <div class="o-term" style="margin-top:8px"><div>git fetch origin</div><div>git switch {{ cf.source }}</div><div>git merge origin/{{ cf.target }}</div><div># resolve, then</div><div>git commit &amp;&amp; git push</div></div>
3293
+ </div>
3294
+ }
3295
+ @for (f of cf.files; track f.path) {
3296
+ <div class="o-col" style="gap:8px" [attr.data-testid]="'conflict-' + f.path">
3297
+ <div class="o-flex"><b class="o-mono">{{ f.path }}</b>@if (!f.resolvable) {<span class="o-chip o-amb">{{ conflictKind(f.kind) }}</span>}</div>
3298
+ @if (f.resolvable) {
3299
+ <div class="o-g3" style="gap:8px">
3300
+ <div class="o-col" style="gap:4px;min-width:0"><span class="o-lbl" style="padding:0">{{ cf.source }} (this branch)</span><pre class="o-term" style="margin:0;max-height:220px;overflow:auto">{{ f.ours }}</pre><button type="button" class="o-btn o-xs" (click)="take(f.path, f.ours ?? '')" [attr.data-testid]="'take-ours-' + f.path">Use this branch's</button></div>
3301
+ <div class="o-col" style="gap:4px;min-width:0"><span class="o-lbl" style="padding:0">Common ancestor</span><pre class="o-term" style="margin:0;max-height:220px;overflow:auto">{{ f.base ?? '' }}</pre></div>
3302
+ <div class="o-col" style="gap:4px;min-width:0"><span class="o-lbl" style="padding:0">{{ cf.target }}</span><pre class="o-term" style="margin:0;max-height:220px;overflow:auto">{{ f.theirs }}</pre><button type="button" class="o-btn o-xs" (click)="take(f.path, f.theirs ?? '')" [attr.data-testid]="'take-theirs-' + f.path">Use {{ cf.target }}'s</button></div>
3303
+ </div>
3304
+ <label class="o-lbl" style="padding:0" [attr.for]="'res-' + $index">Result</label>
3305
+ <textarea class="o-in o-mono" style="min-height:160px;height:auto" [id]="'res-' + $index" [ngModel]="resolved()[f.path]" (ngModelChange)="take(f.path, $event)" [attr.data-testid]="'resolution-' + f.path"></textarea>
3306
+ @if (hasMarkers(resolved()[f.path])) {<span class="o-hint">Remove the conflict markers (&lt;&lt;&lt;&lt;&lt;&lt;&lt;, =======, &gt;&gt;&gt;&gt;&gt;&gt;&gt;) to finish this file.</span>}
3307
+ } @else {
3308
+ <span class="o-mut o-sm">{{ f.message || 'This conflict can only be resolved on the command line.' }}</span>
3309
+ }
3310
+ </div>
3311
+ }
3312
+ </div>
3313
+ @if (cf.canResolve) {
3314
+ <div class="o-cf">
3315
+ <span class="o-faint o-xs">{{ cf.direct ? 'Commits a merge of ' + cf.target + ' into ' + cf.source + ', signed by Origine.' : cf.source + ' takes no direct commits: the merge goes to a new branch with a pull request into ' + cf.source + '.' }}</span>
3316
+ <button type="button" class="o-btn o-sm o-dark o-r" (click)="commitResolution()" [disabled]="busy() || !resolutionReady()" data-testid="resolve-commit">Commit merge</button>
3317
+ </div>
3318
+ }
3319
+ </div>
3320
+ }
3321
+
3116
3322
  <div class="o-card">
3117
3323
  <label class="o-sr-only" for="pr-cmt">Add a comment</label>
3118
3324
  <textarea id="pr-cmt" class="o-in" style="border:none;box-shadow:none;min-height:70px;height:auto;padding:10px 12px" placeholder="Add a comment. Markdown works." [ngModel]="draft()" (ngModelChange)="draft.set($event)" data-testid="pull-comment"></textarea>
@@ -3122,7 +3328,7 @@ class PullPage extends RepoPage {
3122
3328
  @case ('commits') {
3123
3329
  <div class="o-card">
3124
3330
  @for (c of cmp()?.commits ?? []; track c.oid) {
3125
- <div class="o-row"><span class="o-av" aria-hidden="true">{{ initials(c.author.name) }}</span><a class="o-ell" style="flex:1;color:var(--o-tx)" [routerLink]="link('commit/' + c.oid)">{{ c.subject }}</a>@if (c.verification; as v) {<span class="o-chip" [class.o-grn]="v.verified" [attr.title]="v.verified ? 'Signed with a key of ' + people().name(v.signer) : verifyWhy(v.reason)" [attr.data-testid]="'verify-' + c.short">{{ v.verified ? 'Verified' : 'Unverified' }}</span>}<a class="o-mono o-sm o-faint" [routerLink]="link('commit/' + c.oid)">{{ c.short }}</a></div>
3331
+ <div class="o-row"><span class="o-av" aria-hidden="true">{{ initials(c.author.name) }}</span><a class="o-ell" style="flex:1;color:var(--o-tx)" [routerLink]="link('commit/' + c.oid)">{{ c.subject }}</a>@if (c.verification; as v) {<span class="o-chip" [class.o-grn]="v.verified" [attr.title]="verifyTitle(v)" [attr.data-testid]="'verify-' + c.short">{{ verifyLabel(v) }}</span>}<a class="o-mono o-sm o-faint" [routerLink]="link('commit/' + c.oid)">{{ c.short }}</a></div>
3126
3332
  } @empty {<div class="o-empty">{{ cmp() ? 'No commits.' : 'Loading…' }}</div>}
3127
3333
  </div>
3128
3334
  }
@@ -3272,8 +3478,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3272
3478
  @if (p.canEdit) {<button type="button" class="o-btn o-ghost" (click)="close(false)" [disabled]="busy()">Close</button>}
3273
3479
  @if (p.autoMergeBy) {
3274
3480
  <span class="o-chip o-blu" data-testid="auto-merge-on">Auto-merge on · {{ methodLabel(p.autoMergeMethod || 'merge') }} · by {{ people().name(p.autoMergeBy) }}</span>
3275
- @if (canWrite()) {<button type="button" class="o-btn o-sm o-ghost" (click)="setAutoMerge(null)" [disabled]="busy()" data-testid="auto-merge-off">Disable auto-merge</button>}
3276
- } @else if (repo()?.allowAutoMerge && canWrite() && !p.gate.mergeable) {
3481
+ @if (canMerge()) {<button type="button" class="o-btn o-sm o-ghost" (click)="setAutoMerge(null)" [disabled]="busy()" data-testid="auto-merge-off">Disable auto-merge</button>}
3482
+ } @else if (repo()?.allowAutoMerge && canMerge() && !p.gate.mergeable) {
3277
3483
  <button type="button" class="o-btn o-sm" (click)="setAutoMerge(method())" [disabled]="busy()" data-testid="auto-merge-enable" title="Merges as you once every requirement passes">Enable auto-merge</button>
3278
3484
  }
3279
3485
  @if (p.canMerge) {
@@ -3289,6 +3495,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3289
3495
  </div>
3290
3496
  }
3291
3497
  </div>
3498
+ @if (p.gate.conflicts?.length && canWrite() && !resolver()) {
3499
+ <div class="o-flex" style="padding:10px 14px;border-top:1px solid var(--o-line-soft)"><span class="o-mut o-sm">Text conflicts can be resolved here; the result is a merge of {{ p.target }} into {{ p.source }}.</span><button type="button" class="o-btn o-sm o-r" (click)="openResolver()" [disabled]="busy()" data-testid="resolve-open">Resolve conflicts</button></div>
3500
+ }
3292
3501
  } @else if (p.state === 'closed' && p.canEdit) {
3293
3502
  <div class="o-flex" style="padding:12px 14px;background:var(--o-well)"><button type="button" class="o-btn o-r" (click)="close(true)" [disabled]="busy()">Reopen</button></div>
3294
3503
  } @else if (p.state === 'merged') {
@@ -3296,6 +3505,42 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3296
3505
  }
3297
3506
  </div>
3298
3507
 
3508
+ @if (resolver(); as cf) {
3509
+ <div class="o-card" data-testid="resolver" aria-label="Resolve conflicts">
3510
+ <div class="o-ch"><b>Resolve conflicts</b><span class="o-faint o-sm">{{ cf.source }} with {{ cf.target }}</span><button type="button" class="o-btn o-xs o-ghost o-r" (click)="resolver.set(null)">Cancel</button></div>
3511
+ <div class="o-cb o-col" style="gap:16px">
3512
+ @if (!cf.resolvable) {
3513
+ <div class="o-note" role="status">Some conflicts can only be resolved on the command line:
3514
+ <div class="o-term" style="margin-top:8px"><div>git fetch origin</div><div>git switch {{ cf.source }}</div><div>git merge origin/{{ cf.target }}</div><div># resolve, then</div><div>git commit &amp;&amp; git push</div></div>
3515
+ </div>
3516
+ }
3517
+ @for (f of cf.files; track f.path) {
3518
+ <div class="o-col" style="gap:8px" [attr.data-testid]="'conflict-' + f.path">
3519
+ <div class="o-flex"><b class="o-mono">{{ f.path }}</b>@if (!f.resolvable) {<span class="o-chip o-amb">{{ conflictKind(f.kind) }}</span>}</div>
3520
+ @if (f.resolvable) {
3521
+ <div class="o-g3" style="gap:8px">
3522
+ <div class="o-col" style="gap:4px;min-width:0"><span class="o-lbl" style="padding:0">{{ cf.source }} (this branch)</span><pre class="o-term" style="margin:0;max-height:220px;overflow:auto">{{ f.ours }}</pre><button type="button" class="o-btn o-xs" (click)="take(f.path, f.ours ?? '')" [attr.data-testid]="'take-ours-' + f.path">Use this branch's</button></div>
3523
+ <div class="o-col" style="gap:4px;min-width:0"><span class="o-lbl" style="padding:0">Common ancestor</span><pre class="o-term" style="margin:0;max-height:220px;overflow:auto">{{ f.base ?? '' }}</pre></div>
3524
+ <div class="o-col" style="gap:4px;min-width:0"><span class="o-lbl" style="padding:0">{{ cf.target }}</span><pre class="o-term" style="margin:0;max-height:220px;overflow:auto">{{ f.theirs }}</pre><button type="button" class="o-btn o-xs" (click)="take(f.path, f.theirs ?? '')" [attr.data-testid]="'take-theirs-' + f.path">Use {{ cf.target }}'s</button></div>
3525
+ </div>
3526
+ <label class="o-lbl" style="padding:0" [attr.for]="'res-' + $index">Result</label>
3527
+ <textarea class="o-in o-mono" style="min-height:160px;height:auto" [id]="'res-' + $index" [ngModel]="resolved()[f.path]" (ngModelChange)="take(f.path, $event)" [attr.data-testid]="'resolution-' + f.path"></textarea>
3528
+ @if (hasMarkers(resolved()[f.path])) {<span class="o-hint">Remove the conflict markers (&lt;&lt;&lt;&lt;&lt;&lt;&lt;, =======, &gt;&gt;&gt;&gt;&gt;&gt;&gt;) to finish this file.</span>}
3529
+ } @else {
3530
+ <span class="o-mut o-sm">{{ f.message || 'This conflict can only be resolved on the command line.' }}</span>
3531
+ }
3532
+ </div>
3533
+ }
3534
+ </div>
3535
+ @if (cf.canResolve) {
3536
+ <div class="o-cf">
3537
+ <span class="o-faint o-xs">{{ cf.direct ? 'Commits a merge of ' + cf.target + ' into ' + cf.source + ', signed by Origine.' : cf.source + ' takes no direct commits: the merge goes to a new branch with a pull request into ' + cf.source + '.' }}</span>
3538
+ <button type="button" class="o-btn o-sm o-dark o-r" (click)="commitResolution()" [disabled]="busy() || !resolutionReady()" data-testid="resolve-commit">Commit merge</button>
3539
+ </div>
3540
+ }
3541
+ </div>
3542
+ }
3543
+
3299
3544
  <div class="o-card">
3300
3545
  <label class="o-sr-only" for="pr-cmt">Add a comment</label>
3301
3546
  <textarea id="pr-cmt" class="o-in" style="border:none;box-shadow:none;min-height:70px;height:auto;padding:10px 12px" placeholder="Add a comment. Markdown works." [ngModel]="draft()" (ngModelChange)="draft.set($event)" data-testid="pull-comment"></textarea>
@@ -3305,7 +3550,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3305
3550
  @case ('commits') {
3306
3551
  <div class="o-card">
3307
3552
  @for (c of cmp()?.commits ?? []; track c.oid) {
3308
- <div class="o-row"><span class="o-av" aria-hidden="true">{{ initials(c.author.name) }}</span><a class="o-ell" style="flex:1;color:var(--o-tx)" [routerLink]="link('commit/' + c.oid)">{{ c.subject }}</a>@if (c.verification; as v) {<span class="o-chip" [class.o-grn]="v.verified" [attr.title]="v.verified ? 'Signed with a key of ' + people().name(v.signer) : verifyWhy(v.reason)" [attr.data-testid]="'verify-' + c.short">{{ v.verified ? 'Verified' : 'Unverified' }}</span>}<a class="o-mono o-sm o-faint" [routerLink]="link('commit/' + c.oid)">{{ c.short }}</a></div>
3553
+ <div class="o-row"><span class="o-av" aria-hidden="true">{{ initials(c.author.name) }}</span><a class="o-ell" style="flex:1;color:var(--o-tx)" [routerLink]="link('commit/' + c.oid)">{{ c.subject }}</a>@if (c.verification; as v) {<span class="o-chip" [class.o-grn]="v.verified" [attr.title]="verifyTitle(v)" [attr.data-testid]="'verify-' + c.short">{{ verifyLabel(v) }}</span>}<a class="o-mono o-sm o-faint" [routerLink]="link('commit/' + c.oid)">{{ c.short }}</a></div>
3309
3554
  } @empty {<div class="o-empty">{{ cmp() ? 'No commits.' : 'Loading…' }}</div>}
3310
3555
  </div>
3311
3556
  }
@@ -3420,7 +3665,12 @@ class RepoSettingsPage extends RepoPage {
3420
3665
  { key: 'requireSignedCommits', label: 'Require signed commits', hint: 'Verified against members’ signing keys, on push and at merge.' },
3421
3666
  { key: 'requireLinearHistory', label: 'Require linear history', hint: 'No merge commits: squash or rebase only.' },
3422
3667
  ];
3423
- this.bypassRoles = [{ id: 'owner', label: 'Owners' }, { id: 'admin', label: 'Admins' }, { id: 'maintainer', label: 'Maintainers' }];
3668
+ /** Who may bypass a rule: the built-in Owner, Admin and Maintainer, and the organization's custom roles. */
3669
+ this.customRoles = signal([], ...(ngDevMode ? [{ debugName: "customRoles" }] : /* istanbul ignore next */ []));
3670
+ this.bypassRoles = computed(() => [{ id: 'owner', label: 'Owners' }, { id: 'admin', label: 'Admins' }, { id: 'maintainer', label: 'Maintainers' }, ...this.customRoles()], ...(ngDevMode ? [{ debugName: "bypassRoles" }] : /* istanbul ignore next */ []));
3671
+ this.ago = ago;
3672
+ this.mirror = signal(null, ...(ngDevMode ? [{ debugName: "mirror" }] : /* istanbul ignore next */ []));
3673
+ this.mf = { url: '', username: '', token: '', interval: 60 };
3424
3674
  this.collabs = signal([], ...(ngDevMode ? [{ debugName: "collabs" }] : /* istanbul ignore next */ []));
3425
3675
  this.perms = [{ id: 'read', label: 'Read' }, { id: 'write', label: 'Write' }, { id: 'maintain', label: 'Maintain' }];
3426
3676
  this.addable = computed(() => this.host.people().filter((p) => !this.collabs().some((c) => c.userId === p.id)), ...(ngDevMode ? [{ debugName: "addable" }] : /* istanbul ignore next */ []));
@@ -3428,11 +3678,62 @@ class RepoSettingsPage extends RepoPage {
3428
3678
  this.newPerm = 'write';
3429
3679
  this.route.queryParamMap.subscribe((q) => this.section.set(q.get('section') || 'general'));
3430
3680
  }
3681
+ loadMirror(r) {
3682
+ if (!r.mirror)
3683
+ return this.mirror.set(null);
3684
+ this.api.mirror(this.projectId(), r.name).subscribe({
3685
+ next: (m) => {
3686
+ this.mirror.set(m);
3687
+ this.mf = { url: m.source, username: '', token: '', interval: m.intervalMinutes };
3688
+ if (m.syncing)
3689
+ setTimeout(() => this.loadMirror(r), 1500);
3690
+ },
3691
+ error: (e) => this.error.set(apiMessage(e, 'Could not load the mirror.')),
3692
+ });
3693
+ }
3694
+ syncNow() {
3695
+ const r = this.repo();
3696
+ if (!r)
3697
+ return;
3698
+ this.api.syncMirror(this.projectId(), r.name).subscribe({
3699
+ next: (m) => { this.mirror.set(m); this.note.set('Syncing with the source.'); setTimeout(() => this.loadMirror(r), 1000); },
3700
+ error: (e) => this.error.set(apiMessage(e, 'Could not start the sync.')),
3701
+ });
3702
+ }
3703
+ saveMirror() {
3704
+ const r = this.repo();
3705
+ const m = this.mirror();
3706
+ if (!r || !m)
3707
+ return;
3708
+ const body = { intervalMinutes: Number(this.mf.interval) || m.intervalMinutes };
3709
+ if (this.mf.url.trim() && this.mf.url.trim() !== m.source)
3710
+ body.url = this.mf.url.trim();
3711
+ if (this.mf.token) {
3712
+ body.token = this.mf.token;
3713
+ body.username = this.mf.username.trim();
3714
+ }
3715
+ this.busy.set(true);
3716
+ this.error.set('');
3717
+ this.api.updateMirror(this.projectId(), r.name, body).subscribe({
3718
+ next: (n) => { this.busy.set(false); this.mirror.set(n); this.mf.token = ''; this.note.set('Mirror saved. It syncs as you now.'); },
3719
+ error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not save the mirror.')); },
3720
+ });
3721
+ }
3722
+ stopMirror() {
3723
+ const r = this.repo();
3724
+ if (!r)
3725
+ return;
3726
+ this.busy.set(true);
3727
+ this.api.stopMirror(this.projectId(), r.name).subscribe({
3728
+ next: (n) => { this.busy.set(false); this.repo.set({ ...r, ...n, mirror: false }); this.mirror.set(null); this.note.set(`${r.name} is an ordinary repository now.`); this.go('general'); },
3729
+ error: (e) => { this.busy.set(false); this.error.set(apiMessage(e, 'Could not stop mirroring.')); },
3730
+ });
3731
+ }
3431
3732
  roleLabel(r) {
3432
3733
  return r ? r[0].toUpperCase() + r.slice(1) : 'Not in the project';
3433
3734
  }
3434
3735
  loadCollabs(r) {
3435
- if (!this.canAdmin())
3736
+ if (!this.canCollaborators())
3436
3737
  return;
3437
3738
  this.api.collaborators(this.projectId(), r.name).subscribe({ next: (l) => this.collabs.set(l), error: () => undefined });
3438
3739
  }
@@ -3461,10 +3762,14 @@ class RepoSettingsPage extends RepoPage {
3461
3762
  }
3462
3763
  onRepo(r) {
3463
3764
  this.loadCollabs(r);
3765
+ this.loadMirror(r);
3464
3766
  this.g = { name: r.name, description: r.description, visibility: r.visibility, defaultBranch: r.defaultBranch, mergeMethods: [...(r.mergeMethods ?? [])], deleteBranchOnMerge: r.deleteBranchOnMerge, allowAutoMerge: !!r.allowAutoMerge };
3465
3767
  if (!r.empty)
3466
3768
  this.api.refs(this.projectId(), r.name, 'branch').subscribe({ next: (b) => this.branches.set(b), error: () => undefined });
3467
3769
  this.api.protections(this.projectId(), r.name).subscribe({ next: (p) => this.rules.set(p), error: () => undefined });
3770
+ if (this.canProtect() && !this.customRoles().length) {
3771
+ this.api.customRoles().subscribe({ next: (l) => this.customRoles.set(l.map((c) => ({ id: c.id, label: c.name }))), error: () => undefined });
3772
+ }
3468
3773
  }
3469
3774
  go(s) {
3470
3775
  void this.router.navigate([], { relativeTo: this.route, queryParams: { section: s === 'general' ? null : s }, replaceUrl: true });
@@ -3523,7 +3828,7 @@ class RepoSettingsPage extends RepoPage {
3523
3828
  this.busy.set(true);
3524
3829
  const renamed = this.g.name.trim() !== r.name;
3525
3830
  // a maintain collaborator sends what they may change; the name and visibility stay a Maintainer's
3526
- const body = this.canAdmin()
3831
+ const body = this.canSettings()
3527
3832
  ? { 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, allowAutoMerge: this.g.allowAutoMerge }
3528
3833
  : { description: this.g.description, defaultBranch: this.g.defaultBranch, mergeMethods: this.g.mergeMethods, deleteBranchOnMerge: this.g.deleteBranchOnMerge, allowAutoMerge: this.g.allowAutoMerge };
3529
3834
  this.api.update(this.projectId(), r.name, body).subscribe({
@@ -3590,6 +3895,7 @@ class RepoSettingsPage extends RepoPage {
3590
3895
  <nav class="o-setnav" aria-label="Repository settings">
3591
3896
  <span class="o-lbl" style="padding-top:0">{{ r.name }}</span>
3592
3897
  <button type="button" [class.o-on]="section() === 'general'" (click)="go('general')">General</button>
3898
+ @if (r.mirror) {<button type="button" [class.o-on]="section() === 'mirror'" (click)="go('mirror')" data-testid="settings-mirror">Mirror</button>}
3593
3899
  <button type="button" [class.o-on]="section() === 'protect'" (click)="go('protect')">Branch protection</button>
3594
3900
  <button type="button" [class.o-on]="section() === 'collab'" (click)="go('collab')">Collaborators</button>
3595
3901
  <button type="button" [class.o-on]="section() === 'danger'" (click)="go('danger')">Danger zone</button>
@@ -3602,14 +3908,14 @@ class RepoSettingsPage extends RepoPage {
3602
3908
  <h1 class="o-t">General</h1>
3603
3909
  <form class="o-card o-cb o-form" (ngSubmit)="saveGeneral()">
3604
3910
  <div class="o-g2">
3605
- <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>
3911
+ <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]="!canSettings()" /><span class="o-hint">Renaming keeps the old clone URL working as a redirect.</span></div>
3606
3912
  <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>
3607
3913
  </div>
3608
3914
  <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>
3609
3915
  <div class="o-fld"><span class="o-lbl" style="padding:0">Visibility</span>
3610
3916
  <div class="o-g2">
3611
- <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>
3612
- <button type="button" class="o-opt" [class.o-on]="g.visibility === 'internal'" (click)="g.visibility = 'internal'" [disabled]="!canAdmin()"><span class="o-rad" [class.o-on]="g.visibility === 'internal'"></span><span class="o-col" style="gap:2px"><b>Org-internal</b><span class="o-faint o-sm">Everyone in the organization can read</span></span></button>
3917
+ <button type="button" class="o-opt" [class.o-on]="g.visibility === 'private'" (click)="g.visibility = 'private'" [disabled]="!canSettings()"><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>
3918
+ <button type="button" class="o-opt" [class.o-on]="g.visibility === 'internal'" (click)="g.visibility = 'internal'" [disabled]="!canSettings()"><span class="o-rad" [class.o-on]="g.visibility === 'internal'"></span><span class="o-col" style="gap:2px"><b>Org-internal</b><span class="o-faint o-sm">Everyone in the organization can read</span></span></button>
3613
3919
  </div>
3614
3920
  </div>
3615
3921
  <div class="o-fld"><span class="o-lbl" style="padding:0">Allowed merge methods</span>
@@ -3617,9 +3923,41 @@ class RepoSettingsPage extends RepoPage {
3617
3923
  </div>
3618
3924
  <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>
3619
3925
  <div class="o-flex"><button type="button" class="o-tog" role="switch" aria-labelledby="rs-auto" [class.o-on]="g.allowAutoMerge" [attr.aria-checked]="g.allowAutoMerge" (click)="g.allowAutoMerge = !g.allowAutoMerge" [disabled]="!canMaintain()" data-testid="allow-auto-merge"></button><span id="rs-auto">Allow auto-merge when all requirements pass</span></div>
3620
- @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>}
3926
+ @if (canMaintain()) {<div class="o-flex">@if (!canSettings()) {<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>}
3621
3927
  </form>
3622
3928
  }
3929
+ @case ('mirror') {
3930
+ <h1 class="o-t">Mirror</h1>
3931
+ @if (mirror(); as m) {
3932
+ <div class="o-card" data-testid="mirror-status">
3933
+ <div class="o-row"><span class="o-mut" style="width:140px">Source</span><span class="o-mono o-ell">{{ m.source }}</span></div>
3934
+ <div class="o-row"><span class="o-mut" style="width:140px">Syncs</span><span>every {{ m.intervalMinutes }} minutes, as {{ people().name(m.setBy) }}</span></div>
3935
+ <div class="o-row"><span class="o-mut" style="width:140px">Last sync</span><span data-testid="mirror-last">{{ m.syncing ? 'Syncing now…' : m.lastSyncAt ? ago(m.lastSyncAt) : 'Not yet' }}</span>@if (!m.lastError && m.lastSuccessAt) {<span class="o-chip o-grn">Up to date</span>}</div>
3936
+ @if (m.lastError) {<div class="o-row"><span class="o-mut" style="width:140px">Problem</span><span class="o-red" role="alert" data-testid="mirror-error">{{ m.lastError }}</span></div>}
3937
+ @if (m.lfsMissing) {<div class="o-row"><span class="o-mut" style="width:140px">Git LFS</span><span>{{ m.lfsMissing }} files the source could not supply stay pointers.</span></div>}
3938
+ <div class="o-cf">
3939
+ <button type="button" class="o-btn o-sm" (click)="syncNow()" [disabled]="!can('code.push') || m.syncing || busy()" data-testid="mirror-sync">Sync now</button>
3940
+ <span class="o-faint o-xs">Pushes to this repository are refused while it mirrors its source.</span>
3941
+ </div>
3942
+ </div>
3943
+ @if (canSettings()) {
3944
+ <form class="o-card o-cb o-form" (ngSubmit)="saveMirror()" aria-label="Mirror settings">
3945
+ <div class="o-fld"><label for="mi-url">Source URL</label><input id="mi-url" class="o-in o-mono" name="murl" [(ngModel)]="mf.url" /></div>
3946
+ <div class="o-g2">
3947
+ <div class="o-fld"><label for="mi-user">Username</label><input id="mi-user" class="o-in" name="muser" [(ngModel)]="mf.username" autocomplete="off" /></div>
3948
+ <div class="o-fld"><label for="mi-tok">New access token</label><input id="mi-tok" class="o-in" type="password" name="mtok" [(ngModel)]="mf.token" autocomplete="off" [placeholder]="m.hasCredentials ? 'A token is kept, encrypted' : 'None'" /></div>
3949
+ </div>
3950
+ <div class="o-fld"><label for="mi-int">Sync every (minutes)</label><input id="mi-int" class="o-in" type="number" min="1" name="mint" [(ngModel)]="mf.interval" style="width:140px" /></div>
3951
+ <div class="o-flex"><span class="o-faint o-sm">Saving makes the mirror sync as you.</span><button type="submit" class="o-btn o-dark o-r" [disabled]="busy()" data-testid="mirror-save">Save</button></div>
3952
+ </form>
3953
+ <div class="o-card" style="border-color:var(--o-bad-line)">
3954
+ <div class="o-row" style="padding:14px"><div class="o-col" style="gap:2px;flex:1"><b>Stop mirroring</b><span class="o-hint">The repository keeps its history and takes pushes; the kept token is deleted.</span></div><button type="button" class="o-btn o-sm" (click)="stopMirror()" [disabled]="busy()" data-testid="mirror-stop">Stop mirroring</button></div>
3955
+ </div>
3956
+ }
3957
+ } @else {
3958
+ <div class="o-card"><div class="o-empty">Loading…</div></div>
3959
+ }
3960
+ }
3623
3961
  @case ('protect') {
3624
3962
  <div class="o-flex"><h1 class="o-t">Branch protection</h1>@if (canProtect()) {<button type="button" class="o-btn o-sm o-r" (click)="edit(null)" data-testid="add-rule">+ Add rule</button>}</div>
3625
3963
  <div class="o-card">
@@ -3642,7 +3980,7 @@ class RepoSettingsPage extends RepoPage {
3642
3980
  </div>
3643
3981
  </div>
3644
3982
  <div class="o-fld"><span class="o-lbl" style="padding:0">Allowed to bypass</span><div class="o-flex o-wrap">
3645
- @for (b of bypassRoles; track b.id) {<button type="button" class="o-chip" style="cursor:pointer;border:none" [class.o-blu]="f.bypass.includes(b.id)" [attr.aria-pressed]="f.bypass.includes(b.id)" (click)="flipBypass(f, b.id)" [attr.data-testid]="'bypass-' + b.id">{{ b.label }}</button>}
3983
+ @for (b of bypassRoles(); track b.id) {<button type="button" class="o-chip" style="cursor:pointer;border:none" [class.o-blu]="f.bypass.includes(b.id)" [attr.aria-pressed]="f.bypass.includes(b.id)" (click)="flipBypass(f, b.id)" [attr.data-testid]="'bypass-' + b.id">{{ b.label }}</button>}
3646
3984
  <span class="o-hint">Pushes by these roles go through this rule; every bypass is written to the audit log.</span></div></div>
3647
3985
  <div class="o-fld"><span class="o-lbl" style="padding:0">What a blocked push sees</span><div class="o-term" style="font-size:11.5px"><div>$ git push origin {{ f.pattern || 'main' }}</div><div>remote: ✕ {{ f.pattern || 'main' }} is protected by rule “{{ f.pattern || 'main' }}”: direct pushes are blocked.</div><div>remote: Open a pull request instead.</div></div></div>
3648
3986
  <div class="o-flex">
@@ -3657,7 +3995,7 @@ class RepoSettingsPage extends RepoPage {
3657
3995
  @case ('collab') {
3658
3996
  <h1 class="o-t">Collaborators</h1>
3659
3997
  <span class="o-mut">Access beyond the project role, for this repository only.</span>
3660
- @if (canAdmin()) {
3998
+ @if (canCollaborators()) {
3661
3999
  <div class="o-card" style="overflow-x:auto" data-testid="collaborators">
3662
4000
  <div class="o-tbl" role="table" aria-label="Collaborators" style="min-width:560px">
3663
4001
  <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>
@@ -3688,10 +4026,10 @@ class RepoSettingsPage extends RepoPage {
3688
4026
  @case ('danger') {
3689
4027
  <h1 class="o-t">Danger zone</h1>
3690
4028
  <div class="o-card" style="border-color:var(--o-bad-line)">
3691
- <div class="o-row" style="padding:14px"><div class="o-col" style="gap:2px;flex:1"><b>{{ r.archivedAt ? 'Unarchive' : 'Archive' }}</b><span class="o-hint">{{ r.archivedAt ? 'Pushes are accepted again.' : 'Read-only. Pushes are rejected; pipelines stop triggering.' }}</span></div><button type="button" class="o-btn o-sm" (click)="archive(!r.archivedAt)" [disabled]="!canAdmin() || busy()">{{ r.archivedAt ? 'Unarchive' : 'Archive' }}</button></div>
4029
+ <div class="o-row" style="padding:14px"><div class="o-col" style="gap:2px;flex:1"><b>{{ r.archivedAt ? 'Unarchive' : 'Archive' }}</b><span class="o-hint">{{ r.archivedAt ? 'Pushes are accepted again.' : 'Read-only. Pushes are rejected; pipelines stop triggering.' }}</span></div><button type="button" class="o-btn o-sm" (click)="archive(!r.archivedAt)" [disabled]="!canDelete() || busy()">{{ r.archivedAt ? 'Unarchive' : 'Archive' }}</button></div>
3692
4030
  <div class="o-row" style="padding:14px;flex-wrap:wrap"><div class="o-col" style="gap:2px;flex:1;min-width:240px"><b>Delete this repository</b><span class="o-hint">Recoverable for 7 days, then gone for good. Type <b class="o-mono">{{ r.name }}</b> to confirm.</span></div>
3693
- <input class="o-in o-sm o-mono" style="width:180px" aria-label="Type the repository name to confirm" [ngModel]="confirm()" (ngModelChange)="confirm.set($event)" [disabled]="!canAdmin()" />
3694
- <button type="button" class="o-btn o-sm o-red" (click)="remove()" [disabled]="!canAdmin() || confirm() !== r.name || busy()">Delete</button>
4031
+ <input class="o-in o-sm o-mono" style="width:180px" aria-label="Type the repository name to confirm" [ngModel]="confirm()" (ngModelChange)="confirm.set($event)" [disabled]="!canDelete()" />
4032
+ <button type="button" class="o-btn o-sm o-red" (click)="remove()" [disabled]="!canDelete() || confirm() !== r.name || busy()">Delete</button>
3695
4033
  </div>
3696
4034
  </div>
3697
4035
  }
@@ -3702,7 +4040,7 @@ class RepoSettingsPage extends RepoPage {
3702
4040
  <div class="o-card o-empty"><p>{{ error() }}</p></div>
3703
4041
  }
3704
4042
  </div>
3705
- `, isInline: true, 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.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { 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.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 }); }
4043
+ `, isInline: true, 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.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { 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.MinValidator, selector: "input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]", inputs: ["min"] }, { 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 }); }
3706
4044
  }
3707
4045
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: RepoSettingsPage, decorators: [{
3708
4046
  type: Component,
@@ -3717,6 +4055,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3717
4055
  <nav class="o-setnav" aria-label="Repository settings">
3718
4056
  <span class="o-lbl" style="padding-top:0">{{ r.name }}</span>
3719
4057
  <button type="button" [class.o-on]="section() === 'general'" (click)="go('general')">General</button>
4058
+ @if (r.mirror) {<button type="button" [class.o-on]="section() === 'mirror'" (click)="go('mirror')" data-testid="settings-mirror">Mirror</button>}
3720
4059
  <button type="button" [class.o-on]="section() === 'protect'" (click)="go('protect')">Branch protection</button>
3721
4060
  <button type="button" [class.o-on]="section() === 'collab'" (click)="go('collab')">Collaborators</button>
3722
4061
  <button type="button" [class.o-on]="section() === 'danger'" (click)="go('danger')">Danger zone</button>
@@ -3729,14 +4068,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3729
4068
  <h1 class="o-t">General</h1>
3730
4069
  <form class="o-card o-cb o-form" (ngSubmit)="saveGeneral()">
3731
4070
  <div class="o-g2">
3732
- <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>
4071
+ <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]="!canSettings()" /><span class="o-hint">Renaming keeps the old clone URL working as a redirect.</span></div>
3733
4072
  <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>
3734
4073
  </div>
3735
4074
  <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>
3736
4075
  <div class="o-fld"><span class="o-lbl" style="padding:0">Visibility</span>
3737
4076
  <div class="o-g2">
3738
- <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>
3739
- <button type="button" class="o-opt" [class.o-on]="g.visibility === 'internal'" (click)="g.visibility = 'internal'" [disabled]="!canAdmin()"><span class="o-rad" [class.o-on]="g.visibility === 'internal'"></span><span class="o-col" style="gap:2px"><b>Org-internal</b><span class="o-faint o-sm">Everyone in the organization can read</span></span></button>
4077
+ <button type="button" class="o-opt" [class.o-on]="g.visibility === 'private'" (click)="g.visibility = 'private'" [disabled]="!canSettings()"><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>
4078
+ <button type="button" class="o-opt" [class.o-on]="g.visibility === 'internal'" (click)="g.visibility = 'internal'" [disabled]="!canSettings()"><span class="o-rad" [class.o-on]="g.visibility === 'internal'"></span><span class="o-col" style="gap:2px"><b>Org-internal</b><span class="o-faint o-sm">Everyone in the organization can read</span></span></button>
3740
4079
  </div>
3741
4080
  </div>
3742
4081
  <div class="o-fld"><span class="o-lbl" style="padding:0">Allowed merge methods</span>
@@ -3744,9 +4083,41 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3744
4083
  </div>
3745
4084
  <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>
3746
4085
  <div class="o-flex"><button type="button" class="o-tog" role="switch" aria-labelledby="rs-auto" [class.o-on]="g.allowAutoMerge" [attr.aria-checked]="g.allowAutoMerge" (click)="g.allowAutoMerge = !g.allowAutoMerge" [disabled]="!canMaintain()" data-testid="allow-auto-merge"></button><span id="rs-auto">Allow auto-merge when all requirements pass</span></div>
3747
- @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>}
4086
+ @if (canMaintain()) {<div class="o-flex">@if (!canSettings()) {<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>}
3748
4087
  </form>
3749
4088
  }
4089
+ @case ('mirror') {
4090
+ <h1 class="o-t">Mirror</h1>
4091
+ @if (mirror(); as m) {
4092
+ <div class="o-card" data-testid="mirror-status">
4093
+ <div class="o-row"><span class="o-mut" style="width:140px">Source</span><span class="o-mono o-ell">{{ m.source }}</span></div>
4094
+ <div class="o-row"><span class="o-mut" style="width:140px">Syncs</span><span>every {{ m.intervalMinutes }} minutes, as {{ people().name(m.setBy) }}</span></div>
4095
+ <div class="o-row"><span class="o-mut" style="width:140px">Last sync</span><span data-testid="mirror-last">{{ m.syncing ? 'Syncing now…' : m.lastSyncAt ? ago(m.lastSyncAt) : 'Not yet' }}</span>@if (!m.lastError && m.lastSuccessAt) {<span class="o-chip o-grn">Up to date</span>}</div>
4096
+ @if (m.lastError) {<div class="o-row"><span class="o-mut" style="width:140px">Problem</span><span class="o-red" role="alert" data-testid="mirror-error">{{ m.lastError }}</span></div>}
4097
+ @if (m.lfsMissing) {<div class="o-row"><span class="o-mut" style="width:140px">Git LFS</span><span>{{ m.lfsMissing }} files the source could not supply stay pointers.</span></div>}
4098
+ <div class="o-cf">
4099
+ <button type="button" class="o-btn o-sm" (click)="syncNow()" [disabled]="!can('code.push') || m.syncing || busy()" data-testid="mirror-sync">Sync now</button>
4100
+ <span class="o-faint o-xs">Pushes to this repository are refused while it mirrors its source.</span>
4101
+ </div>
4102
+ </div>
4103
+ @if (canSettings()) {
4104
+ <form class="o-card o-cb o-form" (ngSubmit)="saveMirror()" aria-label="Mirror settings">
4105
+ <div class="o-fld"><label for="mi-url">Source URL</label><input id="mi-url" class="o-in o-mono" name="murl" [(ngModel)]="mf.url" /></div>
4106
+ <div class="o-g2">
4107
+ <div class="o-fld"><label for="mi-user">Username</label><input id="mi-user" class="o-in" name="muser" [(ngModel)]="mf.username" autocomplete="off" /></div>
4108
+ <div class="o-fld"><label for="mi-tok">New access token</label><input id="mi-tok" class="o-in" type="password" name="mtok" [(ngModel)]="mf.token" autocomplete="off" [placeholder]="m.hasCredentials ? 'A token is kept, encrypted' : 'None'" /></div>
4109
+ </div>
4110
+ <div class="o-fld"><label for="mi-int">Sync every (minutes)</label><input id="mi-int" class="o-in" type="number" min="1" name="mint" [(ngModel)]="mf.interval" style="width:140px" /></div>
4111
+ <div class="o-flex"><span class="o-faint o-sm">Saving makes the mirror sync as you.</span><button type="submit" class="o-btn o-dark o-r" [disabled]="busy()" data-testid="mirror-save">Save</button></div>
4112
+ </form>
4113
+ <div class="o-card" style="border-color:var(--o-bad-line)">
4114
+ <div class="o-row" style="padding:14px"><div class="o-col" style="gap:2px;flex:1"><b>Stop mirroring</b><span class="o-hint">The repository keeps its history and takes pushes; the kept token is deleted.</span></div><button type="button" class="o-btn o-sm" (click)="stopMirror()" [disabled]="busy()" data-testid="mirror-stop">Stop mirroring</button></div>
4115
+ </div>
4116
+ }
4117
+ } @else {
4118
+ <div class="o-card"><div class="o-empty">Loading…</div></div>
4119
+ }
4120
+ }
3750
4121
  @case ('protect') {
3751
4122
  <div class="o-flex"><h1 class="o-t">Branch protection</h1>@if (canProtect()) {<button type="button" class="o-btn o-sm o-r" (click)="edit(null)" data-testid="add-rule">+ Add rule</button>}</div>
3752
4123
  <div class="o-card">
@@ -3769,7 +4140,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3769
4140
  </div>
3770
4141
  </div>
3771
4142
  <div class="o-fld"><span class="o-lbl" style="padding:0">Allowed to bypass</span><div class="o-flex o-wrap">
3772
- @for (b of bypassRoles; track b.id) {<button type="button" class="o-chip" style="cursor:pointer;border:none" [class.o-blu]="f.bypass.includes(b.id)" [attr.aria-pressed]="f.bypass.includes(b.id)" (click)="flipBypass(f, b.id)" [attr.data-testid]="'bypass-' + b.id">{{ b.label }}</button>}
4143
+ @for (b of bypassRoles(); track b.id) {<button type="button" class="o-chip" style="cursor:pointer;border:none" [class.o-blu]="f.bypass.includes(b.id)" [attr.aria-pressed]="f.bypass.includes(b.id)" (click)="flipBypass(f, b.id)" [attr.data-testid]="'bypass-' + b.id">{{ b.label }}</button>}
3773
4144
  <span class="o-hint">Pushes by these roles go through this rule; every bypass is written to the audit log.</span></div></div>
3774
4145
  <div class="o-fld"><span class="o-lbl" style="padding:0">What a blocked push sees</span><div class="o-term" style="font-size:11.5px"><div>$ git push origin {{ f.pattern || 'main' }}</div><div>remote: ✕ {{ f.pattern || 'main' }} is protected by rule “{{ f.pattern || 'main' }}”: direct pushes are blocked.</div><div>remote: Open a pull request instead.</div></div></div>
3775
4146
  <div class="o-flex">
@@ -3784,7 +4155,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3784
4155
  @case ('collab') {
3785
4156
  <h1 class="o-t">Collaborators</h1>
3786
4157
  <span class="o-mut">Access beyond the project role, for this repository only.</span>
3787
- @if (canAdmin()) {
4158
+ @if (canCollaborators()) {
3788
4159
  <div class="o-card" style="overflow-x:auto" data-testid="collaborators">
3789
4160
  <div class="o-tbl" role="table" aria-label="Collaborators" style="min-width:560px">
3790
4161
  <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>
@@ -3815,10 +4186,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3815
4186
  @case ('danger') {
3816
4187
  <h1 class="o-t">Danger zone</h1>
3817
4188
  <div class="o-card" style="border-color:var(--o-bad-line)">
3818
- <div class="o-row" style="padding:14px"><div class="o-col" style="gap:2px;flex:1"><b>{{ r.archivedAt ? 'Unarchive' : 'Archive' }}</b><span class="o-hint">{{ r.archivedAt ? 'Pushes are accepted again.' : 'Read-only. Pushes are rejected; pipelines stop triggering.' }}</span></div><button type="button" class="o-btn o-sm" (click)="archive(!r.archivedAt)" [disabled]="!canAdmin() || busy()">{{ r.archivedAt ? 'Unarchive' : 'Archive' }}</button></div>
4189
+ <div class="o-row" style="padding:14px"><div class="o-col" style="gap:2px;flex:1"><b>{{ r.archivedAt ? 'Unarchive' : 'Archive' }}</b><span class="o-hint">{{ r.archivedAt ? 'Pushes are accepted again.' : 'Read-only. Pushes are rejected; pipelines stop triggering.' }}</span></div><button type="button" class="o-btn o-sm" (click)="archive(!r.archivedAt)" [disabled]="!canDelete() || busy()">{{ r.archivedAt ? 'Unarchive' : 'Archive' }}</button></div>
3819
4190
  <div class="o-row" style="padding:14px;flex-wrap:wrap"><div class="o-col" style="gap:2px;flex:1;min-width:240px"><b>Delete this repository</b><span class="o-hint">Recoverable for 7 days, then gone for good. Type <b class="o-mono">{{ r.name }}</b> to confirm.</span></div>
3820
- <input class="o-in o-sm o-mono" style="width:180px" aria-label="Type the repository name to confirm" [ngModel]="confirm()" (ngModelChange)="confirm.set($event)" [disabled]="!canAdmin()" />
3821
- <button type="button" class="o-btn o-sm o-red" (click)="remove()" [disabled]="!canAdmin() || confirm() !== r.name || busy()">Delete</button>
4191
+ <input class="o-in o-sm o-mono" style="width:180px" aria-label="Type the repository name to confirm" [ngModel]="confirm()" (ngModelChange)="confirm.set($event)" [disabled]="!canDelete()" />
4192
+ <button type="button" class="o-btn o-sm o-red" (click)="remove()" [disabled]="!canDelete() || confirm() !== r.name || busy()">Delete</button>
3822
4193
  </div>
3823
4194
  </div>
3824
4195
  }
@@ -3989,7 +4360,7 @@ class ReleasesPage extends RepoPage {
3989
4360
  <div class="o-page o-narrow">
3990
4361
  <div class="o-ph">
3991
4362
  <div class="o-col"><span class="o-eyebrow">{{ name() }}</span><h1 class="o-t">Releases and tags</h1></div>
3992
- @if (canWrite()) {
4363
+ @if (canRelease()) {
3993
4364
  <div class="o-acts">
3994
4365
  <button type="button" class="o-btn o-sm" (click)="tagging.set(!tagging()); draft.set(null)" data-testid="tag-new">New tag</button>
3995
4366
  <button type="button" class="o-btn o-sm o-dark" (click)="startRelease()" data-testid="release-new">Draft release</button>
@@ -4060,11 +4431,11 @@ class ReleasesPage extends RepoPage {
4060
4431
  <span>Assets</span>
4061
4432
  @for (a of r.assets; track a.id) {
4062
4433
  <button type="button" class="o-lnk o-mono" (click)="download(r, a.id, a.name)" [attr.aria-label]="'Download ' + a.name">{{ a.name }}</button>
4063
- @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>}
4434
+ @if (canRelease()) {<button type="button" class="o-lnk" style="color:var(--o-mut)" (click)="removeAsset(r, a.id)" [attr.aria-label]="'Remove ' + a.name">×</button>}
4064
4435
  }
4065
4436
  <button type="button" class="o-lnk" (click)="archive(r, 'zip')">Source (zip)</button>
4066
4437
  <button type="button" class="o-lnk" (click)="archive(r, 'tar.gz')">Source (tar.gz)</button>
4067
- @if (canWrite()) {
4438
+ @if (canRelease()) {
4068
4439
  <span class="o-r o-flex" style="gap:10px">
4069
4440
  <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>
4070
4441
  <button type="button" class="o-lnk" (click)="edit(r)" [attr.aria-label]="'Edit ' + r.tag">Edit</button>
@@ -4079,7 +4450,7 @@ class ReleasesPage extends RepoPage {
4079
4450
  </div>
4080
4451
  </div>
4081
4452
  } @empty {
4082
- <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>
4453
+ <div class="o-card"><div class="o-empty">{{ loaded() ? 'No releases yet.' + (canRelease() ? ' Draft one from a tag; its notes can come from the issues it finishes.' : '') : 'Loading…' }}</div></div>
4083
4454
  }
4084
4455
 
4085
4456
  <div class="o-card" data-testid="tags">
@@ -4088,7 +4459,7 @@ class ReleasesPage extends RepoPage {
4088
4459
  <div class="o-row" [attr.data-testid]="'tag-' + t.name">
4089
4460
  <span class="o-mono o-sm o-b5">{{ t.name }}</span><span class="o-faint o-sm o-mono">{{ t.commit?.short }}</span>
4090
4461
  <span class="o-r o-faint o-sm">{{ ago(t.commit?.committer?.when) }}</span>
4091
- @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>}
4462
+ @if (canRelease() && !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>}
4092
4463
  </div>
4093
4464
  } @empty {<div class="o-empty o-sm">No tags yet.</div>}
4094
4465
  </div>
@@ -4101,7 +4472,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
4101
4472
  <div class="o-page o-narrow">
4102
4473
  <div class="o-ph">
4103
4474
  <div class="o-col"><span class="o-eyebrow">{{ name() }}</span><h1 class="o-t">Releases and tags</h1></div>
4104
- @if (canWrite()) {
4475
+ @if (canRelease()) {
4105
4476
  <div class="o-acts">
4106
4477
  <button type="button" class="o-btn o-sm" (click)="tagging.set(!tagging()); draft.set(null)" data-testid="tag-new">New tag</button>
4107
4478
  <button type="button" class="o-btn o-sm o-dark" (click)="startRelease()" data-testid="release-new">Draft release</button>
@@ -4172,11 +4543,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
4172
4543
  <span>Assets</span>
4173
4544
  @for (a of r.assets; track a.id) {
4174
4545
  <button type="button" class="o-lnk o-mono" (click)="download(r, a.id, a.name)" [attr.aria-label]="'Download ' + a.name">{{ a.name }}</button>
4175
- @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>}
4546
+ @if (canRelease()) {<button type="button" class="o-lnk" style="color:var(--o-mut)" (click)="removeAsset(r, a.id)" [attr.aria-label]="'Remove ' + a.name">×</button>}
4176
4547
  }
4177
4548
  <button type="button" class="o-lnk" (click)="archive(r, 'zip')">Source (zip)</button>
4178
4549
  <button type="button" class="o-lnk" (click)="archive(r, 'tar.gz')">Source (tar.gz)</button>
4179
- @if (canWrite()) {
4550
+ @if (canRelease()) {
4180
4551
  <span class="o-r o-flex" style="gap:10px">
4181
4552
  <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>
4182
4553
  <button type="button" class="o-lnk" (click)="edit(r)" [attr.aria-label]="'Edit ' + r.tag">Edit</button>
@@ -4191,7 +4562,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
4191
4562
  </div>
4192
4563
  </div>
4193
4564
  } @empty {
4194
- <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>
4565
+ <div class="o-card"><div class="o-empty">{{ loaded() ? 'No releases yet.' + (canRelease() ? ' Draft one from a tag; its notes can come from the issues it finishes.' : '') : 'Loading…' }}</div></div>
4195
4566
  }
4196
4567
 
4197
4568
  <div class="o-card" data-testid="tags">
@@ -4200,7 +4571,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
4200
4571
  <div class="o-row" [attr.data-testid]="'tag-' + t.name">
4201
4572
  <span class="o-mono o-sm o-b5">{{ t.name }}</span><span class="o-faint o-sm o-mono">{{ t.commit?.short }}</span>
4202
4573
  <span class="o-r o-faint o-sm">{{ ago(t.commit?.committer?.when) }}</span>
4203
- @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>}
4574
+ @if (canRelease() && !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>}
4204
4575
  </div>
4205
4576
  } @empty {<div class="o-empty o-sm">No tags yet.</div>}
4206
4577
  </div>
@@ -4495,5 +4866,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
4495
4866
  * Generated bundle index. Do not edit.
4496
4867
  */
4497
4868
 
4498
- export { BranchesPage, CodeApi, CodeHost, CodeSearchPage, CommitBox, CommitPage, CommitsPage, CurrentRepo, CurrentRepoPage, DiffView, FilePage, NewFilePage, NewRepoPage, People, PullPage, PullsPage, RefPicker, ReleasesPage, RepoHomePage, RepoPage, RepoSettingsPage, ReposPage, TOKEN_CSS, ago, apiMessage, checkDot, copy, dayLabel, initials, langColour, matchesPattern, pair, shortDate, size, verifyWhy };
4869
+ export { BranchesPage, CodeApi, CodeHost, CodeSearchPage, CommitBox, CommitPage, CommitsPage, CurrentRepo, CurrentRepoPage, DiffView, FilePage, NewFilePage, NewRepoPage, People, PullPage, PullsPage, RefPicker, ReleasesPage, RepoHomePage, RepoPage, RepoSettingsPage, ReposPage, TOKEN_CSS, ago, apiMessage, checkDot, copy, dayLabel, initials, langColour, matchesPattern, pair, shortDate, size, verifyLabel, verifyTitle, verifyWhy };
4499
4870
  //# sourceMappingURL=chisquare-tech-origine-ngx-apps-code.mjs.map