@papillon6814/pi-gh 0.1.1 → 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/README.md +116 -35
- package/dist/src/core/gh-process.d.ts +1 -0
- package/dist/src/core/gh-process.js +8 -1
- 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/permissions.d.ts +10 -0
- package/dist/src/permissions.js +100 -0
- package/dist/src/service-types.d.ts +4 -1
- package/dist/src/service.d.ts +3 -1
- package/dist/src/service.js +27 -6
- package/extensions/tools.ts +32 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,54 +1,135 @@
|
|
|
1
1
|
# pi-gh
|
|
2
2
|
|
|
3
|
-
Pi
|
|
3
|
+
PiからGitHubを操作するTypeScript製の基盤拡張です。個別ツールを別のPi拡張から組み合わせられます。Scaffoldの工程・ウェーブ・専用テンプレートやHerdr操作は、利用側の拡張が担当します。MIT。CLIのbinは提供しません。既存gh-issueflowを削除・変更する必要はありません。
|
|
4
4
|
|
|
5
5
|
## 要件と導入
|
|
6
|
-
- Node.js >=22.19.0、Pi 1.0.4
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
| 名前 | 入力 | 動作 |
|
|
14
19
|
|---|---|---|
|
|
15
|
-
|
|
|
16
|
-
|
|
|
17
|
-
|
|
|
18
|
-
|
|
|
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 | ラベル変更案のオフライン検証 |
|
|
19
25
|
| gh_labels_preview | changePath | 対象・before/after・影響対象・digest |
|
|
20
|
-
| gh_labels_apply | changePath |
|
|
21
|
-
| gh_issue_form | template/templatePath |
|
|
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
|
|
22
67
|
|
|
23
|
-
|
|
68
|
+
新規Issue・ラベル変更は従来の入力を維持します。新しい変更ツールは`{ changePath: "change.json" }`を受け、以下のJSONを読みます。未知・重複キーやツールとoperationの不一致は拒否します。
|
|
24
69
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
## 人間承認と自動実行許可
|
|
30
90
|
|
|
31
|
-
|
|
91
|
+
既定は従来と同じローカルTUI承認です。全文を読んでApproveを明示選択してください。既定Cancel、Escape/Ctrl-Cは取消。RPC・print/JSON・子エージェントは、対応するローカル許可がなければ変更を拒否します。`approved`フラグやdigestをAIが渡して承認する入口はありません。
|
|
32
92
|
|
|
33
|
-
|
|
93
|
+
所有者が`~/.pi/agent/pi-gh-permissions.json`を配置した場合だけ、許可範囲内の操作を自動実行できます。PiのagentDir/Profileの設定とは独立した、HOME配下の固定パスです。リポジトリ内の設定は許可として読みません。
|
|
34
94
|
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
```
|
|
37
107
|
|
|
38
|
-
|
|
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ユーザーの悪意あるプロセスを隔離するサンドボックスではありません。
|
|
39
114
|
|
|
40
|
-
##
|
|
41
|
-
秘密情報検出はヒューリスティックです。raw inputだけでなくdecoded文字列・object key・remote metadataをマスクし、候補があれば変更を拒否します。秘密漏洩ゼロや自由なbashからの直接gh実行防止を保証するものではありません。
|
|
115
|
+
## 安全境界と制限
|
|
42
116
|
|
|
43
|
-
|
|
117
|
+
入力bytes・template・モデルpolicy・関連remote状態を固定し、承認後に再検査します。会話切り替え・tree移動・reload/shutdown・取消で保留中の処理は失効します。変更は同一拡張runtime内で直列化します。別Piプロセス間のロックやGitHub側のatomic compare-and-swapは提供しません。同じ対象の並行編集は避けてください。
|
|
44
118
|
|
|
45
|
-
|
|
119
|
+
秘密検出はヒューリスティックです。decoded文字列・object key・remote metadataもマスクし、秘密候補があれば変更を拒否します。秘密漏洩ゼロ、直接gh実行の防止、チェックと実write間の全競合防止は保証しません。
|
|
46
120
|
|
|
47
|
-
|
|
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値の切り捨てを拒否します。
|
|
48
122
|
|
|
49
|
-
|
|
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の工程判断。
|
|
50
128
|
|
|
51
129
|
## 開発・検証
|
|
52
|
-
|
|
53
|
-
`
|
|
54
|
-
`
|
|
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)。
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
export function runGh(args, body, options = {}) {
|
|
2
|
+
export async function runGh(args, body, options = {}) {
|
|
3
3
|
const timeout = options.timeoutMs ?? 60000, max = options.maxOutputBytes ?? 1048576;
|
|
4
4
|
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 60000 || !Number.isSafeInteger(max) || max < 1 || max > 1048576 || Buffer.byteLength(body) > 1048576)
|
|
5
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
|
+
}
|
|
6
13
|
return new Promise(resolve => {
|
|
7
14
|
const group = process.platform !== 'win32';
|
|
8
15
|
let child;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type GhOptions } from './core/gh-process.js';
|
|
2
|
+
export type JsonObject = Record<string, unknown>;
|
|
3
|
+
export declare function object(value: unknown): JsonObject;
|
|
4
|
+
export declare function array(value: unknown): unknown[];
|
|
5
|
+
export declare const projectFieldsQuery = "query($projectId:ID!,$cursor:String){node(id:$projectId){__typename ... on ProjectV2{id title fields(first:100,after:$cursor){nodes{__typename ... on ProjectV2Field{id name dataType} ... on ProjectV2SingleSelectField{id name dataType options{id name}} ... on ProjectV2IterationField{id name dataType}} pageInfo{hasNextPage endCursor}}}}}";
|
|
6
|
+
export declare const projectItemsQuery = "query($projectId:ID!,$cursor:String){node(id:$projectId){__typename ... on ProjectV2{id title items(first:100,after:$cursor){nodes{id content{__typename ... on Issue{id number repository{nameWithOwner}}} fieldValues(first:100){nodes{__typename ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id}}} ... on ProjectV2ItemFieldNumberValue{number field{... on ProjectV2Field{id}}} ... on ProjectV2ItemFieldDateValue{date field{... on ProjectV2Field{id}}} ... on ProjectV2ItemFieldSingleSelectValue{optionId field{... on ProjectV2SingleSelectField{id}}}} pageInfo{hasNextPage endCursor}}} pageInfo{hasNextPage endCursor}}}}}";
|
|
7
|
+
export declare const addProjectIssueMutation = "mutation($projectId:ID!,$contentId:ID!){addProjectV2ItemById(input:{projectId:$projectId,contentId:$contentId}){item{id}}}";
|
|
8
|
+
export declare const updateProjectFieldMutation = "mutation($projectId:ID!,$itemId:ID!,$fieldId:ID!,$value:ProjectV2FieldValue!){updateProjectV2ItemFieldValue(input:{projectId:$projectId,itemId:$itemId,fieldId:$fieldId,value:$value}){projectV2Item{id}}}";
|
|
9
|
+
export declare class GithubApi {
|
|
10
|
+
private options;
|
|
11
|
+
private bytes;
|
|
12
|
+
constructor(options: GhOptions);
|
|
13
|
+
private args;
|
|
14
|
+
private decode;
|
|
15
|
+
rest(endpoint: string): Promise<unknown>;
|
|
16
|
+
restList(endpoint: string): Promise<unknown[]>;
|
|
17
|
+
issue(repo: string, number: number): Promise<JsonObject>;
|
|
18
|
+
graphql(query: string, variables: JsonObject): Promise<JsonObject>;
|
|
19
|
+
project(projectId: string, kind: 'fields' | 'items'): Promise<JsonObject>;
|
|
20
|
+
mutateRest(endpoint: string, method: 'POST' | 'PATCH', body: JsonObject): Promise<{
|
|
21
|
+
status: 'ok' | 'not-started' | 'unknown';
|
|
22
|
+
data?: unknown;
|
|
23
|
+
}>;
|
|
24
|
+
mutateGraphql(query: string, variables: JsonObject): Promise<{
|
|
25
|
+
status: 'ok' | 'not-started' | 'unknown';
|
|
26
|
+
data?: unknown;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { runGh } from './core/gh-process.js';
|
|
2
|
+
import { fail, parseJson, record } from './core/data.js';
|
|
3
|
+
export function object(value) { if (!record(value))
|
|
4
|
+
fail('GITHUB_RESPONSE', '', 'Expected a GitHub object.'); return value; }
|
|
5
|
+
export function array(value) { if (!Array.isArray(value))
|
|
6
|
+
fail('GITHUB_RESPONSE', '', 'Expected a GitHub array.'); return value; }
|
|
7
|
+
export const projectFieldsQuery = `query($projectId:ID!,$cursor:String){node(id:$projectId){__typename ... on ProjectV2{id title fields(first:100,after:$cursor){nodes{__typename ... on ProjectV2Field{id name dataType} ... on ProjectV2SingleSelectField{id name dataType options{id name}} ... on ProjectV2IterationField{id name dataType}} pageInfo{hasNextPage endCursor}}}}}`;
|
|
8
|
+
export const projectItemsQuery = `query($projectId:ID!,$cursor:String){node(id:$projectId){__typename ... on ProjectV2{id title items(first:100,after:$cursor){nodes{id content{__typename ... on Issue{id number repository{nameWithOwner}}} fieldValues(first:100){nodes{__typename ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id}}} ... on ProjectV2ItemFieldNumberValue{number field{... on ProjectV2Field{id}}} ... on ProjectV2ItemFieldDateValue{date field{... on ProjectV2Field{id}}} ... on ProjectV2ItemFieldSingleSelectValue{optionId field{... on ProjectV2SingleSelectField{id}}}} pageInfo{hasNextPage endCursor}}} pageInfo{hasNextPage endCursor}}}}}`;
|
|
9
|
+
export const addProjectIssueMutation = `mutation($projectId:ID!,$contentId:ID!){addProjectV2ItemById(input:{projectId:$projectId,contentId:$contentId}){item{id}}}`;
|
|
10
|
+
export const updateProjectFieldMutation = `mutation($projectId:ID!,$itemId:ID!,$fieldId:ID!,$value:ProjectV2FieldValue!){updateProjectV2ItemFieldValue(input:{projectId:$projectId,itemId:$itemId,fieldId:$fieldId,value:$value}){projectV2Item{id}}}`;
|
|
11
|
+
export class GithubApi {
|
|
12
|
+
options;
|
|
13
|
+
bytes = 0;
|
|
14
|
+
constructor(options) {
|
|
15
|
+
this.options = options;
|
|
16
|
+
}
|
|
17
|
+
args(endpoint, method) { return ['api', '--hostname', 'github.com', '--method', method, '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2026-03-10', endpoint]; }
|
|
18
|
+
decode(stdout) { this.bytes += Buffer.byteLength(stdout); if (this.bytes > 1048576)
|
|
19
|
+
fail('GITHUB_LIMIT', '', 'Combined response limit exceeded.'); return parseJson(Buffer.from(stdout)); }
|
|
20
|
+
async rest(endpoint) { const r = await runGh(this.args(endpoint, 'GET'), '', this.options); if (r.status !== 'ok')
|
|
21
|
+
fail('GITHUB_READ', '', 'GitHub read failed.'); return this.decode(r.stdout); }
|
|
22
|
+
async restList(endpoint) {
|
|
23
|
+
const all = [];
|
|
24
|
+
for (let page = 1; page <= 100; page++) {
|
|
25
|
+
const items = array(await this.rest(endpoint + (endpoint.includes('?') ? '&' : '?') + 'per_page=100&page=' + page));
|
|
26
|
+
all.push(...items);
|
|
27
|
+
if (items.length < 100)
|
|
28
|
+
return all;
|
|
29
|
+
}
|
|
30
|
+
fail('GITHUB_LIMIT', '', 'Pagination limit exceeded.');
|
|
31
|
+
}
|
|
32
|
+
async issue(repo, number) {
|
|
33
|
+
const v = object(await this.rest('repos/' + repo + '/issues/' + number));
|
|
34
|
+
if (v.pull_request !== undefined || v.number !== number || !Number.isSafeInteger(v.id) || v.id < 1 || typeof v.node_id !== 'string' || !/^I_[A-Za-z0-9_-]+$/.test(v.node_id) || typeof v.html_url !== 'string' || v.html_url.toLowerCase() !== ('https://github.com/' + repo + '/issues/' + number).toLowerCase())
|
|
35
|
+
fail('GITHUB_IDENTITY', '', 'Expected the exact GitHub Issue, not a PR.');
|
|
36
|
+
return v;
|
|
37
|
+
}
|
|
38
|
+
async graphql(query, variables) {
|
|
39
|
+
const r = await runGh([...this.args('graphql', 'POST'), '--input', '-'], JSON.stringify({ query, variables }), this.options);
|
|
40
|
+
if (r.status !== 'ok')
|
|
41
|
+
fail('GITHUB_READ', '', 'GraphQL read failed.');
|
|
42
|
+
const v = object(this.decode(r.stdout));
|
|
43
|
+
if (v.errors !== undefined)
|
|
44
|
+
fail('GITHUB_READ', '', 'GraphQL returned errors.');
|
|
45
|
+
return object(v.data);
|
|
46
|
+
}
|
|
47
|
+
async project(projectId, kind) {
|
|
48
|
+
const nodes = [];
|
|
49
|
+
const cursors = new Set();
|
|
50
|
+
let cursor = null, title;
|
|
51
|
+
for (let page = 0; page < 100; page++) {
|
|
52
|
+
const response = await this.graphql(kind === 'fields' ? projectFieldsQuery : projectItemsQuery, { projectId, cursor });
|
|
53
|
+
const project = object(response.node);
|
|
54
|
+
if (project.__typename !== 'ProjectV2' || project.id !== projectId)
|
|
55
|
+
fail('GITHUB_IDENTITY', '', 'Expected the exact Projects V2 node.');
|
|
56
|
+
title = project.title;
|
|
57
|
+
const connection = object(project[kind]), items = array(connection.nodes);
|
|
58
|
+
nodes.push(...items);
|
|
59
|
+
if (kind === 'items')
|
|
60
|
+
for (const raw of items) {
|
|
61
|
+
const item = object(raw), values = object(item.fieldValues);
|
|
62
|
+
if (values.pageInfo !== undefined && object(values.pageInfo).hasNextPage !== false)
|
|
63
|
+
fail('GITHUB_LIMIT', '', 'Item field values were truncated.');
|
|
64
|
+
}
|
|
65
|
+
const info = object(connection.pageInfo);
|
|
66
|
+
if (info.hasNextPage === false)
|
|
67
|
+
return { id: projectId, title, [kind]: nodes };
|
|
68
|
+
if (info.hasNextPage !== true || typeof info.endCursor !== 'string' || !info.endCursor || cursors.has(info.endCursor))
|
|
69
|
+
fail('GITHUB_LIMIT', '', 'Invalid or repeated pagination cursor.');
|
|
70
|
+
cursor = info.endCursor;
|
|
71
|
+
cursors.add(cursor);
|
|
72
|
+
}
|
|
73
|
+
fail('GITHUB_LIMIT', '', 'Pagination limit exceeded.');
|
|
74
|
+
}
|
|
75
|
+
async mutateRest(endpoint, method, body) {
|
|
76
|
+
const r = await runGh([...this.args(endpoint, method), '--input', '-'], JSON.stringify(body), this.options);
|
|
77
|
+
if (r.status !== 'ok')
|
|
78
|
+
return { status: r.status };
|
|
79
|
+
try {
|
|
80
|
+
return { status: 'ok', data: this.decode(r.stdout) };
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return { status: 'unknown' };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async mutateGraphql(query, variables) {
|
|
87
|
+
const r = await runGh([...this.args('graphql', 'POST'), '--input', '-'], JSON.stringify({ query, variables }), this.options);
|
|
88
|
+
if (r.status !== 'ok')
|
|
89
|
+
return { status: r.status };
|
|
90
|
+
try {
|
|
91
|
+
const v = object(this.decode(r.stdout));
|
|
92
|
+
if (v.errors !== undefined)
|
|
93
|
+
return { status: 'unknown' };
|
|
94
|
+
return { status: 'ok', data: object(v.data) };
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { status: 'unknown' };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CapturedInput } from './core/types.js';
|
|
2
|
+
export declare const githubReads: readonly ["gh_issue_get", "gh_issue_list", "gh_subissues_list", "gh_dependencies_list", "gh_project_get", "gh_project_items"];
|
|
3
|
+
export declare const githubWrites: readonly ["gh_issue_edit", "gh_issue_close", "gh_subissue_add", "gh_dependency_add", "gh_project_add_issue", "gh_project_field_update"];
|
|
4
|
+
export type GithubOperation = typeof githubReads[number] | typeof githubWrites[number];
|
|
5
|
+
export type GithubArgs = {
|
|
6
|
+
repo?: string;
|
|
7
|
+
issue?: number;
|
|
8
|
+
state?: 'open' | 'closed' | 'all';
|
|
9
|
+
projectId?: string;
|
|
10
|
+
changePath?: string;
|
|
11
|
+
};
|
|
12
|
+
export type FieldValue = {
|
|
13
|
+
text?: string;
|
|
14
|
+
number?: number;
|
|
15
|
+
date?: string;
|
|
16
|
+
singleSelectOptionId?: string;
|
|
17
|
+
};
|
|
18
|
+
export type Change = {
|
|
19
|
+
version: 1;
|
|
20
|
+
repo: string;
|
|
21
|
+
operation: string;
|
|
22
|
+
issue?: number;
|
|
23
|
+
title?: string;
|
|
24
|
+
body?: string;
|
|
25
|
+
reason?: 'completed' | 'not_planned';
|
|
26
|
+
relatedIssue?: number;
|
|
27
|
+
projectId?: string;
|
|
28
|
+
itemId?: string;
|
|
29
|
+
fieldId?: string;
|
|
30
|
+
value?: FieldValue;
|
|
31
|
+
};
|
|
32
|
+
export declare function positive(value: unknown): asserts value is number;
|
|
33
|
+
export declare function nodeId(value: unknown, prefix: string): asserts value is string;
|
|
34
|
+
export declare function validateGithubArgs(name: GithubOperation, args: unknown): asserts args is GithubArgs;
|
|
35
|
+
export declare function validateChange(name: GithubOperation, input: CapturedInput): Change;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { fail, keys, record, parseJson } from './core/data.js';
|
|
2
|
+
import { validateRepo } from './core/labels-input.js';
|
|
3
|
+
export const githubReads = ['gh_issue_get', 'gh_issue_list', 'gh_subissues_list', 'gh_dependencies_list', 'gh_project_get', 'gh_project_items'];
|
|
4
|
+
export const githubWrites = ['gh_issue_edit', 'gh_issue_close', 'gh_subissue_add', 'gh_dependency_add', 'gh_project_add_issue', 'gh_project_field_update'];
|
|
5
|
+
export function positive(value) { if (!Number.isSafeInteger(value) || value < 1)
|
|
6
|
+
fail('ARGUMENT', '', 'Expected a positive Issue number.'); }
|
|
7
|
+
export function nodeId(value, prefix) { if (typeof value !== 'string' || !new RegExp('^' + prefix + '[A-Za-z0-9_-]{1,200}$').test(value))
|
|
8
|
+
fail('ARGUMENT', '', 'Invalid GitHub node ID.'); }
|
|
9
|
+
export function validateGithubArgs(name, args) {
|
|
10
|
+
if (!record(args))
|
|
11
|
+
fail('ARGUMENT', '', 'Expected an argument object.');
|
|
12
|
+
if (githubWrites.includes(name)) {
|
|
13
|
+
keys(args, ['changePath'], 'arguments');
|
|
14
|
+
if (typeof args.changePath !== 'string' || !args.changePath.trim())
|
|
15
|
+
fail('ARGUMENT', '', 'Expected changePath.');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (name.startsWith('gh_project_')) {
|
|
19
|
+
keys(args, ['projectId'], 'arguments');
|
|
20
|
+
nodeId(args.projectId, 'PVT_');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
keys(args, name === 'gh_issue_list' ? ['repo', 'state'] : ['repo', 'issue'], 'arguments');
|
|
24
|
+
validateRepo(args.repo);
|
|
25
|
+
if (name === 'gh_issue_list') {
|
|
26
|
+
if (args.state !== undefined && !['open', 'closed', 'all'].includes(args.state))
|
|
27
|
+
fail('ARGUMENT', '', 'Unknown Issue state.');
|
|
28
|
+
}
|
|
29
|
+
else
|
|
30
|
+
positive(args.issue);
|
|
31
|
+
}
|
|
32
|
+
const changes = {
|
|
33
|
+
'issue-edit': { name: 'gh_issue_edit', keys: ['issue', 'title', 'body'] },
|
|
34
|
+
'issue-close': { name: 'gh_issue_close', keys: ['issue', 'reason'] },
|
|
35
|
+
'subissue-add': { name: 'gh_subissue_add', keys: ['issue', 'relatedIssue'] },
|
|
36
|
+
'dependency-add': { name: 'gh_dependency_add', keys: ['issue', 'relatedIssue'] },
|
|
37
|
+
'project-add-issue': { name: 'gh_project_add_issue', keys: ['issue', 'projectId'] },
|
|
38
|
+
'project-field-update': { name: 'gh_project_field_update', keys: ['projectId', 'itemId', 'fieldId', 'value'] }
|
|
39
|
+
};
|
|
40
|
+
export function validateChange(name, input) {
|
|
41
|
+
const raw = parseJson(input.bytes);
|
|
42
|
+
if (!record(raw) || raw.version !== 1 || typeof raw.operation !== 'string')
|
|
43
|
+
fail('ARGUMENT', '', 'Expected version 1 change.');
|
|
44
|
+
const definition = changes[raw.operation];
|
|
45
|
+
if (!definition || definition.name !== name)
|
|
46
|
+
fail('ARGUMENT', '', 'Change does not match the tool operation.');
|
|
47
|
+
keys(raw, ['version', 'repo', 'operation', ...definition.keys], 'change');
|
|
48
|
+
validateRepo(raw.repo);
|
|
49
|
+
if (raw.operation !== 'project-field-update')
|
|
50
|
+
positive(raw.issue);
|
|
51
|
+
if (raw.operation === 'issue-edit') {
|
|
52
|
+
if (raw.title === undefined && raw.body === undefined)
|
|
53
|
+
fail('ARGUMENT', '', 'No edit requested.');
|
|
54
|
+
if (raw.title !== undefined && (typeof raw.title !== 'string' || !raw.title.trim() || raw.title.length > 256 || /[\u0000-\u001f\u007f]/.test(raw.title)))
|
|
55
|
+
fail('ARGUMENT', '', 'Invalid title.');
|
|
56
|
+
if (raw.body !== undefined && (typeof raw.body !== 'string' || raw.body.length > 65536))
|
|
57
|
+
fail('ARGUMENT', '', 'Invalid body.');
|
|
58
|
+
}
|
|
59
|
+
if (raw.operation === 'issue-close' && raw.reason !== undefined && !['completed', 'not_planned'].includes(raw.reason))
|
|
60
|
+
fail('ARGUMENT', '', 'Invalid close reason.');
|
|
61
|
+
if (raw.operation === 'subissue-add' || raw.operation === 'dependency-add') {
|
|
62
|
+
positive(raw.relatedIssue);
|
|
63
|
+
if (raw.relatedIssue === raw.issue)
|
|
64
|
+
fail('ARGUMENT', '', 'Self-links are forbidden.');
|
|
65
|
+
}
|
|
66
|
+
if (raw.operation.startsWith('project-'))
|
|
67
|
+
nodeId(raw.projectId, 'PVT_');
|
|
68
|
+
if (raw.operation === 'project-field-update') {
|
|
69
|
+
nodeId(raw.itemId, 'PVTI_');
|
|
70
|
+
if (typeof raw.fieldId !== 'string' || !/^PVT(F|SSF)_[A-Za-z0-9_-]{1,200}$/.test(raw.fieldId))
|
|
71
|
+
fail('ARGUMENT', '', 'Invalid field ID.');
|
|
72
|
+
if (!record(raw.value) || Object.keys(raw.value).length !== 1)
|
|
73
|
+
fail('ARGUMENT', '', 'Expected one typed field value.');
|
|
74
|
+
keys(raw.value, ['text', 'number', 'date', 'singleSelectOptionId'], 'field value');
|
|
75
|
+
const v = raw.value;
|
|
76
|
+
if ('text' in v && (typeof v.text !== 'string' || v.text.length > 20000))
|
|
77
|
+
fail('ARGUMENT', '', 'Invalid text.');
|
|
78
|
+
if ('number' in v && (typeof v.number !== 'number' || !Number.isFinite(v.number)))
|
|
79
|
+
fail('ARGUMENT', '', 'Invalid number.');
|
|
80
|
+
if ('date' in v) {
|
|
81
|
+
if (typeof v.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v.date) || !Number.isFinite(Date.parse(v.date)) || new Date(v.date).toISOString().slice(0, 10) !== v.date)
|
|
82
|
+
fail('ARGUMENT', '', 'Invalid ISO date.');
|
|
83
|
+
}
|
|
84
|
+
if ('singleSelectOptionId' in v && (typeof v.singleSelectOptionId !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(v.singleSelectOptionId)))
|
|
85
|
+
fail('ARGUMENT', '', 'Invalid option ID.');
|
|
86
|
+
}
|
|
87
|
+
return raw;
|
|
88
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type GithubOperation, type GithubArgs } from './operation-input.js';
|
|
2
|
+
import type { OperationContext, OperationResult } from './service-types.js';
|
|
3
|
+
export declare function runGithubOperation(name: GithubOperation, args: GithubArgs, ctx: OperationContext): Promise<OperationResult>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { loadDraft } from './core/draft.js';
|
|
4
|
+
import { maskDecodedSecrets } from './core/secrets.js';
|
|
5
|
+
import { fail } from './core/data.js';
|
|
6
|
+
import { safeResult, errorResult, rejected } from './result.js';
|
|
7
|
+
import { authorize, recheckAuthority } from './service.js';
|
|
8
|
+
import { GithubApi, object, array, addProjectIssueMutation, updateProjectFieldMutation } from './github-api.js';
|
|
9
|
+
import { githubReads, githubWrites, validateGithubArgs, validateChange } from './operation-input.js';
|
|
10
|
+
function active(ctx) { return !ctx.signal.aborted && ctx.isCurrent(); }
|
|
11
|
+
function requireActive(ctx) { if (!active(ctx))
|
|
12
|
+
fail('STALE_OPERATION', '', 'Session changed or operation was cancelled.'); }
|
|
13
|
+
function digest(bytes, before) { return createHash('sha256').update(bytes).update(JSON.stringify(before)).digest('hex'); }
|
|
14
|
+
function fieldValue(item, fieldId) {
|
|
15
|
+
return array(object(item.fieldValues).nodes).map(object).find(v => v.field !== undefined && object(v.field).id === fieldId);
|
|
16
|
+
}
|
|
17
|
+
function equalField(item, c) { const value = fieldValue(item, c.fieldId); if (!value)
|
|
18
|
+
return false; const [key, wanted] = Object.entries(c.value)[0]; return value[key === 'singleSelectOptionId' ? 'optionId' : key] === wanted; }
|
|
19
|
+
function verifyItem(item, repo) { const content = object(item.content); if (content.__typename !== 'Issue' || object(content.repository).nameWithOwner?.toString().toLowerCase() !== repo.toLowerCase())
|
|
20
|
+
fail('GITHUB_IDENTITY', '', 'Project item does not belong to the specified repository Issue.'); }
|
|
21
|
+
async function snapshot(api, c) {
|
|
22
|
+
const s = { noop: false };
|
|
23
|
+
if (c.issue !== undefined)
|
|
24
|
+
s.issue = await api.issue(c.repo, c.issue);
|
|
25
|
+
if (c.operation === 'issue-edit') {
|
|
26
|
+
s.noop = (c.title === undefined || c.title === s.issue.title) && (c.body === undefined || c.body === s.issue.body);
|
|
27
|
+
return s;
|
|
28
|
+
}
|
|
29
|
+
if (c.operation === 'issue-close') {
|
|
30
|
+
s.noop = s.issue.state === 'closed' && s.issue.state_reason === (c.reason ?? 'completed');
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
if (c.operation === 'subissue-add' || c.operation === 'dependency-add') {
|
|
34
|
+
s.related = await api.issue(c.repo, c.relatedIssue);
|
|
35
|
+
const suffix = c.operation === 'subissue-add' ? 'sub_issues' : 'dependencies/blocked_by';
|
|
36
|
+
s.relations = await api.restList('repos/' + c.repo + '/issues/' + c.issue + '/' + suffix);
|
|
37
|
+
s.noop = s.relations.some(v => object(v).id === s.related.id);
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
const fields = await api.project(c.projectId, 'fields'), items = await api.project(c.projectId, 'items');
|
|
41
|
+
s.project = { id: fields.id };
|
|
42
|
+
if (c.operation === 'project-add-issue') {
|
|
43
|
+
s.item = array(items.items).map(object).find(item => item.content !== null && object(item.content).id === s.issue.node_id);
|
|
44
|
+
if (s.item) {
|
|
45
|
+
verifyItem(s.item, c.repo);
|
|
46
|
+
s.noop = true;
|
|
47
|
+
}
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
s.item = array(items.items).map(object).find(item => item.id === c.itemId);
|
|
51
|
+
if (!s.item)
|
|
52
|
+
fail('GITHUB_IDENTITY', '', 'Item is not in the specified project.');
|
|
53
|
+
verifyItem(s.item, c.repo);
|
|
54
|
+
s.field = array(fields.fields).map(object).find(field => field.id === c.fieldId);
|
|
55
|
+
if (!s.field)
|
|
56
|
+
fail('GITHUB_IDENTITY', '', 'Field is not in the specified project.');
|
|
57
|
+
const key = Object.keys(c.value)[0], types = { text: 'TEXT', number: 'NUMBER', date: 'DATE', singleSelectOptionId: 'SINGLE_SELECT' };
|
|
58
|
+
if (s.field.dataType !== types[key])
|
|
59
|
+
fail('ARGUMENT', '', 'Field type does not match the supplied value.');
|
|
60
|
+
if (key === 'singleSelectOptionId' && !array(s.field.options).some(v => object(v).id === c.value.singleSelectOptionId))
|
|
61
|
+
fail('ARGUMENT', '', 'Option is not in the specified field.');
|
|
62
|
+
s.noop = equalField(s.item, c);
|
|
63
|
+
const current = fieldValue(s.item, c.fieldId);
|
|
64
|
+
s.item = { id: s.item.id, content: s.item.content, fieldValues: { nodes: current ? [current] : [] } };
|
|
65
|
+
s.field = { id: s.field.id, name: s.field.name, dataType: s.field.dataType, ...(key === 'singleSelectOptionId' ? { options: array(s.field.options).filter(v => object(v).id === c.value.singleSelectOptionId) } : {}) };
|
|
66
|
+
return s;
|
|
67
|
+
}
|
|
68
|
+
async function apply(api, c, before) {
|
|
69
|
+
const endpoint = 'repos/' + c.repo + '/issues/' + c.issue;
|
|
70
|
+
switch (c.operation) {
|
|
71
|
+
case 'issue-edit': return api.mutateRest(endpoint, 'PATCH', { ...(c.title !== undefined ? { title: c.title } : {}), ...(c.body !== undefined ? { body: c.body } : {}) });
|
|
72
|
+
case 'issue-close': return api.mutateRest(endpoint, 'PATCH', { state: 'closed', state_reason: c.reason ?? 'completed' });
|
|
73
|
+
case 'subissue-add': return api.mutateRest(endpoint + '/sub_issues', 'POST', { sub_issue_id: before.related.id });
|
|
74
|
+
case 'dependency-add': return api.mutateRest(endpoint + '/dependencies/blocked_by', 'POST', { issue_id: before.related.id });
|
|
75
|
+
case 'project-add-issue': return api.mutateGraphql(addProjectIssueMutation, { projectId: c.projectId, contentId: before.issue.node_id });
|
|
76
|
+
case 'project-field-update': return api.mutateGraphql(updateProjectFieldMutation, { projectId: c.projectId, itemId: c.itemId, fieldId: c.fieldId, value: c.value });
|
|
77
|
+
default: fail('ARGUMENT', '', 'Unknown change.');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function runGithubOperation(name, args, ctx) {
|
|
81
|
+
let mutationStarted = false;
|
|
82
|
+
try {
|
|
83
|
+
if (![...githubReads, ...githubWrites].includes(name))
|
|
84
|
+
return rejected('ARGUMENT', 'Unknown operation.');
|
|
85
|
+
validateGithubArgs(name, args);
|
|
86
|
+
requireActive(ctx);
|
|
87
|
+
const write = githubWrites.includes(name);
|
|
88
|
+
if (write && !ctx.interactive && !ctx.permissions)
|
|
89
|
+
return rejected('APPROVAL_UI_REQUIRED', 'External changes require local approval or an exact permission grant.');
|
|
90
|
+
let machineAuthority = false;
|
|
91
|
+
const api = new GithubApi({ signal: ctx.signal, beforeStart: () => active(ctx), beforeStartAsync: async () => !machineAuthority || !!ctx.permissions && await ctx.permissions.isCurrent() });
|
|
92
|
+
if (!write) {
|
|
93
|
+
let data;
|
|
94
|
+
if (name === 'gh_project_get' || name === 'gh_project_items')
|
|
95
|
+
data = await api.project(args.projectId, name === 'gh_project_get' ? 'fields' : 'items');
|
|
96
|
+
else if (name === 'gh_issue_list')
|
|
97
|
+
data = (await api.restList('repos/' + args.repo + '/issues?state=' + (args.state ?? 'all'))).filter(v => object(v).pull_request === undefined);
|
|
98
|
+
else {
|
|
99
|
+
const issue = await api.issue(args.repo, args.issue);
|
|
100
|
+
data = name === 'gh_issue_get' ? issue : await api.restList('repos/' + args.repo + '/issues/' + args.issue + '/' + (name === 'gh_subissues_list' ? 'sub_issues' : 'dependencies/blocked_by'));
|
|
101
|
+
}
|
|
102
|
+
requireActive(ctx);
|
|
103
|
+
return safeResult({ status: 'read', data });
|
|
104
|
+
}
|
|
105
|
+
const path = resolve(ctx.cwd, args.changePath), input = await loadDraft(path), change = validateChange(name, input);
|
|
106
|
+
if (maskDecodedSecrets(change).sensitive)
|
|
107
|
+
return rejected('SENSITIVE', 'Remove secret candidates before applying.');
|
|
108
|
+
if (!ctx.interactive && !ctx.permissions?.allows(name, change.repo, change.projectId))
|
|
109
|
+
return rejected('PERMISSION_DENIED', 'No matching operation/repository/project/actor permission.');
|
|
110
|
+
const before = await snapshot(api, change);
|
|
111
|
+
requireActive(ctx);
|
|
112
|
+
const preview = { operation: name, change, before };
|
|
113
|
+
if (maskDecodedSecrets(preview).sensitive)
|
|
114
|
+
return rejected('SENSITIVE', 'Remove secret candidates before applying.');
|
|
115
|
+
if (before.noop)
|
|
116
|
+
return safeResult({ status: 'noop', data: { repo: change.repo, operation: name } });
|
|
117
|
+
const hash = digest(input.bytes, before), machine = await authorize({ operation: name, text: JSON.stringify(preview, null, 2), digest: hash }, ctx, change.repo, name, change.projectId);
|
|
118
|
+
machineAuthority = machine;
|
|
119
|
+
const latest = await loadDraft(path), nextChange = validateChange(name, latest), next = await snapshot(api, nextChange);
|
|
120
|
+
requireActive(ctx);
|
|
121
|
+
if (digest(latest.bytes, next) !== hash || maskDecodedSecrets({ change: nextChange, before: next }).sensitive)
|
|
122
|
+
return rejected('APPROVAL_MISMATCH', 'Input or remote state changed; preview again.');
|
|
123
|
+
await recheckAuthority(ctx, machine);
|
|
124
|
+
mutationStarted = true;
|
|
125
|
+
const result = await apply(api, change, before);
|
|
126
|
+
if (result.status !== 'ok')
|
|
127
|
+
return safeResult({ status: result.status, message: result.status === 'unknown' ? 'Change outcome is uncertain; inspect GitHub before retrying.' : 'Change did not start.' });
|
|
128
|
+
const verified = await snapshot(api, change);
|
|
129
|
+
requireActive(ctx);
|
|
130
|
+
if (!verified.noop)
|
|
131
|
+
return safeResult({ status: 'unknown', message: 'Write returned, but the requested state could not be verified. Inspect GitHub before retrying.' });
|
|
132
|
+
return safeResult({ status: 'applied', data: { repo: change.repo, operation: name, ...(change.projectId ? { projectId: change.projectId } : {}), ...(verified.issue ? { issue: verified.issue } : {}), ...(verified.item ? { itemId: verified.item.id } : {}) } });
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
return mutationStarted ? safeResult({ status: 'unknown', message: 'Change or its verification was interrupted; inspect GitHub before retrying.' }) : errorResult(error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type PermissionLease = {
|
|
2
|
+
allows: (operation: string, repo: string, projectId?: string) => boolean;
|
|
3
|
+
isCurrent: () => Promise<boolean>;
|
|
4
|
+
};
|
|
5
|
+
export type Actor = {
|
|
6
|
+
child: boolean;
|
|
7
|
+
headless: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare function permissionPath(): string;
|
|
10
|
+
export declare function loadPermissions(path: string, actor: Actor): Promise<PermissionLease | undefined>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open, lstat } from 'node:fs/promises';
|
|
3
|
+
import { resolve, dirname, join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { fail, keys, record, parseJson } from './core/data.js';
|
|
7
|
+
import { validateRepo } from './core/labels-input.js';
|
|
8
|
+
const allowed = new Set(['gh_issue_submit', 'gh_label_create', 'gh_label_edit', 'gh_issue_labels', 'gh_issue_edit', 'gh_issue_close', 'gh_subissue_add', 'gh_dependency_add', 'gh_project_add_issue', 'gh_project_field_update']);
|
|
9
|
+
export function permissionPath() { return join(homedir(), '.pi', 'agent', 'pi-gh-permissions.json'); }
|
|
10
|
+
async function capture(path) {
|
|
11
|
+
const absolute = resolve(path);
|
|
12
|
+
let parent = dirname(absolute);
|
|
13
|
+
try {
|
|
14
|
+
const named = await lstat(absolute);
|
|
15
|
+
if (!named.isFile() || named.size > 65536 || (named.mode & 0o077) !== 0 || (process.getuid && named.uid !== process.getuid()))
|
|
16
|
+
fail('PERMISSION_FILE', '', 'Use an owner-only regular permission file.');
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code === 'ENOENT')
|
|
20
|
+
return undefined;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
while (true) {
|
|
24
|
+
const s = await lstat(parent);
|
|
25
|
+
if (!s.isDirectory() || s.isSymbolicLink())
|
|
26
|
+
fail('PERMISSION_FILE', '', 'Permission parents must be real directories.');
|
|
27
|
+
const next = dirname(parent);
|
|
28
|
+
if (next === parent)
|
|
29
|
+
break;
|
|
30
|
+
parent = next;
|
|
31
|
+
}
|
|
32
|
+
let handle;
|
|
33
|
+
try {
|
|
34
|
+
handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT')
|
|
38
|
+
return undefined;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const s = await handle.stat();
|
|
43
|
+
if (!s.isFile() || s.size > 65536 || (s.mode & 0o077) !== 0 || (process.getuid && s.uid !== process.getuid()))
|
|
44
|
+
fail('PERMISSION_FILE', '', 'Use an owner-only regular permission file.');
|
|
45
|
+
const buffer = Buffer.alloc(65537);
|
|
46
|
+
let used = 0;
|
|
47
|
+
while (used < buffer.length) {
|
|
48
|
+
const read = await handle.read(buffer, used, buffer.length - used, used);
|
|
49
|
+
if (read.bytesRead === 0)
|
|
50
|
+
break;
|
|
51
|
+
used += read.bytesRead;
|
|
52
|
+
}
|
|
53
|
+
if (used > 65536)
|
|
54
|
+
fail('PERMISSION_FILE', '', 'Permission file exceeds limit.');
|
|
55
|
+
const bytes = buffer.subarray(0, used), after = await handle.stat(), named = await lstat(absolute);
|
|
56
|
+
if (named.isSymbolicLink() || s.ino !== named.ino || s.dev !== named.dev || s.size !== after.size || s.size !== used || s.mode !== after.mode || s.uid !== after.uid || s.mode !== named.mode || s.mtimeMs !== after.mtimeMs)
|
|
57
|
+
fail('PERMISSION_FILE', '', 'Permission file changed while reading.');
|
|
58
|
+
return JSON.stringify({ inode: s.ino, device: s.dev, mode: s.mode, bytes: bytes.toString('base64') });
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await handle.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function loadPermissions(path, actor) {
|
|
65
|
+
const captured = await capture(path);
|
|
66
|
+
if (captured === undefined)
|
|
67
|
+
return undefined;
|
|
68
|
+
const encoded = JSON.parse(captured);
|
|
69
|
+
const raw = parseJson(Buffer.from(encoded.bytes, 'base64'));
|
|
70
|
+
if (!record(raw))
|
|
71
|
+
fail('PERMISSION_FORMAT', '', 'Expected permission object.');
|
|
72
|
+
keys(raw, ['version', 'grants'], 'permissions');
|
|
73
|
+
if (raw.version !== 1 || !Array.isArray(raw.grants) || raw.grants.length > 100)
|
|
74
|
+
fail('PERMISSION_FORMAT', '', 'Expected version 1 and at most 100 grants.');
|
|
75
|
+
const grants = raw.grants.map(value => {
|
|
76
|
+
if (!record(value))
|
|
77
|
+
fail('PERMISSION_FORMAT', '', 'Expected grant object.');
|
|
78
|
+
keys(value, ['repo', 'operations', 'allowChild', 'allowHeadless', 'projectIds'], 'grant');
|
|
79
|
+
validateRepo(value.repo);
|
|
80
|
+
if (!Array.isArray(value.operations) || !value.operations.length || value.operations.some(x => typeof x !== 'string' || !allowed.has(x)) || new Set(value.operations).size !== value.operations.length)
|
|
81
|
+
fail('PERMISSION_FORMAT', '', 'Unknown or repeated operation.');
|
|
82
|
+
for (const key of ['allowChild', 'allowHeadless'])
|
|
83
|
+
if (value[key] !== undefined && typeof value[key] !== 'boolean')
|
|
84
|
+
fail('PERMISSION_FORMAT', '', 'Actor flags must be booleans.');
|
|
85
|
+
if (value.projectIds !== undefined && (!Array.isArray(value.projectIds) || value.projectIds.length > 100 || value.projectIds.some(x => typeof x !== 'string' || !/^PVT_[A-Za-z0-9_-]+$/.test(x))))
|
|
86
|
+
fail('PERMISSION_FORMAT', '', 'Expected exact project IDs.');
|
|
87
|
+
return { repo: value.repo, operations: value.operations, allowChild: value.allowChild === true, allowHeadless: value.allowHeadless === true, projectIds: (value.projectIds ?? []) };
|
|
88
|
+
});
|
|
89
|
+
const digest = createHash('sha256').update(captured).digest('hex');
|
|
90
|
+
return {
|
|
91
|
+
allows: (operation, repo, projectId) => grants.some(g => g.repo.toLowerCase() === repo.toLowerCase() && g.operations.includes(operation) && (!actor.child || g.allowChild) && (!actor.headless || g.allowHeadless) && (!projectId || g.projectIds.includes(projectId))),
|
|
92
|
+
isCurrent: async () => { try {
|
|
93
|
+
const next = await capture(path);
|
|
94
|
+
return next !== undefined && createHash('sha256').update(next).digest('hex') === digest;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
} }
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ValidationProblem } from './core/types.js';
|
|
2
|
+
import type { PermissionLease } from './permissions.js';
|
|
2
3
|
export type Operation = 'gh_issue_validate' | 'gh_issue_preview' | 'gh_issue_submit' | 'gh_labels_validate' | 'gh_labels_preview' | 'gh_labels_apply' | 'gh_issue_form';
|
|
3
4
|
export type IssueArgs = {
|
|
4
5
|
draftPath: string;
|
|
@@ -13,7 +14,7 @@ export type FormArgs = {
|
|
|
13
14
|
templatePath?: string;
|
|
14
15
|
};
|
|
15
16
|
export type ApprovalView = {
|
|
16
|
-
operation:
|
|
17
|
+
operation: string;
|
|
17
18
|
text: string;
|
|
18
19
|
digest: string;
|
|
19
20
|
};
|
|
@@ -21,6 +22,7 @@ export type OperationContext = {
|
|
|
21
22
|
cwd: string;
|
|
22
23
|
packageRoot: string;
|
|
23
24
|
interactive: boolean;
|
|
25
|
+
permissions?: PermissionLease;
|
|
24
26
|
signal: AbortSignal;
|
|
25
27
|
stamp: string;
|
|
26
28
|
isCurrent: () => boolean;
|
|
@@ -31,4 +33,5 @@ export type OperationResult = {
|
|
|
31
33
|
data?: unknown;
|
|
32
34
|
problems?: ValidationProblem[];
|
|
33
35
|
message?: string;
|
|
36
|
+
candidateUrl?: string;
|
|
34
37
|
};
|
package/dist/src/service.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
import type { Operation, IssueArgs, LabelsArgs, FormArgs, OperationContext, OperationResult } from './service-types.js';
|
|
1
|
+
import type { Operation, IssueArgs, LabelsArgs, FormArgs, OperationContext, OperationResult, ApprovalView } from './service-types.js';
|
|
2
|
+
export declare function authorize(view: ApprovalView, ctx: OperationContext, repo: string, permissionOperation?: string, projectId?: string): Promise<boolean>;
|
|
3
|
+
export declare function recheckAuthority(ctx: OperationContext, machine: boolean): Promise<void>;
|
|
2
4
|
export declare function runOperation(operation: Operation, args: IssueArgs | LabelsArgs | FormArgs, ctx: OperationContext): Promise<OperationResult>;
|
package/dist/src/service.js
CHANGED
|
@@ -26,6 +26,23 @@ async function confirm(view, ctx) {
|
|
|
26
26
|
ctx.signal.removeEventListener('abort', listener);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
export async function authorize(view, ctx, repo, permissionOperation = view.operation, projectId) {
|
|
30
|
+
requireActive(ctx);
|
|
31
|
+
if (ctx.permissions?.allows(permissionOperation, repo, projectId)) {
|
|
32
|
+
if (!await ctx.permissions.isCurrent())
|
|
33
|
+
fail('PERMISSION_CHANGED', '', 'Permission file changed.');
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
if (!ctx.interactive || !await confirm(view, ctx))
|
|
37
|
+
fail('APPROVAL_DENIED', '', 'Change was not approved.');
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
export async function recheckAuthority(ctx, machine) {
|
|
41
|
+
requireActive(ctx);
|
|
42
|
+
if (machine && (!ctx.permissions || !await ctx.permissions.isCurrent()))
|
|
43
|
+
fail('PERMISSION_CHANGED', '', 'Permission file changed.');
|
|
44
|
+
requireActive(ctx);
|
|
45
|
+
}
|
|
29
46
|
async function issue(args, ctx, withPreview = true) {
|
|
30
47
|
const input = await loadDraft(inputPath(args.draftPath, ctx)), bundle = await loadTemplate(templatePath(args, ctx)), checked = validateDraft(input, bundle);
|
|
31
48
|
if (!checked.issue)
|
|
@@ -43,9 +60,10 @@ export async function runOperation(operation, args, ctx) {
|
|
|
43
60
|
validateArguments(operation, args);
|
|
44
61
|
requireActive(ctx);
|
|
45
62
|
const write = operation === 'gh_issue_submit' || operation === 'gh_labels_apply';
|
|
46
|
-
if (write && !ctx.interactive)
|
|
63
|
+
if (write && !ctx.interactive && !ctx.permissions)
|
|
47
64
|
return rejected('APPROVAL_UI_REQUIRED', 'External changes require a local human confirmation screen.');
|
|
48
|
-
|
|
65
|
+
let machineAuthority = false;
|
|
66
|
+
const options = { signal: ctx.signal, beforeStart: () => active(ctx), beforeStartAsync: async () => !machineAuthority || !!ctx.permissions && await ctx.permissions.isCurrent() };
|
|
49
67
|
if (operation === 'gh_issue_form') {
|
|
50
68
|
const bundle = await loadTemplate(templatePath(args, ctx));
|
|
51
69
|
requireActive(ctx);
|
|
@@ -65,13 +83,15 @@ export async function runOperation(operation, args, ctx) {
|
|
|
65
83
|
return safeResult({ status: 'preview', data: displayed });
|
|
66
84
|
if (sensitive)
|
|
67
85
|
return rejected('SENSITIVE', 'Remove secret candidates before applying.');
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
const change = validateLabelChange(input), permissionOperation = change.operation === 'label-delete' ? 'forbidden-delete' : change.operation === 'issue-labels' ? 'gh_issue_labels' : change.operation === 'label-create' ? 'gh_label_create' : 'gh_label_edit';
|
|
87
|
+
const machine = await authorize({ operation, text: JSON.stringify(maskDecodedSecrets(displayed).value, null, 2), digest: preview.digest }, ctx, change.repo, permissionOperation);
|
|
88
|
+
machineAuthority = machine;
|
|
70
89
|
requireActive(ctx);
|
|
71
90
|
const latest = await loadDraft(path), next = await previewLabelChange(latest, options);
|
|
72
91
|
requireActive(ctx);
|
|
73
92
|
if (next.digest !== preview.digest || next.sensitive || maskDecodedSecrets(parseJson(latest.bytes)).sensitive)
|
|
74
93
|
return rejected('APPROVAL_MISMATCH', 'Input or GitHub state changed; preview and approve again.');
|
|
94
|
+
await recheckAuthority(ctx, machine);
|
|
75
95
|
const result = await applyLabelChange(latest, preview.digest, options);
|
|
76
96
|
return safeResult({ ...result });
|
|
77
97
|
}
|
|
@@ -86,13 +106,14 @@ export async function runOperation(operation, args, ctx) {
|
|
|
86
106
|
return safeResult({ status: 'preview', data: preview });
|
|
87
107
|
if (preview.sensitive)
|
|
88
108
|
return rejected('SENSITIVE', 'Remove secret candidates before submitting.');
|
|
89
|
-
|
|
90
|
-
|
|
109
|
+
const machine = await authorize({ operation, text: JSON.stringify(maskDecodedSecrets(preview).value, null, 2), digest: preview.digest }, ctx, preview.repo);
|
|
110
|
+
machineAuthority = machine;
|
|
91
111
|
requireActive(ctx);
|
|
92
112
|
const latest = await issue(args, ctx);
|
|
93
113
|
requireActive(ctx);
|
|
94
114
|
if (!latest.checked.issue || latest.preview.digest !== preview.digest || latest.preview.sensitive)
|
|
95
115
|
return rejected('APPROVAL_MISMATCH', 'Input or GitHub state changed; preview and approve again.');
|
|
116
|
+
await recheckAuthority(ctx, machine);
|
|
96
117
|
const result = await submitIssue(latest.checked.issue, preview.digest, options);
|
|
97
118
|
return safeResult(result.status === 'created' ? { status: 'created', data: { url: result.url } } : { ...result });
|
|
98
119
|
}
|
package/extensions/tools.ts
CHANGED
|
@@ -1,35 +1,51 @@
|
|
|
1
1
|
import {Type} from '@earendil-works/pi-ai';
|
|
2
2
|
import type {ExtensionAPI,ExtensionToolContext} from '@earendil-works/pi-coding-agent';
|
|
3
3
|
import {runOperation} from '../dist/src/service.js';
|
|
4
|
-
import {safeResult} from '../dist/src/result.js';
|
|
5
|
-
import
|
|
4
|
+
import {safeResult,errorResult} from '../dist/src/result.js';
|
|
5
|
+
import {loadPermissions,permissionPath} from '../dist/src/permissions.js';
|
|
6
|
+
import {runGithubOperation} from '../dist/src/operations.js';
|
|
7
|
+
import {githubReads,githubWrites} from '../dist/src/operation-input.js';
|
|
8
|
+
import type {GithubOperation,GithubArgs} from '../src/operation-input.js';
|
|
9
|
+
import type {Operation,IssueArgs,LabelsArgs,FormArgs,OperationResult} from '../src/service-types.js';
|
|
6
10
|
import {showApproval} from './approval-ui.ts';
|
|
7
11
|
import {RuntimeScope} from './runtime-scope.ts';
|
|
8
12
|
const template={template:Type.Optional(Type.Union([Type.Literal('parent'),Type.Literal('task')])),templatePath:Type.Optional(Type.String({minLength:1}))};
|
|
9
13
|
const issue=Type.Object({draftPath:Type.String({minLength:1}),...template},{additionalProperties:false});
|
|
10
14
|
const labels=Type.Object({changePath:Type.String({minLength:1})},{additionalProperties:false});
|
|
11
15
|
const form=Type.Object(template,{additionalProperties:false});
|
|
12
|
-
const
|
|
16
|
+
const output=Type.Object({status:Type.String(),data:Type.Optional(Type.Unknown()),problems:Type.Optional(Type.Array(Type.Object({code:Type.String(),path:Type.String(),message:Type.String()}))),message:Type.Optional(Type.String()),candidateUrl:Type.Optional(Type.String())},{additionalProperties:false});
|
|
17
|
+
const operations:Array<Operation|GithubOperation>=['gh_issue_validate','gh_issue_preview','gh_issue_submit','gh_labels_validate','gh_labels_preview','gh_labels_apply','gh_issue_form',...githubReads,...githubWrites];
|
|
18
|
+
const repo=Type.String({minLength:3});
|
|
19
|
+
const issueRead=Type.Object({repo,issue:Type.Integer({minimum:1})},{additionalProperties:false});
|
|
20
|
+
const issueList=Type.Object({repo,state:Type.Optional(Type.Union([Type.Literal('open'),Type.Literal('closed'),Type.Literal('all')]))},{additionalProperties:false});
|
|
21
|
+
const projectRead=Type.Object({projectId:Type.String({pattern:'^PVT_[A-Za-z0-9_-]+$'})},{additionalProperties:false});
|
|
13
22
|
function identity(ctx:ExtensionToolContext){return ctx.sessionManager.getSessionId()+':'+ctx.sessionManager.getLeafId();}
|
|
23
|
+
function render(result:OperationResult){
|
|
24
|
+
const safe=safeResult(result),full=JSON.stringify(safe),limit=32768;
|
|
25
|
+
return {content:[{type:'text' as const,text:full.length<=limit?full:full.slice(0,limit)+'\n[truncated; complete masked data in structuredContent]'}],details:safe,structuredContent:safe as never,isError:['rejected','not-started','unknown'].includes(safe.status)};
|
|
26
|
+
}
|
|
14
27
|
export function registerTools(pi:ExtensionAPI,scope:RuntimeScope,packageRoot:string){
|
|
28
|
+
pi.registerTool({name:'gh_capabilities',label:'GitHub capabilities',description:'Return pi-gh tool contract version and supported operations. No remote access.',parameters:Type.Object({},{additionalProperties:false}),outputSchema:output,annotations:{readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},async execute(){return render({status:'read',data:{contractVersion:1,operations:[...operations,'gh_capabilities']}});}});
|
|
15
29
|
for(const name of operations){
|
|
16
|
-
const write=name==='gh_issue_submit'||name==='gh_labels_apply';
|
|
30
|
+
const write=name==='gh_issue_submit'||name==='gh_labels_apply'||(githubWrites as readonly string[]).includes(name);
|
|
17
31
|
pi.registerTool({
|
|
18
|
-
name,label:name,description:write?'Preview and
|
|
19
|
-
parameters:name.startsWith('gh_labels_')?labels:name==='gh_issue_form'?form:issue,
|
|
20
|
-
exposure:
|
|
32
|
+
name,label:name,description:write?'Preview and apply one GitHub change after local human approval or an exact owner-configured permission grant. Never auto-retries unknown outcomes.':'Validate or preview a GitHub workflow. Preview may read remote metadata; never changes GitHub.',
|
|
33
|
+
parameters:(githubWrites as readonly string[]).includes(name)?labels:(githubReads as readonly string[]).includes(name)?(name==='gh_issue_list'?issueList:name.startsWith('gh_project_')?projectRead:issueRead):name.startsWith('gh_labels_')?labels:name==='gh_issue_form'?form:issue,outputSchema:output,
|
|
34
|
+
exposure:'direct',executionMode:write?'sequential':'parallel',
|
|
21
35
|
annotations:{readOnlyHint:!write,destructiveHint:write,idempotentHint:!write,openWorldHint:name!=='gh_issue_validate'&&name!=='gh_labels_validate'&&name!=='gh_issue_form'},
|
|
22
36
|
async execute(_id,args,signal,_update,ctx){
|
|
23
37
|
const captured=identity(ctx),lease=scope.acquire(signal),isCurrent=()=>scope.isCurrent(lease.stamp)&&identity(ctx)===captured;
|
|
24
|
-
const work=()=>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return
|
|
32
|
-
}
|
|
38
|
+
const work=async()=>{
|
|
39
|
+
const child=!!process.env.PI_SUBAGENT_CHILD,headless=ctx.mode!=='tui'||!ctx.hasUI;
|
|
40
|
+
const permissions=write?await loadPermissions(permissionPath(),{child,headless}):undefined;
|
|
41
|
+
const context={
|
|
42
|
+
cwd:ctx.cwd,packageRoot,interactive:!headless&&!child,permissions,signal:lease.signal,stamp:lease.stamp,isCurrent,
|
|
43
|
+
confirm:(view:import('../src/service-types.js').ApprovalView)=>showApproval(view,{signal:lease.signal,isCurrent},ctx.ui)
|
|
44
|
+
};
|
|
45
|
+
return (githubReads as readonly string[]).includes(name)||(githubWrites as readonly string[]).includes(name)?runGithubOperation(name as GithubOperation,args as GithubArgs,context):runOperation(name as Operation,args as IssueArgs|LabelsArgs|FormArgs,context);
|
|
46
|
+
};
|
|
47
|
+
try{return render(write?await scope.runExclusive(work):await work());}
|
|
48
|
+
catch(error){return render(errorResult(error));}
|
|
33
49
|
finally{lease.dispose();}
|
|
34
50
|
}
|
|
35
51
|
});
|