@volter/twin-github 0.1.0 → 0.1.1
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 +162 -20
- package/client/github-mirror.tsx +10 -4
- package/package.json +2 -2
- package/src/cli.ts +3 -2
- package/src/github-a11y-snapshot.uitest.ts +117 -0
- package/src/github-budget.ts +198 -0
- package/src/github-capabilities.ts +502 -23
- package/src/github-connector.ts +832 -61
- package/src/github-events.ts +11 -1
- package/src/github-git-http.ts +248 -0
- package/src/github-git-plane.ts +511 -0
- package/src/github-graphql.ts +212 -1
- package/src/github-journey.uitest.ts +193 -0
- package/src/github-mirror-state.ts +369 -0
- package/src/github-mirror-ui.ts +53 -372
- package/src/github-server.ts +125 -21
- package/src/github-shared.ts +26 -0
- package/src/github-twin.ts +821 -53
- package/src/github-ui-conformance.ts +2 -2
- package/src/index.ts +71 -5
- package/test-fixtures/github-openapi-operations.json +467 -34
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// GitHub MIRROR STATE — the ONE named store the mirror console renders (runtime contract
|
|
2
|
+
// R5, the store door; R3, mirror purity): `GET /twin/store/mirror`, declared on the pack's
|
|
3
|
+
// own fetch adapter (`github-server.ts`) and served from it. A deterministic projection of
|
|
4
|
+
// the stored state — pull requests grouped by repo (the per-repo selector), issues,
|
|
5
|
+
// reviews/comments, Actions, releases, discussions, contents, notifications, deployments,
|
|
6
|
+
// projects, insights and settings — built from the twin's own evidence + local-write state.
|
|
7
|
+
// No wall-clock, no randomness: the same stored state projects the same bytes. Server-side
|
|
8
|
+
// only — the mirror module (`github-mirror-ui.ts`) never imports this; it reads the door
|
|
9
|
+
// over the wire. The git plane (bare repos, smart-HTTP) is never read here: the projection
|
|
10
|
+
// is over the folded REST state alone.
|
|
11
|
+
//
|
|
12
|
+
// Honesty: the github world is an EVIDENCE mirror — observed data carries PR metadata +
|
|
13
|
+
// review/comment COUNTS but not their content; local simulator/fork writes carry
|
|
14
|
+
// titles/bodies. So review/comment timeline entries are surfaced from the observed counts
|
|
15
|
+
// and labelled as such; PR titles/bodies appear when present.
|
|
16
|
+
import { discussionCategoriesFor, githubState } from './github-twin.ts';
|
|
17
|
+
import { GITHUB_WEB_ORIGIN, githubIssuePath, githubPullPath } from './github-shared.ts';
|
|
18
|
+
|
|
19
|
+
const OBSERVED = 'content not mirrored by the evidence twin';
|
|
20
|
+
|
|
21
|
+
/** The `mirror` store: the whole console payload, projected from the twin's github world. */
|
|
22
|
+
export function githubMirrorState(root?: string): Record<string, unknown> {
|
|
23
|
+
const { prs, issues, reviews: foldedReviews, comments: foldedComments, statuses, checkRuns, milestones, workflows: foldedWorkflows, workflowRuns, jobs, releases: foldedReleases, releaseAssets, tags, discussions: foldedDiscussions, discussionComments: foldedDiscussionComments, discussionCategories: customCategories, contents: foldedContents, notifications: foldedNotifications, deployments: foldedDeployments, deploymentStatuses: foldedDeploymentStatuses, environments: foldedEnvironments, projects: foldedProjects, projectFields: foldedProjectFields, projectItems: foldedProjectItems, projectViews: foldedProjectViews, repos: foldedRepos, collaborators: foldedCollaborators, webhooks: foldedWebhooks, branches: foldedBranches } = githubState(root);
|
|
24
|
+
// CI rollup per PR head_sha from commit statuses + check runs (a LOCAL CI construct;
|
|
25
|
+
// observed-only PRs carry no head_sha CI, so this stays null for them — honesty).
|
|
26
|
+
// Precedence mirrors GitHub's rollup: any failure/error → 'failure'; any pending/queued/
|
|
27
|
+
// in_progress → 'pending'; all success/neutral → 'success'.
|
|
28
|
+
const ciStatusFor = (repository: string, headSha?: string): string | undefined => {
|
|
29
|
+
if (!headSha) return undefined;
|
|
30
|
+
const relStatuses = statuses.filter((s) => s.repository === repository && s.sha === headSha);
|
|
31
|
+
const relChecks = checkRuns.filter((c) => c.repository === repository && c.head_sha === headSha);
|
|
32
|
+
if (!relStatuses.length && !relChecks.length) return undefined;
|
|
33
|
+
const states: string[] = [
|
|
34
|
+
...relStatuses.map((s) => s.state),
|
|
35
|
+
...relChecks.map((c) => (c.status === 'completed' ? (c.conclusion ?? 'neutral') : 'pending')),
|
|
36
|
+
];
|
|
37
|
+
if (states.some((s) => s === 'failure' || s === 'error')) return 'failure';
|
|
38
|
+
if (states.some((s) => s === 'pending')) return 'pending';
|
|
39
|
+
return 'success';
|
|
40
|
+
};
|
|
41
|
+
const pullRequests = prs.map((pr) => {
|
|
42
|
+
// A PR's merge state: an explicit merged write wins; otherwise the recorded state,
|
|
43
|
+
// defaulting to open. We never fabricate a title for observed-only PRs.
|
|
44
|
+
const state = pr.merged ? 'merged' : (pr.state ?? 'open');
|
|
45
|
+
// milestone: a local PATCH stores the NUMBER; surface its title from state.milestones.
|
|
46
|
+
const ms = pr.milestone !== undefined ? milestones.find((m) => m.repository === pr.repository && m.number === pr.milestone) : undefined;
|
|
47
|
+
return {
|
|
48
|
+
repo: pr.repository,
|
|
49
|
+
number: pr.number,
|
|
50
|
+
title: pr.title,
|
|
51
|
+
body: pr.body,
|
|
52
|
+
state,
|
|
53
|
+
merged: pr.merged ?? false,
|
|
54
|
+
mergedAt: pr.merged_at,
|
|
55
|
+
mergeCommit: pr.merge_commit_sha,
|
|
56
|
+
url: `${GITHUB_WEB_ORIGIN}${githubPullPath(pr.repository, pr.number)}`,
|
|
57
|
+
baseBranch: pr.base_ref,
|
|
58
|
+
headSha: pr.head_sha,
|
|
59
|
+
changedFiles: pr.changed_files,
|
|
60
|
+
commitsCount: pr.commits,
|
|
61
|
+
commentsCount: pr.comment_count,
|
|
62
|
+
reviewCount: pr.review_count,
|
|
63
|
+
reviewCommentsCount: foldedComments.filter((c) => c.repository === pr.repository && c.number === pr.number && c.kind === 'review').length,
|
|
64
|
+
labels: (pr.labels ?? []).map((name) => ({ name })),
|
|
65
|
+
assignees: pr.assignees ?? [],
|
|
66
|
+
// requested reviewers (LOCAL writes): bare logins distinct from submitted reviews.
|
|
67
|
+
requestedReviewers: pr.requested_reviewers ?? [],
|
|
68
|
+
// milestone title (LOCAL write); undefined for observed-only PRs.
|
|
69
|
+
milestone: ms?.title,
|
|
70
|
+
// additions/deletions line counts: roll up from a carried file list on LOCAL writes;
|
|
71
|
+
// undefined for observed-only PRs (the evidence fold never carries line counts).
|
|
72
|
+
additions: pr.additions,
|
|
73
|
+
deletions: pr.deletions,
|
|
74
|
+
// CI rollup over head_sha statuses + check-runs; undefined when no CI evidence.
|
|
75
|
+
ciStatus: ciStatusFor(pr.repository, pr.head_sha),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
// Per-commit detail + per-file diffs ride on LOCAL PR writes only (observed PR evidence
|
|
79
|
+
// carries COUNTS, never content) — so these lists are empty for observed-only PRs, keeping
|
|
80
|
+
// the mirror honest. Each entry is tagged with repo+number so the client can scope it to
|
|
81
|
+
// the selected PR, mirroring the GitHub conversation's Commits + Files-changed sections.
|
|
82
|
+
const commits = prs.flatMap((pr) =>
|
|
83
|
+
(pr.commits_list ?? []).map((c) => ({
|
|
84
|
+
repo: pr.repository,
|
|
85
|
+
number: pr.number,
|
|
86
|
+
sha: c.sha,
|
|
87
|
+
message: c.message,
|
|
88
|
+
author: c.author_name,
|
|
89
|
+
date: c.author_date,
|
|
90
|
+
url: `${GITHUB_WEB_ORIGIN}/${pr.repository}/commit/${c.sha}`,
|
|
91
|
+
})),
|
|
92
|
+
);
|
|
93
|
+
const files = prs.flatMap((pr) =>
|
|
94
|
+
(pr.files ?? []).map((f) => ({
|
|
95
|
+
repo: pr.repository,
|
|
96
|
+
number: pr.number,
|
|
97
|
+
filename: f.filename,
|
|
98
|
+
status: f.status,
|
|
99
|
+
additions: f.additions,
|
|
100
|
+
deletions: f.deletions,
|
|
101
|
+
changes: f.changes,
|
|
102
|
+
// patch: the unified-diff hunk text (LOCAL writes only) — the Files-changed diff view
|
|
103
|
+
// renders these as +/- lines; observed PRs carry no per-file diff so this is undefined.
|
|
104
|
+
patch: f.patch,
|
|
105
|
+
})),
|
|
106
|
+
);
|
|
107
|
+
// First-class issues (a LOCAL construct) grouped per repo via the `repo` field, like PRs.
|
|
108
|
+
const issuesOut = issues.map((iss) => {
|
|
109
|
+
const ms = iss.milestone !== undefined ? milestones.find((m) => m.repository === iss.repository && m.number === iss.milestone) : undefined;
|
|
110
|
+
return {
|
|
111
|
+
repo: iss.repository,
|
|
112
|
+
number: iss.number,
|
|
113
|
+
title: iss.title,
|
|
114
|
+
body: iss.body,
|
|
115
|
+
state: iss.state ?? 'open',
|
|
116
|
+
url: `${GITHUB_WEB_ORIGIN}${githubIssuePath(iss.repository, iss.number)}`,
|
|
117
|
+
createdAt: iss.created_at,
|
|
118
|
+
updatedAt: iss.updated_at,
|
|
119
|
+
labels: (iss.labels ?? []).map((name) => ({ name })),
|
|
120
|
+
assignees: iss.assignees ?? [],
|
|
121
|
+
milestone: ms?.title,
|
|
122
|
+
stateReason: iss.state_reason ?? undefined,
|
|
123
|
+
locked: iss.locked ?? false,
|
|
124
|
+
// linked PRs/issues (a LOCAL relation): surface as typed references the client links.
|
|
125
|
+
linked: (iss.linked ?? []).map((l) => ({
|
|
126
|
+
type: l.type,
|
|
127
|
+
number: l.number,
|
|
128
|
+
url: l.type === 'pull_request'
|
|
129
|
+
? `${GITHUB_WEB_ORIGIN}${githubPullPath(iss.repository, l.number)}`
|
|
130
|
+
: `${GITHUB_WEB_ORIGIN}${githubIssuePath(iss.repository, l.number)}`,
|
|
131
|
+
})),
|
|
132
|
+
// issue comments (kind:'issue' on the shared comment space) — the detail page renders
|
|
133
|
+
// these in its timeline; observed comments carry no body (labelled, never fabricated).
|
|
134
|
+
comments: foldedComments
|
|
135
|
+
.filter((c) => c.kind === 'issue' && c.repository === iss.repository && c.number === iss.number)
|
|
136
|
+
.map((c) => ({ id: c.id, author: c.body === undefined ? '(observed)' : 'simulator', body: c.body ?? `Comment observed — ${OBSERVED}.`, createdAt: c.created_at })),
|
|
137
|
+
// a derived timeline of state-change events (labeled/assigned/milestoned/locked/closed)
|
|
138
|
+
// mirroring the issue's current modeled state — the detail page renders it as a feed.
|
|
139
|
+
timeline: [
|
|
140
|
+
...(iss.labels ?? []).map((name) => ({ event: 'labeled', detail: name })),
|
|
141
|
+
...(iss.assignees ?? []).map((login) => ({ event: 'assigned', detail: `@${login}` })),
|
|
142
|
+
...(iss.locked ? [{ event: 'locked', detail: iss.active_lock_reason ?? '' }] : []),
|
|
143
|
+
...(iss.state === 'closed' ? [{ event: 'closed', detail: iss.state_reason ?? '' }] : []),
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
// Reviews carry state/body for LOCAL writes; observed reviews carry neither, so we
|
|
148
|
+
// label them honestly as observed-only rather than fabricating review content.
|
|
149
|
+
const reviews = foldedReviews.map((r) => {
|
|
150
|
+
const observed = r.state === undefined && r.body === undefined;
|
|
151
|
+
return {
|
|
152
|
+
repo: r.repository,
|
|
153
|
+
number: r.number,
|
|
154
|
+
reviewer: observed ? '(observed)' : 'simulator',
|
|
155
|
+
state: r.state ?? 'reviewed',
|
|
156
|
+
body: r.body ?? (observed ? `Review observed — ${OBSERVED}.` : ''),
|
|
157
|
+
submittedAt: r.submitted_at,
|
|
158
|
+
observed,
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
// Issue comments and review (diff-thread) comments are split by kind so the client can
|
|
162
|
+
// render them in their right place. Observed comments carry no body — label them.
|
|
163
|
+
const toComment = (c: (typeof foldedComments)[number]) => {
|
|
164
|
+
const observed = c.body === undefined;
|
|
165
|
+
return {
|
|
166
|
+
repo: c.repository,
|
|
167
|
+
number: c.number,
|
|
168
|
+
author: observed ? '(observed)' : 'simulator',
|
|
169
|
+
body: c.body ?? (observed ? `Comment observed — ${OBSERVED}.` : ''),
|
|
170
|
+
createdAt: c.created_at,
|
|
171
|
+
observed,
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
const comments = foldedComments.filter((c) => c.kind === 'issue').map(toComment);
|
|
175
|
+
const reviewComments = foldedComments.filter((c) => c.kind === 'review').map(toComment);
|
|
176
|
+
// Actions (workflows + runs + jobs) — a LOCAL construct (running real CI is the only Actions
|
|
177
|
+
// non-goal; the run/job OBJECTS are modeled). Surfaced per-repo like PRs/issues. Runs carry
|
|
178
|
+
// status/conclusion the client renders as badges; jobs are scoped to a run by run_id.
|
|
179
|
+
const workflows = foldedWorkflows.map((w) => ({
|
|
180
|
+
repo: w.repository,
|
|
181
|
+
id: w.id,
|
|
182
|
+
name: w.name,
|
|
183
|
+
path: w.path,
|
|
184
|
+
state: w.state ?? 'active',
|
|
185
|
+
}));
|
|
186
|
+
const runs = workflowRuns.map((r) => {
|
|
187
|
+
const wf = foldedWorkflows.find((w) => w.repository === r.repository && w.id === r.workflow_id);
|
|
188
|
+
return {
|
|
189
|
+
repo: r.repository,
|
|
190
|
+
id: r.id,
|
|
191
|
+
workflowId: r.workflow_id,
|
|
192
|
+
workflowName: wf?.name ?? r.name,
|
|
193
|
+
name: r.name,
|
|
194
|
+
runNumber: r.run_number,
|
|
195
|
+
event: r.event,
|
|
196
|
+
status: r.status,
|
|
197
|
+
conclusion: r.conclusion,
|
|
198
|
+
headSha: r.head_sha,
|
|
199
|
+
headBranch: r.head_branch,
|
|
200
|
+
createdAt: r.created_at,
|
|
201
|
+
updatedAt: r.updated_at,
|
|
202
|
+
url: `${GITHUB_WEB_ORIGIN}/${r.repository}/actions/runs/${r.id}`,
|
|
203
|
+
jobCount: jobs.filter((j) => j.repository === r.repository && j.run_id === r.id).length,
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
const runJobs = jobs.map((j) => ({
|
|
207
|
+
repo: j.repository,
|
|
208
|
+
id: j.id,
|
|
209
|
+
runId: j.run_id,
|
|
210
|
+
name: j.name,
|
|
211
|
+
status: j.status,
|
|
212
|
+
conclusion: j.conclusion,
|
|
213
|
+
steps: j.steps.map((s) => ({ name: s.name, status: s.status, conclusion: s.conclusion, number: s.number })),
|
|
214
|
+
}));
|
|
215
|
+
// Releases (a LOCAL construct — there is no observed release evidence; release ASSETS are
|
|
216
|
+
// metadata only, the binary bytes are the declared Non-goal). Surfaced per-repo like PRs.
|
|
217
|
+
// Each release carries its tag/name/draft/prerelease flags + its asset catalog; tags are
|
|
218
|
+
// the published-release tag list (derived, no git blob bytes).
|
|
219
|
+
const releases = foldedReleases.map((r) => ({
|
|
220
|
+
repo: r.repository,
|
|
221
|
+
id: r.id,
|
|
222
|
+
tagName: r.tag_name,
|
|
223
|
+
name: r.name,
|
|
224
|
+
body: r.body,
|
|
225
|
+
draft: r.draft ?? false,
|
|
226
|
+
prerelease: r.prerelease ?? false,
|
|
227
|
+
createdAt: r.created_at,
|
|
228
|
+
publishedAt: r.published_at,
|
|
229
|
+
targetCommitish: r.target_commitish,
|
|
230
|
+
url: `${GITHUB_WEB_ORIGIN}/${r.repository}/releases/tag/${encodeURIComponent(r.tag_name)}`,
|
|
231
|
+
assets: releaseAssets
|
|
232
|
+
.filter((a) => a.repository === r.repository && a.release_id === r.id)
|
|
233
|
+
.map((a) => ({ id: a.id, name: a.name, label: a.label, contentType: a.content_type, size: a.size ?? 0, downloadCount: a.download_count ?? 0 })),
|
|
234
|
+
}));
|
|
235
|
+
const tagList = tags.map((t) => ({ repo: t.repository, name: t.name, commitSha: t.commit_sha }));
|
|
236
|
+
// Discussions (a LOCAL construct — no observed discussion evidence; modeled over REST,
|
|
237
|
+
// honestly noted as a deviation since real GitHub Discussions are GraphQL). Each discussion
|
|
238
|
+
// carries its category (resolved from the per-repo category list), state/lock, the chosen
|
|
239
|
+
// answer pointer, and its comments/replies (with the is_answer flag). Surfaced per-repo.
|
|
240
|
+
const discussionRepos = new Set(foldedDiscussions.map((d) => d.repository));
|
|
241
|
+
const categoryByRepoSlug = new Map<string, { name: string; emoji?: string; isAnswerable: boolean }>();
|
|
242
|
+
for (const r of discussionRepos) for (const c of discussionCategoriesFor(r, customCategories)) categoryByRepoSlug.set(`${r}#${c.slug}`, { name: c.name, emoji: c.emoji, isAnswerable: c.is_answerable ?? false });
|
|
243
|
+
const discussions = foldedDiscussions.map((d) => {
|
|
244
|
+
const cat = d.category_slug ? categoryByRepoSlug.get(`${d.repository}#${d.category_slug}`) : undefined;
|
|
245
|
+
const threadComments = foldedDiscussionComments.filter((c) => c.repository === d.repository && c.discussion_number === d.number);
|
|
246
|
+
return {
|
|
247
|
+
repo: d.repository,
|
|
248
|
+
number: d.number,
|
|
249
|
+
title: d.title,
|
|
250
|
+
body: d.body,
|
|
251
|
+
state: d.locked ? 'locked' : (d.state ?? 'open'),
|
|
252
|
+
locked: d.locked ?? false,
|
|
253
|
+
categorySlug: d.category_slug,
|
|
254
|
+
categoryName: cat?.name,
|
|
255
|
+
categoryEmoji: cat?.emoji,
|
|
256
|
+
isAnswerable: cat?.isAnswerable ?? false,
|
|
257
|
+
answerCommentId: d.answer_comment_id ?? null,
|
|
258
|
+
isAnswered: d.answer_comment_id != null,
|
|
259
|
+
commentsCount: threadComments.length,
|
|
260
|
+
createdAt: d.created_at,
|
|
261
|
+
updatedAt: d.updated_at,
|
|
262
|
+
url: `${GITHUB_WEB_ORIGIN}/${d.repository}/discussions/${d.number}`,
|
|
263
|
+
comments: threadComments.map((c) => ({
|
|
264
|
+
id: c.id,
|
|
265
|
+
parentId: c.parent_id ?? null,
|
|
266
|
+
body: c.body,
|
|
267
|
+
isAnswer: c.is_answer ?? false,
|
|
268
|
+
createdAt: c.created_at,
|
|
269
|
+
})),
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
// Repo contents (a LOCAL construct) — the Code/file browser tree. Each entry carries its
|
|
273
|
+
// path + size; the browser renders the directory tree per repo (no blob bytes rendered).
|
|
274
|
+
const contents = foldedContents.map((c) => ({ repo: c.repository, path: c.path, size: c.size, sha: c.sha, branch: c.branch ?? 'main' }));
|
|
275
|
+
// Notifications (a LOCAL construct) — the inbox screen. unread flag + reason + subject.
|
|
276
|
+
const notifications = foldedNotifications.map((n) => ({ id: n.id, repo: n.repository, title: n.subject_title, type: n.subject_type, reason: n.reason, unread: n.unread, updatedAt: n.updated_at }));
|
|
277
|
+
// Deployments (a LOCAL construct) — the repo's Deployments/Environments screen. Each
|
|
278
|
+
// deployment carries its environment + ref + the LATEST status (the deployment's state),
|
|
279
|
+
// mirroring GitHub's Environments view (most-recent deployment per environment, with state).
|
|
280
|
+
const deployments = foldedDeployments.map((d) => {
|
|
281
|
+
const statusesForDep = foldedDeploymentStatuses.filter((s) => s.deployment_id === d.id).sort((a, b) => b.id - a.id);
|
|
282
|
+
const latest = statusesForDep[0];
|
|
283
|
+
return {
|
|
284
|
+
repo: d.repository,
|
|
285
|
+
id: d.id,
|
|
286
|
+
ref: d.ref,
|
|
287
|
+
sha: d.sha,
|
|
288
|
+
environment: d.environment,
|
|
289
|
+
task: d.task,
|
|
290
|
+
description: d.description ?? undefined,
|
|
291
|
+
production: d.production_environment ?? false,
|
|
292
|
+
transient: d.transient_environment ?? false,
|
|
293
|
+
createdAt: d.created_at,
|
|
294
|
+
state: latest?.state ?? 'pending',
|
|
295
|
+
environmentUrl: latest?.environment_url,
|
|
296
|
+
url: `${GITHUB_WEB_ORIGIN}/${d.repository}/deployments`,
|
|
297
|
+
statuses: statusesForDep.map((s) => ({ id: s.id, state: s.state, description: s.description ?? undefined, createdAt: s.created_at })),
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
// Environments (a LOCAL construct) — protection-rule summaries per environment.
|
|
301
|
+
const environments = foldedEnvironments.map((e) => ({
|
|
302
|
+
repo: e.repository,
|
|
303
|
+
name: e.name,
|
|
304
|
+
waitTimer: e.wait_timer,
|
|
305
|
+
reviewers: e.reviewers ?? [],
|
|
306
|
+
protected: Boolean(e.wait_timer) || Boolean(e.reviewers?.length),
|
|
307
|
+
}));
|
|
308
|
+
// ── Projects v2 board (a LOCAL construct) — the Project board screen. Each project carries
|
|
309
|
+
// its views (board/table) + its Status field's options as COLUMNS, and its items grouped
|
|
310
|
+
// into those columns by their Status field value (the board's swim-lanes). Data-coupled:
|
|
311
|
+
// seeding a project + a Status field + items moves the column counts the board renders.
|
|
312
|
+
const projectBoards = foldedProjects.map((p) => {
|
|
313
|
+
const fields = foldedProjectFields.filter((f) => f.project_id === p.id);
|
|
314
|
+
const statusField = fields.find((f) => f.data_type === 'single_select') ?? fields.find((f) => /status/i.test(f.name));
|
|
315
|
+
const columnNames = statusField?.options?.map((o) => o.name) ?? [];
|
|
316
|
+
const items = foldedProjectItems.filter((i) => i.project_id === p.id);
|
|
317
|
+
const columns = columnNames.map((name) => ({
|
|
318
|
+
name,
|
|
319
|
+
cards: items
|
|
320
|
+
.filter((i) => String((i.field_values as Record<string, unknown>)[statusField!.name] ?? '') === name)
|
|
321
|
+
.map((i) => ({ id: i.id, title: i.title ?? `#${i.content_id ?? i.id}`, contentType: i.content_type })),
|
|
322
|
+
}));
|
|
323
|
+
// items with no (or an unrecognized) status land in a "No Status" lane (GitHub behavior).
|
|
324
|
+
const noStatus = items.filter((i) => !columnNames.includes(String((i.field_values as Record<string, unknown>)[statusField?.name ?? ''] ?? '')));
|
|
325
|
+
return {
|
|
326
|
+
id: p.id, number: p.number, title: p.title, owner: p.owner, closed: p.closed ?? false,
|
|
327
|
+
views: foldedProjectViews.filter((v) => v.project_id === p.id).map((v) => ({ number: v.number, name: v.name, layout: v.layout })),
|
|
328
|
+
fields: fields.map((f) => ({ name: f.name, dataType: f.data_type })),
|
|
329
|
+
itemCount: items.length,
|
|
330
|
+
columns,
|
|
331
|
+
noStatus: { name: 'No Status', cards: noStatus.map((i) => ({ id: i.id, title: i.title ?? `#${i.content_id ?? i.id}`, contentType: i.content_type })) },
|
|
332
|
+
url: `${GITHUB_WEB_ORIGIN}/${p.owner_type === 'organization' ? 'orgs/' : 'users/'}${p.owner}/projects/${p.number}`,
|
|
333
|
+
};
|
|
334
|
+
});
|
|
335
|
+
// ── Insights / Pulse / Contributors (DERIVED per repo from the modeled PRs/issues/commits) —
|
|
336
|
+
// the Pulse screen's counts (merged/opened PRs, opened/closed issues) + a contributors
|
|
337
|
+
// leaderboard from the per-commit authors the twin tracks. Data-coupled: each new PR/issue
|
|
338
|
+
// moves a Pulse count; each new commit author moves a contributor's commit total.
|
|
339
|
+
const repoNames = new Set<string>([...prs.map((p) => p.repository), ...issues.map((i) => i.repository), ...foldedRepos.map((r) => r.full_name)]);
|
|
340
|
+
const insights = [...repoNames].sort().map((repo) => {
|
|
341
|
+
const repoPrs = prs.filter((p) => p.repository === repo);
|
|
342
|
+
const repoIssues = issues.filter((i) => i.repository === repo);
|
|
343
|
+
const contribCounts = new Map<string, number>();
|
|
344
|
+
for (const pr of repoPrs) for (const c of pr.commits_list ?? []) { const a = c.author_name ?? '(unknown)'; contribCounts.set(a, (contribCounts.get(a) ?? 0) + 1); }
|
|
345
|
+
return {
|
|
346
|
+
repo,
|
|
347
|
+
pulse: {
|
|
348
|
+
mergedPrs: repoPrs.filter((p) => p.merged).length,
|
|
349
|
+
openPrs: repoPrs.filter((p) => !p.merged && (p.state ?? 'open') === 'open').length,
|
|
350
|
+
openedIssues: repoIssues.filter((i) => (i.state ?? 'open') === 'open').length,
|
|
351
|
+
closedIssues: repoIssues.filter((i) => i.state === 'closed').length,
|
|
352
|
+
},
|
|
353
|
+
contributors: [...contribCounts.entries()].map(([login, commits]) => ({ login, commits })).sort((a, b) => b.commits - a.commits),
|
|
354
|
+
};
|
|
355
|
+
});
|
|
356
|
+
// ── Settings (DERIVED per repo from the modeled repo config + collaborators/webhooks/branches)
|
|
357
|
+
// the repo Settings screens (General / Collaborators / Webhooks / Branches). Data-coupled:
|
|
358
|
+
// adding a collaborator or webhook moves the counts/rows the settings screen renders.
|
|
359
|
+
const settings = foldedRepos.map((r) => ({
|
|
360
|
+
repo: r.full_name,
|
|
361
|
+
general: { defaultBranch: r.default_branch, private: r.private ?? false, hasIssues: r.has_issues ?? true, hasProjects: r.has_projects ?? true, hasWiki: r.has_wiki ?? true, hasDiscussions: r.has_discussions ?? false, description: r.description ?? null },
|
|
362
|
+
collaborators: foldedCollaborators.filter((c) => c.repository === r.full_name).map((c) => ({ login: c.login, permission: c.permission })),
|
|
363
|
+
webhooks: foldedWebhooks.filter((h) => h.repository === r.full_name).map((h) => ({ id: h.id, url: h.url, active: h.active, events: h.events })),
|
|
364
|
+
branches: foldedBranches.filter((b) => b.repository === r.full_name).map((b) => ({ name: b.name, protected: b.protected ?? false })),
|
|
365
|
+
}));
|
|
366
|
+
return {
|
|
367
|
+
github: { pullRequests, issues: issuesOut, reviews, commits, comments, reviewComments, files, workflows, runs, jobs: runJobs, releases, tags: tagList, discussions, contents, notifications, deployments, environments, projectBoards, insights, settings },
|
|
368
|
+
};
|
|
369
|
+
}
|