Skip to content

Commit 9eef2ea

Browse files
midagedevclaude
andcommitted
cli: gadak issue prints the markdown source, and --json carries it
The text form printed adf.PlainText of the description and of every comment, so a heading came out as a bare line and bold as bare words. The web editor opens on adf.Present's Source (GDK-1386), which made the CLI the one surface where read → edit -m - → write pressed formatting flat even inside the markdown subset (GDK-1394). store.Detail and DetailComment now carry DescriptionMD / BodyMD, derived on read by the same adf.Present the server uses — one owner, never stored, the mirror keeps the origin's shape. printIssue prints those; --json gets description_md and body_md beside the ADF. Golden test: heading + code mark + bold in a list prints as `##`, backticks and `**`, and FromMarkdown of the printed text is the stored document. Gates: go build/vet/test, gofmt, doc-checks (snapshot regenerated). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 23c682c commit 9eef2ea

6 files changed

Lines changed: 113 additions & 17 deletions

File tree

CHANGELOG.ko.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
## Unreleased
66

7+
- **`gadak issue` 가 눌린 평문이 아니라 마크다운을 찍는다.** 설명과 모든 코멘트가 웹
8+
편집기가 여는 것과 같은 마크다운 원본으로 나온다 — 헤딩은 `##`, 굵게는 `**`, 코드
9+
블록은 펜스 — 그래서 에이전트가 읽은 것이 곧 `edit -m -` 가 받는 것이고, 왕복이
10+
서식을 눌러 버리지 않고 유지한다. `--json` 은 ADF 옆에 `description_md`·`body_md`
11+
로 싣는다 ([GDK-1394]).
12+
713
- 툴바의 보드 버튼이 옆 컬럼 메뉴와 같은 세 기둥 글리프가 아니라 칸반 글리프를 단다 —
814
손가락 하나 거리의 같은 표시 둘은 컨트롤 하나로 읽힌다. 뷰 컨트롤 넷을 설정 메뉴
915
하나로 접는 것은 같은 이슈의 나머지이고 아직 열려 있다 ([GDK-1391]).
@@ -1440,3 +1446,4 @@ HTTP·sync·에이전트 계약을 담았습니다.
14401446
[GDK-1388]: https://gadak.dev/backlog/#/?ks=GDK-1388
14411447
[GDK-1390]: https://gadak.dev/backlog/#/?ks=GDK-1390
14421448
[GDK-1391]: https://gadak.dev/backlog/#/?ks=GDK-1391
1449+
[GDK-1394]: https://gadak.dev/backlog/#/?ks=GDK-1394

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44

55
## Unreleased
66

7+
- **`gadak issue` prints the markdown, not the flattened text.** The
8+
description and every comment come out as the same markdown source the
9+
web editor opens with — a heading is `##`, bold is `**`, a code block is
10+
fenced — so what an agent reads is what `edit -m -` takes back, and the
11+
round trip keeps the formatting instead of pressing it flat. `--json`
12+
carries it as `description_md` and `body_md` beside the ADF ([GDK-1394]).
13+
714
- The board button in the toolbar wears a kanban glyph, not the same three
815
columns as the columns menu beside it — two identical marks a finger apart
916
read as one control. Folding the four view controls into one settings menu
@@ -1499,3 +1506,4 @@ and the storage schema plus the HTTP, sync and agent contracts.
14991506
[GDK-1388]: https://gadak.dev/backlog/#/?ks=GDK-1388
15001507
[GDK-1390]: https://gadak.dev/backlog/#/?ks=GDK-1390
15011508
[GDK-1391]: https://gadak.dev/backlog/#/?ks=GDK-1391
1509+
[GDK-1394]: https://gadak.dev/backlog/#/?ks=GDK-1394

cmd/gadak/agent.go

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -590,10 +590,10 @@ func printIssue(l store.IssueLite, d *store.Detail, dur store.Spans) {
590590
kv(alias, fmt.Sprint(l.Custom[alias]))
591591
}
592592

593-
desc := strings.TrimSpace(adf.PlainText(d.DescriptionADF))
594-
if desc == "" {
595-
desc = strings.TrimSpace(d.DescriptionText)
596-
}
593+
// The markdown source, not PlainText (GDK-1394): what this prints is
594+
// what `edit -m -` takes back, so a heading read here must still be a
595+
// heading after the round trip. Same owner as the web editor.
596+
desc := strings.TrimSpace(d.DescriptionMD)
597597
if desc != "" {
598598
fmt.Printf("\ndescription\n%s\n", indent(desc))
599599
} else {
@@ -605,10 +605,7 @@ func printIssue(l store.IssueLite, d *store.Detail, dur store.Spans) {
605605
if len(d.Comments) > 0 {
606606
fmt.Printf("\ncomments (%d)\n", len(d.Comments))
607607
for _, c := range d.Comments {
608-
body := strings.TrimSpace(c.Body)
609-
if body == "" {
610-
body = strings.TrimSpace(adf.PlainText(c.BodyADF))
611-
}
608+
body := strings.TrimSpace(c.BodyMD)
612609
if mark := commentMark(c); mark != "" {
613610
if body != "" {
614611
body = mark + " " + body

cmd/gadak/agent_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"fmt"
78
"io"
89
"mime"
910
"mime/multipart"
@@ -15,6 +16,7 @@ import (
1516
"strings"
1617
"testing"
1718

19+
"github.com/midagedev/gadak/internal/adf"
1820
"github.com/midagedev/gadak/internal/config"
1921
"github.com/midagedev/gadak/internal/jira"
2022
"github.com/midagedev/gadak/internal/origin"
@@ -2837,3 +2839,78 @@ func TestIssueDeriveReportsAnIncompleteCategoryMap(t *testing.T) {
28372839
t.Errorf("--derive claimed agreement while the category map was short:\n%s", out)
28382840
}
28392841
}
2842+
2843+
// GDK-1394: the text form prints the markdown source, so what an agent reads
2844+
// is what `edit -m -` takes back. A heading must come out as `##`, bold as
2845+
// `**`, and FromMarkdown of the printed text must be the stored document —
2846+
// PlainText pressed all of that flat and made the CLI round trip lossy while
2847+
// the web editor (same adf.Present) kept it.
2848+
func TestIssuePrintsMarkdownSource(t *testing.T) {
2849+
mirror(t, "https://unused.example.com")
2850+
db, err := store.Open(filepath.Join(os.Getenv("GADAK_HOME"), "gadak.db"))
2851+
if err != nil {
2852+
t.Fatal(err)
2853+
}
2854+
rich := json.RawMessage(`{"type":"doc","version":1,"content":[` +
2855+
`{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Repro"}]},` +
2856+
`{"type":"paragraph","content":[{"type":"text","text":"run "},{"type":"text","text":"bd ready","marks":[{"type":"code"}]},{"type":"text","text":" twice"}]},` +
2857+
`{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"second call "},{"type":"text","text":"drops","marks":[{"type":"strong"}]},{"type":"text","text":" the key"}]}]}]}]}`)
2858+
commentADF := json.RawMessage(`{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":"seen on "},{"type":"text","text":"staging","marks":[{"type":"em"}]}]}]}`)
2859+
if _, err := db.UpsertIssues(context.Background(), store.Batch{
2860+
Records: []store.IssueRecord{{
2861+
Item: store.Item{
2862+
ID: "jira:1003", SourceID: "jira", Kind: "issue", ExternalID: "1003", Key: "NMB-3",
2863+
Title: "formatted body", BodyText: "Repro\nrun bd ready twice\nsecond call drops the key",
2864+
CreatedAt: "2026-08-01T00:00:00.000Z", UpdatedAt: "2026-08-01T00:00:00.000Z",
2865+
},
2866+
Issue: store.Issue{ProjectKey: "NMB", StatusCategory: "new", Status: "To Do", DescriptionADF: rich},
2867+
Comments: []store.Comment{{
2868+
ID: "jira:c-3", ExternalID: "c-3", Author: "Marco Reyes",
2869+
BodyADF: commentADF, BodyText: "seen on staging", CreatedAt: "2026-08-02T00:00:00.000Z",
2870+
}},
2871+
}},
2872+
}); err != nil {
2873+
t.Fatal(err)
2874+
}
2875+
if err := db.Close(); err != nil {
2876+
t.Fatal(err)
2877+
}
2878+
2879+
out, err := capture(t, func() error { return cmdIssue([]string{"NMB-3"}) })
2880+
if err != nil {
2881+
t.Fatalf("issue: %v", err)
2882+
}
2883+
wantDesc := "## Repro\n\nrun `bd ready` twice\n\n- second call **drops** the key"
2884+
if !strings.Contains(out, "\ndescription\n"+indent(wantDesc)+"\n") {
2885+
t.Fatalf("description must print as markdown source:\n%s", out)
2886+
}
2887+
if !strings.Contains(out, indent("seen on _staging_")) {
2888+
t.Fatalf("comment must print as markdown source:\n%s", out)
2889+
}
2890+
// The round trip: the printed source parses back to the stored document.
2891+
if got := string(adf.FromMarkdown(wantDesc)); got != string(rich) {
2892+
var a, b any
2893+
_ = json.Unmarshal([]byte(got), &a)
2894+
_ = json.Unmarshal(rich, &b)
2895+
if fmt.Sprint(a) != fmt.Sprint(b) {
2896+
t.Fatalf("printed source does not round-trip:\n got %s\nwant %s", got, rich)
2897+
}
2898+
}
2899+
2900+
out, err = capture(t, func() error { return cmdIssue([]string{"NMB-3", "--json"}) })
2901+
if err != nil {
2902+
t.Fatalf("issue --json: %v", err)
2903+
}
2904+
var doc struct {
2905+
DescriptionMD string `json:"description_md"`
2906+
Comments []struct {
2907+
BodyMD string `json:"body_md"`
2908+
} `json:"comments"`
2909+
}
2910+
if err := json.Unmarshal([]byte(out), &doc); err != nil {
2911+
t.Fatalf("json: %v\n%s", err, out)
2912+
}
2913+
if doc.DescriptionMD != wantDesc || len(doc.Comments) != 1 || doc.Comments[0].BodyMD != "seen on _staging_" {
2914+
t.Fatalf("--json must carry description_md/body_md: %+v", doc)
2915+
}
2916+
}

examples/backlog-snapshot.tar.gz

0 Bytes
Binary file not shown.

internal/store/read.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,8 @@ type DetailComment struct {
380380
Author string `json:"author"`
381381
AuthorID string `json:"author_id"`
382382
BodyADF json.RawMessage `json:"body_adf"`
383-
Body string `json:"body"` // flattened; the client's fallback when ADF will not render
383+
Body string `json:"body"` // flattened; the client's fallback when ADF will not render
384+
BodyMD string `json:"body_md,omitempty"` // adf.Present Source (GDK-1394)
384385
CreatedAt string `json:"created_at"`
385386
UpdatedAt string `json:"updated_at"`
386387
// VisibilityType/VisibilityValue are empty when the origin sent no
@@ -440,11 +441,15 @@ type Detail struct {
440441
// DescriptionText is items.body_text. Linear (and any source that does not
441442
// store ADF) lands markdown/plain here; surfaces fall back to it when
442443
// DescriptionADF is empty. Never stuff markdown into DescriptionADF.
443-
DescriptionText string `json:"description_text,omitempty"`
444-
Comments []DetailComment `json:"comments"`
445-
Attachments []DetailAttachment `json:"attachments"`
446-
History []DetailChange `json:"history"`
447-
LinkedIssues []DetailLink `json:"linked_issues"`
444+
DescriptionText string `json:"description_text,omitempty"`
445+
// DescriptionMD is the markdown an editor opens with — adf.Present's
446+
// Source, the one owner the web and the CLI share (GDK-1394). Derived on
447+
// read, never stored: the mirror keeps the origin's shape.
448+
DescriptionMD string `json:"description_md,omitempty"`
449+
Comments []DetailComment `json:"comments"`
450+
Attachments []DetailAttachment `json:"attachments"`
451+
History []DetailChange `json:"history"`
452+
LinkedIssues []DetailLink `json:"linked_issues"`
448453
// RefPages are wiki pages this issue's body/comments mention (item_refs,
449454
// target_kind=page). Only pages present in the mirror; empty omitted.
450455
RefPages []PageLite `json:"ref_pages,omitempty"`
@@ -470,7 +475,7 @@ type Detail struct {
470475
// handler can answer 404 without importing database/sql.
471476
func (db *DB) Detail(ctx context.Context, key string) (*Detail, error) {
472477
var itemID string
473-
var adf *string
478+
var descADF *string
474479
var customJSON string
475480
var bodyText string
476481
var createdAt string
@@ -479,15 +484,15 @@ func (db *DB) Detail(ctx context.Context, key string) (*Detail, error) {
479484
COALESCE(it.created_at, '')
480485
FROM issues i JOIN items it ON it.id = i.item_id
481486
WHERE i.key = ?`, key).
482-
Scan(&itemID, &adf, &customJSON, &bodyText, &createdAt); err != nil {
487+
Scan(&itemID, &descADF, &customJSON, &bodyText, &createdAt); err != nil {
483488
if errors.Is(err, sql.ErrNoRows) {
484489
return nil, ErrNotFound
485490
}
486491
return nil, err
487492
}
488493
d := &Detail{
489494
IssueKey: key,
490-
DescriptionADF: rawOrNull(adf),
495+
DescriptionADF: rawOrNull(descADF),
491496
DescriptionText: bodyText,
492497
Comments: []DetailComment{},
493498
Attachments: []DetailAttachment{},
@@ -498,6 +503,7 @@ func (db *DB) Detail(ctx context.Context, key string) (*Detail, error) {
498503
Created: createdAt,
499504
}
500505
_ = json.Unmarshal([]byte(customJSON), &d.Custom)
506+
d.DescriptionMD = adf.Present(d.DescriptionADF, d.DescriptionText).Source
501507

502508
if err := each(ctx, db.sql, `
503509
SELECT id, COALESCE(external_id,''), COALESCE(author,''), COALESCE(author_id,''),
@@ -513,6 +519,7 @@ func (db *DB) Detail(ctx context.Context, key string) (*Detail, error) {
513519
return err
514520
}
515521
c.BodyADF = rawOrNull(body)
522+
c.BodyMD = adf.Present(c.BodyADF, c.Body).Source
516523
c.JsdPublic = jsdPublicFromSQL(jsd)
517524
d.Comments = append(d.Comments, c)
518525
return nil

0 commit comments

Comments
 (0)