Commit 230fe608ef1516dbdc57be322b43aa7d33813625
Initial commit
Charles Lehner committed on 3/25/2016, 11:51:02 PMFiles changed
README.md | added |
index.js | added |
lib/schemas.js | added |
package.json | added |
test.js | added |
README.md | ||
---|---|---|
@@ -1,0 +1,174 @@ | ||
1 … | +# ssb-issues | |
2 … | + | |
3 … | +Issue tracking built on secure-scuttlebutt | |
4 … | + | |
5 … | +## Schema | |
6 … | + | |
7 … | +#### type: issue | |
8 … | + | |
9 … | +An issue. Represents something that should be fixed. | |
10 … | + | |
11 … | +```js | |
12 … | +{ | |
13 … | + type: 'issue', | |
14 … | + project: Link?, | |
15 … | + title: string?, | |
16 … | + text: string? | |
17 … | +} | |
18 … | +``` | |
19 … | + | |
20 … | +#### type: issue-edit | |
21 … | + | |
22 … | +An edit to an issue. Only considered valid if its author is the author of the | |
23 … | +issue (`issue.author`) or of the project of the issue (`issue.projectAuthor`). | |
24 … | + | |
25 … | +```js | |
26 … | +{ | |
27 … | + type: 'issue-edit', | |
28 … | + issue: IssueRef, | |
29 … | + open: boolean?, | |
30 … | + title: string? | |
31 … | +} | |
32 … | +``` | |
33 … | + | |
34 … | +## API | |
35 … | + | |
36 … | +```js | |
37 … | +var Issues = require('ssb-issues') | |
38 … | +var issues = Issues.init(sbot) | |
39 … | +``` | |
40 … | + | |
41 … | +#### get: async | |
42 … | + | |
43 … | +Get an issue by its id | |
44 … | + | |
45 … | +```js | |
46 … | +issues.get(issueId, cb) | |
47 … | +``` | |
48 … | + | |
49 … | +The resulting issue object is as follows: | |
50 … | + | |
51 … | +```js | |
52 … | +{ | |
53 … | + id: MsgRef, | |
54 … | + author: FeedRef, | |
55 … | + project: Ref?, | |
56 … | + projectAuthor: FeedRef?, | |
57 … | + created_at: number, | |
58 … | + updated_at: number, | |
59 … | + open: boolean | |
60 … | +} | |
61 … | +``` | |
62 … | + | |
63 … | +- `id`: id of the issue | |
64 … | +- `author`: author of the issue | |
65 … | +- `created_at` (timestamp): when the issue was created | |
66 … | +- `updated_at` (timestamp): when the issue was last updated | |
67 … | +- `title`: title of the issue | |
68 … | +- `open`: whether the issue is open (true) or closed (false) | |
69 … | +- `project`: the project that the issue is for | |
70 … | +- `projectAuthor`: the author of the project | |
71 … | + | |
72 … | +#### createFeedStream: source | |
73 … | + | |
74 … | +Get a stream of issues | |
75 … | + | |
76 … | +```js | |
77 … | +issues.createFeedStream({ project:, open:, live:, gt:, gte:, lt:, lte:, reverse:, limit:, }, cb) | |
78 … | +``` | |
79 … | + | |
80 … | + - `project` (Ref): get only issues for the given target | |
81 … | + - `open` (boolean): get only open or closed issues | |
82 … | + - `author` (FeedRef): get only issues from the given feed | |
83 … | + - `live` (boolean, default: `false`): Keep the stream open and emit new messages as they are received. | |
84 … | + - `gt` (greater than), `gte` (greater than or equal): maximum `[timestamp, id]` | |
85 … | + - `lt` (less than), `lte` (less than or equal): minimum `[timestamp, id]` | |
86 … | + - `reverse` (boolean, default: `false`): reverse the order of results | |
87 … | + | |
88 … | +#### new: async | |
89 … | + | |
90 … | +Create a new issue | |
91 … | + | |
92 … | +```js | |
93 … | +issues.new({ project:, title:, text: }, cb) | |
94 … | +``` | |
95 … | + | |
96 … | +- `project` (Ref): id of an ssb object representing the target of the issue | |
97 … | +- `title` (string): title of the issue | |
98 … | +- `text` (string): text describing the issue | |
99 … | + | |
100 … | +#### close: async | |
101 … | + | |
102 … | +```js | |
103 … | +issues.close(id, cb) | |
104 … | +``` | |
105 … | + | |
106 … | +Mark an issue as closed. | |
107 … | + | |
108 … | +`id` (MsgRef): id of the issue to reopen | |
109 … | + | |
110 … | +#### reopen: async | |
111 … | + | |
112 … | +```js | |
113 … | +issues.reopen(id, cb)` | |
114 … | +``` | |
115 … | + | |
116 … | +Mark an issue as open. | |
117 … | + | |
118 … | +`id` (MsgRef): id of the issue to reopen | |
119 … | +`text` (string): text to accompany the open action | |
120 … | + | |
121 … | +#### edit: async | |
122 … | + | |
123 … | +```js | |
124 … | +issues.edit(id, opts, cb)` | |
125 … | +``` | |
126 … | + | |
127 … | +Edit an issue. | |
128 … | + | |
129 … | +`id` (MsgRef): id of the issue to reopen | |
130 … | +`opts.open` (boolean): set open/closed status | |
131 … | +`opts.title` (string): set title | |
132 … | + | |
133 … | +### schemas | |
134 … | + | |
135 … | +```js | |
136 … | +var issueSchemas = Issues.schemas | |
137 … | +``` | |
138 … | + | |
139 … | +#### `issueSchemas.new(project, title, text)` | |
140 … | + | |
141 … | +Create a new issue. | |
142 … | + | |
143 … | +- `project` (Ref): id of project to associate the issue with | |
144 … | +- `title` (string): title to give the issue | |
145 … | +- `text` (string): text body for the issue | |
146 … | + | |
147 … | +#### `issueSchemas.edit(id, opts)` | |
148 … | + | |
149 … | +Edit an issue. | |
150 … | + | |
151 … | +- `opts.open` (boolean): open or close the issue | |
152 … | +- `opts.title` (string): set the title of the issue | |
153 … | + | |
154 … | +#### `issueSchemas.close(id)` | |
155 … | + | |
156 … | +Close an issue. | |
157 … | + | |
158 … | +- `id` (MsgRef): id of an issue to mark as closed | |
159 … | + | |
160 … | +#### `issueSchemas.reopen(id)` | |
161 … | + | |
162 … | +Reopen an issue. | |
163 … | + | |
164 … | +- `id` (MsgRef): id of an issue to mark as open | |
165 … | + | |
166 … | +## License | |
167 … | + | |
168 … | +Copyright (c) 2016 Charles Lehner | |
169 … | + | |
170 … | +Usage of the works is permitted provided that this instrument is | |
171 … | +retained with the works, so that any entity that uses the works is | |
172 … | +notified of this instrument. | |
173 … | + | |
174 … | +DISCLAIMER: THE WORKS ARE WITHOUT WARRANTY. |
index.js | ||
---|---|---|
@@ -1,0 +1,182 @@ | ||
1 … | +var pull = require('pull-stream') | |
2 … | +var paramap = require('pull-paramap') | |
3 … | +var asyncMemo = require('asyncmemo') | |
4 … | +var issueSchemas = require('./lib/schemas') | |
5 … | + | |
6 … | +function Cache(fn, ssb) { | |
7 … | + return asyncMemo(fn) | |
8 … | + return function (key, cb) { ac.get(key, cb) } | |
9 … | +} | |
10 … | + | |
11 … | +function isUpdateValid(issue, msg) { | |
12 … | + return msg.value.author == issue.author | |
13 … | + && msg.value.author == issue.projectAuthor | |
14 … | +} | |
15 … | + | |
16 … | +exports.name = 'issues' | |
17 … | + | |
18 … | +exports.manifest = { | |
19 … | + get: 'async', | |
20 … | + createFeedStream: 'source', | |
21 … | + new: 'async', | |
22 … | + edit: 'async', | |
23 … | + close: 'async', | |
24 … | + reopen: 'async' | |
25 … | +} | |
26 … | + | |
27 … | +exports.schemas = issueSchemas | |
28 … | + | |
29 … | +exports.init = function (ssb) { | |
30 … | + | |
31 … | + var ssbGet = asyncMemo(ssb.get) | |
32 … | + | |
33 … | + var getIssue = asyncMemo(function (id, cb) { | |
34 … | + var issue = {} | |
35 … | + var issueMsg | |
36 … | + | |
37 … | + if (id && id.value && id.key) { | |
38 … | + var msg = id | |
39 … | + id = id.key | |
40 … | + gotIssueMsg(null, msg) | |
41 … | + } else { | |
42 … | + ssbGet(id, gotIssueMsg) | |
43 … | + } | |
44 … | + | |
45 … | + function gotIssueMsg(err, msg) { | |
46 … | + if (err) return cb(err) | |
47 … | + issueMsg = msg | |
48 … | + issue.id = msg.key | |
49 … | + issue.author = msg.value.author | |
50 … | + var c = msg.value.content | |
51 … | + issue.project = c.project | |
52 … | + issue.text = c.text | |
53 … | + issue.created_at = issue.updated_at = msg.value.timestamp | |
54 … | + if (c.project) | |
55 … | + ssbGet(c.project, gotProjectMsg) | |
56 … | + else | |
57 … | + getLinks() | |
58 … | + } | |
59 … | + | |
60 … | + function gotProjectMsg(err, msg) { | |
61 … | + if (err) return cb(err) | |
62 … | + issue.projectAuthor = msg.author | |
63 … | + getLinks() | |
64 … | + } | |
65 … | + | |
66 … | + function getLinks() { | |
67 … | + var now = Date.now() | |
68 … | + // compute the result from the past data | |
69 … | + pull( | |
70 … | + ssb.links({dest: id, lt: now, reverse: true}), | |
71 … | + pull.drain(onOldMsg, onOldEnd) | |
72 … | + ) | |
73 … | + // keep the results up-to-date in the future | |
74 … | + pull( | |
75 … | + ssb.links({dest: id, gte: now, values: true, live: true}), | |
76 … | + pull.drain(onNewMsg, onNewEnd) | |
77 … | + ) | |
78 … | + } | |
79 … | + | |
80 … | + function onOldMsg(msg) { | |
81 … | + if (!isUpdateValid(issue, msg)) | |
82 … | + return | |
83 … | + var c = msg.value.content | |
84 … | + if (c.open != null && issue.open == null) | |
85 … | + issue.open = c.open | |
86 … | + if (c.title != null && issue.title == null) | |
87 … | + issue.title = c.title | |
88 … | + if (msg.value.timestamp > issue.updated_at) | |
89 … | + issue.updated_at = msg.value.timestamp | |
90 … | + checkReady() | |
91 … | + } | |
92 … | + | |
93 … | + function onNewMsg(msg) { | |
94 … | + if (!isUpdateValid(issue, msg)) | |
95 … | + return | |
96 … | + var c = msg.value.content | |
97 … | + if (c.open != null) | |
98 … | + issue.open = c.open | |
99 … | + if (c.title != null) | |
100 … | + issue.title = c.title | |
101 … | + if (msg.value.timestamp > issue.updated_at) | |
102 … | + issue.updated_at = msg.value.timestamp | |
103 … | + } | |
104 … | + | |
105 … | + function checkReady() { | |
106 … | + // call back once all the issue properties are set | |
107 … | + if (issue.open != null && issue.title != null) { | |
108 … | + var _cb = cb | |
109 … | + delete cb | |
110 … | + _cb(null, issue) | |
111 … | + } | |
112 … | + } | |
113 … | + | |
114 … | + function onOldEnd(err) { | |
115 … | + if (err) { | |
116 … | + if (cb) cb(err) | |
117 … | + else console.error(err) | |
118 … | + return | |
119 … | + } | |
120 … | + // process the root message last | |
121 … | + onOldMsg(issueMsg) | |
122 … | + // if callback hasn't been called yet, the issue is missing a field | |
123 … | + if (cb) { | |
124 … | + if (issue.open == null) | |
125 … | + issue.open = true | |
126 … | + if (issue.title == null) | |
127 … | + issue.title = issue.id | |
128 … | + checkReady() | |
129 … | + } | |
130 … | + } | |
131 … | + | |
132 … | + function onNewEnd(err) { | |
133 … | + if (err) { | |
134 … | + if (cb) cb(err) | |
135 … | + else console.error(err) | |
136 … | + } | |
137 … | + } | |
138 … | + }) | |
139 … | + | |
140 … | + function createFeedStream(opts) { | |
141 … | + opts.type = 'issue' | |
142 … | + delete opts.limit | |
143 … | + return pull( | |
144 … | + // TODO: use links2 for this | |
145 … | + ssb.messagesByType(opts), | |
146 … | + pull.filter(function (msg) { | |
147 … | + return (!opts.project || opts.project == msg.value.content.project) | |
148 … | + && (!opts.author || opts.author == msg.value.author) | |
149 … | + }), | |
150 … | + paramap(getIssue, 8) | |
151 … | + ) | |
152 … | + } | |
153 … | + | |
154 … | + function editIssue(id, opts, cb) { | |
155 … | + ssb.publish(issueSchemas.edit(id, opts), cb) | |
156 … | + } | |
157 … | + | |
158 … | + function closeIssue(id, cb) { | |
159 … | + ssb.publish(issueSchemas.close(id), cb) | |
160 … | + } | |
161 … | + | |
162 … | + function reopenIssue(id, cb) { | |
163 … | + ssb.publish(issueSchemas.reopen(id), cb) | |
164 … | + } | |
165 … | + | |
166 … | + function newIssue(opts, cb) { | |
167 … | + var msg = issueSchemas.new(opts.project, opts.title, opts.text) | |
168 … | + ssb.publish(msg, function (err, msg) { | |
169 … | + if (err) return cb(err) | |
170 … | + getIssue(msg, cb) | |
171 … | + }) | |
172 … | + } | |
173 … | + | |
174 … | + return { | |
175 … | + get: getIssue, | |
176 … | + createFeedStream: createFeedStream, | |
177 … | + new: newIssue, | |
178 … | + edit: editIssue, | |
179 … | + close: closeIssue, | |
180 … | + reopen: reopenIssue | |
181 … | + } | |
182 … | +} |
lib/schemas.js | ||
---|---|---|
@@ -1,0 +1,43 @@ | ||
1 … | +var ssbRef = require('ssb-ref') | |
2 … | +var mlib = require('ssb-msgs') | |
3 … | + | |
4 … | +exports.new = function (project, title, text) { | |
5 … | + if (!ssbRef.isLink(project)) | |
6 … | + throw new Error('invalid project id') | |
7 … | + var msg = { type: 'issue', project: project } | |
8 … | + if (title) { | |
9 … | + if (typeof title === 'string') | |
10 … | + msg.title = title | |
11 … | + else | |
12 … | + throw new Error('invalid issue title') | |
13 … | + } | |
14 … | + if (text) { | |
15 … | + if (typeof text === 'string') | |
16 … | + msg.text = text | |
17 … | + else | |
18 … | + throw new Error('invalid issue text') | |
19 … | + } | |
20 … | + return msg | |
21 … | +} | |
22 … | + | |
23 … | +exports.edit = function (id, opts) { | |
24 … | + if (!ssbRef.isMsg(id)) | |
25 … | + throw new Error('invalid issue id') | |
26 … | + var msg = { | |
27 … | + type: 'issue-edit', | |
28 … | + issue: id | |
29 … | + } | |
30 … | + if (opts.open != null) | |
31 … | + msg.open = opts.open | |
32 … | + if (opts.title != null) | |
33 … | + msg.title = opts.title | |
34 … | + return msg | |
35 … | +} | |
36 … | + | |
37 … | +exports.close = function (id) { | |
38 … | + return exports.edit(id, {open: false}) | |
39 … | +} | |
40 … | + | |
41 … | +exports.reopen = function (id) { | |
42 … | + return exports.edit(id, {open: true}) | |
43 … | +} |
package.json | ||
---|---|---|
@@ -1,0 +1,27 @@ | ||
1 … | +{ | |
2 … | + "name": "ssb-issues", | |
3 … | + "version": "0.0.0", | |
4 … | + "description": "Issue tracking built on secure-scuttlebutt", | |
5 … | + "main": "index.js", | |
6 … | + "author": "Charles Lehner (http://celehner.com/)", | |
7 … | + "license": "Fair", | |
8 … | + "homepage": "http://git-ssb.celehner.com/", | |
9 … | + "scripts": { | |
10 … | + "test": "node test" | |
11 … | + }, | |
12 … | + "dependencies": { | |
13 … | + "asyncmemo": "^0.1.0", | |
14 … | + "pull-cat": "^1.1.8", | |
15 … | + "pull-paramap": "^1.1.2", | |
16 … | + "pull-stream": "^3.2.0", | |
17 … | + "ssb-client": "^3.0.1", | |
18 … | + "ssb-links": "^1.0.1", | |
19 … | + "ssb-msgs": "^5.2.0", | |
20 … | + "ssb-ref": "^2.3.0" | |
21 … | + }, | |
22 … | + "devDependencies": { | |
23 … | + "scuttlebot": "^7.6.6", | |
24 … | + "ssb-keys": "^5.0.0", | |
25 … | + "tape": "^4.5.1" | |
26 … | + } | |
27 … | +} |
test.js | ||
---|---|---|
@@ -1,0 +1,99 @@ | ||
1 … | +var test = require('tape') | |
2 … | +var Issues = require('.') | |
3 … | +var ssbKeys = require('ssb-keys') | |
4 … | +var pull = require('pull-stream') | |
5 … | + | |
6 … | +function awaitMsg(sbot, msg, cb) { | |
7 … | + sbot.createUserStream({ | |
8 … | + id: msg.value.author, | |
9 … | + gte: msg.value.sequence, | |
10 … | + live: true, | |
11 … | + limit: 1 | |
12 … | + })(null, cb) | |
13 … | +} | |
14 … | + | |
15 … | +var createSbot = require('scuttlebot') | |
16 … | + .use(require('scuttlebot/plugins/master')) | |
17 … | + .use(require('scuttlebot/plugins/blobs')) | |
18 … | + | |
19 … | +var sbot = createSbot({ | |
20 … | + temp: 'test-ssb-issues', timeout: 200, | |
21 … | + allowPrivate: true, | |
22 … | + keys: ssbKeys.generate() | |
23 … | +}) | |
24 … | + | |
25 … | +var issues = Issues.init(sbot) | |
26 … | + | |
27 … | +test.onFinish(function () { | |
28 … | + sbot.close(true) | |
29 … | +}) | |
30 … | + | |
31 … | +var projectId | |
32 … | + | |
33 … | +test('create project', function (t) { | |
34 … | + sbot.publish({type: 'foo'}, function (err, msg) { | |
35 … | + t.error(err, 'publish') | |
36 … | + projectId = msg.key | |
37 … | + t.end() | |
38 … | + }) | |
39 … | +}) | |
40 … | + | |
41 … | +var issue1 | |
42 … | + | |
43 … | +test('create an issue', function (t) { | |
44 … | + var title = 'Test Title' | |
45 … | + issues.new({ | |
46 … | + project: projectId, | |
47 … | + title: title, | |
48 … | + text: 'Test Text' | |
49 … | + }, function (err, issue) { | |
50 … | + t.error(err, 'new issue') | |
51 … | + t.ok(issue.id, 'id') | |
52 … | + t.equals(issue.project, projectId, 'project') | |
53 … | + t.equals(issue.author, sbot.id, 'author') | |
54 … | + t.equals(issue.title, title, 'title') | |
55 … | + t.equals(issue.open, true, 'open') | |
56 … | + t.equals(~~(issue.created_at/1e5), ~~(Date.now()/1e5), 'created_at') | |
57 … | + issue1 = issue | |
58 … | + t.end() | |
59 … | + }) | |
60 … | +}) | |
61 … | + | |
62 … | +test('update the issue', function (t) { | |
63 … | + var title = 'New Title' | |
64 … | + issues.edit(issue1.id, {title: title}, function (err, msg) { | |
65 … | + t.error(err, 'edit') | |
66 … | + t.ok(msg, 'msg') | |
67 … | + awaitMsg(sbot, msg, function (err) { | |
68 … | + t.error(err, 'await') | |
69 … | + t.equals(issue1.title, title, 'new title') | |
70 … | + t.notEquals(issue1.updated_at, issue1.created_at, 'updated_at is new') | |
71 … | + t.ok(Date.now() - issue1.updated_at < 60e3, 'updated_at is recent') | |
72 … | + t.end() | |
73 … | + }) | |
74 … | + }) | |
75 … | +}) | |
76 … | + | |
77 … | +test('close the issue', function (t) { | |
78 … | + issues.close(issue1.id, function (err, msg) { | |
79 … | + t.error(err, 'close') | |
80 … | + t.ok(msg, 'msg') | |
81 … | + awaitMsg(sbot, msg, function (err) { | |
82 … | + t.error(err, 'await') | |
83 … | + t.equals(issue1.open, false, 'closed') | |
84 … | + t.end() | |
85 … | + }) | |
86 … | + }) | |
87 … | +}) | |
88 … | + | |
89 … | +test('reopen the issue', function (t) { | |
90 … | + issues.reopen(issue1.id, function (err, msg) { | |
91 … | + t.error(err, 'reopen') | |
92 … | + t.ok(msg, 'msg') | |
93 … | + awaitMsg(sbot, msg, function (err) { | |
94 … | + t.error(err, 'await') | |
95 … | + t.equals(issue1.open, true, 'open') | |
96 … | + t.end() | |
97 … | + }) | |
98 … | + }) | |
99 … | +}) |
Built with git-ssb-web