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.
Files changed (44) hide show
  1. package/lib/browser.d.ts +7 -0
  2. package/lib/browser.js +86 -0
  3. package/lib/categories/load-categories.d.ts +3 -0
  4. package/lib/categories/load-categories.js +32 -0
  5. package/lib/categories/sync-categories.d.ts +3 -0
  6. package/lib/categories/sync-categories.js +182 -0
  7. package/lib/cli.js +1 -5
  8. package/lib/config/load-package-config.d.ts +2 -8
  9. package/lib/config/load-package-config.js +36 -21
  10. package/lib/discussions/create-discussion.d.ts +3 -0
  11. package/lib/discussions/create-discussion.js +127 -0
  12. package/lib/discussions/load-discussion.d.ts +3 -0
  13. package/lib/discussions/load-discussion.js +29 -0
  14. package/lib/issues/create-issue.d.ts +2 -3
  15. package/lib/issues/create-issue.js +49 -7
  16. package/lib/issues/load-issue.js +10 -3
  17. package/lib/labels/load-labels.js +1 -1
  18. package/lib/labels/sync-labels.d.ts +3 -4
  19. package/lib/labels/sync-labels.js +16 -11
  20. package/lib/npms/download-package.js +2 -1
  21. package/lib/paths/resolve-local-path.js +1 -1
  22. package/lib/repos/ensure-repo.d.ts +2 -3
  23. package/lib/repos/ensure-repo.js +30 -11
  24. package/lib/repos/exists-repo.js +2 -2
  25. package/lib/repos/load-new-repository.d.ts +3 -0
  26. package/lib/repos/load-new-repository.js +31 -0
  27. package/lib/rulesets/load-rulesets.js +2 -2
  28. package/lib/rulesets/sync-rulesets.d.ts +2 -3
  29. package/lib/rulesets/sync-rulesets.js +23 -15
  30. package/lib/run.js +73 -40
  31. package/lib/types.d.ts +57 -10
  32. package/lib/utils/confirm.d.ts +5 -0
  33. package/lib/utils/confirm.js +10 -0
  34. package/lib/utils/load-resource.d.ts +1 -1
  35. package/lib/utils/load-resource.js +2 -2
  36. package/lib/utils/logger.d.ts +8 -2
  37. package/lib/utils/logger.js +41 -11
  38. package/lib/utils/open-page.d.ts +4 -0
  39. package/lib/utils/open-page.js +14 -0
  40. package/package.json +20 -13
  41. package/lib/utils/is-node-error.d.ts +0 -1
  42. package/lib/utils/is-node-error.js +0 -6
  43. package/lib/utils/is-record.d.ts +0 -1
  44. package/lib/utils/is-record.js +0 -3
@@ -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,3 @@
1
+ import { type Result } from '@zokugun/xtry';
2
+ import { type Category } from '../types.js';
3
+ export declare function loadCategories(filename: string): Promise<Result<Category[], string>>;
@@ -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,3 @@
1
+ import { type Failure } from '@zokugun/xtry';
2
+ import { type Context, type Category } from '../types.js';
3
+ export declare function syncCategories(context: Context, categories: Category[], keepExisting?: boolean): Promise<Failure<string> | undefined>;
@@ -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-labels', 'Do not delete labels missing from the configuration', false)
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 { err, ok, stringifyError, xtry, yerr, yress } from '@zokugun/xtry';
4
- import fse from 'fs-extra';
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.isFile()) {
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
- try {
78
- const content = await readFile(filename, 'utf8');
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 yress(normalizeConfig(parsed.value, root, name));
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.isFile()) {
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 labels = typeof data.labels === 'string' ? data.labels : undefined;
128
- const issue = typeof data.issue === 'string' ? data.issue : undefined;
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 (typeof data.rulesets === 'string') {
137
+ if (isNonBlankString(data.rulesets)) {
131
138
  rulesets = [data.rulesets];
132
139
  }
133
- else if (Array.isArray(data.rulesets) && data.rulesets.every((ruleset) => typeof ruleset === 'string')) {
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
- labels,
149
+ categories,
150
+ discussion,
139
151
  issue,
152
+ labels,
153
+ newRepository,
140
154
  rulesets,
155
+ order,
141
156
  });
142
157
  } // }}}
@@ -0,0 +1,3 @@
1
+ import { type Failure } from '@zokugun/xtry';
2
+ import { type Context, type Discussion } from '../types.js';
3
+ export declare function createDiscussion(context: Context, { title, body, category, labels, close, pin, lock }: Discussion): Promise<Failure<string> | undefined>;
@@ -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,3 @@
1
+ import { type Result } from '@zokugun/xtry';
2
+ import { type Discussion } from '../types.js';
3
+ export declare function loadDiscussion(filename: string): Promise<Result<Discussion, string>>;
@@ -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 Issue, type RepoReference } from '../types.js';
4
- export declare function createIssue(octokit: Octokit, repo: RepoReference, { title, body, labels }: Issue): Promise<Failure<string> | undefined>;
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>;