Compare commits

..

1 Commits

Author SHA1 Message Date
agent-company 2c32e1c6aa feat: render issue and PR body as markdown via Gitea API
Add RenderMarkdown method to gitea client that calls POST /api/v1/markdown
to convert raw markdown text to safe HTML. Wire it into IssueDetail and
PullDetail handlers to render body content as formatted markdown.
Falls back gracefully to plain text if the API call fails.

Templates updated to use RenderedBody (template.HTML) with fallback
to raw Issue.Body/Pull.Body when rendering fails.

Closes leeworks-agents/gitea-mobile#35

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:10:12 +00:00
5 changed files with 80 additions and 57 deletions
+39
View File
@@ -706,6 +706,45 @@ func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, in
return nil return nil
} }
// RenderMarkdown renders raw markdown text to HTML using the Gitea API.
// Falls back to the raw text if the API call fails.
func (c *Client) RenderMarkdown(ctx context.Context, token, text string) (string, error) {
payload, err := json.Marshal(map[string]string{
"Text": text,
"Mode": "gfm",
})
if err != nil {
return text, fmt.Errorf("marshaling markdown request: %w", err)
}
url := c.baseURL + "/api/v1/markdown"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload)))
if err != nil {
return text, fmt.Errorf("creating markdown request: %w", err)
}
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/html")
resp, err := c.httpClient.Do(req)
if err != nil {
return text, fmt.Errorf("executing markdown request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return text, fmt.Errorf("markdown API error %d", resp.StatusCode)
}
rendered, err := io.ReadAll(resp.Body)
if err != nil {
return text, fmt.Errorf("reading markdown response: %w", err)
}
return string(rendered), nil
}
// priorityScore returns a numeric score for sorting (lower = higher priority). // priorityScore returns a numeric score for sorting (lower = higher priority).
func priorityScore(labels []string) int { func priorityScore(labels []string) int {
for _, l := range labels { for _, l := range labels {
+28 -14
View File
@@ -367,6 +367,17 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
labels = nil labels = nil
} }
// Render markdown body if present.
var renderedBody template.HTML
if issue.Body != "" {
rendered, err := h.Client.RenderMarkdown(r.Context(), token, issue.Body)
if err != nil {
slog.Warn("failed to render issue body markdown, using plain text", "error", err)
} else {
renderedBody = template.HTML(rendered)
}
}
// Build the content HTML using the template. // Build the content HTML using the template.
tmpl, err := template.ParseFiles("internal/templates/issue_detail.html") tmpl, err := template.ParseFiles("internal/templates/issue_detail.html")
if err != nil { if err != nil {
@@ -377,12 +388,14 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
type templateData struct { type templateData struct {
Issue *giteaclient.Issue Issue *giteaclient.Issue
RenderedBody template.HTML
Comments []giteaclient.Comment Comments []giteaclient.Comment
AvailableLabels []giteaclient.Label AvailableLabels []giteaclient.Label
} }
data := templateData{ data := templateData{
Issue: issue, Issue: issue,
RenderedBody: renderedBody,
Comments: comments, Comments: comments,
AvailableLabels: labels, AvailableLabels: labels,
} }
@@ -418,6 +431,17 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
return return
} }
// Render markdown body if present.
var renderedBody template.HTML
if pr.Body != "" {
rendered, err := h.Client.RenderMarkdown(r.Context(), token, pr.Body)
if err != nil {
slog.Warn("failed to render PR body markdown, using plain text", "error", err)
} else {
renderedBody = template.HTML(rendered)
}
}
// Build the content HTML using the template. // Build the content HTML using the template.
tmpl, err := template.ParseFiles("internal/templates/pull_detail.html") tmpl, err := template.ParseFiles("internal/templates/pull_detail.html")
if err != nil { if err != nil {
@@ -427,11 +451,13 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
} }
type templateData struct { type templateData struct {
Pull *giteaclient.PullRequest Pull *giteaclient.PullRequest
RenderedBody template.HTML
} }
data := templateData{ data := templateData{
Pull: pr, Pull: pr,
RenderedBody: renderedBody,
} }
var buf strings.Builder var buf strings.Builder
@@ -458,12 +484,6 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
body := r.FormValue("body") body := r.FormValue("body")
if owner == "" || repo == "" || title == "" { if owner == "" || repo == "" || title == "" {
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `<span class="empty">owner, repo, and title are required</span>`)
return
}
http.Error(w, "owner, repo, and title are required", http.StatusBadRequest) http.Error(w, "owner, repo, and title are required", http.StatusBadRequest)
return return
} }
@@ -471,12 +491,6 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
issue, err := h.Client.CreateIssue(r.Context(), token, owner, repo, title, body, nil) issue, err := h.Client.CreateIssue(r.Context(), token, owner, repo, title, body, nil)
if err != nil { if err != nil {
slog.Error("failed to create issue", "error", err) slog.Error("failed to create issue", "error", err)
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, `<span class="empty">Failed to create issue. Please try again.</span>`)
return
}
http.Error(w, "failed to create issue", http.StatusInternalServerError) http.Error(w, "failed to create issue", http.StatusInternalServerError)
return return
} }
+7 -41
View File
@@ -1,9 +1,7 @@
{{define "content"}} {{define "content"}}
<h1>Create Issue</h1> <h1>Create Issue</h1>
<div id="form-error" class="empty" style="display:none;"></div> <form hx-post="/issues" hx-swap="innerHTML" hx-target="#main-content">
<form id="create-issue-form" hx-post="/issues" hx-swap="innerHTML" hx-target="#main-content">
<div class="form-group"> <div class="form-group">
<label for="repo-select">Repository</label> <label for="repo-select">Repository</label>
<select id="repo-select" name="owner_repo" required> <select id="repo-select" name="owner_repo" required>
@@ -34,43 +32,11 @@
</form> </form>
<script> <script>
(function() { // Split owner/repo from select into hidden fields.
var repoSelect = document.getElementById('repo-select'); document.getElementById('repo-select').addEventListener('change', function() {
var ownerInput = document.getElementById('owner-input'); var parts = this.value.split('/');
var repoInput = document.getElementById('repo-input'); document.getElementById('owner-input').value = parts[0] || '';
var formError = document.getElementById('form-error'); document.getElementById('repo-input').value = parts[1] || '';
});
function splitOwnerRepo() {
var val = repoSelect.value;
if (val) {
var parts = val.split('/');
ownerInput.value = parts[0] || '';
repoInput.value = parts[1] || '';
} else {
ownerInput.value = '';
repoInput.value = '';
}
}
repoSelect.addEventListener('change', splitOwnerRepo);
// Validate before HTMX submit.
document.getElementById('create-issue-form').addEventListener('htmx:configRequest', function(evt) {
splitOwnerRepo();
if (!ownerInput.value || !repoInput.value) {
evt.preventDefault();
formError.textContent = 'Please select a repository before submitting.';
formError.style.display = 'block';
return false;
}
formError.style.display = 'none';
});
// Show server-side errors inline on HTMX error responses.
document.getElementById('create-issue-form').addEventListener('htmx:responseError', function(evt) {
formError.textContent = evt.detail.xhr.responseText || 'An error occurred. Please try again.';
formError.style.display = 'block';
});
})();
</script> </script>
{{end}} {{end}}
+3 -1
View File
@@ -9,7 +9,9 @@
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span> <span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}} {{end}}
</div> </div>
{{if .Issue.Body}} {{if .RenderedBody}}
<div class="card-body markdown-body">{{.RenderedBody}}</div>
{{else if .Issue.Body}}
<div class="card-body">{{.Issue.Body}}</div> <div class="card-body">{{.Issue.Body}}</div>
{{end}} {{end}}
</div> </div>
+3 -1
View File
@@ -15,7 +15,9 @@
<span class="diff-del">-{{.Pull.Deletions}}</span> <span class="diff-del">-{{.Pull.Deletions}}</span>
{{if .Pull.Mergeable}}<span style="color:var(--accent-green);">Mergeable</span>{{end}} {{if .Pull.Mergeable}}<span style="color:var(--accent-green);">Mergeable</span>{{end}}
</div> </div>
{{if .Pull.Body}} {{if .RenderedBody}}
<div class="card-body markdown-body">{{.RenderedBody}}</div>
{{else if .Pull.Body}}
<div class="card-body">{{.Pull.Body}}</div> <div class="card-body">{{.Pull.Body}}</div>
{{end}} {{end}}
</div> </div>