@sentry/junior-github 0.160.0 → 0.161.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,3 +29,5 @@ The plugin owns its signed webhook route, deployment, pull request, and release
29
29
  resource events, normalized pull request and issue outcome projections, and
30
30
  dashboard operational report. Core only owns delivery from plugin-published
31
31
  resource events into matching conversation subscriptions.
32
+
33
+ Person profiles call `hooks.profileReport` for Junior-owned GitHub work attributed through conversation actors.
@@ -0,0 +1,3 @@
1
+ import type { ConversationAnnotation, ConversationSidebarAnnotation } from "@sentry/junior-plugin-api";
2
+ /** Select the one GitHub annotation summary shown in a conversation row. */
3
+ export declare function githubSidebarAnnotation(annotations: ConversationAnnotation[]): ConversationSidebarAnnotation | undefined;
package/dist/index.js CHANGED
@@ -3398,20 +3398,258 @@ function createGitHubWebhookRoute(args) {
3398
3398
  };
3399
3399
  }
3400
3400
 
3401
- // src/outcomes/report.ts
3402
- import { sql as sql5 } from "drizzle-orm";
3403
- import { z as z16 } from "zod";
3404
-
3405
- // src/outcomes/cost.ts
3401
+ // src/outcomes/profile-report.ts
3406
3402
  import { sql as sql4 } from "drizzle-orm";
3407
3403
  import { z as z15 } from "zod";
3408
3404
  var DAY_MS = 24 * 60 * 60 * 1e3;
3409
- var costWindowSchema = z15.object({
3405
+ var WINDOWS = [7, 30, 90];
3406
+ var pullRequestStatsSchema = z15.object({
3407
+ closed: z15.number().int().nonnegative(),
3408
+ created: z15.number().int().nonnegative(),
3410
3409
  days: z15.number().int().positive(),
3411
- issueCostUsd: z15.number().nonnegative().nullable(),
3412
- medianIssueCostUsd: z15.number().nonnegative().nullable(),
3413
- medianPullRequestCostUsd: z15.number().nonnegative().nullable(),
3414
- pullRequestCostUsd: z15.number().nonnegative().nullable()
3410
+ merged: z15.number().int().nonnegative()
3411
+ }).strict().transform((row) => {
3412
+ const terminal = row.merged + row.closed;
3413
+ return {
3414
+ ...row,
3415
+ mergeRate: terminal > 0 ? row.merged / terminal : void 0
3416
+ };
3417
+ });
3418
+ var issueStatsSchema = z15.object({
3419
+ created: z15.number().int().nonnegative(),
3420
+ days: z15.number().int().positive()
3421
+ }).strict();
3422
+ var daySchema = z15.object({
3423
+ created: z15.number().int().nonnegative(),
3424
+ date: z15.string().date()
3425
+ }).strict();
3426
+ function queryRows(result) {
3427
+ if (typeof result !== "object" || result === null || !("rows" in result) || !Array.isArray(result.rows)) {
3428
+ throw new TypeError("GitHub profile report query did not return rows");
3429
+ }
3430
+ return result.rows;
3431
+ }
3432
+ function ownedByUserSql(conversationIds, userId) {
3433
+ return sql4`EXISTS (
3434
+ SELECT 1
3435
+ FROM unnest(${conversationIds}) AS linked(conversation_id)
3436
+ INNER JOIN junior_conversations AS conversations
3437
+ ON conversations.conversation_id = linked.conversation_id
3438
+ INNER JOIN junior_identities AS identities
3439
+ ON identities.id = conversations.actor_identity_id
3440
+ WHERE identities.user_id = ${userId}
3441
+ )`;
3442
+ }
3443
+ function formatPercent(value) {
3444
+ return value === void 0 ? "\u2014" : `${Math.round(value * 100)}%`;
3445
+ }
3446
+ function startOfUtcDay(timestampMs) {
3447
+ const date = new Date(timestampMs);
3448
+ date.setUTCHours(0, 0, 0, 0);
3449
+ return date;
3450
+ }
3451
+ async function aggregatePullRequestWindows(args) {
3452
+ const starts = WINDOWS.map(
3453
+ (days) => [days, new Date(args.nowMs - days * DAY_MS)]
3454
+ );
3455
+ const oldestStart = starts.at(-1)[1];
3456
+ const table = juniorGitHubPullRequests;
3457
+ const owned = ownedByUserSql(sql4`${table.conversationIds}`, args.userId);
3458
+ const result = await args.db.execute(sql4`
3459
+ WITH windows(days, start_at) AS (
3460
+ VALUES
3461
+ (${starts[0][0]}::integer, ${starts[0][1]}::timestamptz),
3462
+ (${starts[1][0]}::integer, ${starts[1][1]}::timestamptz),
3463
+ (${starts[2][0]}::integer, ${starts[2][1]}::timestamptz)
3464
+ ), recent_pull_requests AS MATERIALIZED (
3465
+ SELECT
3466
+ ${table.pullRequestId},
3467
+ ${table.state},
3468
+ ${table.openedAt},
3469
+ ${table.mergedAt},
3470
+ ${table.closedAt}
3471
+ FROM ${table}
3472
+ WHERE (${table.openedAt} >= ${oldestStart}
3473
+ OR ${table.mergedAt} >= ${oldestStart}
3474
+ OR ${table.closedAt} >= ${oldestStart})
3475
+ AND ${owned}
3476
+ )
3477
+ SELECT
3478
+ windows.days AS "days",
3479
+ count(recent_pull_requests.pull_request_id)
3480
+ FILTER (WHERE recent_pull_requests.opened_at >= windows.start_at)::integer
3481
+ AS "created",
3482
+ count(recent_pull_requests.pull_request_id)
3483
+ FILTER (
3484
+ WHERE recent_pull_requests.state = 'merged'
3485
+ AND recent_pull_requests.merged_at >= windows.start_at
3486
+ )::integer AS "merged",
3487
+ count(recent_pull_requests.pull_request_id)
3488
+ FILTER (
3489
+ WHERE recent_pull_requests.state = 'closed_unmerged'
3490
+ AND recent_pull_requests.closed_at >= windows.start_at
3491
+ )::integer AS "closed"
3492
+ FROM windows
3493
+ LEFT JOIN recent_pull_requests ON true
3494
+ GROUP BY windows.days
3495
+ ORDER BY windows.days
3496
+ `);
3497
+ return z15.array(pullRequestStatsSchema).parse(queryRows(result));
3498
+ }
3499
+ async function aggregateIssueWindows(args) {
3500
+ const starts = WINDOWS.map(
3501
+ (days) => [days, new Date(args.nowMs - days * DAY_MS)]
3502
+ );
3503
+ const oldestStart = starts.at(-1)[1];
3504
+ const table = juniorGitHubIssues;
3505
+ const owned = ownedByUserSql(sql4`${table.conversationIds}`, args.userId);
3506
+ const result = await args.db.execute(sql4`
3507
+ WITH windows(days, start_at) AS (
3508
+ VALUES
3509
+ (${starts[0][0]}::integer, ${starts[0][1]}::timestamptz),
3510
+ (${starts[1][0]}::integer, ${starts[1][1]}::timestamptz),
3511
+ (${starts[2][0]}::integer, ${starts[2][1]}::timestamptz)
3512
+ ), recent_issues AS MATERIALIZED (
3513
+ SELECT
3514
+ ${table.issueId},
3515
+ ${table.openedAt}
3516
+ FROM ${table}
3517
+ WHERE ${table.openedAt} >= ${oldestStart}
3518
+ AND ${owned}
3519
+ )
3520
+ SELECT
3521
+ windows.days AS "days",
3522
+ count(recent_issues.issue_id)
3523
+ FILTER (WHERE recent_issues.opened_at >= windows.start_at)::integer
3524
+ AS "created"
3525
+ FROM windows
3526
+ LEFT JOIN recent_issues ON true
3527
+ GROUP BY windows.days
3528
+ ORDER BY windows.days
3529
+ `);
3530
+ return z15.array(issueStatsSchema).parse(queryRows(result));
3531
+ }
3532
+ async function aggregateOpenedDays(args) {
3533
+ const end = new Date(args.nowMs);
3534
+ const start = startOfUtcDay(args.nowMs - (WINDOWS.at(-1) - 1) * DAY_MS);
3535
+ const table = args.table;
3536
+ const owned = ownedByUserSql(sql4`${table.conversationIds}`, args.userId);
3537
+ const result = await args.db.execute(sql4`
3538
+ WITH days AS (
3539
+ SELECT generate_series(
3540
+ date_trunc('day', ${start}::timestamptz AT TIME ZONE 'UTC'),
3541
+ date_trunc('day', ${end}::timestamptz AT TIME ZONE 'UTC'),
3542
+ interval '1 day'
3543
+ ) AS day
3544
+ ), daily AS (
3545
+ SELECT
3546
+ date_trunc('day', ${table.openedAt} AT TIME ZONE 'UTC') AS day,
3547
+ count(*)::integer AS created
3548
+ FROM ${table}
3549
+ WHERE ${table.openedAt} >= ${start}
3550
+ AND ${owned}
3551
+ GROUP BY date_trunc('day', ${table.openedAt} AT TIME ZONE 'UTC')
3552
+ )
3553
+ SELECT
3554
+ to_char(days.day, 'YYYY-MM-DD') AS "date",
3555
+ coalesce(daily.created, 0)::integer AS "created"
3556
+ FROM days
3557
+ LEFT JOIN daily ON daily.day = days.day
3558
+ ORDER BY days.day
3559
+ `);
3560
+ return z15.array(daySchema).parse(queryRows(result));
3561
+ }
3562
+ async function buildGitHubProfileReport(args) {
3563
+ const [windows, pullRequestDays, issueWindows, issueDays] = await Promise.all(
3564
+ [
3565
+ aggregatePullRequestWindows(args),
3566
+ aggregateOpenedDays({
3567
+ db: args.db,
3568
+ nowMs: args.nowMs,
3569
+ table: juniorGitHubPullRequests,
3570
+ userId: args.userId
3571
+ }),
3572
+ aggregateIssueWindows(args),
3573
+ aggregateOpenedDays({
3574
+ db: args.db,
3575
+ nowMs: args.nowMs,
3576
+ table: juniorGitHubIssues,
3577
+ userId: args.userId
3578
+ })
3579
+ ]
3580
+ );
3581
+ const thirtyDays = windows.find((window) => window.days === 30);
3582
+ const issueThirtyDays = issueWindows.find((window) => window.days === 30);
3583
+ const hasActivity = windows.some((window) => window.created + window.merged + window.closed > 0) || issueWindows.some((window) => window.created > 0);
3584
+ if (!hasActivity) {
3585
+ return void 0;
3586
+ }
3587
+ return {
3588
+ generatedAt: new Date(args.nowMs).toISOString(),
3589
+ title: "GitHub",
3590
+ metrics: [
3591
+ {
3592
+ label: "PRs opened \xB7 30d",
3593
+ value: String(thirtyDays.created)
3594
+ },
3595
+ {
3596
+ label: "PRs merged \xB7 30d",
3597
+ value: String(thirtyDays.merged)
3598
+ },
3599
+ {
3600
+ label: "Issues opened \xB7 30d",
3601
+ value: String(issueThirtyDays.created)
3602
+ },
3603
+ {
3604
+ label: "PR merge rate \xB7 30d",
3605
+ value: formatPercent(thirtyDays.mergeRate)
3606
+ }
3607
+ ],
3608
+ widgets: [
3609
+ {
3610
+ id: "pull-requests-created",
3611
+ type: "bar_chart",
3612
+ title: "Pull requests opened",
3613
+ description: "Junior-owned pull requests opened for this person per day",
3614
+ timeRangeDays: [...WINDOWS],
3615
+ series: [{ key: "created", label: "Opened" }],
3616
+ categories: pullRequestDays.map((stats) => ({
3617
+ id: stats.date,
3618
+ label: stats.date,
3619
+ values: { created: stats.created }
3620
+ }))
3621
+ },
3622
+ {
3623
+ id: "issues-created",
3624
+ type: "bar_chart",
3625
+ title: "Issues opened",
3626
+ description: "Junior-owned issues opened for this person per day",
3627
+ timeRangeDays: [...WINDOWS],
3628
+ series: [{ key: "created", label: "Opened" }],
3629
+ categories: issueDays.map((stats) => ({
3630
+ id: stats.date,
3631
+ label: stats.date,
3632
+ values: { created: stats.created }
3633
+ }))
3634
+ }
3635
+ ]
3636
+ };
3637
+ }
3638
+
3639
+ // src/outcomes/report.ts
3640
+ import { sql as sql6 } from "drizzle-orm";
3641
+ import { z as z17 } from "zod";
3642
+
3643
+ // src/outcomes/cost.ts
3644
+ import { sql as sql5 } from "drizzle-orm";
3645
+ import { z as z16 } from "zod";
3646
+ var DAY_MS2 = 24 * 60 * 60 * 1e3;
3647
+ var costWindowSchema = z16.object({
3648
+ days: z16.number().int().positive(),
3649
+ issueCostUsd: z16.number().nonnegative().nullable(),
3650
+ medianIssueCostUsd: z16.number().nonnegative().nullable(),
3651
+ medianPullRequestCostUsd: z16.number().nonnegative().nullable(),
3652
+ pullRequestCostUsd: z16.number().nonnegative().nullable()
3415
3653
  }).strict().transform((row) => ({
3416
3654
  days: row.days,
3417
3655
  issueCostUsd: row.issueCostUsd ?? void 0,
@@ -3419,12 +3657,12 @@ var costWindowSchema = z15.object({
3419
3657
  medianPullRequestCostUsd: row.medianPullRequestCostUsd ?? void 0,
3420
3658
  pullRequestCostUsd: row.pullRequestCostUsd ?? void 0
3421
3659
  }));
3422
- var repositoryCostSchema = z15.object({
3423
- issueCostUsd: z15.number().nonnegative().nullable(),
3424
- medianIssueCostUsd: z15.number().nonnegative().nullable(),
3425
- medianPullRequestCostUsd: z15.number().nonnegative().nullable(),
3426
- pullRequestCostUsd: z15.number().nonnegative().nullable(),
3427
- repository: z15.string().min(1)
3660
+ var repositoryCostSchema = z16.object({
3661
+ issueCostUsd: z16.number().nonnegative().nullable(),
3662
+ medianIssueCostUsd: z16.number().nonnegative().nullable(),
3663
+ medianPullRequestCostUsd: z16.number().nonnegative().nullable(),
3664
+ pullRequestCostUsd: z16.number().nonnegative().nullable(),
3665
+ repository: z16.string().min(1)
3428
3666
  }).strict().transform((row) => ({
3429
3667
  issueCostUsd: row.issueCostUsd ?? void 0,
3430
3668
  medianIssueCostUsd: row.medianIssueCostUsd ?? void 0,
@@ -3432,17 +3670,17 @@ var repositoryCostSchema = z15.object({
3432
3670
  pullRequestCostUsd: row.pullRequestCostUsd ?? void 0,
3433
3671
  repository: row.repository
3434
3672
  }));
3435
- function queryRows(result) {
3673
+ function queryRows2(result) {
3436
3674
  if (typeof result !== "object" || result === null || !("rows" in result) || !Array.isArray(result.rows)) {
3437
3675
  throw new TypeError("GitHub cost query did not return rows");
3438
3676
  }
3439
3677
  return result.rows;
3440
3678
  }
3441
3679
  async function hasConversationUsageTable(db) {
3442
- const result = await db.execute(sql4`
3680
+ const result = await db.execute(sql5`
3443
3681
  SELECT to_regclass('public.junior_conversations') IS NOT NULL AS "present"
3444
3682
  `);
3445
- const row = queryRows(result)[0];
3683
+ const row = queryRows2(result)[0];
3446
3684
  return typeof row === "object" && row !== null && "present" in row && row.present === true;
3447
3685
  }
3448
3686
  function emptyCostWindows(windows) {
@@ -3455,7 +3693,7 @@ function emptyCostWindows(windows) {
3455
3693
  }));
3456
3694
  }
3457
3695
  function conversationTreeCostExpr() {
3458
- return sql4`
3696
+ return sql5`
3459
3697
  coalesce((
3460
3698
  SELECT sum(
3461
3699
  CASE
@@ -3486,7 +3724,7 @@ function conversationTreeCostExpr() {
3486
3724
  function pullRequestConversationIdsExpr() {
3487
3725
  const pullRequests = juniorGitHubPullRequests;
3488
3726
  const issues = juniorGitHubIssues;
3489
- return sql4`
3727
+ return sql5`
3490
3728
  ARRAY(
3491
3729
  SELECT DISTINCT unnest(
3492
3730
  ${pullRequests.conversationIds}
@@ -3508,7 +3746,7 @@ function pullRequestConversationIdsExpr() {
3508
3746
  function issueConversationIdsExpr() {
3509
3747
  const pullRequests = juniorGitHubPullRequests;
3510
3748
  const issues = juniorGitHubIssues;
3511
- return sql4`
3749
+ return sql5`
3512
3750
  ARRAY(
3513
3751
  SELECT DISTINCT unnest(
3514
3752
  ${issues.conversationIds}
@@ -3532,21 +3770,21 @@ async function aggregateGitHubCostWindows(args) {
3532
3770
  return emptyCostWindows(args.windows);
3533
3771
  }
3534
3772
  const starts = args.windows.map(
3535
- (days) => [days, new Date(args.nowMs - days * DAY_MS)]
3773
+ (days) => [days, new Date(args.nowMs - days * DAY_MS2)]
3536
3774
  );
3537
3775
  const oldestStart = starts.at(-1)[1];
3538
3776
  const pullRequests = juniorGitHubPullRequests;
3539
3777
  const issues = juniorGitHubIssues;
3540
- const windowValues = sql4.join(
3778
+ const windowValues = sql5.join(
3541
3779
  starts.map(
3542
- ([days, start]) => sql4`(${days}::integer, ${start}::timestamptz)`
3780
+ ([days, start]) => sql5`(${days}::integer, ${start}::timestamptz)`
3543
3781
  ),
3544
- sql4`, `
3782
+ sql5`, `
3545
3783
  );
3546
3784
  const conversationTreeCost = conversationTreeCostExpr();
3547
3785
  const pullRequestConversationIds = pullRequestConversationIdsExpr();
3548
3786
  const issueConversationIds = issueConversationIdsExpr();
3549
- const result = await args.db.execute(sql4`
3787
+ const result = await args.db.execute(sql5`
3550
3788
  WITH windows(days, start_at) AS (
3551
3789
  VALUES ${windowValues}
3552
3790
  ), pull_request_entities AS (
@@ -3629,19 +3867,19 @@ async function aggregateGitHubCostWindows(args) {
3629
3867
  INNER JOIN issue_window ON issue_window.days = pull_request_window.days
3630
3868
  ORDER BY pull_request_window.days
3631
3869
  `);
3632
- return z15.array(costWindowSchema).parse(queryRows(result));
3870
+ return z16.array(costWindowSchema).parse(queryRows2(result));
3633
3871
  }
3634
3872
  async function aggregateGitHubRepositoryCosts(args) {
3635
3873
  if (!await hasConversationUsageTable(args.db)) {
3636
3874
  return [];
3637
3875
  }
3638
- const start = new Date(args.nowMs - 30 * DAY_MS);
3876
+ const start = new Date(args.nowMs - 30 * DAY_MS2);
3639
3877
  const pullRequests = juniorGitHubPullRequests;
3640
3878
  const issues = juniorGitHubIssues;
3641
3879
  const conversationTreeCost = conversationTreeCostExpr();
3642
3880
  const pullRequestConversationIds = pullRequestConversationIdsExpr();
3643
3881
  const issueConversationIds = issueConversationIdsExpr();
3644
- const result = await args.db.execute(sql4`
3882
+ const result = await args.db.execute(sql5`
3645
3883
  WITH pull_request_entities AS (
3646
3884
  SELECT
3647
3885
  ${pullRequests.repositoryFullName} AS repository,
@@ -3727,7 +3965,7 @@ async function aggregateGitHubRepositoryCosts(args) {
3727
3965
  ON issue_totals.repository = repositories.repository
3728
3966
  ORDER BY "repository" ASC
3729
3967
  `);
3730
- return z15.array(repositoryCostSchema).parse(queryRows(result));
3968
+ return z16.array(repositoryCostSchema).parse(queryRows2(result));
3731
3969
  }
3732
3970
  function formatCostUsd(value) {
3733
3971
  if (value === void 0) return "\u2014";
@@ -3740,14 +3978,14 @@ function formatCostUsd(value) {
3740
3978
  }
3741
3979
 
3742
3980
  // src/outcomes/report.ts
3743
- var DAY_MS2 = 24 * 60 * 60 * 1e3;
3744
- var WINDOWS = [7, 30, 90];
3745
- var pullRequestStatsSchema = z16.object({
3746
- closed: z16.number().int().nonnegative(),
3747
- created: z16.number().int().nonnegative(),
3748
- days: z16.number().int().positive(),
3749
- medianMergeTimeMs: z16.number().nonnegative().nullable(),
3750
- merged: z16.number().int().nonnegative()
3981
+ var DAY_MS3 = 24 * 60 * 60 * 1e3;
3982
+ var WINDOWS2 = [7, 30, 90];
3983
+ var pullRequestStatsSchema2 = z17.object({
3984
+ closed: z17.number().int().nonnegative(),
3985
+ created: z17.number().int().nonnegative(),
3986
+ days: z17.number().int().positive(),
3987
+ medianMergeTimeMs: z17.number().nonnegative().nullable(),
3988
+ merged: z17.number().int().nonnegative()
3751
3989
  }).strict().transform((row) => {
3752
3990
  const terminal = row.merged + row.closed;
3753
3991
  return {
@@ -3756,12 +3994,12 @@ var pullRequestStatsSchema = z16.object({
3756
3994
  mergeRate: terminal > 0 ? row.merged / terminal : void 0
3757
3995
  };
3758
3996
  });
3759
- var pullRequestRepositoryStatsSchema = z16.object({
3760
- closed: z16.number().int().nonnegative(),
3761
- created: z16.number().int().nonnegative(),
3762
- juniorOnly: z16.number().int().nonnegative(),
3763
- merged: z16.number().int().nonnegative(),
3764
- repository: z16.string().min(1)
3997
+ var pullRequestRepositoryStatsSchema = z17.object({
3998
+ closed: z17.number().int().nonnegative(),
3999
+ created: z17.number().int().nonnegative(),
4000
+ juniorOnly: z17.number().int().nonnegative(),
4001
+ merged: z17.number().int().nonnegative(),
4002
+ repository: z17.string().min(1)
3765
4003
  }).strict().transform((row) => {
3766
4004
  const terminal = row.merged + row.closed;
3767
4005
  return {
@@ -3769,47 +4007,47 @@ var pullRequestRepositoryStatsSchema = z16.object({
3769
4007
  mergeRate: terminal > 0 ? row.merged / terminal : void 0
3770
4008
  };
3771
4009
  });
3772
- var issueStatsSchema = z16.object({
3773
- closedCompleted: z16.number().int().nonnegative(),
3774
- closedDuplicate: z16.number().int().nonnegative(),
3775
- closedNotPlanned: z16.number().int().nonnegative(),
3776
- closedUnknown: z16.number().int().nonnegative(),
3777
- created: z16.number().int().nonnegative(),
3778
- days: z16.number().int().positive(),
3779
- medianCloseTimeMs: z16.number().nonnegative().nullable()
4010
+ var issueStatsSchema2 = z17.object({
4011
+ closedCompleted: z17.number().int().nonnegative(),
4012
+ closedDuplicate: z17.number().int().nonnegative(),
4013
+ closedNotPlanned: z17.number().int().nonnegative(),
4014
+ closedUnknown: z17.number().int().nonnegative(),
4015
+ created: z17.number().int().nonnegative(),
4016
+ days: z17.number().int().positive(),
4017
+ medianCloseTimeMs: z17.number().nonnegative().nullable()
3780
4018
  }).strict().transform((row) => ({
3781
4019
  ...row,
3782
4020
  medianCloseTimeMs: row.medianCloseTimeMs ?? void 0
3783
4021
  }));
3784
- var pullRequestDaySchema = z16.object({
3785
- created: z16.number().int().nonnegative(),
3786
- date: z16.string().date()
4022
+ var pullRequestDaySchema = z17.object({
4023
+ created: z17.number().int().nonnegative(),
4024
+ date: z17.string().date()
3787
4025
  }).strict();
3788
- var issueDaySchema = z16.object({
3789
- created: z16.number().int().nonnegative(),
3790
- date: z16.string().date()
4026
+ var issueDaySchema = z17.object({
4027
+ created: z17.number().int().nonnegative(),
4028
+ date: z17.string().date()
3791
4029
  }).strict();
3792
- var issueRepositoryStatsSchema = z16.object({
3793
- closedCompleted: z16.number().int().nonnegative(),
3794
- closedDuplicate: z16.number().int().nonnegative(),
3795
- closedNotPlanned: z16.number().int().nonnegative(),
3796
- closedUnknown: z16.number().int().nonnegative(),
3797
- created: z16.number().int().nonnegative(),
3798
- repository: z16.string().min(1)
4030
+ var issueRepositoryStatsSchema = z17.object({
4031
+ closedCompleted: z17.number().int().nonnegative(),
4032
+ closedDuplicate: z17.number().int().nonnegative(),
4033
+ closedNotPlanned: z17.number().int().nonnegative(),
4034
+ closedUnknown: z17.number().int().nonnegative(),
4035
+ created: z17.number().int().nonnegative(),
4036
+ repository: z17.string().min(1)
3799
4037
  }).strict();
3800
- function queryRows2(result) {
4038
+ function queryRows3(result) {
3801
4039
  if (typeof result !== "object" || result === null || !("rows" in result) || !Array.isArray(result.rows)) {
3802
4040
  throw new TypeError("GitHub outcome query did not return rows");
3803
4041
  }
3804
4042
  return result.rows;
3805
4043
  }
3806
- async function aggregatePullRequestWindows(args) {
3807
- const starts = WINDOWS.map(
3808
- (days) => [days, new Date(args.nowMs - days * DAY_MS2)]
4044
+ async function aggregatePullRequestWindows2(args) {
4045
+ const starts = WINDOWS2.map(
4046
+ (days) => [days, new Date(args.nowMs - days * DAY_MS3)]
3809
4047
  );
3810
4048
  const oldestStart = starts.at(-1)[1];
3811
4049
  const table = juniorGitHubPullRequests;
3812
- const result = await args.db.execute(sql5`
4050
+ const result = await args.db.execute(sql6`
3813
4051
  WITH windows(days, start_at) AS (
3814
4052
  VALUES
3815
4053
  (${starts[0][0]}::integer, ${starts[0][1]}::timestamptz),
@@ -3859,13 +4097,13 @@ async function aggregatePullRequestWindows(args) {
3859
4097
  GROUP BY windows.days
3860
4098
  ORDER BY windows.days
3861
4099
  `);
3862
- return z16.array(pullRequestStatsSchema).parse(queryRows2(result));
4100
+ return z17.array(pullRequestStatsSchema2).parse(queryRows3(result));
3863
4101
  }
3864
4102
  async function aggregatePullRequestDays(args) {
3865
4103
  const end = new Date(args.nowMs);
3866
- const start = startOfUtcDay(args.nowMs - (WINDOWS.at(-1) - 1) * DAY_MS2);
4104
+ const start = startOfUtcDay2(args.nowMs - (WINDOWS2.at(-1) - 1) * DAY_MS3);
3867
4105
  const table = juniorGitHubPullRequests;
3868
- const result = await args.db.execute(sql5`
4106
+ const result = await args.db.execute(sql6`
3869
4107
  WITH days AS (
3870
4108
  SELECT generate_series(
3871
4109
  date_trunc('day', ${start}::timestamptz AT TIME ZONE 'UTC'),
@@ -3887,12 +4125,12 @@ async function aggregatePullRequestDays(args) {
3887
4125
  LEFT JOIN daily ON daily.day = days.day
3888
4126
  ORDER BY days.day
3889
4127
  `);
3890
- return z16.array(pullRequestDaySchema).parse(queryRows2(result));
4128
+ return z17.array(pullRequestDaySchema).parse(queryRows3(result));
3891
4129
  }
3892
4130
  async function aggregatePullRequestRepositories(args) {
3893
- const start = new Date(args.nowMs - 30 * DAY_MS2);
4131
+ const start = new Date(args.nowMs - 30 * DAY_MS3);
3894
4132
  const table = juniorGitHubPullRequests;
3895
- const result = await args.db.execute(sql5`
4133
+ const result = await args.db.execute(sql6`
3896
4134
  SELECT
3897
4135
  ${table.repositoryFullName} AS "repository",
3898
4136
  count(*) FILTER (WHERE ${table.openedAt} >= ${start})::integer
@@ -3917,15 +4155,15 @@ async function aggregatePullRequestRepositories(args) {
3917
4155
  ORDER BY "merged" DESC, "created" DESC, "repository" ASC
3918
4156
  LIMIT 25
3919
4157
  `);
3920
- return z16.array(pullRequestRepositoryStatsSchema).parse(queryRows2(result));
4158
+ return z17.array(pullRequestRepositoryStatsSchema).parse(queryRows3(result));
3921
4159
  }
3922
- async function aggregateIssueWindows(args) {
3923
- const starts = WINDOWS.map(
3924
- (days) => [days, new Date(args.nowMs - days * DAY_MS2)]
4160
+ async function aggregateIssueWindows2(args) {
4161
+ const starts = WINDOWS2.map(
4162
+ (days) => [days, new Date(args.nowMs - days * DAY_MS3)]
3925
4163
  );
3926
4164
  const oldestStart = starts.at(-1)[1];
3927
4165
  const table = juniorGitHubIssues;
3928
- const result = await args.db.execute(sql5`
4166
+ const result = await args.db.execute(sql6`
3929
4167
  WITH windows(days, start_at) AS (
3930
4168
  VALUES
3931
4169
  (${starts[0][0]}::integer, ${starts[0][1]}::timestamptz),
@@ -3986,13 +4224,13 @@ async function aggregateIssueWindows(args) {
3986
4224
  GROUP BY windows.days
3987
4225
  ORDER BY windows.days
3988
4226
  `);
3989
- return z16.array(issueStatsSchema).parse(queryRows2(result));
4227
+ return z17.array(issueStatsSchema2).parse(queryRows3(result));
3990
4228
  }
3991
4229
  async function aggregateIssueDays(args) {
3992
4230
  const end = new Date(args.nowMs);
3993
- const start = startOfUtcDay(args.nowMs - (WINDOWS.at(-1) - 1) * DAY_MS2);
4231
+ const start = startOfUtcDay2(args.nowMs - (WINDOWS2.at(-1) - 1) * DAY_MS3);
3994
4232
  const table = juniorGitHubIssues;
3995
- const result = await args.db.execute(sql5`
4233
+ const result = await args.db.execute(sql6`
3996
4234
  WITH days AS (
3997
4235
  SELECT generate_series(
3998
4236
  date_trunc('day', ${start}::timestamptz AT TIME ZONE 'UTC'),
@@ -4014,12 +4252,12 @@ async function aggregateIssueDays(args) {
4014
4252
  LEFT JOIN daily ON daily.day = days.day
4015
4253
  ORDER BY days.day
4016
4254
  `);
4017
- return z16.array(issueDaySchema).parse(queryRows2(result));
4255
+ return z17.array(issueDaySchema).parse(queryRows3(result));
4018
4256
  }
4019
4257
  async function aggregateIssueRepositories(args) {
4020
- const start = new Date(args.nowMs - 30 * DAY_MS2);
4258
+ const start = new Date(args.nowMs - 30 * DAY_MS3);
4021
4259
  const table = juniorGitHubIssues;
4022
- const result = await args.db.execute(sql5`
4260
+ const result = await args.db.execute(sql6`
4023
4261
  SELECT
4024
4262
  ${table.repositoryFullName} AS "repository",
4025
4263
  count(*) FILTER (WHERE ${table.openedAt} >= ${start})::integer
@@ -4051,9 +4289,9 @@ async function aggregateIssueRepositories(args) {
4051
4289
  ORDER BY "created" DESC, "closedCompleted" DESC, "repository" ASC
4052
4290
  LIMIT 25
4053
4291
  `);
4054
- return z16.array(issueRepositoryStatsSchema).parse(queryRows2(result));
4292
+ return z17.array(issueRepositoryStatsSchema).parse(queryRows3(result));
4055
4293
  }
4056
- function formatPercent(value) {
4294
+ function formatPercent2(value) {
4057
4295
  return value === void 0 ? "\u2014" : `${Math.round(value * 100)}%`;
4058
4296
  }
4059
4297
  function formatDuration(value) {
@@ -4063,7 +4301,7 @@ function formatDuration(value) {
4063
4301
  if (hours < 24) return `${Math.round(hours * 10) / 10}h`;
4064
4302
  return `${Math.round(hours / 24 * 10) / 10}d`;
4065
4303
  }
4066
- function startOfUtcDay(timestampMs) {
4304
+ function startOfUtcDay2(timestampMs) {
4067
4305
  const date = new Date(timestampMs);
4068
4306
  date.setUTCHours(0, 0, 0, 0);
4069
4307
  return date;
@@ -4079,13 +4317,13 @@ async function buildGitHubOutcomeReport(args) {
4079
4317
  costWindows,
4080
4318
  repositoryCosts
4081
4319
  ] = await Promise.all([
4082
- aggregatePullRequestWindows(args),
4320
+ aggregatePullRequestWindows2(args),
4083
4321
  aggregatePullRequestDays(args),
4084
4322
  aggregatePullRequestRepositories(args),
4085
- aggregateIssueWindows(args),
4323
+ aggregateIssueWindows2(args),
4086
4324
  aggregateIssueDays(args),
4087
4325
  aggregateIssueRepositories(args),
4088
- aggregateGitHubCostWindows({ ...args, windows: WINDOWS }),
4326
+ aggregateGitHubCostWindows({ ...args, windows: WINDOWS2 }),
4089
4327
  aggregateGitHubRepositoryCosts(args)
4090
4328
  ]);
4091
4329
  const thirtyDays = windows.find((window) => window.days === 30);
@@ -4100,7 +4338,7 @@ async function buildGitHubOutcomeReport(args) {
4100
4338
  metrics: [
4101
4339
  {
4102
4340
  label: "PR closure merge rate \xB7 30d",
4103
- value: formatPercent(thirtyDays.mergeRate)
4341
+ value: formatPercent2(thirtyDays.mergeRate)
4104
4342
  },
4105
4343
  {
4106
4344
  label: "Median PR merge time \xB7 merged in 30d",
@@ -4133,7 +4371,7 @@ async function buildGitHubOutcomeReport(args) {
4133
4371
  type: "bar_chart",
4134
4372
  title: "Pull requests created",
4135
4373
  description: "Junior-owned pull requests opened per day",
4136
- timeRangeDays: [...WINDOWS],
4374
+ timeRangeDays: [...WINDOWS2],
4137
4375
  series: [{ key: "created", label: "Created" }],
4138
4376
  categories: pullRequestDays.map((stats) => ({
4139
4377
  id: stats.date,
@@ -4146,7 +4384,7 @@ async function buildGitHubOutcomeReport(args) {
4146
4384
  type: "bar_chart",
4147
4385
  title: "Issues created",
4148
4386
  description: "Junior-owned issues opened per day",
4149
- timeRangeDays: [...WINDOWS],
4387
+ timeRangeDays: [...WINDOWS2],
4150
4388
  series: [{ key: "created", label: "Created" }],
4151
4389
  categories: issueDays.map((stats) => ({
4152
4390
  id: stats.date,
@@ -4176,7 +4414,7 @@ async function buildGitHubOutcomeReport(args) {
4176
4414
  merged: String(stats.merged),
4177
4415
  closed: String(stats.closed),
4178
4416
  juniorOnly: String(stats.juniorOnly),
4179
- mergeRate: formatPercent(stats.mergeRate),
4417
+ mergeRate: formatPercent2(stats.mergeRate),
4180
4418
  medianCost: formatCostUsd(
4181
4419
  repositoryCostByName.get(repository)?.medianPullRequestCostUsd
4182
4420
  )
@@ -4215,18 +4453,18 @@ async function buildGitHubOutcomeReport(args) {
4215
4453
  }
4216
4454
 
4217
4455
  // src/pull-request-outcomes/commit-composition.ts
4218
- import { z as z17 } from "zod";
4219
- var canonicalCommitSchema = z17.object({
4220
- authorEmail: z17.string().nullable(),
4221
- authorLogin: z17.string().nullable()
4456
+ import { z as z18 } from "zod";
4457
+ var canonicalCommitSchema = z18.object({
4458
+ authorEmail: z18.string().nullable(),
4459
+ authorLogin: z18.string().nullable()
4222
4460
  }).strict();
4223
- var providerCommitSchema = z17.object({
4224
- author: z17.object({ login: z17.string() }).passthrough().nullable(),
4225
- commit: z17.object({
4226
- author: z17.object({ email: z17.string() }).passthrough().nullable()
4461
+ var providerCommitSchema = z18.object({
4462
+ author: z18.object({ login: z18.string() }).passthrough().nullable(),
4463
+ commit: z18.object({
4464
+ author: z18.object({ email: z18.string() }).passthrough().nullable()
4227
4465
  }).passthrough()
4228
4466
  }).passthrough();
4229
- var commitPageSchema = z17.array(providerCommitSchema).transform(
4467
+ var commitPageSchema = z18.array(providerCommitSchema).transform(
4230
4468
  (commits) => commits.map(
4231
4469
  (commit) => canonicalCommitSchema.parse({
4232
4470
  authorEmail: commit.commit.author?.email ?? null,
@@ -4267,6 +4505,48 @@ async function classifyGitHubPullRequestCommitComposition(args) {
4267
4505
  return foundCommit ? "junior_only" : void 0;
4268
4506
  }
4269
4507
 
4508
+ // src/annotations.ts
4509
+ var STATUS_RANK = {
4510
+ warning: 5,
4511
+ open: 4,
4512
+ draft: 3,
4513
+ merged: 2,
4514
+ closed: 1
4515
+ };
4516
+ var STATUS_ICON = {
4517
+ warning: "triangle-alert",
4518
+ open: "circle-dot",
4519
+ draft: "circle-dashed",
4520
+ merged: "git-merge",
4521
+ closed: "circle-x"
4522
+ };
4523
+ function repositoryScope(annotation) {
4524
+ try {
4525
+ const [, owner, repo] = new URL(annotation.url).pathname.split("/");
4526
+ return owner && repo ? { key: `${owner}/${repo}`, label: repo } : void 0;
4527
+ } catch {
4528
+ return void 0;
4529
+ }
4530
+ }
4531
+ function githubSidebarAnnotation(annotations) {
4532
+ const links = annotations.flatMap((annotation) => {
4533
+ const repo = repositoryScope(annotation);
4534
+ const status2 = annotation.status;
4535
+ return repo && status2 ? [{ repo, status: status2 }] : [];
4536
+ });
4537
+ if (links.length === 0) return void 0;
4538
+ const repos = new Map(links.map((link) => [link.repo.key, link.repo.label]));
4539
+ const status = links.reduce(
4540
+ (current, link) => STATUS_RANK[link.status] > STATUS_RANK[current] ? link.status : current,
4541
+ "closed"
4542
+ );
4543
+ return {
4544
+ icon: STATUS_ICON[status],
4545
+ key: "github",
4546
+ label: repos.size === 1 ? [...repos.values()][0] : `${repos.size} repos`
4547
+ };
4548
+ }
4549
+
4270
4550
  // src/webhooks/check-suite-enrichment.ts
4271
4551
  function checkRunsFromResponse(value) {
4272
4552
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -4514,6 +4794,121 @@ async function configureGit(ctx, key, value) {
4514
4794
  }
4515
4795
  }
4516
4796
 
4797
+ // src/reply-markdown.ts
4798
+ var GITHUB_OWNER_PATTERN = "[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?";
4799
+ var GITHUB_REPOSITORY_PATTERN = "[A-Za-z0-9._-]+";
4800
+ var GITHUB_ISSUE_REFERENCE_PATTERN = new RegExp(
4801
+ `^(${GITHUB_OWNER_PATTERN})\\/(${GITHUB_REPOSITORY_PATTERN})#(\\d+)\\b`
4802
+ );
4803
+ function isReferenceBoundary(char) {
4804
+ return char === void 0 || !/[A-Za-z0-9._-]/.test(char);
4805
+ }
4806
+ function readInlineCode(text2, start) {
4807
+ if (text2[start] !== "`") {
4808
+ return void 0;
4809
+ }
4810
+ let markerLength = 1;
4811
+ while (text2[start + markerLength] === "`") {
4812
+ markerLength++;
4813
+ }
4814
+ const marker = "`".repeat(markerLength);
4815
+ const end = text2.indexOf(marker, start + markerLength);
4816
+ return end === -1 ? void 0 : end + markerLength;
4817
+ }
4818
+ function readFenceLength(line) {
4819
+ const trimmed = line.trimStart();
4820
+ if (!trimmed.startsWith("```")) {
4821
+ return void 0;
4822
+ }
4823
+ let length = 0;
4824
+ while (trimmed[length] === "`") {
4825
+ length++;
4826
+ }
4827
+ return length >= 3 ? length : void 0;
4828
+ }
4829
+ function readMarkdownLink(text2, start) {
4830
+ if (text2[start] !== "[") {
4831
+ return void 0;
4832
+ }
4833
+ const labelEnd = text2.indexOf("](", start + 1);
4834
+ if (labelEnd === -1) {
4835
+ return void 0;
4836
+ }
4837
+ const label = text2.slice(start + 1, labelEnd);
4838
+ if (label.includes("[") || label.includes("]") || label.includes("\n")) {
4839
+ return void 0;
4840
+ }
4841
+ const destStart = labelEnd + 2;
4842
+ const closeParens = text2.indexOf(")", destStart);
4843
+ return closeParens === -1 ? void 0 : closeParens + 1;
4844
+ }
4845
+ function readAngleToken(text2, start) {
4846
+ if (text2[start] !== "<") {
4847
+ return void 0;
4848
+ }
4849
+ const end = text2.indexOf(">", start + 1);
4850
+ return end === -1 ? void 0 : end + 1;
4851
+ }
4852
+ function linkifyLine(line) {
4853
+ let output = "";
4854
+ let index2 = 0;
4855
+ while (index2 < line.length) {
4856
+ const codeEnd = readInlineCode(line, index2);
4857
+ if (codeEnd !== void 0) {
4858
+ output += line.slice(index2, codeEnd);
4859
+ index2 = codeEnd;
4860
+ continue;
4861
+ }
4862
+ const markdownLinkEnd = readMarkdownLink(line, index2);
4863
+ if (markdownLinkEnd !== void 0) {
4864
+ output += line.slice(index2, markdownLinkEnd);
4865
+ index2 = markdownLinkEnd;
4866
+ continue;
4867
+ }
4868
+ const angleTokenEnd = readAngleToken(line, index2);
4869
+ if (angleTokenEnd !== void 0) {
4870
+ output += line.slice(index2, angleTokenEnd);
4871
+ index2 = angleTokenEnd;
4872
+ continue;
4873
+ }
4874
+ if (line.startsWith("https://", index2) || line.startsWith("http://", index2)) {
4875
+ const match = /^https?:\/\/\S+/.exec(line.slice(index2));
4876
+ if (match) {
4877
+ output += match[0];
4878
+ index2 += match[0].length;
4879
+ continue;
4880
+ }
4881
+ }
4882
+ if (isReferenceBoundary(index2 === 0 ? void 0 : line[index2 - 1])) {
4883
+ const match = GITHUB_ISSUE_REFERENCE_PATTERN.exec(line.slice(index2));
4884
+ if (match) {
4885
+ const [reference, owner, repository, number] = match;
4886
+ output += `[${reference}](https://github.com/${owner}/${repository}/issues/${number})`;
4887
+ index2 += reference.length;
4888
+ continue;
4889
+ }
4890
+ }
4891
+ output += line[index2];
4892
+ index2++;
4893
+ }
4894
+ return output;
4895
+ }
4896
+ function linkifyGitHubReferences(text2) {
4897
+ let openFenceLength = 0;
4898
+ return text2.split("\n").map((line) => {
4899
+ const fenceLength = readFenceLength(line);
4900
+ if (fenceLength !== void 0) {
4901
+ if (openFenceLength === 0) {
4902
+ openFenceLength = fenceLength;
4903
+ } else if (fenceLength >= openFenceLength) {
4904
+ openFenceLength = 0;
4905
+ }
4906
+ return line;
4907
+ }
4908
+ return openFenceLength > 0 ? line : linkifyLine(line);
4909
+ }).join("\n");
4910
+ }
4911
+
4517
4912
  // src/plugin.ts
4518
4913
  function githubSmartHttpAccess(upstreamUrl) {
4519
4914
  const pathname = upstreamUrl.pathname.toLowerCase();
@@ -4962,6 +5357,21 @@ function githubPlugin(options = {}) {
4962
5357
  ]
4963
5358
  },
4964
5359
  hooks: {
5360
+ conversationSidebar(ctx) {
5361
+ return {
5362
+ annotationsByConversationId: Object.fromEntries(
5363
+ ctx.conversationIds.flatMap((conversationId) => {
5364
+ const annotation = githubSidebarAnnotation(
5365
+ ctx.annotationsByConversationId[conversationId] ?? []
5366
+ );
5367
+ return annotation ? [[conversationId, [annotation]]] : [];
5368
+ })
5369
+ )
5370
+ };
5371
+ },
5372
+ formatMarkdown({ text: text2 }) {
5373
+ return linkifyGitHubReferences(text2);
5374
+ },
4965
5375
  async unfinishedWork(ctx) {
4966
5376
  const db = ctx.db;
4967
5377
  const [
@@ -5031,6 +5441,13 @@ function githubPlugin(options = {}) {
5031
5441
  nowMs: ctx.nowMs
5032
5442
  });
5033
5443
  },
5444
+ async profileReport(ctx) {
5445
+ return await buildGitHubProfileReport({
5446
+ db: ctx.db,
5447
+ nowMs: ctx.nowMs,
5448
+ userId: ctx.subject.id
5449
+ });
5450
+ },
5034
5451
  tools(ctx) {
5035
5452
  return createGitHubTools(ctx);
5036
5453
  },
@@ -0,0 +1,11 @@
1
+ import type { PluginOperationalReportContent } from "@sentry/junior-plugin-api";
2
+ import type { GitHubDb } from "../db/database.js";
3
+ /**
4
+ * Build one person-scoped GitHub report for Junior-owned work linked to the
5
+ * subject's conversations.
6
+ */
7
+ export declare function buildGitHubProfileReport(args: {
8
+ db: GitHubDb;
9
+ nowMs: number;
10
+ userId: string;
11
+ }): Promise<PluginOperationalReportContent | undefined>;
@@ -0,0 +1,2 @@
1
+ /** Linkify GitHub issue and pull request shorthand outside Markdown code. */
2
+ export declare function linkifyGitHubReferences(text: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-github",
3
- "version": "0.160.0",
3
+ "version": "0.161.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -31,7 +31,7 @@
31
31
  "@sinclair/typebox": "^0.34.49",
32
32
  "drizzle-orm": "^0.45.2",
33
33
  "zod": "^4.4.3",
34
- "@sentry/junior-plugin-api": "0.160.0"
34
+ "@sentry/junior-plugin-api": "0.161.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^25.9.1",
@@ -81,5 +81,3 @@ If PR creation or update is blocked, report the exact failed command/tool call a
81
81
  When PR creation returns a subscribable resource hint, subscribe to suggested review/CI events. Report only actionable feedback addressed, build failures fixed, fully green/ready state, or merge.
82
82
 
83
83
  Return: repo, branch, PR URL/number, checks and results, pre-existing failures, and anything not run with the reason.
84
-
85
- When you mention a pull request in a user-facing reply, always include a direct link. Prefer the full PR URL or a Markdown link such as `[#123](https://github.com/owner/repo/pull/123)`. `owner/repo#number` is also fine. Do not leave bare `PR #123` text without a URL or repo.
@@ -80,4 +80,5 @@ jr-rpc config set github.repo owner/repo
80
80
  - Pull request reviews and inline review comments use the same repository-scoped `installation-write` credential as other bot-owned PR writes, so they post as Junior even on headless turns. Merge remains denied.
81
81
  - 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.
82
82
  - PR comments, labels, and assignees use GitHub's issue endpoints; use the `github-issues` REST guidance for those operations. All allowlisted bot writes share the same repository-scoped `installation-write` credential.
83
+ - To embed a local image in a GitHub issue, pull request, review, or comment, call `publishImage` first. That tool returns a durable public URL. The published image is public to anyone on the internet who has the URL. Embed the URL with normal GitHub Markdown. Do not use private Slack file links or conversation attachment URLs.
83
84
  - Return actionable errors for access, permission, not-found, and validation failures.
@@ -76,7 +76,7 @@ Follow [references/research-rules.md](references/research-rules.md) for cross-ty
76
76
 
77
77
  - The runtime adds the verified `Requested by` block. Do not add or rewrite requester attribution in model-authored body text.
78
78
  - 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.`
79
- - Attach screenshots from the thread as image links when present.
79
+ - Attach screenshots from the thread when present. For GitHub Markdown, first publish each local image with `publishImage` (the image becomes public to anyone with the URL), then embed the returned URL in the issue body or comment. Do not use private Slack file links or conversation attachment URLs.
80
80
  - Include code snippets, related issues, and related PRs only when they materially improve the issue.
81
81
 
82
82
  ### 4. Verify draft