Add agentpr issue create/comment/edit
Filing an issue as unkin-agent means hand-rolling a POST to the issues API with a manually minted token, the exact credential handling agentpr exists to encapsulate. - add `issue create`, `issue comment` and `issue edit`, mirroring the pr command group's flags, output and errors - add CreateIssue/EditIssue to the Gitea client, sharing EditOptions with EditPR - build both comment commands from one constructor so pr and issue comments cannot drift off Gitea's shared endpoint - cover create, edit, a missing repo, an API error and missing flags
This commit is contained in:
@@ -104,13 +104,13 @@ func TestEditPRSendsOnlySuppliedFields(t *testing.T) {
|
||||
title, body, empty := "new title", "new body", ""
|
||||
tests := []struct {
|
||||
name string
|
||||
opts EditPROptions
|
||||
opts EditOptions
|
||||
want map[string]any
|
||||
}{
|
||||
{"body only", EditPROptions{Body: &body}, map[string]any{"body": "new body"}},
|
||||
{"title only", EditPROptions{Title: &title}, map[string]any{"title": "new title"}},
|
||||
{"both", EditPROptions{Title: &title, Body: &body}, map[string]any{"title": "new title", "body": "new body"}},
|
||||
{"explicit empty body is sent", EditPROptions{Body: &empty}, map[string]any{"body": ""}},
|
||||
{"body only", EditOptions{Body: &body}, map[string]any{"body": "new body"}},
|
||||
{"title only", EditOptions{Title: &title}, map[string]any{"title": "new title"}},
|
||||
{"both", EditOptions{Title: &title, Body: &body}, map[string]any{"title": "new title", "body": "new body"}},
|
||||
{"explicit empty body is sent", EditOptions{Body: &empty}, map[string]any{"body": ""}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -163,7 +163,7 @@ func TestEditPRAPIError(t *testing.T) {
|
||||
|
||||
title := "new title"
|
||||
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
||||
_, err := c.EditPR("unkin/repo", 7, EditPROptions{Title: &title})
|
||||
_, err := c.EditPR("unkin/repo", 7, EditOptions{Title: &title})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on 404")
|
||||
}
|
||||
@@ -618,3 +618,139 @@ func TestAnonymousPollingNeverMints(t *testing.T) {
|
||||
t.Errorf("sent %d Authorization headers, want none", authHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIssueRequestBody(t *testing.T) {
|
||||
var gotPath, gotMethod, gotAuth string
|
||||
var gotBody CreateIssueOptions
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/unkin/repo/issues", func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotMethod = r.URL.Path, r.Method
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
_, _ = io.WriteString(w, `{"number":12,"state":"open","title":"T","html_url":"https://git.unkin.net/unkin/repo/issues/12"}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := &GiteaClient{BaseURL: srv.URL, Token: "gitea-abc", HTTP: srv.Client()}
|
||||
issue, err := c.CreateIssue("unkin/repo", CreateIssueOptions{Title: "T", Body: "B"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIssue: %v", err)
|
||||
}
|
||||
if gotMethod != http.MethodPost || gotPath != "/api/v1/repos/unkin/repo/issues" {
|
||||
t.Errorf("request = %s %s, want POST /api/v1/repos/unkin/repo/issues", gotMethod, gotPath)
|
||||
}
|
||||
if gotAuth != "token gitea-abc" {
|
||||
t.Errorf("auth header = %q, want 'token gitea-abc'", gotAuth)
|
||||
}
|
||||
if gotBody.Title != "T" || gotBody.Body != "B" {
|
||||
t.Errorf("request body = %+v", gotBody)
|
||||
}
|
||||
if issue.Number != 12 || issue.HTMLURL != "https://git.unkin.net/unkin/repo/issues/12" {
|
||||
t.Errorf("parsed issue = %+v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
// Filing against a repo that does not exist (or that the token may not see)
|
||||
// gets Gitea's 404, which must surface as a not-found error carrying the API's
|
||||
// own message rather than a bare status.
|
||||
func TestCreateIssueRepoNotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `{"errors":null,"message":"user redirect does not exist [name: ghost]","url":"https://git.unkin.net/api/swagger"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
||||
_, err := c.CreateIssue("ghost/repo", CreateIssueOptions{Title: "T"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for a repo that does not exist")
|
||||
}
|
||||
if !IsNotFound(err) {
|
||||
t.Errorf("IsNotFound(%v) = false, want true", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "user redirect does not exist") {
|
||||
t.Errorf("error %q should carry the API message", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Any other non-2xx is a plain API failure: reported, not retried, and not
|
||||
// mistaken for a missing repo.
|
||||
func TestCreateIssueAPIError(t *testing.T) {
|
||||
requests := 0
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/unkin/repo/issues", func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = io.WriteString(w, `{"errors":null,"message":"Validation Error: title is empty","url":"https://git.unkin.net/api/swagger"}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
||||
_, err := c.CreateIssue("unkin/repo", CreateIssueOptions{Title: "T"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on 422")
|
||||
}
|
||||
if IsNotFound(err) {
|
||||
t.Errorf("a 422 must not read as not-found: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Validation Error") {
|
||||
t.Errorf("error %q should carry the API message", err)
|
||||
}
|
||||
if requests != 1 {
|
||||
t.Errorf("requests = %d, want 1 (a 422 is not retried)", requests)
|
||||
}
|
||||
}
|
||||
|
||||
// An issue edit sends only the fields it was given, for the same reason a PR
|
||||
// edit does: Gitea overwrites whatever key it receives.
|
||||
func TestEditIssueSendsOnlySuppliedFields(t *testing.T) {
|
||||
title, body := "new title", "new body"
|
||||
tests := []struct {
|
||||
name string
|
||||
opts EditOptions
|
||||
want map[string]any
|
||||
}{
|
||||
{"body only", EditOptions{Body: &body}, map[string]any{"body": "new body"}},
|
||||
{"title only", EditOptions{Title: &title}, map[string]any{"title": "new title"}},
|
||||
{"both", EditOptions{Title: &title, Body: &body}, map[string]any{"title": "new title", "body": "new body"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
var gotMethod, gotPath string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/repos/unkin/repo/issues/12", func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod, gotPath = r.Method, r.URL.Path
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
_, _ = io.WriteString(w, `{"number":12,"title":"new title","html_url":"https://git.unkin.net/unkin/repo/issues/12"}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := &GiteaClient{BaseURL: srv.URL, Token: "t", HTTP: srv.Client()}
|
||||
issue, err := c.EditIssue("unkin/repo", 12, tt.opts)
|
||||
if err != nil {
|
||||
t.Fatalf("EditIssue: %v", err)
|
||||
}
|
||||
if gotMethod != http.MethodPatch {
|
||||
t.Errorf("method = %s, want PATCH", gotMethod)
|
||||
}
|
||||
if gotPath != "/api/v1/repos/unkin/repo/issues/12" {
|
||||
t.Errorf("path = %q", gotPath)
|
||||
}
|
||||
if len(gotBody) != len(tt.want) {
|
||||
t.Errorf("payload = %v, want exactly the supplied fields %v", gotBody, tt.want)
|
||||
}
|
||||
for k, v := range tt.want {
|
||||
if gotBody[k] != v {
|
||||
t.Errorf("payload[%q] = %v, want %v", k, gotBody[k], v)
|
||||
}
|
||||
}
|
||||
if issue.Number != 12 || issue.HTMLURL == "" {
|
||||
t.Errorf("parsed issue = %+v", issue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+42
-9
@@ -214,19 +214,20 @@ func (c *GiteaClient) CreatePR(repoPath string, opts CreatePROptions) (PullReque
|
||||
return pr, err
|
||||
}
|
||||
|
||||
// EditPROptions are the fields an edit may change. Pointers so an unset field
|
||||
// is omitted from the payload entirely, leaving that field as it is. The two
|
||||
// fields are not symmetric: Gitea only applies a title when it is non-empty,
|
||||
// so Title can be set but never cleared and a "" title is a silent no-op,
|
||||
// while a pointer to "" Body really does blank the body.
|
||||
type EditPROptions struct {
|
||||
// EditOptions are the fields an edit may change, for a pull request or an
|
||||
// issue alike. Pointers so an unset field is omitted from the payload
|
||||
// entirely, leaving that field as it is. The two fields are not symmetric:
|
||||
// Gitea only applies a title when it is non-empty, so Title can be set but
|
||||
// never cleared and a "" title is a silent no-op, while a pointer to "" Body
|
||||
// really does blank the body.
|
||||
type EditOptions struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Body *string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// EditPR updates a pull request's title and/or body
|
||||
// (PATCH /api/v1/repos/{owner}/{repo}/pulls/{index}).
|
||||
func (c *GiteaClient) EditPR(repoPath string, number int, opts EditPROptions) (PullRequest, error) {
|
||||
func (c *GiteaClient) EditPR(repoPath string, number int, opts EditOptions) (PullRequest, error) {
|
||||
var pr PullRequest
|
||||
err := c.do(http.MethodPatch, fmt.Sprintf("/api/v1/repos/%s/pulls/%d", repoPath, number), opts, &pr)
|
||||
return pr, err
|
||||
@@ -239,6 +240,36 @@ func (c *GiteaClient) GetPR(repoPath string, number int) (PullRequest, error) {
|
||||
return pr, err
|
||||
}
|
||||
|
||||
// Issue is the subset of Gitea's issue object we track. Gitea numbers issues
|
||||
// and pull requests in one sequence, so Number is comparable to a PR number.
|
||||
type Issue struct {
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"`
|
||||
Title string `json:"title"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// CreateIssueOptions are the fields for filing an issue.
|
||||
type CreateIssueOptions struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// CreateIssue files an issue (POST /api/v1/repos/{owner}/{repo}/issues).
|
||||
func (c *GiteaClient) CreateIssue(repoPath string, opts CreateIssueOptions) (Issue, error) {
|
||||
var issue Issue
|
||||
err := c.do(http.MethodPost, "/api/v1/repos/"+repoPath+"/issues", opts, &issue)
|
||||
return issue, err
|
||||
}
|
||||
|
||||
// EditIssue updates an issue's title and/or body
|
||||
// (PATCH /api/v1/repos/{owner}/{repo}/issues/{index}).
|
||||
func (c *GiteaClient) EditIssue(repoPath string, number int, opts EditOptions) (Issue, error) {
|
||||
var issue Issue
|
||||
err := c.do(http.MethodPatch, fmt.Sprintf("/api/v1/repos/%s/issues/%d", repoPath, number), opts, &issue)
|
||||
return issue, err
|
||||
}
|
||||
|
||||
// Comment is the subset of an issue comment we track.
|
||||
type Comment struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -246,8 +277,10 @@ type Comment struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// CreateComment posts a comment on the PR's issue thread
|
||||
// (POST /api/v1/repos/{owner}/{repo}/issues/{n}/comments).
|
||||
// CreateComment posts a comment on an issue thread
|
||||
// (POST /api/v1/repos/{owner}/{repo}/issues/{n}/comments). Gitea backs a pull
|
||||
// request with an issue of the same number, so this is the single path for
|
||||
// both.
|
||||
func (c *GiteaClient) CreateComment(repoPath string, number int, body string) (Comment, error) {
|
||||
var cm Comment
|
||||
payload := map[string]string{"body": body}
|
||||
|
||||
Reference in New Issue
Block a user