@sentry/junior-github 0.93.0 → 0.95.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/SETUP.md +26 -26
- package/dist/index.js +333 -86
- package/dist/tool-support/attribution.d.ts +5 -0
- package/package.json +2 -2
- package/skills/github-code/SKILL.md +8 -4
- package/skills/github-code/references/api-surface.md +20 -20
- package/skills/github-code/references/troubleshooting-workarounds.md +2 -2
- package/skills/github-issues/SKILL.md +1 -1
- package/skills/github-issues/references/api-surface.md +11 -11
- package/skills/github-issues/references/troubleshooting-workarounds.md +4 -4
package/SETUP.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GitHub plugin setup
|
|
2
2
|
|
|
3
|
-
This plugin exposes two skills — `github-code` (clone, source-code investigation, pull requests) and `github-issues` (issue workflows).
|
|
3
|
+
This plugin exposes two skills — `github-code` (clone, source-code investigation, pull requests) and `github-issues` (issue workflows). Junior uses GitHub App installation tokens for reads, workflow dispatches, allowlisted issue and pull request writes, and Git branch pushes. Human OAuth is reserved for explicitly personal operations such as pull request reviews.
|
|
4
4
|
|
|
5
5
|
## 1) Create/install GitHub App
|
|
6
6
|
|
|
@@ -83,7 +83,7 @@ githubPlugin({
|
|
|
83
83
|
|
|
84
84
|
Junior records these permissions as plugin capabilities. The configured values are the maximum GitHub App envelope Junior may need for writes. Installation-read token requests remain read-only by requesting read-capable configured permissions at `read` level and omitting GitHub permission fields that have no `read` value. GitHub remains the source of truth for whether a permission name or level exists.
|
|
85
85
|
|
|
86
|
-
GitHub App user-to-server tokens do not use OAuth scopes as their permission model. Their effective access is limited by the GitHub App's installed permissions, the app installation's repository access, and the requesting user's own GitHub access. GitHub returns an empty `scope` value for
|
|
86
|
+
GitHub App user-to-server tokens do not use OAuth scopes as their permission model. Their effective access is limited by the GitHub App's installed permissions, the app installation's repository access, and the requesting user's own GitHub access. Repository-scoped installation tokens instead use the App permission envelope and installation repository access without borrowing the requesting user's authority. GitHub returns an empty `scope` value for user-to-server tokens, so Junior cannot verify granted scopes from the token response.
|
|
87
87
|
|
|
88
88
|
If you pass `additionalUserScopes`, Junior includes those values in the authorization URL and records the requested scope string as a local reauthorization contract. This does not expand or prove GitHub API permissions — configure GitHub App permissions with `appPermissions` and in the GitHub App settings for provider-enforced access:
|
|
89
89
|
|
|
@@ -93,15 +93,16 @@ githubPlugin({
|
|
|
93
93
|
});
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
Use `additionalUserScopes` only when
|
|
96
|
+
Use `additionalUserScopes` only when a human-identity integration flow requires specific GitHub OAuth scope parameters in the authorization URL. Do not rely on it to authorize Junior-owned repository or workflow writes — those use repository-scoped installation tokens and the configured GitHub App permissions.
|
|
97
97
|
|
|
98
98
|
## 3) Runtime behavior
|
|
99
99
|
|
|
100
100
|
- When either GitHub skill is active, authenticated `gh` and `git` commands cause the runtime to inject GitHub credentials automatically for the current turn.
|
|
101
|
-
- The plugin classifies GitHub traffic from the forwarded HTTP request.
|
|
102
|
-
- `user-read` and `user-write` require the actor, or an explicitly delegated user subject
|
|
103
|
-
-
|
|
104
|
-
-
|
|
101
|
+
- The plugin classifies GitHub traffic from the forwarded HTTP request. Reads use `installation-read`, while `GET /user` uses `user-read`. Workflow dispatches use the repository-scoped `installation-actions-write` grant. Allowlisted issue and pull request mutations use their own repository-scoped installation grants. Git smart-HTTP pushes use `installation-pr-branch-write`. Unknown REST writes and GraphQL mutations are denied.
|
|
102
|
+
- `user-read` and the remaining explicitly human `user-write` operations require the actor, or an explicitly delegated user subject, to authorize the GitHub App through the private OAuth flow. Junior-owned issue, pull request, and branch operations do not fall back to user OAuth.
|
|
103
|
+
- Headless resource-event turns use the `resource-event` system actor and may receive the same repository-scoped installation grants. This lets Junior respond to subscribed pull request events by committing and pushing fixes without inheriting a subscriber's OAuth credential.
|
|
104
|
+
- Git commits use Junior as author and committer. Resolvable human run actors are credited once with `Co-Authored-By` trailers.
|
|
105
|
+
- Issued credentials are reused only within the current turn, credential leases are cached by plugin grant and repository lease scope, and upstream 403 permission denials clear the cached lease before the next retry.
|
|
105
106
|
- Sandbox does not receive raw tokens via env; host applies Authorization header transforms for GitHub API calls.
|
|
106
107
|
|
|
107
108
|
## 4) CLI usage
|
|
@@ -121,33 +122,32 @@ git -C repo fetch --depth=50 origin
|
|
|
121
122
|
git -C repo fetch --unshallow
|
|
122
123
|
```
|
|
123
124
|
|
|
124
|
-
Load the relevant GitHub skill for command guidance and repo context
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
gh
|
|
128
|
-
```
|
|
125
|
+
Load the relevant GitHub skill for command guidance and repo context. Create
|
|
126
|
+
new resources through the typed tools, for example
|
|
127
|
+
`github_createIssue({ repo: "owner/repo", title: "Example issue", body: "..." })`.
|
|
128
|
+
Use `gh` for the allowlisted lifecycle operations described by the skill.
|
|
129
129
|
|
|
130
130
|
`gh` supports either direct `GITHUB_TOKEN` (for local debugging) or sandbox-level header injection.
|
|
131
|
-
The plugin uses
|
|
131
|
+
The plugin uses installation credentials for read-only GitHub traffic, workflow dispatches, allowlisted issue and pull request mutations, and Git smart-HTTP pushes. GitHub App permissions still need to cover the operation: actions for workflow dispatches, issues for issue edits/comments/labels, contents for pushes, pull requests for PR mutations, and workflows for workflow-file pushes.
|
|
132
132
|
|
|
133
133
|
Committing and pushing code uses more than one GitHub surface:
|
|
134
134
|
|
|
135
|
-
- Creating the local Git commit does not call GitHub. Junior sets the
|
|
136
|
-
- Pushing a branch with Git smart HTTP (`git push`) uses the `
|
|
137
|
-
-
|
|
135
|
+
- Creating the local Git commit does not call GitHub. Junior sets the GitHub App bot as author and committer and credits resolvable human actors with `Co-Authored-By` trailers.
|
|
136
|
+
- Pushing a branch with Git smart HTTP (`git push`) uses the repository-scoped `installation-pr-branch-write` grant and requires `Contents: write`. If `Workflows: write` is configured, it is included so workflow-file changes can be pushed.
|
|
137
|
+
- The smart-HTTP classifier does not distinguish Junior-managed branches or independently detect force updates or ref deletion. Use GitHub branch protection and limit the App installation to repositories where Junior may push.
|
|
138
|
+
- REST Git database and ref writes are denied by the current write allowlist. Use Git smart HTTP (`git push`) for branch updates instead.
|
|
138
139
|
- Opening the PR after the branch exists is separate: `github_createPullRequest` needs pull-request write permission, but it should not create or push commits itself.
|
|
139
140
|
|
|
140
|
-
Fork creation is not part of the default PR path
|
|
141
|
+
Fork creation is not part of the default PR path and is denied by the current write allowlist. Do not grant `Administration: write` for routine PR creation; push a branch explicitly and create the PR with `github_createPullRequest` instead.
|
|
141
142
|
|
|
142
143
|
GitHub App permission scoping is a safety rail, not a hard sandbox boundary. It helps prevent accidental write scope and wrong-repo mutations, and the host runtime still decides when to mint credentials. Credential injection is provider-domain scoped for sandbox traffic to `api.github.com` and `github.com` during turns with a signed credential context. Keep repo context explicit, and let the plugin choose the required grant for the outbound request.
|
|
143
144
|
|
|
144
|
-
Be careful with mixed-surface PR commands
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
- `gh pr close --delete-branch` needs `github.contents.write`.
|
|
145
|
+
Be careful with mixed-surface PR commands. Use the allowlisted REST endpoints
|
|
146
|
+
rather than GraphQL-backed `gh pr` mutation commands. PR-native title, body,
|
|
147
|
+
base, state, ready-for-review, and requested-reviewer changes use the pull
|
|
148
|
+
request grant. Comments, labels, and assignees use GitHub's issue endpoints and
|
|
149
|
+
the issue grant. Merge, branch deletion, REST ref mutation, and administration
|
|
150
|
+
remain unsupported API operations.
|
|
151
151
|
|
|
152
152
|
For PR creation in automation, push explicitly and pass that branch as `head`:
|
|
153
153
|
|
|
@@ -192,10 +192,10 @@ jr-rpc config set github.repo getsentry/junior
|
|
|
192
192
|
2. Confirm the GitHub App is installed on your test repo with the permissions above.
|
|
193
193
|
3. Deploy `main` to prod.
|
|
194
194
|
4. Exercise `github-issues` to create an issue in a safe test repo.
|
|
195
|
-
5. Verify the issue is authored by the
|
|
195
|
+
5. Verify the issue is authored by the GitHub App bot and includes `Requested by` attribution.
|
|
196
196
|
6. Exercise `github-issues` to update title/body, add/remove labels, and add a comment.
|
|
197
197
|
7. Push a test branch and exercise `github-code` to create a draft PR using explicit repo targeting and `--head`.
|
|
198
|
-
8. Verify
|
|
198
|
+
8. Verify allowlisted mutations are attributed to the GitHub App bot, while a pull request review still requires delegated human authorization.
|
|
199
199
|
9. Verify GitHub API calls succeed while this skill is active without writing tokens into sandbox env/files.
|
|
200
200
|
10. Verify raw token values are never printed in output or logs.
|
|
201
201
|
11. Check logs for:
|
package/dist/index.js
CHANGED
|
@@ -150,6 +150,43 @@ function appendGitHubFooter(body, conversationId) {
|
|
|
150
150
|
${footer}` : footer;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// src/tool-support/attribution.ts
|
|
154
|
+
var GITHUB_REQUEST_ATTRIBUTION_START = "<!-- junior-request-attribution:start -->";
|
|
155
|
+
var GITHUB_REQUEST_ATTRIBUTION_END = "<!-- junior-request-attribution:end -->";
|
|
156
|
+
function cleanDisplayValue(value) {
|
|
157
|
+
const cleaned = value?.replace(/[\r\n<>]/g, " ").trim();
|
|
158
|
+
return cleaned || void 0;
|
|
159
|
+
}
|
|
160
|
+
function actorLabel(actor) {
|
|
161
|
+
if (!actor) {
|
|
162
|
+
return void 0;
|
|
163
|
+
}
|
|
164
|
+
if (actor.platform === "system") {
|
|
165
|
+
return `Junior system actor \`${actor.name}\``;
|
|
166
|
+
}
|
|
167
|
+
const display = cleanDisplayValue(actor.fullName) ?? cleanDisplayValue(actor.userName) ?? cleanDisplayValue(actor.userId);
|
|
168
|
+
return display ? `**${display.replaceAll("*", "\\*")}**` : void 0;
|
|
169
|
+
}
|
|
170
|
+
function appendGitHubRequesterAttribution(body, actor) {
|
|
171
|
+
const label = actorLabel(actor);
|
|
172
|
+
const attribution = label ? `${GITHUB_REQUEST_ATTRIBUTION_START}
|
|
173
|
+
Requested by ${label} via Junior.
|
|
174
|
+
${GITHUB_REQUEST_ATTRIBUTION_END}` : void 0;
|
|
175
|
+
const normalizedBody = body.trimEnd();
|
|
176
|
+
const existing = new RegExp(
|
|
177
|
+
`${GITHUB_REQUEST_ATTRIBUTION_START}[\\s\\S]*?${GITHUB_REQUEST_ATTRIBUTION_END}`
|
|
178
|
+
);
|
|
179
|
+
if (existing.test(normalizedBody)) {
|
|
180
|
+
return attribution ? normalizedBody.replace(existing, attribution) : normalizedBody.replace(existing, "").trimEnd();
|
|
181
|
+
}
|
|
182
|
+
if (!attribution) {
|
|
183
|
+
return normalizedBody;
|
|
184
|
+
}
|
|
185
|
+
return normalizedBody ? `${normalizedBody}
|
|
186
|
+
|
|
187
|
+
${attribution}` : attribution;
|
|
188
|
+
}
|
|
189
|
+
|
|
153
190
|
// src/tools/create-issue.ts
|
|
154
191
|
var GITHUB_ISSUE_CREATE_IDEMPOTENCY_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
155
192
|
var GITHUB_ISSUE_CREATE_LOCK_TTL_MS = 6e4;
|
|
@@ -297,14 +334,17 @@ function isDefinitiveGitHubIssueCreateRejection(error) {
|
|
|
297
334
|
}
|
|
298
335
|
return [400, 401, 404, 410, 422].includes(error.status);
|
|
299
336
|
}
|
|
300
|
-
function createGitHubIssueRequest(conversationId, input) {
|
|
337
|
+
function createGitHubIssueRequest(conversationId, input, actor) {
|
|
301
338
|
const repo = parseRepo(input.repo);
|
|
302
339
|
const labels = input.labels?.map(
|
|
303
340
|
(label) => nonEmptyString2(label, "labels entry")
|
|
304
341
|
);
|
|
305
342
|
const payload = {
|
|
306
343
|
title: nonEmptyString2(input.title, "title"),
|
|
307
|
-
body: appendGitHubFooter(
|
|
344
|
+
body: appendGitHubFooter(
|
|
345
|
+
appendGitHubRequesterAttribution(input.body ?? "", actor),
|
|
346
|
+
conversationId
|
|
347
|
+
),
|
|
308
348
|
...labels?.length ? { labels } : {}
|
|
309
349
|
};
|
|
310
350
|
return new Request(
|
|
@@ -378,7 +418,11 @@ function createGitHubIssueTool(ctx) {
|
|
|
378
418
|
"GitHub issue creation for this tool call has an uncertain pending result; refusing to create a duplicate issue."
|
|
379
419
|
);
|
|
380
420
|
}
|
|
381
|
-
const request = createGitHubIssueRequest(
|
|
421
|
+
const request = createGitHubIssueRequest(
|
|
422
|
+
conversationId,
|
|
423
|
+
parsedInput,
|
|
424
|
+
ctx.actor
|
|
425
|
+
);
|
|
382
426
|
const pendingState = {
|
|
383
427
|
status: "pending",
|
|
384
428
|
createdAtMs: Date.now()
|
|
@@ -601,13 +645,16 @@ function isDefinitiveGitHubPullRequestCreateRejection(error) {
|
|
|
601
645
|
}
|
|
602
646
|
return [400, 401, 404, 410, 422].includes(error.status);
|
|
603
647
|
}
|
|
604
|
-
function createGitHubPullRequestRequest(conversationId, input) {
|
|
648
|
+
function createGitHubPullRequestRequest(conversationId, input, actor) {
|
|
605
649
|
const repo = parseRepo2(input.repo);
|
|
606
650
|
const payload = {
|
|
607
651
|
title: nonEmptyString3(input.title, "title"),
|
|
608
652
|
head: nonEmptyString3(input.head, "head"),
|
|
609
653
|
base: nonEmptyString3(input.base, "base"),
|
|
610
|
-
body: appendGitHubFooter(
|
|
654
|
+
body: appendGitHubFooter(
|
|
655
|
+
appendGitHubRequesterAttribution(input.body ?? "", actor),
|
|
656
|
+
conversationId
|
|
657
|
+
),
|
|
611
658
|
...input.draft !== void 0 ? { draft: input.draft } : {}
|
|
612
659
|
};
|
|
613
660
|
return new Request(
|
|
@@ -740,7 +787,8 @@ function createGitHubPullRequestTool(ctx) {
|
|
|
740
787
|
}
|
|
741
788
|
const request = createGitHubPullRequestRequest(
|
|
742
789
|
conversationId,
|
|
743
|
-
parsedInput
|
|
790
|
+
parsedInput,
|
|
791
|
+
ctx.actor
|
|
744
792
|
);
|
|
745
793
|
const pendingState = {
|
|
746
794
|
status: "pending",
|
|
@@ -801,25 +849,20 @@ var GITHUB_GRAPHQL_RESPONSE_BODY_LIMIT_BYTES = 64 * 1024;
|
|
|
801
849
|
var HTTP_READ_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
802
850
|
var USER_TOKEN_GRANTS = /* @__PURE__ */ new Set(["user-read", "user-write"]);
|
|
803
851
|
var CONTENTS_WRITE_REQUIREMENTS = [
|
|
804
|
-
"GitHub App Contents: write on the target repository"
|
|
805
|
-
"requesting GitHub user write access to the repository"
|
|
852
|
+
"GitHub App Contents: write on the target repository"
|
|
806
853
|
];
|
|
807
|
-
var
|
|
808
|
-
"GitHub App
|
|
809
|
-
"requesting GitHub user write access to the repository"
|
|
854
|
+
var ACTIONS_WRITE_REQUIREMENTS = [
|
|
855
|
+
"GitHub App Actions: write on the target repository"
|
|
810
856
|
];
|
|
811
857
|
var ISSUES_WRITE_REQUIREMENTS = [
|
|
812
|
-
"GitHub App Issues: write on the target repository"
|
|
813
|
-
"requesting GitHub user issue access to the repository"
|
|
858
|
+
"GitHub App Issues: write on the target repository"
|
|
814
859
|
];
|
|
815
860
|
var PULL_REQUESTS_WRITE_REQUIREMENTS = [
|
|
816
|
-
"GitHub App Pull requests: write on the target repository"
|
|
817
|
-
"requesting GitHub user write access to the repository"
|
|
861
|
+
"GitHub App Pull requests: write on the target repository"
|
|
818
862
|
];
|
|
819
|
-
var
|
|
820
|
-
|
|
821
|
-
"
|
|
822
|
-
"requesting GitHub user permission to fork the repository"
|
|
863
|
+
var PULL_REVIEW_WRITE_REQUIREMENTS = [
|
|
864
|
+
...PULL_REQUESTS_WRITE_REQUIREMENTS,
|
|
865
|
+
"requesting GitHub user permission to review the pull request"
|
|
823
866
|
];
|
|
824
867
|
var GitHubUserRefreshRejectedError = class extends Error {
|
|
825
868
|
constructor(message) {
|
|
@@ -920,14 +963,12 @@ function additionalActorCoauthorTrailers(args) {
|
|
|
920
963
|
if (!args.actors || args.actors.length === 0) {
|
|
921
964
|
return [];
|
|
922
965
|
}
|
|
923
|
-
const runActorKey = args.runActor ? actorIdentityKey(args.runActor) : void 0;
|
|
924
966
|
const seenEmails = /* @__PURE__ */ new Set([args.botEmail.toLowerCase()]);
|
|
925
|
-
|
|
926
|
-
seenEmails.add(args.authorEmail.toLowerCase());
|
|
927
|
-
}
|
|
967
|
+
const seenActors = /* @__PURE__ */ new Set();
|
|
928
968
|
const trailers = [];
|
|
929
969
|
for (const candidate of args.actors) {
|
|
930
|
-
|
|
970
|
+
const actorKey = actorIdentityKey(candidate);
|
|
971
|
+
if (seenActors.has(actorKey)) {
|
|
931
972
|
continue;
|
|
932
973
|
}
|
|
933
974
|
const name = actorName(candidate);
|
|
@@ -939,6 +980,7 @@ function additionalActorCoauthorTrailers(args) {
|
|
|
939
980
|
if (seenEmails.has(emailKey)) {
|
|
940
981
|
continue;
|
|
941
982
|
}
|
|
983
|
+
seenActors.add(actorKey);
|
|
942
984
|
seenEmails.add(emailKey);
|
|
943
985
|
trailers.push(`Co-Authored-By: ${name} <${email}>`);
|
|
944
986
|
}
|
|
@@ -954,23 +996,18 @@ if [ -z "$message_file" ]; then
|
|
|
954
996
|
fi
|
|
955
997
|
|
|
956
998
|
if [ -z "\${JUNIOR_GIT_AUTHOR_NAME:-}" ] || [ -z "\${JUNIOR_GIT_AUTHOR_EMAIL:-}" ]; then
|
|
957
|
-
echo "Junior GitHub plugin internal error:
|
|
999
|
+
echo "Junior GitHub plugin internal error: Junior author identity was not injected by the host runtime. Do not set Git author env vars manually; report this configuration error." >&2
|
|
958
1000
|
exit 1
|
|
959
1001
|
fi
|
|
960
1002
|
|
|
961
1003
|
if [ "\${GIT_AUTHOR_NAME:-}" != "$JUNIOR_GIT_AUTHOR_NAME" ] || [ "\${GIT_AUTHOR_EMAIL:-}" != "$JUNIOR_GIT_AUTHOR_EMAIL" ]; then
|
|
962
|
-
echo "Junior GitHub plugin internal error: Git author was not set to the
|
|
963
|
-
exit 1
|
|
964
|
-
fi
|
|
965
|
-
|
|
966
|
-
if [ -z "\${JUNIOR_GIT_COAUTHOR_NAME:-}" ] || [ -z "\${JUNIOR_GIT_COAUTHOR_EMAIL:-}" ]; then
|
|
967
|
-
echo "Junior GitHub plugin internal error: Junior coauthor identity was not injected by the host runtime. Do not set coauthor env vars manually; report this configuration error." >&2
|
|
1004
|
+
echo "Junior GitHub plugin internal error: Git author was not set to the Junior identity. Do not override Git author manually; report this configuration error." >&2
|
|
968
1005
|
exit 1
|
|
969
1006
|
fi
|
|
970
1007
|
|
|
971
1008
|
# Git and GitHub only interpret the final contiguous paragraph as the trailer
|
|
972
1009
|
# block, so all missing trailers are collected and appended as one block:
|
|
973
|
-
# actor trailers in order
|
|
1010
|
+
# human actor trailers in run order.
|
|
974
1011
|
desired_trailers=""
|
|
975
1012
|
add_trailer() {
|
|
976
1013
|
desired_trailers="$desired_trailers$1"$'\\n'
|
|
@@ -984,8 +1021,6 @@ if [ -n "\${JUNIOR_GIT_ACTOR_COAUTHOR_TRAILERS:-}" ]; then
|
|
|
984
1021
|
done <<< "$JUNIOR_GIT_ACTOR_COAUTHOR_TRAILERS"
|
|
985
1022
|
fi
|
|
986
1023
|
|
|
987
|
-
add_trailer "Co-Authored-By: $JUNIOR_GIT_COAUTHOR_NAME <$JUNIOR_GIT_COAUTHOR_EMAIL>"
|
|
988
|
-
|
|
989
1024
|
final_trailer_block=$(awk '
|
|
990
1025
|
{ lines[NR] = $0 }
|
|
991
1026
|
END {
|
|
@@ -1011,6 +1046,57 @@ final_trailer_block=$(awk '
|
|
|
1011
1046
|
}
|
|
1012
1047
|
' "$message_file")
|
|
1013
1048
|
|
|
1049
|
+
duplicate_desired_trailer=false
|
|
1050
|
+
while IFS= read -r desired_trailer; do
|
|
1051
|
+
if [ -n "$desired_trailer" ] && [ "$(printf '%s\\n' "$final_trailer_block" | grep -Fxc -- "$desired_trailer")" -gt 1 ]; then
|
|
1052
|
+
duplicate_desired_trailer=true
|
|
1053
|
+
break
|
|
1054
|
+
fi
|
|
1055
|
+
done <<< "$desired_trailers"
|
|
1056
|
+
|
|
1057
|
+
if [ "$duplicate_desired_trailer" = true ]; then
|
|
1058
|
+
tmp_file=$(mktemp)
|
|
1059
|
+
desired_file=$(mktemp)
|
|
1060
|
+
trap 'rm -f "$tmp_file" "$desired_file"' EXIT
|
|
1061
|
+
printf '%s' "$desired_trailers" > "$desired_file"
|
|
1062
|
+
awk -v desired_file="$desired_file" '
|
|
1063
|
+
BEGIN {
|
|
1064
|
+
while ((getline value < desired_file) > 0) {
|
|
1065
|
+
if (value != "") {
|
|
1066
|
+
wanted[value] = 1
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
close(desired_file)
|
|
1070
|
+
}
|
|
1071
|
+
{ lines[NR] = $0 }
|
|
1072
|
+
END {
|
|
1073
|
+
line = NR
|
|
1074
|
+
while (line > 0 && lines[line] == "") {
|
|
1075
|
+
line--
|
|
1076
|
+
}
|
|
1077
|
+
start = line
|
|
1078
|
+
while (start > 0 && lines[start] != "") {
|
|
1079
|
+
start--
|
|
1080
|
+
}
|
|
1081
|
+
valid = line > 0
|
|
1082
|
+
for (i = start + 1; valid && i <= line; i++) {
|
|
1083
|
+
if (lines[i] !~ /^[[:alnum:]-]+: .+/) {
|
|
1084
|
+
valid = 0
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
for (i = 1; i <= NR; i++) {
|
|
1088
|
+
if (valid && i > start && i <= line && wanted[lines[i]]) {
|
|
1089
|
+
if (seen[lines[i]]++) {
|
|
1090
|
+
continue
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
print lines[i]
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
' "$message_file" > "$tmp_file"
|
|
1097
|
+
cat "$tmp_file" > "$message_file"
|
|
1098
|
+
fi
|
|
1099
|
+
|
|
1014
1100
|
missing_trailers=""
|
|
1015
1101
|
collect_missing_trailer() {
|
|
1016
1102
|
if ! printf '%s\\n' "$final_trailer_block" | grep -Fqx -- "$1"; then
|
|
@@ -1032,18 +1118,7 @@ if [ -n "$(tail -c 1 "$message_file")" ]; then
|
|
|
1032
1118
|
printf '\\n' >> "$message_file"
|
|
1033
1119
|
fi
|
|
1034
1120
|
|
|
1035
|
-
|
|
1036
|
-
# actor trailers before it so the bot trailer stays last.
|
|
1037
|
-
last_line=$(tail -n 1 "$message_file")
|
|
1038
|
-
bot_trailer="Co-Authored-By: $JUNIOR_GIT_COAUTHOR_NAME <$JUNIOR_GIT_COAUTHOR_EMAIL>"
|
|
1039
|
-
if [ "$last_line" = "$bot_trailer" ]; then
|
|
1040
|
-
tmp_file=$(mktemp)
|
|
1041
|
-
trap 'rm -f "$tmp_file"' EXIT
|
|
1042
|
-
sed '$d' "$message_file" > "$tmp_file"
|
|
1043
|
-
printf '%s' "$missing_trailers" >> "$tmp_file"
|
|
1044
|
-
printf '%s\\n' "$bot_trailer" >> "$tmp_file"
|
|
1045
|
-
cat "$tmp_file" > "$message_file"
|
|
1046
|
-
elif [ -n "$final_trailer_block" ]; then
|
|
1121
|
+
if [ -n "$final_trailer_block" ]; then
|
|
1047
1122
|
printf '%s' "$missing_trailers" >> "$message_file"
|
|
1048
1123
|
else
|
|
1049
1124
|
printf '\\n%s' "$missing_trailers" >> "$message_file"
|
|
@@ -1298,6 +1373,41 @@ function readInstallationPermissions(installation) {
|
|
|
1298
1373
|
}
|
|
1299
1374
|
return readGrantPermissions(installation.permissions);
|
|
1300
1375
|
}
|
|
1376
|
+
function decodeGitHubPathSegment(value) {
|
|
1377
|
+
try {
|
|
1378
|
+
const decoded = decodeURIComponent(value).trim();
|
|
1379
|
+
return decoded && !decoded.includes("/") ? decoded : void 0;
|
|
1380
|
+
} catch {
|
|
1381
|
+
return void 0;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
function githubRepositoryFromUrl(upstreamUrl) {
|
|
1385
|
+
const segments = upstreamUrl.pathname.split("/").filter(Boolean);
|
|
1386
|
+
if (isGitHubApiUrl(upstreamUrl) && segments[0]?.toLowerCase() === "repos") {
|
|
1387
|
+
const owner2 = segments[1] ? decodeGitHubPathSegment(segments[1]) : void 0;
|
|
1388
|
+
const name2 = segments[2] ? decodeGitHubPathSegment(segments[2]) : void 0;
|
|
1389
|
+
return owner2 && name2 ? { owner: owner2, name: name2 } : void 0;
|
|
1390
|
+
}
|
|
1391
|
+
if (upstreamUrl.hostname.toLowerCase() !== "github.com") {
|
|
1392
|
+
return void 0;
|
|
1393
|
+
}
|
|
1394
|
+
const owner = segments[0] ? decodeGitHubPathSegment(segments[0]) : void 0;
|
|
1395
|
+
const rawName = segments[1]?.replace(/\.git$/i, "");
|
|
1396
|
+
const name = rawName ? decodeGitHubPathSegment(rawName) : void 0;
|
|
1397
|
+
return owner && name ? { owner, name } : void 0;
|
|
1398
|
+
}
|
|
1399
|
+
function githubRepositoryLeaseScope(repository) {
|
|
1400
|
+
return `repository:${repository.owner.toLowerCase()}/${repository.name.toLowerCase()}`;
|
|
1401
|
+
}
|
|
1402
|
+
function githubRepositoryFromLeaseScope(leaseScope) {
|
|
1403
|
+
const match = /^repository:([^/]+)\/([^/]+)$/.exec(leaseScope ?? "");
|
|
1404
|
+
if (!match?.[1] || !match[2]) {
|
|
1405
|
+
throw new GitHubPluginSetupError(
|
|
1406
|
+
"GitHub installation write grant is missing a repository lease scope."
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
return { owner: match[1], name: match[2] };
|
|
1410
|
+
}
|
|
1301
1411
|
async function resolveUserAccount(tokens) {
|
|
1302
1412
|
const account = await githubRequest("https://api.github.com", "/user", {
|
|
1303
1413
|
token: tokens.accessToken
|
|
@@ -1475,12 +1585,11 @@ async function issueInstallationCredential(options) {
|
|
|
1475
1585
|
throw new GitHubPluginSetupError(`Invalid ${options.installationIdEnv}`);
|
|
1476
1586
|
}
|
|
1477
1587
|
const appJwt = createAppJwt(appId, options.privateKeyEnv);
|
|
1478
|
-
|
|
1479
|
-
if (!
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
});
|
|
1588
|
+
const permissions = options.permissions ?? await options.loadPermissions?.({ appJwt, installationId });
|
|
1589
|
+
if (!permissions) {
|
|
1590
|
+
throw new GitHubPluginSetupError(
|
|
1591
|
+
"GitHub installation credential permissions are not configured."
|
|
1592
|
+
);
|
|
1484
1593
|
}
|
|
1485
1594
|
const accessTokenResponse = await githubRequest(
|
|
1486
1595
|
"https://api.github.com",
|
|
@@ -1488,7 +1597,10 @@ async function issueInstallationCredential(options) {
|
|
|
1488
1597
|
{
|
|
1489
1598
|
method: "POST",
|
|
1490
1599
|
token: appJwt,
|
|
1491
|
-
body: {
|
|
1600
|
+
body: {
|
|
1601
|
+
permissions,
|
|
1602
|
+
...options.repositories ? { repositories: options.repositories } : {}
|
|
1603
|
+
}
|
|
1492
1604
|
}
|
|
1493
1605
|
);
|
|
1494
1606
|
const parsedToken = parseInstallationTokenResponse(accessTokenResponse);
|
|
@@ -1678,18 +1790,42 @@ function githubApiWriteReason(method, upstreamUrl) {
|
|
|
1678
1790
|
if (!isGitHubApiUrl(upstreamUrl)) {
|
|
1679
1791
|
return void 0;
|
|
1680
1792
|
}
|
|
1793
|
+
if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/actions\/workflows\/[^/]+\/dispatches$/.test(
|
|
1794
|
+
pathname
|
|
1795
|
+
)) {
|
|
1796
|
+
return "github.actions-workflow-dispatch";
|
|
1797
|
+
}
|
|
1681
1798
|
if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/issues$/.test(pathname)) {
|
|
1682
1799
|
return "github.issue-create";
|
|
1683
1800
|
}
|
|
1684
1801
|
if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+\/comments$/.test(pathname)) {
|
|
1685
1802
|
return "github.issues-write";
|
|
1686
1803
|
}
|
|
1804
|
+
if (method === "PATCH" && /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+$/.test(pathname)) {
|
|
1805
|
+
return "github.issues-write";
|
|
1806
|
+
}
|
|
1807
|
+
if ((method === "POST" || method === "DELETE") && /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+\/(labels|assignees)(?:\/[^/]+)?$/.test(
|
|
1808
|
+
pathname
|
|
1809
|
+
)) {
|
|
1810
|
+
return "github.issues-write";
|
|
1811
|
+
}
|
|
1687
1812
|
if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/pulls$/.test(pathname)) {
|
|
1688
1813
|
return "github.pull-create";
|
|
1689
1814
|
}
|
|
1690
1815
|
if (method === "PATCH" && /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+$/.test(pathname)) {
|
|
1691
1816
|
return "github.pull-requests-write";
|
|
1692
1817
|
}
|
|
1818
|
+
if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/ready_for_review$/.test(pathname)) {
|
|
1819
|
+
return "github.pull-requests-write";
|
|
1820
|
+
}
|
|
1821
|
+
if ((method === "POST" || method === "DELETE") && /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/requested_reviewers$/.test(pathname)) {
|
|
1822
|
+
return "github.pull-requests-write";
|
|
1823
|
+
}
|
|
1824
|
+
if (/^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/reviews(?:\/[^/]+(?:\/(events|dismissals))?)?$/.test(
|
|
1825
|
+
pathname
|
|
1826
|
+
) && !HTTP_READ_METHODS.has(method)) {
|
|
1827
|
+
return "github.pull-review-write";
|
|
1828
|
+
}
|
|
1693
1829
|
if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/forks$/.test(pathname)) {
|
|
1694
1830
|
return "github.fork-create";
|
|
1695
1831
|
}
|
|
@@ -1779,32 +1915,73 @@ function assertGitHubWriteAllowed(input) {
|
|
|
1779
1915
|
}
|
|
1780
1916
|
}
|
|
1781
1917
|
function grantRequirements(reason) {
|
|
1918
|
+
if (reason === "github.actions-workflow-dispatch") {
|
|
1919
|
+
return ACTIONS_WRITE_REQUIREMENTS;
|
|
1920
|
+
}
|
|
1782
1921
|
if (reason === "github.git-write" || reason === "github.contents-write") {
|
|
1783
1922
|
return CONTENTS_WRITE_REQUIREMENTS;
|
|
1784
1923
|
}
|
|
1785
|
-
if (reason === "github.workflows-write") {
|
|
1786
|
-
return WORKFLOWS_WRITE_REQUIREMENTS;
|
|
1787
|
-
}
|
|
1788
1924
|
if (reason === "github.issue-create" || reason === "github.issues-write") {
|
|
1789
1925
|
return ISSUES_WRITE_REQUIREMENTS;
|
|
1790
1926
|
}
|
|
1927
|
+
if (reason === "github.pull-review-write") {
|
|
1928
|
+
return PULL_REVIEW_WRITE_REQUIREMENTS;
|
|
1929
|
+
}
|
|
1791
1930
|
if (reason === "github.pull-create" || reason === "github.pull-requests-write") {
|
|
1792
1931
|
return PULL_REQUESTS_WRITE_REQUIREMENTS;
|
|
1793
1932
|
}
|
|
1794
|
-
if (reason === "github.fork-create") {
|
|
1795
|
-
return FORK_CREATE_REQUIREMENTS;
|
|
1796
|
-
}
|
|
1797
1933
|
return void 0;
|
|
1798
1934
|
}
|
|
1799
|
-
function grantForAccess(access, reason, name) {
|
|
1935
|
+
function grantForAccess(access, reason, name, leaseScope) {
|
|
1800
1936
|
const requirements = grantRequirements(reason);
|
|
1801
1937
|
return {
|
|
1802
1938
|
name,
|
|
1803
1939
|
access,
|
|
1940
|
+
...leaseScope ? { leaseScope } : {},
|
|
1804
1941
|
reason,
|
|
1805
1942
|
...requirements ? { requirements } : {}
|
|
1806
1943
|
};
|
|
1807
1944
|
}
|
|
1945
|
+
function repositoryLeaseScope(upstreamUrl) {
|
|
1946
|
+
const repository = githubRepositoryFromUrl(upstreamUrl);
|
|
1947
|
+
if (!repository) {
|
|
1948
|
+
throw new EgressPolicyDenied(
|
|
1949
|
+
"GitHub write request does not identify a target repository."
|
|
1950
|
+
);
|
|
1951
|
+
}
|
|
1952
|
+
return githubRepositoryLeaseScope(repository);
|
|
1953
|
+
}
|
|
1954
|
+
function installationGrantForWrite(reason, upstreamUrl) {
|
|
1955
|
+
const leaseScope = repositoryLeaseScope(upstreamUrl);
|
|
1956
|
+
if (reason === "github.actions-workflow-dispatch") {
|
|
1957
|
+
return grantForAccess(
|
|
1958
|
+
"write",
|
|
1959
|
+
reason,
|
|
1960
|
+
"installation-actions-write",
|
|
1961
|
+
leaseScope
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
if (reason === "github.issue-create" || reason === "github.issues-write") {
|
|
1965
|
+
return grantForAccess(
|
|
1966
|
+
"write",
|
|
1967
|
+
reason,
|
|
1968
|
+
"installation-issues-write",
|
|
1969
|
+
leaseScope
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
if (reason === "github.pull-create" || reason === "github.pull-requests-write") {
|
|
1973
|
+
return grantForAccess(
|
|
1974
|
+
"write",
|
|
1975
|
+
reason,
|
|
1976
|
+
"installation-pull-requests-write",
|
|
1977
|
+
leaseScope
|
|
1978
|
+
);
|
|
1979
|
+
}
|
|
1980
|
+
if (reason === "github.pull-review-write") {
|
|
1981
|
+
return grantForAccess("write", reason, "user-write", leaseScope);
|
|
1982
|
+
}
|
|
1983
|
+
return void 0;
|
|
1984
|
+
}
|
|
1808
1985
|
async function githubGrantForEgress(ctx) {
|
|
1809
1986
|
const method = ctx.request.method.toUpperCase();
|
|
1810
1987
|
const upstreamUrl = new URL(ctx.request.url);
|
|
@@ -1816,10 +1993,18 @@ async function githubGrantForEgress(ctx) {
|
|
|
1816
1993
|
});
|
|
1817
1994
|
const smartHttpAccess = githubSmartHttpAccess(upstreamUrl);
|
|
1818
1995
|
if (smartHttpAccess) {
|
|
1996
|
+
if (smartHttpAccess === "write") {
|
|
1997
|
+
return grantForAccess(
|
|
1998
|
+
"write",
|
|
1999
|
+
"github.git-write",
|
|
2000
|
+
"installation-pr-branch-write",
|
|
2001
|
+
repositoryLeaseScope(upstreamUrl)
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
1819
2004
|
return grantForAccess(
|
|
1820
2005
|
smartHttpAccess,
|
|
1821
|
-
|
|
1822
|
-
|
|
2006
|
+
"github.git-read",
|
|
2007
|
+
"installation-read"
|
|
1823
2008
|
);
|
|
1824
2009
|
}
|
|
1825
2010
|
const userReadReason = githubUserReadReason(method, upstreamUrl);
|
|
@@ -1828,7 +2013,13 @@ async function githubGrantForEgress(ctx) {
|
|
|
1828
2013
|
}
|
|
1829
2014
|
const writeReason = githubApiWriteReason(method, upstreamUrl);
|
|
1830
2015
|
if (writeReason) {
|
|
1831
|
-
|
|
2016
|
+
const grant = installationGrantForWrite(writeReason, upstreamUrl);
|
|
2017
|
+
if (grant) {
|
|
2018
|
+
return grant;
|
|
2019
|
+
}
|
|
2020
|
+
throw new EgressPolicyDenied(
|
|
2021
|
+
`GitHub write operation ${writeReason} is not enabled for Junior credentials.`
|
|
2022
|
+
);
|
|
1832
2023
|
}
|
|
1833
2024
|
const graphqlAccess = githubGraphqlAccess(
|
|
1834
2025
|
method,
|
|
@@ -1836,18 +2027,55 @@ async function githubGrantForEgress(ctx) {
|
|
|
1836
2027
|
ctx.request.bodyText
|
|
1837
2028
|
);
|
|
1838
2029
|
if (graphqlAccess) {
|
|
2030
|
+
if (graphqlAccess === "write") {
|
|
2031
|
+
throw new EgressPolicyDenied(
|
|
2032
|
+
"GitHub GraphQL mutations are not enabled for Junior credentials."
|
|
2033
|
+
);
|
|
2034
|
+
}
|
|
1839
2035
|
return grantForAccess(
|
|
1840
2036
|
graphqlAccess,
|
|
1841
|
-
|
|
1842
|
-
|
|
2037
|
+
"github.graphql-read",
|
|
2038
|
+
"installation-read"
|
|
1843
2039
|
);
|
|
1844
2040
|
}
|
|
1845
2041
|
const access = HTTP_READ_METHODS.has(method) ? "read" : "write";
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
2042
|
+
if (access === "write") {
|
|
2043
|
+
throw new EgressPolicyDenied(
|
|
2044
|
+
"GitHub write request is not an explicitly allowed Junior operation."
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
2047
|
+
return grantForAccess(access, "github.api-read", "installation-read");
|
|
2048
|
+
}
|
|
2049
|
+
function configuredWritePermission(appPermissions, permission) {
|
|
2050
|
+
const level = appPermissions?.[permission];
|
|
2051
|
+
if (level !== void 0 && level !== "write" && level !== "admin") {
|
|
2052
|
+
throw new GitHubPluginSetupError(
|
|
2053
|
+
`githubPlugin appPermissions.${permission} must allow write access for Junior-owned GitHub resources.`
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
return {
|
|
2057
|
+
metadata: "read",
|
|
2058
|
+
[permission]: "write"
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
function configuredBranchWritePermissions(appPermissions) {
|
|
2062
|
+
const contents = appPermissions?.contents;
|
|
2063
|
+
if (contents !== void 0 && contents !== "write" && contents !== "admin") {
|
|
2064
|
+
throw new GitHubPluginSetupError(
|
|
2065
|
+
"githubPlugin appPermissions.contents must allow write access for Junior-managed pull request branches."
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
const workflows = appPermissions?.workflows;
|
|
2069
|
+
if (workflows !== void 0 && workflows !== "write" && workflows !== "admin") {
|
|
2070
|
+
throw new GitHubPluginSetupError(
|
|
2071
|
+
"githubPlugin appPermissions.workflows must allow write access when configured."
|
|
2072
|
+
);
|
|
2073
|
+
}
|
|
2074
|
+
return {
|
|
2075
|
+
contents: "write",
|
|
2076
|
+
metadata: "read",
|
|
2077
|
+
...workflows === "write" || workflows === "admin" ? { workflows: "write" } : {}
|
|
2078
|
+
};
|
|
1851
2079
|
}
|
|
1852
2080
|
function githubPlugin(options = {}) {
|
|
1853
2081
|
const botNameEnv = options.botNameEnv ?? "GITHUB_APP_BOT_NAME";
|
|
@@ -1933,23 +2161,15 @@ function githubPlugin(options = {}) {
|
|
|
1933
2161
|
if (!botName || !botEmail) {
|
|
1934
2162
|
return;
|
|
1935
2163
|
}
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
ctx.env.set("GIT_AUTHOR_EMAIL", authorEmail);
|
|
1941
|
-
ctx.env.set("JUNIOR_GIT_AUTHOR_NAME", authorName);
|
|
1942
|
-
ctx.env.set("JUNIOR_GIT_AUTHOR_EMAIL", authorEmail);
|
|
1943
|
-
}
|
|
2164
|
+
ctx.env.set("GIT_AUTHOR_NAME", botName);
|
|
2165
|
+
ctx.env.set("GIT_AUTHOR_EMAIL", botEmail);
|
|
2166
|
+
ctx.env.set("JUNIOR_GIT_AUTHOR_NAME", botName);
|
|
2167
|
+
ctx.env.set("JUNIOR_GIT_AUTHOR_EMAIL", botEmail);
|
|
1944
2168
|
ctx.env.set("GIT_COMMITTER_NAME", botName);
|
|
1945
2169
|
ctx.env.set("GIT_COMMITTER_EMAIL", botEmail);
|
|
1946
|
-
ctx.env.set("JUNIOR_GIT_COAUTHOR_NAME", botName);
|
|
1947
|
-
ctx.env.set("JUNIOR_GIT_COAUTHOR_EMAIL", botEmail);
|
|
1948
2170
|
const actorTrailers = additionalActorCoauthorTrailers({
|
|
1949
|
-
actors: ctx.actors,
|
|
1950
|
-
|
|
1951
|
-
botEmail,
|
|
1952
|
-
runActor: ctx.actor
|
|
2171
|
+
actors: [...ctx.actor ? [ctx.actor] : [], ...ctx.actors ?? []],
|
|
2172
|
+
botEmail
|
|
1953
2173
|
});
|
|
1954
2174
|
ctx.env.set(
|
|
1955
2175
|
"JUNIOR_GIT_ACTOR_COAUTHOR_TRAILERS",
|
|
@@ -1984,8 +2204,35 @@ function githubPlugin(options = {}) {
|
|
|
1984
2204
|
appIdEnv,
|
|
1985
2205
|
privateKeyEnv,
|
|
1986
2206
|
installationIdEnv,
|
|
1987
|
-
|
|
1988
|
-
|
|
2207
|
+
...appReadPermissions ? { permissions: appReadPermissions } : { loadPermissions: loadReadPermissions }
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
if (ctx.grant.name === "installation-actions-write" || ctx.grant.name === "installation-issues-write" || ctx.grant.name === "installation-pull-requests-write") {
|
|
2211
|
+
const repository = githubRepositoryFromLeaseScope(
|
|
2212
|
+
ctx.grant.leaseScope
|
|
2213
|
+
);
|
|
2214
|
+
const permission = ctx.grant.name === "installation-actions-write" ? "actions" : ctx.grant.name === "installation-issues-write" ? "issues" : "pull_requests";
|
|
2215
|
+
return await issueInstallationCredential({
|
|
2216
|
+
appIdEnv,
|
|
2217
|
+
privateKeyEnv,
|
|
2218
|
+
installationIdEnv,
|
|
2219
|
+
permissions: configuredWritePermission(
|
|
2220
|
+
appPermissions,
|
|
2221
|
+
permission
|
|
2222
|
+
),
|
|
2223
|
+
repositories: [repository.name]
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
if (ctx.grant.name === "installation-pr-branch-write") {
|
|
2227
|
+
const repository = githubRepositoryFromLeaseScope(
|
|
2228
|
+
ctx.grant.leaseScope
|
|
2229
|
+
);
|
|
2230
|
+
return await issueInstallationCredential({
|
|
2231
|
+
appIdEnv,
|
|
2232
|
+
privateKeyEnv,
|
|
2233
|
+
installationIdEnv,
|
|
2234
|
+
permissions: configuredBranchWritePermissions(appPermissions),
|
|
2235
|
+
repositories: [repository.name]
|
|
1989
2236
|
});
|
|
1990
2237
|
}
|
|
1991
2238
|
if (USER_TOKEN_GRANTS.has(ctx.grant.name)) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Actor } from "@sentry/junior-plugin-api";
|
|
2
|
+
export declare const GITHUB_REQUEST_ATTRIBUTION_START = "<!-- junior-request-attribution:start -->";
|
|
3
|
+
export declare const GITHUB_REQUEST_ATTRIBUTION_END = "<!-- junior-request-attribution:end -->";
|
|
4
|
+
/** Append or replace runtime-owned requester attribution in a GitHub body. */
|
|
5
|
+
export declare function appendGitHubRequesterAttribution(body: string, actor: Actor | undefined): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentry/junior-github",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.95.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@sinclair/typebox": "^0.34.49",
|
|
27
27
|
"zod": "^4.4.3",
|
|
28
|
-
"@sentry/junior-plugin-api": "0.
|
|
28
|
+
"@sentry/junior-plugin-api": "0.95.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/node": "^25.9.1",
|
|
@@ -66,6 +66,8 @@ Rules:
|
|
|
66
66
|
- After a failed fix attempt or strong correction, stop patching symptoms — re-read the evidence and restate the root cause before editing again.
|
|
67
67
|
- If the task is investigation-only, answer from evidence without editing.
|
|
68
68
|
|
|
69
|
+
**Advisor planning checkpoint:** For a non-trivial implementation — especially architecture or API changes, security-sensitive behavior, concurrency, migrations, broad cross-file edits, or work following failed attempts — call the advisor after gathering enough evidence and before editing. Provide the requirements, relevant code and output, constraints, proposed plan, alternatives considered, risks, and intended validation path. Resolve or explicitly account for material concerns before implementing.
|
|
70
|
+
|
|
69
71
|
### 3. Edit safely
|
|
70
72
|
|
|
71
73
|
- Smallest coherent change that satisfies the request.
|
|
@@ -79,8 +81,6 @@ Rules:
|
|
|
79
81
|
|
|
80
82
|
For multi-step or risky work, keep a compact checklist of intent, touched files, verification state, next step, and blockers.
|
|
81
83
|
|
|
82
|
-
For non-obvious architecture, security-sensitive, concurrency, or repeated-failure work, pause after investigation with evidence, risks, and a proposed plan before pushing ahead.
|
|
83
|
-
|
|
84
84
|
### 4. Verify before packaging
|
|
85
85
|
|
|
86
86
|
Before committing and creating a PR for code, config, or docs-as-code changes:
|
|
@@ -89,6 +89,8 @@ Before committing and creating a PR for code, config, or docs-as-code changes:
|
|
|
89
89
|
- For docs or instruction-only changes, do a content consistency review instead of claiming automated validation.
|
|
90
90
|
- If no credible check exists, say so explicitly.
|
|
91
91
|
|
|
92
|
+
**Advisor review checkpoint:** For a non-trivial implementation, call the advisor after inspecting the final diff and running the initial validation path, but before committing, opening or updating a PR, or reporting completion. Provide the diff, relevant surrounding code, requirements, validation results, known limitations, and any remaining risks. Ask specifically for correctness issues, regressions, missed cases, and testing gaps. Investigate material findings, revise as needed, and rerun affected checks before packaging.
|
|
93
|
+
|
|
92
94
|
### 5. Commit
|
|
93
95
|
|
|
94
96
|
Default format when the repo does not specify otherwise:
|
|
@@ -159,11 +161,13 @@ Before finishing, reconcile any plan or checklist stated earlier — mark items
|
|
|
159
161
|
|
|
160
162
|
**PR inspection** — read-only `gh pr` and `gh api` commands. Query both conversation comments (`--json comments`) and review comments (`gh api .../pulls/{n}/comments` and `.../reviews`).
|
|
161
163
|
|
|
162
|
-
**PR mutation** — push before create.
|
|
164
|
+
**PR mutation** — push before create. Use only the allowlisted REST endpoints in the API reference. Merge, close-with-delete, REST ref mutation, and admin operations are unsupported. Git smart HTTP does not independently prevent force updates or ref deletion; rely on GitHub rulesets and do not request destructive pushes.
|
|
165
|
+
|
|
166
|
+
**Workflow dispatch** — `gh workflow run` is supported for workflows that declare `workflow_dispatch`. Workflow reruns, cancellations, and other Actions mutations remain unsupported.
|
|
163
167
|
|
|
164
168
|
## Guardrails
|
|
165
169
|
|
|
166
170
|
- Default shallow clones; deepen only when needed.
|
|
167
|
-
-
|
|
171
|
+
- Do not attempt destructive merges, force pushes, or ref deletion.
|
|
168
172
|
- Answer source questions from repo evidence, not product framing or memory.
|
|
169
173
|
- Stop and return concrete remediation on missing access or permissions.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GitHub API Surface — code & pull requests
|
|
2
2
|
|
|
3
|
-
PR creation uses Junior's `github_createPullRequest` tool.
|
|
3
|
+
PR creation uses Junior's `github_createPullRequest` tool. Supported mutations use allowlisted REST endpoints through `gh api`; generic GraphQL-backed `gh pr` mutations are not supported.
|
|
4
4
|
|
|
5
5
|
## Repo scoping
|
|
6
6
|
|
|
@@ -10,16 +10,15 @@ Treat explicit repo flags as command-targeting safety rails, not as a credential
|
|
|
10
10
|
|
|
11
11
|
## GitHub App permission guidance
|
|
12
12
|
|
|
13
|
-
| Permission capability
|
|
14
|
-
|
|
|
15
|
-
| `github.actions.read`
|
|
16
|
-
| `github.actions.write`
|
|
17
|
-
| `github.contents.read`
|
|
18
|
-
| `github.contents.write`
|
|
19
|
-
| `github.workflows.write`
|
|
20
|
-
| `github.pull-requests.read`
|
|
21
|
-
| `github.pull-requests.write`
|
|
22
|
-
| `github.administration.write` + `github.contents.read` | `gh repo fork`; avoid for routine PR creation |
|
|
13
|
+
| Permission capability | Commands |
|
|
14
|
+
| ---------------------------- | ------------------------------------------------------------------------------------ |
|
|
15
|
+
| `github.actions.read` | `gh run list`, `gh run view`, `gh run watch`, `gh workflow list`, `gh workflow view` |
|
|
16
|
+
| `github.actions.write` | `gh workflow run` only |
|
|
17
|
+
| `github.contents.read` | `gh repo clone`, `git fetch` |
|
|
18
|
+
| `github.contents.write` | Git smart-HTTP `git push` only |
|
|
19
|
+
| `github.workflows.write` | Workflow-file changes carried by Git smart-HTTP push |
|
|
20
|
+
| `github.pull-requests.read` | `gh pr view`, `gh pr list`, `gh pr diff`, `gh pr checks` |
|
|
21
|
+
| `github.pull-requests.write` | Typed PR creation and allowlisted REST PR lifecycle endpoints |
|
|
23
22
|
|
|
24
23
|
## Command matrix
|
|
25
24
|
|
|
@@ -36,17 +35,20 @@ Treat explicit repo flags as command-targeting safety rails, not as a credential
|
|
|
36
35
|
| Create branch | `git -C DIRECTORY checkout -b BRANCH` |
|
|
37
36
|
| Stage and commit | `git -C DIRECTORY add -A && git -C DIRECTORY commit -m "message"` |
|
|
38
37
|
| Push branch before PR creation | `git -C DIRECTORY push -u origin BRANCH` |
|
|
38
|
+
| Dispatch workflow | `gh workflow run WORKFLOW --repo owner/repo --ref REF [-f key=value]` |
|
|
39
39
|
| Create pull request (draft) | `github_createPullRequest({ repo: "owner/repo", head: "BRANCH", base: "BASE", title: "...", body: "...", draft: true })` |
|
|
40
|
-
| Update pull request | `gh
|
|
41
|
-
|
|
|
42
|
-
|
|
|
40
|
+
| Update pull request | `gh api repos/owner/repo/pulls/NUMBER --method PATCH --input payload.json` |
|
|
41
|
+
| Mark ready for review | `gh api repos/owner/repo/pulls/NUMBER/ready_for_review --method POST` |
|
|
42
|
+
| Request reviewers | `gh api repos/owner/repo/pulls/NUMBER/requested_reviewers --method POST --input reviewers.json` |
|
|
43
|
+
| Remove requested reviewers | `gh api repos/owner/repo/pulls/NUMBER/requested_reviewers --method DELETE --input reviewers.json` |
|
|
44
|
+
| Close pull request | `gh api repos/owner/repo/pulls/NUMBER --method PATCH -f state=closed` |
|
|
45
|
+
| Submit human review | `gh api repos/owner/repo/pulls/NUMBER/reviews --method POST --input review.json` |
|
|
43
46
|
| View pull request | `gh pr view NUMBER --repo owner/repo [--json ...]` |
|
|
44
47
|
| List pull requests | `gh pr list --repo owner/repo [--state open \| closed \| merged]` |
|
|
45
48
|
| Diff pull request | `gh pr diff NUMBER --repo owner/repo` |
|
|
46
49
|
| Check pull request status | `gh pr checks NUMBER --repo owner/repo` |
|
|
47
50
|
| View PR review comments | `gh api repos/{owner}/{repo}/pulls/{number}/comments` |
|
|
48
51
|
| View PR reviews | `gh api repos/{owner}/{repo}/pulls/{number}/reviews` |
|
|
49
|
-
| Dispatch workflow | `gh workflow run WORKFLOW -R owner/repo --ref REF [-f key=value ...]` |
|
|
50
52
|
| List workflow runs | `gh run list -R owner/repo --workflow WORKFLOW [--limit N] [--json ...]` |
|
|
51
53
|
| View workflow run | `gh run view RUN_ID -R owner/repo [--json ...] [--log-failed]` |
|
|
52
54
|
| Watch workflow run | `gh run watch RUN_ID -R owner/repo --exit-status` |
|
|
@@ -62,12 +64,10 @@ jr-rpc config set github.repo owner/repo
|
|
|
62
64
|
|
|
63
65
|
- Prefer `--json` output for machine-readable parsing where available.
|
|
64
66
|
- Pass extra `git clone` flags after `--` (e.g. `gh repo clone owner/repo -- --depth=1`).
|
|
65
|
-
- A local `git commit` does not call GitHub. Pushing that commit
|
|
66
|
-
- REST Git commit construction also requires `github.contents.write`: `POST /git/blobs`, `POST /git/trees`, `POST /git/commits`, `POST /git/refs`, and `PATCH /git/refs/{ref}`.
|
|
67
|
+
- A local `git commit` does not call GitHub. Pushing that commit uses Junior's repository-scoped installation credential and requires `github.contents.write` on the target repo.
|
|
67
68
|
- If the commit changes workflow files under `.github/workflows`, expect `github.workflows.write` in addition to contents write.
|
|
68
69
|
- Before `github_createPullRequest`, push the head branch explicitly and resolve the target repo's default branch for `base`. That push requires GitHub write access to the remote.
|
|
69
|
-
-
|
|
70
|
+
- Merge, fork creation, workflow reruns or cancellations, REST contents/Git database writes, and repository administration are outside the current write allowlist.
|
|
70
71
|
- If the explicit `git push` fails with 401/403 or another access/permission error, verify the repo context and retry once. If it still fails, load troubleshooting guidance and report the exact command failure.
|
|
71
|
-
-
|
|
72
|
-
- `gh pr close --comment` may need `github.issues.write` (use `github-issues`); `gh pr close --delete-branch` needs `github.contents.write`.
|
|
72
|
+
- PR comments, labels, and assignees use GitHub's issue endpoints and therefore the issue grant; use the `github-issues` REST guidance for those operations.
|
|
73
73
|
- Return actionable errors for access, permission, not-found, and validation failures.
|
|
@@ -10,7 +10,7 @@ Use this table to recover quickly while keeping operations deterministic.
|
|
|
10
10
|
| Command affects or authenticates against the wrong repo | Stale `github.repo` default or authenticated command missing explicit repo. | Pass `--repo owner/repo` for the target repository, or update `github.repo` before retrying. |
|
|
11
11
|
| `GraphQL: Could not resolve to a Repository` | Repo slug is wrong or inaccessible. | Validate `owner/repo` and confirm app installation on target repository. |
|
|
12
12
|
| 401 Unauthorized | Issued GitHub credentials were rejected upstream. | Verify the target repo, then use the grant/auth signal to distinguish stale user OAuth from app installation or host env setup. |
|
|
13
|
-
| `junior-auth-required provider=github grant=user-write` | User-to-server OAuth is missing or stale for a
|
|
13
|
+
| `junior-auth-required provider=github grant=user-write` | User-to-server OAuth is missing or stale for a human-identity operation. | Follow the private OAuth prompt; do not ask the user to paste or manage tokens manually. |
|
|
14
14
|
| `git push` fails with 401/403 or auth/permission output | Write permission is missing, app installation is too narrow, or remote is wrong. | Verify the remote and repo context, retry once, then confirm app permissions and installation scope if it still fails. |
|
|
15
15
|
| Bash result includes `permission_denied` with `source: "upstream"` | GitHub returned 403 after Junior injected the named grant. | Do not call this a Junior runtime block. Use the message, connected account, upstream target, grant requirements, accepted-permissions, and SSO fields to explain the GitHub denial. |
|
|
16
16
|
| 403 Forbidden | App lacks required permission on repo or install scope is too narrow. | Verify the repo context, then confirm GitHub App permissions and installation scope. |
|
|
@@ -24,7 +24,7 @@ Use this table to recover quickly while keeping operations deterministic.
|
|
|
24
24
|
|
|
25
25
|
- Retry once for transient transport failures after verifying repo context.
|
|
26
26
|
- Do not loop retries on repeated 401/403/404 validation errors.
|
|
27
|
-
- Treat missing or stale `user-read`/`user-write` grants as private GitHub App OAuth work
|
|
27
|
+
- Treat missing or stale `user-read`/`user-write` grants as private GitHub App OAuth work. Treat all `installation-*` failures as App permission, installation scope, or host environment setup; they do not fall back to user OAuth.
|
|
28
28
|
- Do not describe `permission_denied` with `source: "upstream"` as Junior blocking the request. It means the egress proxy injected a credential, forwarded the request, and recorded GitHub's upstream 403. Prefer its `account` and `grant.requirements` fields over inference when explaining what to fix.
|
|
29
29
|
- Do not infer permission level from OAuth scopes. GitHub App user tokens report no OAuth scopes; GitHub App permissions and accepted-permissions headers are the useful evidence.
|
|
30
30
|
- For persistent permission problems, return explicit remediation and stop.
|
|
@@ -62,7 +62,7 @@ Follow [references/research-rules.md](references/research-rules.md) for cross-ty
|
|
|
62
62
|
|
|
63
63
|
**Source attribution:**
|
|
64
64
|
|
|
65
|
-
-
|
|
65
|
+
- The runtime adds the verified `Requested by` block. Do not add or rewrite requester attribution in model-authored body text.
|
|
66
66
|
- If the person who originally reported or observed the problem differs from the issue creator, capture that with durable body text such as `Reported by Alice.` or `Raised by Alice during incident triage.`
|
|
67
67
|
- Attach screenshots from the thread as image links when present.
|
|
68
68
|
- Include code snippets, related issues, and related PRs only when they materially improve the issue.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GitHub Issue API Surface
|
|
2
2
|
|
|
3
|
-
Issue creation uses Junior's `github_createIssue` tool. Other issue operations use `gh
|
|
3
|
+
Issue creation uses Junior's `github_createIssue` tool. Other issue operations use allowlisted REST endpoints through `gh api`; generic GraphQL-backed `gh issue` mutations are not supported.
|
|
4
4
|
|
|
5
5
|
## Repo scoping
|
|
6
6
|
|
|
@@ -10,22 +10,22 @@ Treat explicit repo flags as command-targeting safety rails, not as a credential
|
|
|
10
10
|
|
|
11
11
|
## GitHub App permission guidance
|
|
12
12
|
|
|
13
|
-
| Permission capability | Operations
|
|
14
|
-
| --------------------- |
|
|
15
|
-
| `github.issues.read` | `gh issue view`, `gh api /repos/.../comments`
|
|
16
|
-
| `github.issues.write` | `github_createIssue
|
|
13
|
+
| Permission capability | Operations |
|
|
14
|
+
| --------------------- | ------------------------------------------------------------------------------------ |
|
|
15
|
+
| `github.issues.read` | `gh issue view`, `gh api /repos/.../comments` |
|
|
16
|
+
| `github.issues.write` | `github_createIssue` and allowlisted REST issue lifecycle endpoints through `gh api` |
|
|
17
17
|
|
|
18
18
|
## Command matrix
|
|
19
19
|
|
|
20
20
|
| Operation | Command |
|
|
21
21
|
| ------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
22
22
|
| Create issue | `github_createIssue({ repo: "owner/repo", title: "...", body: "...", labels: ["..."] })` |
|
|
23
|
-
| Update issue fields | `gh
|
|
24
|
-
| Close issue | `gh
|
|
25
|
-
| Reopen issue | `gh
|
|
26
|
-
| Add labels | `gh
|
|
27
|
-
| Remove
|
|
28
|
-
| Add comment | `gh
|
|
23
|
+
| Update issue fields | `gh api repos/owner/repo/issues/NUMBER --method PATCH --input payload.json` |
|
|
24
|
+
| Close issue | `gh api repos/owner/repo/issues/NUMBER --method PATCH -f state=closed` |
|
|
25
|
+
| Reopen issue | `gh api repos/owner/repo/issues/NUMBER --method PATCH -f state=open` |
|
|
26
|
+
| Add labels | `gh api repos/owner/repo/issues/NUMBER/labels --method POST --input labels.json` |
|
|
27
|
+
| Remove label | `gh api repos/owner/repo/issues/NUMBER/labels/LABEL --method DELETE` |
|
|
28
|
+
| Add comment | `gh api repos/owner/repo/issues/NUMBER/comments --method POST --input comment.json` |
|
|
29
29
|
| List issues | `gh issue list --repo owner/repo --json number,title,state,url --limit 20` |
|
|
30
30
|
| Read issue | `gh issue view NUMBER --repo owner/repo --json number,title,state,labels,assignees,author,url,body` |
|
|
31
31
|
| Read comments | `gh api /repos/owner/repo/issues/NUMBER/comments --method GET --header "Accept: application/vnd.github+json"` |
|
|
@@ -9,15 +9,15 @@ Use this table to recover quickly while keeping operations deterministic.
|
|
|
9
9
|
| Command affects or authenticates against the wrong repo | Stale `github.repo` default or authenticated command missing explicit repo. | Pass `--repo owner/repo` for the target repository, or update `github.repo` before retrying. |
|
|
10
10
|
| `GraphQL: Could not resolve to a Repository` | Repo slug is wrong or inaccessible. | Validate `owner/repo` and confirm app installation on target repository. |
|
|
11
11
|
| 401 Unauthorized | Issued GitHub credentials were rejected upstream. | Verify the target repo, then use the grant/auth signal to distinguish stale user OAuth from app installation or host env setup. |
|
|
12
|
-
| `junior-auth-required provider=github grant=user-write` | User-to-server OAuth is missing or stale for a
|
|
12
|
+
| `junior-auth-required provider=github grant=user-write` | User-to-server OAuth is missing or stale for a human-identity operation. | Follow the private OAuth prompt; do not ask the user to paste or manage tokens manually. |
|
|
13
13
|
| 403 Forbidden | App lacks required permission on repo or install scope is too narrow. | Verify the repo context, then confirm GitHub App permissions and installation scope. |
|
|
14
14
|
| 404 Not Found | Issue number or repo is wrong. | Validate repo + issue ID with `gh issue view NUMBER --repo owner/repo`. |
|
|
15
|
-
|
|
|
16
|
-
| Comment command fails with empty body |
|
|
15
|
+
| Issue label mutation fails | Wrong REST payload or wrong repo context. | Use the allowlisted issue labels endpoint with an explicit `owner/repo` path and valid JSON input. |
|
|
16
|
+
| Comment command fails with empty body | JSON input is missing a non-empty `body`. | Ensure the REST comment payload contains a non-empty `body` before retrying. |
|
|
17
17
|
|
|
18
18
|
## Retry guidance
|
|
19
19
|
|
|
20
20
|
- Retry once for transient transport failures after verifying repo context.
|
|
21
21
|
- Do not loop retries on repeated 401/403/404 validation errors.
|
|
22
|
-
- Treat missing or stale `user-read`/`user-write` grants as private GitHub App OAuth work
|
|
22
|
+
- Treat missing or stale `user-read`/`user-write` grants as private GitHub App OAuth work. Treat all `installation-*` failures as App permission, installation scope, or host environment setup; they do not fall back to user OAuth.
|
|
23
23
|
- For persistent permission problems, return explicit remediation and stop.
|