diff --git a/modules/code/code.go b/modules/code/code.go index 30bbb0e..75f0809 100644 --- a/modules/code/code.go +++ b/modules/code/code.go @@ -17,8 +17,11 @@ func ModuleInit(reg registry.ModuleRegistrar) { reg.RegisterBodyFn(createPRCommentBodyFnID, createPRCommentBodyFn) reg.RegisterBodyFn(createPRBodyFnID, createPRBodyFn) reg.RegisterQueryParamsFn(listMinePRQueryParamsFnID, listMinePRQueryParamsFn) + reg.RegisterQueryParamsFn(reviewPendingPRQueryParamsFnID, reviewPendingPRQueryParamsFn) reg.RegisterFetchFn(listMinePRFetchFnID, listMinePRFetchFn) + reg.RegisterFetchFn(codeownersPRFetchFnID, codeownersPRFetchFn) reg.RegisterFlagResolveFn(resolvePrincipalIDFnID, resolvePrincipalID) + reg.RegisterBodyFn(reviewPRBodyFnID, reviewPRBodyFn) reg.RegisterWorkflow(getPRWorkflowID, GetPRWorkflow) reg.RegisterTextFormatter(reviewGroupTextFormatterID, reviewGroupTextFormatter) reg.RegisterTextFormatter(insightTextFormatterID, insightTextFormatter) diff --git a/modules/code/codeowners.go b/modules/code/codeowners.go new file mode 100644 index 0000000..101a98c --- /dev/null +++ b/modules/code/codeowners.go @@ -0,0 +1,73 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package code + +import ( + "github.com/harness/cli/pkg/cmdctx" + "github.com/harness/cli/pkg/endpoint" + "github.com/harness/cli/pkg/spec" +) + +const codeownersPRFetchFnID = "codeowners_pr_fetch" + +// codeownersPRFetchFn delegates to HTTPFetchFn (which wraps the single +// TypesCodeOwnerEvaluation response as a one-item list via items_expr: "[it]"), +// then flattens evaluation_entries/owner_evaluations/user_group_owner_evaluations +// into one flat row per (pattern, owner) for the pr_codeowner noun's fields to consume. +func codeownersPRFetchFn(ctx *cmdctx.Ctx, ep *spec.EndpointSpec, wantStart, wantCount int, cursor any) (*cmdctx.PageResult, error) { + result, err := endpoint.HTTPFetchFn(ctx, ep, wantStart, wantCount, cursor) + if err != nil { + return nil, err + } + var rows []any + for _, raw := range result.Items { + m, ok := raw.(map[string]any) + if !ok { + continue + } + entries, _ := m["evaluation_entries"].([]any) + for _, e := range entries { + entry, ok := e.(map[string]any) + if !ok { + continue + } + pattern, _ := entry["pattern"].(string) + + owners, _ := entry["owner_evaluations"].([]any) + for _, o := range owners { + rows = append(rows, ownerRow(pattern, "user", "", o)) + } + + groups, _ := entry["user_group_owner_evaluations"].([]any) + for _, g := range groups { + gm, ok := g.(map[string]any) + if !ok { + continue + } + groupName, _ := gm["name"].(string) + evals, _ := gm["evaluations"].([]any) + for _, o := range evals { + rows = append(rows, ownerRow(pattern, "group", groupName, o)) + } + } + } + } + result.Items = rows + result.Last = true + return result, nil +} + +// ownerRow builds one flat pr_codeowner row from a TypesOwnerEvaluation-shaped map. +func ownerRow(pattern, ownerType, groupName string, raw any) map[string]any { + om, _ := raw.(map[string]any) + owner, _ := om["owner"].(map[string]any) + return map[string]any{ + "pattern": pattern, + "owner_type": ownerType, + "display_name": owner["display_name"], + "email": owner["email"], + "group_name": groupName, + "review_decision": om["review_decision"], + } +} diff --git a/modules/code/mine.go b/modules/code/mine.go index e62f054..70c8f25 100644 --- a/modules/code/mine.go +++ b/modules/code/mine.go @@ -13,8 +13,9 @@ import ( ) const ( - listMinePRQueryParamsFnID = "list_mine_pr_query_params" - listMinePRFetchFnID = "list_mine_pr_fetch" + listMinePRQueryParamsFnID = "list_mine_pr_query_params" + listMinePRFetchFnID = "list_mine_pr_fetch" + reviewPendingPRQueryParamsFnID = "review_pending_pr_query_params" ) // listMinePRQueryParamsFn resolves the current user's Code numeric principal ID @@ -27,6 +28,20 @@ func listMinePRQueryParamsFn(ctx *cmdctx.Ctx) (map[string]string, error) { return map[string]string{"author_id": fmt.Sprintf("%d", id)}, nil } +// reviewPendingPRQueryParamsFn resolves the current user's Code numeric principal ID +// and returns it as the reviewer_id query param, filtered to pending review decisions, +// for the cross-repo PR list endpoint. +func reviewPendingPRQueryParamsFn(ctx *cmdctx.Ctx) (map[string]string, error) { + id, err := CurrentUserPrincipalID(ctx) + if err != nil { + return nil, err + } + return map[string]string{ + "reviewer_id": fmt.Sprintf("%d", id), + "review_decision": "pending", + }, nil +} + // listMinePRFetchFn delegates to HTTPFetchFn (which picks up the author_id via // query_params_fn), then flattens each response item from // {"pull_request": {...}, "repository": {...}} into a single map with diff --git a/modules/code/review.go b/modules/code/review.go new file mode 100644 index 0000000..d5cd4d0 --- /dev/null +++ b/modules/code/review.go @@ -0,0 +1,62 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package code + +import ( + "fmt" + + "github.com/harness/cli/pkg/client" + "github.com/harness/cli/pkg/cmdctx" +) + +const reviewPRBodyFnID = "review_pr_body" + +// cliToAPIReviewDecision maps the CLI's --decision values to the Code API's +// EnumPullReqReviewDecision values. +var cliToAPIReviewDecision = map[string]string{ + "approve": "approved", + "changereq": "changereq", +} + +// reviewPRBodyFn builds the review-submission request body for execute pr:review. +// The API requires commit_sha as a safety check, so we fetch the PR first (same +// pattern as mergePRBodyFn). +func reviewPRBodyFn(ctx *cmdctx.Ctx) (any, error) { + if len(ctx.IdParts) < 2 { + return nil, fmt.Errorf("expected /") + } + repoID := ctx.IdParts[0] + prNumber := ctx.IdParts[1] + + decision := cmdctx.GetString(ctx.FlagValues, "decision") + apiDecision, ok := cliToAPIReviewDecision[decision] + if !ok { + return nil, fmt.Errorf("--decision must be %q or %q, got %q", "approve", "changereq", decision) + } + + c := client.New(ctx) + params := map[string]string{ + "accountIdentifier": ctx.Auth.AccountID, + "orgIdentifier": ctx.Auth.OrgID, + "projectIdentifier": ctx.Auth.ProjectID, + } + raw, _, err := c.Get(fmt.Sprintf("/code/api/v1/repos/%s/pullreq/%s", repoID, prNumber), params) + if err != nil { + return nil, fmt.Errorf("fetching PR to get source SHA: %w", err) + } + + m, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("unexpected PR response type") + } + sourceSHA, _ := m["source_sha"].(string) + if sourceSHA == "" { + return nil, fmt.Errorf("PR response missing source_sha") + } + + return map[string]any{ + "commit_sha": sourceSHA, + "decision": apiDecision, + }, nil +} diff --git a/pkg/spec/code.spec.yaml b/pkg/spec/code.spec.yaml index 8d4fcd4..ce08b65 100644 --- a/pkg/spec/code.spec.yaml +++ b/pkg/spec/code.spec.yaml @@ -371,6 +371,66 @@ nouns: noun_aliases: [pr_review_groups] url_path: /ng/account/{{auth.account}}/all/code/orgs/{{auth.org}}/projects/{{auth.project}}/repos/{{ctx.idParts[0]}}/pulls/{{ctx.idParts[1]}}/conversation + - noun: pr_reviewer + short_desc: A reviewer on a pull request, with their review decision (Harness Code). + noun_aliases: [pr_reviewers] + fields: + - id: id + label: Id + expr: it.reviewer.id + - id: display_name + label: Display Name + expr: it.reviewer.display_name + - id: email + expr: it.reviewer.email + - id: uid + expr: it.reviewer.uid + - id: decision + expr: it.review_decision + - id: type + expr: it.type + - id: added_by + label: Added By + expr: it.added_by.display_name + - id: updated + expr: epochMs(it.updated) + + - noun: pr_codeowner + short_desc: A codeowner (individual or group member) evaluated against a pull request (Harness Code). + noun_aliases: [pr_codeowners] + fields: + - id: pattern + expr: it.pattern + - id: owner_type + label: Owner Type + expr: it.owner_type + - id: display_name + label: Display Name + expr: it.display_name + - id: email + expr: it.email + - id: group_name + label: Group + expr: it.group_name + - id: decision + expr: it.review_decision + + - noun: code_principal + short_desc: A person or service account in the current account/org/project scope (Harness Code). + noun_aliases: [code_principals] + fields: + - id: id + expr: it.id + - id: display_name + label: Display Name + expr: it.display_name + - id: email + expr: it.email + - id: uid + expr: it.uid + - id: type + expr: it.type + - noun: pr_suggested_reviewer short_desc: A suggested reviewer for a pull request (Harness Code review insights). noun_aliases: [pr_suggested_reviewers] @@ -580,6 +640,50 @@ commands: expr: auth.ui_url + "/ng/account/" + auth.account + "/all/code/orgs/" + auth.org + "/projects/" + auth.project + "/repos/" + it.repository.identifier + "/pulls/" + string(it.number) + "/conversation" columns: [repo, number, title, state, source_branch, target_branch, updated] + - command: list pr:review_pending + verb: list + noun: pr + noun_variant: review_pending + short: "List pull requests awaiting your review, across all repos: harness list pr:review_pending [--state open|closed|merged|all]" + handler_type: endpoint + flags: + - name: state + description: "Filter by state: open, closed, merged, all" + default: open + completion_values: [open, closed, merged, all] + - name: created-after + description: "Show PRs created after this date (YYYY-MM-DD, relative like 30d/2w/1m (m=month), or epoch ms)" + - name: created-before + description: "Show PRs created before this date (YYYY-MM-DD, relative like 30d/2w/1m (m=month), or epoch ms)" + endpoint: + path: /code/api/v1/pullreq + query_params_fn: review_pending_pr_query_params + fetch_fn: list_mine_pr_fetch + items_expr: it + get_id_expr: string(it.number) + query_params: + state: 'flags.state == "all" ? "" : flags.state' + created_gt: 'parseDateMs(flags["created-after"])' + created_lt: 'parseDateMs(flags["created-before"])' + page: flags.page + limit: flags.limit + paging: + paging_strategy: page_header + countable: false + page_index_param: page + page_size_param: limit + page_size_default: 30 + page_size_max: 100 + page_base: 1 + fields_extra: + - id: repo + label: Repo + expr: it.repository.identifier + - id: url + label: URL + expr: auth.ui_url + "/ng/account/" + auth.account + "/all/code/orgs/" + auth.org + "/projects/" + auth.project + "/repos/" + it.repository.identifier + "/pulls/" + string(it.number) + "/conversation" + columns: [repo, number, title, state, source_branch, target_branch, updated] + - command: list pr verb: list noun: pr @@ -740,6 +844,29 @@ commands: no_fields: true text_header: "\nClosed PR #{{ctx.idParts[1]}}\n" + - command: execute pr:review + verb: execute + noun: pr + noun_variant: review + short: "Submit a review decision on a pull request: harness execute pr:review / --decision approve|changereq" + handler_type: endpoint + id_parts: 2 + completion_seq: + - completion_noun: repository + - completion_noun: pr + keep_order: true + flags: + - name: decision + description: "Review decision: approve, changereq" + completion_values: [approve, changereq] + required: true + endpoint: + method: POST + path: /code/api/v1/repos/{{ctx.idParts[0]}}/pullreq/{{ctx.idParts[1]}}/reviews + body_fn: review_pr_body + no_fields: true + text_header: "\nReview submitted for PR #{{ctx.idParts[1]}}\n" + # ── pr_insight (Harness Code review insights) ───────────────────────────────── - command: get pr:insight @@ -781,6 +908,120 @@ commands: item_expr: it text_formatter: pr_review_group_text + # ── principal ──────────────────────────────────────────────────────────────── + + - command: list code_principal + verb: list + noun: code_principal + short: "List people/service accounts in scope: harness list code_principal [--search ]" + handler_type: endpoint + flags: + - name: search + description: Filter by substring match on name/email/uid + endpoint: + path: /code/api/v1/principals + items_expr: it + get_id_expr: "-" + query_params: + query: flags.search + type: '"user"' + page: flags.page + limit: flags.limit + paging: + paging_strategy: page_header + countable: false + page_index_param: page + page_size_param: limit + page_size_default: 30 + page_size_max: 100 + page_base: 1 + columns: [id, display_name, email, uid, type] + + # ── pr_reviewer / pr_codeowner ────────────────────────────────────────────────── + + - command: list pr_reviewer + verb: list + noun: pr_reviewer + short: "List reviewers on a pull request: harness list pr_reviewer /" + handler_type: endpoint + requires_parentid: true + parentid_label: "/" + id_parts: 2 + completion_seq: + - completion_noun: repository + - completion_noun: pr + keep_order: true + endpoint: + path: /gateway/code/api/v1/repos/{{auth.scope}}/{{ctx.parentIdParts[0]}}/+/pullreq/{{ctx.parentIdParts[1]}}/reviewers + items_expr: it + get_id_expr: "-" + paging: + paging_strategy: flat_list + columns: [id, display_name, email, decision, type, added_by, updated] + + - command: create pr_reviewer + verb: create + noun: pr_reviewer + short: "Add a reviewer to a pull request: harness create pr_reviewer / --reviewer " + handler_type: endpoint + requires_id: true + id_parts: 2 + id_label: "/" + completion_seq: + - completion_noun: repository + - completion_noun: pr + keep_order: true + flags: + - name: reviewer + description: "Reviewer to add (email, UID, or numeric principal ID)" + required: true + flag_resolve_fn: resolve_principal_id + endpoint: + method: PUT + path: /code/api/v1/repos/{{ctx.idParts[0]}}/pullreq/{{ctx.idParts[1]}}/reviewers + body_params: + reviewer_id: int(flags.reviewer) + no_fields: true + text_header: "\nAdded reviewer to PR #{{ctx.idParts[1]}}\n" + + - command: delete pr_reviewer + verb: delete + noun: pr_reviewer + confirm_mode: prompt + short: "Remove a reviewer from a pull request: harness delete pr_reviewer //" + handler_type: endpoint + id_parts: 3 + id_label: "//" + completion_seq: + - completion_noun: repository + - completion_noun: pr + keep_order: true + endpoint: + method: DELETE + path: /code/api/v1/repos/{{ctx.idParts[0]}}/pullreq/{{ctx.idParts[1]}}/reviewers/{{ctx.idParts[2]}} + item_expr: it + + - command: list pr_codeowner + verb: list + noun: pr_codeowner + short: "List codeowners evaluated on a pull request: harness list pr_codeowner /" + handler_type: endpoint + requires_parentid: true + parentid_label: "/" + id_parts: 2 + completion_seq: + - completion_noun: repository + - completion_noun: pr + keep_order: true + endpoint: + path: /gateway/code/api/v1/repos/{{auth.scope}}/{{ctx.parentIdParts[0]}}/+/pullreq/{{ctx.parentIdParts[1]}}/codeowners + items_expr: "[it]" + fetch_fn: codeowners_pr_fetch + get_id_expr: "-" + paging: + paging_strategy: flat_list + columns: [pattern, owner_type, display_name, email, group_name, decision] + # ── pr_suggested_reviewer (Harness Code review insights) ─────────────────────── - command: list pr_suggested_reviewer @@ -798,6 +1039,7 @@ commands: endpoint: path: /gateway/code/api/v1/repos/{{auth.scope}}/{{ctx.parentIdParts[0]}}/+/pullreq/{{ctx.parentIdParts[1]}}/suggestions/reviewers items_expr: it.suggestions + get_id_expr: "-" paging: paging_strategy: flat_list columns: [display_name, email, suggested_by, suggested_at] @@ -819,6 +1061,7 @@ commands: endpoint: path: /gateway/code/api/v1/repos/{{auth.scope}}/{{ctx.parentIdParts[0]}}/+/pullreq/{{ctx.parentIdParts[1]}}/suggestions/labels items_expr: it + get_id_expr: "-" paging: paging_strategy: flat_list columns: [key, color, type, suggested_by, suggested] @@ -840,6 +1083,7 @@ commands: endpoint: path: /gateway/aicr/api/v1/pullreqs/{{ctx.parentIdParts[1]}}/review items_expr: it.criteria + get_id_expr: "-" query_params: repo_path: auth.scope + "/" + ctx.parentIdParts[0] paging: