Jira already knows what you worked on: rebuilding worklogs from issue history
本文为原文前 6,000 字符的节选翻译,完整内容请查看原文。
Jira already knows what you worked on: rebuilding worklogs from issue history
在 Jira 上记录工时一直是我拖延的事情。月底到了,我坐在那儿试图回忆两周前做了什么,一个个打开工单,猜测花费的时间。后来我注意到,Jira 其实已经掌握了大部分答案。每当我将工单移至“进行中”(In Progress),或者有人将其指派给我时,Jira 都会在工单的变更日志中记录下来。于是,我写了一个小的 Chrome 扩展程序,读取这些历史记录并将其转换为工时草稿。我检查、修正错误,然后提交。这篇文章就是关于它是如何工作的,因为其中一些部分比我预期的更有趣。
The idea
如果一个工单被指派给我,并且从 10:00 到 15:00 处于“工作”状态,那么我当时很可能就在处理它。这只是一个猜测,而不是精确追踪的时间。没有任何东西在监视我的屏幕或计算按键次数,因此也不会自动发布任何内容。所有内容首先都会作为草稿进入日历。你可以选择哪些状态算作“工作”,通常是“进行中”和“审核中”之类的状态。
Step 1: what did the ticket look like at any moment?
变更日志(/rest/api/3/issue/{id}/changelog)只存储变更内容:字段、原值、新值和时间戳。它不会告诉你你所关心的范围开始时的状态。诀窍是从你已知的当前状态开始,向后遍历历史记录,用“原值”撤销每一次变更。这样你就知道了最开始的状态。之后,你向前遍历并记录所有满足以下两个条件的区间:工单归我所有,且处于工作状态。
const state = { status: issue.fields.status.id, assignee: issue.fields.assignee?.accountId, };
// undo every change to get the state before the first one
for (const e of [...events].reverse()) state[e.field] = e.from;
let cursor = start;
for (const e of events) {
const active = state.assignee === me && workingStatuses.includes(state.status);
if (active && e.at > cursor) intervals.push([cursor, e.at]);
cursor = e.at;
state[e.field] = e.to;
}
实际版本还会将所有内容裁剪到日期范围内,但这只是核心逻辑。每个工单最终都会得到一个 [开始, 结束] 时间戳列表。
Step 2: turning intervals into hours
原始区间本身毫无用处。一个在周末保持“进行中”的工单会被计算为 48 小时。因此,每一天都会根据设置的工作窗口进行裁剪:默认是 9:00 到 13:00 以及 14:00 到 18:00,不含周末,且今天的时间截止到“现在”。然后是令人头疼的情况:同时有两三个工单处于进行中。我在任何工单开始或结束的每一个点切分这一天,并将每个切片平均分配给当时处于活跃状态的工单。
for (const [start, end] of windows) {
const points = [...new Set([start, end, ...boundariesInside(start, end)])].sort((a, b) => a - b);
for (let i = 1; i < points.length; i++) {
const [a, b] = [points[i - 1], points[i]];
const active = sources.filter((s) => s.intervals.some(([x, y]) => x <= a && y >= b));
for (const s of active) totals[s.issue.id] += (b - a) / 1000 / active.length;
}
}
所以,如果工单 A 整个上午都处于活跃状态,而 B 在 11:00 加入,那么 A 获得 9 点到 11 点的全部时间,加上 11 点到 13 点的一半时间。这很简单,在实践中也足够准确。当天已经在 Jira 中记录的时间会先被减去,且每天的总工时上限为 8 小时(可配置)。
Step 3: rounding without breaking the total
没人想要一份 2 小时 37 分 12 秒的工时记录,所以草稿会四舍五入到 30 分钟。但单独对每个条目进行四舍五入会破坏当天的总计。三个各 2 小时 40 分的工单,向上取整后会变成 9 小时而不是 8 小时。所以我先对当天的总数进行四舍五入。每个条目取其向下取整的值,剩余的 30 分钟块分配给余数最大的条目。在上面的例子中,结果是 3 小时、2 小时 30 分和 2 小时 30 分,正好 8 小时。这是最大余数法,一些国家也用它来分配议会席位。虽然是小事,但我很喜欢。
Step 4: no API tokens
大多数 Jira 工具需要 API 令牌,或者需要管理员批准的 OAuth 应用。我两者都不想要。当你连接时,扩展程序会请求访问你的 *.atlassian.net 站点的权限,然后直接使用你在浏览器中已有的会话调用 REST API:
const response = await fetch(`${origin}${path}`, {
method,
credentials: 'include',
redirect: 'error',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
我后来增加了一个保护措施:在任何写入操作之前,它会调用 /rest/api/3/myself 并比较账户 ID。如果你在另一个标签页切换了 Jira 账户,发布操作会停止,而不是以别人的身份记录工时。
Step 5: never post the same worklog twice
这是我花时间最多的部分。你按下提交,POST 请求发出,但在响应返回前连接断开了。Jira 到底有没有创建工时记录?重试可能会导致重复,不重试可能会丢失。两件事解决了这个问题:草稿在请求发送前被保存为本地存储中的“正在发布”状态;工时记录本身携带一个带有草稿 ID 的实体属性。Jira 允许你在同一个 POST 请求中附加它。
await request(c, `/rest/api/3/issue/${d.issueId}/worklog?adjustEstimate=leave`, 'POST', {
...worklogBody(d),
properties: [{ key: 'coma-log.draft', value: { id: d.id } }],
});
在下一次同步时,任何卡在“正在发布”的草稿都会变为未知。对于具有相同工单、日期和时长的工时记录,扩展程序会读取该属性。如果 ID 匹配,草稿会被标记为已发布。如果没有匹配项,草稿将保持锁定状态,你需要自己在 Jira 中检查。它绝不会盲目重试。
Where it doesn’t work
如果你不通过状态流转来处理工单,那就没有什么可重建的。Jira 历史记录是主要输入。日历会议和 GitHub 审核可以添加,但它们是可选的。这只是一个估算。处于“进行中”三天并不意味着工作了三天,这就是为什么所有内容在提交前都保持为草稿的原因。仅限 Jira Cloud,仅限 Chrome,一次只能处理一个账户。有些公司会阻止第三方扩展程序,那种情况下就没办法了。