@fuzdev/fuz_gitops 0.78.0 → 0.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -17
- package/dist/ModulesDetail.svelte +22 -27
- package/dist/ModulesDetail.svelte.d.ts.map +1 -1
- package/dist/ModulesNav.svelte +8 -5
- package/dist/ModulesNav.svelte.d.ts.map +1 -1
- package/dist/ModulesPage.svelte +3 -5
- package/dist/ModulesPage.svelte.d.ts.map +1 -1
- package/dist/PageFooter.svelte +2 -2
- package/dist/PageHeader.svelte +6 -6
- package/dist/PageHeader.svelte.d.ts +0 -3
- package/dist/PageHeader.svelte.d.ts.map +1 -1
- package/dist/PullRequestsDetail.svelte +29 -20
- package/dist/PullRequestsDetail.svelte.d.ts.map +1 -1
- package/dist/PullRequestsPage.svelte +4 -6
- package/dist/PullRequestsPage.svelte.d.ts.map +1 -1
- package/dist/ReposTable.svelte +43 -55
- package/dist/ReposTable.svelte.d.ts.map +1 -1
- package/dist/ReposTree.svelte +9 -21
- package/dist/ReposTree.svelte.d.ts.map +1 -1
- package/dist/ReposTreeNav.svelte +9 -12
- package/dist/ReposTreeNav.svelte.d.ts.map +1 -1
- package/dist/TablePage.svelte +3 -5
- package/dist/TablePage.svelte.d.ts.map +1 -1
- package/dist/TreeItemPage.svelte +3 -5
- package/dist/TreeItemPage.svelte.d.ts.map +1 -1
- package/dist/TreePage.svelte +3 -5
- package/dist/TreePage.svelte.d.ts.map +1 -1
- package/dist/changeset_generator.d.ts.map +1 -1
- package/dist/changeset_generator.js +2 -2
- package/dist/changeset_reader.d.ts.map +1 -1
- package/dist/changeset_reader.js +3 -3
- package/dist/ci_reconcile.d.ts +6 -10
- package/dist/ci_reconcile.d.ts.map +1 -1
- package/dist/ci_reconcile.js +6 -8
- package/dist/dependency_graph.d.ts +26 -49
- package/dist/dependency_graph.d.ts.map +1 -1
- package/dist/dependency_graph.js +40 -83
- package/dist/dependency_updater.d.ts +1 -1
- package/dist/dependency_updater.d.ts.map +1 -1
- package/dist/dependency_updater.js +14 -9
- package/dist/fetch_repo_data.d.ts +34 -8
- package/dist/fetch_repo_data.d.ts.map +1 -1
- package/dist/fetch_repo_data.js +44 -22
- package/dist/fs_fetch_value_cache.d.ts +1 -1
- package/dist/fs_fetch_value_cache.d.ts.map +1 -1
- package/dist/fs_fetch_value_cache.js +3 -3
- package/dist/git_operations.d.ts +6 -49
- package/dist/git_operations.d.ts.map +1 -1
- package/dist/git_operations.js +13 -126
- package/dist/github.d.ts +17 -2
- package/dist/github.d.ts.map +1 -1
- package/dist/github.js +17 -8
- package/dist/github_helpers.d.ts.map +1 -1
- package/dist/github_helpers.js +3 -11
- package/dist/gitops_analyze.task.d.ts +24 -2
- package/dist/gitops_analyze.task.d.ts.map +1 -1
- package/dist/gitops_analyze.task.js +47 -34
- package/dist/gitops_config.d.ts +34 -79
- package/dist/gitops_config.d.ts.map +1 -1
- package/dist/gitops_config.js +76 -53
- package/dist/gitops_constants.d.ts +6 -4
- package/dist/gitops_constants.d.ts.map +1 -1
- package/dist/gitops_constants.js +6 -4
- package/dist/gitops_plan.task.d.ts +29 -3
- package/dist/gitops_plan.task.d.ts.map +1 -1
- package/dist/gitops_plan.task.js +83 -61
- package/dist/gitops_publish.task.d.ts +62 -2
- package/dist/gitops_publish.task.d.ts.map +1 -1
- package/dist/gitops_publish.task.js +193 -105
- package/dist/gitops_run.task.d.ts +74 -0
- package/dist/gitops_run.task.d.ts.map +1 -1
- package/dist/gitops_run.task.js +176 -162
- package/dist/gitops_sync.task.d.ts +65 -3
- package/dist/gitops_sync.task.d.ts.map +1 -1
- package/dist/gitops_sync.task.js +163 -37
- package/dist/gitops_task_helpers.d.ts +86 -59
- package/dist/gitops_task_helpers.d.ts.map +1 -1
- package/dist/gitops_task_helpers.js +120 -79
- package/dist/gitops_validate.task.d.ts +1 -2
- package/dist/gitops_validate.task.d.ts.map +1 -1
- package/dist/gitops_validate.task.js +25 -32
- package/dist/graph_validation.d.ts +16 -27
- package/dist/graph_validation.d.ts.map +1 -1
- package/dist/graph_validation.js +24 -65
- package/dist/local_repo.d.ts +50 -62
- package/dist/local_repo.d.ts.map +1 -1
- package/dist/local_repo.js +83 -196
- package/dist/log_helpers.d.ts +8 -8
- package/dist/log_helpers.d.ts.map +1 -1
- package/dist/multi_repo_publisher.d.ts +10 -0
- package/dist/multi_repo_publisher.d.ts.map +1 -1
- package/dist/multi_repo_publisher.js +125 -61
- package/dist/npm_registry.d.ts +23 -19
- package/dist/npm_registry.d.ts.map +1 -1
- package/dist/npm_registry.js +21 -48
- package/dist/operations.d.ts +75 -180
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +15 -7
- package/dist/operations_defaults.d.ts +17 -2
- package/dist/operations_defaults.d.ts.map +1 -1
- package/dist/operations_defaults.js +111 -128
- package/dist/output_helpers.d.ts +36 -0
- package/dist/output_helpers.d.ts.map +1 -1
- package/dist/output_helpers.js +37 -3
- package/dist/preflight_checks.d.ts +20 -27
- package/dist/preflight_checks.d.ts.map +1 -1
- package/dist/preflight_checks.js +37 -126
- package/dist/publish_gate.d.ts.map +1 -1
- package/dist/publish_steps.d.ts +2 -4
- package/dist/publish_steps.d.ts.map +1 -1
- package/dist/publish_steps.js +4 -8
- package/dist/publishing_event.d.ts +2 -8
- package/dist/publishing_event.d.ts.map +1 -1
- package/dist/publishing_event.js +20 -17
- package/dist/publishing_event_handler.d.ts +10 -8
- package/dist/publishing_event_handler.d.ts.map +1 -1
- package/dist/publishing_event_handler.js +19 -17
- package/dist/publishing_plan.d.ts +16 -1
- package/dist/publishing_plan.d.ts.map +1 -1
- package/dist/publishing_plan.js +332 -254
- package/dist/publishing_plan_helpers.d.ts.map +1 -1
- package/dist/publishing_plan_helpers.js +3 -3
- package/dist/publishing_plan_logging.d.ts +1 -1
- package/dist/publishing_plan_logging.d.ts.map +1 -1
- package/dist/publishing_plan_logging.js +22 -15
- package/dist/repo.svelte.d.ts +7 -0
- package/dist/repo.svelte.d.ts.map +1 -1
- package/dist/repo.svelte.js +3 -0
- package/dist/repo_readiness.d.ts +210 -0
- package/dist/repo_readiness.d.ts.map +1 -0
- package/dist/repo_readiness.js +516 -0
- package/dist/repos_status.d.ts +3340 -0
- package/dist/repos_status.d.ts.map +1 -0
- package/dist/repos_status.js +555 -0
- package/dist/repos_status_load.d.ts +65 -0
- package/dist/repos_status_load.d.ts.map +1 -0
- package/dist/repos_status_load.js +131 -0
- package/dist/version_utils.js +1 -1
- package/package.json +15 -34
- package/src/lib/cargo_toml.ts +3 -3
- package/src/lib/changeset_generator.ts +19 -19
- package/src/lib/changeset_reader.ts +18 -18
- package/src/lib/ci_reconcile.ts +12 -16
- package/src/lib/dependency_graph.ts +61 -113
- package/src/lib/dependency_updater.ts +36 -30
- package/src/lib/fetch_repo_data.ts +71 -34
- package/src/lib/fs_fetch_value_cache.ts +15 -11
- package/src/lib/git_operations.ts +22 -194
- package/src/lib/github.ts +33 -21
- package/src/lib/github_helpers.ts +9 -16
- package/src/lib/gitops_analyze.task.ts +104 -66
- package/src/lib/gitops_config.ts +90 -139
- package/src/lib/gitops_constants.ts +6 -4
- package/src/lib/gitops_plan.task.ts +128 -76
- package/src/lib/gitops_publish.task.ts +280 -144
- package/src/lib/gitops_run.task.ts +265 -193
- package/src/lib/gitops_sync.task.ts +236 -52
- package/src/lib/gitops_task_helpers.ts +170 -114
- package/src/lib/gitops_validate.task.ts +44 -54
- package/src/lib/graph_validation.ts +28 -89
- package/src/lib/local_repo.ts +128 -335
- package/src/lib/log_helpers.ts +18 -28
- package/src/lib/multi_repo_publisher.ts +192 -110
- package/src/lib/npm_registry.ts +42 -67
- package/src/lib/operations.ts +99 -160
- package/src/lib/operations_defaults.ts +157 -201
- package/src/lib/output_helpers.ts +58 -6
- package/src/lib/preflight_checks.ts +52 -188
- package/src/lib/publish_gate.ts +7 -9
- package/src/lib/publish_steps.ts +27 -25
- package/src/lib/publishing_event.ts +26 -23
- package/src/lib/publishing_event_handler.ts +24 -22
- package/src/lib/publishing_plan.ts +474 -310
- package/src/lib/publishing_plan_helpers.ts +16 -12
- package/src/lib/publishing_plan_logging.ts +53 -44
- package/src/lib/repo.svelte.ts +16 -8
- package/src/lib/repo_readiness.ts +647 -0
- package/src/lib/repos_status.ts +671 -0
- package/src/lib/repos_status_load.ts +173 -0
- package/src/lib/version_utils.ts +3 -3
- package/dist/config_reconcile.d.ts +0 -46
- package/dist/config_reconcile.d.ts.map +0 -1
- package/dist/config_reconcile.js +0 -60
- package/dist/paths.d.ts +0 -7
- package/dist/paths.d.ts.map +0 -1
- package/dist/paths.js +0 -6
- package/dist/repo_ops.d.ts +0 -59
- package/dist/repo_ops.d.ts.map +0 -1
- package/dist/repo_ops.js +0 -170
- package/dist/resolved_gitops_config.d.ts +0 -9
- package/dist/resolved_gitops_config.d.ts.map +0 -1
- package/dist/resolved_gitops_config.js +0 -12
- package/src/lib/config_reconcile.ts +0 -94
- package/src/lib/paths.ts +0 -6
- package/src/lib/repo_ops.ts +0 -216
- package/src/lib/resolved_gitops_config.ts +0 -27
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether each configured repo sits where the registry puts it, read from the
|
|
3
|
+
* entries `repos status --json` reports: the facts `gitops_publish --wetrun`
|
|
4
|
+
* gates on, the ones `gitops_sync` refuses or warns on, and the at-rest block
|
|
5
|
+
* the read-only diagnostics print.
|
|
6
|
+
*
|
|
7
|
+
* Pure — a report's facts in, problems and their messages out; no git calls.
|
|
8
|
+
* The facts are decided on the Rust side (`at_rest`, `fetch_error`, a
|
|
9
|
+
* checkout's `busy`, `needs_human`); this reads them, never re-derives them.
|
|
10
|
+
*
|
|
11
|
+
* Three readings:
|
|
12
|
+
*
|
|
13
|
+
* - **at rest** (`repo_readiness_at_rest`) — the primary checkout on the
|
|
14
|
+
* branch the entry follows, clean (untracked files count), no operation in
|
|
15
|
+
* progress, and that branch in sync with its origin upstream as of the
|
|
16
|
+
* local remote-tracking refs. What the diagnostics report.
|
|
17
|
+
* - **ready to publish** (`repo_readiness_for_publish`) — at rest, except
|
|
18
|
+
* that the followed branch may be ahead of origin (origin's tip is then an
|
|
19
|
+
* ancestor, so the plan misses nothing and the release push is still a
|
|
20
|
+
* fast-forward), and also fetched without error (so the relation is
|
|
21
|
+
* origin's current word), no other live Claude Code session in the primary
|
|
22
|
+
* checkout (the executor commits there), and nothing the entry leaves to a
|
|
23
|
+
* person (`needs_human`).
|
|
24
|
+
* - **ready to generate** (`repo_readiness_for_gen`) — what `gitops_sync`
|
|
25
|
+
* needs before it writes the dashboard's site data: the primary checkout
|
|
26
|
+
* on its branch, clean, and idle, since the site pairs each repo's local
|
|
27
|
+
* tree with origin's CI. The followed branch's relation to origin and a
|
|
28
|
+
* failed fetch only warn.
|
|
29
|
+
*
|
|
30
|
+
* @module
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { Result } from '@fuzdev/fuz_util/result.ts';
|
|
34
|
+
|
|
35
|
+
import type {
|
|
36
|
+
ReposEntryStatus,
|
|
37
|
+
ReposFetchFailure,
|
|
38
|
+
ReposHead,
|
|
39
|
+
ReposInProgressOp,
|
|
40
|
+
ReposNeedsHuman,
|
|
41
|
+
ReposRelation,
|
|
42
|
+
ReposSession,
|
|
43
|
+
ReposStatusReport,
|
|
44
|
+
ReposUncommitted,
|
|
45
|
+
ReposUnavailable,
|
|
46
|
+
ReposUnreachableCause
|
|
47
|
+
} from './repos_status.ts';
|
|
48
|
+
|
|
49
|
+
/** One way a repo isn't where the registry puts it, or isn't ready to publish. */
|
|
50
|
+
export type RepoReadinessProblem =
|
|
51
|
+
| { kind: 'unprobed'; detail: string }
|
|
52
|
+
| { kind: 'no_branch' }
|
|
53
|
+
| { kind: 'off_branch'; branch: string; head: ReposHead }
|
|
54
|
+
| { kind: 'dirty'; uncommitted: ReposUncommitted }
|
|
55
|
+
| { kind: 'in_progress'; op: ReposInProgressOp | null }
|
|
56
|
+
| { kind: 'followed'; branch: string; relation: ReposRelation | null }
|
|
57
|
+
| { kind: 'fetch_failed'; failure: ReposFetchFailure }
|
|
58
|
+
| { kind: 'busy'; sessions: Array<ReposSession> }
|
|
59
|
+
| { kind: 'needs_human'; reason: ReposNeedsHuman };
|
|
60
|
+
|
|
61
|
+
/** A repo's readiness problems, by registry key; `entry` is `null` when the report lacks it. */
|
|
62
|
+
export interface RepoReadiness {
|
|
63
|
+
key: string;
|
|
64
|
+
entry: ReposEntryStatus | null;
|
|
65
|
+
problems: Array<RepoReadinessProblem>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The ways an entry's primary checkout isn't at rest where the registry puts
|
|
70
|
+
* it, from its `at_rest` facts: empty when it is.
|
|
71
|
+
*
|
|
72
|
+
* @param entry - the entry as `repos status --json` reported it
|
|
73
|
+
* @returns the problems, in a fixed order: unprobed alone, else branch, dirt, operation, then the followed branch's relation
|
|
74
|
+
*/
|
|
75
|
+
export const repo_readiness_at_rest = (entry: ReposEntryStatus): Array<RepoReadinessProblem> => {
|
|
76
|
+
const { at_rest, branch } = entry;
|
|
77
|
+
const primary = entry.checkouts[0];
|
|
78
|
+
if (at_rest === null || primary === undefined) {
|
|
79
|
+
return [{ kind: 'unprobed', detail: unprobed_detail(entry) }];
|
|
80
|
+
}
|
|
81
|
+
const problems: Array<RepoReadinessProblem> = [];
|
|
82
|
+
if (branch === null || at_rest.on_branch === null) {
|
|
83
|
+
problems.push({ kind: 'no_branch' });
|
|
84
|
+
} else if (!at_rest.on_branch) {
|
|
85
|
+
problems.push({ kind: 'off_branch', branch, head: primary.head });
|
|
86
|
+
}
|
|
87
|
+
if (!at_rest.clean) problems.push({ kind: 'dirty', uncommitted: primary.uncommitted });
|
|
88
|
+
if (!at_rest.idle) problems.push({ kind: 'in_progress', op: primary.in_progress });
|
|
89
|
+
if (branch !== null && at_rest.followed?.kind !== 'in_sync') {
|
|
90
|
+
problems.push({ kind: 'followed', branch, relation: at_rest.followed });
|
|
91
|
+
}
|
|
92
|
+
return problems;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The ways an entry isn't ready for `gitops_publish --wetrun`: not at rest
|
|
97
|
+
* (`repo_readiness_at_rest`) other than its followed branch being ahead of
|
|
98
|
+
* origin, its fetch failed, another live session works in its primary
|
|
99
|
+
* checkout, or it has a `needs_human` reason. Ahead is ready: origin's tip is
|
|
100
|
+
* an ancestor of the branch, so the plan misses nothing and `gro publish`'s
|
|
101
|
+
* push stays a fast-forward, pushing those commits with the release (see
|
|
102
|
+
* `format_readiness_ahead`). A reason restating
|
|
103
|
+
* an at-rest problem (the primary's operation or detached HEAD) is left out,
|
|
104
|
+
* and a `default_branch_*` reason stands in for the followed-branch problem
|
|
105
|
+
* it explains.
|
|
106
|
+
*
|
|
107
|
+
* @param entry - the entry as `repos status --fetch --json` reported it
|
|
108
|
+
* @returns the problems; empty when the entry is ready
|
|
109
|
+
*/
|
|
110
|
+
export const repo_readiness_for_publish = (
|
|
111
|
+
entry: ReposEntryStatus
|
|
112
|
+
): Array<RepoReadinessProblem> => {
|
|
113
|
+
let problems = repo_readiness_at_rest(entry).filter(
|
|
114
|
+
(p) => !(p.kind === 'followed' && p.relation?.kind === 'ahead')
|
|
115
|
+
);
|
|
116
|
+
if (entry.fetch_error !== null) {
|
|
117
|
+
problems.push({ kind: 'fetch_failed', failure: entry.fetch_error });
|
|
118
|
+
}
|
|
119
|
+
const primary = entry.checkouts[0];
|
|
120
|
+
if (primary && primary.busy.length > 0) {
|
|
121
|
+
problems.push({ kind: 'busy', sessions: primary.busy });
|
|
122
|
+
}
|
|
123
|
+
const has = (kind: RepoReadinessProblem['kind']): boolean =>
|
|
124
|
+
problems.some((p) => p.kind === kind);
|
|
125
|
+
const reasons = entry.needs_human.filter((reason) => {
|
|
126
|
+
switch (reason.kind) {
|
|
127
|
+
case 'operation_in_progress':
|
|
128
|
+
return !(reason.checkout === primary?.path && has('in_progress'));
|
|
129
|
+
case 'unexpected_detached':
|
|
130
|
+
return !(reason.checkout === primary?.path && has('off_branch'));
|
|
131
|
+
default:
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
if (
|
|
136
|
+
reasons.some(
|
|
137
|
+
(r) =>
|
|
138
|
+
r.kind === 'default_branch_missing' ||
|
|
139
|
+
r.kind === 'default_branch_no_upstream' ||
|
|
140
|
+
r.kind === 'default_branch_gone'
|
|
141
|
+
)
|
|
142
|
+
) {
|
|
143
|
+
problems = problems.filter((p) => p.kind !== 'followed');
|
|
144
|
+
}
|
|
145
|
+
for (const reason of reasons) problems.push({ kind: 'needs_human', reason });
|
|
146
|
+
return problems;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Options for the messages naming what's wrong and the fix. */
|
|
150
|
+
export interface RepoReadinessFormatOptions {
|
|
151
|
+
/** The `repos` invocation fixes name; `repos --registry <path>` when the run passed one. */
|
|
152
|
+
repos_command?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* What a readiness problem says is wrong with the repo keyed `key`, and the
|
|
157
|
+
* fix, when there is one to name.
|
|
158
|
+
*
|
|
159
|
+
* @param key - the repo's registry key
|
|
160
|
+
* @param problem - one of the repo's readiness problems
|
|
161
|
+
* @param options - the `repos` invocation fixes name
|
|
162
|
+
* @returns what's wrong, and the fix
|
|
163
|
+
*/
|
|
164
|
+
export const format_repo_readiness_problem = (
|
|
165
|
+
key: string,
|
|
166
|
+
problem: RepoReadinessProblem,
|
|
167
|
+
options: RepoReadinessFormatOptions = {}
|
|
168
|
+
): { what: string; fix: string } => {
|
|
169
|
+
const repos = options.repos_command ?? 'repos';
|
|
170
|
+
const status_fix = `\`${repos} status ${key}\` says what it found`;
|
|
171
|
+
switch (problem.kind) {
|
|
172
|
+
case 'unprobed':
|
|
173
|
+
return { what: `can't be read: ${problem.detail}`, fix: status_fix };
|
|
174
|
+
case 'no_branch':
|
|
175
|
+
return {
|
|
176
|
+
what: 'its registry entry follows no branch',
|
|
177
|
+
fix: 'declare its `branch` in repos.toml'
|
|
178
|
+
};
|
|
179
|
+
case 'off_branch': {
|
|
180
|
+
const { branch, head } = problem;
|
|
181
|
+
return {
|
|
182
|
+
what:
|
|
183
|
+
head.kind === 'branch'
|
|
184
|
+
? `on \`${head.name}\`, not \`${branch}\``
|
|
185
|
+
: `detached at ${head.commit.slice(0, 12)}, not on \`${branch}\``,
|
|
186
|
+
fix: `switch to \`${branch}\` once the work there is committed or stashed`
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
case 'dirty':
|
|
190
|
+
return {
|
|
191
|
+
what: `uncommitted changes (${format_uncommitted(problem.uncommitted)})`,
|
|
192
|
+
fix: 'commit, stash, or discard them (untracked files count)'
|
|
193
|
+
};
|
|
194
|
+
case 'in_progress':
|
|
195
|
+
return {
|
|
196
|
+
what: `${problem.op === null ? 'an operation' : `a ${OP_LABELS[problem.op]}`} is in progress`,
|
|
197
|
+
fix: problem.op === null ? 'finish or abort it' : OP_FIXES[problem.op]
|
|
198
|
+
};
|
|
199
|
+
case 'followed':
|
|
200
|
+
return format_followed(key, problem.branch, problem.relation, repos);
|
|
201
|
+
case 'fetch_failed':
|
|
202
|
+
return {
|
|
203
|
+
what: `fetching origin failed (${format_fetch_failure(problem.failure)})`,
|
|
204
|
+
fix: `fix the remote, then rerun (\`${repos} status --fetch ${key}\` retries the fetch)`
|
|
205
|
+
};
|
|
206
|
+
case 'busy':
|
|
207
|
+
return {
|
|
208
|
+
what: `another live Claude Code session works in its checkout (pid ${problem.sessions.map((s) => s.pid).join(', ')})`,
|
|
209
|
+
fix: 'wait for it to finish, or end it'
|
|
210
|
+
};
|
|
211
|
+
case 'needs_human':
|
|
212
|
+
return {
|
|
213
|
+
what: format_needs_human(problem.reason),
|
|
214
|
+
fix: `\`${repos} status ${key}\` names the fix`
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Checks a `repos status --fetch --json` report on the repos a real publish
|
|
221
|
+
* reads and writes: every key's entry ready (`repo_readiness_for_publish`),
|
|
222
|
+
* the report fetched, and busy detection available, since a live session it
|
|
223
|
+
* can't vouch for may be working in any checkout.
|
|
224
|
+
*
|
|
225
|
+
* @param options.report - the report, made with `--fetch`
|
|
226
|
+
* @param options.keys - the registry keys that must be ready
|
|
227
|
+
* @returns `ahead`, the ready repos whose followed branch is ahead of origin; and on failure a message naming each repo, what's wrong, and the fix, its `lines`, and every not-ready repo
|
|
228
|
+
*/
|
|
229
|
+
export const check_publish_readiness = (
|
|
230
|
+
options: {
|
|
231
|
+
report: ReposStatusReport;
|
|
232
|
+
keys: ReadonlyArray<string>;
|
|
233
|
+
} & RepoReadinessFormatOptions
|
|
234
|
+
): Result<
|
|
235
|
+
{ ahead: Array<ReadinessAhead> },
|
|
236
|
+
{
|
|
237
|
+
message: string;
|
|
238
|
+
lines: Array<string>;
|
|
239
|
+
not_ready: Array<RepoReadiness>;
|
|
240
|
+
ahead: Array<ReadinessAhead>;
|
|
241
|
+
}
|
|
242
|
+
> => {
|
|
243
|
+
const { report, keys, repos_command } = options;
|
|
244
|
+
const repos = repos_command ?? 'repos';
|
|
245
|
+
const by_key = new Map(report.entries.map((e) => [e.key, e] as const));
|
|
246
|
+
|
|
247
|
+
const lines: Array<string> = [];
|
|
248
|
+
if (!report.fetched) {
|
|
249
|
+
lines.push(`the report wasn't fetched, so "in sync" is only as fresh as the last fetch`);
|
|
250
|
+
}
|
|
251
|
+
if (report.sessions.kind === 'unavailable') {
|
|
252
|
+
lines.push(
|
|
253
|
+
`busy detection is unavailable (${format_unavailable(report.sessions.reason)}), so a live session can't be ruled out of any checkout — \`${repos} status\` says why`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const not_ready: Array<RepoReadiness> = [];
|
|
258
|
+
const ahead: Array<ReadinessAhead> = [];
|
|
259
|
+
for (const key of keys) {
|
|
260
|
+
const entry = by_key.get(key) ?? null;
|
|
261
|
+
const problems: Array<RepoReadinessProblem> = entry
|
|
262
|
+
? repo_readiness_for_publish(entry)
|
|
263
|
+
: [{ kind: 'unprobed', detail: 'not in the `repos status` report' }];
|
|
264
|
+
if (problems.length === 0) {
|
|
265
|
+
const followed = entry?.at_rest?.followed;
|
|
266
|
+
if (entry?.branch && followed?.kind === 'ahead') {
|
|
267
|
+
ahead.push({ key, branch: entry.branch, commits: followed.commits });
|
|
268
|
+
}
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
not_ready.push({ key, entry, problems });
|
|
272
|
+
for (const problem of problems) {
|
|
273
|
+
const { what, fix } = format_repo_readiness_problem(key, problem, options);
|
|
274
|
+
// a failed publish leaves dirt whose changesets committing or discarding would lose
|
|
275
|
+
const after_failure =
|
|
276
|
+
problem.kind === 'dirty'
|
|
277
|
+
? ' — after a failed publish, see the troubleshooting doc first'
|
|
278
|
+
: '';
|
|
279
|
+
lines.push(`${key}: ${what} — ${fix}${after_failure}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (lines.length === 0) return { ok: true, ahead };
|
|
284
|
+
const message =
|
|
285
|
+
'not publishing, and nothing was changed: the plan reads every npm repo as it sits, ' +
|
|
286
|
+
`and publishing commits and pushes there, so each must be ready:\n ${lines.join('\n ')}`;
|
|
287
|
+
return { ok: false, message, lines, not_ready, ahead };
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* An entry's problems for `gitops_sync`, split by what they do to the run:
|
|
292
|
+
* `refused` stops it, `warned` is logged. Off its branch, dirty, or mid-operation
|
|
293
|
+
* refuses, since the site would show that working tree's modules beside origin's
|
|
294
|
+
* CI — unless `allow_dirty`, which reads the repo as it sits and warns instead.
|
|
295
|
+
* A checkout that can't be read, or an entry following no branch (only a
|
|
296
|
+
* reference follows none, and the config refuses references), always
|
|
297
|
+
* refuses. The followed branch not in sync
|
|
298
|
+
* with origin (behind, say: CI is origin's tip, the modules the local tree) and
|
|
299
|
+
* a failed fetch (the last fetch's view stands) warn. Busy sessions and
|
|
300
|
+
* `needs_human` reasons don't matter: generating writes nothing in the repo.
|
|
301
|
+
*
|
|
302
|
+
* @param entry - the entry as `repos status --json` reported it
|
|
303
|
+
* @param options.allow_dirty - read a repo off its branch, dirty, or mid-operation as it sits
|
|
304
|
+
* @returns the problems that refuse, and those that warn
|
|
305
|
+
*/
|
|
306
|
+
export const repo_readiness_for_gen = (
|
|
307
|
+
entry: ReposEntryStatus,
|
|
308
|
+
options: { allow_dirty?: boolean } = {}
|
|
309
|
+
): { refused: Array<RepoReadinessProblem>; warned: Array<RepoReadinessProblem> } => {
|
|
310
|
+
const { allow_dirty = false } = options;
|
|
311
|
+
const refused: Array<RepoReadinessProblem> = [];
|
|
312
|
+
const warned: Array<RepoReadinessProblem> = [];
|
|
313
|
+
for (const problem of repo_readiness_at_rest(entry)) {
|
|
314
|
+
switch (problem.kind) {
|
|
315
|
+
case 'unprobed':
|
|
316
|
+
case 'no_branch':
|
|
317
|
+
refused.push(problem);
|
|
318
|
+
break;
|
|
319
|
+
case 'off_branch':
|
|
320
|
+
case 'dirty':
|
|
321
|
+
case 'in_progress':
|
|
322
|
+
(allow_dirty ? warned : refused).push(problem);
|
|
323
|
+
break;
|
|
324
|
+
default:
|
|
325
|
+
warned.push(problem);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (entry.fetch_error !== null) {
|
|
329
|
+
warned.push({ kind: 'fetch_failed', failure: entry.fetch_error });
|
|
330
|
+
}
|
|
331
|
+
return { refused, warned };
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Checks a `repos status --json` report on the repos `gitops_sync` generates
|
|
336
|
+
* the site data from (`repo_readiness_for_gen`).
|
|
337
|
+
*
|
|
338
|
+
* @param options.report - the report
|
|
339
|
+
* @param options.keys - the registry keys whose repos the site data reads
|
|
340
|
+
* @param options.allow_dirty - read repos off their branch, dirty, or mid-operation as they sit
|
|
341
|
+
* @param options.now - the current time in unix seconds, for how long ago each repo was fetched (defaults to the clock)
|
|
342
|
+
* @returns `warnings`, a line per problem that doesn't refuse; and on failure also a message naming each refused repo, what's wrong, and the fix, and its `lines`
|
|
343
|
+
*/
|
|
344
|
+
export const check_gen_readiness = (
|
|
345
|
+
options: {
|
|
346
|
+
report: ReposStatusReport;
|
|
347
|
+
keys: ReadonlyArray<string>;
|
|
348
|
+
allow_dirty?: boolean;
|
|
349
|
+
now?: number;
|
|
350
|
+
} & RepoReadinessFormatOptions
|
|
351
|
+
): Result<
|
|
352
|
+
{ warnings: Array<string> },
|
|
353
|
+
{ message: string; lines: Array<string>; warnings: Array<string> }
|
|
354
|
+
> => {
|
|
355
|
+
const { report, keys, allow_dirty = false, now = Math.floor(Date.now() / 1000) } = options;
|
|
356
|
+
const by_key = new Map(report.entries.map((e) => [e.key, e] as const));
|
|
357
|
+
|
|
358
|
+
const lines: Array<string> = [];
|
|
359
|
+
const warnings: Array<string> = [];
|
|
360
|
+
for (const key of keys) {
|
|
361
|
+
const entry = by_key.get(key);
|
|
362
|
+
const { refused, warned } = entry
|
|
363
|
+
? repo_readiness_for_gen(entry, { allow_dirty })
|
|
364
|
+
: {
|
|
365
|
+
refused: [{ kind: 'unprobed', detail: 'not in the `repos status` report' } as const],
|
|
366
|
+
warned: []
|
|
367
|
+
};
|
|
368
|
+
for (const problem of refused) {
|
|
369
|
+
const { what, fix } = format_repo_readiness_problem(key, problem, options);
|
|
370
|
+
lines.push(`${key}: ${what} — ${fix}`);
|
|
371
|
+
}
|
|
372
|
+
for (const problem of warned) {
|
|
373
|
+
const { what, fix } = format_repo_readiness_problem(key, problem, options);
|
|
374
|
+
warnings.push(`${key}: ${what}${format_as_of(entry, problem, now)} — ${fix}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (lines.length === 0) return { ok: true, warnings };
|
|
379
|
+
const message =
|
|
380
|
+
"not generating the site data: it pairs each repo's working tree with origin's CI, " +
|
|
381
|
+
'so each must be on its registry branch, clean, and idle — or pass `--allow_dirty` ' +
|
|
382
|
+
`to read them as they sit:\n ${lines.join('\n ')}`;
|
|
383
|
+
return { ok: false, message, lines, warnings };
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
/** A ready repo whose followed branch is ahead of origin: commits a publish pushes. */
|
|
387
|
+
export interface ReadinessAhead {
|
|
388
|
+
key: string;
|
|
389
|
+
branch: string;
|
|
390
|
+
commits: number;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Says what happens to a ready repo's commits ahead of origin: its release
|
|
395
|
+
* push carries them when it publishes, else they stay unpushed.
|
|
396
|
+
*
|
|
397
|
+
* @param ahead - the repo, its branch, and how many commits it's ahead
|
|
398
|
+
* @param publishes - whether the plan publishes it
|
|
399
|
+
* @param options - the `repos` invocation the message names
|
|
400
|
+
* @returns the line to log
|
|
401
|
+
*/
|
|
402
|
+
export const format_readiness_ahead = (
|
|
403
|
+
ahead: ReadinessAhead,
|
|
404
|
+
publishes: boolean,
|
|
405
|
+
options: RepoReadinessFormatOptions = {}
|
|
406
|
+
): string => {
|
|
407
|
+
const repos = options.repos_command ?? 'repos';
|
|
408
|
+
const head = `${ahead.key}: \`${ahead.branch}\` is ${plural(ahead.commits, 'commit')} ahead of origin`;
|
|
409
|
+
return publishes
|
|
410
|
+
? `${head} — publishing pushes them with the release`
|
|
411
|
+
: `${head} — it doesn't publish, so they stay unpushed until \`${repos} sync\` or \`${repos} push\``;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The repos not at rest, for the read-only diagnostics' readiness block.
|
|
416
|
+
*
|
|
417
|
+
* @param entries - the entries of the repos a diagnostic reads
|
|
418
|
+
* @returns each entry with at-rest problems, in the order given
|
|
419
|
+
*/
|
|
420
|
+
export const repos_not_at_rest = (entries: ReadonlyArray<ReposEntryStatus>): Array<RepoReadiness> =>
|
|
421
|
+
entries
|
|
422
|
+
.map((entry) => ({ key: entry.key, entry, problems: repo_readiness_at_rest(entry) }))
|
|
423
|
+
.filter((r) => r.problems.length > 0);
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Formats the diagnostics' readiness block: a header and a line per problem,
|
|
427
|
+
* saying what each repo not at rest is doing instead — or no lines when every
|
|
428
|
+
* repo is at rest. Not a failure: the diagnostics read repos as they sit.
|
|
429
|
+
*
|
|
430
|
+
* @param not_ready - `repos_not_at_rest`'s result
|
|
431
|
+
* @param now - the current time in unix seconds, for how long ago each repo was fetched
|
|
432
|
+
* @returns the block's lines, unindented header first
|
|
433
|
+
*/
|
|
434
|
+
export const format_readiness_block = (
|
|
435
|
+
not_ready: ReadonlyArray<RepoReadiness>,
|
|
436
|
+
now: number
|
|
437
|
+
): Array<string> => {
|
|
438
|
+
if (not_ready.length === 0) return [];
|
|
439
|
+
const lines = [
|
|
440
|
+
'not at rest, so read as they sit (a real publish refuses all but a branch ahead of origin):'
|
|
441
|
+
];
|
|
442
|
+
for (const { key, entry, problems } of not_ready) {
|
|
443
|
+
for (const problem of problems) {
|
|
444
|
+
const { what } = format_repo_readiness_problem(key, problem);
|
|
445
|
+
lines.push(` ${key}: ${what}${format_as_of(entry, problem, now)}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return lines;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const OP_LABELS: Record<ReposInProgressOp, string> = {
|
|
452
|
+
rebase: 'rebase',
|
|
453
|
+
merge: 'merge',
|
|
454
|
+
cherry_pick: 'cherry-pick',
|
|
455
|
+
revert: 'revert',
|
|
456
|
+
bisect: 'bisect',
|
|
457
|
+
sequencer: 'cherry-pick or revert sequence',
|
|
458
|
+
am: '`git am`'
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const OP_FIXES: Record<ReposInProgressOp, string> = {
|
|
462
|
+
rebase: 'finish or abort it (`git rebase --continue` or `--abort`)',
|
|
463
|
+
merge: 'finish or abort it (`git merge --continue` or `--abort`)',
|
|
464
|
+
cherry_pick: 'finish or abort it (`git cherry-pick --continue` or `--abort`)',
|
|
465
|
+
revert: 'finish or abort it (`git revert --continue` or `--abort`)',
|
|
466
|
+
bisect: 'end it (`git bisect reset`)',
|
|
467
|
+
sequencer: 'finish or abort it (`git cherry-pick` or `git revert`, `--continue` or `--abort`)',
|
|
468
|
+
am: 'finish or abort it (`git am --continue` or `--abort`)'
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const format_followed = (
|
|
472
|
+
key: string,
|
|
473
|
+
branch: string,
|
|
474
|
+
relation: ReposRelation | null,
|
|
475
|
+
repos: string
|
|
476
|
+
): { what: string; fix: string } => {
|
|
477
|
+
const b = `\`${branch}\``;
|
|
478
|
+
if (relation === null) {
|
|
479
|
+
return {
|
|
480
|
+
what: `${b} isn't compared with origin`,
|
|
481
|
+
fix: `\`${repos} status ${key}\` says why`
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
switch (relation.kind) {
|
|
485
|
+
case 'in_sync':
|
|
486
|
+
return { what: `${b} is in sync with origin`, fix: 'nothing' };
|
|
487
|
+
case 'ahead':
|
|
488
|
+
return {
|
|
489
|
+
what: `${b} is ${plural(relation.commits, 'commit')} ahead of origin (unpushed)`,
|
|
490
|
+
fix: `\`${repos} sync ${key}\` pushes it`
|
|
491
|
+
};
|
|
492
|
+
case 'behind':
|
|
493
|
+
return {
|
|
494
|
+
what: `${b} is ${plural(relation.commits, 'commit')} behind origin`,
|
|
495
|
+
fix: `\`${repos} sync ${key}\` fast-forwards it`
|
|
496
|
+
};
|
|
497
|
+
case 'diverged':
|
|
498
|
+
return {
|
|
499
|
+
what: `${b} has diverged from origin (${relation.ahead} ahead, ${relation.behind} behind)`,
|
|
500
|
+
fix: 'rebase or merge it by hand, then push'
|
|
501
|
+
};
|
|
502
|
+
case 'shallow':
|
|
503
|
+
return {
|
|
504
|
+
what: `${b} is shallow and not at origin's tip`,
|
|
505
|
+
fix: `\`${repos} sync ${key}\` moves it`
|
|
506
|
+
};
|
|
507
|
+
case 'gone':
|
|
508
|
+
return {
|
|
509
|
+
what: `${b} tracks an upstream gone from origin`,
|
|
510
|
+
fix: 'repoint its upstream by hand'
|
|
511
|
+
};
|
|
512
|
+
case 'unmapped':
|
|
513
|
+
return {
|
|
514
|
+
what: `${b} tracks an upstream origin's fetch refspec doesn't map`,
|
|
515
|
+
fix: "fix origin's fetch refspec by hand"
|
|
516
|
+
};
|
|
517
|
+
case 'untracked':
|
|
518
|
+
return {
|
|
519
|
+
what: `${b} tracks no upstream on origin`,
|
|
520
|
+
fix: `set it (\`git branch -u origin/${branch} ${branch}\`)`
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const format_needs_human = (reason: ReposNeedsHuman): string => {
|
|
526
|
+
switch (reason.kind) {
|
|
527
|
+
case 'not_a_repo':
|
|
528
|
+
return `isn't a repo: ${reason.detail}`;
|
|
529
|
+
case 'operation_in_progress':
|
|
530
|
+
return `a ${OP_LABELS[reason.op]} is in progress in ${reason.checkout}`;
|
|
531
|
+
case 'origin_mismatch':
|
|
532
|
+
return `origin isn't the registry's repo (${reason.expected})`;
|
|
533
|
+
case 'origin_not_https':
|
|
534
|
+
return `origin isn't the registry's repo over HTTPS (${reason.expected})`;
|
|
535
|
+
case 'fetch_url_mismatch':
|
|
536
|
+
return `origin's fetch reaches ${reason.fetch_url}, not ${reason.expected}`;
|
|
537
|
+
case 'worktree_unreadable':
|
|
538
|
+
return `a worktree's git dir can't be read (${reason.path})`;
|
|
539
|
+
case 'default_branch_missing':
|
|
540
|
+
return `has no local \`${reason.branch}\`, the branch its entry follows`;
|
|
541
|
+
case 'default_branch_no_upstream':
|
|
542
|
+
return `\`${reason.branch}\` tracks no upstream on origin`;
|
|
543
|
+
case 'default_branch_gone':
|
|
544
|
+
return `\`${reason.branch}\`'s upstream is gone from origin`;
|
|
545
|
+
case 'unexpected_detached':
|
|
546
|
+
return `${reason.checkout} is detached`;
|
|
547
|
+
case 'checkout_unresolvable':
|
|
548
|
+
return `a checkout's path can't be resolved (${reason.path}: ${reason.error}), so a session there can't be ruled out`;
|
|
549
|
+
case 'unlisted_git_dir':
|
|
550
|
+
return `a git dir no worktree list names shares its refs (${reason.git_dir})`;
|
|
551
|
+
case 'push_url_mismatch':
|
|
552
|
+
return `origin's push URL isn't the registry's repo (${reason.expected})`;
|
|
553
|
+
case 'clone_shares_repo':
|
|
554
|
+
return `shares its repo with \`${reason.with}\``;
|
|
555
|
+
case 'cloned_unregistered':
|
|
556
|
+
return `an unregistered dir clones it (${reason.dir})`;
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// worded as Rust's `RemoteFailure::words` words it with its detail, so the
|
|
561
|
+
// TS messages and `repos status` say the same
|
|
562
|
+
const format_fetch_failure = (failure: ReposFetchFailure): string => {
|
|
563
|
+
switch (failure.kind) {
|
|
564
|
+
case 'ref_gone':
|
|
565
|
+
return `origin has no ${failure.refname}`;
|
|
566
|
+
case 'unreachable':
|
|
567
|
+
return `${UNREACHABLE_WORDS[failure.cause]} — ${failure.message}`;
|
|
568
|
+
case 'repo_not_found':
|
|
569
|
+
return `repo not found — ${failure.message}`;
|
|
570
|
+
case 'timed_out':
|
|
571
|
+
return `timed out after ${failure.after_secs}s`;
|
|
572
|
+
case 'failed':
|
|
573
|
+
return failure.message;
|
|
574
|
+
case 'refspec_outside_origin':
|
|
575
|
+
return `not run — refspec ${failure.refspec} writes outside refs/remotes/origin/`;
|
|
576
|
+
case 'origin_refs_shared':
|
|
577
|
+
return `not run — remote ${failure.remote}'s refspec ${failure.refspec} can write under refs/remotes/origin/, which pruning origin may empty`;
|
|
578
|
+
case 'legacy_remotes_unreadable':
|
|
579
|
+
return `not run — the legacy remote ${failure.path} couldn't be read, and may share origin's refs`;
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const UNREACHABLE_WORDS: Record<ReposUnreachableCause, string> = {
|
|
584
|
+
dns: 'host not found',
|
|
585
|
+
connection: 'no connection',
|
|
586
|
+
host_key: 'host not trusted',
|
|
587
|
+
auth: 'access denied'
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const format_unavailable = (reason: ReposUnavailable): string => {
|
|
591
|
+
switch (reason.kind) {
|
|
592
|
+
case 'home_unknown':
|
|
593
|
+
return 'HOME is unset';
|
|
594
|
+
case 'relative_config_dir':
|
|
595
|
+
return `a relative CLAUDE_CONFIG_DIR, ${reason.path}`;
|
|
596
|
+
case 'unreadable':
|
|
597
|
+
case 'unparseable':
|
|
598
|
+
return `${reason.kind}: ${reason.path}: ${reason.error}`;
|
|
599
|
+
case 'foreign_pid_domain':
|
|
600
|
+
return `a session from another machine or pid namespace: ${reason.path}`;
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const unprobed_detail = (entry: ReposEntryStatus): string => {
|
|
605
|
+
if (entry.probe_error !== null) return `probing failed: ${entry.probe_error.message}`;
|
|
606
|
+
switch (entry.presence.kind) {
|
|
607
|
+
case 'missing':
|
|
608
|
+
return 'missing';
|
|
609
|
+
case 'not_a_repo':
|
|
610
|
+
return 'not a git repo';
|
|
611
|
+
case 'present':
|
|
612
|
+
return 'no checkout was probed';
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const format_uncommitted = (u: ReposUncommitted): string =>
|
|
617
|
+
(
|
|
618
|
+
[
|
|
619
|
+
['staged', u.staged],
|
|
620
|
+
['unstaged', u.unstaged],
|
|
621
|
+
['untracked', u.untracked],
|
|
622
|
+
['conflicted', u.conflicted]
|
|
623
|
+
] as const
|
|
624
|
+
)
|
|
625
|
+
.filter(([, n]) => n > 0)
|
|
626
|
+
.map(([label, n]) => `${n} ${label}`)
|
|
627
|
+
.join(', ');
|
|
628
|
+
|
|
629
|
+
const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`;
|
|
630
|
+
|
|
631
|
+
// relations are as of the remote-tracking refs, so a followed-branch problem says how old they are
|
|
632
|
+
const format_as_of = (
|
|
633
|
+
entry: ReposEntryStatus | null | undefined,
|
|
634
|
+
problem: RepoReadinessProblem,
|
|
635
|
+
now: number
|
|
636
|
+
): string => (problem.kind === 'followed' && entry ? ` (${format_fetched_at(entry, now)})` : '');
|
|
637
|
+
|
|
638
|
+
const format_fetched_at = (entry: ReposEntryStatus, now: number): string =>
|
|
639
|
+
entry.fetched_at === null ? 'never fetched' : `fetched ${format_age(now - entry.fetched_at)} ago`;
|
|
640
|
+
|
|
641
|
+
const format_age = (seconds: number): string => {
|
|
642
|
+
const s = Math.max(0, seconds);
|
|
643
|
+
if (s < 60) return 'under a minute';
|
|
644
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
645
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
646
|
+
return `${Math.floor(s / 86400)}d`;
|
|
647
|
+
};
|