@leftoff/cli 0.8.8 → 0.8.9
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/CHANGELOG.md +11 -0
- package/bin/leftoff.js +287 -286
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +102 -16
- package/ui/app.js +106 -15
- package/ui/changelog-md.js +6 -2
- package/ui/handoffs.js +62 -5
- package/ui/icons.js +2 -0
- package/ui/prs.js +81 -37
package/ui/prs.js
CHANGED
|
@@ -89,11 +89,13 @@ function sessionLinks(r, opts) {
|
|
|
89
89
|
|
|
90
90
|
// One PR row on the PRs tab. 0.8.5: tap the row to expand it in place
|
|
91
91
|
// (`open`, with `detail` as /v1/prs/detail answered: { loading } | { ok, detail } | { ok: false, code }).
|
|
92
|
-
export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null } = {}) {
|
|
92
|
+
export function prRowHtml(r, { team = false, now = Date.now(), mayEdit = true, sessionAction = null, showMachine = false, open = false, detail = null, me = null } = {}) {
|
|
93
93
|
const p = r.pr || {}
|
|
94
94
|
const url = safeUrl(p.url)
|
|
95
|
+
// 0.8.9: what the checks and review chips don't already say (a conflict, your review)
|
|
96
|
+
const bwhy = r.bucketWhy !== undefined ? r.bucketWhy : prBucket(p, me).why
|
|
95
97
|
const line1 = '<span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) +
|
|
96
|
-
(
|
|
98
|
+
(bwhy === 'Merge conflict' || bwhy === 'Your review' ? '<span class="pr-att">' + esc(bwhy) + '</span>' : p.review && REVIEW[p.review] && (p.state === 'open' || p.state === 'draft') ? '<span class="pr-review ' + esc(p.review) + '">' + REVIEW[p.review] + '</span>' : '')
|
|
97
99
|
const when = p.state === 'merged' && p.mergedAt ? 'merged ' + shortAgo(p.mergedAt, now) + ' ago' : p.updatedAt ? 'updated ' + shortAgo(p.updatedAt, now) + ' ago' : ''
|
|
98
100
|
const meta = team
|
|
99
101
|
? [p.repo, p.author ? '@' + p.author : '', p.branch || '', when].filter(Boolean)
|
|
@@ -134,7 +136,7 @@ export function prDetailHtml(p, st, { now = Date.now() } = {}) {
|
|
|
134
136
|
return '<li class="' + esc(c.state) + '"><span class="pd-ic" aria-hidden="true">' + (c.state === 'failed' ? '✕' : '◌') + '</span>' + (u ? '<a href="' + esc(u) + '" target="_blank" rel="noopener noreferrer">' + name + ' ↗</a>' : name) + '<span class="pd-st">' + (c.state === 'failed' ? 'failed' : 'running') + '</span></li>'
|
|
135
137
|
}).join('') + '</ul>' : '') + (passed ? '<div class="pd-pass">✓ ' + passed + (bad.length ? ' passed' : passed === 1 ? ' check passed' : ' checks passed') + '</div>' : '') + '</div>'
|
|
136
138
|
}
|
|
137
|
-
const rv = REVIEW[d.review] || (d.state === 'open' ? 'No review yet' : '')
|
|
139
|
+
const rv = REVIEW[d.review] || (d.review === 'none' && d.state === 'open' ? 'No review required' : d.state === 'open' ? 'No review yet' : '')
|
|
138
140
|
const who = (d.reviews || []).map((r) => esc('@' + r.author) + ' ' + esc(REVIEW_STATE[r.state] || r.state))
|
|
139
141
|
if (rv || who.length || (d.reviewRequested || []).length) {
|
|
140
142
|
h += '<div class="pd-sec"><div class="pd-h">Review</div><div class="pd-review">' + (rv ? '<span class="pr-review ' + esc(d.review || '') + '">' + esc(rv) + '</span>' : '') +
|
|
@@ -154,20 +156,63 @@ export function prDetailHtml(p, st, { now = Date.now() } = {}) {
|
|
|
154
156
|
return h + '</div>'
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
// ---------------------------------------------------------------- 0.8.9: buckets
|
|
160
|
+
|
|
161
|
+
// What an open PR is waiting on, in one of four buckets (idea: "merge ready /
|
|
162
|
+
// needs fix / waiting on others"), from the fields gh already gives us:
|
|
163
|
+
// review Your review: you're a requested reviewer and it isn't yours
|
|
164
|
+
// fix Needs fix: a check failing, changes requested, or a merge conflict
|
|
165
|
+
// ready Merge ready: not a draft, approved or no review required, every
|
|
166
|
+
// check passed (or none), and GitHub says it merges cleanly
|
|
167
|
+
// waiting Waiting on others: everything else (review pending, checks
|
|
168
|
+
// running, a draft, or anything we can't tell yet)
|
|
169
|
+
// Anything unknown (no mergeable from an older daemon, GitHub still working
|
|
170
|
+
// it out, no check list) lands in waiting, never in ready. Merged and closed
|
|
171
|
+
// PRs keep their state. -> { bucket, why }
|
|
172
|
+
export const PR_BUCKETS = [['review', 'Your review'], ['fix', 'Needs fix'], ['ready', 'Merge ready'], ['waiting', 'Waiting on others'], ['merged', 'Merged'], ['closed', 'Closed']]
|
|
173
|
+
const sameUser = (a, b) => !!a && !!b && String(a).toLowerCase() === String(b).toLowerCase()
|
|
174
|
+
export function prBucket(pr, me = null) {
|
|
175
|
+
if (!pr || typeof pr !== 'object') return { bucket: 'waiting', why: null }
|
|
176
|
+
if (pr.state === 'merged') return { bucket: 'merged', why: null }
|
|
177
|
+
if (pr.state === 'closed') return { bucket: 'closed', why: null }
|
|
178
|
+
const ck = pr.checks && typeof pr.checks === 'object' ? pr.checks : null
|
|
179
|
+
if (me && !sameUser(pr.author, me) && (pr.reviewRequested || []).some((x) => sameUser(x, me))) return { bucket: 'review', why: 'Your review' }
|
|
180
|
+
if (ck && ck.failed > 0) return { bucket: 'fix', why: 'Checks failing' }
|
|
181
|
+
if (pr.review === 'changes_requested') return { bucket: 'fix', why: 'Changes requested' }
|
|
182
|
+
if (pr.mergeable === 'conflicting') return { bucket: 'fix', why: 'Merge conflict' }
|
|
183
|
+
if (pr.state === 'draft') return { bucket: 'waiting', why: 'Draft' }
|
|
184
|
+
if (pr.state !== 'open') return { bucket: 'waiting', why: null }
|
|
185
|
+
if (!ck || ck.unknown || !Number.isFinite(ck.total)) return { bucket: 'waiting', why: null }
|
|
186
|
+
if (ck.pending > 0) return { bucket: 'waiting', why: 'Checks running' }
|
|
187
|
+
if (pr.review === 'review_required') return { bucket: 'waiting', why: 'Review pending' }
|
|
188
|
+
if (pr.review !== 'approved' && pr.review !== 'none') return { bucket: 'waiting', why: null }
|
|
189
|
+
if (pr.mergeable !== 'mergeable') return { bucket: 'waiting', why: null }
|
|
190
|
+
return { bucket: 'ready', why: null }
|
|
191
|
+
}
|
|
192
|
+
// A row's bucket: what the daemon worked out, else (an older daemon) worked out here.
|
|
193
|
+
export function bucketOf(r, me = null) {
|
|
194
|
+
if (r && PR_BUCKETS.some(([k]) => k === r.bucket)) return r.bucket
|
|
195
|
+
return prBucket(r && r.pr, me).bucket
|
|
196
|
+
}
|
|
197
|
+
|
|
157
198
|
// ---------------------------------------------------------------- 0.8.5: filters
|
|
158
199
|
|
|
159
|
-
// The page's filter, from the hash (#/prs?view=team&repo=web&state=
|
|
160
|
-
|
|
161
|
-
|
|
200
|
+
// The page's filter, from the hash (#/prs?view=team&repo=web&state=ready&q=search).
|
|
201
|
+
// 0.8.9: `state` is a bucket (PR_BUCKETS). Links from 0.8.5 still open:
|
|
202
|
+
// state=attention or checks=failing -> Needs fix, state=draft -> Waiting on
|
|
203
|
+
// others, state=open and checks=passing -> no filter.
|
|
204
|
+
export const PR_STATES = PR_BUCKETS
|
|
162
205
|
export const QUIET_DAYS = 14
|
|
206
|
+
const LEGACY_STATE = { attention: 'fix', draft: 'waiting' }
|
|
163
207
|
const splitList = (v) => String(v || '').split(',').map((x) => x.trim()).filter(Boolean)
|
|
164
208
|
export function readPrFilter(params) {
|
|
165
209
|
const p = typeof params === 'string' ? new URLSearchParams(params.replace(/^.*\?/, '')) : (params || new URLSearchParams())
|
|
166
|
-
|
|
210
|
+
let state = p.get('state')
|
|
211
|
+
state = PR_BUCKETS.some(([k]) => k === state) ? state : LEGACY_STATE[state] || null
|
|
212
|
+
if (!state && p.get('checks') === 'failing') state = 'fix'
|
|
167
213
|
return {
|
|
168
214
|
repos: [...new Set(p.getAll('repo').flatMap(splitList))],
|
|
169
|
-
state
|
|
170
|
-
checks: PR_CHECKS.some(([k]) => k === checks) ? checks : null,
|
|
215
|
+
state,
|
|
171
216
|
q: String(p.get('q') || '').slice(0, 200),
|
|
172
217
|
}
|
|
173
218
|
}
|
|
@@ -176,11 +221,10 @@ export function prsHash(view, f = {}) {
|
|
|
176
221
|
if (view === 'team') parts.push('view=team')
|
|
177
222
|
if (f.repos && f.repos.length) parts.push('repo=' + f.repos.map((r) => encodeURIComponent(r).replace(/%2F/gi, '/')).join(','))
|
|
178
223
|
if (f.state) parts.push('state=' + f.state)
|
|
179
|
-
if (f.checks) parts.push('checks=' + f.checks)
|
|
180
224
|
if (f.q && f.q.trim()) parts.push('q=' + encodeURIComponent(f.q.trim()))
|
|
181
225
|
return '#/prs' + (parts.length ? '?' + parts.join('&') : '')
|
|
182
226
|
}
|
|
183
|
-
export const prFilterActive = (f) => !!(f && ((f.repos && f.repos.length) || f.state ||
|
|
227
|
+
export const prFilterActive = (f) => !!(f && ((f.repos && f.repos.length) || f.state || (f.q && f.q.trim())))
|
|
184
228
|
|
|
185
229
|
// Every row, flat: the daemon's `rows`, or an older daemon's groups.
|
|
186
230
|
export function prRows(d) {
|
|
@@ -192,13 +236,8 @@ export function prRows(d) {
|
|
|
192
236
|
}
|
|
193
237
|
const lastAt = (pr) => Math.max(Date.parse(pr.updatedAt || '') || 0, Date.parse(pr.mergedAt || '') || 0)
|
|
194
238
|
export const isQuiet = (r, now = Date.now()) => { const t = lastAt(r.pr || {}); return !!t && now - t >= QUIET_DAYS * 864e5 }
|
|
195
|
-
export function prStateOf(r) { return
|
|
196
|
-
export function inPrState(r, state) { return !state || (
|
|
197
|
-
export function inPrChecks(r, checks) {
|
|
198
|
-
const c = r.pr.checks || {}
|
|
199
|
-
if (!checks) return true
|
|
200
|
-
return checks === 'failing' ? c.failed > 0 : c.total > 0 && !c.failed && !c.pending
|
|
201
|
-
}
|
|
239
|
+
export function prStateOf(r, me = null) { return bucketOf(r, me) }
|
|
240
|
+
export function inPrState(r, state, me = null) { return !state || bucketOf(r, me) === state }
|
|
202
241
|
export function inPrSearch(r, q) {
|
|
203
242
|
const words = String(q || '').toLowerCase().split(/\s+/).filter(Boolean)
|
|
204
243
|
if (!words.length) return true
|
|
@@ -215,17 +254,18 @@ export function inPrRepos(r, repos, labels = null) {
|
|
|
215
254
|
const key = r.pr.repo || 'no-repo'
|
|
216
255
|
return repos.some((t) => repoMatches({ repoKey: key, repoLabel: labels ? labels.get(key) : null }, t))
|
|
217
256
|
}
|
|
218
|
-
export function filterPrRows(rows, f = {}, { skip = null } = {}) {
|
|
257
|
+
export function filterPrRows(rows, f = {}, { skip = null, me = null } = {}) {
|
|
219
258
|
const labels = labelRepos(rows.map((r) => r.pr.repo || 'no-repo'))
|
|
220
|
-
return rows.filter((r) => (skip === 'repos' || inPrRepos(r, f.repos, labels)) && (skip === 'state' || inPrState(r, f.state
|
|
259
|
+
return rows.filter((r) => (skip === 'repos' || inPrRepos(r, f.repos, labels)) && (skip === 'state' || inPrState(r, f.state, me)) && inPrSearch(r, f.q))
|
|
221
260
|
}
|
|
222
|
-
// Groups, newest first in each; anything with no
|
|
223
|
-
|
|
224
|
-
|
|
261
|
+
// Groups (0.8.9: the buckets), newest first in each; anything with no
|
|
262
|
+
// activity for 14+ days in Quiet.
|
|
263
|
+
export function groupPrRows(rows, { now = Date.now(), me = null } = {}) {
|
|
264
|
+
const g = Object.fromEntries(PR_BUCKETS.map(([k]) => [k, []]))
|
|
225
265
|
const quiet = []
|
|
226
|
-
for (const r of rows) { if (isQuiet(r, now)) quiet.push(r); else
|
|
266
|
+
for (const r of rows) { if (isQuiet(r, now)) quiet.push(r); else g[bucketOf(r, me)].push(r) }
|
|
227
267
|
const by = (a, b) => lastAt(b.pr) - lastAt(a.pr)
|
|
228
|
-
const out =
|
|
268
|
+
const out = PR_BUCKETS.map(([key, label]) => ({ key, label, rows: g[key].sort(by) })).filter((x) => x.rows.length)
|
|
229
269
|
return { groups: out, quiet: quiet.sort(by) }
|
|
230
270
|
}
|
|
231
271
|
|
|
@@ -238,12 +278,13 @@ function fchip({ on, count, label, attrs, cls = '' }) {
|
|
|
238
278
|
|
|
239
279
|
// The chip rows and the grouped list (repainted as you type in the search box).
|
|
240
280
|
export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true, sessionAction = null, filter = {}, open = null, details = null, quietOpen = false } = {}) {
|
|
241
|
-
const f = { repos: [], state: null,
|
|
281
|
+
const f = { repos: [], state: null, q: '', ...filter }
|
|
242
282
|
const all = prRows(d)
|
|
283
|
+
const me = d.me || null
|
|
243
284
|
let h = ''
|
|
244
285
|
if (all.length) {
|
|
245
286
|
// Repos: All, then by latest activity; a chosen one always shows, first and removable.
|
|
246
|
-
const repoCount = filterPrRows(all, f, { skip: 'repos' })
|
|
287
|
+
const repoCount = filterPrRows(all, f, { skip: 'repos', me })
|
|
247
288
|
let chips = prRepoChips(all, { countIn: repoCount })
|
|
248
289
|
for (const tok of f.repos) if (!chips.some((c) => chipSelected([tok], c))) chips.push({ key: tok, label: tok, count: 0, total: 0, last: 0 })
|
|
249
290
|
chips = orderSelectedFirst(chips, f.repos)
|
|
@@ -251,23 +292,26 @@ export function prsBodyHtml(d, { view = 'mine', now = Date.now(), mayEdit = true
|
|
|
251
292
|
h += chipRow('Filter by repo', [fchip({ on: !f.repos.length, count: repoCount.length, label: 'All', cls: 'all', attrs: 'data-action="prs-filter" data-k="repo" data-v=""' })]
|
|
252
293
|
.concat(chips.map((c) => fchip({ on: chipSelected(f.repos, c), count: c.count, label: c.label, attrs: 'data-action="prs-filter" data-k="repo" data-v="' + esc(c.label) + '" data-key="' + esc(c.key) + '" title="' + esc(c.key + ' · shift- or ⌘-click to pick several') + '"' }))), 'prs-repos')
|
|
253
294
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
|
|
295
|
+
// 0.8.9: one row of buckets. The four open ones always show (dimmed at 0,
|
|
296
|
+
// after the rest), Merged and Closed only when there are some; a chosen
|
|
297
|
+
// one comes first.
|
|
298
|
+
const stCount = filterPrRows(all, f, { skip: 'state', me })
|
|
299
|
+
const bs = PR_BUCKETS.map(([k, l]) => ({ v: k, l, n: stCount.filter((r) => bucketOf(r, me) === k).length, on: f.state === k }))
|
|
300
|
+
const shown = bs.filter((x) => !x.on && (x.n || ['review', 'fix', 'ready', 'waiting'].includes(x.v)) && !(x.v === 'review' && !x.n && !me))
|
|
301
|
+
// the ones with PRs lead, so on a phone the empty ones are what scrolls off
|
|
302
|
+
const ordered = [...bs.filter((x) => x.on), ...shown.filter((x) => x.n), ...shown.filter((x) => !x.n)]
|
|
303
|
+
h += chipRow('Filter by what each PR is waiting on', ordered.map((x) => fchip({ on: x.on, count: x.n, label: x.l, cls: 'b-' + x.v, attrs: 'data-action="prs-filter" data-k="state" data-v="' + x.v + '"' })), 'prs-states prs-buckets')
|
|
260
304
|
}
|
|
261
|
-
const rows = filterPrRows(all, f)
|
|
262
|
-
const { groups, quiet } = groupPrRows(rows, { now })
|
|
305
|
+
const rows = filterPrRows(all, f, { me })
|
|
306
|
+
const { groups, quiet } = groupPrRows(rows, { now, me })
|
|
263
307
|
const showMachine = !!d.multiMachine
|
|
264
|
-
const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null })
|
|
308
|
+
const rowOpts = (r) => ({ team: view === 'team', now, mayEdit, sessionAction, showMachine, me, open: !!(open && open.has(r.pr.url)), detail: details ? details[r.pr.url] : null })
|
|
265
309
|
if (!all.length) {
|
|
266
310
|
h += '<div class="empty">' + (d.loading ? 'Loading pull requests…' : view === 'team' ? (d.repos && d.repos.length ? 'No open PRs in ' + esc(d.repos.join(', ')) + '.' : 'None of your sessions are in a GitHub repo yet.') : 'No PRs linked to your sessions yet. They link on their own when a session runs gh pr create, works on a branch with an open PR, or you paste a PR’s link in it.') + '</div>'
|
|
267
311
|
} else if (!rows.length) {
|
|
268
312
|
h += '<div class="empty">No PRs match. <button type="button" class="linkish" data-action="prs-clear">Clear filters</button></div>'
|
|
269
313
|
} else {
|
|
270
|
-
for (const g of groups) h += '<section class="prgroup' + (g.key === '
|
|
314
|
+
for (const g of groups) h += '<section class="prgroup b-' + g.key + (g.key === 'fix' || g.key === 'review' ? ' attention' : '') + '" data-group="' + g.key + '"><h2>' + esc(g.label) + ' <span class="count">' + g.rows.length + '</span></h2>' + g.rows.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</section>'
|
|
271
315
|
if (quiet.length) h += '<details class="prgroup quiet" data-group="quiet"' + (quietOpen || !groups.length ? ' open' : '') + '><summary><h2>Quiet <span class="count">' + quiet.length + '</span> <span class="faint qhint">no activity for ' + QUIET_DAYS + '+ days</span></h2></summary>' + quiet.map((r) => prRowHtml(r, rowOpts(r))).join('') + '</details>'
|
|
272
316
|
}
|
|
273
317
|
if (view === 'team' && (d.failed || []).length) h += '<div class="hint">Couldn’t load ' + esc(d.failed.join(', ')) + ' right now.</div>'
|