repo-starter-kit 0.2.0 → 0.3.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/lib/browser.d.ts +7 -0
- package/lib/browser.js +86 -0
- package/lib/categories/load-categories.d.ts +3 -0
- package/lib/categories/load-categories.js +32 -0
- package/lib/categories/sync-categories.d.ts +3 -0
- package/lib/categories/sync-categories.js +182 -0
- package/lib/cli.js +1 -5
- package/lib/config/load-package-config.d.ts +2 -8
- package/lib/config/load-package-config.js +36 -21
- package/lib/discussions/create-discussion.d.ts +3 -0
- package/lib/discussions/create-discussion.js +127 -0
- package/lib/discussions/load-discussion.d.ts +3 -0
- package/lib/discussions/load-discussion.js +29 -0
- package/lib/issues/create-issue.d.ts +2 -3
- package/lib/issues/create-issue.js +49 -7
- package/lib/issues/load-issue.js +10 -3
- package/lib/labels/load-labels.js +1 -1
- package/lib/labels/sync-labels.d.ts +3 -4
- package/lib/labels/sync-labels.js +16 -11
- package/lib/npms/download-package.js +2 -1
- package/lib/paths/resolve-local-path.js +1 -1
- package/lib/repos/ensure-repo.d.ts +2 -3
- package/lib/repos/ensure-repo.js +30 -11
- package/lib/repos/exists-repo.js +2 -2
- package/lib/repos/load-new-repository.d.ts +3 -0
- package/lib/repos/load-new-repository.js +31 -0
- package/lib/rulesets/load-rulesets.js +2 -2
- package/lib/rulesets/sync-rulesets.d.ts +2 -3
- package/lib/rulesets/sync-rulesets.js +23 -15
- package/lib/run.js +73 -40
- package/lib/types.d.ts +57 -10
- package/lib/utils/confirm.d.ts +5 -0
- package/lib/utils/confirm.js +10 -0
- package/lib/utils/load-resource.d.ts +1 -1
- package/lib/utils/load-resource.js +2 -2
- package/lib/utils/logger.d.ts +8 -2
- package/lib/utils/logger.js +41 -11
- package/lib/utils/open-page.d.ts +4 -0
- package/lib/utils/open-page.js +14 -0
- package/package.json +20 -13
- package/lib/utils/is-node-error.d.ts +0 -1
- package/lib/utils/is-node-error.js +0 -6
- package/lib/utils/is-record.d.ts +0 -1
- package/lib/utils/is-record.js +0 -3
package/lib/browser.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Failure, type Result } from '@zokugun/xtry';
|
|
2
|
+
import { type Browser, type Page } from 'playwright';
|
|
3
|
+
export type BrowserAction = (page: Page) => Promise<Failure<string> | undefined>;
|
|
4
|
+
export declare function openBrowser(owner: string, repositoryName: string): Promise<Result<{
|
|
5
|
+
browser: Browser;
|
|
6
|
+
page: Page;
|
|
7
|
+
}, string>>;
|
package/lib/browser.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { err, ok, stringifyError } from '@zokugun/xtry';
|
|
2
|
+
import enquirer from 'enquirer';
|
|
3
|
+
import { chromium } from 'playwright';
|
|
4
|
+
import * as logger from './utils/logger.js';
|
|
5
|
+
export async function openBrowser(owner, repositoryName) {
|
|
6
|
+
let browser;
|
|
7
|
+
try {
|
|
8
|
+
browser = await chromium.launch({
|
|
9
|
+
headless: false,
|
|
10
|
+
channel: 'chrome',
|
|
11
|
+
});
|
|
12
|
+
const page = await browser.newPage();
|
|
13
|
+
await page.goto(`https://github.com/${owner}/${repositoryName}`, {
|
|
14
|
+
waitUntil: 'domcontentloaded',
|
|
15
|
+
});
|
|
16
|
+
await page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => undefined);
|
|
17
|
+
const signInExists = await hasSignInButton(page);
|
|
18
|
+
if (signInExists) {
|
|
19
|
+
logger.pause();
|
|
20
|
+
const confirmed = await waitForEnterWithTimeout(300_000, 'GitHub Sign in detected. Please login in Chrome, then press ENTER here within 5 minutes.');
|
|
21
|
+
if (!confirmed) {
|
|
22
|
+
return err('Timed out waiting for login confirmation (5 minutes).');
|
|
23
|
+
}
|
|
24
|
+
logger.resume();
|
|
25
|
+
const signInExists = await hasSignInButton(page);
|
|
26
|
+
if (signInExists) {
|
|
27
|
+
return err('GitHub Sign is still detected');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return ok({ browser, page });
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
return err(`Failed to apply browser actions in Chrome (cross-platform): ${stringifyError(error)}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function hasSignInButton(page) {
|
|
37
|
+
const signInLocators = [
|
|
38
|
+
() => page.getByRole('button', { name: 'Sign in', exact: true }).first(),
|
|
39
|
+
() => page.getByRole('link', { name: 'Sign in', exact: true }).first(),
|
|
40
|
+
() => page.getByText('Sign in', { exact: true }).first(),
|
|
41
|
+
];
|
|
42
|
+
for (const createLocator of signInLocators) {
|
|
43
|
+
try {
|
|
44
|
+
if (await createLocator().count() > 0) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch { }
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
async function waitForEnterWithTimeout(timeoutMilliseconds, message) {
|
|
53
|
+
let timeoutId;
|
|
54
|
+
const promptPromise = enquirer
|
|
55
|
+
.prompt({
|
|
56
|
+
type: 'invisible',
|
|
57
|
+
name: 'open',
|
|
58
|
+
message,
|
|
59
|
+
})
|
|
60
|
+
// eslint-disable-next-line promise/prefer-await-to-then
|
|
61
|
+
.then(() => {
|
|
62
|
+
clearTimeout(timeoutId);
|
|
63
|
+
return true;
|
|
64
|
+
})
|
|
65
|
+
// eslint-disable-next-line promise/prefer-await-to-then
|
|
66
|
+
.catch(() => {
|
|
67
|
+
clearTimeout(timeoutId);
|
|
68
|
+
return false;
|
|
69
|
+
});
|
|
70
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
71
|
+
timeoutId = setTimeout(() => resolve(false), timeoutMilliseconds);
|
|
72
|
+
});
|
|
73
|
+
return Promise.race([promptPromise, timeoutPromise]);
|
|
74
|
+
}
|
|
75
|
+
// function resolveChromeUserDataDir(): string | undefined {
|
|
76
|
+
// if(process.env.CHROME_USER_DATA_DIR) {
|
|
77
|
+
// return process.env.CHROME_USER_DATA_DIR;
|
|
78
|
+
// }
|
|
79
|
+
// if(process.platform === 'darwin') {
|
|
80
|
+
// return `${process.env.HOME}/Library/Application Support/Google/Chrome`;
|
|
81
|
+
// }
|
|
82
|
+
// if(process.platform === 'win32') {
|
|
83
|
+
// return process.env.LOCALAPPDATA ? `${process.env.LOCALAPPDATA}\\Google\\Chrome\\User Data` : undefined;
|
|
84
|
+
// }
|
|
85
|
+
// return `${process.env.HOME}/.config/google-chrome`;
|
|
86
|
+
// }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { isRecord } from '@zokugun/is-it-type';
|
|
3
|
+
import { err, ok, stringifyError } from '@zokugun/xtry';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
export async function loadCategories(filename) {
|
|
6
|
+
let content;
|
|
7
|
+
try {
|
|
8
|
+
content = await readFile(filename, 'utf8');
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
return err(`Failed to read ${filename} from package: ${stringifyError(error)}`);
|
|
12
|
+
}
|
|
13
|
+
const records = YAML.parse(content);
|
|
14
|
+
if (!Array.isArray(records)) {
|
|
15
|
+
return err(`Category file ${filename} must contain an array.`);
|
|
16
|
+
}
|
|
17
|
+
const categories = [];
|
|
18
|
+
for (const [index, record] of records.entries()) {
|
|
19
|
+
if (!isRecord(record)) {
|
|
20
|
+
return err(`Category entry at index ${index} must be an object.`);
|
|
21
|
+
}
|
|
22
|
+
const name = String(record.name ?? '').trim();
|
|
23
|
+
const description = String(record.description ?? '').trim();
|
|
24
|
+
const emoji = String(record.emoji ?? '').trim();
|
|
25
|
+
const format = record.format === 'announcement' || record.format === 'answer' || record.format === 'poll' ? record.format : 'open';
|
|
26
|
+
if (name.length === 0) {
|
|
27
|
+
return err(`Category entry at index ${index} must define a non-empty 'name'.`);
|
|
28
|
+
}
|
|
29
|
+
categories.push({ name, description, emoji, format });
|
|
30
|
+
}
|
|
31
|
+
return ok(categories);
|
|
32
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { err, stringifyError } from '@zokugun/xtry';
|
|
2
|
+
import * as logger from '../utils/logger.js';
|
|
3
|
+
import { openPage } from '../utils/open-page.js';
|
|
4
|
+
export async function syncCategories(context, categories, keepExisting = false) {
|
|
5
|
+
if (categories.length === 0) {
|
|
6
|
+
logger.warn('No categories defined; skipping category sync.');
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
const response = await context.octokit.graphql(`query listCategories($owner: String!, $name: String!) {
|
|
11
|
+
repository(owner: $owner, name: $name) {
|
|
12
|
+
discussionCategories(first: 25) {
|
|
13
|
+
totalCount
|
|
14
|
+
|
|
15
|
+
pageInfo {
|
|
16
|
+
startCursor
|
|
17
|
+
endCursor
|
|
18
|
+
hasNextPage
|
|
19
|
+
hasPreviousPage
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
edges {
|
|
23
|
+
cursor
|
|
24
|
+
|
|
25
|
+
node {
|
|
26
|
+
name
|
|
27
|
+
description
|
|
28
|
+
emoji
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}`, {
|
|
34
|
+
owner: context.owner,
|
|
35
|
+
name: context.repositoryName,
|
|
36
|
+
});
|
|
37
|
+
const currentCategories = [];
|
|
38
|
+
for (const edge of response.repository.discussionCategories.edges) {
|
|
39
|
+
currentCategories.push(edge.node);
|
|
40
|
+
}
|
|
41
|
+
const openResult = await openPage(context);
|
|
42
|
+
if (openResult.fails) {
|
|
43
|
+
return openResult;
|
|
44
|
+
}
|
|
45
|
+
const page = openResult.value;
|
|
46
|
+
await page.goto(`https://github.com/${context.owner}/${context.repositoryName}/discussions/categories`, {
|
|
47
|
+
waitUntil: 'domcontentloaded',
|
|
48
|
+
});
|
|
49
|
+
await page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => undefined);
|
|
50
|
+
const items = await page.locator('li[data-view-component=true]').all();
|
|
51
|
+
for (const item of items) {
|
|
52
|
+
const header = item.locator('h2').first();
|
|
53
|
+
const title = await header.textContent();
|
|
54
|
+
if (!title) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const name = title.trim();
|
|
58
|
+
const match = currentCategories.filter((category) => name === category.name);
|
|
59
|
+
if (match) {
|
|
60
|
+
const icons = await header.locator('svg[aria-label="Restricted"]').count();
|
|
61
|
+
if (icons === 0) {
|
|
62
|
+
const answers = await item.getByText('Answers enabled').count();
|
|
63
|
+
if (answers === 0) {
|
|
64
|
+
match[0].format = 'open';
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
match[0].format = 'answer';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
match[0].format = 'announcement';
|
|
72
|
+
}
|
|
73
|
+
const href = await item.locator('a').first().getAttribute('href');
|
|
74
|
+
if (!href) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const result = /\/(\d+)\/edit/.exec(href);
|
|
78
|
+
if (result) {
|
|
79
|
+
match[0].id = result[1];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const desiredNames = new Set();
|
|
84
|
+
for (const category of categories) {
|
|
85
|
+
desiredNames.add(category.name);
|
|
86
|
+
const match = currentCategories.filter(({ name }) => name === category.name);
|
|
87
|
+
if (match.length === 0) {
|
|
88
|
+
await createCategory(context, category);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const current = match[0];
|
|
92
|
+
if (category.description !== current.description || category.emoji !== current.emoji || category.format !== current.format) {
|
|
93
|
+
await updateCategory(context, category, current.id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (keepExisting) {
|
|
98
|
+
logger.log('Keeping existing categories that are not in the configuration.');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
await deleteMissings(context, currentCategories, desiredNames);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
logger.error(stringifyError(error));
|
|
105
|
+
console.log(error);
|
|
106
|
+
return err(stringifyError(error));
|
|
107
|
+
}
|
|
108
|
+
} // }}}
|
|
109
|
+
async function deleteMissings(context, existings, desiredNames) {
|
|
110
|
+
for (const existing of existings) {
|
|
111
|
+
if (!desiredNames.has(existing.name)) {
|
|
112
|
+
await deleteCategory(context, existing);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} // }}}
|
|
116
|
+
async function createCategory({ page, owner, repositoryName }, category) {
|
|
117
|
+
logger.log(`Creating category: ${category.name}`);
|
|
118
|
+
await page.goto(`https://github.com/${owner}/${repositoryName}/discussions/categories/new`, {
|
|
119
|
+
waitUntil: 'domcontentloaded',
|
|
120
|
+
});
|
|
121
|
+
await page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => undefined);
|
|
122
|
+
await page.locator('#category\\[name\\]').first().fill(category.name);
|
|
123
|
+
await page.locator('#category\\[description\\]').first().fill(category.description ?? '');
|
|
124
|
+
if (category.emoji) {
|
|
125
|
+
await page.locator('input[type=hidden][name*=emoji]').evaluate((element, value) => {
|
|
126
|
+
element.value = value;
|
|
127
|
+
}, category.emoji);
|
|
128
|
+
}
|
|
129
|
+
if (category.format === 'announcement') {
|
|
130
|
+
await page.locator('#supports_announcements_true_discussion_category').first().check();
|
|
131
|
+
}
|
|
132
|
+
else if (category.format === 'answer') {
|
|
133
|
+
await page.locator('#supports_mark_as_answer_true_discussion_category').first().check();
|
|
134
|
+
}
|
|
135
|
+
else if (category.format === 'poll') {
|
|
136
|
+
await page.locator('#supports_polls_true_discussion_category').first().check();
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
await page.locator('#supports_mark_as_answer_false_discussion_category').first().check();
|
|
140
|
+
}
|
|
141
|
+
await page.locator('button.Button--primary[type=submit]').first().click();
|
|
142
|
+
await page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => undefined);
|
|
143
|
+
}
|
|
144
|
+
async function updateCategory({ page, owner, repositoryName }, category, id) {
|
|
145
|
+
logger.log(`Updating category: ${category.name}`);
|
|
146
|
+
await page.goto(`https://github.com/${owner}/${repositoryName}/discussions/categories/${id}/edit`, {
|
|
147
|
+
waitUntil: 'domcontentloaded',
|
|
148
|
+
});
|
|
149
|
+
await page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => undefined);
|
|
150
|
+
await page.locator('#category\\[name\\]').first().fill(category.name);
|
|
151
|
+
await page.locator('#category\\[description\\]').first().fill(category.description ?? '');
|
|
152
|
+
if (category.emoji) {
|
|
153
|
+
await page.locator('input[type=hidden][name*=emoji]').evaluate((element, value) => {
|
|
154
|
+
element.value = value;
|
|
155
|
+
}, category.emoji);
|
|
156
|
+
}
|
|
157
|
+
if (category.format === 'announcement') {
|
|
158
|
+
await page.locator(`#supports_announcements_true_discussion_category_${id}`).first().check();
|
|
159
|
+
}
|
|
160
|
+
else if (category.format === 'answer') {
|
|
161
|
+
await page.locator(`#supports_mark_as_answer_true_discussion_category_${id}`).first().check();
|
|
162
|
+
}
|
|
163
|
+
else if (category.format === 'poll') {
|
|
164
|
+
await page.locator(`#supports_polls_true_discussion_category_${id}`).first().check();
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
await page.locator(`#supports_mark_as_answer_false_discussion_category_${id}`).first().check();
|
|
168
|
+
}
|
|
169
|
+
await page.locator('button.Button--primary[type=submit]').first().click();
|
|
170
|
+
await page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => undefined);
|
|
171
|
+
}
|
|
172
|
+
async function deleteCategory({ page, owner, repositoryName }, category) {
|
|
173
|
+
logger.log(`Deleting category: ${category.name}`);
|
|
174
|
+
await page.goto(`https://github.com/${owner}/${repositoryName}/discussions/categories`, {
|
|
175
|
+
waitUntil: 'domcontentloaded',
|
|
176
|
+
});
|
|
177
|
+
await page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => undefined);
|
|
178
|
+
const form = page.locator(`form[action="/${owner}/${repositoryName}/discussions/categories/${category.id}"]`).first();
|
|
179
|
+
await form.locator('button.Button--link').first().click();
|
|
180
|
+
await form.locator('button.btn-danger').first().click();
|
|
181
|
+
await page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => undefined);
|
|
182
|
+
}
|
package/lib/cli.js
CHANGED
|
@@ -7,11 +7,7 @@ program
|
|
|
7
7
|
.description(pkg.description)
|
|
8
8
|
.requiredOption('-r, --repo <owner/name>', 'Target repository (OWNER/NAME)')
|
|
9
9
|
.option('-c, --create', 'Create the repository if it does not exist', false)
|
|
10
|
-
.option('-l, --labels <path>', 'Path to labels YAML')
|
|
11
|
-
.option('-i, --issue <path>', 'Path to issue Markdown template')
|
|
12
|
-
.option('-b, --rulesets <path>', 'Path to branch rulesets YAML/JSON file')
|
|
13
10
|
.option('-p, --package <name>', 'NPM package that includes a repo-starter-kit config file')
|
|
14
|
-
.option('--keep
|
|
15
|
-
.option('--keep-rulesets', 'Do not delete rulesets missing from the configuration', false)
|
|
11
|
+
.option('-k, --keep', 'Do not delete missing items (labels, categories, rulesets)', false)
|
|
16
12
|
.action(run);
|
|
17
13
|
program.parse();
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
import { type Result } from '@zokugun/xtry';
|
|
2
|
-
type Config
|
|
3
|
-
root: string;
|
|
4
|
-
labels?: string;
|
|
5
|
-
issue?: string;
|
|
6
|
-
rulesets?: string[];
|
|
7
|
-
};
|
|
1
|
+
import { type Result } from '@zokugun/xtry/sync';
|
|
2
|
+
import { type Config } from '../types.js';
|
|
8
3
|
export declare function loadConfig(value: string): Promise<Result<Config, string>>;
|
|
9
|
-
export {};
|
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
1
|
import path from 'node:path';
|
|
3
|
-
import
|
|
4
|
-
import
|
|
2
|
+
import fse from '@zokugun/fs-extra-plus/async';
|
|
3
|
+
import { isArray, isEquals, isNonBlankString, isRecord } from '@zokugun/is-it-type';
|
|
4
|
+
import { err, ok, stringifyError, xtry, yerr, yres } from '@zokugun/xtry/sync';
|
|
5
5
|
import YAML from 'yaml';
|
|
6
6
|
import { downloadPackage } from '../npms/download-package.js';
|
|
7
7
|
import { normalizePackageName } from '../npms/normalize-package-name.js';
|
|
8
8
|
import { splitNpmPath } from '../npms/split-npm-ath.js';
|
|
9
9
|
import { joinWithinRoot } from '../paths/join-within-root.js';
|
|
10
10
|
import { resolveLocalPath } from '../paths/resolve-local-path.js';
|
|
11
|
-
import { isNodeError } from '../utils/is-node-error.js';
|
|
12
|
-
import { isRecord } from '../utils/is-record.js';
|
|
13
11
|
const CONFIG_FILES = [
|
|
14
12
|
{
|
|
15
13
|
name: 'repo-starter-kit.yml',
|
|
@@ -57,7 +55,10 @@ export async function loadConfig(value) {
|
|
|
57
55
|
} // }}}
|
|
58
56
|
async function readConfigFromPackage(packageRoot, packageName) {
|
|
59
57
|
const stat = await fse.stat(packageRoot);
|
|
60
|
-
if (stat.
|
|
58
|
+
if (stat.fails) {
|
|
59
|
+
return err(stringifyError(stat.error));
|
|
60
|
+
}
|
|
61
|
+
if (stat.value.isFile()) {
|
|
61
62
|
const result = await tryReadConfigFile(packageRoot, path.dirname(packageRoot), path.basename(packageRoot));
|
|
62
63
|
if (result.fails || result.success) {
|
|
63
64
|
return result;
|
|
@@ -74,24 +75,27 @@ async function readConfigFromPackage(packageRoot, packageName) {
|
|
|
74
75
|
return err(`Package ${packageName} must include one of ${CONFIG_FILES.map(({ name }) => name).join(', ')} at its root.`);
|
|
75
76
|
} // }}}
|
|
76
77
|
async function tryReadConfigFile(filename, root, name, type) {
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
const { fails, error, value: content } = await fse.readFile(filename, 'utf8');
|
|
79
|
+
if (fails) {
|
|
80
|
+
if (error.code === 'ENOENT') {
|
|
81
|
+
return yerr('not-found');
|
|
82
|
+
}
|
|
83
|
+
return err(`Failed to read ${name} from package: ${stringifyError(error)}`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
79
86
|
const parsed = parseConfigContent(content, type);
|
|
80
87
|
if (parsed.fails) {
|
|
81
88
|
return err(`Failed to parse ${name} from package: ${parsed.error}`);
|
|
82
89
|
}
|
|
83
|
-
return
|
|
84
|
-
}
|
|
85
|
-
catch (error) {
|
|
86
|
-
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
87
|
-
return yerr('not-found');
|
|
88
|
-
}
|
|
89
|
-
return err(`Failed to read ${name} from package: ${stringifyError(error)}`);
|
|
90
|
+
return yres(normalizeConfig(parsed.value, root, name));
|
|
90
91
|
}
|
|
91
92
|
} // }}}
|
|
92
93
|
async function readConfigFromLocal(fileRoot) {
|
|
93
94
|
const stat = await fse.stat(fileRoot);
|
|
94
|
-
if (stat.
|
|
95
|
+
if (stat.fails) {
|
|
96
|
+
return err(stringifyError(stat.error));
|
|
97
|
+
}
|
|
98
|
+
if (stat.value.isFile()) {
|
|
95
99
|
const result = await tryReadConfigFile(fileRoot, path.dirname(fileRoot), path.basename(fileRoot));
|
|
96
100
|
if (result.fails || result.success) {
|
|
97
101
|
return result;
|
|
@@ -124,19 +128,30 @@ function normalizeConfig(data, root, source) {
|
|
|
124
128
|
if (!isRecord(data)) {
|
|
125
129
|
return err(`Config file ${source} must export an object.`);
|
|
126
130
|
}
|
|
127
|
-
const
|
|
128
|
-
const
|
|
131
|
+
const categories = isNonBlankString(data.categories) ? data.categories : undefined;
|
|
132
|
+
const discussion = isNonBlankString(data.discussion) ? data.discussion : undefined;
|
|
133
|
+
const labels = isNonBlankString(data.labels) ? data.labels : undefined;
|
|
134
|
+
const newRepository = isNonBlankString(data.newRepository) ? data.newRepository : undefined;
|
|
135
|
+
const issue = isNonBlankString(data.issue) ? data.issue : undefined;
|
|
129
136
|
let rulesets;
|
|
130
|
-
if (
|
|
137
|
+
if (isNonBlankString(data.rulesets)) {
|
|
131
138
|
rulesets = [data.rulesets];
|
|
132
139
|
}
|
|
133
|
-
else if (
|
|
140
|
+
else if (isArray(data.rulesets, isNonBlankString)) {
|
|
134
141
|
rulesets = data.rulesets;
|
|
135
142
|
}
|
|
143
|
+
let order;
|
|
144
|
+
if (isArray(data.order, (item) => isEquals(item, 'discussion', 'issue'))) {
|
|
145
|
+
order = data.order;
|
|
146
|
+
}
|
|
136
147
|
return ok({
|
|
137
148
|
root,
|
|
138
|
-
|
|
149
|
+
categories,
|
|
150
|
+
discussion,
|
|
139
151
|
issue,
|
|
152
|
+
labels,
|
|
153
|
+
newRepository,
|
|
140
154
|
rulesets,
|
|
155
|
+
order,
|
|
141
156
|
});
|
|
142
157
|
} // }}}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { err, stringifyError } from '@zokugun/xtry';
|
|
2
|
+
import * as logger from '../utils/logger.js';
|
|
3
|
+
import { openPage } from '../utils/open-page.js';
|
|
4
|
+
export async function createDiscussion(context, { title, body, category, labels, close, pin, lock }) {
|
|
5
|
+
const { octokit, owner, repositoryName, repositoryId } = context;
|
|
6
|
+
try {
|
|
7
|
+
logger.log(`Creating discussion '${title}'`);
|
|
8
|
+
const categories = await octokit.graphql(`query listCategories($owner: String!, $name: String!) {
|
|
9
|
+
repository(owner: $owner, name: $name) {
|
|
10
|
+
discussionCategories(first: 25) {
|
|
11
|
+
totalCount
|
|
12
|
+
|
|
13
|
+
pageInfo {
|
|
14
|
+
startCursor
|
|
15
|
+
endCursor
|
|
16
|
+
hasNextPage
|
|
17
|
+
hasPreviousPage
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
edges {
|
|
21
|
+
cursor
|
|
22
|
+
|
|
23
|
+
node {
|
|
24
|
+
id
|
|
25
|
+
name
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}`, {
|
|
31
|
+
owner,
|
|
32
|
+
name: repositoryName,
|
|
33
|
+
});
|
|
34
|
+
const categoryId = categories.repository.discussionCategories.edges.filter(({ node }) => node.name === category).map(({ node }) => node.id)[0];
|
|
35
|
+
if (!categoryId) {
|
|
36
|
+
return err(`Cannot find category "${category}"`);
|
|
37
|
+
}
|
|
38
|
+
const response = await octokit.graphql(`mutation createDiscussion($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
|
|
39
|
+
createDiscussion(input: {repositoryId: $repositoryId, categoryId: $categoryId, title: $title, body: $body}) {
|
|
40
|
+
discussion {
|
|
41
|
+
id
|
|
42
|
+
number
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}`, {
|
|
46
|
+
repositoryId,
|
|
47
|
+
categoryId,
|
|
48
|
+
title,
|
|
49
|
+
body,
|
|
50
|
+
});
|
|
51
|
+
const discussionId = response.createDiscussion.discussion.id;
|
|
52
|
+
const discussionNumber = response.createDiscussion.discussion.number;
|
|
53
|
+
if (labels.length > 0) {
|
|
54
|
+
logger.log(`Adding labels to discussion '${title}'`);
|
|
55
|
+
const labelsForRepo = await octokit.rest.issues.listLabelsForRepo({
|
|
56
|
+
owner,
|
|
57
|
+
repo: repositoryName,
|
|
58
|
+
});
|
|
59
|
+
const labelIds = [];
|
|
60
|
+
for (const name of labels) {
|
|
61
|
+
const label = labelsForRepo.data.filter((label) => label.name === name);
|
|
62
|
+
if (label.length > 0) {
|
|
63
|
+
labelIds.push(label[0].node_id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
await octokit.graphql(`mutation addLabels($discussionId: ID!, $labelIds: [ID!]!) {
|
|
67
|
+
addLabelsToLabelable(input: {labelableId: $discussionId, labelIds: $labelIds}) {
|
|
68
|
+
labelable {
|
|
69
|
+
... on Discussion {
|
|
70
|
+
id
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}`, {
|
|
75
|
+
discussionId,
|
|
76
|
+
labelIds,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (close) {
|
|
80
|
+
logger.log(`Closing discussion '${title}'`);
|
|
81
|
+
const reason = close === 'resolved' ? 'RESOLVED' : 'OUTDATED';
|
|
82
|
+
await octokit.graphql(`mutation closeDiscussion($discussionId: ID!, $reason: DiscussionCloseReason!) {
|
|
83
|
+
closeDiscussion(input: {discussionId: $discussionId, reason: $reason}) {
|
|
84
|
+
discussion {
|
|
85
|
+
id
|
|
86
|
+
closed
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}`, {
|
|
90
|
+
discussionId,
|
|
91
|
+
reason,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (lock) {
|
|
95
|
+
logger.log(`Locking discussion '${title}'`);
|
|
96
|
+
await octokit.graphql(`mutation lockDiscussion($discussionId: ID!) {
|
|
97
|
+
lockLockable(input: {lockableId: $discussionId}) {
|
|
98
|
+
lockedRecord {
|
|
99
|
+
locked
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}`, {
|
|
103
|
+
discussionId,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (pin) {
|
|
107
|
+
logger.log(`Pinning discussion '${title}'`);
|
|
108
|
+
const openResult = await openPage(context);
|
|
109
|
+
if (openResult.fails) {
|
|
110
|
+
return openResult;
|
|
111
|
+
}
|
|
112
|
+
const page = openResult.value;
|
|
113
|
+
await page.goto(`https://github.com/${owner}/${repositoryName}/discussions/${discussionNumber}`, {
|
|
114
|
+
waitUntil: 'domcontentloaded',
|
|
115
|
+
});
|
|
116
|
+
await page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => undefined);
|
|
117
|
+
await page.locator('#dialog-show-discussion-create-spotlight').first().click();
|
|
118
|
+
await page.locator('#discussion_spotlight_preconfigured_color_green').first().check();
|
|
119
|
+
await page.locator('button.Button--primary[type=submit]').getByText('Pin discussion').first().click();
|
|
120
|
+
await page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => undefined);
|
|
121
|
+
}
|
|
122
|
+
logger.log(`Created discussion '${title}' (#${discussionNumber}).`);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
return err(stringifyError(error));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { isArray, isBoolean, isEquals, isNonBlankString } from '@zokugun/is-it-type';
|
|
3
|
+
import { err, ok, stringifyError } from '@zokugun/xtry';
|
|
4
|
+
import matter from 'gray-matter';
|
|
5
|
+
export async function loadDiscussion(filename) {
|
|
6
|
+
let content;
|
|
7
|
+
try {
|
|
8
|
+
content = await readFile(filename, 'utf8');
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
return err(`Failed to read ${filename} from package: ${stringifyError(error)}`);
|
|
12
|
+
}
|
|
13
|
+
const parsed = matter(content);
|
|
14
|
+
const title = isNonBlankString(parsed.data.title) ? parsed.data.title.trim() : '';
|
|
15
|
+
const category = isNonBlankString(parsed.data.category) ? parsed.data.category.trim() : '';
|
|
16
|
+
if (title.length === 0) {
|
|
17
|
+
return err(`Missing title in ${filename}`);
|
|
18
|
+
}
|
|
19
|
+
if (category.length === 0) {
|
|
20
|
+
return err(`Missing category in ${filename}`);
|
|
21
|
+
}
|
|
22
|
+
const rawLabels = isArray(parsed.data.labels) ? parsed.data.labels : [];
|
|
23
|
+
const labels = rawLabels.map((label) => String(label).trim()).filter((label) => label.length > 0);
|
|
24
|
+
const close = isEquals(parsed.data.close, 'resolved', 'outdated') ? parsed.data.close : undefined;
|
|
25
|
+
const pin = isBoolean(parsed.data.pin) ? parsed.data.pin : undefined;
|
|
26
|
+
const lock = isBoolean(parsed.data.lock) ? parsed.data.lock : undefined;
|
|
27
|
+
const body = parsed.content;
|
|
28
|
+
return ok({ title, body, category, labels, close, pin, lock });
|
|
29
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { type Octokit } from '@octokit/rest';
|
|
2
1
|
import { type Failure } from '@zokugun/xtry';
|
|
3
|
-
import { type
|
|
4
|
-
export declare function createIssue(
|
|
2
|
+
import { type Context, type Issue } from '../types.js';
|
|
3
|
+
export declare function createIssue(context: Context, { title, body, labels, close, pin, lock }: Issue): Promise<Failure<string> | undefined>;
|