Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions docs/GITHUB_ACTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ Configure repository secret **`OPENAI_API_KEY`** — the example project’s rev
you need:
- **`contents: read`** — checkout.
- **`pull-requests: write`** — required for the bundled **`review`** job: it runs
**`agentctl run … --approve tool.github.pull_request.post_comment`**, which posts a real issue
comment on the PR after the model review.
**`agentctl run … --approve tool.github.pull_request.post_comment`**, which creates or updates a
single issue comment on the PR after the model review (default **`comment_strategy: replace`**
updates a comment containing **`<!-- agentic-review -->`** instead of posting anew on every push).
- The optional **`post-pointer`** job (**`gh pr comment`**) also needs **`pull-requests: write`**
when **`AGENTIC_GH_PR_COMMENT: "true"`** (short pointer to the Actions run).

Expand Down Expand Up @@ -83,6 +84,15 @@ job without failing CI.
Hard failures use **1**,
**2**, **3**, **4** (see **section 11.2** in **`DESIGN_DOC.md`**).

### Sticky review comment (`synchronize` / re-runs)

The example workflow sets **`comment_strategy: replace`** on **`pull_request.post_comment`**. On each
approved run the native tool lists issue comments on the PR, finds one whose body contains the HTML
marker **`<!-- agentic-review -->`**, and **`PATCH`**es it; if none exists it **`POST`**s once and
embeds the marker. That avoids a new full review comment on every push. Use **`comment_strategy: append`**
in your workflow YAML if you prefer a new comment per run, or **`comment_id`** to update a specific
comment. **`upsert: true`** is an alias for **`replace`**.

---

## Installing `agentctl` in Actions
Expand Down
2 changes: 1 addition & 1 deletion examples/pr-review-github-actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ For the **mock-only** live GitHub path (no OpenAI key, good for CI and integrati
|------|---------|
| `project.yaml` | Imports policies, tools, agent, workflow; **`defaults.model: openai/gpt-4o-mini`**; **`OPENAI_API_KEY`** via `apiKeyFrom` |
| `agents/reviewer.yaml` | **`spec.model: openai/gpt-4o-mini`**, structured JSON output |
| `workflows/pr-review-github.yaml` | GitHub REST read → reviewer → `post_comment` (CI passes `--approve`) |
| `workflows/pr-review-github.yaml` | GitHub REST read → reviewer → `post_comment` with **`comment_strategy: replace`** (one sticky comment per PR) |
| [`.github/workflows/agentctl-pr-review.yml`](../../.github/workflows/agentctl-pr-review.yml) | Runs on PRs; **`AGENTIC_PROJECT`** = **`examples/pr-review-github-actions`** |
| [`.github/workflows/agentctl-pr-review-publish.yml`](../../.github/workflows/agentctl-pr-review-publish.yml) | Optional manual **`workflow_dispatch`** to post an approved PR comment |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ metadata:
spec:
description: |
Live GitHub path: fetch PR JSON and unified diff via the GitHub REST API, run a structured review
with the default project model (OpenAI gpt-4o-mini), then post an issue comment on the PR (real
POST when owner/repo/number/body are set and GITHUB_TOKEN is present; otherwise simulated).
with the default project model (OpenAI gpt-4o-mini), then post or update one issue comment on the PR
(comment_strategy replace by default: PATCH existing <!-- agentic-review --> comment or POST once).
The comment step is policy-gated unless you pass --approve for tool.github.pull_request.post_comment
(the bundled Actions PR job passes --approve so a real comment is posted).
Requires GITHUB_TOKEN, OPENAI_API_KEY (for the reviewer agent), and network access to GitHub/OpenAI.
Expand Down Expand Up @@ -37,6 +37,7 @@ spec:
owner: ${input.owner}
repo: ${input.repo}
number: ${input.number}
comment_strategy: replace
body: |
## Automated review

Expand Down
11 changes: 6 additions & 5 deletions examples/pr-review-github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ This example wires **Phase B + C** of the GitHub integration:

- **Read:** `pull_request.get` and `pull_request.diff` against the GitHub REST API.
- **Review:** structured **mock** model output validated by JSON Schema.
- **Write:** `pull_request.post_comment` performs a **real** `POST …/issues/{n}/comments` when
`owner`, `repo`, `number`, and `body` are set and **`GITHUB_TOKEN` is present**; otherwise it stays
**simulated** (as in `examples/pr-review-demo`). The comment step remains **policy-gated** unless
you pass `--approve tool.github.pull_request.post_comment`.
- **Write:** `pull_request.post_comment` creates or updates an issue comment when `owner`, `repo`,
`number`, and `body` are set and **`GITHUB_TOKEN` is present** (default **`comment_strategy: replace`**
patches a comment containing **`<!-- agentic-review -->`**; use **`append`** for a new comment each run).
Without repo context it stays **simulated** (as in `examples/pr-review-demo`). The step is **policy-gated**
unless you pass `--approve tool.github.pull_request.post_comment`.

## Prerequisites

Expand Down Expand Up @@ -61,7 +62,7 @@ agentctl run workflow/pr-review-github \
## CI / tests

`go test ./test/integration/...` starts an HTTP stub and sets `GITHUB_API_URL` so the workflow runs
without touching GitHub, including an **approved** run that exercises the live comment `POST` path.
without touching GitHub, including an **approved** run that exercises the live comment list + `POST` path.

## GitHub Actions

Expand Down
6 changes: 3 additions & 3 deletions examples/pr-review-github/workflows/pr-review-github.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ metadata:
spec:
description: |
Live GitHub path: fetch PR JSON and unified diff via the GitHub REST API, run a structured review,
then post an issue comment on the PR (real POST when owner/repo/number/body are set and
GITHUB_TOKEN is present; otherwise simulated). The comment step is policy-gated unless you pass
--approve for tool.github.pull_request.post_comment.
then post or update one issue comment (comment_strategy replace: PATCH <!-- agentic-review --> or POST once).
Policy-gated unless you pass --approve for tool.github.pull_request.post_comment.
Requires GITHUB_TOKEN and network access to GITHUB_API_URL (default https://api.github.com).
policy: guarded-writes
input:
Expand Down Expand Up @@ -36,6 +35,7 @@ spec:
owner: ${input.owner}
repo: ${input.repo}
number: ${input.number}
comment_strategy: replace
body: |
## Automated review

Expand Down
4 changes: 3 additions & 1 deletion internal/tools/native/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
//
// Live reads: pull_request.get, pull_request.diff, check_runs.list.
// pull_request.post_comment is simulated unless owner, repo, number, and body are all set, in which
// case it POSTs to the issue comments API (PRs use the same issue number).
// case it writes to the issue comments API (PRs use the same issue number). By default comment_strategy
// is replace: find a comment containing <!-- agentic-review --> and PATCH it, or POST once. Use
// comment_strategy append to always create a new comment. Optional comment_id forces PATCH on that id.
//
// GITHUB_API_URL overrides the REST base URL (default https://api.github.com), e.g. for tests.
package native
62 changes: 9 additions & 53 deletions internal/tools/native/github.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package native

import (
"bytes"
"context"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -70,24 +69,6 @@ func githubPullRequestDiff(ctx context.Context, with map[string]any) (map[string
return map[string]any{"diff": text}, nil
}

func githubPullRequestPostComment(ctx context.Context, owner, repo, number, body string) (map[string]any, error) {
path := fmt.Sprintf("/repos/%s/%s/issues/%s/comments", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(number))
payload := map[string]string{"body": body}
b, err := githubPOSTJSON(ctx, path, payload, maxGitHubJSONBody)
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(b, &out); err != nil {
return nil, fmt.Errorf("native: pull_request.post_comment decode: %w", err)
}
if out == nil {
out = map[string]any{}
}
out["simulated"] = false
return out, nil
}

// githubLivePostCommentContext reports whether step inputs request a real GitHub issue comment:
// non-empty owner, repo, number (or pull_number), and body. When true, post_comment uses the REST
// API if GITHUB_TOKEN is set; otherwise the demo stays fully offline (simulated).
Expand Down Expand Up @@ -118,32 +99,14 @@ func tryStringFromWith(with map[string]any, keys ...string) (string, bool) {
}

func githubPOSTJSON(ctx context.Context, path string, payload any, maxResp int64) ([]byte, error) {
token, err := githubToken()
if err != nil {
return nil, err
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("native: github encode body: %w", err)
}
fullURL := strings.TrimSuffix(githubAPIBase(), "/") + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", githubUserAgent)
req.Header.Set("Accept", githubAcceptJSON)
req.Header.Set("Content-Type", githubAcceptJSON)
req.Header.Set("X-GitHub-Api-Version", githubAPIVersion)
return githubJSONRequest(ctx, http.MethodPost, path, payload, maxResp)
}

cli := &http.Client{Timeout: 60 * time.Second}
resp, err := cli.Do(req)
if err != nil {
return nil, fmt.Errorf("native: github request: %w", err)
}
defer resp.Body.Close()
func defaultGitHubHTTPClient() *http.Client {
return &http.Client{Timeout: 60 * time.Second}
}

func readGitHubResponseBody(resp *http.Response, maxResp int64) ([]byte, error) {
limited := io.LimitReader(resp.Body, maxResp+1)
b, err := io.ReadAll(limited)
if err != nil {
Expand All @@ -152,9 +115,6 @@ func githubPOSTJSON(ctx context.Context, path string, payload any, maxResp int64
if int64(len(b)) > maxResp {
return nil, fmt.Errorf("native: github response body exceeds limit (%d bytes)", maxResp)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("native: github HTTP %s: %s", resp.Status, truncateRunes(string(b), 512))
}
return b, nil
}

Expand Down Expand Up @@ -269,20 +229,16 @@ func githubRequestBody(ctx context.Context, method, path, accept string, maxBody
req.Header.Set("X-GitHub-Api-Version", githubAPIVersion)
}

cli := &http.Client{Timeout: 60 * time.Second}
cli := defaultGitHubHTTPClient()
resp, err := cli.Do(req)
if err != nil {
return nil, fmt.Errorf("native: github request: %w", err)
}
defer resp.Body.Close()

limited := io.LimitReader(resp.Body, maxBody+1)
b, err := io.ReadAll(limited)
b, err := readGitHubResponseBody(resp, maxBody)
if err != nil {
return nil, fmt.Errorf("native: github read body: %w", err)
}
if int64(len(b)) > maxBody {
return nil, fmt.Errorf("native: github response body exceeds limit (%d bytes)", maxBody)
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("native: github HTTP %s: %s", resp.Status, truncateRunes(string(b), 512))
Expand Down
Loading
Loading