Skip to content

Commit 4141e1d

Browse files
authored
Merge pull request #119 from LAA-Software-Engineering/feat/issue-94-dedupe-pr-review-comments
feat(native): dedupe automated PR review comments (closes #94)
2 parents 870aa5c + 4bdc67b commit 4141e1d

11 files changed

Lines changed: 542 additions & 81 deletions

File tree

docs/GITHUB_ACTIONS.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ Configure repository secret **`OPENAI_API_KEY`** — the example project’s rev
5050
you need:
5151
- **`contents: read`** — checkout.
5252
- **`pull-requests: write`** — required for the bundled **`review`** job: it runs
53-
**`agentctl run … --approve tool.github.pull_request.post_comment`**, which posts a real issue
54-
comment on the PR after the model review.
53+
**`agentctl run … --approve tool.github.pull_request.post_comment`**, which creates or updates a
54+
single issue comment on the PR after the model review (default **`comment_strategy: replace`**
55+
updates a comment containing **`<!-- agentic-review -->`** instead of posting anew on every push).
5556
- The optional **`post-pointer`** job (**`gh pr comment`**) also needs **`pull-requests: write`**
5657
when **`AGENTIC_GH_PR_COMMENT: "true"`** (short pointer to the Actions run).
5758

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

87+
### Sticky review comment (`synchronize` / re-runs)
88+
89+
The example workflow sets **`comment_strategy: replace`** on **`pull_request.post_comment`**. On each
90+
approved run the native tool lists issue comments on the PR, finds one whose body contains the HTML
91+
marker **`<!-- agentic-review -->`**, and **`PATCH`**es it; if none exists it **`POST`**s once and
92+
embeds the marker. That avoids a new full review comment on every push. Use **`comment_strategy: append`**
93+
in your workflow YAML if you prefer a new comment per run, or **`comment_id`** to update a specific
94+
comment. **`upsert: true`** is an alias for **`replace`**.
95+
8696
---
8797

8898
## Installing `agentctl` in Actions

examples/pr-review-github-actions/README.md

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

examples/pr-review-github-actions/workflows/pr-review-github.yaml

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

examples/pr-review-github/README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ This example wires **Phase B + C** of the GitHub integration:
66

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

1415
## Prerequisites
1516

@@ -61,7 +62,7 @@ agentctl run workflow/pr-review-github \
6162
## CI / tests
6263

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

6667
## GitHub Actions
6768

examples/pr-review-github/workflows/pr-review-github.yaml

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

internal/tools/native/doc.go

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

internal/tools/native/github.go

Lines changed: 9 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package native
22

33
import (
4-
"bytes"
54
"context"
65
"encoding/json"
76
"fmt"
@@ -70,24 +69,6 @@ func githubPullRequestDiff(ctx context.Context, with map[string]any) (map[string
7069
return map[string]any{"diff": text}, nil
7170
}
7271

73-
func githubPullRequestPostComment(ctx context.Context, owner, repo, number, body string) (map[string]any, error) {
74-
path := fmt.Sprintf("/repos/%s/%s/issues/%s/comments", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(number))
75-
payload := map[string]string{"body": body}
76-
b, err := githubPOSTJSON(ctx, path, payload, maxGitHubJSONBody)
77-
if err != nil {
78-
return nil, err
79-
}
80-
var out map[string]any
81-
if err := json.Unmarshal(b, &out); err != nil {
82-
return nil, fmt.Errorf("native: pull_request.post_comment decode: %w", err)
83-
}
84-
if out == nil {
85-
out = map[string]any{}
86-
}
87-
out["simulated"] = false
88-
return out, nil
89-
}
90-
9172
// githubLivePostCommentContext reports whether step inputs request a real GitHub issue comment:
9273
// non-empty owner, repo, number (or pull_number), and body. When true, post_comment uses the REST
9374
// API if GITHUB_TOKEN is set; otherwise the demo stays fully offline (simulated).
@@ -118,32 +99,14 @@ func tryStringFromWith(with map[string]any, keys ...string) (string, bool) {
11899
}
119100

120101
func githubPOSTJSON(ctx context.Context, path string, payload any, maxResp int64) ([]byte, error) {
121-
token, err := githubToken()
122-
if err != nil {
123-
return nil, err
124-
}
125-
bodyBytes, err := json.Marshal(payload)
126-
if err != nil {
127-
return nil, fmt.Errorf("native: github encode body: %w", err)
128-
}
129-
fullURL := strings.TrimSuffix(githubAPIBase(), "/") + path
130-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(bodyBytes))
131-
if err != nil {
132-
return nil, err
133-
}
134-
req.Header.Set("Authorization", "Bearer "+token)
135-
req.Header.Set("User-Agent", githubUserAgent)
136-
req.Header.Set("Accept", githubAcceptJSON)
137-
req.Header.Set("Content-Type", githubAcceptJSON)
138-
req.Header.Set("X-GitHub-Api-Version", githubAPIVersion)
102+
return githubJSONRequest(ctx, http.MethodPost, path, payload, maxResp)
103+
}
139104

140-
cli := &http.Client{Timeout: 60 * time.Second}
141-
resp, err := cli.Do(req)
142-
if err != nil {
143-
return nil, fmt.Errorf("native: github request: %w", err)
144-
}
145-
defer resp.Body.Close()
105+
func defaultGitHubHTTPClient() *http.Client {
106+
return &http.Client{Timeout: 60 * time.Second}
107+
}
146108

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

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

272-
cli := &http.Client{Timeout: 60 * time.Second}
232+
cli := defaultGitHubHTTPClient()
273233
resp, err := cli.Do(req)
274234
if err != nil {
275235
return nil, fmt.Errorf("native: github request: %w", err)
276236
}
277237
defer resp.Body.Close()
278238

279-
limited := io.LimitReader(resp.Body, maxBody+1)
280-
b, err := io.ReadAll(limited)
239+
b, err := readGitHubResponseBody(resp, maxBody)
281240
if err != nil {
282-
return nil, fmt.Errorf("native: github read body: %w", err)
283-
}
284-
if int64(len(b)) > maxBody {
285-
return nil, fmt.Errorf("native: github response body exceeds limit (%d bytes)", maxBody)
241+
return nil, err
286242
}
287243
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
288244
return nil, fmt.Errorf("native: github HTTP %s: %s", resp.Status, truncateRunes(string(b), 512))

0 commit comments

Comments
 (0)