@blogic-cz/agent-tools 0.14.54 → 0.14.55

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.14.54",
3
+ "version": "0.14.55",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -35,6 +35,7 @@ import {
35
35
  prReplyCommand,
36
36
  prResolveCommand,
37
37
  prReviewsCommand,
38
+ prLastHumanReviewerCommand,
38
39
  prSubmitReviewCommand,
39
40
  prChecksCommand,
40
41
  prChecksFailedCommand,
@@ -82,6 +83,7 @@ const prCommand = Command.make("pr", {}).pipe(
82
83
  prThreadsCommand,
83
84
  prCommentsCommand,
84
85
  prReviewsCommand,
86
+ prLastHumanReviewerCommand,
85
87
  prFeedbackCommand,
86
88
  prIssueCommentsCommand,
87
89
  prIssueCommentsLatestCommand,
@@ -46,6 +46,7 @@ import {
46
46
  fetchDiscussionSummary,
47
47
  fetchFeedback,
48
48
  fetchIssueComments,
49
+ fetchLastHumanReviewer,
49
50
  fetchLatestIssueComment,
50
51
  fetchReviews,
51
52
  fetchThreads,
@@ -520,13 +521,19 @@ export const prChecksFailedCommand = Command.make(
520
521
  Flag.optional,
521
522
  ),
522
523
  repo: repoOption,
524
+ withLogs: Flag.boolean("with-logs").pipe(
525
+ Flag.withDescription(
526
+ "Inline the failed-step logs for each failed check so no follow-up job-logs call is needed",
527
+ ),
528
+ Flag.withDefault(false),
529
+ ),
523
530
  },
524
- ({ format, pr, repo }) =>
531
+ ({ format, pr, repo, withLogs }) =>
525
532
  withRepo(
526
533
  repo,
527
534
  Effect.gen(function* () {
528
535
  const prNumber = Option.getOrNull(pr);
529
- const checks = yield* fetchFailedChecks(prNumber);
536
+ const checks = yield* fetchFailedChecks(prNumber, withLogs);
530
537
  yield* logFormatted(checks, format);
531
538
  }),
532
539
  ),
@@ -620,6 +627,30 @@ export const prCommentsCommand = Command.make(
620
627
  ),
621
628
  ).pipe(Command.withDescription("Fetch review comments for a PR (optionally filter by --since)"));
622
629
 
630
+ export const prLastHumanReviewerCommand = Command.make(
631
+ "last-human-reviewer",
632
+ {
633
+ format: formatOption,
634
+ pr: Flag.integer("pr").pipe(
635
+ Flag.withDescription("PR number (default: current branch PR)"),
636
+ Flag.optional,
637
+ ),
638
+ repo: repoOption,
639
+ },
640
+ ({ format, pr, repo }) =>
641
+ withRepo(
642
+ repo,
643
+ Effect.gen(function* () {
644
+ const result = yield* fetchLastHumanReviewer(Option.getOrNull(pr));
645
+ yield* logFormatted(result, format);
646
+ }),
647
+ ),
648
+ ).pipe(
649
+ Command.withDescription(
650
+ "Report the most recent human (non-bot) reviewer, when they reviewed, and the current requested reviewers",
651
+ ),
652
+ );
653
+
623
654
  export const prReviewsCommand = Command.make(
624
655
  "reviews",
625
656
  {
@@ -17,6 +17,7 @@ import { GitHubService } from "#gh/service";
17
17
 
18
18
  import type { ButStatusJson, PRViewJsonResult } from "./helpers";
19
19
  import { runLocalCommand } from "./helpers";
20
+ import { fetchJobLogs } from "#gh/workflow";
20
21
 
21
22
  const CHECK_JSON_FIELDS = "name,state,bucket,link";
22
23
  const GITHUB_ACTIONS_RUN_ID_RE = /github\.com\/[^/]+\/[^/]+\/actions\/runs\/(\d+)/;
@@ -104,6 +105,16 @@ const getCheckJobNameCandidates = (checkName: string): string[] => {
104
105
  return [...new Set([exact, suffix].filter((value): value is string => value !== undefined))];
105
106
  };
106
107
 
108
+ const failedJobsMatchingCheck = <Job extends { name: string }>(
109
+ checkName: string,
110
+ failedJobs: readonly Job[],
111
+ ): Job[] => {
112
+ const candidates = getCheckJobNameCandidates(checkName);
113
+ return failedJobs.filter((job) =>
114
+ candidates.some((candidate) => job.name.toLowerCase() === candidate.toLowerCase()),
115
+ );
116
+ };
117
+
107
118
  const resolveJobIdsForFailedChecks = (
108
119
  checks: CheckResult[],
109
120
  jobs: WorkflowRunJobsForRerun["jobs"],
@@ -112,10 +123,7 @@ const resolveJobIdsForFailedChecks = (
112
123
  const jobIds = new Set<number>();
113
124
 
114
125
  for (const check of checks) {
115
- const candidates = getCheckJobNameCandidates(check.name);
116
- const matches = failedJobs.filter((job) =>
117
- candidates.some((candidate) => job.name.toLowerCase() === candidate.toLowerCase()),
118
- );
126
+ const matches = failedJobsMatchingCheck(check.name, failedJobs);
119
127
 
120
128
  if (matches.length !== 1) {
121
129
  return null;
@@ -127,6 +135,14 @@ const resolveJobIdsForFailedChecks = (
127
135
  return [...jobIds];
128
136
  };
129
137
 
138
+ const matchFailedJobForCheck = <Job extends { name: string }>(
139
+ checkName: string,
140
+ failedJobs: readonly Job[],
141
+ ): Job | null => {
142
+ const matches = failedJobsMatchingCheck(checkName, failedJobs);
143
+ return matches.length === 1 ? matches[0] : null;
144
+ };
145
+
130
146
  const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureContext")(function* (
131
147
  runId: number,
132
148
  ) {
@@ -149,6 +165,7 @@ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureCont
149
165
  const failedJobs = run.jobs
150
166
  .filter((job) => job.conclusion === "failure" || job.status === "failure")
151
167
  .map((job) => ({
168
+ databaseId: job.databaseId,
152
169
  name: job.name,
153
170
  status: job.status,
154
171
  conclusion: job.conclusion,
@@ -184,6 +201,7 @@ const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (pr: numbe
184
201
  const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function* (
185
202
  pr: number | null,
186
203
  checks: CheckResult[],
204
+ options: { withLogs: boolean } = { withLogs: false },
187
205
  ) {
188
206
  const failedChecks = checks.filter((check) => check.bucket === "fail");
189
207
  const pendingChecks = checks.filter((check) => check.bucket === "pending");
@@ -211,14 +229,40 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
211
229
  runContexts.set(runId, context);
212
230
  }
213
231
 
214
- const enrichedFailedChecks: FailedCheckDetail[] = failedChecks.map((check) => {
215
- const runId = extractRunIdFromCheckLink(check.link);
216
- return {
217
- ...check,
218
- runId,
219
- run: runId === null ? null : (runContexts.get(runId) ?? null),
220
- };
221
- });
232
+ const enrichedFailedChecks: FailedCheckDetail[] = yield* Effect.forEach(
233
+ failedChecks,
234
+ (check) =>
235
+ Effect.gen(function* () {
236
+ const runId = extractRunIdFromCheckLink(check.link);
237
+ const run = runId === null ? null : (runContexts.get(runId) ?? null);
238
+ const detail: FailedCheckDetail = { ...check, runId, run };
239
+
240
+ if (!options.withLogs || runId === null) {
241
+ return detail;
242
+ }
243
+
244
+ const matchedJob = run ? matchFailedJobForCheck(check.name, run.failedJobs) : null;
245
+ if (!matchedJob) {
246
+ return detail;
247
+ }
248
+
249
+ const failedStepLogs = yield* fetchJobLogs({
250
+ runId,
251
+ job: matchedJob.name,
252
+ jobId: matchedJob.databaseId,
253
+ failedStepNames: matchedJob.failedSteps,
254
+ failedStepsOnly: true,
255
+ format: "text",
256
+ repo: null,
257
+ }).pipe(
258
+ Effect.map((result) => ("formatted" in result ? result.formatted : "")),
259
+ Effect.catch(() => Effect.succeed("")),
260
+ );
261
+
262
+ return failedStepLogs && failedStepLogs.length > 0 ? { ...detail, failedStepLogs } : detail;
263
+ }),
264
+ { concurrency: 5 },
265
+ );
222
266
 
223
267
  const nextCommands = [
224
268
  buildChecksFailedCommand(pr),
@@ -902,9 +946,12 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
902
946
  return results;
903
947
  });
904
948
 
905
- export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (pr: number | null) {
949
+ export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (
950
+ pr: number | null,
951
+ withLogs = false,
952
+ ) {
906
953
  const checks = yield* fetchCheckResults(pr);
907
- return yield* buildFailedChecksReport(pr, checks);
954
+ return yield* buildFailedChecksReport(pr, checks, { withLogs });
908
955
  });
909
956
 
910
957
  export const fetchChecksForCommand = Effect.fn("pr.fetchChecksForCommand")(function* (
@@ -10,6 +10,7 @@ export {
10
10
  prFeedbackCommand,
11
11
  prIssueCommentsCommand,
12
12
  prIssueCommentsLatestCommand,
13
+ prLastHumanReviewerCommand,
13
14
  prListCommand,
14
15
  prMergeCommand,
15
16
  prReplyCommand,
@@ -81,6 +81,53 @@ const SUBMIT_REVIEW_MUTATION = `
81
81
  }
82
82
  `;
83
83
 
84
+ const LAST_HUMAN_REVIEWER_QUERY = `
85
+ query($owner: String!, $name: String!, $pr: Int!) {
86
+ repository(owner: $owner, name: $name) {
87
+ pullRequest(number: $pr) {
88
+ reviewRequests(first: 100) {
89
+ nodes {
90
+ requestedReviewer {
91
+ __typename
92
+ ... on User { login }
93
+ ... on Team { name }
94
+ ... on Bot { login }
95
+ }
96
+ }
97
+ }
98
+ reviews(last: 100) {
99
+ nodes {
100
+ author { login }
101
+ state
102
+ submittedAt
103
+ }
104
+ }
105
+ timelineItems(first: 250, itemTypes: [REVIEW_REQUESTED_EVENT, REVIEW_REQUEST_REMOVED_EVENT]) {
106
+ nodes {
107
+ __typename
108
+ ... on ReviewRequestedEvent {
109
+ requestedReviewer {
110
+ __typename
111
+ ... on User { login }
112
+ ... on Team { name }
113
+ ... on Bot { login }
114
+ }
115
+ }
116
+ ... on ReviewRequestRemovedEvent {
117
+ requestedReviewer {
118
+ __typename
119
+ ... on User { login }
120
+ ... on Team { name }
121
+ ... on Bot { login }
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ `;
130
+
84
131
  // ---------------------------------------------------------------------------
85
132
  // Internal types
86
133
  // ---------------------------------------------------------------------------
@@ -141,6 +188,35 @@ type SubmitReviewResult = {
141
188
  };
142
189
  };
143
190
 
191
+ type RequestedReviewerNode = {
192
+ __typename: string;
193
+ login?: string;
194
+ name?: string;
195
+ } | null;
196
+
197
+ type LastHumanReviewerQueryResult = {
198
+ repository: {
199
+ pullRequest: {
200
+ reviewRequests: {
201
+ nodes: Array<{ requestedReviewer: RequestedReviewerNode }>;
202
+ };
203
+ reviews: {
204
+ nodes: Array<{
205
+ author: { login: string } | null;
206
+ state: string;
207
+ submittedAt: string | null;
208
+ }>;
209
+ };
210
+ timelineItems: {
211
+ nodes: Array<{
212
+ __typename: string;
213
+ requestedReviewer: RequestedReviewerNode;
214
+ }>;
215
+ };
216
+ };
217
+ };
218
+ };
219
+
144
220
  type RawReviewComment = {
145
221
  id: number;
146
222
  in_reply_to_id: number | null;
@@ -259,7 +335,7 @@ const enrichThreads = (threads: ThreadNode[], reviewComments: ReviewComment[]):
259
335
  repliesByRootCommentId.set(comment.inReplyToId, replies);
260
336
  }
261
337
 
262
- return threads
338
+ const mapped = threads
263
339
  .map((node) => {
264
340
  const comment = node.comments.nodes[0];
265
341
  if (!comment) {
@@ -295,6 +371,24 @@ const enrichThreads = (threads: ThreadNode[], reviewComments: ReviewComment[]):
295
371
  };
296
372
  })
297
373
  .filter((thread): thread is ReviewThread => thread !== null);
374
+
375
+ const dedupedByKey = new Map<string, number>();
376
+ const deduped: ReviewThread[] = [];
377
+ for (const thread of mapped) {
378
+ const key = `${thread.path}${thread.line}${thread.body.trim()}`;
379
+ const existingIndex = dedupedByKey.get(key);
380
+ if (existingIndex === undefined) {
381
+ dedupedByKey.set(key, deduped.length);
382
+ deduped.push(thread);
383
+ continue;
384
+ }
385
+ const existing = deduped[existingIndex];
386
+ if (existing !== undefined && existing.isResolved && !thread.isResolved) {
387
+ deduped[existingIndex] = thread;
388
+ }
389
+ }
390
+
391
+ return deduped;
298
392
  };
299
393
 
300
394
  const fetchThreadState = Effect.fn("pr.fetchThreadState")(function* (pr: number) {
@@ -768,3 +862,83 @@ export const submitPendingReview = Effect.fn("pr.submitPendingReview")(function*
768
862
  state: result.submitPullRequestReview.pullRequestReview.state,
769
863
  };
770
864
  });
865
+
866
+ const AUTOMATION_LOGINS = new Set(["claude", "github-actions", "dependabot"]);
867
+
868
+ const isHumanLogin = (login: string): boolean => {
869
+ const lower = login.toLowerCase();
870
+ if (lower.endsWith("[bot]")) {
871
+ return false;
872
+ }
873
+ return !AUTOMATION_LOGINS.has(lower);
874
+ };
875
+
876
+ const requestedReviewerLogin = (reviewer: RequestedReviewerNode): string | null =>
877
+ reviewer === null ? null : (reviewer.login ?? reviewer.name ?? null);
878
+
879
+ export const fetchLastHumanReviewer = Effect.fn("pr.fetchLastHumanReviewer")(function* (
880
+ pr: number | null,
881
+ ) {
882
+ const service = yield* GitHubService;
883
+ const repoInfo = yield* service.getRepoInfo();
884
+
885
+ const resolvedPr = pr ?? (yield* viewPR(null)).number;
886
+
887
+ const response = (yield* service.runGraphQL(LAST_HUMAN_REVIEWER_QUERY, {
888
+ owner: repoInfo.owner,
889
+ name: repoInfo.name,
890
+ pr: resolvedPr,
891
+ })) as LastHumanReviewerQueryResult;
892
+
893
+ const prNode = response.repository.pullRequest;
894
+
895
+ const requestedFromTimeline: string[] = [];
896
+ for (const item of prNode.timelineItems.nodes) {
897
+ const login = requestedReviewerLogin(item.requestedReviewer);
898
+ if (login === null) {
899
+ continue;
900
+ }
901
+ if (item.__typename === "ReviewRequestedEvent") {
902
+ if (!requestedFromTimeline.includes(login)) {
903
+ requestedFromTimeline.push(login);
904
+ }
905
+ } else {
906
+ const index = requestedFromTimeline.indexOf(login);
907
+ if (index !== -1) {
908
+ requestedFromTimeline.splice(index, 1);
909
+ }
910
+ }
911
+ }
912
+
913
+ const currentRequestedReviewers =
914
+ requestedFromTimeline.length > 0
915
+ ? requestedFromTimeline
916
+ : prNode.reviewRequests.nodes
917
+ .map((node) => requestedReviewerLogin(node.requestedReviewer))
918
+ .filter((login): login is string => login !== null);
919
+
920
+ const latestHumanReview = prNode.reviews.nodes.reduce<{ login: string; at: string } | null>(
921
+ (latest, review) => {
922
+ if (review.author === null || review.submittedAt === null) {
923
+ return latest;
924
+ }
925
+ if (!isHumanLogin(review.author.login)) {
926
+ return latest;
927
+ }
928
+ if (
929
+ latest === null ||
930
+ new Date(review.submittedAt).getTime() > new Date(latest.at).getTime()
931
+ ) {
932
+ return { login: review.author.login, at: review.submittedAt };
933
+ }
934
+ return latest;
935
+ },
936
+ null,
937
+ );
938
+
939
+ return {
940
+ currentRequestedReviewers,
941
+ lastHumanReviewer: latestHumanReview?.login ?? null,
942
+ lastHumanReviewAt: latestHumanReview?.at ?? null,
943
+ };
944
+ });
@@ -83,6 +83,7 @@ export type CheckResult = {
83
83
  };
84
84
 
85
85
  export type FailedCheckJob = {
86
+ databaseId: number;
86
87
  name: string;
87
88
  status: string;
88
89
  conclusion: string | null;
@@ -102,6 +103,7 @@ export type FailedCheckRunContext = {
102
103
  export type FailedCheckDetail = CheckResult & {
103
104
  runId: number | null;
104
105
  run: FailedCheckRunContext | null;
106
+ failedStepLogs?: string;
105
107
  };
106
108
 
107
109
  export type FailedChecksReport = {
@@ -126,6 +128,7 @@ export type WorkflowRunDetail = {
126
128
  status: string;
127
129
  conclusion: string | null;
128
130
  jobs: Array<{
131
+ databaseId: number;
129
132
  name: string;
130
133
  status: string;
131
134
  conclusion: string | null;
@@ -494,9 +494,11 @@ const filterFailedStepEntries = Effect.fn("workflow.filterFailedStepEntries")(fu
494
494
  return entries.filter((e) => failedStepNames.has(e.step));
495
495
  });
496
496
 
497
- const fetchJobLogs = Effect.fn("workflow.fetchJobLogs")(function* (opts: {
497
+ export const fetchJobLogs = Effect.fn("workflow.fetchJobLogs")(function* (opts: {
498
498
  runId: number;
499
499
  job: string;
500
+ jobId?: number | null;
501
+ failedStepNames?: readonly string[] | null;
500
502
  failedStepsOnly: boolean;
501
503
  format: string;
502
504
  repo: string | null;
@@ -523,7 +525,7 @@ const fetchJobLogs = Effect.fn("workflow.fetchJobLogs")(function* (opts: {
523
525
  repoName = info.name;
524
526
  }
525
527
 
526
- const jobId = yield* resolveJobId(opts.runId, opts.job, opts.repo);
528
+ const jobId = opts.jobId ?? (yield* resolveJobId(opts.runId, opts.job, opts.repo));
527
529
 
528
530
  // Fetch raw logs via API (follows 302 redirect automatically)
529
531
  const raw = yield* gh
@@ -539,7 +541,12 @@ const fetchJobLogs = Effect.fn("workflow.fetchJobLogs")(function* (opts: {
539
541
  let entries = parseRawJobLogs(raw);
540
542
 
541
543
  if (opts.failedStepsOnly) {
542
- entries = yield* filterFailedStepEntries(opts.runId, jobId, entries, opts.repo);
544
+ if (Array.isArray(opts.failedStepNames) && opts.failedStepNames.length > 0) {
545
+ const wanted = new Set(opts.failedStepNames);
546
+ entries = entries.filter((e) => wanted.has(e.step));
547
+ } else {
548
+ entries = yield* filterFailedStepEntries(opts.runId, jobId, entries, opts.repo);
549
+ }
543
550
  }
544
551
 
545
552
  if (opts.format === "json") {