-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathstack.js
More file actions
183 lines (176 loc) · 6.79 KB
/
Copy pathstack.js
File metadata and controls
183 lines (176 loc) · 6.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
// Pure PR-stack helpers shared by the review feed and Sources visualization.
// Stack metadata is intentionally additive: older contribution records remain
// standalone, while a complete plan.stack object opts into ordered rendering
// and the batch submit endpoint.
export function stackMeta(rec) {
const stack = rec?.plan?.stack
if (!stack || typeof stack !== 'object') return null
const id = typeof stack.id === 'string' ? stack.id.trim() : ''
const position = Number(stack.position)
const total = Number(stack.total)
const baseBranch = typeof stack.base_branch === 'string'
? stack.base_branch.trim()
: ''
if (!id || !Number.isInteger(position) || !Number.isInteger(total)) return null
if (total < 2 || position < 1 || position > total || !baseBranch) return null
return {
id,
name: typeof stack.name === 'string' && stack.name.trim()
? stack.name.trim()
: id,
position,
total,
baseBranch,
parentRecordId: typeof stack.parent_record_id === 'string'
? stack.parent_record_id
: '',
}
}
export function sortStackRecords(records) {
return [...(records || [])].sort((a, b) => {
const left = stackMeta(a)?.position || Number.MAX_SAFE_INTEGER
const right = stackMeta(b)?.position || Number.MAX_SAFE_INTEGER
return left - right
})
}
export function groupContributionUnits(records) {
const units = []
const stacks = new Map()
for (const rec of records || []) {
const meta = stackMeta(rec)
if (!meta) {
units.push({ type: 'record', id: rec.id, record: rec, records: [rec] })
continue
}
let unit = stacks.get(meta.id)
if (!unit) {
unit = {
type: 'stack',
id: meta.id,
name: meta.name,
total: meta.total,
records: [],
}
stacks.set(meta.id, unit)
units.push(unit)
}
unit.records.push(rec)
unit.total = Math.max(unit.total, meta.total)
}
for (const unit of units) {
if (unit.type === 'stack') unit.records = sortStackRecords(unit.records)
}
return units
}
// Ready stacks may have an already-open parent after a durable partial retry.
// Include every known layer in that stack so the confirmation still names the
// complete chain, while standalone prepared records remain individual cards.
export function preparedContributionUnits(ready, allRecords) {
const readyStackIds = new Set(
(ready || []).map(stackMeta).filter(Boolean).map((meta) => meta.id)
)
const stackRecords = (allRecords || []).filter((rec) => {
const meta = stackMeta(rec)
return meta && readyStackIds.has(meta.id) &&
['prepared', 'draft', 'open', 'merged'].includes(rec.status)
})
const stackUnits = groupContributionUnits(stackRecords)
.filter((unit) => unit.type === 'stack')
const invalidStackUnits = (ready || [])
.filter((rec) => rec?.plan?.stack && !stackMeta(rec))
.map((rec) => ({
type: 'stack',
id: `invalid:${rec.id}`,
name: [rec.plan.stack.name, rec.plan.stack.id]
.find((value) => typeof value === 'string' && value.trim()) || 'Invalid PR chain',
total: Number(rec.plan.stack.total) || 2,
records: [rec],
}))
const standalone = (ready || [])
.filter((rec) => !rec?.plan?.stack)
.map((rec) => ({ type: 'record', id: rec.id, record: rec, records: [rec] }))
return [...stackUnits, ...invalidStackUnits, ...standalone].sort((a, b) => {
const aRec = a.records.find((rec) => rec.status === 'prepared') || a.records[0]
const bRec = b.records.find((rec) => rec.status === 'prepared') || b.records[0]
return String(bRec?.updated_at || bRec?.created_at || '').localeCompare(
String(aRec?.updated_at || aRec?.created_at || ''))
})
}
export function stackProgress(unit) {
const records = unit?.records || []
return {
ready: records.filter((rec) => rec.status === 'prepared').length,
open: records.filter((rec) => ['submitting', 'draft', 'open'].includes(rec.status)).length,
merged: records.filter((rec) => rec.status === 'merged').length,
total: unit?.total || records.length,
}
}
function recordBranch(rec) {
return rec?.plan?.branch || rec?.branch || ''
}
// Client-side review guard. The platform remains authoritative, but catching
// an incomplete or stale chain here avoids presenting a publish button that is
// guaranteed to fail after confirmation.
export function stackReadiness(unit) {
const records = sortStackRecords(unit?.records || [])
const total = Number(unit?.total || records.length)
const ready = records.filter((rec) => rec.status === 'prepared')
const fail = (code, message) => ({ ok: false, code, message, ready })
if (!Number.isInteger(total) || total < 2 || records.length !== total) {
return fail(
'incomplete',
`This chain is incomplete: ${records.length} of ${total || '?'} layers are available.`,
)
}
const metas = records.map(stackMeta)
if (metas.some((meta) => !meta)) {
return fail('invalid', 'This chain has invalid layer metadata.')
}
if (metas.some((meta, index) => (
meta.id !== metas[0].id || meta.total !== total || meta.position !== index + 1
))) {
return fail('invalid', 'This chain has duplicate, missing, or mismatched layer positions.')
}
const repo = records[0]?.plan?.repo || records[0]?.repo || ''
for (let index = 0; index < records.length; index += 1) {
const rec = records[index]
const meta = metas[index]
const recRepo = rec?.plan?.repo || rec?.repo || ''
if (rec.type !== 'pr' || rec?.plan?.action !== 'pr' || recRepo !== repo) {
return fail('invalid', 'Every layer must be a pull request for the same repository.')
}
if (!['prepared', 'draft', 'open', 'merged'].includes(rec.status)) {
return fail('invalid', 'One layer is not in a publishable stack state.')
}
if (!recordBranch(rec).startsWith(`stack/${meta.id}/`)) {
return fail('invalid', 'A layer branch does not belong to this chain.')
}
if (index === 0) {
if (meta.parentRecordId) {
return fail('invalid', 'The first layer points at an unexpected parent.')
}
continue
}
const previous = records[index - 1]
if (
meta.parentRecordId !== previous.id ||
meta.baseBranch !== recordBranch(previous) ||
(
rec.status === 'prepared' &&
String(rec?.plan?.base_sha || '') !== String(previous?.plan?.head_sha || '')
)
) {
return fail('invalid', 'A layer no longer matches its reviewed parent.')
}
if (rec.status === 'prepared' && previous.status === 'merged') {
return fail(
'refresh',
'A parent PR has merged. Refresh the remaining layers on the repository’s main branch before publishing.',
)
}
}
if (ready.length === 0) {
return fail('settled', 'Every layer in this chain is already public or merged.')
}
return { ok: true, code: 'ready', message: '', ready }
}