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
}
// 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).
func priorityScore(labels []string) int {
for _, l := range labels {
+28 -14
View File
@@ -367,6 +367,17 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
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.
tmpl, err := template.ParseFiles("internal/templates/issue_detail.html")
if err != nil {
@@ -377,12 +388,14 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
type templateData struct {
Issue *giteaclient.Issue
RenderedBody template.HTML
Comments []giteaclient.Comment
AvailableLabels []giteaclient.Label
}
data := templateData{
Issue: issue,
RenderedBody: renderedBody,
Comments: comments,
AvailableLabels: labels,
}
@@ -418,6 +431,17 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
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.
tmpl, err := template.ParseFiles("internal/templates/pull_detail.html")
if err != nil {
@@ -427,11 +451,13 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
}
type templateData struct {
Pull *giteaclient.PullRequest
Pull *giteaclient.PullRequest
RenderedBody template.HTML
}
data := templateData{
Pull: pr,
Pull: pr,
RenderedBody: renderedBody,
}
var buf strings.Builder
@@ -458,12 +484,6 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
body := r.FormValue("body")
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)
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)
if err != nil {
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)
return
}
+7 -41
View File
@@ -1,9 +1,7 @@
{{define "content"}}
<h1>Create Issue</h1>
<div id="form-error" class="empty" style="display:none;"></div>
<form id="create-issue-form" hx-post="/issues" hx-swap="innerHTML" hx-target="#main-content">
<form hx-post="/issues" hx-swap="innerHTML" hx-target="#main-content">
<div class="form-group">
<label for="repo-select">Repository</label>
<select id="repo-select" name="owner_repo" required>
@@ -34,43 +32,11 @@
</form>
<script>
(function() {
var repoSelect = document.getElementById('repo-select');
var ownerInput = document.getElementById('owner-input');
var repoInput = document.getElementById('repo-input');
var formError = document.getElementById('form-error');
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';
});
})();
// Split owner/repo from select into hidden fields.
document.getElementById('repo-select').addEventListener('change', function() {
var parts = this.value.split('/');
document.getElementById('owner-input').value = parts[0] || '';
document.getElementById('repo-input').value = parts[1] || '';
});
</script>
{{end}}
+3 -1
View File
@@ -9,7 +9,9 @@
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
</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>
{{end}}
</div>
+3 -1
View File
@@ -15,7 +15,9 @@
<span class="diff-del">-{{.Pull.Deletions}}</span>
{{if .Pull.Mergeable}}<span style="color:var(--accent-green);">Mergeable</span>{{end}}
</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>
{{end}}
</div>