@sentry/junior-github 0.92.1 → 0.94.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 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). Read operations use host-issued GitHub App installation tokens. Write operations use GitHub App user-to-server OAuth tokens so GitHub attributes the action to the requesting user with the app badge.
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, 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 these tokens, so Junior cannot verify granted scopes from the token response.
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 an integration flow requires specific GitHub OAuth scope parameters in the authorization URL. Do not rely on it to authorize repository, Actions, or workflow writes — those are enforced by GitHub App permissions and the requesting user's own access.
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. Safe app-readable API methods, GraphQL `GET`/`HEAD`/`OPTIONS` requests, GraphQL `POST` bodies that prove the operation is a query, and `git-upload-pack` use the `installation-read` grant. `GET /user` uses the `user-read` grant so account identity checks use the actor token. Write-specific REST URLs, GraphQL mutations/subscriptions, unknown GraphQL `POST` bodies, other non-read API methods, and `git-receive-pack` use the `user-write` grant.
102
- - `user-read` and `user-write` require the actor, or an explicitly delegated user subject from an allowed system run, to authorize the GitHub App through the private OAuth flow. Missing or expired user authorization pauses interactive turns, sends a private authorization link, and resumes after approval.
103
- - Git commits use the actor as the commit author, Junior as committer, and a Junior `Co-Authored-By` trailer.
104
- - Issued credentials are reused only within the current turn, credential leases are cached separately by plugin grant name, and upstream 403 permission denials clear the cached lease before the next retry.
101
+ - The plugin classifies GitHub traffic from the forwarded HTTP request. Reads use `installation-read`, while `GET /user` uses `user-read`. Allowlisted issue and pull request mutations use 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
- ```bash
127
- gh issue create --repo owner/repo --title "Example issue" --body-file /vercel/sandbox/tmp/issue.md
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 `installation-read` for read-only GitHub traffic and `user-write` for mutations. GitHub App permissions still need to cover the operation: issues for issue edits/comments/labels, contents for pushes and merges, pull requests for PR mutations, actions/workflows for workflow operations.
131
+ The plugin uses installation credentials for read-only GitHub traffic, allowlisted issue and pull request mutations, and Git smart-HTTP pushes. GitHub App permissions still need to cover the operation: 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 actor as author and the GitHub App bot as committer in the sandbox.
136
- - Pushing a branch with Git smart HTTP (`git push`) uses the `user-write` grant and requires the GitHub App to have `Contents: write` on the target repository. The requesting user must also have write access to that repository.
137
- - REST Git database writes used by some `gh` flows also require `Contents: write`: create blob (`POST /git/blobs`), create tree (`POST /git/trees`), create commit (`POST /git/commits`), and create/update refs (`POST /git/refs`, `PATCH /git/refs/{ref}`). Changes to workflow files may also require `Workflows: write`.
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. `POST /repos/{owner}/{repo}/forks` uses the `user-write` grant, but GitHub requires `Administration: write` and `Contents: read`, and the app must be installed on both the source and destination accounts. Do not grant `Administration: write` for routine PR creation; push a branch explicitly and create the PR with `github_createPullRequest` instead.
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
- - `gh pr edit` title/body/base/reviewer changes fit `github.pull-requests.write`.
147
- - `gh pr edit` label changes fit `github.issues.write`.
148
- - `gh pr edit` assignee/milestone changes fit `github.issues.write`.
149
- - `gh pr close --comment` may need `github.issues.write`.
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 requesting GitHub user with the GitHub App badge.
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 all mutations succeed and are attributed to the requesting GitHub user with the app badge.
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(input.body ?? "", conversationId),
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(conversationId, parsedInput);
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(input.body ?? "", conversationId),
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,17 @@ 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"
806
- ];
807
- var WORKFLOWS_WRITE_REQUIREMENTS = [
808
- "GitHub App Contents: write and Workflows: write on the target repository",
809
- "requesting GitHub user write access to the repository"
852
+ "GitHub App Contents: write on the target repository"
810
853
  ];
811
854
  var ISSUES_WRITE_REQUIREMENTS = [
812
- "GitHub App Issues: write on the target repository",
813
- "requesting GitHub user issue access to the repository"
855
+ "GitHub App Issues: write on the target repository"
814
856
  ];
815
857
  var PULL_REQUESTS_WRITE_REQUIREMENTS = [
816
- "GitHub App Pull requests: write on the target repository",
817
- "requesting GitHub user write access to the repository"
858
+ "GitHub App Pull requests: write on the target repository"
818
859
  ];
819
- var FORK_CREATE_REQUIREMENTS = [
820
- "GitHub App Administration: write and Contents: read",
821
- "app installation access on the source and destination accounts",
822
- "requesting GitHub user permission to fork the repository"
860
+ var PULL_REVIEW_WRITE_REQUIREMENTS = [
861
+ ...PULL_REQUESTS_WRITE_REQUIREMENTS,
862
+ "requesting GitHub user permission to review the pull request"
823
863
  ];
824
864
  var GitHubUserRefreshRejectedError = class extends Error {
825
865
  constructor(message) {
@@ -920,14 +960,12 @@ function additionalActorCoauthorTrailers(args) {
920
960
  if (!args.actors || args.actors.length === 0) {
921
961
  return [];
922
962
  }
923
- const runActorKey = args.runActor ? actorIdentityKey(args.runActor) : void 0;
924
963
  const seenEmails = /* @__PURE__ */ new Set([args.botEmail.toLowerCase()]);
925
- if (args.authorEmail) {
926
- seenEmails.add(args.authorEmail.toLowerCase());
927
- }
964
+ const seenActors = /* @__PURE__ */ new Set();
928
965
  const trailers = [];
929
966
  for (const candidate of args.actors) {
930
- if (runActorKey && actorIdentityKey(candidate) === runActorKey) {
967
+ const actorKey = actorIdentityKey(candidate);
968
+ if (seenActors.has(actorKey)) {
931
969
  continue;
932
970
  }
933
971
  const name = actorName(candidate);
@@ -939,6 +977,7 @@ function additionalActorCoauthorTrailers(args) {
939
977
  if (seenEmails.has(emailKey)) {
940
978
  continue;
941
979
  }
980
+ seenActors.add(actorKey);
942
981
  seenEmails.add(emailKey);
943
982
  trailers.push(`Co-Authored-By: ${name} <${email}>`);
944
983
  }
@@ -954,23 +993,18 @@ if [ -z "$message_file" ]; then
954
993
  fi
955
994
 
956
995
  if [ -z "\${JUNIOR_GIT_AUTHOR_NAME:-}" ] || [ -z "\${JUNIOR_GIT_AUTHOR_EMAIL:-}" ]; then
957
- echo "Junior GitHub plugin internal error: actor commit attribution was not injected by the host runtime. Do not set Git author env vars manually; report this configuration error." >&2
996
+ 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
997
  exit 1
959
998
  fi
960
999
 
961
1000
  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 resolved actor identity. Do not override Git author manually; report this configuration error." >&2
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
1001
+ 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
1002
  exit 1
969
1003
  fi
970
1004
 
971
1005
  # Git and GitHub only interpret the final contiguous paragraph as the trailer
972
1006
  # block, so all missing trailers are collected and appended as one block:
973
- # actor trailers in order, Junior's agent trailer last.
1007
+ # human actor trailers in run order.
974
1008
  desired_trailers=""
975
1009
  add_trailer() {
976
1010
  desired_trailers="$desired_trailers$1"$'\\n'
@@ -984,8 +1018,6 @@ if [ -n "\${JUNIOR_GIT_ACTOR_COAUTHOR_TRAILERS:-}" ]; then
984
1018
  done <<< "$JUNIOR_GIT_ACTOR_COAUTHOR_TRAILERS"
985
1019
  fi
986
1020
 
987
- add_trailer "Co-Authored-By: $JUNIOR_GIT_COAUTHOR_NAME <$JUNIOR_GIT_COAUTHOR_EMAIL>"
988
-
989
1021
  final_trailer_block=$(awk '
990
1022
  { lines[NR] = $0 }
991
1023
  END {
@@ -1011,6 +1043,57 @@ final_trailer_block=$(awk '
1011
1043
  }
1012
1044
  ' "$message_file")
1013
1045
 
1046
+ duplicate_desired_trailer=false
1047
+ while IFS= read -r desired_trailer; do
1048
+ if [ -n "$desired_trailer" ] && [ "$(printf '%s\\n' "$final_trailer_block" | grep -Fxc -- "$desired_trailer")" -gt 1 ]; then
1049
+ duplicate_desired_trailer=true
1050
+ break
1051
+ fi
1052
+ done <<< "$desired_trailers"
1053
+
1054
+ if [ "$duplicate_desired_trailer" = true ]; then
1055
+ tmp_file=$(mktemp)
1056
+ desired_file=$(mktemp)
1057
+ trap 'rm -f "$tmp_file" "$desired_file"' EXIT
1058
+ printf '%s' "$desired_trailers" > "$desired_file"
1059
+ awk -v desired_file="$desired_file" '
1060
+ BEGIN {
1061
+ while ((getline value < desired_file) > 0) {
1062
+ if (value != "") {
1063
+ wanted[value] = 1
1064
+ }
1065
+ }
1066
+ close(desired_file)
1067
+ }
1068
+ { lines[NR] = $0 }
1069
+ END {
1070
+ line = NR
1071
+ while (line > 0 && lines[line] == "") {
1072
+ line--
1073
+ }
1074
+ start = line
1075
+ while (start > 0 && lines[start] != "") {
1076
+ start--
1077
+ }
1078
+ valid = line > 0
1079
+ for (i = start + 1; valid && i <= line; i++) {
1080
+ if (lines[i] !~ /^[[:alnum:]-]+: .+/) {
1081
+ valid = 0
1082
+ }
1083
+ }
1084
+ for (i = 1; i <= NR; i++) {
1085
+ if (valid && i > start && i <= line && wanted[lines[i]]) {
1086
+ if (seen[lines[i]]++) {
1087
+ continue
1088
+ }
1089
+ }
1090
+ print lines[i]
1091
+ }
1092
+ }
1093
+ ' "$message_file" > "$tmp_file"
1094
+ cat "$tmp_file" > "$message_file"
1095
+ fi
1096
+
1014
1097
  missing_trailers=""
1015
1098
  collect_missing_trailer() {
1016
1099
  if ! printf '%s\\n' "$final_trailer_block" | grep -Fqx -- "$1"; then
@@ -1032,18 +1115,7 @@ if [ -n "$(tail -c 1 "$message_file")" ]; then
1032
1115
  printf '\\n' >> "$message_file"
1033
1116
  fi
1034
1117
 
1035
- # When the message already ends with Junior's bot trailer, insert missing
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
1118
+ if [ -n "$final_trailer_block" ]; then
1047
1119
  printf '%s' "$missing_trailers" >> "$message_file"
1048
1120
  else
1049
1121
  printf '\\n%s' "$missing_trailers" >> "$message_file"
@@ -1298,6 +1370,41 @@ function readInstallationPermissions(installation) {
1298
1370
  }
1299
1371
  return readGrantPermissions(installation.permissions);
1300
1372
  }
1373
+ function decodeGitHubPathSegment(value) {
1374
+ try {
1375
+ const decoded = decodeURIComponent(value).trim();
1376
+ return decoded && !decoded.includes("/") ? decoded : void 0;
1377
+ } catch {
1378
+ return void 0;
1379
+ }
1380
+ }
1381
+ function githubRepositoryFromUrl(upstreamUrl) {
1382
+ const segments = upstreamUrl.pathname.split("/").filter(Boolean);
1383
+ if (isGitHubApiUrl(upstreamUrl) && segments[0]?.toLowerCase() === "repos") {
1384
+ const owner2 = segments[1] ? decodeGitHubPathSegment(segments[1]) : void 0;
1385
+ const name2 = segments[2] ? decodeGitHubPathSegment(segments[2]) : void 0;
1386
+ return owner2 && name2 ? { owner: owner2, name: name2 } : void 0;
1387
+ }
1388
+ if (upstreamUrl.hostname.toLowerCase() !== "github.com") {
1389
+ return void 0;
1390
+ }
1391
+ const owner = segments[0] ? decodeGitHubPathSegment(segments[0]) : void 0;
1392
+ const rawName = segments[1]?.replace(/\.git$/i, "");
1393
+ const name = rawName ? decodeGitHubPathSegment(rawName) : void 0;
1394
+ return owner && name ? { owner, name } : void 0;
1395
+ }
1396
+ function githubRepositoryLeaseScope(repository) {
1397
+ return `repository:${repository.owner.toLowerCase()}/${repository.name.toLowerCase()}`;
1398
+ }
1399
+ function githubRepositoryFromLeaseScope(leaseScope) {
1400
+ const match = /^repository:([^/]+)\/([^/]+)$/.exec(leaseScope ?? "");
1401
+ if (!match?.[1] || !match[2]) {
1402
+ throw new GitHubPluginSetupError(
1403
+ "GitHub installation write grant is missing a repository lease scope."
1404
+ );
1405
+ }
1406
+ return { owner: match[1], name: match[2] };
1407
+ }
1301
1408
  async function resolveUserAccount(tokens) {
1302
1409
  const account = await githubRequest("https://api.github.com", "/user", {
1303
1410
  token: tokens.accessToken
@@ -1475,12 +1582,11 @@ async function issueInstallationCredential(options) {
1475
1582
  throw new GitHubPluginSetupError(`Invalid ${options.installationIdEnv}`);
1476
1583
  }
1477
1584
  const appJwt = createAppJwt(appId, options.privateKeyEnv);
1478
- let tokenPermissions = options.readPermissions;
1479
- if (!tokenPermissions) {
1480
- tokenPermissions = await options.loadReadPermissions({
1481
- appJwt,
1482
- installationId
1483
- });
1585
+ const permissions = options.permissions ?? await options.loadPermissions?.({ appJwt, installationId });
1586
+ if (!permissions) {
1587
+ throw new GitHubPluginSetupError(
1588
+ "GitHub installation credential permissions are not configured."
1589
+ );
1484
1590
  }
1485
1591
  const accessTokenResponse = await githubRequest(
1486
1592
  "https://api.github.com",
@@ -1488,7 +1594,10 @@ async function issueInstallationCredential(options) {
1488
1594
  {
1489
1595
  method: "POST",
1490
1596
  token: appJwt,
1491
- body: { permissions: tokenPermissions }
1597
+ body: {
1598
+ permissions,
1599
+ ...options.repositories ? { repositories: options.repositories } : {}
1600
+ }
1492
1601
  }
1493
1602
  );
1494
1603
  const parsedToken = parseInstallationTokenResponse(accessTokenResponse);
@@ -1684,12 +1793,31 @@ function githubApiWriteReason(method, upstreamUrl) {
1684
1793
  if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+\/comments$/.test(pathname)) {
1685
1794
  return "github.issues-write";
1686
1795
  }
1796
+ if (method === "PATCH" && /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+$/.test(pathname)) {
1797
+ return "github.issues-write";
1798
+ }
1799
+ if ((method === "POST" || method === "DELETE") && /^\/repos\/[^/]+\/[^/]+\/issues\/[^/]+\/(labels|assignees)(?:\/[^/]+)?$/.test(
1800
+ pathname
1801
+ )) {
1802
+ return "github.issues-write";
1803
+ }
1687
1804
  if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/pulls$/.test(pathname)) {
1688
1805
  return "github.pull-create";
1689
1806
  }
1690
1807
  if (method === "PATCH" && /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+$/.test(pathname)) {
1691
1808
  return "github.pull-requests-write";
1692
1809
  }
1810
+ if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/ready_for_review$/.test(pathname)) {
1811
+ return "github.pull-requests-write";
1812
+ }
1813
+ if ((method === "POST" || method === "DELETE") && /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/requested_reviewers$/.test(pathname)) {
1814
+ return "github.pull-requests-write";
1815
+ }
1816
+ if (/^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/reviews(?:\/[^/]+(?:\/(events|dismissals))?)?$/.test(
1817
+ pathname
1818
+ ) && !HTTP_READ_METHODS.has(method)) {
1819
+ return "github.pull-review-write";
1820
+ }
1693
1821
  if (method === "POST" && /^\/repos\/[^/]+\/[^/]+\/forks$/.test(pathname)) {
1694
1822
  return "github.fork-create";
1695
1823
  }
@@ -1782,29 +1910,59 @@ function grantRequirements(reason) {
1782
1910
  if (reason === "github.git-write" || reason === "github.contents-write") {
1783
1911
  return CONTENTS_WRITE_REQUIREMENTS;
1784
1912
  }
1785
- if (reason === "github.workflows-write") {
1786
- return WORKFLOWS_WRITE_REQUIREMENTS;
1787
- }
1788
1913
  if (reason === "github.issue-create" || reason === "github.issues-write") {
1789
1914
  return ISSUES_WRITE_REQUIREMENTS;
1790
1915
  }
1916
+ if (reason === "github.pull-review-write") {
1917
+ return PULL_REVIEW_WRITE_REQUIREMENTS;
1918
+ }
1791
1919
  if (reason === "github.pull-create" || reason === "github.pull-requests-write") {
1792
1920
  return PULL_REQUESTS_WRITE_REQUIREMENTS;
1793
1921
  }
1794
- if (reason === "github.fork-create") {
1795
- return FORK_CREATE_REQUIREMENTS;
1796
- }
1797
1922
  return void 0;
1798
1923
  }
1799
- function grantForAccess(access, reason, name) {
1924
+ function grantForAccess(access, reason, name, leaseScope) {
1800
1925
  const requirements = grantRequirements(reason);
1801
1926
  return {
1802
1927
  name,
1803
1928
  access,
1929
+ ...leaseScope ? { leaseScope } : {},
1804
1930
  reason,
1805
1931
  ...requirements ? { requirements } : {}
1806
1932
  };
1807
1933
  }
1934
+ function repositoryLeaseScope(upstreamUrl) {
1935
+ const repository = githubRepositoryFromUrl(upstreamUrl);
1936
+ if (!repository) {
1937
+ throw new EgressPolicyDenied(
1938
+ "GitHub write request does not identify a target repository."
1939
+ );
1940
+ }
1941
+ return githubRepositoryLeaseScope(repository);
1942
+ }
1943
+ function installationGrantForWrite(reason, upstreamUrl) {
1944
+ const leaseScope = repositoryLeaseScope(upstreamUrl);
1945
+ if (reason === "github.issue-create" || reason === "github.issues-write") {
1946
+ return grantForAccess(
1947
+ "write",
1948
+ reason,
1949
+ "installation-issues-write",
1950
+ leaseScope
1951
+ );
1952
+ }
1953
+ if (reason === "github.pull-create" || reason === "github.pull-requests-write") {
1954
+ return grantForAccess(
1955
+ "write",
1956
+ reason,
1957
+ "installation-pull-requests-write",
1958
+ leaseScope
1959
+ );
1960
+ }
1961
+ if (reason === "github.pull-review-write") {
1962
+ return grantForAccess("write", reason, "user-write", leaseScope);
1963
+ }
1964
+ return void 0;
1965
+ }
1808
1966
  async function githubGrantForEgress(ctx) {
1809
1967
  const method = ctx.request.method.toUpperCase();
1810
1968
  const upstreamUrl = new URL(ctx.request.url);
@@ -1816,10 +1974,18 @@ async function githubGrantForEgress(ctx) {
1816
1974
  });
1817
1975
  const smartHttpAccess = githubSmartHttpAccess(upstreamUrl);
1818
1976
  if (smartHttpAccess) {
1977
+ if (smartHttpAccess === "write") {
1978
+ return grantForAccess(
1979
+ "write",
1980
+ "github.git-write",
1981
+ "installation-pr-branch-write",
1982
+ repositoryLeaseScope(upstreamUrl)
1983
+ );
1984
+ }
1819
1985
  return grantForAccess(
1820
1986
  smartHttpAccess,
1821
- smartHttpAccess === "write" ? "github.git-write" : "github.git-read",
1822
- smartHttpAccess === "write" ? "user-write" : "installation-read"
1987
+ "github.git-read",
1988
+ "installation-read"
1823
1989
  );
1824
1990
  }
1825
1991
  const userReadReason = githubUserReadReason(method, upstreamUrl);
@@ -1828,7 +1994,13 @@ async function githubGrantForEgress(ctx) {
1828
1994
  }
1829
1995
  const writeReason = githubApiWriteReason(method, upstreamUrl);
1830
1996
  if (writeReason) {
1831
- return grantForAccess("write", writeReason, "user-write");
1997
+ const grant = installationGrantForWrite(writeReason, upstreamUrl);
1998
+ if (grant) {
1999
+ return grant;
2000
+ }
2001
+ throw new EgressPolicyDenied(
2002
+ `GitHub write operation ${writeReason} is not enabled for Junior credentials.`
2003
+ );
1832
2004
  }
1833
2005
  const graphqlAccess = githubGraphqlAccess(
1834
2006
  method,
@@ -1836,18 +2008,55 @@ async function githubGrantForEgress(ctx) {
1836
2008
  ctx.request.bodyText
1837
2009
  );
1838
2010
  if (graphqlAccess) {
2011
+ if (graphqlAccess === "write") {
2012
+ throw new EgressPolicyDenied(
2013
+ "GitHub GraphQL mutations are not enabled for Junior credentials."
2014
+ );
2015
+ }
1839
2016
  return grantForAccess(
1840
2017
  graphqlAccess,
1841
- graphqlAccess === "write" ? "github.graphql-write" : "github.graphql-read",
1842
- graphqlAccess === "write" ? "user-write" : "installation-read"
2018
+ "github.graphql-read",
2019
+ "installation-read"
1843
2020
  );
1844
2021
  }
1845
2022
  const access = HTTP_READ_METHODS.has(method) ? "read" : "write";
1846
- return grantForAccess(
1847
- access,
1848
- access === "write" ? "github.api-write" : "github.api-read",
1849
- access === "write" ? "user-write" : "installation-read"
1850
- );
2023
+ if (access === "write") {
2024
+ throw new EgressPolicyDenied(
2025
+ "GitHub write request is not an explicitly allowed Junior operation."
2026
+ );
2027
+ }
2028
+ return grantForAccess(access, "github.api-read", "installation-read");
2029
+ }
2030
+ function configuredWritePermission(appPermissions, permission) {
2031
+ const level = appPermissions?.[permission];
2032
+ if (level !== void 0 && level !== "write" && level !== "admin") {
2033
+ throw new GitHubPluginSetupError(
2034
+ `githubPlugin appPermissions.${permission} must allow write access for Junior-owned GitHub resources.`
2035
+ );
2036
+ }
2037
+ return {
2038
+ metadata: "read",
2039
+ [permission]: "write"
2040
+ };
2041
+ }
2042
+ function configuredBranchWritePermissions(appPermissions) {
2043
+ const contents = appPermissions?.contents;
2044
+ if (contents !== void 0 && contents !== "write" && contents !== "admin") {
2045
+ throw new GitHubPluginSetupError(
2046
+ "githubPlugin appPermissions.contents must allow write access for Junior-managed pull request branches."
2047
+ );
2048
+ }
2049
+ const workflows = appPermissions?.workflows;
2050
+ if (workflows !== void 0 && workflows !== "write" && workflows !== "admin") {
2051
+ throw new GitHubPluginSetupError(
2052
+ "githubPlugin appPermissions.workflows must allow write access when configured."
2053
+ );
2054
+ }
2055
+ return {
2056
+ contents: "write",
2057
+ metadata: "read",
2058
+ ...workflows === "write" || workflows === "admin" ? { workflows: "write" } : {}
2059
+ };
1851
2060
  }
1852
2061
  function githubPlugin(options = {}) {
1853
2062
  const botNameEnv = options.botNameEnv ?? "GITHUB_APP_BOT_NAME";
@@ -1933,23 +2142,15 @@ function githubPlugin(options = {}) {
1933
2142
  if (!botName || !botEmail) {
1934
2143
  return;
1935
2144
  }
1936
- const authorName = actorName(ctx.actor);
1937
- const authorEmail = actorEmail(ctx.actor);
1938
- if (authorName && authorEmail) {
1939
- ctx.env.set("GIT_AUTHOR_NAME", authorName);
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
- }
2145
+ ctx.env.set("GIT_AUTHOR_NAME", botName);
2146
+ ctx.env.set("GIT_AUTHOR_EMAIL", botEmail);
2147
+ ctx.env.set("JUNIOR_GIT_AUTHOR_NAME", botName);
2148
+ ctx.env.set("JUNIOR_GIT_AUTHOR_EMAIL", botEmail);
1944
2149
  ctx.env.set("GIT_COMMITTER_NAME", botName);
1945
2150
  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
2151
  const actorTrailers = additionalActorCoauthorTrailers({
1949
- actors: ctx.actors,
1950
- authorEmail,
1951
- botEmail,
1952
- runActor: ctx.actor
2152
+ actors: [...ctx.actor ? [ctx.actor] : [], ...ctx.actors ?? []],
2153
+ botEmail
1953
2154
  });
1954
2155
  ctx.env.set(
1955
2156
  "JUNIOR_GIT_ACTOR_COAUTHOR_TRAILERS",
@@ -1984,8 +2185,35 @@ function githubPlugin(options = {}) {
1984
2185
  appIdEnv,
1985
2186
  privateKeyEnv,
1986
2187
  installationIdEnv,
1987
- readPermissions: appReadPermissions,
1988
- loadReadPermissions
2188
+ ...appReadPermissions ? { permissions: appReadPermissions } : { loadPermissions: loadReadPermissions }
2189
+ });
2190
+ }
2191
+ if (ctx.grant.name === "installation-issues-write" || ctx.grant.name === "installation-pull-requests-write") {
2192
+ const repository = githubRepositoryFromLeaseScope(
2193
+ ctx.grant.leaseScope
2194
+ );
2195
+ const permission = ctx.grant.name === "installation-issues-write" ? "issues" : "pull_requests";
2196
+ return await issueInstallationCredential({
2197
+ appIdEnv,
2198
+ privateKeyEnv,
2199
+ installationIdEnv,
2200
+ permissions: configuredWritePermission(
2201
+ appPermissions,
2202
+ permission
2203
+ ),
2204
+ repositories: [repository.name]
2205
+ });
2206
+ }
2207
+ if (ctx.grant.name === "installation-pr-branch-write") {
2208
+ const repository = githubRepositoryFromLeaseScope(
2209
+ ctx.grant.leaseScope
2210
+ );
2211
+ return await issueInstallationCredential({
2212
+ appIdEnv,
2213
+ privateKeyEnv,
2214
+ installationIdEnv,
2215
+ permissions: configuredBranchWritePermissions(appPermissions),
2216
+ repositories: [repository.name]
1989
2217
  });
1990
2218
  }
1991
2219
  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.92.1",
3
+ "version": "0.94.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.92.1"
28
+ "@sentry/junior-plugin-api": "0.94.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,11 @@ 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. Retry once on permission failure after verifying repo targeting. Treat merge, close-with-delete, and force-push as confirmation-required. No admin mutations.
164
+ **PR mutation** — push before create. Use only the allowlisted REST endpoints in the API reference. Merge, close-with-delete, workflow dispatch, 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.
163
165
 
164
166
  ## Guardrails
165
167
 
166
168
  - Default shallow clones; deepen only when needed.
167
- - Confirm before destructive merges or force operations.
169
+ - Do not attempt destructive merges, force pushes, or ref deletion.
168
170
  - Answer source questions from repo evidence, not product framing or memory.
169
171
  - 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. Other operations use `gh` CLI and must be deterministic and non-interactive.
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,14 @@ 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 | 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`, `gh run rerun`, `gh run cancel` |
17
- | `github.contents.read` | `gh repo clone`, `git fetch` |
18
- | `github.contents.write` | `git push`, REST Git database writes, `gh api` create/update contents, `gh pr merge` |
19
- | `github.workflows.write` | Workflow-file changes under `.github/workflows` |
20
- | `github.pull-requests.read` | `gh pr view`, `gh pr list`, `gh pr diff`, `gh pr checks` |
21
- | `github.pull-requests.write` | `github_createPullRequest` after explicit push, `gh pr edit`, `gh pr close` |
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.contents.read` | `gh repo clone`, `git fetch` |
17
+ | `github.contents.write` | Git smart-HTTP `git push` only |
18
+ | `github.workflows.write` | Workflow-file changes carried by Git smart-HTTP push |
19
+ | `github.pull-requests.read` | `gh pr view`, `gh pr list`, `gh pr diff`, `gh pr checks` |
20
+ | `github.pull-requests.write` | Typed PR creation and allowlisted REST PR lifecycle endpoints |
23
21
 
24
22
  ## Command matrix
25
23
 
@@ -37,16 +35,18 @@ Treat explicit repo flags as command-targeting safety rails, not as a credential
37
35
  | Stage and commit | `git -C DIRECTORY add -A && git -C DIRECTORY commit -m "message"` |
38
36
  | Push branch before PR creation | `git -C DIRECTORY push -u origin BRANCH` |
39
37
  | Create pull request (draft) | `github_createPullRequest({ repo: "owner/repo", head: "BRANCH", base: "BASE", title: "...", body: "...", draft: true })` |
40
- | Update pull request | `gh pr edit NUMBER --repo owner/repo [--title "..."] [--body-file PATH]` |
41
- | Close pull request | `gh pr close NUMBER --repo owner/repo` |
42
- | Merge pull request | `gh pr merge NUMBER --repo owner/repo [--merge \| --squash \| --rebase]` |
38
+ | Update pull request | `gh api repos/owner/repo/pulls/NUMBER --method PATCH --input payload.json` |
39
+ | Mark ready for review | `gh api repos/owner/repo/pulls/NUMBER/ready_for_review --method POST` |
40
+ | Request reviewers | `gh api repos/owner/repo/pulls/NUMBER/requested_reviewers --method POST --input reviewers.json` |
41
+ | Remove requested reviewers | `gh api repos/owner/repo/pulls/NUMBER/requested_reviewers --method DELETE --input reviewers.json` |
42
+ | Close pull request | `gh api repos/owner/repo/pulls/NUMBER --method PATCH -f state=closed` |
43
+ | Submit human review | `gh api repos/owner/repo/pulls/NUMBER/reviews --method POST --input review.json` |
43
44
  | View pull request | `gh pr view NUMBER --repo owner/repo [--json ...]` |
44
45
  | List pull requests | `gh pr list --repo owner/repo [--state open \| closed \| merged]` |
45
46
  | Diff pull request | `gh pr diff NUMBER --repo owner/repo` |
46
47
  | Check pull request status | `gh pr checks NUMBER --repo owner/repo` |
47
48
  | View PR review comments | `gh api repos/{owner}/{repo}/pulls/{number}/comments` |
48
49
  | 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
50
  | List workflow runs | `gh run list -R owner/repo --workflow WORKFLOW [--limit N] [--json ...]` |
51
51
  | View workflow run | `gh run view RUN_ID -R owner/repo [--json ...] [--log-failed]` |
52
52
  | Watch workflow run | `gh run watch RUN_ID -R owner/repo --exit-status` |
@@ -62,12 +62,10 @@ jr-rpc config set github.repo owner/repo
62
62
 
63
63
  - Prefer `--json` output for machine-readable parsing where available.
64
64
  - 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 does: `git push` requires `github.contents.write` on the target repo and actor write access.
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}`.
65
+ - 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
66
  - If the commit changes workflow files under `.github/workflows`, expect `github.workflows.write` in addition to contents write.
68
67
  - 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
- - Do not use fork creation as the normal PR path. GitHub requires Administration write plus Contents read for `POST /repos/{owner}/{repo}/forks`, and the app must be installed on both source and destination accounts.
68
+ - Merge, fork creation, workflow dispatch, REST contents/Git database writes, and repository administration are outside the current write allowlist.
70
69
  - 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
- - `gh pr edit` is not a single-permission command: title/body/base/reviewer changes need pull request write permission; label, assignee, and milestone changes need issue write permission (use the `github-issues` skill); project flags are outside the current GitHub App permission guidance.
72
- - `gh pr close --comment` may need `github.issues.write` (use `github-issues`); `gh pr close --delete-branch` needs `github.contents.write`.
70
+ - 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
71
  - 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 write request. | Follow the private OAuth prompt; do not ask the user to paste or manage tokens manually. |
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; treat `installation-read` failures as app installation or host environment setup.
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
- - GitHub records the issue creator natively; do not add body or footer text to identify who asked Junior to create the issue.
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` CLI and must be deterministic and non-interactive.
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`, `gh issue edit`, `gh issue comment`, `gh issue close`, `gh issue reopen` |
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 issue edit NUMBER --repo owner/repo [--title "..."] [--body-file PATH]` |
24
- | Close issue | `gh issue close NUMBER --repo owner/repo [--comment "..."]` |
25
- | Reopen issue | `gh issue reopen NUMBER --repo owner/repo` |
26
- | Add labels | `gh issue edit NUMBER --repo owner/repo --add-label LABEL [--add-label LABEL2]` |
27
- | Remove labels | `gh issue edit NUMBER --repo owner/repo --remove-label LABEL [--remove-label LABEL2]` |
28
- | Add comment | `gh issue comment NUMBER --repo owner/repo --body-file PATH` |
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 write request. | Follow the private OAuth prompt; do not ask the user to paste or manage tokens manually. |
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
- | `gh issue edit` does not change labels | Wrong flag usage or wrong repo context. | Use repeated `--add-label/--remove-label` flags and keep `--repo owner/repo` explicit. |
16
- | Comment command fails with empty body | Body file missing/empty. | Ensure comment file exists and has content before `gh issue comment`. |
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; treat `installation-read` failures as app installation or host environment setup.
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.