+30
appview/db/db.go
+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
+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
+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
+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
+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
+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
+107
appview/pages/templates/repo/issue.html
···
1
+
{{ define "title" }}
2
+
{{ .Issue.Title }} ·
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
+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
+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
+1
appview/state/middleware.go
+239
appview/state/repo.go
+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
+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
+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
+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=