@papillon6814/pi-gh 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/src/core/approval.d.ts +2 -0
- package/dist/src/core/approval.js +25 -0
- package/dist/src/core/data.d.ts +19 -0
- package/dist/src/core/data.js +136 -0
- package/dist/src/core/draft.d.ts +6 -0
- package/dist/src/core/draft.js +92 -0
- package/dist/src/core/forms.d.ts +3 -0
- package/dist/src/core/forms.js +54 -0
- package/dist/src/core/gh-process.d.ts +13 -0
- package/dist/src/core/gh-process.js +55 -0
- package/dist/src/core/issue-labels.d.ts +9 -0
- package/dist/src/core/issue-labels.js +52 -0
- package/dist/src/core/labels-apply.d.ts +12 -0
- package/dist/src/core/labels-apply.js +73 -0
- package/dist/src/core/labels-approval.d.ts +10 -0
- package/dist/src/core/labels-approval.js +12 -0
- package/dist/src/core/labels-github.d.ts +54 -0
- package/dist/src/core/labels-github.js +114 -0
- package/dist/src/core/labels-input.d.ts +31 -0
- package/dist/src/core/labels-input.js +58 -0
- package/dist/src/core/render.d.ts +6 -0
- package/dist/src/core/render.js +21 -0
- package/dist/src/core/secrets.d.ts +9 -0
- package/dist/src/core/secrets.js +35 -0
- package/dist/src/core/submit.d.ts +3 -0
- package/dist/src/core/submit.js +37 -0
- package/dist/src/core/template.d.ts +2 -0
- package/dist/src/core/template.js +80 -0
- package/dist/src/core/types.d.ts +95 -0
- package/dist/src/core/types.js +1 -0
- package/dist/src/paths.d.ts +4 -0
- package/dist/src/paths.js +21 -0
- package/dist/src/result.d.ts +4 -0
- package/dist/src/result.js +5 -0
- package/dist/src/service-types.d.ts +34 -0
- package/dist/src/service-types.js +1 -0
- package/dist/src/service.d.ts +2 -0
- package/dist/src/service.js +102 -0
- package/examples/labels-create.json +8 -0
- package/examples/labels-delete.json +6 -0
- package/examples/labels-edit.json +9 -0
- package/examples/labels-issue.json +12 -0
- package/examples/parent.json +37 -0
- package/examples/task.json +51 -0
- package/extensions/approval-ui.ts +41 -0
- package/extensions/index.ts +12 -0
- package/extensions/runtime-scope.ts +15 -0
- package/extensions/tools.ts +37 -0
- package/package.json +51 -4
- package/templates/parent.yml +47 -0
- package/templates/policies/models.yml +20 -0
- package/templates/task.yml +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Papillon6814
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-gh
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Piの会話から、共通YAMLに沿ったGitHub Issue案・ラベル変更を検証し、画面で承認した内容だけ実行するPi拡張です。MIT。CLIのbinは提供しません。既存gh-issueflowは削除・変更不要です。
|
|
4
|
+
|
|
5
|
+
## 要件と導入
|
|
6
|
+
- Node.js >=22.19.0、Pi 1.0.4、GitHub CLI gh。実機検証はmacOS。
|
|
7
|
+
- 変更には通常のgh認証が必要です。認証情報・tokenをこの拡張へ渡す設定はありません。
|
|
8
|
+
- 通常Piへ導入: `pi install npm:@papillon6814/pi-gh@0.1.1`。Profileを使わない通常の共通導入の例です。
|
|
9
|
+
- pi-profileのDevelopmentだけで使う: `pi-profile packages add --profile developer npm:@papillon6814/pi-gh@0.1.1`、`pi-profile packages install --profile developer`。共通settingsへは追加しません。
|
|
10
|
+
- ソース利用時は先に `npm install --ignore-scripts && npm run build`。Piホスト提供APIはpeerで、配布に本体を含めません。
|
|
11
|
+
|
|
12
|
+
## ツール
|
|
13
|
+
| 名前 | 主な入力 | 動作 |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| gh_issue_validate | draftPath、template/templatePath | オフラインの構造/policy検証 |
|
|
16
|
+
| gh_issue_preview | 同上 | マスク済みプレビューとdigest。ラベル付きはremote確認 |
|
|
17
|
+
| gh_issue_submit | 同上 | TUIで人間確認後にIssue作成 |
|
|
18
|
+
| gh_labels_validate | changePath | オフラインの構造検証 |
|
|
19
|
+
| gh_labels_preview | changePath | 対象・before/after・影響対象・digest |
|
|
20
|
+
| gh_labels_apply | changePath | TUI確認後にラベル変更 |
|
|
21
|
+
| gh_issue_form | template/templatePath | GitHub Issue FormのYAML文字列を返す。ファイル上書きなし |
|
|
22
|
+
|
|
23
|
+
パスは作業ディレクトリ基準。標準templateはtask(既定)/parent。独自templatePathと同時指定はできません。examplesは架空のexample/demoなので、実行前に対象repoと内容を直してください。
|
|
24
|
+
|
|
25
|
+
## 操作の流れ
|
|
26
|
+
1. 案JSONを用意しvalidate。
|
|
27
|
+
2. previewを読み、必要なら案・template・policyを直す。
|
|
28
|
+
3. submit/applyを呼び、Piの確認画面で対象と内容を確認。上下/Pageキーで全文を読めます。
|
|
29
|
+
4. 既定はCancel。左右でApproveを明示選択してEnter。Escape/Ctrl-Cは取消。
|
|
30
|
+
|
|
31
|
+
初版ではTUIのローカル人間確認が必要です。RPC、print/JSON、子エージェントは変更を拒否します。読み取りツールとフォーム生成はUIなしでも使えます。外部変更ツールはmodel-onlyで、codemodeスクリプトから呼べません。承認hashやapprovedフラグをモデルから渡す経路はありません。
|
|
32
|
+
|
|
33
|
+
入力bytes/template/policyとremote identity/stateを承認後にも再検査します。変更・取消・会話切替・tree移動・reload/shutdownで旧承認は使えません。承認は保存・再開・別sessionへ引き継ぎません。GitHub APIの検査とwriteの間の競合を完全には防げないため、同じ対象の並行編集は避けてください。
|
|
34
|
+
|
|
35
|
+
## 入力とモデルpolicy
|
|
36
|
+
templates/parent.yml、task.ymlが正本、formsは生成物です。fieldsのrequired/options/順序/制限とpolicies/models.ymlを読み直します。担当はmodel/thinking/reason、上位tierはupperTierApprovalを含めます。配布モデル表は例で、実際の利用可能性や予算承認を保証しません。upperTierApprovalの文字列だけで人間の真正な課金承認は証明できません。
|
|
37
|
+
|
|
38
|
+
Issue JSONはversion/template/repo/title/fields/agents、任意labelsとparentIssue。labelsは既存ラベルのみで、暗黙に作成しません。ラベル変更はversion:1とrepo、operationがissue-labels/label-create/label-edit/label-delete。詳しい例はexamples/labels-*.jsonです。指定外ラベルは維持し、PRへのissue-labelsは拒否します。
|
|
39
|
+
|
|
40
|
+
## 安全境界と結果
|
|
41
|
+
秘密情報検出はヒューリスティックです。raw inputだけでなくdecoded文字列・object key・remote metadataをマスクし、候補があれば変更を拒否します。秘密漏洩ゼロや自由なbashからの直接gh実行防止を保証するものではありません。
|
|
42
|
+
|
|
43
|
+
未知/重複JSON・YAMLキー、独自tag/alias、32段超階層、外部policy参照、入力1MiB超を拒否。policyのsymlink/descriptor検査を維持します。ghはshellなし、固定argv/stdin、GH_HOST=github.comで起動。60秒期限、各stdio1MiB、cleanup猶予100ms、POSIX所有process groupを停止します。Windowsの子孫全体停止は保証しません。
|
|
44
|
+
|
|
45
|
+
ラベル一覧・影響対象は各100ページ/合計1MiBまで。上限・権限不足・検査失敗では変更しません。改名・削除・説明/色編集の影響を確認します。
|
|
46
|
+
|
|
47
|
+
結果はvalidated/preview/generated/created/applied/noop/rejected/not-started/unknown。unknownは既に変更済みの可能性があるため、GitHubを手動確認し、再preview・再承認してください。自動再試行・自動取消はありません。既存コアのIssue URL検証と、ラベル付きIssue/ラベル変更の事後確認を維持しています。
|
|
48
|
+
|
|
49
|
+
既存Issue本文/タイトル編集、sub-issue、assignee/Project、一括操作は対象外です。
|
|
50
|
+
|
|
51
|
+
## 開発・検証
|
|
52
|
+
`npm run build`、`npm run typecheck`、`npm test`。元コアの回帰はtest/core、承認・lifecycleはservice tests、ツール/UIはextension testsです。
|
|
53
|
+
`python3 scripts/test-pi-gh-cli.py --pi /absolute/path/to/pi`は合成HOME、偽gh、loopbackモデルだけで実Piを検証します。実アカウントや実GitHubへ投稿しません。
|
|
54
|
+
`npm pack`にはentry、compiled core、templates、架空examples、README、LICENSEだけを含めます。docs/test/認証は含めません。
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { renderIssue } from './render.js';
|
|
3
|
+
import { maskSecrets } from './secrets.js';
|
|
4
|
+
function canonical(value) {
|
|
5
|
+
if (Array.isArray(value))
|
|
6
|
+
return '[' + value.map(canonical).join(',') + ']';
|
|
7
|
+
if (typeof value === 'object' && value !== null)
|
|
8
|
+
return '{' + Object.entries(value).filter(([, v]) => v !== undefined).sort(([a], [b]) => a.localeCompare(b, 'en')).map(([k, v]) => JSON.stringify(k) + ':' + canonical(v)).join(',') + '}';
|
|
9
|
+
return JSON.stringify(value);
|
|
10
|
+
}
|
|
11
|
+
export function createPreview(issue) {
|
|
12
|
+
const rendered = renderIssue(issue);
|
|
13
|
+
const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
14
|
+
const digest = sha(canonical({ formatVersion: 1, host: 'github.com', ...rendered, agents: issue.draft.agents, inputs: [issue.draftInput, ...issue.bundle.inputs].map(i => ({ path: i.path, sha256: sha(i.bytes) })) }));
|
|
15
|
+
let sensitive = false;
|
|
16
|
+
const mask = (v) => { const m = maskSecrets(v); sensitive ||= m.sensitive; return m.text; };
|
|
17
|
+
for (const i of [issue.draftInput, ...issue.bundle.inputs])
|
|
18
|
+
mask(i.bytes.toString('utf8'));
|
|
19
|
+
const agents = {};
|
|
20
|
+
for (const [name, a] of Object.entries(issue.draft.agents))
|
|
21
|
+
agents[mask(name)] = { model: mask(a.model), thinking: mask(a.thinking), reason: mask(a.reason), ...(a.upperTierApproval === undefined ? {} : { upperTierApproval: mask(a.upperTierApproval) }) };
|
|
22
|
+
const title = mask(rendered.title), body = mask(rendered.body), repo = mask(rendered.repo);
|
|
23
|
+
const labels = issue.draft.labels?.map(mask);
|
|
24
|
+
return { repo, title, body, agents, digest, sensitive, ...(labels === undefined ? {} : { labels }) };
|
|
25
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CapturedInput } from './types.js';
|
|
2
|
+
export type ParsedData = null | boolean | number | string | ParsedData[] | {
|
|
3
|
+
[key: string]: ParsedData;
|
|
4
|
+
};
|
|
5
|
+
export declare const MAX_BYTES = 1048576;
|
|
6
|
+
export declare class DataError extends Error {
|
|
7
|
+
readonly code: string;
|
|
8
|
+
readonly field: string;
|
|
9
|
+
constructor(code: string, field: string, message: string);
|
|
10
|
+
}
|
|
11
|
+
export declare function fail(code: string, field: string, message: string): never;
|
|
12
|
+
export declare function record(value: unknown): value is Record<string, unknown>;
|
|
13
|
+
export declare function keys(value: Record<string, unknown>, allowed: readonly string[], field: string): void;
|
|
14
|
+
export declare function nonblank(value: unknown): value is string;
|
|
15
|
+
export declare function validId(value: unknown): value is string;
|
|
16
|
+
export declare function capture(file: string, allowedRoot?: string): Promise<CapturedInput>;
|
|
17
|
+
export declare function text(bytes: Buffer): string;
|
|
18
|
+
export declare function parseYaml(bytes: Buffer): ParsedData;
|
|
19
|
+
export declare function parseJson(bytes: Buffer): ParsedData;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { open, realpath, stat as pathStat } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { resolve, relative, isAbsolute, sep } from 'node:path';
|
|
4
|
+
import { parseAllDocuments, isAlias, isMap, isSeq } from 'yaml';
|
|
5
|
+
export const MAX_BYTES = 1048576;
|
|
6
|
+
export class DataError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
field;
|
|
9
|
+
constructor(code, field, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.field = field;
|
|
13
|
+
this.name = 'DataError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function fail(code, field, message) { throw new DataError(code, field, message); }
|
|
17
|
+
export function record(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); }
|
|
18
|
+
export function keys(value, allowed, field) {
|
|
19
|
+
for (const k of Object.keys(value))
|
|
20
|
+
if (!allowed.includes(k))
|
|
21
|
+
fail('UNKNOWN_KEY', field, 'Unknown property.');
|
|
22
|
+
}
|
|
23
|
+
export function nonblank(value) { return typeof value === 'string' && value.trim().length > 0; }
|
|
24
|
+
export function validId(value) { return typeof value === 'string' && /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(value) && !['constructor', 'prototype', '__proto__'].includes(value); }
|
|
25
|
+
export async function capture(file, allowedRoot) {
|
|
26
|
+
let handle;
|
|
27
|
+
try {
|
|
28
|
+
const path = await realpath(resolve(file));
|
|
29
|
+
const contained = (candidate) => {
|
|
30
|
+
if (!allowedRoot)
|
|
31
|
+
return;
|
|
32
|
+
const rel = relative(allowedRoot, candidate);
|
|
33
|
+
if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
|
|
34
|
+
fail('POLICY_OUTSIDE', '', 'Policy resolves outside template directory.');
|
|
35
|
+
};
|
|
36
|
+
contained(path);
|
|
37
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
38
|
+
const before = await handle.stat();
|
|
39
|
+
if (!before.isFile())
|
|
40
|
+
fail('INPUT_TYPE', '', 'Input must be a regular file.');
|
|
41
|
+
if (before.size > MAX_BYTES)
|
|
42
|
+
fail('INPUT_LIMIT', '', 'Input exceeds 1 MiB.');
|
|
43
|
+
const verifyLocation = async () => {
|
|
44
|
+
if (allowedRoot && await realpath(allowedRoot) !== allowedRoot)
|
|
45
|
+
fail('INPUT_CHANGED', '', 'Template root changed during capture.');
|
|
46
|
+
const currentPath = await realpath(resolve(file));
|
|
47
|
+
contained(currentPath);
|
|
48
|
+
if (currentPath !== path)
|
|
49
|
+
fail('INPUT_CHANGED', '', 'Input location changed during capture.');
|
|
50
|
+
const current = await pathStat(currentPath);
|
|
51
|
+
if (current.dev !== before.dev || current.ino !== before.ino)
|
|
52
|
+
fail('INPUT_CHANGED', '', 'Input identity changed during capture.');
|
|
53
|
+
};
|
|
54
|
+
await verifyLocation(); // Validate the opened descriptor before reading bytes.
|
|
55
|
+
const bytes = Buffer.alloc(MAX_BYTES + 1);
|
|
56
|
+
let length = 0;
|
|
57
|
+
while (length < bytes.length) {
|
|
58
|
+
const part = await handle.read(bytes, length, bytes.length - length, null);
|
|
59
|
+
if (!part.bytesRead)
|
|
60
|
+
break;
|
|
61
|
+
length += part.bytesRead;
|
|
62
|
+
}
|
|
63
|
+
if (length > MAX_BYTES)
|
|
64
|
+
fail('INPUT_LIMIT', '', 'Input exceeds 1 MiB.');
|
|
65
|
+
await verifyLocation();
|
|
66
|
+
const after = await handle.stat();
|
|
67
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
|
|
68
|
+
fail('INPUT_CHANGED', '', 'Input changed during capture.');
|
|
69
|
+
return { path, bytes: Buffer.from(bytes.subarray(0, length)) };
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
if (e instanceof DataError)
|
|
73
|
+
throw e;
|
|
74
|
+
return fail('INPUT_READ', '', 'Unable to read input file.');
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
await handle?.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function text(bytes) {
|
|
81
|
+
if (bytes.length > MAX_BYTES)
|
|
82
|
+
fail('INPUT_LIMIT', '', 'Input exceeds 1 MiB.');
|
|
83
|
+
try {
|
|
84
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
fail('ENCODING', '', 'Input must be UTF-8.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function parseYaml(bytes) {
|
|
91
|
+
try {
|
|
92
|
+
const docs = parseAllDocuments(text(bytes), { uniqueKeys: true, prettyErrors: false, strict: true });
|
|
93
|
+
if (docs.length !== 1)
|
|
94
|
+
fail('YAML_DOCUMENT', '', 'Exactly one YAML document is required.');
|
|
95
|
+
const doc = docs[0];
|
|
96
|
+
if (doc.errors.length || doc.warnings.length)
|
|
97
|
+
fail('YAML_INVALID', '', 'Invalid YAML or duplicate keys.');
|
|
98
|
+
const stack = [{ node: doc.contents, depth: 0 }];
|
|
99
|
+
const tags = new Set(['tag:yaml.org,2002:str', 'tag:yaml.org,2002:int', 'tag:yaml.org,2002:float', 'tag:yaml.org,2002:bool', 'tag:yaml.org,2002:null', 'tag:yaml.org,2002:map', 'tag:yaml.org,2002:seq']);
|
|
100
|
+
while (stack.length) {
|
|
101
|
+
const { node, depth } = stack.pop();
|
|
102
|
+
if (depth > 32)
|
|
103
|
+
fail('INPUT_DEPTH', '', 'Input nesting exceeds 32.');
|
|
104
|
+
if (isAlias(node))
|
|
105
|
+
fail('YAML_ALIAS', '', 'YAML aliases are not supported.');
|
|
106
|
+
if (record(node) && typeof node.tag === 'string' && !tags.has(node.tag))
|
|
107
|
+
fail('YAML_TAG', '', 'Custom YAML tags are not supported.');
|
|
108
|
+
if (isMap(node))
|
|
109
|
+
for (const pair of node.items) {
|
|
110
|
+
stack.push({ node: pair.key, depth: depth + 1 }, { node: pair.value, depth: depth + 1 });
|
|
111
|
+
}
|
|
112
|
+
else if (isSeq(node))
|
|
113
|
+
for (const item of node.items)
|
|
114
|
+
stack.push({ node: item, depth: depth + 1 });
|
|
115
|
+
}
|
|
116
|
+
return doc.toJS({ maxAliasCount: 0 });
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
if (e instanceof DataError)
|
|
120
|
+
throw e;
|
|
121
|
+
fail('YAML_INVALID', '', 'Invalid YAML.');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function parseJson(bytes) {
|
|
125
|
+
try {
|
|
126
|
+
const raw = text(bytes);
|
|
127
|
+
const value = JSON.parse(raw);
|
|
128
|
+
parseYaml(bytes); // JSON syntax is a YAML subset; detect decoded duplicate keys.
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
if (e instanceof DataError)
|
|
133
|
+
throw e;
|
|
134
|
+
fail('JSON_INVALID', '', 'Invalid JSON.');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CapturedInput, TemplateBundle, ValidatedIssue, ValidationProblem } from './types.js';
|
|
2
|
+
export declare function loadDraft(path: string): Promise<CapturedInput>;
|
|
3
|
+
export declare function validateDraft(input: CapturedInput, bundle: TemplateBundle): {
|
|
4
|
+
issue?: ValidatedIssue;
|
|
5
|
+
problems: ValidationProblem[];
|
|
6
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { capture, parseJson, record, keys, nonblank, fail, DataError } from './data.js';
|
|
2
|
+
import { validateLabelNames } from './labels-input.js';
|
|
3
|
+
export async function loadDraft(path) { return capture(path); }
|
|
4
|
+
function string(value, f, path) {
|
|
5
|
+
if (!nonblank(value))
|
|
6
|
+
fail('FIELD_REQUIRED', path, 'Expected nonblank text.');
|
|
7
|
+
if (f.type === 'string' && /[\r\n]/.test(value))
|
|
8
|
+
fail('FIELD_SINGLE_LINE', path, 'Expected a single line.');
|
|
9
|
+
const n = Array.from(value).length;
|
|
10
|
+
if ((f.minLength !== undefined && n < f.minLength) || (f.maxLength !== undefined && n > f.maxLength))
|
|
11
|
+
fail('FIELD_LENGTH', path, 'Text length is outside allowed range.');
|
|
12
|
+
if (f.options && !f.options.includes(value))
|
|
13
|
+
fail('FIELD_OPTION', path, 'Value is not an allowed option.');
|
|
14
|
+
}
|
|
15
|
+
function field(value, f) {
|
|
16
|
+
if (['string', 'text'].includes(f.type)) {
|
|
17
|
+
string(value, f, 'fields.' + f.id);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (!Array.isArray(value) || (f.required && !value.length) || (f.minItems !== undefined && value.length < f.minItems) || (f.maxItems !== undefined && value.length > f.maxItems))
|
|
21
|
+
fail('FIELD_ITEMS', 'fields.' + f.id, 'Invalid list size or type.');
|
|
22
|
+
if (f.type === 'string-list') {
|
|
23
|
+
for (const v of value)
|
|
24
|
+
string(v, { ...f, type: 'text' }, 'fields.' + f.id);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
for (const v of value) {
|
|
29
|
+
if (!record(v))
|
|
30
|
+
fail('CRITERION_INVALID', 'fields.' + f.id, 'Criterion must be an object.');
|
|
31
|
+
keys(v, ['id', 'requirement', 'verification', 'expectedResult'], 'fields.' + f.id);
|
|
32
|
+
for (const k of ['id', 'requirement', 'verification', 'expectedResult'])
|
|
33
|
+
if (!nonblank(v[k]))
|
|
34
|
+
fail('CRITERION_REQUIRED', 'fields.' + f.id, 'All criterion attributes are required.');
|
|
35
|
+
const id = v.id;
|
|
36
|
+
if (seen.has(id))
|
|
37
|
+
fail('CRITERION_DUPLICATE', 'fields.' + f.id, 'Duplicate criterion ID.');
|
|
38
|
+
seen.add(id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function validateDraft(input, bundle) {
|
|
42
|
+
try {
|
|
43
|
+
const value = parseJson(input.bytes);
|
|
44
|
+
const t = bundle.template;
|
|
45
|
+
if (!record(value))
|
|
46
|
+
fail('DRAFT_TYPE', '', 'Draft must be an object.');
|
|
47
|
+
keys(value, ['version', 'template', 'repo', 'title', 'parentIssue', 'labels', 'fields', 'agents'], 'draft');
|
|
48
|
+
if (value.version !== 1 || value.template !== t.id)
|
|
49
|
+
fail('DRAFT_TEMPLATE', 'template', 'Draft template does not match.');
|
|
50
|
+
if (typeof value.repo !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9-]{0,99}\/[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,99}$/.test(value.repo) || value.repo.endsWith('/.') || value.repo.endsWith('/..'))
|
|
51
|
+
fail('DRAFT_REPO', 'repo', 'Expected OWNER/REPO.');
|
|
52
|
+
if (!nonblank(value.title) || /[\r\n\u0000-\u001f\u007f]/.test(value.title) || Array.from(value.title).length > 256)
|
|
53
|
+
fail('DRAFT_TITLE', 'title', 'Expected a nonblank single-line title up to 256 characters.');
|
|
54
|
+
if (value.labels !== undefined)
|
|
55
|
+
validateLabelNames(value.labels);
|
|
56
|
+
if (t.kind === 'task' && (!Number.isSafeInteger(value.parentIssue) || value.parentIssue < 1))
|
|
57
|
+
fail('DRAFT_PARENT', 'parentIssue', 'A positive parent issue is required.');
|
|
58
|
+
if (t.kind === 'parent' && value.parentIssue !== undefined)
|
|
59
|
+
fail('DRAFT_PARENT', 'parentIssue', 'Parent issue must not specify a parent.');
|
|
60
|
+
if (!record(value.fields))
|
|
61
|
+
fail('DRAFT_FIELDS', 'fields', 'Fields must be an object.');
|
|
62
|
+
keys(value.fields, t.fields.map(f => f.id), 'fields');
|
|
63
|
+
for (const f of t.fields) {
|
|
64
|
+
const present = Object.hasOwn(value.fields, f.id);
|
|
65
|
+
if (!present && !f.required)
|
|
66
|
+
continue;
|
|
67
|
+
field(present ? value.fields[f.id] : undefined, f);
|
|
68
|
+
}
|
|
69
|
+
if (!record(value.agents))
|
|
70
|
+
fail('DRAFT_AGENTS', 'agents', 'Agents must be an object.');
|
|
71
|
+
keys(value.agents, t.agents, 'agents');
|
|
72
|
+
for (const agent of t.agents) {
|
|
73
|
+
const a = Object.hasOwn(value.agents, agent) ? value.agents[agent] : undefined;
|
|
74
|
+
if (!record(a))
|
|
75
|
+
fail('AGENT_REQUIRED', 'agents.' + agent, 'Agent assignment is required.');
|
|
76
|
+
keys(a, ['model', 'thinking', 'reason', 'upperTierApproval'], 'agents.' + agent);
|
|
77
|
+
if (!nonblank(a.model) || !nonblank(a.thinking) || !nonblank(a.reason))
|
|
78
|
+
fail('AGENT_REQUIRED', 'agents.' + agent, 'Model, thinking and reason are required.');
|
|
79
|
+
const profile = bundle.policy.agents[agent]?.find(p => p.model === a.model && p.thinking === a.thinking);
|
|
80
|
+
if (!profile)
|
|
81
|
+
fail('AGENT_PROFILE', 'agents.' + agent, 'Model/thinking combination is not allowed for this agent.');
|
|
82
|
+
if (profile.tier === 'upper' && !nonblank(a.upperTierApproval))
|
|
83
|
+
fail('AGENT_APPROVAL', 'agents.' + agent, 'Upper profile requires an approval record.');
|
|
84
|
+
if (a.upperTierApproval !== undefined && !nonblank(a.upperTierApproval))
|
|
85
|
+
fail('AGENT_APPROVAL', 'agents.' + agent, 'Approval record must be nonblank.');
|
|
86
|
+
}
|
|
87
|
+
return { issue: { draft: value, bundle, draftInput: input }, problems: [] };
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
return { problems: [e instanceof DataError ? { code: e.code, path: e.field, message: e.message } : { code: 'INPUT_INVALID', path: '', message: 'Invalid input.' }] };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { stringify } from 'yaml';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { fail, DataError } from './data.js';
|
|
5
|
+
export function renderForm(bundle) {
|
|
6
|
+
const { template: t, policy: p } = bundle, body = [{ type: 'markdown', attributes: { value: 'このフォームは共通YAMLから生成されています。CLIの全検査(担当別の条件・上位承認等)をGitHub上で強制するものではありません。モデル利用承認と投稿承認を別途確認してください。' } }];
|
|
7
|
+
const ids = new Set();
|
|
8
|
+
const add = (f) => {
|
|
9
|
+
if (f.id) {
|
|
10
|
+
if (ids.has(f.id))
|
|
11
|
+
fail('FORM_ID_COLLISION', '', 'Generated form IDs collide.');
|
|
12
|
+
ids.add(f.id);
|
|
13
|
+
}
|
|
14
|
+
body.push(f);
|
|
15
|
+
};
|
|
16
|
+
if (t.kind === 'task')
|
|
17
|
+
add({ type: 'input', id: 'parent-issue', attributes: { label: '親Issue', description: '同じrepositoryの親Issue番号' }, validations: { required: true } });
|
|
18
|
+
for (const f of t.fields) {
|
|
19
|
+
const id = 'field-' + f.id.toLowerCase();
|
|
20
|
+
let type = f.type === 'string' ? 'input' : 'textarea';
|
|
21
|
+
if (f.options)
|
|
22
|
+
type = 'dropdown';
|
|
23
|
+
add({ type, id, attributes: { label: f.label, ...(f.description ? { description: f.description } : {}), ...(f.options ? { options: f.options, ...(f.type === 'string-list' ? { multiple: true } : {}) } : {}),
|
|
24
|
+
...(!f.options && ['string-list', 'criteria'].includes(f.type) ? { description: (f.description ?? '') + '\nJSON配列で入力してください。criteriaの各要素: id, requirement, verification, expectedResult。' } : {}) }, validations: { required: f.required } });
|
|
25
|
+
}
|
|
26
|
+
for (const agent of t.agents) {
|
|
27
|
+
const prefix = 'agent-' + agent.toLowerCase(), profiles = p.agents[agent];
|
|
28
|
+
add({ type: 'dropdown', id: prefix + '-profile', attributes: { label: agent + ' モデル / thinking', options: profiles.map(x => x.model + ' / ' + x.thinking + ' (' + x.tier + ')') }, validations: { required: true } });
|
|
29
|
+
add({ type: 'textarea', id: prefix + '-reason', attributes: { label: agent + ' 選定理由' }, validations: { required: true } });
|
|
30
|
+
if (profiles.some(x => x.tier === 'upper'))
|
|
31
|
+
add({ type: 'textarea', id: prefix + '-approval', attributes: { label: agent + ' 上位利用の承認記録', description: 'upperを選ぶ場合はユーザー承認の参照・説明が必要。basicの場合は不要。' }, validations: { required: false } });
|
|
32
|
+
}
|
|
33
|
+
return stringify({ name: t.name, description: t.description, body });
|
|
34
|
+
}
|
|
35
|
+
export async function writeForm(path, content, overwrite = false) {
|
|
36
|
+
let handle;
|
|
37
|
+
try {
|
|
38
|
+
handle = await open(path, overwrite ? constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW : constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
39
|
+
if (!(await handle.stat()).isFile())
|
|
40
|
+
fail('OUTPUT_TYPE', '', 'Output must be a regular file.');
|
|
41
|
+
if (overwrite)
|
|
42
|
+
await handle.truncate(0);
|
|
43
|
+
await handle.writeFile(content, 'utf8');
|
|
44
|
+
await handle.sync();
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
if (e instanceof DataError)
|
|
48
|
+
throw e;
|
|
49
|
+
fail('OUTPUT_WRITE', '', 'Cannot write output; existing files need --overwrite and symlinks are refused.');
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await handle?.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type GhOptions = {
|
|
2
|
+
ghExecutable?: string;
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
maxOutputBytes?: number;
|
|
5
|
+
env?: NodeJS.ProcessEnv;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
beforeStart?: () => boolean;
|
|
8
|
+
};
|
|
9
|
+
export type GhResult = {
|
|
10
|
+
status: 'ok' | 'not-started' | 'unknown';
|
|
11
|
+
stdout: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function runGh(args: string[], body: string, options?: GhOptions): Promise<GhResult>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export function runGh(args, body, options = {}) {
|
|
3
|
+
const timeout = options.timeoutMs ?? 60000, max = options.maxOutputBytes ?? 1048576;
|
|
4
|
+
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 60000 || !Number.isSafeInteger(max) || max < 1 || max > 1048576 || Buffer.byteLength(body) > 1048576)
|
|
5
|
+
return Promise.resolve({ status: 'not-started', stdout: '' });
|
|
6
|
+
return new Promise(resolve => {
|
|
7
|
+
const group = process.platform !== 'win32';
|
|
8
|
+
let child;
|
|
9
|
+
try {
|
|
10
|
+
if (options.signal?.aborted || options.beforeStart?.() === false || options.signal?.aborted)
|
|
11
|
+
return resolve({ status: 'not-started', stdout: '' });
|
|
12
|
+
child = spawn(options.ghExecutable ?? 'gh', args, { shell: false, detached: group, env: { ...process.env, ...options.env, GH_HOST: 'github.com' }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return resolve({ status: 'not-started', stdout: '' });
|
|
16
|
+
}
|
|
17
|
+
let started = false, failed = false, settled = false, stopping = false, stdoutBytes = 0, stderrBytes = 0;
|
|
18
|
+
const chunks = [];
|
|
19
|
+
let cleanup;
|
|
20
|
+
const signal = (kind) => { try {
|
|
21
|
+
if (group && child.pid)
|
|
22
|
+
process.kill(-child.pid, kind);
|
|
23
|
+
else
|
|
24
|
+
child.kill(kind);
|
|
25
|
+
}
|
|
26
|
+
catch { /* Owned process already gone. */ } };
|
|
27
|
+
const finish = (status) => { if (settled)
|
|
28
|
+
return; settled = true; clearTimeout(timer); if (cleanup)
|
|
29
|
+
clearTimeout(cleanup); options.signal?.removeEventListener('abort', abort); if (stopping && started)
|
|
30
|
+
signal('SIGKILL'); child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); resolve({ status, stdout: Buffer.concat(chunks).toString('utf8') }); };
|
|
31
|
+
const abort = () => { if (settled || stopping)
|
|
32
|
+
return; stopping = true; failed = true; if (started)
|
|
33
|
+
signal('SIGTERM'); cleanup = setTimeout(() => { if (started)
|
|
34
|
+
signal('SIGKILL'); finish(started ? 'unknown' : 'not-started'); }, 100); };
|
|
35
|
+
const timer = setTimeout(abort, timeout);
|
|
36
|
+
child.once('spawn', () => { started = true; if (stopping)
|
|
37
|
+
signal('SIGTERM'); });
|
|
38
|
+
child.once('error', () => { failed = true; if (!started)
|
|
39
|
+
finish('not-started');
|
|
40
|
+
else
|
|
41
|
+
abort(); });
|
|
42
|
+
child.stdout.on('data', (b) => { stdoutBytes += b.length; if (stdoutBytes > max)
|
|
43
|
+
return abort(); chunks.push(b); });
|
|
44
|
+
child.stderr.on('data', (b) => { stderrBytes += b.length; if (stderrBytes > max)
|
|
45
|
+
abort(); });
|
|
46
|
+
child.stdin.on('error', abort);
|
|
47
|
+
child.stdout.on('error', abort);
|
|
48
|
+
child.stderr.on('error', abort);
|
|
49
|
+
child.once('close', (code, signal) => finish(!started ? 'not-started' : !failed && code === 0 && !signal ? 'ok' : 'unknown'));
|
|
50
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
51
|
+
if (options.signal?.aborted)
|
|
52
|
+
abort();
|
|
53
|
+
child.stdin.end(body);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { GhClient, type Repository, type Label } from './labels-github.js';
|
|
2
|
+
import type { Preview, ValidatedIssue, SubmitOutcome } from './types.js';
|
|
3
|
+
import type { GhOptions } from './gh-process.js';
|
|
4
|
+
export type IssueLabelPreview = Preview & {
|
|
5
|
+
labelState?: Label[];
|
|
6
|
+
repository?: Repository;
|
|
7
|
+
};
|
|
8
|
+
export declare function createIssuePreview(issue: ValidatedIssue, options?: GhOptions, client?: GhClient): Promise<IssueLabelPreview>;
|
|
9
|
+
export declare function submitLabelledIssue(issue: ValidatedIssue, digest: string, options?: GhOptions): Promise<SubmitOutcome>;
|
|
@@ -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>;
|