@papillon6814/pi-gh 0.0.0-stage → 0.2.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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -2
  3. package/dist/src/core/approval.d.ts +2 -0
  4. package/dist/src/core/approval.js +25 -0
  5. package/dist/src/core/data.d.ts +19 -0
  6. package/dist/src/core/data.js +136 -0
  7. package/dist/src/core/draft.d.ts +6 -0
  8. package/dist/src/core/draft.js +92 -0
  9. package/dist/src/core/forms.d.ts +3 -0
  10. package/dist/src/core/forms.js +54 -0
  11. package/dist/src/core/gh-process.d.ts +14 -0
  12. package/dist/src/core/gh-process.js +62 -0
  13. package/dist/src/core/issue-labels.d.ts +9 -0
  14. package/dist/src/core/issue-labels.js +52 -0
  15. package/dist/src/core/labels-apply.d.ts +12 -0
  16. package/dist/src/core/labels-apply.js +73 -0
  17. package/dist/src/core/labels-approval.d.ts +10 -0
  18. package/dist/src/core/labels-approval.js +12 -0
  19. package/dist/src/core/labels-github.d.ts +54 -0
  20. package/dist/src/core/labels-github.js +114 -0
  21. package/dist/src/core/labels-input.d.ts +31 -0
  22. package/dist/src/core/labels-input.js +58 -0
  23. package/dist/src/core/render.d.ts +6 -0
  24. package/dist/src/core/render.js +21 -0
  25. package/dist/src/core/secrets.d.ts +9 -0
  26. package/dist/src/core/secrets.js +35 -0
  27. package/dist/src/core/submit.d.ts +3 -0
  28. package/dist/src/core/submit.js +37 -0
  29. package/dist/src/core/template.d.ts +2 -0
  30. package/dist/src/core/template.js +80 -0
  31. package/dist/src/core/types.d.ts +95 -0
  32. package/dist/src/core/types.js +1 -0
  33. package/dist/src/github-api.d.ts +28 -0
  34. package/dist/src/github-api.js +100 -0
  35. package/dist/src/operation-input.d.ts +35 -0
  36. package/dist/src/operation-input.js +88 -0
  37. package/dist/src/operations.d.ts +3 -0
  38. package/dist/src/operations.js +137 -0
  39. package/dist/src/paths.d.ts +4 -0
  40. package/dist/src/paths.js +21 -0
  41. package/dist/src/permissions.d.ts +10 -0
  42. package/dist/src/permissions.js +100 -0
  43. package/dist/src/result.d.ts +4 -0
  44. package/dist/src/result.js +5 -0
  45. package/dist/src/service-types.d.ts +37 -0
  46. package/dist/src/service-types.js +1 -0
  47. package/dist/src/service.d.ts +4 -0
  48. package/dist/src/service.js +123 -0
  49. package/examples/labels-create.json +8 -0
  50. package/examples/labels-delete.json +6 -0
  51. package/examples/labels-edit.json +9 -0
  52. package/examples/labels-issue.json +12 -0
  53. package/examples/parent.json +37 -0
  54. package/examples/task.json +51 -0
  55. package/extensions/approval-ui.ts +41 -0
  56. package/extensions/index.ts +12 -0
  57. package/extensions/runtime-scope.ts +15 -0
  58. package/extensions/tools.ts +53 -0
  59. package/package.json +51 -4
  60. package/templates/parent.yml +47 -0
  61. package/templates/policies/models.yml +20 -0
  62. package/templates/task.yml +71 -0
@@ -0,0 +1,52 @@
1
+ import { createPreview } from './approval.js';
2
+ import { GhClient, repositoryLabels, requireLabels, parseIssue, readIssue, repositoryIdentity } from './labels-github.js';
3
+ import { sha } from './labels-approval.js';
4
+ import { renderIssue } from './render.js';
5
+ import { maskDecodedSecrets } from './secrets.js';
6
+ import { record, parseJson } from './data.js';
7
+ export async function createIssuePreview(issue, options = {}, client) {
8
+ const base = createPreview(issue);
9
+ if (!issue.draft.labels?.length || base.sensitive)
10
+ return base;
11
+ const gh = client ?? new GhClient(issue.draft.repo, options);
12
+ const repository = await repositoryIdentity(gh);
13
+ const labels = requireLabels(await repositoryLabels(gh), issue.draft.labels);
14
+ const masked = maskDecodedSecrets({ repository, labels });
15
+ const state = masked.value;
16
+ return { ...base, repository: state.repository, labels: state.labels.map(l => l.name), labelState: state.labels, digest: sha(JSON.stringify({ version: 1, host: 'github.com', issueDigest: base.digest, repository, labels })), sensitive: base.sensitive || masked.sensitive };
17
+ }
18
+ export async function submitLabelledIssue(issue, digest, options = {}) {
19
+ const client = new GhClient(issue.draft.repo, options);
20
+ let preview;
21
+ try {
22
+ preview = await createIssuePreview(issue, options, client);
23
+ }
24
+ catch {
25
+ return { status: 'rejected', problems: [{ code: 'LABEL_PREFLIGHT', path: 'labels', message: 'Cannot confirm requested labels. No Issue created.' }] };
26
+ }
27
+ if (preview.sensitive)
28
+ return { status: 'rejected', problems: [{ code: 'SENSITIVE', path: 'labels', message: 'Remove secret candidates before submission.' }] };
29
+ if (preview.digest !== digest)
30
+ return { status: 'rejected', problems: [{ code: 'APPROVAL_MISMATCH', path: '', message: 'Input or labels changed; preview and approve again.' }] };
31
+ const fixed = renderIssue(issue);
32
+ const result = await client.request('POST', 'issues', { title: fixed.title, body: fixed.body, labels: preview.labels });
33
+ if (result.status === 'not-started')
34
+ return { status: 'not-started', message: 'Issue creation process did not start.' };
35
+ const uncertain = { status: 'unknown', message: 'Issue creation outcome is uncertain. Check GitHub manually before retrying.' };
36
+ if (result.status !== 'ok')
37
+ return uncertain;
38
+ try {
39
+ const raw = parseJson(Buffer.from(result.stdout));
40
+ const created = parseIssue(raw, fixed.repo);
41
+ if (!record(raw) || raw.title !== fixed.title || raw.body !== fixed.body || created.identity.kind !== 'issue')
42
+ return uncertain;
43
+ const current = await readIssue(client, created.identity.number);
44
+ const confirmed = await client.read('issues/' + created.identity.number);
45
+ if (current.identity.id !== created.identity.id || current.identity.node_id !== created.identity.node_id || JSON.stringify(current.labels) !== JSON.stringify(preview.labelState) || !record(confirmed) || confirmed.title !== fixed.title || confirmed.body !== fixed.body)
46
+ return { ...uncertain, candidateUrl: created.identity.url };
47
+ return { status: 'created', url: created.identity.url };
48
+ }
49
+ catch {
50
+ return uncertain;
51
+ }
52
+ }
@@ -0,0 +1,12 @@
1
+ import type { CapturedInput, ValidationProblem } from './types.js';
2
+ import type { GhOptions } from './gh-process.js';
3
+ export type LabelOutcome = {
4
+ status: 'applied' | 'noop';
5
+ } | {
6
+ status: 'rejected';
7
+ problems: ValidationProblem[];
8
+ } | {
9
+ status: 'not-started' | 'unknown';
10
+ message: string;
11
+ };
12
+ export declare function applyLabelChange(input: CapturedInput, digest: string, options?: GhOptions): Promise<LabelOutcome>;
@@ -0,0 +1,73 @@
1
+ import { DataError, fail } from './data.js';
2
+ import { validateLabelChange } from './labels-input.js';
3
+ import { previewLabelChange } from './labels-approval.js';
4
+ import { GhClient, repositoryLabels, readIssue } from './labels-github.js';
5
+ import { maskSecrets, maskDecodedSecrets } from './secrets.js';
6
+ export async function applyLabelChange(input, digest, options = {}) {
7
+ let started = false;
8
+ try {
9
+ if (!/^[a-f0-9]{64}$/.test(digest))
10
+ fail('APPROVAL_MISMATCH', '', 'A valid approval digest is required.');
11
+ const change = validateLabelChange(input);
12
+ if (maskDecodedSecrets(change).sensitive || maskSecrets(input.bytes.toString('utf8')).sensitive)
13
+ fail('SENSITIVE', '', 'Remove secret candidates before applying.');
14
+ const client = new GhClient(change.repo, options);
15
+ const preview = await previewLabelChange(input, options, client);
16
+ if (preview.sensitive)
17
+ fail('SENSITIVE', '', 'Remove secret candidates before applying.');
18
+ if (preview.digest !== digest)
19
+ fail('APPROVAL_MISMATCH', '', 'Input or GitHub state changed; preview and approve again.');
20
+ if (preview.noop)
21
+ return { status: 'noop' };
22
+ let suffix, method, payload;
23
+ if (change.operation === 'issue-labels') {
24
+ suffix = 'issues/' + change.issue;
25
+ method = 'PATCH';
26
+ payload = { labels: preview.after.map(l => l.name) };
27
+ }
28
+ else if (change.operation === 'label-create') {
29
+ suffix = 'labels';
30
+ method = 'POST';
31
+ payload = { name: change.name, color: change.color, description: change.description ?? '' };
32
+ }
33
+ else {
34
+ suffix = 'labels/' + encodeURIComponent(preview.before.name);
35
+ if (change.operation === 'label-delete') {
36
+ method = 'DELETE';
37
+ }
38
+ else {
39
+ method = 'PATCH';
40
+ payload = { ...(change.newName === undefined ? {} : { new_name: change.newName }), ...(change.color === undefined ? {} : { color: change.color }), ...(change.description === undefined ? {} : { description: change.description }) };
41
+ }
42
+ }
43
+ const result = await client.request(method, suffix, payload);
44
+ if (result.status === 'not-started')
45
+ return { status: 'not-started', message: 'Change process did not start.' };
46
+ started = true;
47
+ if (result.status !== 'ok')
48
+ throw new Error('Uncertain write');
49
+ if (change.operation === 'issue-labels') {
50
+ const current = await readIssue(client, change.issue);
51
+ if (JSON.stringify(current.identity) !== JSON.stringify(preview.issue) || JSON.stringify(current.labels) !== JSON.stringify(preview.after))
52
+ throw new Error('Unconfirmed issue labels');
53
+ }
54
+ else {
55
+ const all = await repositoryLabels(client), expected = preview.after, before = preview.before;
56
+ if (!expected) {
57
+ if (all.some(l => l.id === before.id || l.name.toLowerCase() === before.name.toLowerCase()))
58
+ throw new Error('Unconfirmed deletion');
59
+ }
60
+ else {
61
+ const current = all.find(l => l.name === expected.name);
62
+ if (!current || current.color !== expected.color || current.description !== expected.description || (before && (current.id !== before.id || current.node_id !== before.node_id)))
63
+ throw new Error('Unconfirmed label');
64
+ }
65
+ }
66
+ return { status: 'applied' };
67
+ }
68
+ catch (e) {
69
+ if (started)
70
+ return { status: 'unknown', message: 'Change outcome is uncertain. Check GitHub manually before retrying.' };
71
+ return { status: 'rejected', problems: [e instanceof DataError ? { code: e.code, path: e.field, message: e.message } : { code: 'LABEL_FAILED', path: '', message: 'Could not confirm label change. No changes performed.' }] };
72
+ }
73
+ }
@@ -0,0 +1,10 @@
1
+ import { GhClient, type LabelState } from './labels-github.js';
2
+ import type { CapturedInput } from './types.js';
3
+ import type { GhOptions } from './gh-process.js';
4
+ export type LabelPreview = LabelState & {
5
+ digest: string;
6
+ sensitive: boolean;
7
+ noop: boolean;
8
+ };
9
+ export declare const sha: (value: string | Buffer) => string;
10
+ export declare function previewLabelChange(input: CapturedInput, options?: GhOptions, client?: GhClient): Promise<LabelPreview>;
@@ -0,0 +1,12 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { validateLabelChange } from './labels-input.js';
3
+ import { GhClient, captureLabelState } from './labels-github.js';
4
+ import { maskSecrets, maskDecodedSecrets } from './secrets.js';
5
+ export const sha = (value) => createHash('sha256').update(value).digest('hex');
6
+ export async function previewLabelChange(input, options = {}, client) {
7
+ const change = validateLabelChange(input);
8
+ const state = await captureLabelState(change, client ?? new GhClient(change.repo, options));
9
+ const digest = sha(JSON.stringify({ version: 1, host: 'github.com', path: input.path, input: sha(input.bytes), state }));
10
+ const masked = maskDecodedSecrets(state);
11
+ return { ...masked.value, digest, sensitive: masked.sensitive || maskDecodedSecrets(change).sensitive || maskSecrets(input.bytes.toString('utf8')).sensitive, noop: JSON.stringify(state.before) === JSON.stringify(state.after) };
12
+ }
@@ -0,0 +1,54 @@
1
+ import { type GhOptions, type GhResult } from './gh-process.js';
2
+ import { type LabelChange } from './labels-input.js';
3
+ export type Label = {
4
+ id: number;
5
+ node_id: string;
6
+ name: string;
7
+ color: string;
8
+ description: string;
9
+ };
10
+ export type Affected = {
11
+ id: number;
12
+ node_id: string;
13
+ number: number;
14
+ url: string;
15
+ kind: 'issue' | 'pull-request';
16
+ };
17
+ export type Repository = {
18
+ id: number;
19
+ node_id: string;
20
+ full_name: string;
21
+ };
22
+ export declare function repositoryIdentity(client: GhClient): Promise<Repository>;
23
+ export type LabelState = {
24
+ repository: Repository;
25
+ repo: string;
26
+ operation: LabelChange['operation'];
27
+ issue?: Affected;
28
+ before: Label | Label[] | null;
29
+ after: Label | Label[] | null;
30
+ affected: Affected[];
31
+ };
32
+ export declare class GhClient {
33
+ readonly repo: string;
34
+ private readonly options;
35
+ private readonly expires;
36
+ constructor(repo: string, options?: GhOptions);
37
+ endpoint(suffix: string): string;
38
+ request(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', suffix: string, payload?: unknown): Promise<GhResult>;
39
+ read(suffix: string): Promise<unknown>;
40
+ list(suffix: string): Promise<unknown[]>;
41
+ }
42
+ export declare function parseLabel(v: unknown): Label;
43
+ export declare const sortLabels: (labels: Label[]) => Label[];
44
+ export declare function repositoryLabels(client: GhClient): Promise<Label[]>;
45
+ export declare function requireLabels(all: Label[], names: string[]): Label[];
46
+ export declare function parseIssue(v: unknown, repo: string): {
47
+ identity: Affected;
48
+ labels: Label[];
49
+ };
50
+ export declare function readIssue(client: GhClient, number: number): Promise<{
51
+ identity: Affected;
52
+ labels: Label[];
53
+ }>;
54
+ export declare function captureLabelState(change: LabelChange, client: GhClient): Promise<LabelState>;
@@ -0,0 +1,114 @@
1
+ import { fail, record, nonblank, parseJson } from './data.js';
2
+ import { runGh } from './gh-process.js';
3
+ import { labelKey, validateLabelName, validateRepo } from './labels-input.js';
4
+ export async function repositoryIdentity(client) {
5
+ const value = await client.read('');
6
+ if (!record(value) || !Number.isSafeInteger(value.id) || value.id < 1 || !nonblank(value.node_id) || typeof value.full_name !== 'string' || value.full_name.toLowerCase() !== client.repo.toLowerCase() || typeof value.html_url !== 'string' || value.html_url.toLowerCase() !== ('https://github.com/' + client.repo).toLowerCase())
7
+ fail('GITHUB_REPOSITORY', '', 'Cannot confirm the requested repository identity.');
8
+ return { id: value.id, node_id: value.node_id, full_name: value.full_name };
9
+ }
10
+ export class GhClient {
11
+ repo;
12
+ options;
13
+ expires = Date.now() + 60000;
14
+ constructor(repo, options = {}) {
15
+ this.repo = repo;
16
+ this.options = options;
17
+ validateRepo(repo);
18
+ }
19
+ endpoint(suffix) { return 'repos/' + this.repo + (suffix ? '/' + suffix : ''); }
20
+ async request(method, suffix, payload) {
21
+ const remaining = Math.min(this.options.timeoutMs ?? 60000, this.expires - Date.now());
22
+ if (remaining <= 0)
23
+ return { status: 'not-started', stdout: '' };
24
+ const args = ['api', '--hostname=github.com', '--method', method, this.endpoint(suffix), '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', ...(payload === undefined ? [] : ['--input', '-'])];
25
+ return runGh(args, payload === undefined ? '' : JSON.stringify(payload), { ...this.options, timeoutMs: remaining });
26
+ }
27
+ async read(suffix) {
28
+ const r = await this.request('GET', suffix);
29
+ if (r.status !== 'ok')
30
+ fail('GITHUB_READ', '', 'Cannot confirm GitHub state. No changes performed.');
31
+ try {
32
+ return parseJson(Buffer.from(r.stdout));
33
+ }
34
+ catch {
35
+ fail('GITHUB_RESPONSE', '', 'Invalid GitHub response.');
36
+ }
37
+ }
38
+ async list(suffix) {
39
+ const result = [];
40
+ let bytes = 0;
41
+ for (let page = 1; page <= 100; page++) {
42
+ const value = await this.read(suffix + (suffix.includes('?') ? '&' : '?') + 'per_page=100&page=' + page);
43
+ if (!Array.isArray(value) || value.length > 100)
44
+ fail('GITHUB_RESPONSE', '', 'Expected a paginated array.');
45
+ bytes += Buffer.byteLength(JSON.stringify(value));
46
+ if (bytes > 1048576)
47
+ fail('GITHUB_LIMIT', '', 'Complete listing exceeds 1 MiB. Narrow the repository or inspect manually.');
48
+ result.push(...value);
49
+ if (value.length < 100)
50
+ return result;
51
+ }
52
+ fail('GITHUB_LIMIT', '', 'Incomplete listing: more than 100 pages.');
53
+ }
54
+ }
55
+ export function parseLabel(v) {
56
+ if (!record(v) || !Number.isSafeInteger(v.id) || v.id < 1 || !nonblank(v.node_id) || typeof v.color !== 'string' || !/^[a-fA-F0-9]{6}$/.test(v.color) || (v.description !== null && typeof v.description !== 'string'))
57
+ fail('GITHUB_RESPONSE', '', 'Invalid label.');
58
+ validateLabelName(v.name);
59
+ return { id: v.id, node_id: v.node_id, name: v.name, color: v.color.toLowerCase(), description: v.description ?? '' };
60
+ }
61
+ export const sortLabels = (labels) => labels.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : a.id - b.id);
62
+ export async function repositoryLabels(client) {
63
+ const labels = (await client.list('labels')).map(parseLabel);
64
+ if (new Set(labels.map(l => labelKey(l.name))).size !== labels.length || new Set(labels.map(l => l.id)).size !== labels.length)
65
+ fail('GITHUB_RESPONSE', '', 'Duplicate labels in listing.');
66
+ return sortLabels(labels);
67
+ }
68
+ export function requireLabels(all, names) {
69
+ return sortLabels(names.map(n => { const l = all.find(x => labelKey(x.name) === labelKey(n)); if (!l)
70
+ fail('LABEL_MISSING', '', 'Requested label does not exist. Create it separately.'); return l; }));
71
+ }
72
+ export function parseIssue(v, repo) {
73
+ if (!record(v) || !Number.isSafeInteger(v.id) || v.id < 1 || !nonblank(v.node_id) || !Number.isSafeInteger(v.number) || v.number < 1 || !Array.isArray(v.labels) || typeof v.html_url !== 'string')
74
+ fail('GITHUB_RESPONSE', '', 'Invalid Issue response.');
75
+ const kind = v.pull_request === undefined ? 'issue' : 'pull-request';
76
+ const expected = 'https://github.com/' + repo + '/' + (kind === 'issue' ? 'issues' : 'pull') + '/' + v.number;
77
+ if (v.html_url.toLowerCase() !== expected.toLowerCase())
78
+ fail('GITHUB_RESPONSE', '', 'Unexpected Issue URL.');
79
+ const labels = v.labels.map(parseLabel);
80
+ if (new Set(labels.map(l => l.id)).size !== labels.length || new Set(labels.map(l => labelKey(l.name))).size !== labels.length)
81
+ fail('GITHUB_RESPONSE', '', 'Duplicate Issue labels.');
82
+ return { identity: { id: v.id, node_id: v.node_id, number: v.number, url: v.html_url, kind }, labels: sortLabels(labels) };
83
+ }
84
+ export async function readIssue(client, number) {
85
+ const issue = parseIssue(await client.read('issues/' + number), client.repo);
86
+ if (issue.identity.number !== number || issue.identity.kind !== 'issue')
87
+ fail('LABEL_ISSUE', '', 'Target must be an Issue, not a pull request.');
88
+ return issue;
89
+ }
90
+ export async function captureLabelState(change, client) {
91
+ const repository = await repositoryIdentity(client);
92
+ const all = await repositoryLabels(client);
93
+ if (change.operation === 'issue-labels') {
94
+ const issue = await readIssue(client, change.issue);
95
+ const add = requireLabels(all, change.add ?? []), remove = requireLabels(all, change.remove ?? []);
96
+ const after = sortLabels([...issue.labels.filter(l => !remove.some(r => r.id === l.id)), ...add.filter(l => !issue.labels.some(x => x.id === l.id))]);
97
+ return { repository, repo: change.repo, operation: change.operation, issue: issue.identity, before: issue.labels, after, affected: [] };
98
+ }
99
+ const before = all.find(l => labelKey(l.name) === labelKey(change.name)) ?? null;
100
+ if (change.operation === 'label-create') {
101
+ if (before)
102
+ fail('LABEL_EXISTS', '', 'Label already exists; use an explicit edit.');
103
+ return { repository, repo: change.repo, operation: change.operation, before: null, after: { id: 0, node_id: '', name: change.name, color: change.color.toLowerCase(), description: change.description ?? '' }, affected: [] };
104
+ }
105
+ if (!before)
106
+ fail('LABEL_MISSING', '', 'Requested label does not exist.');
107
+ if (change.operation === 'label-edit' && change.newName && all.some(l => labelKey(l.name) === labelKey(change.newName) && l.id !== before.id))
108
+ fail('LABEL_EXISTS', '', 'New name collides with an existing label.');
109
+ const affected = (await client.list('issues?state=all&sort=created&direction=asc')).map(v => parseIssue(v, change.repo)).filter(i => i.labels.some(l => l.id === before.id)).map(i => i.identity).sort((a, b) => a.number - b.number);
110
+ if (new Set(affected.map(i => i.id)).size !== affected.length)
111
+ fail('GITHUB_RESPONSE', '', 'Duplicate Issues in impact listing.');
112
+ const after = change.operation === 'label-delete' ? null : { ...before, name: change.newName ?? before.name, color: change.color?.toLowerCase() ?? before.color, description: change.description ?? before.description };
113
+ return { repository, repo: change.repo, operation: change.operation, before, after, affected };
114
+ }
@@ -0,0 +1,31 @@
1
+ import type { CapturedInput } from './types.js';
2
+ type Base = {
3
+ version: 1;
4
+ repo: string;
5
+ };
6
+ export type LabelChange = Base & ({
7
+ operation: 'issue-labels';
8
+ issue: number;
9
+ add?: string[];
10
+ remove?: string[];
11
+ } | {
12
+ operation: 'label-create';
13
+ name: string;
14
+ color: string;
15
+ description?: string;
16
+ } | {
17
+ operation: 'label-edit';
18
+ name: string;
19
+ newName?: string;
20
+ color?: string;
21
+ description?: string;
22
+ } | {
23
+ operation: 'label-delete';
24
+ name: string;
25
+ });
26
+ export declare function labelKey(name: string): string;
27
+ export declare function validateRepo(value: unknown): asserts value is string;
28
+ export declare function validateLabelName(value: unknown): asserts value is string;
29
+ export declare function validateLabelNames(value: unknown): string[];
30
+ export declare function validateLabelChange(input: CapturedInput): LabelChange;
31
+ export {};
@@ -0,0 +1,58 @@
1
+ import { fail, keys, record, parseJson, nonblank } from './data.js';
2
+ export function labelKey(name) { return name.toLowerCase(); }
3
+ export function validateRepo(value) {
4
+ if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9-]{0,99}\/[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,99}$/.test(value) || value.endsWith('/.') || value.endsWith('/..'))
5
+ fail('LABEL_REPO', 'repo', 'Expected OWNER/REPO.');
6
+ }
7
+ export function validateLabelName(value) {
8
+ if (!nonblank(value) || value === '.' || value === '..' || /[\u0000-\u001f\u007f]/.test(value) || Array.from(value).length > 50)
9
+ fail('LABEL_NAME', 'labels', 'Expected a nonblank label name up to 50 characters without control characters.');
10
+ }
11
+ export function validateLabelNames(value) {
12
+ if (!Array.isArray(value) || value.length > 100)
13
+ fail('LABEL_LIST', 'labels', 'Expected at most 100 label names.');
14
+ const seen = new Set();
15
+ for (const name of value) {
16
+ validateLabelName(name);
17
+ const key = labelKey(name);
18
+ if (seen.has(key))
19
+ fail('LABEL_DUPLICATE', 'labels', 'Duplicate label name.');
20
+ seen.add(key);
21
+ }
22
+ return value;
23
+ }
24
+ export function validateLabelChange(input) {
25
+ const v = parseJson(input.bytes);
26
+ if (!record(v) || v.version !== 1)
27
+ fail('LABEL_INPUT', '', 'Expected version 1 label change.');
28
+ validateRepo(v.repo);
29
+ const allowed = {
30
+ 'issue-labels': ['issue', 'add', 'remove'], 'label-create': ['name', 'color', 'description'],
31
+ 'label-edit': ['name', 'newName', 'color', 'description'], 'label-delete': ['name']
32
+ };
33
+ if (typeof v.operation !== 'string' || !Object.hasOwn(allowed, v.operation))
34
+ fail('LABEL_OPERATION', 'operation', 'Unknown label operation.');
35
+ keys(v, ['version', 'repo', 'operation', ...allowed[v.operation]], 'change');
36
+ if (v.operation === 'issue-labels') {
37
+ if (!Number.isSafeInteger(v.issue) || v.issue < 1)
38
+ fail('LABEL_ISSUE', 'issue', 'Expected a positive Issue number.');
39
+ const add = v.add === undefined ? [] : validateLabelNames(v.add), remove = v.remove === undefined ? [] : validateLabelNames(v.remove);
40
+ if (!add.length && !remove.length)
41
+ fail('LABEL_EMPTY', '', 'No label changes requested.');
42
+ if (add.some(x => remove.some(y => labelKey(x) === labelKey(y))))
43
+ fail('LABEL_CONFLICT', '', 'Cannot add and remove the same label.');
44
+ }
45
+ else {
46
+ validateLabelName(v.name);
47
+ if (v.newName !== undefined)
48
+ validateLabelName(v.newName);
49
+ if (v.operation === 'label-create' || v.color !== undefined)
50
+ if (typeof v.color !== 'string' || !/^[0-9a-fA-F]{6}$/.test(v.color))
51
+ fail('LABEL_COLOR', 'color', 'Expected a six-digit hexadecimal color.');
52
+ if (v.description !== undefined && (typeof v.description !== 'string' || /[\u0000-\u001f\u007f]/.test(v.description) || Array.from(v.description).length > 100))
53
+ fail('LABEL_DESCRIPTION', 'description', 'Expected a single-line description up to 100 characters.');
54
+ if (v.operation === 'label-edit' && v.newName === undefined && v.color === undefined && v.description === undefined)
55
+ fail('LABEL_EMPTY', '', 'No label changes requested.');
56
+ }
57
+ return v;
58
+ }
@@ -0,0 +1,6 @@
1
+ import type { ValidatedIssue } from './types.js';
2
+ export declare function renderIssue(issue: ValidatedIssue): {
3
+ repo: string;
4
+ title: string;
5
+ body: string;
6
+ };
@@ -0,0 +1,21 @@
1
+ export function renderIssue(issue) {
2
+ const { draft: d, bundle: { template: t } } = issue;
3
+ const blocks = [];
4
+ if (d.parentIssue !== undefined)
5
+ blocks.push('## 親Issue\n\nhttps://github.com/' + d.repo + '/issues/' + d.parentIssue);
6
+ for (const f of t.fields) {
7
+ const v = d.fields[f.id];
8
+ if (v === undefined)
9
+ continue;
10
+ let content;
11
+ if (f.type === 'criteria')
12
+ content = v.map(c => '- [ ] ' + c.id + ': ' + c.requirement + '\n - 検証: ' + c.verification + '\n - 期待結果: ' + c.expectedResult).join('\n');
13
+ else if (Array.isArray(v))
14
+ content = v.map(s => '- ' + s).join('\n');
15
+ else
16
+ content = v;
17
+ blocks.push('## ' + f.label + '\n\n' + content);
18
+ }
19
+ blocks.push('## 担当モデル\n\n' + t.agents.map(name => { const a = d.agents[name]; return '- ' + name + ': `' + a.model + '` / `' + a.thinking + '`\n - 選定理由: ' + a.reason + (a.upperTierApproval ? '\n - 上位利用承認: ' + a.upperTierApproval : ''); }).join('\n'));
20
+ return { repo: d.repo, title: d.title, body: (blocks.join('\n\n') + '\n').replace(/\r\n?/g, '\n') };
21
+ }
@@ -0,0 +1,9 @@
1
+ export declare function maskSecrets(value: string): {
2
+ text: string;
3
+ sensitive: boolean;
4
+ };
5
+ /** Inspect decoded JSON values before serialization so escaping cannot hide a candidate. */
6
+ export declare function maskDecodedSecrets<T>(value: T): {
7
+ value: T;
8
+ sensitive: boolean;
9
+ };
@@ -0,0 +1,35 @@
1
+ const patterns = [
2
+ /\b(?:sk_live_[a-zA-Z0-9_-]{8,}|sk-[a-zA-Z0-9_-]{16,}|(?:gh[pousr]_[a-zA-Z0-9]{16,}|github_pat_[a-zA-Z0-9_]{20,})|xox[baprs]-[a-zA-Z0-9-]{8,})\b/g,
3
+ /\bBearer\s+[a-zA-Z0-9._~+\/-]{12,}=*/gi,
4
+ /\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|password|client[_-]?secret|authorization)\s*["']?\s*[:=]\s*["']?(?!\s*(?:\$|<|placeholder|example|changeme|your[_-]))[a-zA-Z0-9_./+=:-]{8,}/gi
5
+ ];
6
+ export function maskSecrets(value) {
7
+ let text = value, sensitive = false;
8
+ for (const pattern of patterns) {
9
+ pattern.lastIndex = 0;
10
+ text = text.replace(pattern, () => { sensitive = true; return '[REDACTED]'; });
11
+ }
12
+ return { text, sensitive };
13
+ }
14
+ /** Inspect decoded JSON values before serialization so escaping cannot hide a candidate. */
15
+ export function maskDecodedSecrets(value) {
16
+ let sensitive = false;
17
+ const mask = (text) => {
18
+ const result = maskSecrets(text);
19
+ sensitive ||= result.sensitive;
20
+ return result.text;
21
+ };
22
+ const visit = (item) => {
23
+ if (typeof item === 'string')
24
+ return mask(item);
25
+ if (Array.isArray(item))
26
+ return item.map(visit);
27
+ if (item !== null && typeof item === 'object')
28
+ return Object.fromEntries(Object.entries(item).map(([key, child]) => [mask(key), visit(child)]));
29
+ if (item === null || item === undefined || typeof item === 'number' || typeof item === 'boolean')
30
+ return item;
31
+ throw new TypeError('Secret inspection expects decoded data.');
32
+ };
33
+ const masked = visit(value);
34
+ return { value: masked, sensitive };
35
+ }
@@ -0,0 +1,3 @@
1
+ import { type GhOptions } from './gh-process.js';
2
+ import type { SubmitOutcome, ValidatedIssue } from './types.js';
3
+ export declare function submitIssue(issue: ValidatedIssue, approvedDigest: string, options?: GhOptions): Promise<SubmitOutcome>;
@@ -0,0 +1,37 @@
1
+ import { runGh } from './gh-process.js';
2
+ import { createPreview } from './approval.js';
3
+ import { renderIssue } from './render.js';
4
+ import { validateDraft } from './draft.js';
5
+ import { submitLabelledIssue } from './issue-labels.js';
6
+ export async function submitIssue(issue, approvedDigest, options = {}) {
7
+ const checked = validateDraft(issue.draftInput, issue.bundle);
8
+ if (!checked.issue)
9
+ return { status: 'rejected', problems: checked.problems };
10
+ const preview = createPreview(checked.issue);
11
+ if (preview.sensitive)
12
+ return { status: 'rejected', problems: [{ code: 'SENSITIVE', path: '', message: 'Secret candidates must be removed before submission.' }] };
13
+ if (!/^[a-f0-9]{64}$/.test(approvedDigest))
14
+ return { status: 'rejected', problems: [{ code: 'APPROVAL_MISMATCH', path: '', message: 'Approval does not match the current content.' }] };
15
+ if (checked.issue.draft.labels?.length)
16
+ return submitLabelledIssue(checked.issue, approvedDigest, options);
17
+ if (preview.digest !== approvedDigest)
18
+ return { status: 'rejected', problems: [{ code: 'APPROVAL_MISMATCH', path: '', message: 'Approval does not match the current content.' }] };
19
+ const fixed = renderIssue(checked.issue);
20
+ const result = await runGh(['issue', 'create', '--repo=github.com/' + fixed.repo, '--title=' + fixed.title, '--body-file', '-'], fixed.body, options);
21
+ if (result.status === 'not-started')
22
+ return { status: 'not-started', message: 'Unable to start gh or invalid process limits.' };
23
+ const urls = result.stdout.trim().split(/\s+/).filter(v => v.startsWith('https://github.com/'));
24
+ let url;
25
+ if (urls.length === 1) {
26
+ try {
27
+ const u = new URL(urls[0]);
28
+ const parts = u.pathname.split('/');
29
+ if (u.origin === 'https://github.com' && !u.search && !u.hash && parts.length === 5 && (parts[1] + '/' + parts[2]).toLowerCase() === fixed.repo.toLowerCase() && parts[3] === 'issues' && /^[1-9][0-9]*$/.test(parts[4]))
30
+ url = u.href;
31
+ }
32
+ catch { /* Invalid URL is uncertain. */ }
33
+ }
34
+ if (result.status === 'ok' && url)
35
+ return { status: 'created', url };
36
+ return { status: 'unknown', message: 'Submission outcome is uncertain. Check GitHub manually before retrying.', ...(url ? { candidateUrl: url } : {}) };
37
+ }
@@ -0,0 +1,2 @@
1
+ import type { TemplateBundle } from './types.js';
2
+ export declare function loadTemplate(file: string): Promise<TemplateBundle>;
@@ -0,0 +1,80 @@
1
+ import { dirname, resolve, isAbsolute } from 'node:path';
2
+ import { capture, parseYaml, record, keys, nonblank, validId, fail } from './data.js';
3
+ const thinking = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
4
+ const fieldKeys = ['id', 'label', 'type', 'required', 'description', 'options', 'minLength', 'maxLength', 'minItems', 'maxItems'];
5
+ function field(value) {
6
+ if (!record(value))
7
+ fail('FIELD_TYPE', 'fields', 'Field must be an object.');
8
+ keys(value, fieldKeys, 'fields');
9
+ if (!validId(value.id) || !nonblank(value.label) || (typeof value.type !== 'string' || !['string', 'text', 'string-list', 'criteria'].includes(value.type)) || typeof value.required !== 'boolean')
10
+ fail('FIELD_INVALID', 'fields', 'Invalid field definition.');
11
+ if (value.description !== undefined && !nonblank(value.description))
12
+ fail('FIELD_INVALID', 'fields', 'Description must be nonblank.');
13
+ for (const k of ['minLength', 'maxLength', 'minItems', 'maxItems'])
14
+ if (value[k] !== undefined && (!Number.isSafeInteger(value[k]) || value[k] < 0 || value[k] > 1048576))
15
+ fail('FIELD_BOUND', 'fields', 'Invalid bound.');
16
+ if (value.minLength !== undefined && value.maxLength !== undefined && value.minLength > value.maxLength)
17
+ fail('FIELD_BOUND', 'fields', 'Invalid length range.');
18
+ if (value.minItems !== undefined && value.maxItems !== undefined && value.minItems > value.maxItems)
19
+ fail('FIELD_BOUND', 'fields', 'Invalid item range.');
20
+ const list = ['string-list', 'criteria'].includes(value.type);
21
+ if (!list && (value.minItems !== undefined || value.maxItems !== undefined))
22
+ fail('FIELD_BOUND', 'fields', 'Item bounds require a list.');
23
+ if (value.type === 'criteria' && (value.options !== undefined || value.minLength !== undefined || value.maxLength !== undefined))
24
+ fail('FIELD_BOUND', 'fields', 'Criteria use item bounds only.');
25
+ if (value.options !== undefined && (!Array.isArray(value.options) || !value.options.length || !value.options.every(nonblank) || new Set(value.options).size !== value.options.length))
26
+ fail('FIELD_OPTIONS', 'fields', 'Invalid options.');
27
+ return value;
28
+ }
29
+ function definition(value) {
30
+ if (!record(value))
31
+ fail('TEMPLATE_INVALID', '', 'Template must be an object.');
32
+ keys(value, ['version', 'id', 'name', 'description', 'kind', 'fields', 'agents', 'modelPolicy'], 'template');
33
+ if (value.version !== 1 || !validId(value.id) || !nonblank(value.name) || !nonblank(value.description) || (typeof value.kind !== 'string' || !['parent', 'task'].includes(value.kind)) || !nonblank(value.modelPolicy))
34
+ fail('TEMPLATE_INVALID', '', 'Invalid template metadata.');
35
+ if (!Array.isArray(value.fields) || !value.fields.length)
36
+ fail('TEMPLATE_FIELDS', 'fields', 'At least one field is required.');
37
+ const fields = value.fields.map(field);
38
+ if (new Set(fields.map(f => f.id)).size !== fields.length)
39
+ fail('FIELD_DUPLICATE', 'fields', 'Duplicate field IDs.');
40
+ if (!Array.isArray(value.agents) || !value.agents.length || !value.agents.every(validId) || new Set(value.agents).size !== value.agents.length)
41
+ fail('TEMPLATE_AGENTS', 'agents', 'Invalid agent list.');
42
+ return { ...value, fields };
43
+ }
44
+ function policy(value) {
45
+ if (!record(value))
46
+ fail('POLICY_INVALID', '', 'Policy must be an object.');
47
+ keys(value, ['version', 'agents'], 'policy');
48
+ if (value.version !== 1 || !record(value.agents) || !Object.keys(value.agents).length)
49
+ fail('POLICY_INVALID', 'agents', 'Invalid model policy.');
50
+ for (const [agent, profiles] of Object.entries(value.agents)) {
51
+ if (!validId(agent) || !Array.isArray(profiles) || !profiles.length)
52
+ fail('POLICY_AGENT', 'agents', 'Invalid agent profiles.');
53
+ const seen = new Set();
54
+ for (const p of profiles) {
55
+ if (!record(p))
56
+ fail('POLICY_PROFILE', 'agents', 'Profile must be an object.');
57
+ keys(p, ['model', 'thinking', 'tier'], 'agents');
58
+ if (!nonblank(p.model) || !/^[^\s/]+\/[^\s]+$/.test(p.model) || typeof p.thinking !== 'string' || !thinking.includes(p.thinking) || typeof p.tier !== 'string' || !['basic', 'upper'].includes(p.tier))
59
+ fail('POLICY_PROFILE', 'agents', 'Invalid profile.');
60
+ const key = JSON.stringify([p.model, p.thinking]);
61
+ if (seen.has(key))
62
+ fail('POLICY_DUPLICATE', 'agents', 'Duplicate profile.');
63
+ seen.add(key);
64
+ }
65
+ }
66
+ return value;
67
+ }
68
+ export async function loadTemplate(file) {
69
+ const input = await capture(file);
70
+ const t = definition(parseYaml(input.bytes));
71
+ const root = dirname(input.path);
72
+ if (isAbsolute(t.modelPolicy) || t.modelPolicy.includes('://') || t.modelPolicy.startsWith('~'))
73
+ fail('POLICY_PATH', '', 'Policy must be a local relative path.');
74
+ const policyInput = await capture(resolve(root, t.modelPolicy), root);
75
+ const p = policy(parseYaml(policyInput.bytes));
76
+ for (const agent of t.agents)
77
+ if (!Object.hasOwn(p.agents, agent))
78
+ fail('POLICY_MISSING', 'agents', 'Template agent has no policy.');
79
+ return { template: t, policy: p, inputs: [input, policyInput] };
80
+ }