@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.
- package/CHANGELOG.md +18 -0
- package/FEATURES.md +15 -4
- package/README.md +5 -5
- package/app/components/Pluto/Admin/PlutoAdminBackendBadge.vue +84 -0
- package/app/components/Pluto/Admin/PlutoAdminNavbar.vue +7 -0
- package/app/components/Pluto/Github/PlutoGithubBackendDetails.vue +180 -0
- package/app/components/Pluto/Github/PlutoGithubBuildStatus.vue +17 -4
- package/app/components/Pluto/Github/PlutoGithubDeployPage.vue +472 -0
- package/app/components/Pluto/Github/PlutoGithubSetupPanel.vue +1 -1
- package/app/components/Pluto/Github/PlutoGithubUsersPage.vue +214 -0
- package/app/components/Pluto/PlutoHtml.vue +8 -2
- package/app/composables/internal/async-component.ts +9 -0
- package/app/composables/pluto-extension.ts +4 -0
- package/app/composables/pluto-navbar.ts +25 -0
- package/app/composables/pluto-registry.ts +1 -0
- package/i18n/locales/en.json +164 -2
- package/i18n/locales/pt-BR.json +164 -2
- package/modules/pluto-github/index.ts +127 -51
- package/modules/pluto-github/options.ts +18 -5
- package/modules/pluto-github/runtime/app/composables/pluto-github-local-git.ts +50 -0
- package/modules/pluto-github/runtime/app/composables/pluto-github-session.ts +8 -1
- package/modules/pluto-github/runtime/app/plugins/pluto-github.ts +84 -1
- package/modules/pluto-github/runtime/server/adapter.ts +26 -7
- package/modules/pluto-github/runtime/server/api/deploy/pages.post.ts +5 -0
- package/modules/pluto-github/runtime/server/api/deploy/status.get.ts +5 -0
- package/modules/pluto-github/runtime/server/api/local/push.post.ts +5 -0
- package/modules/pluto-github/runtime/server/api/local/status.get.ts +5 -0
- package/modules/pluto-github/runtime/server/api/users.get.ts +5 -0
- package/modules/pluto-github/runtime/server/store/github.ts +4 -1
- package/modules/pluto-github/runtime/server/store/local-git.ts +145 -0
- package/modules/pluto-github/runtime/server/store/memory.ts +4 -1
- package/modules/pluto-github/runtime/server/utils/build-status.ts +16 -7
- package/modules/pluto-github/runtime/server/utils/deploy.ts +204 -0
- package/modules/pluto-github/runtime/server/utils/github-setup.ts +1 -3
- package/modules/pluto-github/runtime/server/utils/github-users.ts +120 -0
- package/modules/pluto-github/runtime/server/utils/local-git-routes.ts +95 -0
- package/modules/pluto-github/runtime/shared/build-status.ts +26 -0
- package/modules/pluto-github/runtime/shared/client-rules.ts +16 -0
- package/modules/pluto-github/runtime/shared/deploy.ts +43 -0
- package/modules/pluto-github/runtime/shared/pages-workflow.ts +138 -0
- package/modules/pluto-github/runtime/shared/repo-users.ts +97 -0
- package/package.json +1 -1
- package/shared/types/github.d.ts +2 -0
- package/shared/types/registry.ts +22 -0
- 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]) => ({
|
|
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 {
|
|
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
|
|
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
|
|
207
|
-
*
|
|
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
|
-
|
|
229
|
-
|
|
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 =
|