forked from tangled.org/core
Monorepo for Tangled

implement issues and comments

authored by anirudh.fi and committed by GitHub 95c4e51c c7793cda

+30
appview/db/db.go
··· 47 47 name text not null, 48 48 knot text not null, 49 49 rkey text not null, 50 + at_uri text not null unique, 50 51 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 51 52 unique(did, name, knot, rkey) 52 53 ); ··· 59 60 create table if not exists follows ( 60 61 user_did text not null, 61 62 subject_did text not null, 63 + at_uri text not null unique, 62 64 rkey text not null, 63 65 followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 64 66 primary key (user_did, subject_did), 65 67 check (user_did <> subject_did) 66 68 ); 69 + create table if not exists issues ( 70 + id integer primary key autoincrement, 71 + owner_did text not null, 72 + repo_at text not null, 73 + issue_id integer not null unique, 74 + title text not null, 75 + body text not null, 76 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 77 + unique(repo_at, issue_id), 78 + foreign key (repo_at) references repos(at_uri) on delete cascade 79 + ); 80 + create table if not exists comments ( 81 + id integer primary key autoincrement, 82 + owner_did text not null, 83 + issue_id integer not null, 84 + repo_at text not null, 85 + comment_id integer not null, 86 + body text not null, 87 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 88 + unique(issue_id, comment_id), 89 + foreign key (issue_id) references issues(issue_id) on delete cascade 90 + ); 67 91 create table if not exists _jetstream ( 68 92 id integer primary key autoincrement, 69 93 last_time_us integer not null 70 94 ); 95 + 96 + create table if not exists repo_issue_seqs ( 97 + repo_at text primary key, 98 + next_issue_id integer not null default 1 99 + ); 100 + 71 101 `) 72 102 if err != nil { 73 103 return nil, err
+179
appview/db/issues.go
··· 1 + package db 2 + 3 + import "time" 4 + 5 + type Issue struct { 6 + RepoAt string 7 + OwnerDid string 8 + IssueId int 9 + Created *time.Time 10 + Title string 11 + Body string 12 + Open bool 13 + } 14 + 15 + type Comment struct { 16 + OwnerDid string 17 + RepoAt string 18 + Issue int 19 + CommentId int 20 + Body string 21 + Created *time.Time 22 + } 23 + 24 + func (d *DB) NewIssue(issue *Issue) (int, error) { 25 + tx, err := d.db.Begin() 26 + if err != nil { 27 + return 0, err 28 + } 29 + defer tx.Rollback() 30 + 31 + _, err = tx.Exec(` 32 + insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 33 + values (?, 1) 34 + `, issue.RepoAt) 35 + if err != nil { 36 + return 0, err 37 + } 38 + 39 + var nextId int 40 + err = tx.QueryRow(` 41 + update repo_issue_seqs 42 + set next_issue_id = next_issue_id + 1 43 + where repo_at = ? 44 + returning next_issue_id - 1 45 + `, issue.RepoAt).Scan(&nextId) 46 + if err != nil { 47 + return 0, err 48 + } 49 + 50 + issue.IssueId = nextId 51 + 52 + _, err = tx.Exec(` 53 + insert into issues (repo_at, owner_did, issue_id, title, body) 54 + values (?, ?, ?, ?, ?) 55 + `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 56 + if err != nil { 57 + return 0, err 58 + } 59 + 60 + if err := tx.Commit(); err != nil { 61 + return 0, err 62 + } 63 + 64 + return nextId, nil 65 + } 66 + 67 + func (d *DB) GetIssues(repoAt string) ([]Issue, error) { 68 + var issues []Issue 69 + 70 + rows, err := d.db.Query(`select owner_did, issue_id, created, title, body, open from issues where repo_at = ?`, repoAt) 71 + if err != nil { 72 + return nil, err 73 + } 74 + defer rows.Close() 75 + 76 + for rows.Next() { 77 + var issue Issue 78 + var createdAt string 79 + err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 80 + if err != nil { 81 + return nil, err 82 + } 83 + 84 + createdTime, err := time.Parse(time.RFC3339, createdAt) 85 + if err != nil { 86 + return nil, err 87 + } 88 + issue.Created = &createdTime 89 + 90 + issues = append(issues, issue) 91 + } 92 + 93 + if err := rows.Err(); err != nil { 94 + return nil, err 95 + } 96 + 97 + return issues, nil 98 + } 99 + 100 + func (d *DB) GetIssueWithComments(repoAt string, issueId int) (*Issue, []Comment, error) { 101 + query := `select owner_did, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 102 + row := d.db.QueryRow(query, repoAt, issueId) 103 + 104 + var issue Issue 105 + var createdAt string 106 + err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 107 + if err != nil { 108 + return nil, nil, err 109 + } 110 + 111 + createdTime, err := time.Parse(time.RFC3339, createdAt) 112 + if err != nil { 113 + return nil, nil, err 114 + } 115 + issue.Created = &createdTime 116 + 117 + comments, err := d.GetComments(repoAt, issueId) 118 + if err != nil { 119 + return nil, nil, err 120 + } 121 + 122 + return &issue, comments, nil 123 + } 124 + 125 + func (d *DB) NewComment(comment *Comment) error { 126 + query := `insert into comments (owner_did, repo_at, issue_id, comment_id, body) values (?, ?, ?, ?, ?)` 127 + _, err := d.db.Exec( 128 + query, 129 + comment.OwnerDid, 130 + comment.RepoAt, 131 + comment.Issue, 132 + comment.CommentId, 133 + comment.Body, 134 + ) 135 + return err 136 + } 137 + 138 + func (d *DB) GetComments(repoAt string, issueId int) ([]Comment, error) { 139 + var comments []Comment 140 + 141 + rows, err := d.db.Query(`select owner_did, issue_id, comment_id, body, created from comments where repo_at = ? and issue_id = ? order by created asc`, repoAt, issueId) 142 + if err != nil { 143 + return nil, err 144 + } 145 + defer rows.Close() 146 + 147 + for rows.Next() { 148 + var comment Comment 149 + var createdAt string 150 + err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &comment.Body, &createdAt) 151 + if err != nil { 152 + return nil, err 153 + } 154 + 155 + createdAtTime, err := time.Parse(time.RFC3339, createdAt) 156 + if err != nil { 157 + return nil, err 158 + } 159 + comment.Created = &createdAtTime 160 + 161 + comments = append(comments, comment) 162 + } 163 + 164 + if err := rows.Err(); err != nil { 165 + return nil, err 166 + } 167 + 168 + return comments, nil 169 + } 170 + 171 + func (d *DB) CloseIssue(repoAt string, issueId int) error { 172 + _, err := d.db.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId) 173 + return err 174 + } 175 + 176 + func (d *DB) ReopenIssue(repoAt string, issueId int) error { 177 + _, err := d.db.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId) 178 + return err 179 + }
+4 -3
appview/db/repos.go
··· 11 11 Knot string 12 12 Rkey string 13 13 Created time.Time 14 + AtUri string 14 15 } 15 16 16 17 func (d *DB) GetAllRepos() ([]Repo, error) { ··· 66 67 func (d *DB) GetRepo(did, name string) (*Repo, error) { 67 68 var repo Repo 68 69 69 - row := d.db.QueryRow(`select did, name, knot, created from repos where did = ? and name = ?`, did, name) 70 + row := d.db.QueryRow(`select did, name, knot, created, at_uri from repos where did = ? and name = ?`, did, name) 70 71 71 72 var createdAt string 72 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt); err != nil { 73 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri); err != nil { 73 74 return nil, err 74 75 } 75 76 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 79 80 } 80 81 81 82 func (d *DB) AddRepo(repo *Repo) error { 82 - _, err := d.db.Exec(`insert into repos (did, name, knot, rkey) values (?, ?, ?, ?)`, repo.Did, repo.Name, repo.Knot, repo.Rkey) 83 + _, err := d.db.Exec(`insert into repos (did, name, knot, rkey, at_uri) values (?, ?, ?, ?, ?)`, repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri) 83 84 return err 84 85 } 85 86
+47
appview/pages/pages.go
··· 283 283 284 284 func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 285 285 params.Active = "overview" 286 + if params.IsEmpty { 287 + return p.executeRepo("repo/empty", w, params) 288 + } 286 289 return p.executeRepo("repo/index", w, params) 287 290 } 288 291 ··· 427 430 func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 428 431 params.Active = "settings" 429 432 return p.executeRepo("repo/settings", w, params) 433 + } 434 + 435 + type RepoIssuesParams struct { 436 + LoggedInUser *auth.User 437 + RepoInfo RepoInfo 438 + Active string 439 + Issues []db.Issue 440 + } 441 + 442 + func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 443 + params.Active = "issues" 444 + return p.executeRepo("repo/issues", w, params) 445 + } 446 + 447 + type RepoSingleIssueParams struct { 448 + LoggedInUser *auth.User 449 + RepoInfo RepoInfo 450 + Active string 451 + Issue db.Issue 452 + Comments []db.Comment 453 + IssueOwnerHandle string 454 + 455 + State string 456 + } 457 + 458 + func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 459 + params.Active = "issues" 460 + if params.Issue.Open { 461 + params.State = "open" 462 + } else { 463 + params.State = "closed" 464 + } 465 + return p.execute("repo/issue", w, params) 466 + } 467 + 468 + type RepoNewIssueParams struct { 469 + LoggedInUser *auth.User 470 + RepoInfo RepoInfo 471 + Active string 472 + } 473 + 474 + func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 475 + params.Active = "issues" 476 + return p.executeRepo("repo/new-issue", w, params) 430 477 } 431 478 432 479 func (p *Pages) Static() http.Handler {
+4 -4
appview/pages/templates/repo/empty.html
··· 1 - {{ define "title" }}{{ .RepoInfo.OwnerWithAt }} / {{ .RepoInfo.Name }}{{ end }} 2 - 3 - {{ define "content" }} 1 + {{ define "repoContent" }} 4 2 <main> 5 - <p>This is an empty Git repository. Push some commits here.</p> 3 + <p class="text-center pt-5 text-gray-400"> 4 + This is an empty repository. Push some commits here. 5 + </p> 6 6 </main> 7 7 {{ end }}
+168 -142
appview/pages/templates/repo/index.html
··· 1 1 {{ define "repoContent" }} 2 - <main> 3 - {{- if .IsEmpty }} 4 - this repo is empty 5 - {{ else }} 6 - <div class="flex gap-4"> 7 - <div id="file-tree" class="w-3/5"> 8 - {{ $containerstyle := "py-1" }} 9 - {{ $linkstyle := "no-underline hover:underline" }} 2 + <main> 3 + <div class="flex gap-4"> 4 + <div id="file-tree" class="w-3/5"> 5 + {{ $containerstyle := "py-1" }} 6 + {{ $linkstyle := "no-underline hover:underline" }} 10 7 11 - <div class="flex justify-end"> 12 - <select 13 - onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + this.value" 14 - class="p-1 border border-gray-500 bg-white" 15 - > 16 - <optgroup label="branches" class="bold text-sm"> 17 - {{ range .Branches }} 18 - <option 19 - value="{{ .Reference.Name }}" 20 - class="py-1" 21 - {{if eq .Reference.Name $.Ref}}selected{{end}} 22 - > 23 - {{ .Reference.Name }} 24 - </option> 25 - {{ end }} 26 - </optgroup> 27 - <optgroup label="tags" class="bold text-sm"> 28 - {{ range .Tags }} 29 - <option 30 - value="{{ .Reference.Name }}" 31 - class="py-1" 32 - {{if eq .Reference.Name $.Ref}}selected{{end}} 33 - > 34 - {{ .Reference.Name }} 35 - </option> 36 - {{ else }} 37 - <option class="py-1" disabled>no tags found</option> 38 - {{ end }} 39 - </optgroup> 40 - </select> 41 - </div> 8 + 9 + <div class="flex justify-end"> 10 + <select 11 + onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + this.value" 12 + class="p-1 border border-gray-500 bg-white" 13 + > 14 + <optgroup label="branches" class="bold text-sm"> 15 + {{ range .Branches }} 16 + <option 17 + value="{{ .Reference.Name }}" 18 + class="py-1" 19 + {{ if eq .Reference.Name $.Ref }} 20 + selected 21 + {{ end }} 22 + > 23 + {{ .Reference.Name }} 24 + </option> 25 + {{ end }} 26 + </optgroup> 27 + <optgroup label="tags" class="bold text-sm"> 28 + {{ range .Tags }} 29 + <option 30 + value="{{ .Reference.Name }}" 31 + class="py-1" 32 + {{ if eq .Reference.Name $.Ref }} 33 + selected 34 + {{ end }} 35 + > 36 + {{ .Reference.Name }} 37 + </option> 38 + {{ else }} 39 + <option class="py-1" disabled> 40 + no tags found 41 + </option> 42 + {{ end }} 43 + </optgroup> 44 + </select> 45 + </div> 42 46 43 - {{ range .Files }} 44 - {{ if not .IsFile }} 45 - <div class="{{ $containerstyle }}"> 46 - <div class="flex justify-between items-center"> 47 - <a 48 - href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}/{{ .Name }}" 49 - class="{{ $linkstyle }}" 50 - > 51 - <div class="flex items-center gap-2"> 52 - <i 53 - class="w-3 h-3 fill-current" 54 - data-lucide="folder" 55 - ></i 56 - >{{ .Name }} 57 - </div> 58 - </a> 59 - 60 - <time class="text-xs text-gray-500">{{ timeFmt .LastCommit.Author.When }}</time> 61 - </div> 62 - </div> 63 - {{ end }} 64 - {{ end }} 47 + {{ range .Files }} 48 + {{ if not .IsFile }} 49 + <div class="{{ $containerstyle }}"> 50 + <div class="flex justify-between items-center"> 51 + <a 52 + href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}/{{ .Name }}" 53 + class="{{ $linkstyle }}" 54 + > 55 + <div class="flex items-center gap-2"> 56 + <i 57 + class="w-3 h-3 fill-current" 58 + data-lucide="folder" 59 + ></i 60 + >{{ .Name }} 61 + </div> 62 + </a> 65 63 66 - {{ range .Files }} 67 - {{ if .IsFile }} 68 - <div class="{{ $containerstyle }}"> 69 - <div class="flex justify-between items-center"> 70 - <a 71 - href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref }}/{{ .Name }}" 72 - class="{{ $linkstyle }}" 73 - > 74 - <div class="flex items-center gap-2"> 75 - <i 76 - class="w-3 h-3" 77 - data-lucide="file" 78 - ></i 79 - >{{ .Name }} 80 - </div> 81 - </a> 64 + <time class="text-xs text-gray-500" 65 + >{{ timeFmt .LastCommit.Author.When }}</time 66 + > 67 + </div> 68 + </div> 69 + {{ end }} 70 + {{ end }} 82 71 83 - <time class="text-xs text-gray-500">{{ timeFmt .LastCommit.Author.When }}</time> 84 - </div> 85 - </div> 86 - {{ end }} 87 - {{ end }} 88 - </div> 89 - <div id="commit-log" class="flex-1"> 90 - {{ range .Commits }} 91 - <div 92 - class="relative 72 + {{ range .Files }} 73 + {{ if .IsFile }} 74 + <div class="{{ $containerstyle }}"> 75 + <div class="flex justify-between items-center"> 76 + <a 77 + href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref }}/{{ .Name }}" 78 + class="{{ $linkstyle }}" 79 + > 80 + <div class="flex items-center gap-2"> 81 + <i 82 + class="w-3 h-3" 83 + data-lucide="file" 84 + ></i 85 + >{{ .Name }} 86 + </div> 87 + </a> 88 + 89 + <time class="text-xs text-gray-500" 90 + >{{ timeFmt .LastCommit.Author.When }}</time 91 + > 92 + </div> 93 + </div> 94 + {{ end }} 95 + {{ end }} 96 + </div> 97 + <div id="commit-log" class="flex-1"> 98 + {{ range .Commits }} 99 + <div 100 + class="relative 93 101 px-4 94 102 py-4 95 103 border-l ··· 103 111 before:left-[-2.2px] 104 112 before:top-1/2 105 113 before:-translate-y-1/2 106 - "> 114 + " 115 + > 116 + <div id="commit-message"> 117 + {{ $messageParts := splitN .Message "\n\n" 2 }} 118 + <div class="text-base cursor-pointer"> 119 + <div> 120 + <div> 121 + <a 122 + href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 123 + class="inline no-underline hover:underline" 124 + >{{ index $messageParts 0 }}</a 125 + > 126 + {{ if gt (len $messageParts) 1 }} 107 127 108 - <div id="commit-message"> 109 - {{ $messageParts := splitN .Message "\n\n" 2 }} 110 - <div class="text-base cursor-pointer"> 111 - <div> 112 - <div> 113 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" class="inline no-underline hover:underline">{{ index $messageParts 0 }}</a> 114 - {{ if gt (len $messageParts) 1 }} 128 + <button 129 + class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded" 130 + hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')" 131 + > 132 + <i 133 + class="w-3 h-3" 134 + data-lucide="ellipsis" 135 + ></i> 136 + </button> 137 + {{ end }} 138 + </div> 139 + {{ if gt (len $messageParts) 1 }} 140 + <p 141 + class="hidden mt-1 text-sm cursor-text pb-2" 142 + > 143 + {{ nl2br (unwrapText (index $messageParts 1)) }} 144 + </p> 145 + {{ end }} 146 + </div> 147 + </div> 148 + </div> 115 149 116 - <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded" 117 - hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"> 118 - <i class="w-3 h-3" data-lucide="ellipsis"></i> 119 - </button> 150 + <div class="text-xs text-gray-500"> 151 + <span class="font-mono"> 152 + <a 153 + href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 154 + class="text-gray-500 no-underline hover:underline" 155 + >{{ slice .Hash.String 0 8 }}</a 156 + > 157 + </span> 158 + <span 159 + class="mx-2 before:content-['·'] before:select-none" 160 + ></span> 161 + <span> 162 + <a 163 + href="mailto:{{ .Author.Email }}" 164 + class="text-gray-500 no-underline hover:underline" 165 + >{{ .Author.Name }}</a 166 + > 167 + </span> 168 + <div 169 + class="inline-block px-1 select-none after:content-['·']" 170 + ></div> 171 + <span>{{ timeFmt .Author.When }}</span> 172 + </div> 173 + </div> 120 174 {{ end }} 121 - </div> 122 - {{ if gt (len $messageParts) 1 }} 123 - <p class="hidden mt-1 text-sm cursor-text pb-2">{{ nl2br (unwrapText (index $messageParts 1)) }}</p> 124 - {{ end }} 125 175 </div> 126 - </div> 127 176 </div> 128 - 129 - <div class="text-xs text-gray-500"> 130 - <span class="font-mono"> 131 - <a 132 - href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 133 - class="text-gray-500 no-underline hover:underline" 134 - >{{ slice .Hash.String 0 8 }}</a 135 - > 136 - </span> 137 - <span class="mx-2 before:content-['·'] before:select-none"></span> 138 - <span> 139 - <a 140 - href="mailto:{{ .Author.Email }}" 141 - class="text-gray-500 no-underline hover:underline" 142 - >{{ .Author.Name }}</a 143 - > 144 - </span> 145 - <div class="inline-block px-1 select-none after:content-['·']"></div> 146 - <span>{{ timeFmt .Author.When }}</span> 147 - </div> 148 - </div> 149 - {{ end }} 150 - </div> 151 - </div> 152 - {{- end -}} 153 - 154 - </main> 177 + </main> 155 178 {{ end }} 156 179 157 180 {{ define "repoAfter" }} 158 - {{- if .Readme }} 159 - <section class="mt-4 p-6 border border-black w-full mx-auto"> 160 - <article class="readme"> 161 - {{- .Readme -}} 162 - </article> 163 - </section> 164 - {{- end -}} 181 + {{- if .Readme }} 182 + <section class="mt-4 p-6 border border-black w-full mx-auto"> 183 + <article class="readme"> 184 + {{- .Readme -}} 185 + </article> 186 + </section> 187 + {{- end -}} 165 188 166 - <section class="mt-4 p-6 border border-black w-full mx-auto"> 167 - <strong>clone</strong> 168 - <pre> git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }} </pre> 169 - </section> 189 + 190 + <section class="mt-4 p-6 border border-black w-full mx-auto"> 191 + <strong>clone</strong> 192 + <pre> 193 + git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }} </pre 194 + > 195 + </section> 170 196 {{ end }}
+107
appview/pages/templates/repo/issue.html
··· 1 + {{ define "title" }} 2 + {{ .Issue.Title }} &middot; 3 + {{ .RepoInfo.FullName }} 4 + {{ end }} 5 + 6 + {{ define "repoContent" }} 7 + <div class="flex items-center justify-between"> 8 + <h1> 9 + {{ .Issue.Title }} 10 + <span class="text-gray-400">#{{ .Issue.IssueId }}</span> 11 + </h1> 12 + 13 + <time class="text-sm">{{ .Issue.Created | timeFmt }}</time> 14 + </div> 15 + 16 + {{ $bgColor := "bg-gray-800" }} 17 + {{ $icon := "ban" }} 18 + {{ if eq .State "open" }} 19 + {{ $bgColor = "bg-green-600" }} 20 + {{ $icon = "circle-dot" }} 21 + {{ end }} 22 + 23 + 24 + <section class="m-2"> 25 + <div class="flex items-center gap-2"> 26 + <div 27 + id="state" 28 + class="inline-flex items-center px-3 py-1 {{ $bgColor }}" 29 + > 30 + <i 31 + data-lucide="{{ $icon }}" 32 + class="w-4 h-4 mr-1.5 text-white" 33 + ></i> 34 + <span class="text-white">{{ .State }}</span> 35 + </div> 36 + <span class="text-gray-400 text-sm"> 37 + opened by 38 + {{ didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 39 + </span> 40 + </div> 41 + 42 + {{ if .Issue.Body }} 43 + <article id="body" class="mt-8"> 44 + {{ .Issue.Body | escapeHtml }} 45 + </article> 46 + {{ end }} 47 + </section> 48 + 49 + <section id="comments" class="mt-8 space-y-4"> 50 + {{ range .Comments }} 51 + <div 52 + id="comment-{{ .CommentId }}" 53 + class="border border-gray-200 p-4" 54 + > 55 + <div class="flex items-center gap-2 mb-2"> 56 + <span class="text-gray-400 text-sm"> 57 + {{ .OwnerDid }} 58 + </span> 59 + <span class="text-gray-500 text-sm"> 60 + {{ .Created | timeFmt }} 61 + </span> 62 + </div> 63 + <div class=""> 64 + {{ nl2br .Body }} 65 + </div> 66 + </div> 67 + {{ end }} 68 + </section> 69 + 70 + {{ if .LoggedInUser }} 71 + <form 72 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 73 + class="mt-8" 74 + > 75 + <textarea 76 + name="body" 77 + class="w-full p-2 border border-gray-200" 78 + placeholder="Add to the discussion..." 79 + ></textarea> 80 + <button type="submit" class="btn mt-2">comment</button> 81 + <div id="issue-comment"></div> 82 + </form> 83 + {{ end }} 84 + 85 + {{ if eq .LoggedInUser.Did .Issue.OwnerDid }} 86 + {{ $action := "close" }} 87 + {{ $icon := "circle-x" }} 88 + {{ $hoverColor := "red" }} 89 + {{ if eq .State "closed" }} 90 + {{ $action = "reopen" }} 91 + {{ $icon = "circle-dot" }} 92 + {{ $hoverColor = "green" }} 93 + {{ end }} 94 + <form 95 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/{{ $action }}" 96 + class="mt-8" 97 + > 98 + <button type="submit" class="btn hover:bg-{{ $hoverColor }}-300"> 99 + <i 100 + data-lucide="{{ $icon }}" 101 + class="w-4 h-4 mr-2 text-{{ $hoverColor }}-400" 102 + ></i> 103 + <span class="text-black">{{ $action }}</span> 104 + </button> 105 + </form> 106 + {{ end }} 107 + {{ end }}
+48
appview/pages/templates/repo/issues.html
··· 1 + {{ define "title" }}issues | {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <div class="flex justify-between items-center"> 5 + <h1 class="m-0">issues</h1> 6 + <div class="error" id="issues"></div> 7 + <a 8 + href="/{{ .RepoInfo.FullName }}/issues/new" 9 + class="btn flex items-center gap-2 no-underline" 10 + > 11 + <i data-lucide="square-plus" class="w-5 h-5"></i> 12 + <span>new issue</span> 13 + </a> 14 + </div> 15 + 16 + <section id="issues" class="mt-8 space-y-4"> 17 + {{ range .Issues }} 18 + <div class="border border-gray-200 p-4"> 19 + <time class="float-right text-sm"> 20 + {{ .Created | timeFmt }} 21 + </time> 22 + <div class="flex items-center gap-2 py-2"> 23 + {{ if .Open }} 24 + <i 25 + data-lucide="circle-dot" 26 + class="w-4 h-4 text-green-600" 27 + ></i> 28 + {{ else }} 29 + <i data-lucide="ban" class="w-4 h-4 text-red-600"></i> 30 + {{ end }} 31 + <a 32 + href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 33 + class="no-underline hover:underline" 34 + > 35 + {{ .Title }} 36 + </a> 37 + </div> 38 + <div class="text-sm flex gap-2 text-gray-400"> 39 + <span>#{{ .IssueId }}</span> 40 + <span class="before:content-['·']"> 41 + opened by 42 + {{ .OwnerDid }} 43 + </span> 44 + </div> 45 + </div> 46 + {{ end }} 47 + </section> 48 + {{ end }}
+30
appview/pages/templates/repo/new-issue.html
··· 1 + {{ define "title" }}new issue | {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <form 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/new" 6 + class="mt-6 space-y-6" 7 + hx-swap="none" 8 + > 9 + <div class="flex flex-col gap-4"> 10 + <div> 11 + <label for="title">title</label> 12 + <input type="text" name="title" id="title" class="w-full" /> 13 + </div> 14 + <div> 15 + <label for="body">body</label> 16 + <textarea 17 + name="body" 18 + id="body" 19 + rows="6" 20 + class="w-full resize-y" 21 + placeholder="Describe your issue." 22 + ></textarea> 23 + </div> 24 + <div> 25 + <button type="submit" class="btn">create</button> 26 + </div> 27 + </div> 28 + <div id="issues" class="error"></div> 29 + </form> 30 + {{ end }}
+1
appview/state/middleware.go
··· 187 187 } 188 188 189 189 ctx := context.WithValue(req.Context(), "knot", repo.Knot) 190 + ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 190 191 next.ServeHTTP(w, req.WithContext(ctx)) 191 192 }) 192 193 }
+239
appview/state/repo.go
··· 6 6 "fmt" 7 7 "io" 8 8 "log" 9 + "math/rand/v2" 9 10 "net/http" 10 11 "path" 12 + "strconv" 11 13 "strings" 12 14 13 15 "github.com/bluesky-social/indigo/atproto/identity" 14 16 securejoin "github.com/cyphar/filepath-securejoin" 15 17 "github.com/go-chi/chi/v5" 16 18 "github.com/sotangled/tangled/appview/auth" 19 + "github.com/sotangled/tangled/appview/db" 17 20 "github.com/sotangled/tangled/appview/pages" 18 21 "github.com/sotangled/tangled/types" 19 22 ) ··· 441 444 Knot string 442 445 OwnerId identity.Identity 443 446 RepoName string 447 + RepoAt string 444 448 } 445 449 446 450 func (f *FullyResolvedRepo) OwnerDid() string { ··· 500 504 return collaborators, nil 501 505 } 502 506 507 + func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 508 + user := s.auth.GetUser(r) 509 + f, err := fullyResolvedRepo(r) 510 + if err != nil { 511 + log.Println("failed to get repo and knot", err) 512 + return 513 + } 514 + 515 + issueId := chi.URLParam(r, "issue") 516 + issueIdInt, err := strconv.Atoi(issueId) 517 + if err != nil { 518 + http.Error(w, "bad issue id", http.StatusBadRequest) 519 + log.Println("failed to parse issue id", err) 520 + return 521 + } 522 + 523 + issue, comments, err := s.db.GetIssueWithComments(f.RepoAt, issueIdInt) 524 + if err != nil { 525 + log.Println("failed to get issue and comments", err) 526 + s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 527 + return 528 + } 529 + 530 + issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 531 + if err != nil { 532 + log.Println("failed to resolve issue owner", err) 533 + } 534 + 535 + s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 536 + LoggedInUser: user, 537 + RepoInfo: pages.RepoInfo{ 538 + OwnerDid: f.OwnerDid(), 539 + OwnerHandle: f.OwnerHandle(), 540 + Name: f.RepoName, 541 + SettingsAllowed: settingsAllowed(s, user, f), 542 + }, 543 + Issue: *issue, 544 + Comments: comments, 545 + 546 + IssueOwnerHandle: issueOwnerIdent.Handle.String(), 547 + }) 548 + 549 + } 550 + 551 + func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 552 + user := s.auth.GetUser(r) 553 + f, err := fullyResolvedRepo(r) 554 + if err != nil { 555 + log.Println("failed to get repo and knot", err) 556 + return 557 + } 558 + 559 + issueId := chi.URLParam(r, "issue") 560 + issueIdInt, err := strconv.Atoi(issueId) 561 + if err != nil { 562 + http.Error(w, "bad issue id", http.StatusBadRequest) 563 + log.Println("failed to parse issue id", err) 564 + return 565 + } 566 + 567 + if user.Did == f.OwnerDid() { 568 + err := s.db.CloseIssue(f.RepoAt, issueIdInt) 569 + if err != nil { 570 + log.Println("failed to close issue", err) 571 + s.pages.Notice(w, "issues", "Failed to close issue. Try again later.") 572 + return 573 + } 574 + s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 575 + return 576 + } else { 577 + log.Println("user is not the owner of the repo") 578 + http.Error(w, "for biden", http.StatusUnauthorized) 579 + return 580 + } 581 + } 582 + 583 + func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 584 + user := s.auth.GetUser(r) 585 + f, err := fullyResolvedRepo(r) 586 + if err != nil { 587 + log.Println("failed to get repo and knot", err) 588 + return 589 + } 590 + 591 + issueId := chi.URLParam(r, "issue") 592 + issueIdInt, err := strconv.Atoi(issueId) 593 + if err != nil { 594 + http.Error(w, "bad issue id", http.StatusBadRequest) 595 + log.Println("failed to parse issue id", err) 596 + return 597 + } 598 + 599 + if user.Did == f.OwnerDid() { 600 + err := s.db.ReopenIssue(f.RepoAt, issueIdInt) 601 + if err != nil { 602 + log.Println("failed to reopen issue", err) 603 + s.pages.Notice(w, "issues", "Failed to reopen issue. Try again later.") 604 + return 605 + } 606 + s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 607 + return 608 + } else { 609 + log.Println("user is not the owner of the repo") 610 + http.Error(w, "forbidden", http.StatusUnauthorized) 611 + return 612 + } 613 + } 614 + 615 + func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 616 + user := s.auth.GetUser(r) 617 + f, err := fullyResolvedRepo(r) 618 + if err != nil { 619 + log.Println("failed to get repo and knot", err) 620 + return 621 + } 622 + 623 + issueId := chi.URLParam(r, "issue") 624 + issueIdInt, err := strconv.Atoi(issueId) 625 + if err != nil { 626 + http.Error(w, "bad issue id", http.StatusBadRequest) 627 + log.Println("failed to parse issue id", err) 628 + return 629 + } 630 + 631 + switch r.Method { 632 + case http.MethodPost: 633 + body := r.FormValue("body") 634 + if body == "" { 635 + s.pages.Notice(w, "issue", "Body is required") 636 + return 637 + } 638 + 639 + commentId := rand.IntN(1000000) 640 + fmt.Println(commentId) 641 + fmt.Println("comment id", commentId) 642 + 643 + err := s.db.NewComment(&db.Comment{ 644 + OwnerDid: user.Did, 645 + RepoAt: f.RepoAt, 646 + Issue: issueIdInt, 647 + CommentId: commentId, 648 + Body: body, 649 + }) 650 + if err != nil { 651 + log.Println("failed to create comment", err) 652 + s.pages.Notice(w, "issue-comment", "Failed to create comment.") 653 + return 654 + } 655 + 656 + s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 657 + return 658 + } 659 + } 660 + 661 + func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 662 + user := s.auth.GetUser(r) 663 + f, err := fullyResolvedRepo(r) 664 + if err != nil { 665 + log.Println("failed to get repo and knot", err) 666 + return 667 + } 668 + 669 + issues, err := s.db.GetIssues(f.RepoAt) 670 + if err != nil { 671 + log.Println("failed to get issues", err) 672 + s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 673 + return 674 + } 675 + 676 + s.pages.RepoIssues(w, pages.RepoIssuesParams{ 677 + LoggedInUser: s.auth.GetUser(r), 678 + RepoInfo: pages.RepoInfo{ 679 + OwnerDid: f.OwnerDid(), 680 + OwnerHandle: f.OwnerHandle(), 681 + Name: f.RepoName, 682 + SettingsAllowed: settingsAllowed(s, user, f), 683 + }, 684 + Issues: issues, 685 + }) 686 + return 687 + } 688 + 689 + func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 690 + user := s.auth.GetUser(r) 691 + 692 + f, err := fullyResolvedRepo(r) 693 + if err != nil { 694 + log.Println("failed to get repo and knot", err) 695 + return 696 + } 697 + 698 + switch r.Method { 699 + case http.MethodGet: 700 + s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 701 + LoggedInUser: user, 702 + RepoInfo: pages.RepoInfo{ 703 + Name: f.RepoName, 704 + OwnerDid: f.OwnerDid(), 705 + OwnerHandle: f.OwnerHandle(), 706 + SettingsAllowed: settingsAllowed(s, user, f), 707 + }, 708 + }) 709 + case http.MethodPost: 710 + title := r.FormValue("title") 711 + body := r.FormValue("body") 712 + 713 + if title == "" || body == "" { 714 + s.pages.Notice(w, "issue", "Title and body are required") 715 + return 716 + } 717 + 718 + issueId, err := s.db.NewIssue(&db.Issue{ 719 + RepoAt: f.RepoAt, 720 + Title: title, 721 + Body: body, 722 + OwnerDid: user.Did, 723 + }) 724 + if err != nil { 725 + log.Println("failed to create issue", err) 726 + s.pages.Notice(w, "issue", "Failed to create issue.") 727 + return 728 + } 729 + 730 + s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 731 + return 732 + } 733 + } 734 + 503 735 func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { 504 736 repoName := chi.URLParam(r, "repo") 505 737 knot, ok := r.Context().Value("knot").(string) ··· 513 745 return nil, fmt.Errorf("malformed middleware") 514 746 } 515 747 748 + repoAt, ok := r.Context().Value("repoAt").(string) 749 + if !ok { 750 + log.Println("malformed middleware") 751 + return nil, fmt.Errorf("malformed middleware") 752 + } 753 + 516 754 return &FullyResolvedRepo{ 517 755 Knot: knot, 518 756 OwnerId: id, 519 757 RepoName: repoName, 758 + RepoAt: repoAt, 520 759 }, nil 521 760 } 522 761
+12 -1
appview/state/state.go
··· 70 70 } 71 71 72 72 did := e.Did 73 - fmt.Println("got event", e.Commit.Collection, e.Commit.RKey, e.Commit.Record) 74 73 raw := json.RawMessage(e.Commit.Record) 75 74 76 75 switch e.Commit.Collection { ··· 597 596 } 598 597 log.Println("created repo record: ", atresp.Uri) 599 598 599 + repo.AtUri = atresp.Uri 600 + 600 601 err = s.db.AddRepo(repo) 601 602 if err != nil { 602 603 log.Println(err) ··· 801 802 r.Get("/branches", s.RepoBranches) 802 803 r.Get("/tags", s.RepoTags) 803 804 r.Get("/blob/{ref}/*", s.RepoBlob) 805 + 806 + r.Route("/issues", func(r chi.Router) { 807 + r.Get("/", s.RepoIssues) 808 + r.Get("/{issue}", s.RepoSingleIssue) 809 + r.Get("/new", s.NewIssue) 810 + r.Post("/new", s.NewIssue) 811 + r.Post("/{issue}/comment", s.IssueComment) 812 + r.Post("/{issue}/close", s.CloseIssue) 813 + r.Post("/{issue}/reopen", s.ReopenIssue) 814 + }) 804 815 805 816 // These routes get proxied to the knot 806 817 r.Get("/info/refs", s.InfoRefs)
+1
go.mod
··· 24 24 github.com/russross/blackfriday/v2 v2.1.0 25 25 github.com/sethvargo/go-envconfig v1.1.0 26 26 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 27 + golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 27 28 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 28 29 ) 29 30
+2
go.sum
··· 307 307 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 308 308 golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 309 309 golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 310 + golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM= 311 + golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= 310 312 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 311 313 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 312 314 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=