@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export type Profile = {
|
|
2
|
+
model: string;
|
|
3
|
+
thinking: string;
|
|
4
|
+
tier: 'basic' | 'upper';
|
|
5
|
+
};
|
|
6
|
+
export type AgentAssignment = {
|
|
7
|
+
model: string;
|
|
8
|
+
thinking: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
upperTierApproval?: string;
|
|
11
|
+
};
|
|
12
|
+
export type Criterion = {
|
|
13
|
+
id: string;
|
|
14
|
+
requirement: string;
|
|
15
|
+
verification: string;
|
|
16
|
+
expectedResult: string;
|
|
17
|
+
};
|
|
18
|
+
export type FieldDefinition = {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
type: 'string' | 'text' | 'string-list' | 'criteria';
|
|
22
|
+
required: boolean;
|
|
23
|
+
description?: string;
|
|
24
|
+
options?: string[];
|
|
25
|
+
minLength?: number;
|
|
26
|
+
maxLength?: number;
|
|
27
|
+
minItems?: number;
|
|
28
|
+
maxItems?: number;
|
|
29
|
+
};
|
|
30
|
+
export type TemplateDefinition = {
|
|
31
|
+
version: 1;
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
description: string;
|
|
35
|
+
kind: 'parent' | 'task';
|
|
36
|
+
fields: FieldDefinition[];
|
|
37
|
+
agents: string[];
|
|
38
|
+
modelPolicy: string;
|
|
39
|
+
};
|
|
40
|
+
export type ModelPolicy = {
|
|
41
|
+
version: 1;
|
|
42
|
+
agents: Record<string, Profile[]>;
|
|
43
|
+
};
|
|
44
|
+
export type Draft = {
|
|
45
|
+
version: 1;
|
|
46
|
+
template: string;
|
|
47
|
+
repo: string;
|
|
48
|
+
title: string;
|
|
49
|
+
labels?: string[];
|
|
50
|
+
parentIssue?: number;
|
|
51
|
+
fields: Record<string, string | string[] | Criterion[]>;
|
|
52
|
+
agents: Record<string, AgentAssignment>;
|
|
53
|
+
};
|
|
54
|
+
export type CapturedInput = {
|
|
55
|
+
path: string;
|
|
56
|
+
bytes: Buffer;
|
|
57
|
+
};
|
|
58
|
+
export type TemplateBundle = {
|
|
59
|
+
template: TemplateDefinition;
|
|
60
|
+
policy: ModelPolicy;
|
|
61
|
+
inputs: CapturedInput[];
|
|
62
|
+
};
|
|
63
|
+
export type ValidationProblem = {
|
|
64
|
+
code: string;
|
|
65
|
+
path: string;
|
|
66
|
+
message: string;
|
|
67
|
+
};
|
|
68
|
+
export type ValidatedIssue = {
|
|
69
|
+
draft: Draft;
|
|
70
|
+
bundle: TemplateBundle;
|
|
71
|
+
draftInput: CapturedInput;
|
|
72
|
+
};
|
|
73
|
+
export type Preview = {
|
|
74
|
+
repo: string;
|
|
75
|
+
title: string;
|
|
76
|
+
body: string;
|
|
77
|
+
agents: Draft['agents'];
|
|
78
|
+
labels?: string[];
|
|
79
|
+
digest: string;
|
|
80
|
+
sensitive: boolean;
|
|
81
|
+
};
|
|
82
|
+
export type SubmitOutcome = {
|
|
83
|
+
status: 'created';
|
|
84
|
+
url: string;
|
|
85
|
+
} | {
|
|
86
|
+
status: 'rejected';
|
|
87
|
+
problems: ValidationProblem[];
|
|
88
|
+
} | {
|
|
89
|
+
status: 'not-started';
|
|
90
|
+
message: string;
|
|
91
|
+
} | {
|
|
92
|
+
status: 'unknown';
|
|
93
|
+
message: string;
|
|
94
|
+
candidateUrl?: string;
|
|
95
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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,4 @@
|
|
|
1
|
+
import type { Operation, IssueArgs, FormArgs, OperationContext } from './service-types.js';
|
|
2
|
+
export declare function validateArguments(operation: Operation, args: unknown): void;
|
|
3
|
+
export declare function templatePath(args: IssueArgs | FormArgs, ctx: OperationContext): string;
|
|
4
|
+
export declare function inputPath(path: string, ctx: OperationContext): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { resolve, join } from 'node:path';
|
|
2
|
+
import { fail, record } from './core/data.js';
|
|
3
|
+
export function validateArguments(operation, args) {
|
|
4
|
+
if (!record(args))
|
|
5
|
+
fail('ARGUMENT', '', 'Arguments must be an object.');
|
|
6
|
+
const label = operation.startsWith('gh_labels_'), form = operation === 'gh_issue_form';
|
|
7
|
+
const allowed = label ? ['changePath'] : form ? ['template', 'templatePath'] : ['draftPath', 'template', 'templatePath'];
|
|
8
|
+
if (Object.keys(args).some(key => !allowed.includes(key)))
|
|
9
|
+
fail('ARGUMENT', '', 'Unknown argument.');
|
|
10
|
+
for (const key of [label ? 'changePath' : form ? '' : 'draftPath', 'templatePath'])
|
|
11
|
+
if (key && args[key] !== undefined && (typeof args[key] !== 'string' || !args[key].trim()))
|
|
12
|
+
fail('ARGUMENT', key, 'A non-empty path is required.');
|
|
13
|
+
if (!form && typeof args[label ? 'changePath' : 'draftPath'] !== 'string')
|
|
14
|
+
fail('ARGUMENT', '', 'Input path is required.');
|
|
15
|
+
if (args.template !== undefined && !['parent', 'task'].includes(args.template))
|
|
16
|
+
fail('ARGUMENT', 'template', 'Unknown standard template.');
|
|
17
|
+
if (args.template !== undefined && args.templatePath !== undefined)
|
|
18
|
+
fail('ARGUMENT', 'template', 'Choose standard or custom template, not both.');
|
|
19
|
+
}
|
|
20
|
+
export function templatePath(args, ctx) { return args.templatePath ? resolve(ctx.cwd, args.templatePath) : join(ctx.packageRoot, 'templates', (args.template ?? 'task') + '.yml'); }
|
|
21
|
+
export function inputPath(path, ctx) { return resolve(ctx.cwd, path); }
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { OperationResult } from './service-types.js';
|
|
2
|
+
export declare function safeResult(result: OperationResult): OperationResult;
|
|
3
|
+
export declare function rejected(code: string, message: string): OperationResult;
|
|
4
|
+
export declare function errorResult(error: unknown): OperationResult;
|