@plutocms/core 0.47.0 → 0.48.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/FEATURES.md +15 -4
  3. package/README.md +5 -5
  4. package/app/components/Pluto/Admin/PlutoAdminBackendBadge.vue +84 -0
  5. package/app/components/Pluto/Admin/PlutoAdminNavbar.vue +7 -0
  6. package/app/components/Pluto/Github/PlutoGithubBackendDetails.vue +180 -0
  7. package/app/components/Pluto/Github/PlutoGithubBuildStatus.vue +17 -4
  8. package/app/components/Pluto/Github/PlutoGithubDeployPage.vue +472 -0
  9. package/app/components/Pluto/Github/PlutoGithubSetupPanel.vue +1 -1
  10. package/app/components/Pluto/Github/PlutoGithubUsersPage.vue +214 -0
  11. package/app/components/Pluto/PlutoHtml.vue +8 -2
  12. package/app/composables/internal/async-component.ts +9 -0
  13. package/app/composables/pluto-extension.ts +4 -0
  14. package/app/composables/pluto-navbar.ts +25 -0
  15. package/app/composables/pluto-registry.ts +1 -0
  16. package/i18n/locales/en.json +164 -2
  17. package/i18n/locales/pt-BR.json +164 -2
  18. package/modules/pluto-github/index.ts +127 -51
  19. package/modules/pluto-github/options.ts +18 -5
  20. package/modules/pluto-github/runtime/app/composables/pluto-github-local-git.ts +50 -0
  21. package/modules/pluto-github/runtime/app/composables/pluto-github-session.ts +8 -1
  22. package/modules/pluto-github/runtime/app/plugins/pluto-github.ts +84 -1
  23. package/modules/pluto-github/runtime/server/adapter.ts +26 -7
  24. package/modules/pluto-github/runtime/server/api/deploy/pages.post.ts +5 -0
  25. package/modules/pluto-github/runtime/server/api/deploy/status.get.ts +5 -0
  26. package/modules/pluto-github/runtime/server/api/local/push.post.ts +5 -0
  27. package/modules/pluto-github/runtime/server/api/local/status.get.ts +5 -0
  28. package/modules/pluto-github/runtime/server/api/users.get.ts +5 -0
  29. package/modules/pluto-github/runtime/server/store/github.ts +4 -1
  30. package/modules/pluto-github/runtime/server/store/local-git.ts +145 -0
  31. package/modules/pluto-github/runtime/server/store/memory.ts +4 -1
  32. package/modules/pluto-github/runtime/server/utils/build-status.ts +16 -7
  33. package/modules/pluto-github/runtime/server/utils/deploy.ts +204 -0
  34. package/modules/pluto-github/runtime/server/utils/github-setup.ts +1 -3
  35. package/modules/pluto-github/runtime/server/utils/github-users.ts +120 -0
  36. package/modules/pluto-github/runtime/server/utils/local-git-routes.ts +95 -0
  37. package/modules/pluto-github/runtime/shared/build-status.ts +26 -0
  38. package/modules/pluto-github/runtime/shared/client-rules.ts +16 -0
  39. package/modules/pluto-github/runtime/shared/deploy.ts +43 -0
  40. package/modules/pluto-github/runtime/shared/pages-workflow.ts +138 -0
  41. package/modules/pluto-github/runtime/shared/repo-users.ts +97 -0
  42. package/package.json +1 -1
  43. package/shared/types/github.d.ts +2 -0
  44. package/shared/types/registry.ts +22 -0
  45. package/shared/utils/html-base.ts +23 -0
@@ -74,3 +74,148 @@ export async function commitLocalFiles(
74
74
  await git(root, ['commit', '-m', message, '--', ...staged])
75
75
  return { committed: true, sha: await git(root, ['rev-parse', 'HEAD']) }
76
76
  }
77
+
78
+ /** The state of the local Git repo, for the backend badge of the admin (local mode). */
79
+ export type LocalGitStatus =
80
+ | { repo: false; reason: 'not-a-repo' | 'no-git' }
81
+ | {
82
+ repo: true
83
+ /** The current branch. `null` on a detached HEAD. */
84
+ branch: string | null
85
+ /** The upstream of the branch, for example `origin/main`. `null` when it has none. */
86
+ upstream: string | null
87
+ /** The remote that a push uses: the remote of the upstream, else `origin`, else the first. */
88
+ remote: string | null
89
+ /** The commits that are not pushed: not on the upstream, or on no remote at all. */
90
+ ahead: number
91
+ }
92
+
93
+ /** Why a push failed. `rejected`: the remote has commits that this repo has not (pull first). */
94
+ export type LocalPushError =
95
+ | 'not-a-repo'
96
+ | 'detached'
97
+ | 'no-remote'
98
+ | 'rejected'
99
+ | 'auth'
100
+ | 'timeout'
101
+ | 'failed'
102
+
103
+ export class LocalPushFailure extends Error {
104
+ constructor(readonly reason: LocalPushError) {
105
+ super(`git push failed: ${reason}`)
106
+ }
107
+ }
108
+
109
+ async function gitOrNull(root: string, args: string[]): Promise<string | null> {
110
+ try {
111
+ return await git(root, args)
112
+ } catch {
113
+ return null
114
+ }
115
+ }
116
+
117
+ /** Reads the branch, the upstream and the commits that are not pushed. It runs no network call. */
118
+ export async function readLocalGitStatus(
119
+ root: string
120
+ ): Promise<LocalGitStatus> {
121
+ try {
122
+ if ((await git(root, ['rev-parse', '--is-inside-work-tree'])) !== 'true') {
123
+ return { repo: false, reason: 'not-a-repo' }
124
+ }
125
+ } catch (error) {
126
+ const code = (error as NodeJS.ErrnoException).code
127
+ return { repo: false, reason: code === 'ENOENT' ? 'no-git' : 'not-a-repo' }
128
+ }
129
+
130
+ const branch =
131
+ (await gitOrNull(root, ['symbolic-ref', '--short', '-q', 'HEAD'])) || null
132
+ const upstream =
133
+ (await gitOrNull(root, [
134
+ 'rev-parse',
135
+ '--abbrev-ref',
136
+ '--symbolic-full-name',
137
+ '@{u}',
138
+ ])) || null
139
+ const remotes = ((await gitOrNull(root, ['remote'])) ?? '')
140
+ .split('\n')
141
+ .filter(Boolean)
142
+ const upstreamRemote = upstream
143
+ ? remotes.find((name) => upstream.startsWith(`${name}/`))
144
+ : undefined
145
+ const remote =
146
+ upstreamRemote ??
147
+ (remotes.includes('origin') ? 'origin' : (remotes[0] ?? null))
148
+
149
+ // With an upstream: the commits that it lacks. Without one: the commits on no remote branch. A
150
+ // repo with no commit yet has no HEAD, so the count fails and reads as 0.
151
+ const count = await gitOrNull(
152
+ root,
153
+ upstream
154
+ ? ['rev-list', '--count', '@{u}..HEAD']
155
+ : ['rev-list', '--count', 'HEAD', '--not', '--remotes']
156
+ )
157
+ const ahead = Math.max(0, Number.parseInt(count ?? '0', 10) || 0)
158
+
159
+ return { repo: true, branch, upstream, remote, ahead }
160
+ }
161
+
162
+ /** Sorts the stderr text of a failed push into a reason. */
163
+ export function pushErrorReason(stderr: string): LocalPushError {
164
+ if (/\[rejected\]|non-fast-forward|fetch first/i.test(stderr)) {
165
+ return 'rejected'
166
+ }
167
+ if (
168
+ /authentication failed|could not read username|permission denied|terminal prompts disabled|error: 403|returned error: 403/i.test(
169
+ stderr
170
+ )
171
+ ) {
172
+ return 'auth'
173
+ }
174
+ return 'failed'
175
+ }
176
+
177
+ /**
178
+ * Pushes the current branch. A branch with no upstream is pushed to the remote of the status and
179
+ * gets it as its upstream. Git never asks for a password in the terminal (`GIT_TERMINAL_PROMPT=0`),
180
+ * and the push stops after `timeout` ms. A credential helper of the system can still ask in a
181
+ * window. Throws `LocalPushFailure`.
182
+ */
183
+ export async function pushLocalGit(
184
+ root: string,
185
+ options: { timeout?: number } = {}
186
+ ): Promise<LocalGitStatus> {
187
+ const status = await readLocalGitStatus(root)
188
+ if (!status.repo) {
189
+ throw new LocalPushFailure('not-a-repo')
190
+ }
191
+ if (!status.branch) {
192
+ throw new LocalPushFailure('detached')
193
+ }
194
+ if (!status.remote) {
195
+ throw new LocalPushFailure('no-remote')
196
+ }
197
+
198
+ const args = status.upstream
199
+ ? ['push']
200
+ : ['push', '--set-upstream', '--', status.remote, status.branch]
201
+ try {
202
+ await run('git', args, {
203
+ cwd: root,
204
+ windowsHide: true,
205
+ timeout: options.timeout ?? 120_000,
206
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
207
+ })
208
+ } catch (error) {
209
+ const failure = error as NodeJS.ErrnoException & {
210
+ killed?: boolean
211
+ signal?: string | null
212
+ stderr?: string
213
+ }
214
+ if (failure.killed || failure.signal === 'SIGTERM') {
215
+ throw new LocalPushFailure('timeout')
216
+ }
217
+ throw new LocalPushFailure(pushErrorReason(String(failure.stderr ?? '')))
218
+ }
219
+
220
+ return readLocalGitStatus(root)
221
+ }
@@ -31,7 +31,10 @@ export function createMemoryStore(
31
31
 
32
32
  function currentHead(): string {
33
33
  return listingOid(
34
- [...files, ...raw].map(([path, text]) => ({ path, oid: gitBlobSha(text) }))
34
+ [...files, ...raw].map(([path, text]) => ({
35
+ path,
36
+ oid: gitBlobSha(text),
37
+ }))
35
38
  )
36
39
  }
37
40
 
@@ -8,7 +8,11 @@ import type {
8
8
  } from '../../shared/build-status'
9
9
  import { setResponseHeader } from 'h3'
10
10
  import { createPlutoError } from '../../../../../server/utils/pluto-error'
11
- import { commitHeadline, reduceBuildStatus } from '../../shared/build-status'
11
+ import {
12
+ commitHeadline,
13
+ pickDeployCommit,
14
+ reduceBuildStatus,
15
+ } from '../../shared/build-status'
12
16
  import { githubRest } from '../github/client'
13
17
  import { PlutoGithubStoreError } from '../store/errors'
14
18
  import { getGithubConfig } from './github-config'
@@ -17,6 +21,8 @@ import { requireGithubSession } from './session'
17
21
  /** Milliseconds that the sources of one commit stay in memory, for each user. */
18
22
  const SOURCES_TTL_MS = 3_000
19
23
  const MAX_ENTRIES = 200
24
+ /** The newest commits that the route reads to find the last one that did not skip CI. */
25
+ const COMMITS_TO_SCAN = 20
20
26
  /** The most environments that the route reads. Each one costs one GitHub call. */
21
27
  const MAX_DEPLOYMENTS = 5
22
28
 
@@ -159,8 +165,8 @@ async function loadSources(
159
165
  return { checkRuns, statuses, deployments, unavailable }
160
166
  }
161
167
 
162
- /** Maps an error of the GitHub call to an HTTP error, like the content adapter does. */
163
- function toHttpError(error: unknown, event: H3Event): unknown {
168
+ /** Maps an error of a GitHub call to an HTTP error, like the content adapter does. The users route uses it too. */
169
+ export function toHttpError(error: unknown, event: H3Event): unknown {
164
170
  if (!(error instanceof PlutoGithubStoreError)) {
165
171
  return error
166
172
  }
@@ -203,8 +209,8 @@ function toHttpError(error: unknown, event: H3Event): unknown {
203
209
  }
204
210
 
205
211
  /**
206
- * Route: GET /api/_pluto/github/build-status. The state of the builds of the head
207
- * commit of the content branch: check runs, commit statuses and deployments.
212
+ * Route: GET /api/_pluto/github/build-status. The state of the builds of the newest commit of the
213
+ * content branch that did not skip CI: check runs, commit statuses and deployments.
208
214
  * It needs a signed-in user and reads with the token of that user. The `fs`
209
215
  * store has no builds, so it answers `none`.
210
216
  */
@@ -225,10 +231,13 @@ export async function handleBuildStatus(
225
231
  fetch: config.fetch,
226
232
  }
227
233
  try {
228
- const { data: commit } = await githubRest<RawCommit>(
229
- `/repos/${config.repo}/commits/${encodeURIComponent(config.branch)}`,
234
+ // A draft save skips the deploy (`[skip ci]`), so the newest commit that did not skip it has
235
+ // the builds of the site.
236
+ const { data: commits } = await githubRest<RawCommit[]>(
237
+ `/repos/${config.repo}/commits?sha=${encodeURIComponent(config.branch)}&per_page=${COMMITS_TO_SCAN}`,
230
238
  request
231
239
  )
240
+ const commit = pickDeployCommit(Array.isArray(commits) ? commits : [])
232
241
  const sha = String(commit?.sha ?? '')
233
242
  if (!/^[0-9a-f]{7,64}$/i.test(sha)) {
234
243
  throw new PlutoGithubStoreError(
@@ -0,0 +1,204 @@
1
+ import type { H3Event } from 'h3'
2
+ import type {
3
+ PlutoDeployStatus,
4
+ PlutoPagesSetupResult,
5
+ PlutoPagesState,
6
+ } from '../../shared/deploy'
7
+ import { execFile } from 'node:child_process'
8
+ import { existsSync } from 'node:fs'
9
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
10
+ import { dirname, join } from 'node:path'
11
+ import { promisify } from 'node:util'
12
+ import { createError, setResponseHeader } from 'h3'
13
+ import {
14
+ defaultPagesUrl,
15
+ detectPackageManager,
16
+ PAGES_WORKFLOW_PATH,
17
+ renderPagesWorkflow,
18
+ } from '../../shared/pages-workflow'
19
+ import { commitLocalFiles } from '../store/local-git'
20
+ import { getGithubConfig } from './github-config'
21
+ import { isLocalRequest, requireGithubSession } from './session'
22
+
23
+ const run = promisify(execFile)
24
+
25
+ /** A repo name that is safe to put in a `gh api` path. */
26
+ const REPO_NAME = /^[\w.-]+\/[\w.-]+$/
27
+
28
+ async function gh(args: string[]): Promise<string> {
29
+ const { stdout } = await run('gh', args, {
30
+ windowsHide: true,
31
+ timeout: 30_000,
32
+ env: { ...process.env, GH_PROMPT_DISABLED: '1' },
33
+ })
34
+ return stdout
35
+ }
36
+
37
+ /** Sorts a failure of `gh` into a reason, and `off` for a 404. */
38
+ function ghFailure(error: unknown): PlutoPagesState {
39
+ const failure = error as NodeJS.ErrnoException & { stderr?: string }
40
+ if (failure.code === 'ENOENT') {
41
+ return { state: 'unknown', reason: 'no-gh' }
42
+ }
43
+ const text = String(failure.stderr ?? failure.message ?? '')
44
+ if (/HTTP 404|Not Found/i.test(text)) {
45
+ return { state: 'off' }
46
+ }
47
+ if (/gh auth login|not logged in|authentication/i.test(text)) {
48
+ return { state: 'unknown', reason: 'signed-out' }
49
+ }
50
+ return { state: 'unknown', reason: 'error' }
51
+ }
52
+
53
+ /** Reads the Pages site of the repo with `gh api`. It never throws. */
54
+ export async function readPagesState(repo: string): Promise<PlutoPagesState> {
55
+ if (!REPO_NAME.test(repo)) {
56
+ return { state: 'unknown', reason: 'error' }
57
+ }
58
+ try {
59
+ const site = JSON.parse(await gh(['api', `repos/${repo}/pages`])) as {
60
+ html_url?: string
61
+ build_type?: string
62
+ }
63
+ return {
64
+ state: 'on',
65
+ url: site.html_url,
66
+ source: site.build_type === 'workflow' ? 'workflow' : 'legacy',
67
+ }
68
+ } catch (error) {
69
+ return ghFailure(error)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Turns Pages on for the repo with the Actions source (`build_type=workflow`), or moves a site
75
+ * that builds from a branch to the Actions source. Needs `gh` signed in as a user who administers
76
+ * the repo. Returns the new state.
77
+ */
78
+ export async function enablePages(repo: string): Promise<PlutoPagesState> {
79
+ const current = await readPagesState(repo)
80
+ if (current.state === 'on' && current.source === 'workflow') {
81
+ return current
82
+ }
83
+ if (current.state === 'unknown') {
84
+ return current
85
+ }
86
+ try {
87
+ await gh([
88
+ 'api',
89
+ '-X',
90
+ current.state === 'on' ? 'PUT' : 'POST',
91
+ `repos/${repo}/pages`,
92
+ '-f',
93
+ 'build_type=workflow',
94
+ ])
95
+ } catch (error) {
96
+ return ghFailure(error)
97
+ }
98
+ return readPagesState(repo)
99
+ }
100
+
101
+ /** The local mode of this request: a dev build, the `fs` store, and this computer. */
102
+ function isLocalMode(event: H3Event): boolean {
103
+ const config = getGithubConfig()
104
+ return (
105
+ config.dev &&
106
+ config.storage === 'fs' &&
107
+ Boolean(config.fsRoot) &&
108
+ isLocalRequest(event)
109
+ )
110
+ }
111
+
112
+ async function rootFiles(root: string): Promise<string[]> {
113
+ try {
114
+ return await readdir(root)
115
+ } catch {
116
+ return []
117
+ }
118
+ }
119
+
120
+ async function buildStatus(local: boolean): Promise<PlutoDeployStatus> {
121
+ const config = getGithubConfig()
122
+ const root = config.fsRoot
123
+ const packageManager = detectPackageManager(
124
+ local ? await rootFiles(root) : []
125
+ )
126
+ const text = renderPagesWorkflow({ branch: config.branch, packageManager })
127
+ const status: PlutoDeployStatus = {
128
+ repo: config.repo,
129
+ branch: config.branch,
130
+ local,
131
+ packageManager,
132
+ workflow: { path: PAGES_WORKFLOW_PATH, text },
133
+ pagesUrl: config.repo ? defaultPagesUrl(config.repo) : '',
134
+ }
135
+ if (local) {
136
+ const file = join(root, PAGES_WORKFLOW_PATH)
137
+ const exists = existsSync(file)
138
+ status.workflow.exists = exists
139
+ status.workflow.current = exists && (await readFile(file, 'utf8')) === text
140
+ status.pages = config.repo
141
+ ? await readPagesState(config.repo)
142
+ : { state: 'unknown', reason: 'error' }
143
+ }
144
+ return status
145
+ }
146
+
147
+ /**
148
+ * Route: GET /api/_pluto/github/deploy/status. The facts that the Deploy page needs: the repo,
149
+ * the branch, and the GitHub Pages workflow to copy. In local mode on this computer it also says
150
+ * whether the workflow is in the project and whether Pages is on (with `gh`). It needs a signed-in
151
+ * user.
152
+ */
153
+ export async function handleDeployStatus(
154
+ event: H3Event
155
+ ): Promise<PlutoDeployStatus> {
156
+ setResponseHeader(event, 'Cache-Control', 'no-store')
157
+ await requireGithubSession(event)
158
+ return buildStatus(isLocalMode(event))
159
+ }
160
+
161
+ /**
162
+ * Route: POST /api/_pluto/github/deploy/pages. Local mode on this computer only (404 otherwise).
163
+ * It writes the workflow when it is missing or old, commits it to the local repo, and turns Pages
164
+ * on with `gh` (the Actions source). The author pushes to deploy.
165
+ */
166
+ export async function handleSetupPages(
167
+ event: H3Event
168
+ ): Promise<PlutoPagesSetupResult> {
169
+ setResponseHeader(event, 'Cache-Control', 'no-store')
170
+ if (!isLocalMode(event)) {
171
+ throw createError({ statusCode: 404 })
172
+ }
173
+ const config = getGithubConfig()
174
+ const before = await buildStatus(true)
175
+
176
+ let committed = false
177
+ if (!before.workflow.current) {
178
+ const file = join(config.fsRoot, PAGES_WORKFLOW_PATH)
179
+ await mkdir(dirname(file), { recursive: true })
180
+ await writeFile(file, before.workflow.text, 'utf8')
181
+ try {
182
+ const result = await commitLocalFiles(
183
+ config.fsRoot,
184
+ [PAGES_WORKFLOW_PATH],
185
+ 'ci: deploy the site to GitHub Pages'
186
+ )
187
+ committed = result.committed
188
+ } catch (error) {
189
+ // The file is written. A hook or a missing author stops the commit only.
190
+ console.warn('[pluto-github] Could not commit the Pages workflow.', error)
191
+ }
192
+ }
193
+
194
+ const pages = config.repo
195
+ ? await enablePages(config.repo)
196
+ : ({ state: 'unknown', reason: 'error' } as PlutoPagesState)
197
+ const status = await buildStatus(true)
198
+ status.pages = pages
199
+ return {
200
+ status,
201
+ committed,
202
+ pagesOn: pages.state === 'on' && pages.source === 'workflow',
203
+ }
204
+ }
@@ -9,9 +9,7 @@ import {
9
9
  registerContentAdapter,
10
10
  registerContentType,
11
11
  } from '../../../../../server/utils/pluto-content'
12
- import {
13
- registerMediaDriver,
14
- } from '../../../../../server/utils/pluto-media-driver'
12
+ import { registerMediaDriver } from '../../../../../server/utils/pluto-media-driver'
15
13
  import {
16
14
  isPublicRead,
17
15
  markPublicRead,
@@ -0,0 +1,120 @@
1
+ import type { H3Event } from 'h3'
2
+ import type {
3
+ GithubCollaborator,
4
+ PlutoGithubUsersResponse,
5
+ } from '../../shared/repo-users'
6
+ import type { AppTokenProvider } from '../github/app-token'
7
+ import type { GithubConfig } from './github-config'
8
+ import { setResponseHeader } from 'h3'
9
+ import { toRepoUsers } from '../../shared/repo-users'
10
+ import { createAppTokenProvider } from '../github/app-token'
11
+ import { githubRest } from '../github/client'
12
+ import { toHttpError } from './build-status'
13
+ import { getGithubConfig } from './github-config'
14
+ import { requireGithubSession } from './session'
15
+
16
+ /** GitHub gives at most 100 collaborators per page. The route reads at most this many pages. */
17
+ const MAX_PAGES = 10
18
+ /** How long one list stays in memory. Several tabs and reloads share it. */
19
+ const CACHE_TTL = 30_000
20
+
21
+ const cache = new Map<string, { at: number; value: PlutoGithubUsersResponse }>()
22
+ let appTokens: { key: string; provider: AppTokenProvider } | undefined
23
+
24
+ /** Forgets the cached lists. For tests. */
25
+ export function clearGithubUsersCache(): void {
26
+ cache.clear()
27
+ appTokens = undefined
28
+ }
29
+
30
+ /** The token of the App (or the read token), for local mode. One provider per config. */
31
+ function appToken(config: GithubConfig): Promise<string> {
32
+ const key = [config.repo, config.appId, config.apiUrl, config.readToken].join(
33
+ '\n'
34
+ )
35
+ if (appTokens?.key !== key) {
36
+ appTokens = {
37
+ key,
38
+ provider: createAppTokenProvider({
39
+ repo: config.repo,
40
+ apiUrl: config.apiUrl,
41
+ appId: config.appId,
42
+ privateKey: config.privateKey,
43
+ readToken: config.readToken,
44
+ fetch: config.fetch,
45
+ }),
46
+ }
47
+ }
48
+ return appTokens.provider.getReadToken()
49
+ }
50
+
51
+ /** Reads every collaborator with push access or more, page by page. */
52
+ async function listCollaborators(
53
+ config: GithubConfig,
54
+ token: string
55
+ ): Promise<GithubCollaborator[]> {
56
+ const all: GithubCollaborator[] = []
57
+ for (let page = 1; page <= MAX_PAGES; page++) {
58
+ const { data } = await githubRest<GithubCollaborator[]>(
59
+ `/repos/${config.repo}/collaborators?affiliation=all&permission=push&per_page=100&page=${page}`,
60
+ { apiUrl: config.apiUrl, fetch: config.fetch, token }
61
+ )
62
+ all.push(...(data ?? []))
63
+ if (!data || data.length < 100) {
64
+ break
65
+ }
66
+ }
67
+ return all
68
+ }
69
+
70
+ /**
71
+ * Route: GET /api/_pluto/github/users. The people with write access to the content repo, read
72
+ * only. It needs a signed-in user, and reads with the token of that user. In local mode (the dev
73
+ * user has no GitHub token) it reads with the App installation token or the read token; with
74
+ * neither, it answers `reason: 'no-app'`, and `app-error` when the App gives no token (not
75
+ * installed on the repo, or a wrong key). Admins, then maintainers, then writers.
76
+ */
77
+ export async function handleGithubUsers(
78
+ event: H3Event
79
+ ): Promise<PlutoGithubUsersResponse> {
80
+ setResponseHeader(event, 'Cache-Control', 'no-store')
81
+ const session = await requireGithubSession(event)
82
+ const config = getGithubConfig()
83
+ const restricted = config.allowedUsers.length > 0
84
+ const base = { repo: config.repo, users: [], restricted }
85
+
86
+ if (!config.repo) {
87
+ return { ...base, reason: 'no-repo' }
88
+ }
89
+ const local = config.storage === 'fs' || session.local === true
90
+ if (local && !config.readToken && !(config.appId && config.privateKey)) {
91
+ return { ...base, reason: 'no-app' }
92
+ }
93
+
94
+ const cached = cache.get(config.repo)
95
+ if (cached && Date.now() - cached.at < CACHE_TTL) {
96
+ return cached.value
97
+ }
98
+
99
+ let token = session.token
100
+ if (local) {
101
+ try {
102
+ token = await appToken(config)
103
+ } catch {
104
+ // The App is not installed on the repo, or its key is wrong. The provider logs the cause.
105
+ return { ...base, reason: 'app-error' }
106
+ }
107
+ }
108
+
109
+ try {
110
+ const users = toRepoUsers(
111
+ await listCollaborators(config, token),
112
+ config.allowedUsers
113
+ )
114
+ const value: PlutoGithubUsersResponse = { ...base, users }
115
+ cache.set(config.repo, { at: Date.now(), value })
116
+ return value
117
+ } catch (error) {
118
+ throw toHttpError(error, event)
119
+ }
120
+ }
@@ -0,0 +1,95 @@
1
+ import type { H3Event } from 'h3'
2
+ import type { LocalGitStatus } from '../store/local-git'
3
+ import { createError, setResponseHeader } from 'h3'
4
+ import { createPlutoError } from '../../../../../server/utils/pluto-error'
5
+ import {
6
+ LocalPushFailure,
7
+ pushLocalGit,
8
+ readLocalGitStatus,
9
+ } from '../store/local-git'
10
+ import { getGithubConfig } from './github-config'
11
+ import { isLocalRequest } from './session'
12
+
13
+ /**
14
+ * The local Git routes exist for local mode only: a dev build, the `fs` store, and a request from
15
+ * this machine (the rule of the dev local user). Any other request gets 404, so the routes say
16
+ * nothing about the repo.
17
+ */
18
+ function localRoot(event: H3Event): string {
19
+ setResponseHeader(event, 'Cache-Control', 'no-store')
20
+ const config = getGithubConfig()
21
+ if (
22
+ !config.dev ||
23
+ config.storage !== 'fs' ||
24
+ !config.fsRoot ||
25
+ !isLocalRequest(event)
26
+ ) {
27
+ throw createError({ statusCode: 404 })
28
+ }
29
+ return config.fsRoot
30
+ }
31
+
32
+ /** Route: GET /api/_pluto/github/local/status. The branch and the commits that are not pushed. */
33
+ export async function handleLocalGitStatus(
34
+ event: H3Event
35
+ ): Promise<LocalGitStatus> {
36
+ return readLocalGitStatus(localRoot(event))
37
+ }
38
+
39
+ const PUSH_ERRORS: Record<
40
+ LocalPushFailure['reason'],
41
+ { statusCode: number; message: string; key: string }
42
+ > = {
43
+ 'not-a-repo': {
44
+ statusCode: 409,
45
+ message: 'The project is not a Git repo.',
46
+ key: 'notARepo',
47
+ },
48
+ detached: {
49
+ statusCode: 409,
50
+ message: 'Git is not on a branch.',
51
+ key: 'detached',
52
+ },
53
+ 'no-remote': {
54
+ statusCode: 409,
55
+ message: 'The repo has no remote.',
56
+ key: 'noRemote',
57
+ },
58
+ rejected: {
59
+ statusCode: 409,
60
+ message: 'The remote has commits that this repo does not have. Pull first.',
61
+ key: 'rejected',
62
+ },
63
+ auth: {
64
+ statusCode: 502,
65
+ message: 'Git could not sign in to the remote.',
66
+ key: 'auth',
67
+ },
68
+ timeout: {
69
+ statusCode: 504,
70
+ message: 'The push took too long.',
71
+ key: 'timeout',
72
+ },
73
+ failed: { statusCode: 502, message: 'Git could not push.', key: 'failed' },
74
+ }
75
+
76
+ /** Route: POST /api/_pluto/github/local/push. Pushes the current branch, then answers the status. */
77
+ export async function handleLocalGitPush(
78
+ event: H3Event
79
+ ): Promise<LocalGitStatus> {
80
+ const root = localRoot(event)
81
+ try {
82
+ return await pushLocalGit(root)
83
+ } catch (error) {
84
+ if (!(error instanceof LocalPushFailure)) {
85
+ throw error
86
+ }
87
+ const known = PUSH_ERRORS[error.reason]
88
+ throw createPlutoError({
89
+ statusCode: known.statusCode,
90
+ message: known.message,
91
+ key: `pluto.github.local.errors.${known.key}`,
92
+ data: { reason: error.reason },
93
+ })
94
+ }
95
+ }
@@ -245,6 +245,32 @@ export function reduceBuildStatus(sources: BuildStatusSources): {
245
245
  return { state: reduceBuildState(items), items }
246
246
  }
247
247
 
248
+ /**
249
+ * True when a commit message asks CI not to run: `[skip ci]`, `[ci skip]`, `[no ci]`,
250
+ * `[skip actions]` or `[actions skip]`, in any case. GitHub Actions reads the same markers. A draft
251
+ * save of the Git CMS gets `[skip ci]`.
252
+ */
253
+ export function hasSkipMarker(message: unknown): boolean {
254
+ return (
255
+ typeof message === 'string' &&
256
+ /\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]/i.test(message)
257
+ )
258
+ }
259
+
260
+ /**
261
+ * The commit whose builds the edit form shows: the newest commit that does not skip CI, from a
262
+ * list of commits, newest first. A draft save skips the deploy, so the build of the last real
263
+ * change still tells the state of the site. With no such commit, the newest one.
264
+ */
265
+ export function pickDeployCommit<
266
+ T extends { commit?: { message?: string } | null },
267
+ >(commits: readonly T[]): T | undefined {
268
+ return (
269
+ commits.find((commit) => !hasSkipMarker(commit.commit?.message)) ??
270
+ commits[0]
271
+ )
272
+ }
273
+
248
274
  /** The first line of a commit message, cut to 200 characters. */
249
275
  export function commitHeadline(message: unknown): string {
250
276
  const first =