A delivery I estimated at three weeks landed closer to seven. The code the ticket actually described was finished in the first week, more or less on time. The other four weeks went somewhere, and for a while the only account I had of them was a feeling.
Before I wrote software for a living I signed off on steel structures: cement plants, one racetrack. Sites run late too, sometimes badly. What a site has that my repository did not is the monthly measurement: a document listing what went up, what did not go up and why, signed by both sides long before the schedule blows. The delay gets assembled in pieces. In my project the delay showed up finished, in one meeting, at the end.
A 2012 question on Software Engineering Stack Exchange asking why IT cannot deliver large projects quickly like other industries has 123,583 views, a score of 543 and 31 answers. Most of them talk about estimation and essential complexity. I wanted a number instead of an opinion, so I went looking for the four missing weeks in data I already had: the issue tracker.
First attempt: a label nobody applied
At kickoff we agreed on a label called scope-change. Anything requested after the scope was agreed would carry it. Simple, free, and it failed.
gh issue list --milestone "Release 2" --state all --limit 300 \
--json number,title,createdAt,closedAt,labels > issues.json
jq '[.[] | select(any(.labels[]; .name == "scope-change"))] | length' issues.json
2
Two. Both created by me, both on the same afternoon, both in the first month. A label works only when a human remembers to apply it at the exact moment he is under pressure to keep the work moving. Nobody labels a request that arrives as "quick thing, five minutes".
Second attempt, also bad: I tried churn per week as a proxy, lines added and removed from git log --numstat. The chart was flat and useless. Rework and new scope look identical in a diff, which is the whole reason the status meeting stays green.
What worked: creation date against the freeze date
The one field nobody has to maintain is createdAt. The tracker writes it whether anyone cares or not. So the question became mechanical: how much of what we shipped in this milestone did not exist when we agreed on the date?
FREEZE=2026-03-09T00:00:00Z
# everything in the milestone
jq 'length' issues.json
# everything born after we agreed on the date
jq --arg freeze "$FREEZE" \
'[.[] | select(.createdAt > $freeze)] | length' issues.json
118
41
Around 35% of the milestone was written after the estimate that the milestone was judged by. Broken down by month, the shape is worse than the total:
jq -r --arg freeze "$FREEZE" '
[.[] | select(.createdAt > $freeze)]
| group_by(.createdAt[0:7])
| map({month: .[0].createdAt[0:7], added: length})
| .[] | "\(.month) \(.added)"
' issues.json
2026-03 6
2026-04 11
2026-05 17
2026-06 7
May was the month everyone in the room described as "the team is struggling with the last stretch". It was the month the scope grew the most. Nobody lied. The growth simply had no line anywhere, so the only visible variable was the date.
The second number is the one that hurts
Work arriving late is normal. On a site the owner changes his mind constantly: he moves the warehouse layout after the foundation is poured, raises the clear height, adds a crane bay. There is a path for it, and it is tedious on purpose, because price and date move together with the request.
So I asked the tracker the second question: of those 41, how many ever got a revised date written down anywhere?
jq -r --arg freeze "$FREEZE" \
'.[] | select(.createdAt > $freeze) | .number' issues.json |
while read -r n; do
hits=$(gh issue view "$n" --json body,comments \
--jq '[.body] + [.comments[].body] | join("\n")' |
grep -Eic 'estimate|revised date|new deadline|moves the date')
[ "$hits" -gt 0 ] && echo "$n"
done | wc -l
3
Three out of 41. Thirty eight work items entered the milestone through a conversation, and the schedule they landed on was still the one negotiated before they existed. That is the difference between a site that runs 20% long and a project that reads as a failure. One measured the drift along the way, the other showed up finished at the end.
Turning the measurement into a CI check
The fix I kept is boring and it lives in the repo, because process that lives in someone's memory is the same label that got applied twice. The workflow does not block the merge. It writes the fact on the PR, at the moment the work is being requested, while the decision is still cheap.
name: scope-gate
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
flag-late-scope:
runs-on: ubuntu-latest
steps:
- name: Compare issue creation with the freeze date
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FREEZE: "2026-03-09T00:00:00Z"
PR: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
issue=$(gh pr view "$PR" --repo "$REPO" \
--json closingIssuesReferences \
--jq '.closingIssuesReferences[0].number // empty')
if [ -z "$issue" ]; then echo "no linked issue"; exit 0; fi
created=$(gh issue view "$issue" --repo "$REPO" \
--json createdAt --jq .createdAt)
labels=$(gh issue view "$issue" --repo "$REPO" \
--json labels --jq '[.labels[].name] | join(",")')
if [[ "$created" > "$FREEZE" && "$labels" != *"scope-change"* ]]; then
gh pr comment "$PR" --repo "$REPO" --body \
"Issue #$issue was created after the scope freeze and carries no scope-change label. Add the label with an effort range and a revised date, or move it out of this milestone."
fi
The label came back to life once a robot asked for it instead of a person. Over the next two milestones the count went from 2 to 29, and the useful part was never the label itself: it was that the date discussion happened in the week the request arrived, not in the week of the deadline.
Where this falls apart
If your team opens issues after the code is written, createdAt measures nothing and you will get a clean report on a project that drifted anyway. Same for repos where one issue means "epic" and the next one means "typo", since counting items assumes items are roughly comparable in size, and mine were not. I looked at the 41 by hand to be sure the big ones were spread out, which is not a method, just a sanity check.
I am also not convinced this pays for itself on a new product still hunting for its first customers. There the scope is supposed to move every week, and a bot commenting on every PR is noise with a YAML file attached.
The part I do trust is the count. Take the last milestone that blew its date, run those two jq lines, and compare the number of items born after the estimate with the number that ever got a new date written down. If the second number is zero, the delay was never a surprise.
How do you keep this visible in your repo? I am curious about people using milestones with explicit budget, or a bot that recomputes a forecast whenever an issue joins the milestone, because comparing dates in bash is the crudest version of this I could build.
Originally published on the Revin blog: https://revin.com.br/en/blog/why-it-cannot-deliver-like-construction
Top comments (0)