@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.
- package/LICENSE +21 -0
- package/README.md +134 -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 +14 -0
- package/dist/src/core/gh-process.js +62 -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/github-api.d.ts +28 -0
- package/dist/src/github-api.js +100 -0
- package/dist/src/operation-input.d.ts +35 -0
- package/dist/src/operation-input.js +88 -0
- package/dist/src/operations.d.ts +3 -0
- package/dist/src/operations.js +137 -0
- package/dist/src/paths.d.ts +4 -0
- package/dist/src/paths.js +21 -0
- package/dist/src/permissions.d.ts +10 -0
- package/dist/src/permissions.js +100 -0
- package/dist/src/result.d.ts +4 -0
- package/dist/src/result.js +5 -0
- package/dist/src/service-types.d.ts +37 -0
- package/dist/src/service-types.js +1 -0
- package/dist/src/service.d.ts +4 -0
- package/dist/src/service.js +123 -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 +53 -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,135 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-gh
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
PiからGitHubを操作するTypeScript製の基盤拡張です。個別ツールを別のPi拡張から組み合わせられます。Scaffoldの工程・ウェーブ・専用テンプレートやHerdr操作は、利用側の拡張が担当します。MIT。CLIのbinは提供しません。既存gh-issueflowを削除・変更する必要はありません。
|
|
4
|
+
|
|
5
|
+
## 要件と導入
|
|
6
|
+
- Node.js >=22.19.0、Pi 1.0.4、認証済みGitHub CLI gh。実機検証はmacOS。
|
|
7
|
+
- 認証情報・tokenをpi-ghへ渡す設定はありません。Projectsには対象Projectへのアクセスと`project`相当の権限が必要です。権限不足時は停止し、自動再認証しません。
|
|
8
|
+
- 新機能を含むnpm版:`pi install npm:@papillon6814/pi-gh@0.2.0`。
|
|
9
|
+
- Developmentだけで使う場合:`pi-profile packages add --profile developer npm:@papillon6814/pi-gh@0.2.0 --replace`、`pi-profile packages install --profile developer`。共通settingsへは追加しません。
|
|
10
|
+
- Profileの割り当て変更後はPiを再起動してください。起動済みプロセスは元のsource指定を保持するため、`/reload`だけでは新しい版の指定へ切り替わりません。
|
|
11
|
+
- ソース利用は`npm install --ignore-scripts && npm run build`後、`pi -e /absolute/path/to/pi-gh`。
|
|
12
|
+
- Piホスト提供APIはpeerで、本体を配布へ含めません。
|
|
13
|
+
|
|
14
|
+
## 個別ツール(契約バージョン1)
|
|
15
|
+
|
|
16
|
+
すべての結果に`outputSchema`とマスク済み`structuredContent`があります。対応版・機能は`gh_capabilities`で確認してください。変更ツールも`direct`で登録され、Piの通常のツール検証・hooksを経由して呼べます。
|
|
17
|
+
|
|
18
|
+
| 名前 | 入力 | 動作 |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| gh_capabilities | `{}` | contractVersionと対応操作。remoteアクセスなし |
|
|
21
|
+
| gh_issue_validate | draftPath、template/templatePath | オフラインの構造・モデルpolicy検証 |
|
|
22
|
+
| gh_issue_preview | 同上 | 投稿内容・digest。ラベル付きはremote確認 |
|
|
23
|
+
| gh_issue_submit | 同上 | 検証・許可・再確認を経てIssue作成 |
|
|
24
|
+
| gh_labels_validate | changePath | ラベル変更案のオフライン検証 |
|
|
25
|
+
| gh_labels_preview | changePath | 対象・before/after・影響対象・digest |
|
|
26
|
+
| gh_labels_apply | changePath | 検証・許可・再確認を経てラベル変更 |
|
|
27
|
+
| gh_issue_form | template/templatePath | Issue Form YAMLを返す。ファイル上書きなし |
|
|
28
|
+
| gh_issue_get | repo、issue | 指定Issueを取得。PRは拒否 |
|
|
29
|
+
| gh_issue_list | repo、任意state | Issue一覧。stateはopen/closed/all(既定all)。PRは除外 |
|
|
30
|
+
| gh_issue_edit | changePath | 既存Issueのtitle/body更新 |
|
|
31
|
+
| gh_issue_close | changePath | 既存Issueをclose |
|
|
32
|
+
| gh_subissues_list | repo、issue | native子Issue一覧 |
|
|
33
|
+
| gh_subissue_add | changePath | native親子関係の追加 |
|
|
34
|
+
| gh_dependencies_list | repo、issue | 指定Issueのblocked_by一覧 |
|
|
35
|
+
| gh_dependency_add | changePath | 指定Issueをブロックする先行Issueを追加 |
|
|
36
|
+
| gh_project_get | projectId | Projects V2のtitle・field一覧 |
|
|
37
|
+
| gh_project_items | projectId | Projectの項目・field値一覧 |
|
|
38
|
+
| gh_project_add_issue | changePath | 指定IssueをProjectへ登録 |
|
|
39
|
+
| gh_project_field_update | changePath | text/number/date/single-select field値更新 |
|
|
40
|
+
|
|
41
|
+
パスは実行時cwd基準。標準templateはtask(既定)/parent。独自templatePathと同時指定できません。Epic/Feature/Task専用テンプレートは利用側で用意します。examplesのexample/demoは架空です。
|
|
42
|
+
|
|
43
|
+
## 別拡張からの呼び出し
|
|
44
|
+
|
|
45
|
+
利用側は、pi-ghのロードと必要ツールの存在を確認し、不在・版不一致では停止してください。内部`dist/src/*`の直接importは公開SDK契約ではありません。
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
// 利用側ツールのexecute内。信頼済みpi-ghが同じPiにロードされている前提。
|
|
49
|
+
const capabilities = await ctx.executeTool("gh_capabilities", {});
|
|
50
|
+
if (capabilities.isError) throw new Error("pi-gh unavailable");
|
|
51
|
+
const info = capabilities.result.structuredContent as {
|
|
52
|
+
data?: { contractVersion?: number; operations?: string[] };
|
|
53
|
+
};
|
|
54
|
+
if (info.data?.contractVersion !== 1 ||
|
|
55
|
+
!info.data.operations?.includes("gh_issue_get")) {
|
|
56
|
+
throw new Error("Unsupported pi-gh contract");
|
|
57
|
+
}
|
|
58
|
+
const nested = await ctx.executeTool("gh_issue_get", {
|
|
59
|
+
repo: "example/demo", issue: 10,
|
|
60
|
+
}, { signal });
|
|
61
|
+
return { ...nested.result, isError: nested.isError };
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`ctx.executeTool()`の戻り値は結果そのものではなく`{ result, isError, toolCall }`です。結果をモデルへ返すのは呼び出し側の責務です。Piはネスト呼び出しをhooksへ通します。機能照会は登録が正しいことの保証ではないため、ロードする拡張は信頼済みのものに限ってください。
|
|
65
|
+
|
|
66
|
+
## 変更JSON
|
|
67
|
+
|
|
68
|
+
新規Issue・ラベル変更は従来の入力を維持します。新しい変更ツールは`{ changePath: "change.json" }`を受け、以下のJSONを読みます。未知・重複キーやツールとoperationの不一致は拒否します。
|
|
69
|
+
|
|
70
|
+
| operation | 必須フィールド(version:1、repo以外) | 任意フィールド |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| issue-edit | issue、title/bodyの少なくとも一方 | — |
|
|
73
|
+
| issue-close | issue | reason: completed(既定)/not_planned |
|
|
74
|
+
| subissue-add | issue(親)、relatedIssue(子) | — |
|
|
75
|
+
| dependency-add | issue(後続)、relatedIssue(先行) | — |
|
|
76
|
+
| project-add-issue | issue、projectId | — |
|
|
77
|
+
| project-field-update | projectId、itemId、fieldId、value | — |
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{"version":1,"repo":"example/demo","operation":"issue-edit","issue":10,"title":"Updated","body":"Revised specification"}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{"version":1,"repo":"example/demo","operation":"project-field-update","projectId":"PVT_example","itemId":"PVTI_example","fieldId":"PVTSSF_example","value":{"singleSelectOptionId":"option_done"}}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
valueは`text`、有限の`number`、実在するISO日付`date`、`singleSelectOptionId`のうち1つ。fieldの種類、Project所属、option所属、項目のIssue/repo所属を照合します。親子・依存追加は同一repo内のみ、自己参照は禁止。入力のIssue番号をGitHubのdatabase ID/node IDへ解決します。従来の`parentIssue`は本文リンクであり、native親子関係は`gh_subissue_add`で別途設定します。
|
|
88
|
+
|
|
89
|
+
## 人間承認と自動実行許可
|
|
90
|
+
|
|
91
|
+
既定は従来と同じローカルTUI承認です。全文を読んでApproveを明示選択してください。既定Cancel、Escape/Ctrl-Cは取消。RPC・print/JSON・子エージェントは、対応するローカル許可がなければ変更を拒否します。`approved`フラグやdigestをAIが渡して承認する入口はありません。
|
|
92
|
+
|
|
93
|
+
所有者が`~/.pi/agent/pi-gh-permissions.json`を配置した場合だけ、許可範囲内の操作を自動実行できます。PiのagentDir/Profileの設定とは独立した、HOME配下の固定パスです。リポジトリ内の設定は許可として読みません。
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"version": 1,
|
|
98
|
+
"grants": [{
|
|
99
|
+
"repo": "example/demo",
|
|
100
|
+
"operations": ["gh_issue_edit", "gh_issue_close", "gh_project_field_update"],
|
|
101
|
+
"allowHeadless": true,
|
|
102
|
+
"allowChild": true,
|
|
103
|
+
"projectIds": ["PVT_example"]
|
|
104
|
+
}]
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- ファイルは現在のOSユーザー所有・mode 0600以下のregular file。ファイルと親ディレクトリのsymlinkは拒否します。未配置は通常の承認経路へ戻ります。壊れたJSON・権限不備は停止します。
|
|
109
|
+
- repo・operationは完全指定。ワイルドカード不可。Project変更にはprojectIdsの完全一致も必要です。
|
|
110
|
+
- 子にはallowChild、UIのない実行にはallowHeadlessがそれぞれ必要です。省略はfalse。
|
|
111
|
+
- 許可できる操作:`gh_issue_submit`、新しい変更ツール6種、`gh_label_create`、`gh_label_edit`、`gh_issue_labels`。最後の3種は`gh_labels_apply`の操作別許可名です。ラベル削除は自動許可できません。
|
|
112
|
+
- 許可ファイルの内容・inode・modeを固定し、変更前とghプロセス起動直前に再照合します。取り消された許可は再利用しません。許可ファイルは自動生成・変更しません。
|
|
113
|
+
- 許可はこのツール群の境界です。任意のshell/ファイル編集権限を持つAIや同じOSユーザーの悪意あるプロセスを隔離するサンドボックスではありません。
|
|
114
|
+
|
|
115
|
+
## 安全境界と制限
|
|
116
|
+
|
|
117
|
+
入力bytes・template・モデルpolicy・関連remote状態を固定し、承認後に再検査します。会話切り替え・tree移動・reload/shutdown・取消で保留中の処理は失効します。変更は同一拡張runtime内で直列化します。別Piプロセス間のロックやGitHub側のatomic compare-and-swapは提供しません。同じ対象の並行編集は避けてください。
|
|
118
|
+
|
|
119
|
+
秘密検出はヒューリスティックです。decoded文字列・object key・remote metadataもマスクし、秘密候補があれば変更を拒否します。秘密漏洩ゼロ、直接gh実行の防止、チェックと実write間の全競合防止は保証しません。
|
|
120
|
+
|
|
121
|
+
ghはshellなし・固定argv/stdin・github.com固定。RESTは明示methodとAPI version 2026-03-10、GraphQLは固定queryとJSON variablesを使い、任意endpoint/query入力はありません。各プロセス60秒、各stdio1MiB、cleanup猶予100ms。POSIX所有process groupを停止しますが、Windowsの子孫全体停止は保証しません。新規read操作は最大100ページ・合計1MiB、反復cursor・field値の切り捨てを拒否します。
|
|
122
|
+
|
|
123
|
+
結果はread/validated/preview/generated/created/applied/noop/rejected/not-started/unknown。`unknown`は変更済みの可能性があります。GitHubを確認するまで再実行しないでください。自動再試行・自動取消はありません。新変更操作は要求した状態を事後確認し、不一致・確認失敗はunknownにします。
|
|
124
|
+
|
|
125
|
+
Issue用YAMLはfieldsのrequired/options/順序/制限とmodels policyを正本にします。従来の上位tierにはupperTierApprovalが必要ですが、その文字列だけで真正な課金承認は証明できません。テンプレートと利用可能なモデルは別問題です。
|
|
126
|
+
|
|
127
|
+
対象外:assignee変更、コメント投稿、クロスrepo親子・依存、関係解除、Project作成/削除/field定義/view変更、iteration等のfield更新、一括write、Herdr/Scaffoldの工程判断。
|
|
128
|
+
|
|
129
|
+
## 開発・検証
|
|
130
|
+
|
|
131
|
+
`npm run build`、`npm run typecheck`、`npm test`。
|
|
132
|
+
`python3 scripts/test-pi-gh-cli.py --pi /absolute/path/to/pi`は合成HOME・偽gh・loopbackモデルで実PiのTUI/RPC/print・ネスト呼び出し・許可付き子実行・hooksを検証します。実アカウントや実GitHubへの試験投稿はしません。GitHubのライブ変更互換性は未実測です。
|
|
133
|
+
`npm pack`にはentry、compiled core、templates、架空examples、README、LICENSEだけを含め、docs/test/認証は含めません。
|
|
134
|
+
|
|
135
|
+
GitHub API参照:[sub-issues](https://docs.github.com/en/rest/issues/sub-issues)、[dependencies](https://docs.github.com/en/rest/issues/issue-dependencies)、[Projects V2](https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/using-the-api-to-manage-projects)。
|
|
@@ -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,14 @@
|
|
|
1
|
+
export type GhOptions = {
|
|
2
|
+
ghExecutable?: string;
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
maxOutputBytes?: number;
|
|
5
|
+
env?: NodeJS.ProcessEnv;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
beforeStart?: () => boolean;
|
|
8
|
+
beforeStartAsync?: () => Promise<boolean>;
|
|
9
|
+
};
|
|
10
|
+
export type GhResult = {
|
|
11
|
+
status: 'ok' | 'not-started' | 'unknown';
|
|
12
|
+
stdout: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function runGh(args: string[], body: string, options?: GhOptions): Promise<GhResult>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export async 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
|
+
try {
|
|
7
|
+
if (options.beforeStartAsync && !await options.beforeStartAsync())
|
|
8
|
+
return { status: 'not-started', stdout: '' };
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return { status: 'not-started', stdout: '' };
|
|
12
|
+
}
|
|
13
|
+
return new Promise(resolve => {
|
|
14
|
+
const group = process.platform !== 'win32';
|
|
15
|
+
let child;
|
|
16
|
+
try {
|
|
17
|
+
if (options.signal?.aborted || options.beforeStart?.() === false || options.signal?.aborted)
|
|
18
|
+
return resolve({ status: 'not-started', stdout: '' });
|
|
19
|
+
child = spawn(options.ghExecutable ?? 'gh', args, { shell: false, detached: group, env: { ...process.env, ...options.env, GH_HOST: 'github.com' }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return resolve({ status: 'not-started', stdout: '' });
|
|
23
|
+
}
|
|
24
|
+
let started = false, failed = false, settled = false, stopping = false, stdoutBytes = 0, stderrBytes = 0;
|
|
25
|
+
const chunks = [];
|
|
26
|
+
let cleanup;
|
|
27
|
+
const signal = (kind) => { try {
|
|
28
|
+
if (group && child.pid)
|
|
29
|
+
process.kill(-child.pid, kind);
|
|
30
|
+
else
|
|
31
|
+
child.kill(kind);
|
|
32
|
+
}
|
|
33
|
+
catch { /* Owned process already gone. */ } };
|
|
34
|
+
const finish = (status) => { if (settled)
|
|
35
|
+
return; settled = true; clearTimeout(timer); if (cleanup)
|
|
36
|
+
clearTimeout(cleanup); options.signal?.removeEventListener('abort', abort); if (stopping && started)
|
|
37
|
+
signal('SIGKILL'); child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); resolve({ status, stdout: Buffer.concat(chunks).toString('utf8') }); };
|
|
38
|
+
const abort = () => { if (settled || stopping)
|
|
39
|
+
return; stopping = true; failed = true; if (started)
|
|
40
|
+
signal('SIGTERM'); cleanup = setTimeout(() => { if (started)
|
|
41
|
+
signal('SIGKILL'); finish(started ? 'unknown' : 'not-started'); }, 100); };
|
|
42
|
+
const timer = setTimeout(abort, timeout);
|
|
43
|
+
child.once('spawn', () => { started = true; if (stopping)
|
|
44
|
+
signal('SIGTERM'); });
|
|
45
|
+
child.once('error', () => { failed = true; if (!started)
|
|
46
|
+
finish('not-started');
|
|
47
|
+
else
|
|
48
|
+
abort(); });
|
|
49
|
+
child.stdout.on('data', (b) => { stdoutBytes += b.length; if (stdoutBytes > max)
|
|
50
|
+
return abort(); chunks.push(b); });
|
|
51
|
+
child.stderr.on('data', (b) => { stderrBytes += b.length; if (stderrBytes > max)
|
|
52
|
+
abort(); });
|
|
53
|
+
child.stdin.on('error', abort);
|
|
54
|
+
child.stdout.on('error', abort);
|
|
55
|
+
child.stderr.on('error', abort);
|
|
56
|
+
child.once('close', (code, signal) => finish(!started ? 'not-started' : !failed && code === 0 && !signal ? 'ok' : 'unknown'));
|
|
57
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
58
|
+
if (options.signal?.aborted)
|
|
59
|
+
abort();
|
|
60
|
+
child.stdin.end(body);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -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>;
|